From 1bb26758eda30d14d0429b4ef28aed5494d95307 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:18:18 +0000 Subject: [PATCH 01/29] feat(sdk): add keeper conditional-source seam and CoW run loop (#239) * feat(sdk): add the keeper conditional-source seam, retry ledger, and run Introduce the world-neutral half of the poll loop in nexum_sdk::keeper: a ConditionalSource trait (one watch in, one outcome out, generic over the host, no venue-transport pre-abstraction), a Tick carrying the dispatch instant, and the RetryAction (try-next-block / backoff / drop) whose effects the Retrier runs through the gate and watch-set stores. RetryAction moves out of shepherd-sdk; the CoW crate re-exports it so no module rewires. shepherd-sdk keeps classify_api_error as the CoW errorType table but returns the keeper action, and encodes two fixes in the one classification path: classify_poll_error maps an unrecognised revert selector with a structured payload to DontTryAgain instead of re-polling every block forever, and DuplicatedOrder (both spellings) classifies as already-submitted - the run records the submitted: receipt and keeps the watch rather than dropping every future tranche. classify_submit_error widens the table to the whole CowApiError surface and gives Backoff its producer: a rate-limit fault with server guidance gates the watch on the epoch clock. cow::run composes the loop: watch-set scan -> gate check -> source poll -> PollOutcome dispatch, driving submission through the existing CowApiHost seam with the journal as the idempotency guard and the retry ledger as the failure dispatch. Acceptance tests run against the composed shepherd-sdk-test MockHost as integration tests; no module is rewired yet. * docs(sdk): disambiguate the run intra-doc link * fix(cow): build the classify_poll_error test RpcError data as Bytes RpcError.data is now Bytes; the test helper takes raw Vec and wraps it (Vec -> Bytes is O(1)). * fix(cow): keep the sweep alive when a post-submit journal write faults journal.record after a successful submit_order was propagating a store fault out of submit_ready, aborting the whole watch sweep with the receipt unwritten - the next tick then re-polled and re-posted the same order. Log the failure and carry on instead; the already-submitted arm keeps the re-post idempotent. Both post-submit writes are covered: the Ok arm and the already-submitted (DuplicatedOrder) arm. --- Cargo.lock | 2 + crates/nexum-sdk/src/keeper.rs | 95 +++++ crates/nexum-sdk/src/lib.rs | 6 +- crates/nexum-sdk/tests/keeper.rs | 138 ++++++- crates/shepherd-sdk/Cargo.toml | 6 + crates/shepherd-sdk/src/cow/composable.rs | 94 ++++- crates/shepherd-sdk/src/cow/error.rs | 155 +++++--- crates/shepherd-sdk/src/cow/mod.rs | 23 +- crates/shepherd-sdk/src/cow/order.rs | 51 ++- crates/shepherd-sdk/src/cow/run.rs | 198 ++++++++++ crates/shepherd-sdk/src/lib.rs | 7 +- crates/shepherd-sdk/tests/run.rs | 422 ++++++++++++++++++++++ 12 files changed, 1139 insertions(+), 58 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/run.rs create mode 100644 crates/shepherd-sdk/tests/run.rs diff --git a/Cargo.lock b/Cargo.lock index db6e15e7..fa9ae472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5102,6 +5102,8 @@ dependencies = [ "cowprotocol", "nexum-sdk", "proptest", + "serde_json", + "shepherd-sdk-test", "strum", "thiserror 2.0.18", ] diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 06d1cd9e..88edc6de 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -13,6 +13,14 @@ //! - [`Journal`] - the receipt-keyed idempotency journal of //! `submitted:` / `observed:` presence markers. //! +//! Two pieces drive the stores from the poll loop: +//! +//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! one outcome out, at a given [`Tick`]. Implementations own the +//! transport and the outcome shape. +//! - [`Retrier`] - runs a [`RetryAction`]'s effect through the +//! stores after a failed 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 @@ -69,6 +77,7 @@ //! ``` use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; use crate::host::{Fault, LocalStoreHost}; @@ -292,3 +301,89 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { .is_some()) } } + +/// One poll dispatch's world view: chain, block height, and the block +/// clock in Unix seconds. Gate checks and backoff arithmetic read the +/// same instant a source is polled at, so a watch can never gate +/// itself against a clock it was not judged by. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Tick { + /// Chain the dispatch targets. + pub chain_id: u64, + /// Block height at the tick. + pub block: u64, + /// Block timestamp, Unix seconds. + pub epoch_s: u64, +} + +/// A source of conditional commitments: poll one watch, produce one +/// outcome. Generic over the host so implementations stay mock- +/// testable; deliberately no venue-transport abstraction - the source +/// owns its own wire (an `eth_call`, an HTTP probe, a stub). +/// +/// A transient failure should surface as a retry-flavoured outcome, +/// not tear down the caller's sweep: `poll` is infallible by contract. +pub trait ConditionalSource { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value (the encoded commitment parameters), passed + /// verbatim so the source owns the decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; +} + +/// What the retry ledger should do to a watch after a failed +/// run attempt. +/// +/// `IntoStaticStr` exposes each variant as a snake_case `&'static +/// str` for log and metric labels. `#[non_exhaustive]` so the +/// contract can grow a variant; downstream dispatch should treat an +/// unknown variant as "leave the watch in place" (the conservative +/// choice). +#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum RetryAction { + /// Leave the watch untouched; the next tick re-attempts. + TryNextBlock, + /// Gate the watch until `now + seconds` on the epoch clock. + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// 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. +pub struct Retrier<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Retrier<'h, H> { + /// Ledger view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Apply `action` to the watch, with `now_epoch_s` as the backoff + /// origin. + pub fn apply( + &self, + watch: WatchRef<'_>, + action: RetryAction, + now_epoch_s: u64, + ) -> Result<(), Fault> { + match action { + RetryAction::TryNextBlock => Ok(()), + RetryAction::Backoff { seconds } => { + Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + } + RetryAction::Drop => WatchSet::new(self.host).remove(watch), + } + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index ac6531fe..b11cd21e 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -31,7 +31,8 @@ //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]). +//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! //! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader @@ -81,6 +82,9 @@ //! [`WatchSet`]: keeper::WatchSet //! [`Gates`]: keeper::Gates //! [`Journal`]: keeper::Journal +//! [`ConditionalSource`]: keeper::ConditionalSource +//! [`Retrier`]: keeper::Retrier +//! [`RetryAction`]: keeper::RetryAction //! [`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/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 5ac54c87..ff8f9746 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -7,7 +7,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, WATCH_PREFIX, WatchRef, WatchSet, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, + Tick, WATCH_PREFIX, WatchRef, WatchSet, }; use shepherd_sdk_test::MockHost; @@ -351,3 +352,138 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(snapshot.contains_key("submitted:0xuid")); assert!(!snapshot.contains_key("observed:0xuid")); } + +// ---- retry ledger ---- + +fn seeded_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +#[test] +fn ledger_try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let before = host.store.snapshot(); + + Retrier::new(&host) + .apply( + WatchRef::parse(&key).unwrap(), + RetryAction::TryNextBlock, + 1_000, + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); +} + +#[test] +fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .unwrap(); + + let gates = Gates::new(&host); + assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_030_u64.to_le_bytes().to_vec(), + ); + assert!( + host.store.snapshot().contains_key(&key), + "backoff must keep the watch", + ); +} + +#[test] +fn ledger_backoff_saturates_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &u64::MAX.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 500).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Drop, 1_000) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn retry_action_labels_are_stable_snake_case() { + let cases: [(RetryAction, &str); 3] = [ + (RetryAction::TryNextBlock, "try_next_block"), + (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::Drop, "drop"), + ]; + for (action, label) in cases { + assert_eq!(<&'static str>::from(action), label); + } +} + +// ---- conditional source ---- + +/// A source is generic over the host and owns its outcome shape; the +/// keeper passes the stored params verbatim and the tick it judged +/// the gates by. +#[test] +fn conditional_source_sees_params_and_tick_verbatim() { + struct EchoSource; + impl ConditionalSource for EchoSource { + type Outcome = (usize, u64, u64, u64, String); + fn poll( + &self, + _host: &H, + watch: WatchRef<'_>, + params: &[u8], + tick: &Tick, + ) -> Self::Outcome { + ( + params.len(), + tick.chain_id, + tick.block, + tick.epoch_s, + watch.key(), + ) + } + } + + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tick = Tick { + chain_id: 1, + block: 42, + epoch_s: 1_700_000_000, + }; + + let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); + assert_eq!(len, b"params".len()); + assert_eq!(chain_id, 1); + assert_eq!(block, 42); + assert_eq!(epoch_s, 1_700_000_000); + assert_eq!(echoed, key); +} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index fe8e1af3..717d5c5d 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -16,8 +16,14 @@ 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 [dev-dependencies] proptest.workspace = true +# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, +# and the 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/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 212e1240..47aa22ee 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -12,6 +12,7 @@ use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolError, sol}; use cowprotocol::GPv2OrderData; +use nexum_sdk::host::ChainError; sol! { /// Five custom errors `IConditionalOrder.verify` reverts with. @@ -75,9 +76,9 @@ pub enum PollOutcome { /// /// Returns `None` when the selector is not one of the five /// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. Callers should treat that as `TryNextBlock` (the -/// safe default) so a transient RPC blip does not drop a still-valid -/// watch. +/// require-revert. [`classify_poll_error`] is the lifecycle policy on +/// top: it treats any such foreign selector as a permanent +/// contract-level rejection. #[must_use] pub fn decode_revert(data: &[u8]) -> Option { if data.len() < 4 { @@ -105,6 +106,29 @@ pub fn decode_revert(data: &[u8]) -> Option { } } +/// Classify a failed poll `eth_call` into a [`PollOutcome`] - the one +/// policy for what a poll failure means to the watch lifecycle. +/// +/// A revert payload big enough to carry a selector that +/// [`decode_revert`] does not recognise maps to `DontTryAgain`: it is +/// a contract-level rejection outside the `IConditionalOrder` +/// vocabulary (a handler-specific error, typically permanent), and +/// retrying it on every block loops forever. Only payload-free +/// failures - transport faults and reverts whose `data` is absent or +/// shorter than a selector - stay `TryNextBlock`. +#[must_use] +pub fn classify_poll_error(err: &ChainError) -> PollOutcome { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + decode_revert(data).unwrap_or(PollOutcome::DontTryAgain) + } + _ => PollOutcome::TryNextBlock, + }, + ChainError::Fault(_) => PollOutcome::TryNextBlock, + } +} + fn u256_to_u64_saturating(v: U256) -> u64 { u64::try_from(v).unwrap_or(u64::MAX) } @@ -187,4 +211,68 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } + + // ---- classify_poll_error ---- + + use nexum_sdk::host::{Fault, RpcError}; + + fn rpc(data: Option>) -> ChainError { + ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: data.map(Into::into), + }) + } + + #[test] + fn classify_dispatches_a_recognised_selector() { + let revert = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(777_u64), + reason: "wait".to_string(), + } + .abi_encode(); + assert!(matches!( + classify_poll_error(&rpc(Some(revert))), + PollOutcome::TryOnBlock(777) + )); + } + + /// A handler-specific selector outside the `IConditionalOrder` + /// vocabulary is a permanent contract-level rejection: it must + /// drop, not re-poll every block forever. + #[test] + fn classify_unrecognised_selector_drops() { + let mut data = vec![0x7a, 0x93, 0x32, 0x34]; + data.extend_from_slice(&[0u8; 32]); + assert!(matches!( + classify_poll_error(&rpc(Some(data))), + PollOutcome::DontTryAgain + )); + // A bare 4-byte selector with no body classifies the same way. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + PollOutcome::DontTryAgain + )); + } + + #[test] + fn classify_payload_free_failures_stay_try_next_block() { + assert!(matches!( + classify_poll_error(&rpc(None)), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&rpc(Some(Vec::new()))), + PollOutcome::TryNextBlock + )); + // Sub-selector payloads cannot name a contract error. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x01, 0x02]))), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&ChainError::Fault(Fault::Timeout)), + PollOutcome::TryNextBlock + )); + } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 23360273..4129cd71 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -7,12 +7,16 @@ //! 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 a -//! [`RetryAction`] the lifecycle layer dispatches on. +//! [`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. @@ -79,49 +83,19 @@ impl HostFault for CowApiError { } } -/// What the lifecycle layer should do after a failed submission. -/// -/// Mirrors the retry contract: `TryNextBlock` / -/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer -/// today because the retry classifier is bool-only; the -/// variant is kept so dispatch can grow into it once a server -/// `Retry-After` hint shows up. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the dispatch layer can record -/// `shepherd_cow_api_retry_total{action=...}` and surface the action -/// in `tracing::info!(retry_action = ...)` without an ad-hoc match -/// ladder. -#[derive(Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch / placement in place; the next event will - /// re-attempt. - TryNextBlock, - /// Persist `next_attempt = now + seconds`. Reserved - no producer - /// today (kept so the dispatch contract is stable). - #[allow(dead_code)] - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Remove the watch / mark as terminally rejected. The orderbook - /// will not accept this body on a retry. - Drop, -} - -/// Classify a decoded orderbook [`OrderRejection`] into a -/// [`RetryAction`]. +/// Classify a decoded orderbook [`OrderRejection`] into the keeper +/// [`RetryAction`] - the CoW `errorType` classification table. /// /// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, /// `PriceExceedsMarketPrice`) -> `TryNextBlock`. +/// - Already-submitted rejections ([`is_already_submitted`]) -> +/// `TryNextBlock`: the order is live, so the watch must survive; the +/// run also records the `submitted:` receipt so the next +/// tick short-circuits instead of re-posting. /// - Every other (including unrecognised) kind -> `Drop`. /// -/// Non-`Rejected` failures (transport faults, raw HTTP errors) carry -/// no `error_type` and are not classified here; the caller treats them -/// as transient (leave the watch in place) so a flaky orderbook does -/// not poison a still-valid order. +/// Non-`Rejected` failures carry no `error_type`; classify those with +/// [`classify_submit_error`]. /// /// # Example /// @@ -147,13 +121,48 @@ pub enum RetryAction { /// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); /// ``` pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_retriable(&rejection.error_type) { + if is_already_submitted(rejection) || is_retriable(&rejection.error_type) { RetryAction::TryNextBlock } else { RetryAction::Drop } } +/// Whether the rejection says the orderbook already holds this exact +/// order: `DuplicatedOrder` (the orderbook's spelling) plus the +/// `DuplicateOrder` variant 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 { + matches!( + rejection.error_type.as_str(), + "DuplicatedOrder" | "DuplicateOrder" + ) +} + +/// 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, + } +} + /// Orderbook `errorType` values the protocol treats as transient: a /// fresh submission on a later block may succeed. Everything else /// (including unrecognised types) is permanent. Mirrors the upstream @@ -199,7 +208,6 @@ mod tests { for kind in [ "InvalidSignature", "WrongOwner", - "DuplicateOrder", "UnsupportedToken", "InvalidAppData", "InvalidErc1271Signature", @@ -220,6 +228,69 @@ mod tests { ); } + /// 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); diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 36cbdcca..893a80e6 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,20 +3,27 @@ //! Type conversions and ABI decoding helpers that translate between //! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, //! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`). +//! `PollOutcome`, `RetryAction`), plus [`run()`] - the +//! poll/submit composition over the keeper stores. //! -//! Each submodule stays 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 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 +//! run is generic over the host traits alone. pub mod composable; pub mod error; pub mod order; +pub mod run; -pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; -pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; -pub use order::gpv2_to_order_data; +pub use composable::{IConditionalOrder, PollOutcome, classify_poll_error, decode_revert}; +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 run::run; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d983d649..5bcef8fb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -7,7 +7,9 @@ //! into Rust enums. [`gpv2_to_order_data`] is the bridge. use alloy_primitives::Address; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource}; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, +}; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the /// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` @@ -71,6 +73,23 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { }) } +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker; both also stop the submit path +/// downstream, so callers fall through and let it surface the +/// diagnostic. +#[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))) +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +156,34 @@ mod tests { g.buyTokenBalance = B256::repeat_byte(0x55); assert!(gpv2_to_order_data(&g).is_none()); } + + // ---- order_uid_hex ---- + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } } diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs new file mode 100644 index 00000000..f2e6bf32 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -0,0 +1,198 @@ +//! Watch run: the poll-loop composition conditional- +//! commitment modules share. +//! +//! [`run`] walks the keeper watch set, polls each gate-ready +//! watch through a [`ConditionalSource`], and runs the +//! [`PollOutcome`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Ready` drives one submission through the +//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` +//! journal as the idempotency guard 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`](super::RetryAction), the ledger applies the +//! effect, and the sweep moves on. + +use alloy_primitives::{Address, Bytes}; +use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; +use nexum_sdk::Level; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Retrier, Tick, WatchRef, WatchSet}; + +use super::{ + CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, + is_already_submitted, order_uid_hex, +}; + +/// Poll every gate-ready watch once at `tick` and run each outcome's +/// effect. One source poll per ready watch; a `Ready` outcome makes at +/// most one `submit_order` call. +pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: CowHost, + S: ConditionalSource, +{ + let watches = WatchSet::new(host); + let gates = Gates::new(host); + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + continue; + } + let Some(params) = watches.get(watch)? else { + continue; + }; + match source.poll(host, watch, ¶ms, tick) { + PollOutcome::Ready { order, signature } => { + submit_ready(host, watch, &order, signature, tick)?; + } + PollOutcome::TryNextBlock => {} + PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, + PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, + PollOutcome::DontTryAgain => watches.remove(watch)?, + } + } + Ok(()) +} + +/// Submit one freshly-polled `Ready` order, guarding on the +/// `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. +fn submit_ready( + host: &H, + watch: WatchRef<'_>, + order: &GPv2OrderData, + signature: Bytes, + tick: &Tick, +) -> Result<(), Fault> { + let Ok(owner) = watch.owner_hex().parse::
() else { + host.log( + Level::WARN, + &format!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), + ), + ); + 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)? + { + host.log( + Level::INFO, + &format!("{uid} already submitted; skipping re-submit"), + ); + return Ok(()); + } + + let creation = match build_order_creation(order, signature, owner) { + Ok(creation) => creation, + Err(message) => { + host.log( + Level::WARN, + &format!("submit skipped for {owner:#x}: {message}"), + ); + return Ok(()); + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(body) => body, + Err(e) => { + host.log( + Level::ERROR, + &format!("OrderCreation JSON encode failed: {e}"), + ); + return Ok(()); + } + }; + + 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()); + // 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) { + host.log( + Level::ERROR, + &format!("submitted {marker} but journal write failed: {fault}"), + ); + } + if let Some(client) = client_uid.as_deref() + && client != server_uid + { + host.log( + Level::WARN, + &format!( + "UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" + ), + ); + } + host.log(Level::INFO, &format!("submitted {marker}")); + } + 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) + { + host.log( + Level::ERROR, + &format!("orderbook already holds {uid} but journal write failed: {fault}"), + ); + } + host.log( + Level::INFO, + &format!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, + ), + ); + } + Err(err) => { + let action = classify_submit_error(&err); + Retrier::new(host).apply(watch, action, tick.epoch_s)?; + let label: &'static str = action.into(); + host.log( + Level::WARN, + &format!("submit failed ({err}); retry action {label}"), + ); + } + } + 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. +fn build_order_creation( + order: &GPv2OrderData, + signature: Bytes, + from: Address, +) -> Result { + let order_data = gpv2_to_order_data(order) + .ok_or_else(|| "GPv2OrderData carried an unknown enum marker".to_string())?; + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(&order_data, signature, from, None) + .map_err(|e| e.to_string()) +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 86e00dea..70dacc1c 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -17,8 +17,10 @@ //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), //! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), and the [`RetryAction`] classifier driving -//! submit-failure dispatch. +//! [`decode_revert`]), the classifiers mapping submit failures into +//! the keeper [`RetryAction`], and [`run`] - the poll -> +//! outcome -> gate/journal/submit composition over the keeper +//! stores. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,6 +52,7 @@ //! [`PollOutcome`]: cow::PollOutcome //! [`decode_revert`]: cow::decode_revert //! [`RetryAction`]: cow::RetryAction +//! [`run`]: cow::run() #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs new file mode 100644 index 00000000..d3190991 --- /dev/null +++ b/crates/shepherd-sdk/tests/run.rs @@ -0,0 +1,422 @@ +//! 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. + +use std::cell::Cell; + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +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 shepherd_sdk::cow::{CowApiError, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk_test::MockHost; + +const SEPOLIA: u64 = 11_155_111; + +/// Closure-backed source so each test scripts its own outcome and +/// observes its own poll calls. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (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(&MockHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + keccak256(b"conditional order params") +} + +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) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +// ---- lifecycle outcomes ---- + +#[test] +fn try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + seed_watch(&host); + let before = host.store.snapshot(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryNextBlock), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); + assert_eq!(host.cow_api.call_count(), 0); +} + +#[test] +fn try_on_block_sets_the_block_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryOnBlock(2_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_block_key()).unwrap(), + &2_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn try_at_epoch_sets_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryAtEpoch(1_800_000_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_800_000_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn dont_try_again_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 1).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::DontTryAgain), + &sample_tick(), + ) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +// ---- gating and skipping ---- + +#[test] +fn gated_watch_is_not_polled() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0, "a gated watch must not reach the source"); +} + +#[test] +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); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0); +} + +// ---- ready -> submission ---- + +#[test] +fn ready_submits_once_and_journals_the_client_uid() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok(client_uid(&order))); + + 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); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "submitted:{{client_uid}} receipt 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() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xfeedface".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!( + !snapshot.contains_key("submitted:0xfeedface"), + "marker must key on the client UID, not the divergent server UID", + ); + assert!(host.logging.contains("UID divergence")); +} + +#[test] +fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + Journal::submitted(&host) + .record(&client_uid(&order)) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "the source is still consulted"); + assert_eq!( + host.cow_api.call_count(), + 0, + "the journal guard must short-circuit before any network work", + ); +} + +#[test] +fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.kind = B256::repeat_byte(0x42); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert!(host.store.snapshot().contains_key(&key)); +} + +// ---- 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() { + 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"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &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!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +#[test] +fn permanent_rejection_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"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert!( + host.store.is_empty(), + "a permanent rejection must drop the watch and its gates", + ); +} + +/// 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. +#[test] +fn duplicated_order_records_the_receipt_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "already-submitted must record the receipt", + ); + + // The next tick must not touch the orderbook again. + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// 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, &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:"))); +} From f37f0d19424f68b46395b870c584130547979e86 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:32:09 +0000 Subject: [PATCH 02/29] refactor(twap-monitor): port poll loop onto keeper (#240) * feat(sdk): log run diagnostics through the tracing facade The run logged through the LoggingHost seam, which the guest tracing capture cannot observe - module tests proving behaviour identity for keeper ports assert on tracing events. Route the submit-path diagnostics through the tracing macros with the wording the legacy twap poll loop established, and give ConditionalSource a defaulted label so shared log lines attribute the strategy that produced them. * refactor(twap-monitor): port the poll loop onto the keeper composition Reduce strategy.rs to decode and evaluate: log decoding persists watches through the keeper watch set, and the getTradeableOrderWithSignature evaluation moves behind ConditionalSource. The hand-rolled gate keys, watch-update lifecycle, submitted: markers, and submit-retry dispatch are deleted in favour of cow::run over the keeper stores and retry ledger, still on the legacy CowApiHost seam. The MockHost dispatch tests are untouched - the behaviour-identity proof for the port. --- Cargo.lock | 4 +- crates/nexum-sdk/src/keeper.rs | 7 + crates/shepherd-sdk/Cargo.toml | 4 + crates/shepherd-sdk/src/cow/run.rs | 86 ++--- crates/shepherd-sdk/tests/run.rs | 6 +- modules/twap-monitor/Cargo.toml | 4 +- modules/twap-monitor/src/strategy.rs | 504 ++++----------------------- 7 files changed, 126 insertions(+), 489 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa9ae472..61076120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5101,11 +5101,13 @@ dependencies = [ "alloy-sol-types", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", "serde_json", "shepherd-sdk-test", "strum", "thiserror 2.0.18", + "tracing", ] [[package]] @@ -5781,8 +5783,6 @@ dependencies = [ "serde_json", "shepherd-sdk", "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", "tracing", "wit-bindgen 0.59.0", ] diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 88edc6de..ff78e082 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -331,6 +331,13 @@ pub trait ConditionalSource { /// watch value (the encoded commitment parameters), passed /// verbatim so the source owns the decode. fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. + fn label(&self) -> &'static str { + "conditional" + } } /// What the retry ledger should do to a watch after a failed diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 717d5c5d..2e4547f2 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -19,8 +19,12 @@ alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true +tracing.workspace = true [dev-dependencies] +# `capture_tracing` observes the run's diagnostics in the +# acceptance tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index f2e6bf32..cda6c6e0 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -11,14 +11,17 @@ //! //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they classify into a -//! [`RetryAction`](super::RetryAction), the ledger applies the -//! effect, and the sweep moves on. +//! [`RetryAction`], 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 cowprotocol::{GPv2OrderData, OrderCreation, Signature}; -use nexum_sdk::Level; use nexum_sdk::host::Fault; -use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Retrier, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; use super::{ CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, @@ -47,7 +50,7 @@ where }; match source.poll(host, watch, ¶ms, tick) { PollOutcome::Ready { order, signature } => { - submit_ready(host, watch, &order, signature, tick)?; + submit_ready(host, watch, &order, signature, tick, source.label())?; } PollOutcome::TryNextBlock => {} PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, @@ -71,14 +74,12 @@ fn submit_ready( order: &GPv2OrderData, signature: Bytes, tick: &Tick, + label: &str, ) -> Result<(), Fault> { let Ok(owner) = watch.owner_hex().parse::
() else { - host.log( - Level::WARN, - &format!( - "watch {} carries an unparseable owner; skipping submit", - watch.key(), - ), + tracing::warn!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), ); return Ok(()); }; @@ -88,30 +89,21 @@ fn submit_ready( if let Some(uid) = client_uid.as_deref() && journal.contains(uid)? { - host.log( - Level::INFO, - &format!("{uid} already submitted; skipping re-submit"), - ); + tracing::info!("{label} {uid} already submitted; skipping re-submit"); return Ok(()); } let creation = match build_order_creation(order, signature, owner) { Ok(creation) => creation, Err(message) => { - host.log( - Level::WARN, - &format!("submit skipped for {owner:#x}: {message}"), - ); + tracing::warn!("{label} submit skipped for {owner:#x}: {message}"); return Ok(()); } }; let body = match serde_json::to_vec(&creation) { Ok(body) => body, Err(e) => { - host.log( - Level::ERROR, - &format!("OrderCreation JSON encode failed: {e}"), - ); + tracing::error!("OrderCreation JSON encode failed: {e}"); return Ok(()); } }; @@ -127,23 +119,17 @@ fn submit_ready( // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. if let Err(fault) = journal.record(marker) { - host.log( - Level::ERROR, - &format!("submitted {marker} but journal write failed: {fault}"), - ); + tracing::error!("submitted {marker} but journal write failed: {fault}"); } if let Some(client) = client_uid.as_deref() && client != server_uid { - host.log( - Level::WARN, - &format!( - "UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ), + tracing::warn!( + "{label} UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" ); } - host.log(Level::INFO, &format!("submitted {marker}")); + tracing::info!("submitted {marker}"); } Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { // Success wearing an error status: the orderbook already @@ -154,27 +140,29 @@ fn submit_ready( if let Some(uid) = client_uid.as_deref() && let Err(fault) = journal.record(uid) { - host.log( - Level::ERROR, - &format!("orderbook already holds {uid} but journal write failed: {fault}"), - ); + tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); } - host.log( - Level::INFO, - &format!( - "orderbook already holds this order ({}); receipt recorded", - rejection.error_type, - ), + tracing::info!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, ); } Err(err) => { let action = classify_submit_error(&err); Retrier::new(host).apply(watch, action, tick.epoch_s)?; - let label: &'static str = action.into(); - host.log( - Level::WARN, - &format!("submit failed ({err}); retry action {label}"), - ); + match action { + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::Backoff { seconds } => { + tracing::warn!("submit backoff {seconds}s: {err}"); + } + RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + // `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}"); + } + } } } Ok(()) diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index d3190991..d5088626 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -10,6 +10,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; 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, PollOutcome, order_uid_hex, run}; use shepherd_sdk_test::MockHost; @@ -253,7 +254,8 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); @@ -261,7 +263,7 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { !snapshot.contains_key("submitted:0xfeedface"), "marker must key on the client UID, not the divergent server UID", ); - assert!(host.logging.contains("UID divergence")); + assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 1910ae93..91a37a90 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -14,12 +14,10 @@ shepherd-sdk = { path = "../../crates/shepherd-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"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -thiserror = "2" tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +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/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a58a58bf..fe0c72c1 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -7,20 +7,23 @@ //! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under //! `#[cfg(test)]` hand the same functions a //! `shepherd_sdk_test::MockHost`. +//! +//! 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`). -use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use cowprotocol::{ - COMPOSABLE_COW, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, + 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 shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowHost, PollOutcome, classify_poll_error, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -66,9 +69,16 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: scan every persisted watch and dispatch ready tranches. +/// Poll entry: run every gate-ready watch through the keeper +/// composition. The block timestamp arrives in milliseconds; the tick +/// carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { - poll_all_watches(host, &block) + let tick = Tick { + chain_id: block.chain_id, + block: block.number, + epoch_s: block.timestamp / 1000, + }; + run(host, &TwapSource, &tick) } // ---- indexing path ---- @@ -78,64 +88,51 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// `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, so re-indexing the same log +/// (re-org replay, overlapping subscription windows) produces no +/// observable side effect. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; + let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; tracing::info!("indexed {key}"); Ok(()) } // ---- poll path ---- -fn poll_all_watches(host: &H, block: &BlockInfo) -> Result<(), Fault> { - let now_epoch_s = block.timestamp / 1000; - let keys = host.list_keys("watch:")?; - for key in keys { - let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { - continue; - }; - if !is_ready(host, owner_hex, hash_hex, block.number, now_epoch_s)? { - continue; - } - let Some(value) = host.get(&key)? else { - continue; - }; - let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; +/// 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. +struct TwapSource; + +impl ConditionalSource for TwapSource { + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + let Ok(params) = ConditionalOrderParams::abi_decode(params) else { + tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); + return PollOutcome::TryNextBlock; }; - let Ok(owner) = owner_hex.parse::
() else { - continue; + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carried an unparseable owner; skipping", + watch.key() + ); + return PollOutcome::TryNextBlock; }; - let outcome = poll_one(host, block.chain_id, &owner, ¶ms); - tracing::info!("poll {key} -> {}", outcome_label(&outcome)); - match outcome { - PollOutcome::Ready { order, signature } => { - submit_ready( - host, - block.chain_id, - owner, - &order, - signature, - &key, - now_epoch_s, - )?; - } - non_ready => { - apply_watch_update(host, outcome_to_update(&non_ready), &key)?; - } - } + let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); + tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); + outcome + } + + fn label(&self) -> &'static str { + "twap" } - Ok(()) } fn poll_one( @@ -159,28 +156,14 @@ fn poll_one( Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) .unwrap_or(PollOutcome::TryNextBlock), - // A structured JSON-RPC error (the normal shape for an - // `eth_call` revert): the chain backend has already hex-decoded - // the `error.data` payload, so `decode_revert` dispatches - // `PollTryAtBlock` / `PollTryAtEpoch` / `OrderNotValid` / - // `PollNever` straight off the bytes. A revert the decoder does - // not recognise falls through to the safe `TryNextBlock`. - Err(ChainError::Rpc(rpc)) => rpc - .data - .as_deref() - .and_then(|bytes| decode_revert(bytes)) - .unwrap_or_else(|| { - tracing::warn!( - "eth_call reverted ({}); defaulting to TryNextBlock", - rpc.message - ); - PollOutcome::TryNextBlock - }), - // A transport-level fault (timeout, RPC down, ...): retry on the - // next block. - Err(ChainError::Fault(fault)) => { - tracing::warn!("eth_call failed ({fault}); defaulting to TryNextBlock"); - PollOutcome::TryNextBlock + // `classify_poll_error` is the one policy for what a failed + // poll call means to the watch lifecycle; only a transport + // fault warrants its own diagnostic here. + Err(err) => { + if let ChainError::Fault(fault) = &err { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + classify_poll_error(&err) } } } @@ -207,312 +190,36 @@ fn outcome_label(o: &PollOutcome) -> &'static str { } } -// ---- key conventions ---- +// ---- test-only seam mirrors ---- +// +// Thin views over the keeper / SDK canon so the dispatch tests can +// seed and inspect the store in the exact shapes production writes. -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") +#[cfg(test)] +fn watch_key(owner: &Address, params_hash: &alloy_primitives::B256) -> String { + WatchSet::::key(owner, params_hash) } +#[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) -} - -fn is_ready( - host: &H, - owner_hex: &str, - hash_hex: &str, - block_number: u64, - epoch_s: u64, -) -> Result { - if let Some(next) = read_u64(host, &format!("next_block:{owner_hex}:{hash_hex}"))? - && block_number < next - { - return Ok(false); - } - if let Some(next) = read_u64(host, &format!("next_epoch:{owner_hex}:{hash_hex}"))? - && epoch_s < next - { - return Ok(false); - } - Ok(true) -} - -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let bytes = host.get(key)?; - Ok(bytes - .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) - .map(u64::from_le_bytes)) -} - -// ---- submission path ---- - -/// `cowprotocol`-side rejection envelope for an `OrderCreation` we -/// failed to assemble. Surfaces in a Warn log; the watch is left in -/// place so the next poll can either re-construct or transition on -/// its own. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the submission warning log can carry `error_kind = -/// unknown_marker` without a match-ladder in the call site. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum BuildError { - /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't - /// know how to map. - #[error("GPv2OrderData carried an unknown enum marker")] - UnknownMarker, - /// `cowprotocol` rejected the body - typically `from == - /// Address::ZERO` or a `validTo` beyond the client-side horizon. - #[error(transparent)] - Cowprotocol(#[from] cowprotocol::Error), -} - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// freshly-polled TWAP tranche. -/// -/// The signed `order.appData` digest is submitted verbatim (the -/// hash-only `OrderCreationAppData::Hash` wire shape) - watch-tower -/// parity. The orderbook joins the document it already has registered -/// for that digest; when it has none, the submit rejects with -/// `INVALID_APP_DATA` and [`classify_api_error`] dispatches the retry. -fn build_order_creation( - order: &GPv2OrderData, - signature: Bytes, - from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; - let signature = Signature::Eip1271(signature.to_vec()); - let creation = OrderCreation::new_app_data_hash_only(&order_data, signature, from, None)?; - Ok(creation) + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) } -fn submit_ready( - host: &H, - chain_id: u64, - owner: Address, - order: &GPv2OrderData, - signature: Bytes, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Short-circuit if the orderbook UID for this exact - // (order, owner, chain) tuple is already in our local-store as - // `submitted:`. The poll-tick can re-fire `Ready` for the same - // TWAP child in successive blocks - `getTradeableOrderWithSignature` - // does not know shepherd already POSTed it - and re-submitting - // wastes a submit_order call and emits a misleading - // `DuplicatedOrder` Warn. The UID computation is deterministic - // from on-chain inputs (and matches what the orderbook derives - // server-side from the signed payload), so we can check before - // doing any network work. We also reuse the computed value below - // as the `submitted:{uid}` marker key, so the read and write - // paths agree. - let client_uid_hex = compute_uid_hex(chain_id, order, owner); - if let Some(uid_hex) = client_uid_hex.as_deref() - && host.get(&format!("submitted:{uid_hex}"))?.is_some() - { - tracing::info!("twap {uid_hex} already submitted; skipping poll re-submit"); - return Ok(()); - } - - // CoW Swap UI (and other clients) sign TWAPs with a non-empty - // `appData` hash that points at a JSON document already registered - // with the orderbook. Submit the signed digest verbatim (hash-only - // shape) and let the orderbook join its own registry - watch-tower - // parity. An unregistered digest rejects as `INVALID_APP_DATA` and - // `classify_api_error` dispatches the backoff. - let creation = match build_order_creation(order, signature, owner) { - Ok(c) => c, - Err(e) => { - tracing::warn!("twap submit skipped for {owner:#x}: {e}"); - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID for the marker key so the - // idempotency check at the top of `submit_ready` reads what - // we wrote. In production the server-returned - // UID is the same value (both sides derive it from the - // signed `OrderData` via the canonical - // `digest || owner || valid_to` layout); a divergence - // would be a protocol-level bug worth surfacing rather - // than silently splitting the keyspace. - let marker_uid = client_uid_hex.as_deref().unwrap_or(server_uid.as_str()); - let key = format!("submitted:{marker_uid}"); - // Empty marker - presence of the key is the receipt. - host.set(&key, b"")?; - if let Some(client_uid) = client_uid_hex.as_deref() - && client_uid != server_uid - { - tracing::warn!( - "twap UID divergence: client={client_uid} server={server_uid} \ - (marker stored under client UID for idempotency consistency)" - ); - } - tracing::info!("submitted {key}"); - } - Err(err) => { - apply_submit_retry(host, &err, watch_key, now_epoch_s)?; - } - } - Ok(()) -} - -/// Compute the orderbook UID hex (`0x` + 112 hex chars) for the given -/// on-chain (order, owner, chain) tuple, mirroring what `submit_order` -/// will deduce server-side. Used by [`submit_ready`] to short-circuit -/// poll-tick re-submissions of an already-submitted TWAP child. -/// -/// Returns `None` if the chain id is unsupported by `cowprotocol::Chain` -/// or the order carries an unknown enum marker - both cases also stop -/// the regular submit path downstream, so the caller can fall through -/// to the normal flow and let it surface the appropriate diagnostic. +#[cfg(test)] fn compute_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))) -} - -// ---- OrderPostError -> retry action ---- - -fn apply_submit_retry( - host: &H, - err: &CowApiError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Only a typed orderbook rejection classifies; transport faults and - // raw HTTP errors are transient, so the watch stays in place. - let action = match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock => { - tracing::warn!("submit retry-next-block: {err}"); - } - RetryAction::Backoff { seconds } => { - let until = now_epoch_s.saturating_add(seconds); - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &until.to_le_bytes(), - )?; - } - tracing::warn!("submit backoff {seconds}s -> next_epoch={until}: {err}"); - } - RetryAction::Drop => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::warn!("submit dropped watch: {err}"); - } - // `RetryAction` is `#[non_exhaustive]`; future variants - // default to "leave the watch in place" (the conservative - // dispatch choice). Once a new variant gets a real meaning - // its arm should be added explicitly. - _ => { - tracing::warn!("submit unknown retry-action: {err} - leaving watch in place"); - } - } - Ok(()) -} - -// ---- PollOutcome lifecycle dispatch ---- - -/// What `apply_watch_update` should do for a given outcome. Kept as a -/// data type (rather than running the effects directly) so the -/// decision is host-free testable. -#[derive(Debug, Eq, PartialEq)] -enum WatchUpdate { - /// Leave the store untouched. Next block re-polls the watch. - NoOp, - /// Write `next_block:` so subsequent polls skip until the given - /// block number is reached. - SetNextBlock(u64), - /// Write `next_epoch:` so subsequent polls skip until the given - /// Unix-seconds timestamp is reached. - SetNextEpoch(u64), - /// Delete the watch and any stale gate keys - TWAP completed, - /// cancelled, or otherwise irrecoverable. - DropWatch, -} - -/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect -/// the contract specifies. `Ready` is handled by the submit -/// path and is rejected here so a caller cannot -/// accidentally erase the watch when an order was actually produced. -fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { - match outcome { - PollOutcome::Ready { .. } => WatchUpdate::NoOp, - PollOutcome::TryNextBlock => WatchUpdate::NoOp, - PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), - PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), - PollOutcome::DontTryAgain => WatchUpdate::DropWatch, - } -} - -fn apply_watch_update( - host: &H, - update: WatchUpdate, - watch_key: &str, -) -> Result<(), Fault> { - match update { - WatchUpdate::NoOp => Ok(()), - WatchUpdate::SetNextBlock(n) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &n.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::SetNextEpoch(t) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &t.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::DropWatch => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::info!("dropped watch {watch_key}"); - Ok(()) - } - } + shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; - use cowprotocol::OrderCreationAppData; + use alloy_primitives::{B256, U256, address, b256, hex}; 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::OrderRejection; + use shepherd_sdk::cow::{CowApiError, OrderRejection}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -627,33 +334,6 @@ mod tests { } } - /// The signed `appData` digest goes into the body verbatim as the - /// hash-only shape - no document lookup, no digest re-derivation. - #[test] - fn build_order_creation_submits_app_data_hash_verbatim() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let creation = build_order_creation(&order, sig.clone(), owner).expect("build succeeds"); - assert_eq!(creation.from, owner); - assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); - assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!( - creation.app_data, - OrderCreationAppData::Hash { - hash: order.appData - } - ); - } - - #[test] - fn build_order_creation_rejects_zero_from() { - let err = - build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - #[test] fn watch_key_round_trips_via_parse() { let owner = address!("00112233445566778899aabbccddeeff00112233"); @@ -664,48 +344,6 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - #[test] - fn outcome_try_next_block_is_no_op() { - assert_eq!( - outcome_to_update(&PollOutcome::TryNextBlock), - WatchUpdate::NoOp - ); - } - - #[test] - fn outcome_try_on_block_sets_next_block_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryOnBlock(12_345)), - WatchUpdate::SetNextBlock(12_345), - ); - } - - #[test] - fn outcome_try_at_epoch_sets_next_epoch_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), - WatchUpdate::SetNextEpoch(1_700_000_000), - ); - } - - #[test] - fn outcome_dont_try_again_drops_watch() { - assert_eq!( - outcome_to_update(&PollOutcome::DontTryAgain), - WatchUpdate::DropWatch - ); - } - - #[test] - fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { - let order = Box::new(submittable_order()); - let outcome = PollOutcome::Ready { - order, - signature: Bytes::new(), - }; - assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); - } - // ---- MockHost dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed From b4800f5f9f3daa42efe6bf0627b5edb56666797a Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:36:17 +0000 Subject: [PATCH 03/29] feat(sdk-test): add MockVenue and namespaced MockLocalStore (#241) * feat(sdk-test): namespace the mock local store and share its limits Rework MockLocalStore to mirror the runtime store's shape: namespaced views over one shared row map, so identical key strings in sibling namespaces never collide, plus store-wide entry and byte limits that span namespaces the way one database file does. Fault injection stays per-view. * feat(sdk-test): add the programmable MockVenue on the cow-api seam Script per-call venue behaviour where MockCowApi replays one response: a strictly-draining queue of submit outcomes with a configurable steady-state fallback, per-route response sequences whose final entry sticks (a terminal order status persists across re-polls), and venue fault injection that overrides both without consuming them. MockHost grows a defaulted venue type parameter so the composed host carries either mock on the same CowApiHost seam. Acceptance tests drive the keeper run across multi-tick retry, backoff, and outage scenarios, and module-shaped strategy code against status sequences. --- Cargo.lock | 2 + crates/nexum-sdk-test/src/lib.rs | 222 ++++++++++-- crates/shepherd-sdk-test/Cargo.toml | 6 + crates/shepherd-sdk-test/src/lib.rs | 331 +++++++++++++++++- crates/shepherd-sdk-test/tests/mock_venue.rs | 348 +++++++++++++++++++ 5 files changed, 869 insertions(+), 40 deletions(-) create mode 100644 crates/shepherd-sdk-test/tests/mock_venue.rs diff --git a/Cargo.lock b/Cargo.lock index 61076120..23e6f02d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5114,6 +5114,8 @@ dependencies = [ name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ + "alloy-primitives", + "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..b9e135bb 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -60,9 +60,10 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Write as _}; +use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -191,41 +192,99 @@ impl ChainHost for MockChain { // ---------------------------------------------------------------- local-store -/// In-memory [`LocalStoreHost`] backed by a `HashMap`. Each operation -/// runs in O(1) except `list_keys`, which scans (small N expected for -/// tests). +/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: +/// namespaced views over one shared row map, plus store-wide entry +/// and byte limits. /// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. +/// A fresh store is the root view. [`namespaced`](Self::namespaced) +/// derives a sibling view over the same backing rows - identical key +/// strings in different namespaces never collide, matching the host's +/// per-module key prefixing. Limits sit on the shared backing store, +/// so one namespace's writes can exhaust another's headroom exactly +/// as two modules share one database file. Fault injection via +/// [`fail_on`](Self::fail_on) stays per-view. #[derive(Default)] pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, + shared: Rc, + namespace: String, /// Key patterns that trigger injected faults on any operation. error_patterns: RefCell>, } +/// Backing rows and limits shared by every namespaced view. +#[derive(Default)] +struct SharedRows { + /// Rows keyed by `(namespace, key)`. + rows: RefCell>>, + /// Total stored bytes (key + value) across all namespaces. + bytes: Cell, + /// When set, `set` on a new key fails once the store holds this + /// many rows. + max_entries: Cell>, + /// When set, `set` fails once stored bytes would exceed this. + max_bytes: Cell>, +} + impl MockLocalStore { - /// Number of rows currently held. + /// A view over the same backing rows under `namespace`. Views with + /// the same namespace alias the same data (two handles onto one + /// module store); different namespaces are fully isolated even for + /// identical key strings. + /// + /// # Panics + /// + /// On an empty namespace - the runtime rejects those too. + pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { + let namespace = namespace.into(); + assert!( + !namespace.is_empty(), + "MockLocalStore: namespace must not be empty", + ); + MockLocalStore { + shared: Rc::clone(&self.shared), + namespace, + error_patterns: RefCell::new(Vec::new()), + } + } + + /// Number of rows in this view's namespace. pub fn len(&self) -> usize { - self.rows.borrow().len() + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() } - /// Whether the store is empty. + /// Whether this view's namespace holds no rows. pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() + self.len() == 0 } - /// Direct read for assertions - bypasses the trait. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() } - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. + /// Cap the row count across every namespace. Once reached, `set` + /// on a new key fails; overwriting an existing key still succeeds. pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, across every namespace). + /// A `set` that would push the total past the cap fails; deletes + /// and same-key overwrites release the bytes they displace. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); } /// Inject a fault for any operation where the key starts with @@ -250,36 +309,64 @@ impl MockLocalStore { impl LocalStoreHost for MockLocalStore { fn get(&self, key: &str) -> Result>, Fault> { self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .cloned()) } fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { self.check_injected_error(key)?; - if let Some(limit) = *self.max_entries.borrow() { - let rows = self.rows.borrow(); - if rows.len() >= limit && !rows.contains_key(key) { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } + let mut rows = self.shared.rows.borrow_mut(); + let compound = (self.namespace.clone(), key.to_string()); + let existing = rows.get(&compound).map(Vec::len); + if existing.is_none() + && let Some(limit) = self.shared.max_entries.get() + && rows.len() >= limit + { + return Err(Fault::Internal(format!( + "MockLocalStore: max entries ({limit}) reached" + ))); } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); + // Same-key overwrites release the displaced bytes before the + // new row is charged. + let displaced = existing.map_or(0, |len| key.len() + len); + let total = self.shared.bytes.get() - displaced + key.len() + value.len(); + if let Some(budget) = self.shared.max_bytes.get() + && total > budget + { + return Err(Fault::Internal(format!( + "MockLocalStore: max bytes ({budget}) reached" + ))); + } + rows.insert(compound, value.to_vec()); + self.shared.bytes.set(total); Ok(()) } fn delete(&self, key: &str) -> Result<(), Fault> { self.check_injected_error(key)?; - self.rows.borrow_mut().remove(key); + if let Some(value) = self + .shared + .rows + .borrow_mut() + .remove(&(self.namespace.clone(), key.to_string())) + { + self.shared + .bytes + .set(self.shared.bytes.get() - key.len() - value.len()); + } Ok(()) } fn list_keys(&self, prefix: &str) -> Result, Fault> { self.check_injected_error(prefix)?; let mut keys: Vec = self + .shared .rows .borrow() .keys() - .filter(|k| k.starts_with(prefix)) - .cloned() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .map(|(_, key)| key.clone()) .collect(); keys.sort(); Ok(keys) @@ -671,6 +758,77 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn local_store_namespaces_isolate_identical_keys() { + let store = MockLocalStore::default(); + let other = store.namespaced("other-module"); + store.set("watch:a", b"mine").unwrap(); + other.set("watch:a", b"theirs").unwrap(); + + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + assert_eq!( + other.get("watch:a").unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + + // Scans, counts, and snapshots stay view-scoped. + assert_eq!(store.len(), 1); + assert_eq!(other.len(), 1); + assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); + assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); + + // Deletes never reach across the namespace boundary. + other.delete("watch:a").unwrap(); + assert!(other.is_empty()); + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + } + + #[test] + fn local_store_same_namespace_views_alias_the_same_rows() { + let store = MockLocalStore::default(); + let one = store.namespaced("mod"); + let two = store.namespaced("mod"); + one.set("k", b"v").unwrap(); + assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + #[should_panic(expected = "namespace must not be empty")] + fn local_store_empty_namespace_panics() { + let _ = MockLocalStore::default().namespaced(""); + } + + #[test] + fn local_store_entry_limit_spans_namespaces() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + let other = store.namespaced("other-module"); + store.set("a", b"1").unwrap(); + other.set("b", b"2").unwrap(); + // The store is one shared file: a sibling namespace's rows + // consume the same headroom. + let err = store.set("c", b"3").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + } + + #[test] + fn local_store_byte_budget_enforced_and_released() { + let store = MockLocalStore::default(); + store.set_max_bytes(8); + store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget + let err = store.set("x", b"y").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); + + // A same-key overwrite releases the displaced value first. + store.set("abcd", b"12").unwrap(); + store.set("x", b"y").unwrap(); + + // Deleting releases the whole row's bytes. + store.delete("abcd").unwrap(); + store.set("ab", b"12").unwrap(); + assert_eq!(store.len(), 2); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index a09c388f..83a3525c 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -15,3 +15,9 @@ 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 +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 index e1289cd0..2e08b74f 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -23,6 +23,23 @@ //! 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. @@ -30,6 +47,7 @@ #![warn(missing_docs)] use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; @@ -37,16 +55,18 @@ use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus [`MockCowApi`]. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. +/// 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 { +pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, /// `nexum:host/local-store` mock. pub store: MockLocalStore, /// `shepherd:cow/cow-api` mock. - pub cow_api: MockCowApi, + pub cow_api: V, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -58,13 +78,20 @@ impl MockHost { } } -impl ChainHost for MockHost { +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 { +impl LocalStoreHost for MockHost { fn get(&self, key: &str) -> Result>, Fault> { self.store.get(key) } @@ -79,7 +106,7 @@ impl LocalStoreHost for MockHost { } } -impl CowApiHost for MockHost { +impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) } @@ -94,7 +121,7 @@ impl CowApiHost for MockHost { } } -impl LoggingHost for MockHost { +impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); } @@ -240,6 +267,181 @@ impl CowApiHost for MockCowApi { } } +// ---------------------------------------------------------------- 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::*; @@ -266,6 +468,119 @@ mod tests { ); } + // ---- 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(); diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs new file mode 100644 index 00000000..411f012e --- /dev/null +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -0,0 +1,348 @@ +//! 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 cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk_test::{MockHost, MockVenue}; + +const SEPOLIA: u64 = 11_155_111; + +type VenueHost = MockHost; + +/// Closure-backed source so each test scripts its own outcome. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (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) -> PollOutcome, +{ + 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) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn ready_source( + order: &GPv2OrderData, +) -> FnSource, &[u8], &Tick) -> PollOutcome> { + 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") +} + +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, &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(&client_uid(&order)) + .unwrap() + ); + + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&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, &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, &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(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&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, &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, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&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, &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, + &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()); +} + +/// A B256 hash keeps the watch-key helper honest against the venue +/// host's default type parameter. +#[test] +fn watch_key_helper_unifies_with_the_venue_host() { + let hash: B256 = keccak256(b"conditional order params"); + assert_eq!( + WatchSet::::key(&sample_owner(), &hash), + WatchSet::::key(&sample_owner(), &hash), + ); +} From 020e2136c3eb703d88fe169e2f44f7f7e8cd77b8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:45:30 +0000 Subject: [PATCH 04/29] fix(keeper): drop stale watches and idle duplicate rejections (#242) * fix(sdk): drop the watch when the OrderCreation constructor rejects the order A constructor rejection (zero from, validTo beyond the client-side one-year horizon) is deterministic for the polled payload: skipping with the watch intact re-polled and re-warned on every block forever. Route it through the retry ledger as a drop, matching the pre-keeper net effect where the orderbook rejected the shipped body (e.g. ExcessiveValidTo) and the classifier dropped the watch. The unknown-enum-marker skip keeps its pinned keep-the-watch behaviour. Also log the keeper-level DontTryAgain removal so a permanent drop is observable even for sources that do not log their own outcomes. * fix(stop-loss): record the submitted receipt on a duplicate rejection classify_api_error maps DuplicatedOrder to TryNextBlock on the premise that the caller records the submitted: receipt - true for the run, but stop-loss consumed the classification without the compensating write, so a duplicate rejection re-POSTed every block until validTo. Mirror run::submit_ready: treat already-submitted as success wearing an error status, write the marker the dedup guard reads, and idle. * refactor(sdk): hoist the watch key to a free fn and shed the cross-layer dev cycle WatchSet::key never used the host parameter, forcing a meaningless turbofish at every call site (one test existed solely to prove two instantiations agree). keeper::watch_key is the free canon; WatchSet::key stays as a thin delegate for discoverability. The keeper acceptance tests only touch the local-store seam, so they now run against nexum-sdk-test's MockHost (the same MockLocalStore type), shrinking nexum-sdk's dev cycle from the cross-layer shepherd-sdk-test pair - which dragged the whole CoW domain layer into the world-neutral crate's dev graph - to the within-layer pair its own mock crate documents. * feat(twap-monitor): carry the revert cause on the permanent poll-drop path A revert that classifies to DontTryAgain destroyed the watch with only an Info outcome label; the selector and node message needed to triage a wrongly-dropped watch were discarded. Warn with both before returning the outcome, and pin the log lines in the drop test. The test-only watch_key wrapper now reuses the keeper free fn. * docs(sdk): correct stale order.rs rustdoc OrderCreation::from_signed_order_data was renamed to OrderCreation::new in cowprotocol 0.2; and only the unknown-marker case of order_uid_hex returning None also stops the submit path downstream - an unsupported chain id does not, so say so instead of claiming both do. * chore(backtest): retire the dead app_data resolution scripting The epic removed resolve_app_data and the orderbook-mock's GET /api/v1/app_data route, but the replay harness still programmed that route against a strategy that never requests it. Drop the dead programming (and the now-unused shepherd-sdk dependency) and refresh the module doc to the hash-only submission flow. The RejectedExpected classification is kept for historical report comparability until the harness is reworked around the observer-only strategy (follow-up). * docs: surface the keeper and run in the overview; record the cow-rs patch-channel move The overview's SDK table predated the epic's headline surface: add the nexum-sdk keeper row and the shepherd-sdk cow::run row. Amend ADR-0004 with the move of the [patch.crates-io] channel from bleu/cow-rs to nullislabs/cow-rs and what the pinned rev carries. * style: rustfmt the sweep additions * docs: reword PR-number references out of module comments Numeric issue/PR references in .rs files go stale across repository moves; describe the change instead of pointing at the review. --- Cargo.lock | 3 +- crates/nexum-sdk/Cargo.toml | 11 ++-- crates/nexum-sdk/src/keeper.rs | 15 ++++- crates/nexum-sdk/tests/keeper.rs | 21 ++++--- crates/shepherd-backtest/Cargo.toml | 1 - crates/shepherd-backtest/src/replay.rs | 37 ++++------- crates/shepherd-sdk-test/tests/mock_venue.rs | 16 ++--- crates/shepherd-sdk/src/cow/order.rs | 18 +++--- crates/shepherd-sdk/src/cow/run.rs | 44 +++++++++---- crates/shepherd-sdk/tests/run.rs | 27 ++++++++ docs/00-overview.md | 2 + .../0004-patch-cowprotocol-to-bleu-cow-rs.md | 6 ++ modules/examples/stop-loss/src/strategy.rs | 62 ++++++++++++++++++- modules/twap-monitor/src/strategy.rs | 56 ++++++++++++++--- tools/load-gen/src/main.rs | 2 +- 15 files changed, 237 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23e6f02d..f486526e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,9 +3591,9 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "http", + "nexum-sdk-test", "proptest", "serde_json", - "shepherd-sdk-test", "strum", "thiserror 2.0.18", "tracing", @@ -5063,7 +5063,6 @@ dependencies = [ "nexum-sdk", "serde", "serde_json", - "shepherd-sdk", "shepherd-sdk-test", ] diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 8410babb..67e5d8b2 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -39,11 +39,12 @@ tracing-core.workspace = true [dev-dependencies] proptest.workspace = true -# Dev-dependencies are excluded from Cargo's dependency-cycle check, so -# the nexum-sdk -> shepherd-sdk -> shepherd-sdk-test dev-dep chain is a -# normal, supported arrangement: the keeper stores acceptance-test -# against the same composed MockHost the flagship modules use. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +# Dev-dependencies are excluded from Cargo's dependency-cycle check, so the +# nexum-sdk <- nexum-sdk-test dev-dep is a normal, supported arrangement: the +# keeper stores acceptance-test against the composed world-neutral MockHost. +# 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" } # 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/keeper.rs b/crates/nexum-sdk/src/keeper.rs index ff78e082..60bcbb00 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -95,6 +95,14 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Canonical watch key for an owner / commitment-hash pair (lowercase +/// `0x`-prefixed hex on both halves). Free-standing because the key +/// shape is a property of the store convention, not of any host. +#[must_use] +pub fn watch_key(owner: &Address, hash: &B256) -> String { + format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") +} + /// Borrowed view of a watch key's two hex halves, parsed from a /// `watch:{owner}:{hash}` row. Gate keys are derived from the exact /// substrings of the stored key, so a parse-then-derive round trip is @@ -161,10 +169,11 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair (lowercase - /// `0x`-prefixed hex on both halves). + /// Canonical key for an owner / commitment-hash pair. Thin + /// delegate kept for discoverability; prefer the free + /// [`watch_key`], which needs no host turbofish. pub fn key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") + watch_key(owner, hash) } /// Insert or overwrite the watch row; returns the key written. diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index ff8f9746..e271fa41 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,16 +1,17 @@ -//! Keeper-store acceptance tests against the composed CoW -//! `shepherd_sdk_test::MockHost` - the same host the flagship modules -//! test with. These live as an integration test (not `#[cfg(test)]`) -//! because the mock crate links `nexum-sdk` externally, and the -//! external and unit-test copies of the host traits are distinct types. +//! Keeper-store acceptance tests against the composed +//! `nexum_sdk_test::MockHost` - the keeper touches only the local +//! store, so the world-neutral mock is the whole seam. These live as +//! an integration test (not `#[cfg(test)]`) because the mock crate +//! links `nexum-sdk` externally, and the external and unit-test +//! copies of the host traits are distinct types. 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, + Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; -use shepherd_sdk_test::MockHost; +use nexum_sdk_test::MockHost; fn sample_owner() -> Address { address!("00112233445566778899aabbccddeeff00112233") @@ -24,7 +25,7 @@ fn sample_hash() -> B256 { #[test] fn watch_key_is_lowercase_prefixed_hex() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); assert_eq!( key, concat!( @@ -36,7 +37,7 @@ fn watch_key_is_lowercase_prefixed_hex() { #[test] fn watch_key_round_trips_via_parse() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).expect("parse"); assert_eq!( watch.owner_hex().parse::
().unwrap(), @@ -113,7 +114,7 @@ fn put_overwrites_in_place() { fn get_absent_watch_is_none() { let host = MockHost::new(); let watches = WatchSet::new(&host); - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).unwrap(); assert_eq!(watches.get(watch).unwrap(), None); } diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2bc9f4b1..2ec14aa6 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -16,7 +16,6 @@ path = "src/main.rs" # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk = { path = "../shepherd-sdk" } shepherd-sdk-test = { path = "../shepherd-sdk-test" } anyhow.workspace = true diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 1ba6e44f..60d8f28e 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -2,11 +2,12 @@ //! //! Each [`EthFlowFixture`] is driven through the production strategy //! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, the resolved `app_data` JSON is programmed as the -//! `GET /api/v1/app_data/{hash}` response, the -//! `cow_api.submit_order` response is programmed to echo the -//! fixture's pre-derived UID, and `strategy::on_chain_logs` is invoked -//! with an alloy `Log` reconstructed from the raw `eth_getLogs` payload. +//! constructed, the `cow_api.submit_order` response is programmed to +//! echo the fixture's pre-derived UID, and `strategy::on_chain_logs` +//! is invoked with an alloy `Log` reconstructed from the raw +//! `eth_getLogs` payload. Submission is appData-hash-only: no +//! `GET /api/v1/app_data/{hash}` resolution runs first, so the +//! fixture's collected `app_data_resolved` document is schema-only. //! //! The classification falls into one of the four buckets defined in //! the issue: @@ -15,8 +16,7 @@ //! `OrderCreation` body. The body is captured for downstream //! validation (Phase 2B / orderbook quote round-trip). //! - `RejectedExpected`: the strategy returned without submitting in -//! a documented case - e.g. the app_data hash didn't resolve -//! (documented skip path), or dedup already saw the UID. +//! a documented case - e.g. dedup already saw the UID. //! - `RejectedUnexpected`: the strategy returned without submitting //! in a path we don't recognise; a follow-up should be //! filed before the report closes. @@ -24,7 +24,6 @@ //! bug or an `unreachable!` we want to investigate. use ethflow_watcher::strategy; -use shepherd_sdk::cow::{CowApiError, HttpFailure}; use shepherd_sdk_test::MockHost; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -87,22 +86,6 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { // re-run the orderbook itself. host.cow_api.respond(Ok(fx.uid.clone())); - // Program the `app_data` resolution path. If the - // collector captured a resolved document, hand it back verbatim; - // if the hash 404'd at collection time, return a host-side - // `Unavailable` so the strategy hits its documented "appData - // hash not mirrored" branch. - let app_data_path = format!("/api/v1/app_data/{}", fx.app_data_hash); - let app_data_response = match &fx.app_data_resolved { - Some(doc) => Ok(serde_json::to_string(doc).expect("re-serialise app_data")), - None => Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - })), - }; - host.cow_api - .respond_to_request_for("GET", app_data_path, app_data_response); - // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow // owner the fixture pins. @@ -179,7 +162,11 @@ fn classify_ok(host: &MockHost, fx: &EthFlowFixture, log_lines: &[String]) -> Cl return Classification::Submitted; } // The strategy returned Ok without submitting. Distinguish the - // documented branches from anomalies. + // documented branches from anomalies. NOTE: the "not mirrored" + // skip path was retired with hash-only submission; this + // classification is kept for historical report comparability + // until the harness is reworked around the observer-only + // strategy (follow-up). if fx.app_data_resolved.is_none() { return Classification::RejectedExpected( "app_data hash not mirrored (documented skip path)".into(), diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index 411f012e..ca2ba439 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -6,7 +6,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; -use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, PollOutcome, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; @@ -336,13 +336,15 @@ fn module_probe_rides_out_an_injected_outage() { assert!(Journal::observed(&host).contains("0xuid").unwrap()); } -/// A B256 hash keeps the watch-key helper honest against the venue -/// host's default type parameter. +/// 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"); - assert_eq!( - WatchSet::::key(&sample_owner(), &hash), - WatchSet::::key(&sample_owner(), &hash), - ); + 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/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index 5bcef8fb..d0a79bfb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -12,8 +12,7 @@ use cowprotocol::{ }; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` -/// expects. +/// typed [`OrderData`] shape `OrderCreation::new` expects. /// /// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride /// the wire as `bytes32` markers (the `keccak256` of the lowercase @@ -22,10 +21,10 @@ use cowprotocol::{ /// chain payload carries a marker the SDK doesn't recognise - the /// caller skips the order rather than ship a malformed body. /// -/// `receiver = Address::ZERO` is normalised to `None`; `OrderCreation:: -/// from_signed_order_data` does the same downstream, but doing it here +/// `receiver = Address::ZERO` is normalised to `None`; +/// `OrderCreation::new` does the same downstream, but doing it here /// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// helper later. +/// constructor later. /// /// # Example /// @@ -79,9 +78,12 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { /// idempotency state before any network work. /// /// `None` when the chain id has no settlement domain or the order -/// carries an unknown enum marker; both also stop the submit path -/// downstream, so callers fall through and let it surface the -/// diagnostic. +/// carries an unknown enum marker. Only the unknown-marker case also +/// stops the submit path downstream ([`gpv2_to_order_data`] fails the +/// same way there); an unsupported chain id does not, so a caller +/// keying idempotency on this value alone re-submits until `validTo` +/// on such a chain - bounded, but callers adding new chains should +/// teach `cowprotocol::Chain` about them first. #[must_use] pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { let chain = Chain::try_from(chain_id).ok()?; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index cda6c6e0..f70433cf 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -17,7 +17,7 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes}; -use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; +use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, @@ -55,7 +55,12 @@ where PollOutcome::TryNextBlock => {} PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, - PollOutcome::DontTryAgain => watches.remove(watch)?, + PollOutcome::DontTryAgain => { + // The removal is permanent; leave a trace of it even + // for sources that do not log their own outcomes. + tracing::info!("{} dropped watch {}", source.label(), watch.key()); + watches.remove(watch)?; + } } } Ok(()) @@ -93,10 +98,27 @@ fn submit_ready( return Ok(()); } - let creation = match build_order_creation(order, signature, owner) { + 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 + // still pick the watch up. + tracing::warn!( + "{label} submit skipped for {owner:#x}: GPv2OrderData carried an unknown enum marker" + ); + return Ok(()); + }; + let creation = match build_order_creation(&order_data, signature, owner) { Ok(creation) => creation, - Err(message) => { - tracing::warn!("{label} submit skipped for {owner:#x}: {message}"); + 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(()); } }; @@ -173,14 +195,14 @@ fn submit_ready( /// 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: &GPv2OrderData, + order_data: &OrderData, signature: Bytes, from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order) - .ok_or_else(|| "GPv2OrderData carried an unknown enum marker".to_string())?; +) -> Result { let signature = Signature::Eip1271(signature.to_vec()); - OrderCreation::new_app_data_hash_only(&order_data, signature, from, None) - .map_err(|e| e.to_string()) + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index d5088626..a643803f 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -312,6 +312,33 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { 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.) +#[test] +fn ready_beyond_the_valid_to_horizon_drops_the_watch() { + 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 source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); + + assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); + let snapshot = host.store.snapshot(); + assert!( + !snapshot.contains_key(&key), + "an unsubmittable order must not survive to warn-loop forever", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); +} + // ---- submission failure dispatch ---- fn rejection(error_type: &str) -> CowApiError { diff --git a/docs/00-overview.md b/docs/00-overview.md index 11e3c0fb..e920b3f5 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -272,11 +272,13 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `Fault` + the `HostFault` trait - the shared failure vocabulary and per-interface typed errors (`ChainError`) with `?` support | | | `chain::{eth_call_params, parse_eth_call_result}` + `chain::chainlink` - JSON-RPC plumbing helpers | | | `config` / `address` - config-table lookups, decimal scaling, address parsing | +| | `keeper::{WatchSet, Gates, Journal, Retrier, ConditionalSource}` - the conditional-commitment strategy keeper: watch registry, poll gates, receipt journal, retry dispatch over the local-store seam | | | `http::{fetch, Fetch, FetchError, FetchOptions}` - allowlisted outbound HTTP over wasi:http on the standard `http` crate's `Request` / `Response` types | | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | | | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | +| | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | | `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | diff --git a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md index c8ba48b4..c78d14c6 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -36,3 +36,9 @@ This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream P - Bumping the rev is a single-line workspace edit; reviewers see one diff per primitive added to PR #5. - Drop the patch entirely once a published `cowprotocol` release contains both the alpha.3 follow-ups and the ADR-0007 protocol-primitive additions (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, `wasm32` feature-gate). Until then, expect the patch rev to advance with every push to PR #5. - Modules built against this workspace inherit the patch transitively; modules built standalone against crates.io will see `alpha.3` and may hit the very bugs the patch closes. Flag this in the SDK README when M3 lands. + +## Addendum (2026-07): patch channel moved to nullislabs/cow-rs + +The patch target has since moved from `bleu/cow-rs` to `https://github.com/nullislabs/cow-rs` (rev `17fc0c5`). The fork carries two changes the workspace needs ahead of a published `cowprotocol` 0.2.0: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and the WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) has neither. + +The decision and its consequences are otherwise unchanged: one workspace-level `[patch.crates-io]` line, advanced by bumping the rev, dropped entirely once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the workspace `Cargo.toml` states the current drop condition. diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index bcec815b..8c97f74f 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -9,7 +9,7 @@ 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, + 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, @@ -104,6 +104,20 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res ); } 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(()); + } // Only a typed orderbook rejection classifies; transport // faults and raw HTTP errors are transient (retry next // block) rather than a terminal drop. @@ -135,7 +149,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res } // `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (PR #55 review): the same flow + `Option` return shape now serves +// (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. @@ -436,6 +450,50 @@ mod tests { assert_eq!(host.cow_api.call_count(), 1); // no resubmit } + /// 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() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle( + &host, + s.oracle_address, + 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(); + + 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", + ); + assert!( + !host + .store + .snapshot() + .contains_key(&format!("dropped:{uid}")), + "already-submitted must never mark the order dropped", + ); + + // Second block: the receipt idles the loop, no re-POST. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index fe0c72c1..06f4d8fc 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -157,13 +157,34 @@ fn poll_one( .and_then(|bytes| decode_return(&bytes)) .unwrap_or(PollOutcome::TryNextBlock), // `classify_poll_error` is the one policy for what a failed - // poll call means to the watch lifecycle; only a transport - // fault warrants its own diagnostic here. + // poll call means to the watch lifecycle; the diagnostics here + // cover the cases where the raw error carries information the + // outcome alone does not. Err(err) => { - if let ChainError::Fault(fault) = &err { - tracing::warn!("eth_call failed ({fault}); retrying next block"); + let outcome = classify_poll_error(&err); + match &err { + ChainError::Fault(fault) => { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + // A permanent drop deserves its cause on the record: + // the revert selector and the node's message are + // unrecoverable once the watch is gone. + ChainError::Rpc(rpc) if matches!(outcome, PollOutcome::DontTryAgain) => { + let selector = rpc + .data + .as_deref() + .and_then(|data| data.get(..4)) + .map(alloy_primitives::hex::encode_prefixed) + .unwrap_or_else(|| "none".to_string()); + tracing::warn!( + "eth_call reverted permanently (selector {selector}, {}); \ + dropping watch", + rpc.message, + ); + } + _ => {} } - classify_poll_error(&err) + outcome } } } @@ -196,9 +217,7 @@ fn outcome_label(o: &PollOutcome) -> &'static str { // seed and inspect the store in the exact shapes production writes. #[cfg(test)] -fn watch_key(owner: &Address, params_hash: &alloy_primitives::B256) -> String { - WatchSet::::key(owner, params_hash) -} +use nexum_sdk::keeper::watch_key; #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { @@ -739,7 +758,8 @@ mod tests { })), ); - on_block(&host, sample_block(1_000)).unwrap(); + let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); assert!( @@ -753,5 +773,23 @@ mod tests { 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. + let warn = logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("eth_call reverted permanently") + }); + assert!(warn.message.contains("execution reverted")); + let selector_hex = + alloy_primitives::hex::encode_prefixed(&IConditionalOrder::OrderNotValid::SELECTOR[..]); + assert!( + warn.message.contains(&selector_hex), + "the four-byte selector must be greppable: {}", + warn.message, + ); + logs.expect_one(|e| { + e.message + .contains(&format!("dropped watch {watch_key_str}")) + }); } } diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 1546ac42..15a05760 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -399,7 +399,7 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { /// needs no registration step. `validTo` is `u32::MAX` per the /// canonical EthFlow shape (the mock orderbook is /// permissive here, and shepherd's strategy will drop with the -/// expected Info-level log per PR #49). +/// expected Info-level log). fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, From 6d2b2278ec28acca9a675b029c29e65ca8e023ed Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:49:56 +0000 Subject: [PATCH 05/29] feat(sdk): scaffold nexum-macros and #[module] attribute (#243) * feat(sdk): scaffold nexum-macros and ship #[module] attribute Add the `nexum-macros` proc-macro crate. `#[nexum_sdk::module]` generates the per-cdylib glue every module hand-wrote: the `wit_bindgen::generate!` call for the blanket `shepherd:cow/shepherd` world, the host adapter via `bind_host_via_wit_bindgen!`, the `Guest` implementation whose `on-event` dispatches to the named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, with undefined handlers ignored), and `export!`. The WIT directory is located by walking up from the consuming crate's manifest, so the emitted paths need no per-module tuning. Re-export it as `nexum_sdk::module` and port the example, balance-tracker and price-alert modules onto it; each strategy MockHost suite passes unchanged. * refactor(http-probe): port onto #[nexum::module] Complete the acceptance list: drop the hand-written wit-bindgen glue, host adapter, and Guest/export block in favour of the attribute, leaving only the init and on-block handlers. Strategy MockHost suite unchanged. --- Cargo.lock | 11 ++ Cargo.toml | 8 + crates/nexum-macros/Cargo.toml | 18 ++ crates/nexum-macros/src/lib.rs | 176 ++++++++++++++++++++ crates/nexum-sdk/Cargo.toml | 4 + crates/nexum-sdk/src/lib.rs | 10 ++ modules/example/Cargo.toml | 1 + modules/example/src/lib.rs | 90 +++++----- modules/examples/balance-tracker/src/lib.rs | 30 +--- modules/examples/http-probe/src/lib.rs | 33 ++-- modules/examples/price-alert/src/lib.rs | 31 ++-- 11 files changed, 302 insertions(+), 110 deletions(-) create mode 100644 crates/nexum-macros/Cargo.toml create mode 100644 crates/nexum-macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f486526e..0669df5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2266,6 +2266,7 @@ dependencies = [ name = "example" version = "0.1.0" dependencies = [ + "nexum-sdk", "wit-bindgen 0.59.0", ] @@ -3545,6 +3546,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "nexum-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "nexum-runtime" version = "0.2.0" @@ -3591,6 +3601,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "http", + "nexum-macros", "nexum-sdk-test", "proptest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 24b465db..25604bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/nexum-cli", + "crates/nexum-macros", "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", @@ -115,6 +116,13 @@ 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-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + # `wit-bindgen` is consumed by every guest module crate (example + # every strategy + every fixture). Hoisted so a single bump moves # them in lock-step. diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml new file mode 100644 index 00000000..ed9fcf2c --- /dev/null +++ b/crates/nexum-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nexum-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." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs new file mode 100644 index 00000000..e5dca6f7 --- /dev/null +++ b/crates/nexum-macros/src/lib.rs @@ -0,0 +1,176 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for the +//! blanket `shepherd:cow/shepherd` world, the host adapter (via +//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation +//! whose `on-event` dispatches to the handlers present, and `export!`. +//! +//! Consumers reach this through the `nexum_sdk::module` re-export rather +//! than depending on this crate directly. + +use std::path::{Path, PathBuf}; + +use proc_macro::TokenStream; +use quote::quote; +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; any handler in the set that is +/// absent is treated as a no-op in the generated `on-event` dispatch. +const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on_message"]; + +/// Generate the per-cdylib glue for a nexum module. +/// +/// Apply to an `impl` block whose associated functions are the event +/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, +/// `on_message`). Each handler takes the wit-bindgen payload for its +/// event and returns `Result<(), Fault>`; `init` takes the config table. +/// Handlers left undefined are ignored (their events become no-ops). The +/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` +/// impl, and `export!` around the untouched impl. +/// +/// The one non-obvious invariant: the `wit`/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 directory is located by walking up from +/// `CARGO_MANIFEST_DIR`. +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_sdk::module] 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, + "#[nexum_sdk::module] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + + let present: Vec<&str> = 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) + } + _ => None, + }) + .collect(); + let has = |name: &str| present.contains(&name); + + let (nexum_wit, shepherd_wit) = match locate_wit() { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let nexum_wit = nexum_wit.to_string_lossy().into_owned(); + let shepherd_wit = shepherd_wit.to_string_lossy().into_owned(); + + // `init` is a required export; when the handler is absent the config + // is bound but unused, so drop it to keep the module warning-clean. + let init_impl = if has("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(()) + } + } + }; + + let arm = |handler: &str, variant| -> proc_macro2::TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + if has(handler) { + let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); + quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } + } else { + 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"); + + quote! { + wit_bindgen::generate!({ + path: [#nexum_wit, #shepherd_wit], + world: "shepherd:cow/shepherd", + generate_all, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(); + + #input + + #[doc(hidden)] + struct __NexumModuleExport; + + impl Guest for __NexumModuleExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + } + } + } + + export!(__NexumModuleExport); + } + .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()) +} + +/// Locate the workspace `wit/nexum-host` and `wit/shepherd-cow` +/// directories by walking up from the consuming crate's manifest. +fn locate_wit() -> Result<(PathBuf, PathBuf), 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)); + while let Some(cur) = dir { + let wit = cur.join("wit"); + let nexum = wit.join("nexum-host"); + let shepherd = wit.join("shepherd-cow"); + if nexum.is_dir() && shepherd.is_dir() { + return Ok((nexum, shepherd)); + } + dir = cur.parent(); + } + Err(format!( + "could not find a `wit/` directory containing `nexum-host` and `shepherd-cow` \ + in any ancestor of {manifest}" + )) +} diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 67e5d8b2..f713c5c1 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -19,6 +19,10 @@ description = "Guest-side SDK for nexum runtime modules: host-neutral helpers us stderr-echo = [] [dependencies] +# 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" } 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 b11cd21e..bd255a8e 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -28,6 +28,11 @@ //! generates the per-module `WitBindgenHost` adapter over the //! wit-bindgen import shims. //! +//! - [`module`] - attribute macro that generates the whole per-cdylib +//! glue (the wit-bindgen call, the adapter above, the +//! `Guest`/`on-event` dispatch, and `export!`) from an `impl` block of +//! named handlers. +//! //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal @@ -104,6 +109,11 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_cfg))] +/// 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; + pub mod address; pub mod chain; pub mod config; diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index c9a0ff70..19311814 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -12,4 +12,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] +nexum-sdk = { path = "../../crates/nexum-sdk" } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 6ed4deb6..273b299d 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,24 +1,24 @@ +//! # example (reference Shepherd module) +//! +//! The minimal reference module: one handler per event, each logging a +//! one-line summary through the raw host `logging` binding. It carries +//! no strategy layer and no `[config]` behaviour, so it doubles as the +//! smallest end-to-end demonstration of `#[nexum_sdk::module]` - the +//! attribute supplies the wit-bindgen call, the host adapter, the +//! `Guest`/`on-event` dispatch, and `export!`, leaving only the +//! handlers. + // 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", - world: "nexum:host/event-module", -}); - -use nexum::host::logging; -use nexum::host::types; +use nexum::host::{logging, types}; -// This is the SDK-free reference module: it depends only on -// `wit-bindgen` and installs no tracing subscriber, so it logs through -// the raw host `logging` binding directly. That binding is the same -// sink the `tracing` facade forwards to in the SDK-based modules, so -// the records are indistinguishable to the host. struct ExampleModule; -impl Guest for ExampleModule { +#[nexum_sdk::module] +impl ExampleModule { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { let name = config .iter() @@ -32,38 +32,38 @@ impl Guest for ExampleModule { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match &event { - types::Event::Block(block) => { - logging::log( - logging::Level::Info, - &format!( - "block {} on chain {} (ts={}ms)", - block.number, block.chain_id, block.timestamp - ), - ); - } - types::Event::ChainLogs(batch) => { - logging::log( - logging::Level::Info, - &format!("received {} chain-log entries", batch.logs.len()), - ); - } - types::Event::Tick(tick) => { - logging::log( - logging::Level::Info, - &format!("tick fired at {}ms", tick.fired_at), - ); - } - types::Event::Message(msg) => { - logging::log( - logging::Level::Info, - &format!("message on topic {}", msg.content_topic), - ); - } - } + fn on_block(block: types::Block) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "block {} on chain {} (ts={}ms)", + block.number, block.chain_id, block.timestamp + ), + ); + Ok(()) + } + + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("received {} chain-log entries", batch.logs.len()), + ); Ok(()) } -} -export!(ExampleModule); + fn on_tick(tick: types::Tick) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("tick fired at {}ms", tick.fired_at), + ); + Ok(()) + } + + fn on_message(msg: types::Message) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("message on topic {}", msg.content_topic), + ); + Ok(()) + } +} diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 55aec121..263a7bcd 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -11,8 +11,8 @@ //! - `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. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Config //! @@ -27,28 +27,21 @@ #![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`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; -impl Guest for BalanceTracker { +#[nexum_sdk::module] +impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -61,15 +54,10 @@ impl Guest for BalanceTracker { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: 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).map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) } } - -export!(BalanceTracker); diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index bb9ab42e..c48377b1 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -14,9 +14,8 @@ //! - `strategy.rs` holds the pure logic and tests against the SDK's //! `http::Fetch` seam, logging through the `tracing` facade. It does //! not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, `install_tracing`, the -//! `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -36,28 +35,21 @@ #![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`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// generated by the attribute alongside the wit-bindgen call and the +// `Guest`/`export!` glue. struct HttpProbe; -impl Guest for HttpProbe { +#[nexum_sdk::module] +impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -71,16 +63,11 @@ impl Guest for HttpProbe { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(HttpProbe); diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index e9d922d0..94285268 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -16,8 +16,8 @@ //! - `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. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -42,27 +42,21 @@ #![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`, `sdk_fault_into_wit`, `convert_level` are generated -// below. Single source of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; -impl Guest for PriceAlert { +#[nexum_sdk::module] +impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -77,16 +71,11 @@ impl Guest for PriceAlert { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: 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, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(PriceAlert); From 558e6274d00e2c25000f85195e65d49a9d3ce3f7 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:07:15 +0000 Subject: [PATCH 06/29] docs(sdk): rewrite doc 05 around the two-persona SDK plan (#245) Doc 05 was stamped future-direction and described the superseded macro/TypedState/Signer/HostTransport vision. Replace it with the shipped module-author persona (nexum-sdk + shepherd-sdk + the landed #[nexum::module] macro) and the planned venue-adapter persona (nexum-venue-sdk / per-venue-crate), clearly labelled as design intent tracked by a separate epic. Drop the deferred-features table and cross-link ADR-0009 and sdk.md. --- docs/05-sdk-design.md | 1223 +++++++++-------------------------------- 1 file changed, 267 insertions(+), 956 deletions(-) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 697d3448..b4b784e6 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,982 +1,293 @@ -# SDK Design: Layered SDK (`nexum-sdk` + `shepherd-sdk`) - -> **Status: future direction, not in 0.2 scope.** This document is the **0.3+ north-star** vision for the layered SDK. The 0.2 SDK shipped a focused subset, and the macro-driven authoring model below was superseded by the host-trait seam in [ADR-0009](adr/0009-host-trait-surface.md) - that is the design that ships. Treat the macros, two-crate split, `TypedState`, `Signer`, `HostTransport` / `Provider`, and `cargo-nexum` CLI sections below as design intent, not API documentation. For the shipped surface, see [`sdk.md`](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. -> -> The split, for quick reference: -> -> | Feature | M3 status | Where | -> |---|---|---| -> | `shepherd-sdk` crate | ✅ shipped | `crates/shepherd-sdk/` | -> | `shepherd-sdk-test` crate (mock host) | ✅ shipped | `crates/shepherd-sdk-test/` | -> | Host traits (`ChainHost`, `LocalStoreHost`, `LoggingHost`) + supertrait `Host` | ✅ shipped | `crates/nexum-sdk/src/host.rs` (see ADR-0009); the CoW `CowApiHost` lives in `crates/shepherd-sdk/src/cow/` | -> | `strategy.rs` (pure logic) + `lib.rs` (wit-bindgen adapter) recipe | ✅ shipped | every M2/M3 module | -> | `Fault` + `HostFault` trait, `ChainError` (SDK-side mirror of wit) | ✅ shipped | `crates/nexum-sdk/src/host.rs` | -> | `chain` helpers (`eth_call_params`, `parse_eth_call_result`) | ✅ shipped | `crates/nexum-sdk/src/chain/`; the CoW `decode_revert_hex` lives in `crates/shepherd-sdk/src/cow/` | -> | `cow` helpers (`PollOutcome`, `RetryAction`, `classify_api_error`, `gpv2_to_order_data`, `decode_revert`, `IConditionalOrder`) | ✅ shipped | `crates/shepherd-sdk/src/cow/` | -> | `http::fetch` over wasi:http (+ `Fetch` seam, `FetchError`) | ✅ shipped | `crates/nexum-sdk/src/http.rs` | -> | `MockHost` with per-trait mocks (`MockChain`, `MockLocalStore`, `MockLogging`; CoW `MockCowApi`) | ✅ shipped | `crates/nexum-sdk-test/src/lib.rs` + `crates/shepherd-sdk-test/src/lib.rs` | -> | Separate `nexum-sdk` crate | ✅ shipped | `crates/nexum-sdk/` carries the generic surface (host seam, bind macro, chain/config/address, http, tracing); `shepherd-sdk` layers the CoW domain on top with no re-export | -> | `#[nexum::module]` / `#[shepherd::module]` proc macros | ❌ deferred (M5) | modules write `wit_bindgen::generate!` + `WitBindgenHost` adapter by hand | -> | Named event handlers (`on_block` / `on_chain_logs` / `on_tick` / `on_message` injection) | ❌ deferred (M5) | modules pattern-match on `types::Event` in `Guest::on_event` | -> | `async fn` handler support via `block_on` | ❌ deferred (M5) | strategy functions are synchronous | -> | Full alloy `Provider` via `HostTransport` | ❌ deferred (M5) | modules call `host.request(chain_id, method, params)` with JSON strings | -> | `TypedState` (postcard-backed typed local-store) | ❌ deferred (M5) | modules call `host.set(&key, &raw_bytes)` directly | -> | `Signer` (ECDSA + EIP-712 via `identity` host interface) | ❌ deferred (M5) | modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side | -> | `Cow` typed CoW Protocol API client (quote / get_order / raw_request) | ❌ deferred (M5) | `cow-api` exposes only `submit-order` today | -> | `MockIdentity`, `MockProvider`, `WasmTestHarness` | ❌ deferred (M5) | tests against `&impl Host` + per-trait mocks | -> | `cargo nexum` CLI (new / build / package / publish) | ❌ deferred (M5) | modules use `cargo build --target wasm32-wasip2` directly | -> | `block.timestamp` in ms | ✅ shipped | confirmed in `nexum:host/types` | -> -> **Reader's guide**: treat the sections below as design intent the next two milestones move toward, not API documentation for the code that exists today. For M3 API reference, see [sdk.md](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. The M3 architectural decision is captured in [ADR-0009](adr/0009-host-trait-surface.md). - -## Purpose - -The SDK is split into two layers: - -1. **`nexum-sdk`** -- the universal SDK for any `nexum:host/event-module`. It provides: - - WIT bindings (re-exported, version-pinned) - - A proc macro (`#[nexum::module]`) that eliminates boilerplate (supports `async fn` for natural `.await`) - - A full alloy `Provider` backed by the host's RPC stack (`HostTransport`) - - Typed local-store helpers (serde over raw bytes) - - A typed `Signer` for key management and signing - - Ethereum ABI helpers (alloy-sol-types integration) - - A test harness with a mock host (`MockHost`) - - A logging convenience layer - - The per-interface typed error model over the shared `Fault` vocabulary - -2. **`shepherd-sdk`** -- the CoW Protocol extension. It depends on `nexum-sdk` (modules import both directly; nothing is re-exported) and adds: - - CoW-specific WIT bindings (`shepherd:cow`) - - A typed CoW Protocol API client (`Cow`) - - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd` world - - CoW-specific mock testing utilities - -Module authors should never interact with `wit-bindgen` or the canonical ABI directly. - -## Crate Structure +# 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-macros/`. + +## The two personas + +The runtime has two kinds of guest authors, and they need different +things from the SDK: + +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) 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 (`nexum-venue-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 + 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 +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 ``` nexum-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor (block_on is internal) -│ ├── bindings.rs # generated by wit-bindgen (checked in or build.rs) -│ ├── transport.rs # HostTransport -- alloy Transport impl over chain::request / chain::request-batch -│ ├── local_store.rs # typed local-store helpers -│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing) -│ ├── abi.rs # Ethereum ABI encoding/decoding -│ ├── log.rs # logging convenience -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro (async fn support) +└── src/ + ├── lib.rs # crate docs, `pub use nexum_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 + ├── 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 + +nexum-macros/ +├── Cargo.toml # proc-macro = true +└── src/ + └── lib.rs # #[module] attribute macro shepherd-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # CoW-specific prelude and API; modules import nexum-sdk directly -│ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow) -│ ├── cow.rs # Cow -- typed CoW Protocol API wrapper -│ └── testing.rs # CoW-specific mock utilities -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (CoW variant) -``` - -The workspace root `wit/nexum-host/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: - -```toml -# nexum-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/nexum-host" -``` - -```toml -# shepherd-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/shepherd-cow" -``` - -Both SDKs pin a specific `wit-bindgen` version so module authors are insulated from upstream churn. - -The crates above are the guest-side SDK. The host side ships separately as the `nexum-runtime` library plus the `nexum` binary (`crates/nexum-cli`). A Rust host embedding the runtime directly should start from `crates/nexum-runtime/examples/embed.rs` rather than the SDK. - -## The `#[nexum::module]` and `#[shepherd::module]` Macros - -### Universal: `#[nexum::module]` - -Without the macro, a module author writes (against the typed 0.2 config): - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -struct MyModule; - -impl Guest for MyModule { - fn init(config: Config) -> Result<(), Fault> { ... } - fn on_event(event: Event) -> Result<(), Fault> { - match event { - Event::Block(block) => { ... } - Event::ChainLogs(logs) => { ... } - Event::Tick(tick) => { ... } - Event::Message(msg) => { ... } - } - } -} - -export!(MyModule); -``` - -With the macro, module authors implement **named event handlers** instead. The macro generates the `on_event` match dispatch, the `Guest` trait impl, WIT bindings, and `export!`: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; - // ... - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. - -### CoW Protocol: `#[shepherd::module]` - -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: - -```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct CowTwapMonitor; - -impl CowTwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?; - // ... - Ok(()) - } -} -``` - -### What the macro generates - -For the universal `#[nexum::module]`: - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -impl Guest for TwapMonitor { - fn init(config: Config) -> Result<(), Fault> { - TwapMonitor::init(config.into()).map_err(Fault::from) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - TwapMonitor::on_block(block, &provider).await - } - Event::ChainLogs(logs) => { - let provider = nexum_sdk::provider(logs[0].chain_id); - TwapMonitor::on_chain_logs(logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }).map_err(Fault::from) - } -} - -export!(TwapMonitor); -``` - -For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow/cow-api` alongside the `nexum:host` base. - -### Named event handlers - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from the `chain-logs` batch chain id) | -| `on_tick(tick)` | `Tick` (`tick.fired_at`) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: - -- **If the second parameter is `&RootProvider`**: the macro creates `nexum_sdk::provider(chain_id)` (also for CoW modules) and passes it in. The chain_id is derived from the event payload (`block.chain_id`, `logs[0].chain_id`). -- **If no second parameter**: the macro passes only the payload. -- **Both sync and async handlers work.** Async handlers are wrapped in `block_on`; sync handlers are called directly. -- **Unimplemented handlers** become `Ok(())` -- the module only handles event types it cares about. - -### Escape hatch: `on_event` - -For modules that need custom dispatch logic, defining `on_event` directly takes precedence over named handlers: - -```rust -#[nexum::module] -struct CustomModule; - -impl CustomModule { - fn init(config: Config) -> Result<()> { Ok(()) } - - // Full control -- named handlers are ignored if on_event exists - async fn on_event(event: Event) -> Result<()> { - match event { - Event::Block(block) if block.chain_id == 42161 => { /* Arbitrum only */ } - Event::Block(_) => { /* other chains */ } - _ => {} - } - Ok(()) - } -} -``` - -Resolution order: -1. `on_event` defined -> use it directly (wrap in `block_on` if async) -2. Any of `on_block` / `on_chain_logs` / `on_tick` / `on_message` defined -> generate the match dispatch -3. Neither -> compile error - -> Full async design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md#eliminating-block_on-async-module-functions) - -## Prelude - -### Universal: `nexum_sdk::prelude` - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; - -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` - -One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `Signer` for signing. - -`block_on` is no longer a public re-export in 0.2 -- it's hidden behind the `#[nexum::module]` macro. See the [migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) for the full SDK rename table. - -### CoW Protocol: `shepherd_sdk::prelude` - -```rust -// shepherd_sdk::prelude -- CoW-specific items only; no nexum-sdk re-export -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` - -CoW module authors write `use nexum_sdk::prelude::*;` alongside `use shepherd_sdk::prelude::*;` -- the CoW prelude adds only the merged CoW `cow-api` interface and the typed `Cow` client. - -## Typed Local-Store Helpers - -Raw local-store is `string -> list`. The SDK adds a typed layer using serde: - -```rust -use nexum_sdk::prelude::*; -use serde::{Serialize, Deserialize}; - -#[derive(Serialize, Deserialize)] -struct TwapProgress { - last_block: u64, - posted_parts: Vec<[u8; 32]>, -} - -// Read typed value -let progress: Option = TypedState::get("progress")?; - -// Write typed value -TypedState::set("progress", &TwapProgress { - last_block: 19_000_001, - posted_parts: vec![part_hash], -})?; - -// Delete -TypedState::delete("progress")?; - -// List keys by prefix -let keys: Vec = TypedState::list_keys("orders/")?; -``` - -Implementation: - -```rust -pub struct TypedState; - -impl TypedState { - pub fn get(key: &str) -> Result> { - match local_store::get(key)? { - Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)), - None => Ok(None), - } - } - - pub fn set(key: &str, value: &T) -> Result<()> { - let bytes = postcard::to_allocvec(value)?; - local_store::set(key, &bytes)?; - Ok(()) - } - - pub fn delete(key: &str) -> Result<()> { - local_store::delete(key)?; - Ok(()) - } - - pub fn list_keys(prefix: &str) -> Result> { - Ok(local_store::list_keys(prefix)?) - } -} -``` - -Serialisation uses **postcard** (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead. - -## Signer - -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: - -```rust -use nexum_sdk::prelude::*; - -// Get available signing accounts -let accounts = Signer::accounts()?; -for account in &accounts { - info!("available signer: 0x{}", hex::encode(account)); -} - -// Sign raw bytes with a specific account -let signature = Signer::sign(&accounts[0], &data_to_sign)?; -// signature is 65 bytes: r (32) || s (32) || v (1) - -// Sign EIP-712 typed data -let typed_data_json = r#"{"types":...,"primaryType":"Order","domain":...,"message":...}"#; -let signature = Signer::sign_typed_data(&accounts[0], typed_data_json)?; -``` - -Implementation: - -```rust -/// Typed client for the identity WIT interface. -/// -/// Provides cryptographic signing operations backed by the host engine's -/// key management. The host manages private keys -- modules never see them. -pub struct Signer; - -impl Signer { - /// Get available signing accounts (20-byte Ethereum addresses). - pub fn accounts() -> Result>> { - identity::accounts().map_err(Fault::from) - } - - /// Get available signing accounts as alloy `Address` types. - pub fn addresses() -> Result> { - let accounts = Self::accounts()?; - accounts - .into_iter() - .map(|a| { - Address::try_from(a.as_slice()) - .map_err(|_| Fault::InvalidInput("invalid address length".into())) - }) - .collect() - } - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign(account: &[u8], data: &[u8]) -> Result> { - identity::sign(account, data).map_err(Fault::from) - } - - /// Sign EIP-712 typed data with the specified account. - /// `typed_data` is a JSON string conforming to the EIP-712 specification. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign_typed_data(account: &[u8], typed_data: &str) -> Result> { - identity::sign_typed_data(account, typed_data).map_err(Fault::from) - } -} -``` - -Note: modules can also use `identity` indirectly through `chain`. When a module calls `chain::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `chain` implementation delegates to the `identity` backend internally. `Signer` is for modules that need direct, raw signing operations -- e.g. EIP-712 over an off-chain order payload. - -Modules can match on `Fault::Denied` to distinguish "user rejected" from a transport failure -- see the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping table. - -## Ethereum ABI Helpers & alloy Provider - -Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `chain::request` / `chain::request-batch`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): - -```rust -use nexum_sdk::prelude::*; - -// Define the contract interface -sol! { - function getTradeableOrderWithSignature( - address owner, - bytes32 ctx, - bytes32 orderHash - ) external view returns ( - bytes memory order, - bytes memory signature - ); -} - -// Named handler -- provider is injected by the macro -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API -- natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner_addr).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract_addr) - .input(getTradeableOrderWithSignatureCall { - owner: owner_addr, - ctx: ctx_bytes, - orderHash: order_hash, - }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - let order_bytes = decoded.order; - Ok(()) -} -``` - -The SDK re-exports: -- `alloy_primitives::{Address, B256, U256, Bytes}` -- core Ethereum types. -- `alloy_sol_types::sol!` -- compile-time ABI codec generation. -- `alloy_provider::Provider` -- the full alloy Provider trait. -- `alloy_rpc_types::*` -- `TransactionRequest`, `Filter`, `Block`, etc. - -These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the `chain::request` (and batched `chain::request-batch`) host functions -- see doc 07 for the full design. - -## CoW Protocol API: `Cow` - -The `cow-api` WIT interface (in `shepherd:cow`) exposes a REST passthrough to the CoW Protocol API plus a typed `submit-order` function (the two were separate interfaces, `cow` and `order`, in 0.1). The `shepherd-sdk` wraps this with a typed `Cow` client: - -```rust -use shepherd_sdk::prelude::*; - -let cow = Cow::new(42161); - -// Submit an order via the merged cow-api interface -let uid = cow.submit_order(&OrderCreation { - sell_token: sell_addr, - buy_token: buy_addr, - sell_amount: U256::from(1_000_000), - buy_amount: U256::from(950_000), - kind: OrderKind::Sell, - valid_to: (block.timestamp / 1000) + 300, // block.timestamp is ms in 0.2 - ..Default::default() -})?; - -// Get a quote -let quote = cow.get_quote(&OrderQuoteRequest { ... })?; - -// Get an order by UID -let order = cow.get_order(&uid)?; - -// Raw request for endpoints not yet wrapped -let resp = cow.raw_request("GET", "/api/v1/auction", None)?; -``` - -The `Cow` client handles JSON serialisation and routes requests through the host's `cow-api::request` (REST passthrough) and `cow-api::submit-order` (order submission) functions. - -## Logging Convenience - -Wrappers over the `logging` WIT interface (provided in `nexum-sdk`; CoW modules import them from `nexum-sdk` directly): - -```rust -// nexum_sdk::log -pub fn trace(msg: &str) { logging::log(Level::Trace, msg); } -pub fn debug(msg: &str) { logging::log(Level::Debug, msg); } -pub fn info(msg: &str) { logging::log(Level::Info, msg); } -pub fn warn(msg: &str) { logging::log(Level::Warn, msg); } -pub fn error(msg: &str) { logging::log(Level::Error, msg); } - -/// Format + log in one call -#[macro_export] -macro_rules! info { - ($($arg:tt)*) => { - $crate::log::info(&format!($($arg)*)) - }; -} -``` - -Usage: - -```rust -nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id); -``` - -## Error Handling - -In 0.2 each interface declares its own typed error, and they share one payload-bearing `Fault` vocabulary for the cross-domain cases. The SDK exposes `Fault`, the `HostFault` trait (recovers an embedded fault plus a stable snake_case label), and the richer `ChainError`. A `From for Fault` fold lets a strategy aggregating store and chain calls `?`-propagate into one `Fault`. - -```rust -pub enum Fault { - Unsupported(String), - Unavailable(String), - Denied(String), - RateLimited(RateLimit), // { retry_after_ms: Option } - Timeout, - InvalidInput(String), - Internal(String), -} - -/// Recovers the shared fault from a richer, per-interface error, plus a -/// stable snake_case label for logs and metrics. -pub trait HostFault { - fn fault(&self) -> Option<&Fault>; - fn label(&self) -> &'static str; -} - -/// The chain interface embeds `Fault` and adds a structured JSON-RPC case. -pub enum ChainError { - Fault(Fault), - Rpc(RpcError), // { code: i32, message: String, data: Option> } -} - -pub type Result = core::result::Result; -``` - -Interfaces with nothing to add report `Fault` directly (identity, local-store, remote-store, messaging, and the module exports). The module exports return `Result<(), Fault>`; module-defined failures are plain `Fault` cases, and the supervisor supplies the module name and derives its log kind from the fault label. Module authors use `?` naturally, and match on the case (or on `ChainError::Rpc` for a revert) for retry/backoff: - -```rust -// Universal module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // ChainError -> Fault via From - let decoded = MyCall::abi_decode_returns(&data) - .map_err(|e| Fault::InvalidInput(e.to_string()))?; // module-defined - TypedState::set("last", &decoded)?; // store Fault - let sig = Signer::sign(&account, &data)?; // identity Fault - Ok(()) -} - -// Inspecting the case for retry decisions -match host.request(chain_id, "eth_blockNumber", "[]") { - Ok(n) => Ok(n), - Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), - Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), - Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // structured revert - Err(e) => Err(e.into()), -} - -// CoW module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // chain error - TypedState::set("last", &num)?; // store Fault - Cow::new(block.chain_id).submit_order(&order)?; // cow-api-error - Ok(()) -} -``` - -See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to `fault` cases. - -## Testing Framework - -### Universal: `nexum-sdk` Mock Host - -The `nexum-sdk` provides a mock host so modules can be tested without a live blockchain or runtime. +└── 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, PollOutcome, decode_revert, + # RetryAction classifiers, run() (poll -> gate/journal/submit) +``` + +`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. + +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. + +### The host-trait seam + +Neither crate calls `wit_bindgen`-generated functions directly. +Instead `nexum-sdk::host` exposes small traits that mirror the WIT +interfaces: ```rust -use nexum_sdk::testing::{MockHost, MockChain}; - -#[test] -fn test_monitor_processes_block() { - let mut host = MockHost::new(); - - // Set up mock chain state - host.chain(42161) - .block_number(19_000_001) - .mock_call( - "0xfdaFc9d...", // contract address - &get_active_orders_calldata(), // expected calldata - &mock_orders_response(), // return value - ); - - // Pre-populate local-store (simulating previous run) - host.local_store() - .set("last_block", &19_000_000u64.to_le_bytes()); - - // Dispatch a block event - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, // ms since epoch - })); - - assert!(result.is_ok()); - - // Verify local-store was updated - let last = host.local_store().get::("last_block").unwrap(); - assert_eq!(last, Some(19_000_001)); +pub trait ChainHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -``` - -### Identity Mocking - -The `MockHost` supports identity mocking for modules that use signing: - -```rust -use nexum_sdk::testing::{MockHost, MockIdentity}; - -#[test] -fn test_module_signs_data() { - let mut host = MockHost::new(); - - // Configure mock identity with test accounts - host.identity() - .add_account(hex::decode("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap()) - .on_sign(|account, data| { - // Return a mock 65-byte signature - Ok(vec![0u8; 65]) - }) - .on_sign_typed_data(|account, typed_data| { - // Return a mock 65-byte signature for EIP-712 - Ok(vec![0u8; 65]) - }); - - let result = host.dispatch(Event::Block(Block { - chain_id: 1, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1700000000, - })); - - assert!(result.is_ok()); - - // Verify signing was called - assert_eq!(host.identity().sign_calls().len(), 1); +pub trait LocalStoreHost { + fn get(&self, key: &str) -> Result>, Fault>; + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; + fn delete(&self, key: &str) -> Result<(), Fault>; + fn list_keys(&self, prefix: &str) -> Result, Fault>; } -``` - -### CoW Protocol: `shepherd-sdk` Mock Extensions - -The `shepherd-sdk` extends `MockHost` with CoW-specific assertions: - -```rust -use shepherd_sdk::testing::{MockHost, MockCow}; - -#[test] -fn test_twap_monitor_submits_order() { - let mut host = MockHost::new(); - - host.chain(42161).block_number(19_000_001); - - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - - assert!(result.is_ok()); - - // Verify an order was submitted (CoW-specific) - assert_eq!(host.submitted_orders().len(), 1); +pub trait LoggingHost { + fn log(&self, level: Level, message: &str); } -``` - -### MockHost Internals +pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} +impl Host for T {} +``` + +`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`), 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)). + +### 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 +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 +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. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` +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_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 generates the +`wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` +invocation, a `Guest` implementation whose `on_event` dispatches to +whichever handlers are present (absent handlers become a no-op for +that event), and `export!`: ```rust -// nexum-sdk: universal mock host -pub struct MockHost { - local_store: HashMap>, - chains: HashMap, - identity: MockIdentity, - logs: Vec<(Level, String)>, -} - -pub struct MockChain { - block_number: u64, - /// Maps (method, params_json) -> result_json for RPC mocking - rpc_mocks: HashMap<(String, String), String>, - logs: Vec, -} - -pub struct MockIdentity { - accounts: Vec>, - sign_handler: Option Result>>>, - sign_typed_data_handler: Option Result>>>, - sign_calls: Vec<(Vec, Vec)>, - sign_typed_data_calls: Vec<(Vec, String)>, -} - -// shepherd-sdk: extends with CoW-specific fields -pub struct CowMockHost { - inner: MockHost, // universal mock - submitted_orders: Vec<(u64, Vec)>, - cow_requests: Vec<(u64, String, String, Option)>, -} -``` +// modules/examples/http-probe/src/lib.rs (shipped) +mod strategy; -The mock host implements the same trait interface as the real host. Tests run as native Rust (not compiled to WASM) -- the mock substitutes for the WIT imports. +use nexum::host::types; -For alloy `Provider`-based tests, the `nexum-sdk` also provides `MockProvider` (backed by alloy's `Asserter`-based mock transport) -- see doc 07 for details. +struct HttpProbe; -### Integration Testing - -For tests that compile to WASM and run in a real wasmtime instance: - -```rust -use nexum_sdk::testing::WasmTestHarness; - -#[test] -fn test_module_as_component() { - let harness = WasmTestHarness::new("target/wasm32-wasip2/release/twap_monitor.wasm"); - - harness.mock_chain(42161).block_number(100); - harness.call_init(vec![("api_url".into(), "mock".into())]).unwrap(); - - let result = harness.call_on_event(Event::Block(Block { - chain_id: 42161, - number: 100, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - assert!(result.is_ok()); -} -``` - -This tests the full component boundary (canonical ABI marshalling, host function binding). - -## Project Scaffolding - -### `cargo-nexum` CLI - -> **Future direction, not in 0.2 scope.** The `cargo-nexum` cargo subcommand described in this section does not ship in 0.2. Module authors today build with `cargo build --target wasm32-wasip2 --release` (the M5 reference repo includes a `justfile` with the canonical recipes). A `cargo-nexum` (or successor) scaffolding/packaging CLI is on the 0.3 roadmap. -> -> **Two separate tools (design intent):** `cargo-nexum` would be a cargo subcommand for **module authors** (new, build, package, publish). The `nexum` binary is the **operator runtime** (run, module list/restart, local-store purge). Embedders bypass the binary entirely and drive the runtime through the `nexum-runtime` library. - -```bash -cargo nexum new my-module -``` - -Generates: - -``` -my-module/ -├── Cargo.toml -├── module.toml # manifest template -└── src/ - └── lib.rs # minimal module skeleton -``` - -#### Universal module (targeting `nexum:host/event-module`) - -`Cargo.toml`: -```toml -[package] -name = "my-module" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + // ... Ok(()) } - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) + fn on_block(block: types::Block) -> Result<(), Fault> { + strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + .map_err(sdk_fault_into_wit) } } ``` -#### CoW Protocol module (targeting `shepherd:cow/shepherd`) - -`Cargo.toml`: -```toml -[package] -name = "my-cow-module" -version = "0.1.0" -edition = "2024" +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 currently always generates against the blanket + `shepherd:cow/shepherd` world with `generate_all` (every module gets + the full CoW-extended import set whether it uses `cow-api` or not). + Emitting a per-component world scoped to the manifest's declared + capabilities - which would retire the import-elision dependency + ADR-0009 flags - is separate follow-on work, tracked alongside the + venue-adapter macro below. +- **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 + 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). + +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. + +### Testing: `nexum-sdk-test` and `shepherd-sdk-test` -[lib] -crate-type = ["cdylib"] - -[dependencies] -shepherd-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: ```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct MyCowModule; - -impl MyCowModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); - Ok(()) - } - - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` - -`module.toml` (same for both): -```toml -[module] -name = "my-module" -version = "0.1.0" -description = "" -authors = [] -component = "sha256:TODO" - -[module.resources] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -max_state_bytes = 52_428_800 - -[module.restart] -max_consecutive_failures = 10 - -[chains] -required = [] -optional = [] - -[capabilities] -required = ["chain", "local-store", "logging"] -optional = [] - -[config] -``` - -### Build - -```bash -cargo component build --release -# -> target/wasm32-wasip2/release/my_module.wasm -``` - -### Package - -```bash -cargo nexum package -# Computes sha256, updates module.toml, creates bundle directory -``` - -### Publish to Swarm - -```bash -cargo nexum publish --swarm http://localhost:1633 --batch-id -# Uploads bundle to Swarm, prints content reference -``` - -## SDK / Runtime Version Compatibility - -The WIT definition is versioned (`nexum:host@0.2.0`). The SDK pins this version. When the WIT evolves: - -- **Patch** (0.2.x): backwards-compatible additions (new host functions, new manifest fields, new SDK helpers). Old modules continue to work. -- **Minor** (0.x.0): may add new required exports. Old modules need recompilation. -- **Major** (x.0.0): breaking changes. Runtime supports multiple world versions during transition. - -The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.2.1` satisfies a guest compiled against `@0.2.0`. - -0.2 is the coordinated breaking-change window relative to 0.1. The 0.2.0 contracts (WIT package name, interface names, the per-interface typed errors over the shared `fault` vocabulary, the `module.toml` schema, the `#[nexum::module]` macro surface) are stable starting at 0.2.0 -- see the [migration guide §10](migration/0.1-to-0.2.md#10-deprecation-policy-going-forward-both) for the full deprecation policy. - -## Summary - -| SDK Layer | Provides | -|-----------|----------| -| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`); `async fn` + provider injection (universal) | -| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd` world | -| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` (including 0.2's `chain::request-batch` for real wire-level batching) | -| `Signer` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | -| `Cow` | Typed CoW Protocol API client backed by host `cow-api` interface (shepherd-sdk only) | -| `nexum_sdk::prelude::*` | Universal types, interfaces, alloy re-exports in one import | -| `shepherd_sdk::prelude::*` | CoW-specific types and interfaces; the universal surface comes from `nexum_sdk::prelude::*` | -| `TypedState` | Serde-based typed local-store over raw bytes | -| `sol!` | Compile-time Ethereum ABI codec (alloy-sol-types) | -| `log::{info!, ...}` | Formatted logging macros | -| `Fault` / `HostFault` / `ChainError` / `Result` | Per-interface typed errors over the shared `fault` vocabulary, with `?` support and case-based matching | -| `nexum_sdk::testing::MockHost` | Native-Rust unit tests with universal mock host (includes identity mocking) | -| `shepherd_sdk::testing::MockHost` | Extends universal mock with CoW-specific assertions | -| `testing::MockProvider` | alloy `Provider` mock for RPC-level testing | -| `testing::WasmTestHarness` | Integration tests against real wasmtime | -| `cargo nexum` | new / build / package / publish / check / migrate CLI | +use nexum_sdk::host::*; +use nexum_sdk_test::MockHost; + +let host = MockHost::new(); +host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + +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 testing +story today; there is no `WasmTestHarness` or component-level +integration harness in the tree. + +## 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: + +- **`nexum-venue-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 `nexum-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). +- **`nexum-venue-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. + +## 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. + +## Where to go next + +- [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry + point for module authors. +- [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. +- [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - + the 0.1 -> 0.2 SDK rename table. +- [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. From c6f36e1b897903b636b6deff18f00f42c3a5af31 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:10:10 +0000 Subject: [PATCH 07/29] fix(sdk): M1 SDK sweep fixes - macro hardening, doc fidelity (#246) * fix(macros): reject typo'd handlers and malformed impls at expansion A method named on_blocks (or any other on_-prefixed typo) previously compiled as an ordinary helper while its event silently no-opped; the only signal was a dead_code warning that vanishes on pub items. Reserve the on_ prefix for the recognised handler set and error on anything else. Also reject trait impls, generic impls, and impls with no recognised handlers - all previously slipped past the self-type guard and failed later with incidental, unhygienic errors (or compiled into a do-nothing module). Document the wit-bindgen direct-dependency and prelude- shadowing invariants inherited from the generated glue. * docs(migration): rewrite SDK section 7 around the shipped surface Section 7 still described the superseded Signer/Messaging/RemoteStore/ provider() vision - APIs that never shipped - and a #[shepherd::module] macro that does not exist, while doc 05 endorsed it as the authoritative 0.1 -> 0.2 SDK table. Replace it with the real story: 0.1 had no SDK crate, 0.2 ships nexum-sdk/shepherd-sdk host traits, typed errors, and the single #[nexum_sdk::module] attribute. Point doc 05's cross-link text at what the section now says, and fix doc 05 details flagged by the sweep: the CowApiHost parenthetical (submit_order, cow_api_request), the crate trees (proptests.rs in both SDK crates), the testing-story claim (nexum-runtime's feature-gated test_utils::TestRuntime is an in-tree component-level harness), and the first #[nexum::module] mention now names the real nexum_sdk path. * docs(sdk): document the #[nexum_sdk::module] attribute in sdk.md sdk.md is doc 05's designated day-to-day reference but still presented the pre-macro bind_host_via_wit_bindgen! flow as the authoring pattern. Add an authoring section covering the attribute and its handler set, mention it in the intro, and include nexum-macros in the rustdoc command. --- crates/nexum-macros/src/lib.rs | 58 ++++++++++++++++++++++++++++++++-- docs/05-sdk-design.md | 28 ++++++++++------ docs/migration/0.1-to-0.2.md | 46 +++++++++++++++++---------- docs/sdk.md | 25 ++++++++++++--- 4 files changed, 122 insertions(+), 35 deletions(-) diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index e5dca6f7..93d0e9a3 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -16,8 +16,10 @@ use quote::quote; 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; any handler in the set that is -/// absent is treated as a no-op in the generated `on-event` dispatch. +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on_message"]; /// Generate the per-cdylib glue for a nexum module. @@ -34,7 +36,12 @@ const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on /// (`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 directory is located by walking up from -/// `CARGO_MANIFEST_DIR`. +/// `CARGO_MANIFEST_DIR`. 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 +/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` +/// trait refers to them unqualified). #[proc_macro_attribute] pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { @@ -57,6 +64,42 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .to_compile_error() .into(); } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_sdk::module] 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, + "#[nexum_sdk::module] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise + // compile as an ordinary helper while its event silently no-ops, so + // reserve the `on_` prefix for the recognised handler set. + 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 syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + ) + .to_compile_error() + .into(); + } + } + } let present: Vec<&str> = input .items @@ -69,6 +112,15 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { _ => None, }) .collect(); + if present.is_empty() { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`", + ) + .to_compile_error() + .into(); + } let has = |name: &str| present.contains(&name); let (nexum_wit, shepherd_wit) = match locate_wit() { diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index b4b784e6..96699f85 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -23,8 +23,9 @@ things from the SDK: `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) and, for CoW-specific - modules, `shepherd-sdk` on top. + `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 @@ -61,7 +62,8 @@ nexum-sdk/ ├── 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 + ├── 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/ ├── Cargo.toml # proc-macro = true @@ -74,8 +76,9 @@ shepherd-sdk/ ├── 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, PollOutcome, decode_revert, - # RetryAction classifiers, run() (poll -> gate/journal/submit) + ├── cow/ # CowApiHost trait, gpv2_to_order_data, PollOutcome, decode_revert, + │ # RetryAction classifiers, run() (poll -> gate/journal/submit) + └── proptests.rs # cfg(test) property tests (not part of the public surface) ``` `nexum-sdk` is host-neutral and domain-free: any module targeting the @@ -116,7 +119,8 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`), and +`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 @@ -223,9 +227,13 @@ in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on 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 testing -story today; there is no `WasmTestHarness` or component-level -integration harness in the tree. +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) @@ -287,7 +295,7 @@ of it, not a requirement. error model (`Fault`, `ChainError`, `CowApiError`) the host traits return. - [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - - the 0.1 -> 0.2 SDK rename table. + 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/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md index 0bbcb844..6daafe1e 100644 --- a/docs/migration/0.1-to-0.2.md +++ b/docs/migration/0.1-to-0.2.md @@ -404,23 +404,35 @@ The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` e ### Rust SDK -```diff -- use nexum_sdk::{provider, Identity, MsgClient, RemoteStore}; -+ use nexum_sdk::{provider, Signer, Messaging, RemoteStore}; -``` - -| 0.1 type | 0.2 type | Notes | -|---|---|---| -| `IdentityClient` | `Signer` | Trait renamed to reflect what it does | -| `MsgClient` | `Messaging` | Drops the meaningless `Client` suffix | -| `CowClient` | `Cow` | Same | -| `HostTransport` | (internal) | Now `pub(crate)`; you access it through `provider()` | -| `block_on` (re-export) | (removed from public API) | Hidden behind the `#[nexum::module]` macro | -| `Error` (multiple variants per domain) | `Fault` + `HostFault` / `ChainError` | Per-interface typed errors over the shared vocabulary; see §2 | - -### Proc macro - -`#[nexum::module]` and `#[shepherd::module]` are unchanged in shape. They now generate against `event-module` / `shepherd` worlds. If you targeted `headless-module` explicitly anywhere, rename to `event-module`. +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 diff --git a/docs/sdk.md b/docs/sdk.md index e752c5fa..1af60111 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -2,19 +2,34 @@ `nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the -per-module adapter macro, and a `prelude` that keeps boilerplate out -of module crates. `shepherd-sdk` layers the CoW Protocol surface on -top; modules that touch the orderbook depend on both crates and -import each directly (nothing is re-exported between them). +`#[nexum_sdk::module]` attribute macro and per-module adapter macro, +and a `prelude` that keeps boilerplate out of module crates. +`shepherd-sdk` layers the CoW Protocol surface on top; modules that +touch the orderbook depend on both crates and import each directly +(nothing is re-exported between them). This page is the entry point. The full API reference is the rustdoc 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 --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-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` +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>`. +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 +example and the `nexum-macros` rustdoc for the fine print. + ## Supported host capabilities The SDK is host-neutral - it does not call wit-bindgen-generated From ae8b46918ecaa6580202d9768c4aa7f788031805 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:14:39 +0000 Subject: [PATCH 08/29] feat(wit): add nexum:value-flow types package v0.1.0 (#247) feat(wit): author the nexum:value-flow types package Add the egress-neutral value-flow vocabulary at 0.1.0: the settlement domain (evm-chain or offchain), the asset variant (native-token, erc20, erc721, erc1155, service, offchain), the service and offchain descriptors, and the big-endian asset-amount. The package is dependency-free so it can outlive any interface built on it. Every identifier is vetted against WIT keywords, in-flight component-model proposals, and the nine binding-target reserved-word lists, preferring a two-word kebab id wherever a single word is a keyword anywhere: native-token over native (a Java modifier), offchain over external (a Dart keyword), value-flow over value (value-imports is circling that word). A wasmtime bindgen smoke, compiled under test in the host crate through a throwaway world, names every generated type and field by its plain Rust spelling so a keyword collision surfaces as an escaped identifier and fails the build rather than a downstream binding. --- crates/nexum-runtime/src/bindings.rs | 51 ++++++++++++++++ wit/nexum-value-flow/types.wit | 87 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 wit/nexum-value-flow/types.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index a48bebca..008ed56b 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -15,3 +15,54 @@ wasmtime::component::bindgen!({ imports: { default: async }, exports: { default: async }, }); + +/// 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. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package nexum:value-flow-smoke; + world smoke { + import nexum:value-flow/types@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; + + 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 _ = 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 amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit new file mode 100644 index 00000000..e70b4f24 --- /dev/null +++ b/wit/nexum-value-flow/types.wit @@ -0,0 +1,87 @@ +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, ...). + 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. + record asset-amount { + asset: asset, + amount: list, + } +} From 5b0c2486532991cb476d48fdec56d1ba83b711b0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:21:08 +0000 Subject: [PATCH 09/29] feat(wit): author the nexum:intent package with pool interface (#248) feat(wit): author the nexum:intent package with the pool interface Add the venue-neutral intent ontology at 0.1.0 over the value-flow vocabulary: the intent-header (gives/wants/valid-until/settlement/ authorisation), the auth-scheme variant (eip712, eip1271, presign, offchain-sig, unsigned), the intent-status lifecycle with a structured fail-reason, the opaque receipt alias, and a self-contained venue-error (deliberately not embedding the host fault type, so the package's freeze cadence is not pinned to nexum:host versioning). The pool interface routes submit/status/cancel by a venue string with the body as opaque bytes. submit returns a submit-outcome variant from day one: accepted(receipt), or requires-signing(unsigned-tx) for venues whose settlement is a host-signed on-chain call. The unsigned-tx record only describes the call (chain, to, value, input); the host routes it through the guard's host-signed class and fills gas and fees, so adapters still structurally cannot move value. Identifier hygiene follows the value-flow rule (internal-error rather than internal, a Swift declaration keyword). A wasmtime bindgen smoke, compiled under test in the host crate through a throwaway world that imports the pool interface, names every generated type, case, and field by its plain Rust spelling and pins the three pool function signatures with a dummy host impl. --- crates/nexum-runtime/src/bindings.rs | 98 ++++++++++++++++++++ wit/nexum-intent/pool.wit | 26 ++++++ wit/nexum-intent/types.wit | 132 +++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 wit/nexum-intent/pool.wit create mode 100644 wit/nexum-intent/types.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 008ed56b..23d8776b 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -66,3 +66,101 @@ mod value_flow_smoke { assert!(amount.amount.is_empty()); } } + +/// 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 +/// 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 { + wasmtime::component::bindgen!({ + inline: " + package nexum:intent-smoke; + world smoke { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + }); + + use nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, + }; + use nexum::value_flow::types::Settlement; + + struct DummyPool; + + impl nexum::intent::pool::Host for DummyPool { + 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) + } + } + + #[test] + fn identifiers_bind_unescaped() { + use nexum::intent::pool::Host; + + let _ = AuthScheme::Eip712; + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Presign; + let _ = AuthScheme::OffchainSig; + let _ = AuthScheme::Unsigned; + + let header = IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }; + assert!(header.gives.is_empty() && header.wants.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::Cancelled; + + let tx = UnsignedTx { + chain_id: 1, + to: Vec::new(), + value: Vec::new(), + input: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::InvalidReceipt; + let _ = VenueError::Rejected(String::new()); + let _ = VenueError::Denied(String::new()); + let _ = VenueError::Unsupported(String::new()); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::InternalError(String::new()); + + 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()); + } +} diff --git a/wit/nexum-intent/pool.wit b/wit/nexum-intent/pool.wit new file mode 100644 index 00000000..7655292a --- /dev/null +++ b/wit/nexum-intent/pool.wit @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000..22472e7b --- /dev/null +++ b/wit/nexum-intent/types.wit @@ -0,0 +1,132 @@ +package nexum:intent@0.1.0; + +/// The venue-neutral intent ontology: what a deal gives and wants, how the +/// venue authorizes 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 authorized 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 authorization recorded at the settlement contract + /// ahead of submission. + presign, + /// A venue-defined off-chain signature scheme. + offchain-sig, + /// No authorization 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 authorizes 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), + } +} From 39e117d861424326e816afb5b70b602514bd7bb6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:29:29 +0000 Subject: [PATCH 10/29] feat(runtime): introduce the venue-adapter component kind (#249) * feat(runtime): introduce the venue-adapter component kind Add the second component kind alongside the event-module. A venue adapter speaks one venue's protocol over scoped transport only and exports the intent adapter face. - WIT: a nexum:intent/adapter interface (derive-header, submit, status, cancel) and a nexum:adapter/venue-adapter world that imports scoped chain and messaging, exports init plus the adapter face, and withholds the core-only primitives. - Bindings: a second bindgen path binding VenueAdapter beside EventModule, reusing the shared nexum:host interfaces via with. - Manifest: a module-kind discriminator defaulting to event-module, plus an adapter capability registry that recognises only the scoped transport. - Engine config: an [[adapters]] table carrying path, manifest, a per-adapter HTTP allowlist, and messaging-topic scopes. - Supervisor: adapters instantiate into supervised stores against a dedicated scoped linker, reusing the store, fuel, and restart machinery; the kind discriminator gates the load path. No routing yet. - Messaging: per-store content-topic scope enforced ahead of the deferred backend. * docs(runtime): keep the venue-adapter world token on one line The bindgen module rustdoc split the `nexum:adapter/venue-adapter` code span across a line break after the slash, so rustdoc rendered it with a stray space. Rewrap so the token stays intact. --- crates/nexum-runtime/src/bindings.rs | 30 ++ crates/nexum-runtime/src/builder.rs | 7 +- crates/nexum-runtime/src/engine_config.rs | 73 ++++ .../nexum-runtime/src/host/impls/messaging.rs | 72 +++- crates/nexum-runtime/src/host/state.rs | 6 + .../src/manifest/capabilities.rs | 67 ++++ crates/nexum-runtime/src/manifest/load.rs | 44 +++ crates/nexum-runtime/src/manifest/mod.rs | 2 +- crates/nexum-runtime/src/manifest/types.rs | 23 +- crates/nexum-runtime/src/supervisor.rs | 320 ++++++++++++++++-- crates/nexum-runtime/src/supervisor/tests.rs | 101 ++++++ wit/nexum-adapter/venue-adapter.wit | 30 ++ wit/nexum-intent/adapter.wit | 31 ++ 13 files changed, 774 insertions(+), 32 deletions(-) create mode 100644 wit/nexum-adapter/venue-adapter.wit create mode 100644 wit/nexum-intent/adapter.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 23d8776b..c067249f 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -16,6 +16,36 @@ wasmtime::component::bindgen!({ exports: { default: async }, }); +/// WIT bindings for the second component kind: the +/// `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` +/// 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 intent and +/// value-flow types generate here, their first non-test binding. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/nexum-host", + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/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, + }, + }); +} + +pub use venue_adapter::VenueAdapter; + /// 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 diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 86457759..aaad84e0 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -156,7 +156,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { clocks, ) .await? - } else if !engine_cfg.modules.is_empty() { + } else if !engine_cfg.modules.is_empty() || !engine_cfg.adapters.is_empty() { Supervisor::boot( &engine, &linker, @@ -168,13 +168,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { .await? } else { anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] entries \ - in engine.toml" + "no modules to run - set a module source or declare [[modules]] or \ + [[adapters]] entries in engine.toml" ); }; info!( modules = supervisor.module_count(), + adapters = supervisor.adapter_count(), chains = supervisor.block_chains().len(), "supervisor ready" ); diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index b93a0096..cac75d82 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -82,6 +82,14 @@ 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 + /// reach. + #[serde(default)] + pub adapters: Vec, } /// One `[[modules]]` table from `engine.toml`. @@ -98,6 +106,33 @@ pub struct ModuleEntry { pub manifest: Option, } +/// One `[[adapters]]` table from `engine.toml`. +/// +/// `path` and `manifest` mirror [`ModuleEntry`]; `manifest` defaults to a +/// sibling `module.toml`. The two scope fields are the operator's grant of +/// the adapter's transport: `http_allow` is the outbound HTTP host +/// allowlist the adapter's wasi:http gate enforces, and `messaging_topics` +/// scopes the messaging content topics it may publish to. Both default +/// empty; an empty `http_allow` denies every outbound request, and an +/// empty `messaging_topics` leaves messaging unscoped for parity with the +/// module default (the messaging backend itself is deferred). +#[derive(Debug, Deserialize)] +pub struct AdapterEntry { + /// Path to the compiled `.wasm` adapter component. + pub path: std::path::PathBuf, + /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. + #[serde(default)] + pub manifest: Option, + /// Outbound HTTP host allowlist granted to this adapter. Each entry is + /// either an exact hostname or a `*.suffix` wildcard, matched the same + /// way as a module's `[capabilities.http].allow`. + #[serde(default)] + pub http_allow: Vec, + /// Messaging content topics this adapter may reach. + #[serde(default)] + pub messaging_topics: Vec, +} + #[derive(Debug, Deserialize)] pub struct EngineSection { #[serde(default = "default_state_dir")] @@ -795,6 +830,44 @@ window_secs = 0 assert_eq!(poison.window, Duration::from_secs(1)); } + #[test] + fn adapters_parse_with_scoped_transport_grants() { + 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"] + +[[adapters]] +path = "adapters/bare/bare.wasm" +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!(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"]); + let second = &cfg.adapters[1]; + assert_eq!( + second.manifest.as_deref(), + Some(Path::new("adapters/bare/module.toml")) + ); + assert!( + second.http_allow.is_empty() && second.messaging_topics.is_empty(), + "unset scope grants default empty", + ); + } + + #[test] + fn adapters_default_empty_when_absent() { + let cfg = EngineConfig::default(); + assert!(cfg.adapters.is_empty()); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index f2ff87c2..bd450cb3 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,13 +1,43 @@ -//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` -//! returns an empty result, same posture as `identity::accounts`. +//! `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 +//! `[[adapters]].messaging_topics` grant may only publish within it, so +//! the egress boundary is live even though delivery is not. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; +/// Whether `topic` falls within `scope`. An empty scope is unscoped and +/// admits every topic (the module default); otherwise a topic is admitted +/// 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/...`). +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 nexum::host::messaging::Host for HostState { - async fn publish(&mut self, _content_topic: String, _payload: Vec) -> Result<(), Fault> { + async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), Fault> { + if !topic_in_scope(&content_topic, &self.messaging_topics) { + return Err(Fault::Denied(format!( + "content topic {content_topic:?} outside this component's messaging scope" + ))); + } Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) } @@ -21,3 +51,39 @@ impl nexum::host::messaging::Host for HostState { Ok(vec![]) } } + +#[cfg(test)] +mod tests { + use super::topic_in_scope; + + #[test] + fn empty_scope_admits_everything() { + assert!(topic_in_scope("/nexum/1/anything/proto", &[])); + } + + #[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)); + 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/twap/proto", &scope)); + // A sibling namespace stays out. + assert!(!topic_in_scope("/nexum/2/cow-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)); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 32723fb8..5dfbadb5 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -27,6 +27,12 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. 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 + /// backend. + pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart /// sequence. Tags every captured log record. The namespace identity /// for storage is baked into `store`'s prefix. diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 6c7126e8..05d6b79a 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -28,6 +28,22 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_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 +/// gates `wasi:http/*` and is handled by the registry directly. +pub const ADAPTER_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 +/// 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 { + prefix: "nexum:host/", + ifaces: ADAPTER_CAPABILITIES, +}; + /// Import prefix of the wasi:http package. Every interface under it /// (outgoing-handler, types, ...) is gated by the single /// [`HTTP_CAPABILITY`] declaration. @@ -58,6 +74,17 @@ impl CapabilityRegistry { } } + /// The registry a venue adapter validates against: only the scoped + /// transport interfaces plus `http`. An adapter 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 + /// component cannot instantiate against them either. + pub fn adapter() -> Self { + Self { + namespaces: vec![ADAPTER_NAMESPACE], + } + } + /// Add an extension's namespace. pub fn register(&mut self, ns: NamespaceCaps) { self.namespaces.push(ns); @@ -313,4 +340,44 @@ mod tests { let r = registry_with_cow(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } + + #[test] + fn adapter_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 + // declaring them fails validation as unknown. + let r = CapabilityRegistry::adapter(); + assert!(r.is_known("chain")); + assert!(r.is_known("messaging")); + assert!(r.is_known("http")); + assert!(!r.is_known("local-store")); + assert!(!r.is_known("remote-store")); + assert!(!r.is_known("identity")); + assert!(!r.is_known("logging")); + } + + #[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/messaging@0.2.0"), + Some("messaging") + ); + assert_eq!( + r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), + 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); + } + + #[test] + fn adapter_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(); + 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 e0b5efb6..81368568 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -258,6 +258,50 @@ enabled = true assert_eq!(config.get("enabled").map(String::as_str), Some("true")); } + #[test] + fn module_kind_defaults_to_event_module() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "plain" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::EventModule); + } + + #[test] + fn module_kind_parses_venue_adapter() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "cow" +kind = "venue-adapter" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + } + + #[test] + fn module_kind_rejects_unknown_variant() { + let err = 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}", + ); + } + #[test] fn host_allowed_exact_and_wildcard() { let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 19d283a4..e4c032f1 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, Subscription}; +pub(crate) use types::{LoadedManifest, ModuleKind, 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 628641d2..4802e0d2 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -73,7 +73,7 @@ pub enum Subscription { /// durable per-subscription cursor and re-opens the log poller /// from just after the last dispatched block, instead of at the /// current head. Delivery is then at-least-once, so the module must - /// tolerate redelivery (the chassis idempotency journal already + /// tolerate redelivery (the keeper idempotency journal already /// dedups it). #[serde(default)] resume: bool, @@ -104,6 +104,27 @@ 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. + #[serde(default)] + pub kind: ModuleKind, +} + +/// 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. + #[default] + EventModule, + /// A single-venue adapter over scoped chain, messaging, and HTTP. + VenueAdapter, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 0b428d6b..79a93968 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -37,8 +37,10 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, nexum}; -use crate::engine_config::{EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits}; +use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +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::http::HttpGate; @@ -48,13 +50,20 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; +use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ModuleKind, Subscription}; /// Owns every loaded module and exposes the dispatch surface the /// event loop needs. Generic over the [`RuntimeTypes`] lattice binding /// the component seam backends. pub struct Supervisor { modules: Vec>, + /// Venue adapters instantiated into supervised stores. They boot + /// through the same store, fuel, and memory machinery as modules but + /// carry no subscriptions: nothing dispatches to them yet. A later + /// change wires the intent router - which reaches an adapter through + /// the same extension seam the `extension.rs` router note describes - + /// and folds them into the restart and poison sweeps. + adapters: Vec>, /// 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 @@ -202,6 +211,47 @@ struct LoadedModule { poisoned: bool, } +/// A venue adapter instantiated into a supervised store. It mirrors +/// [`LoadedModule`] so the intent router a later change adds can drive +/// adapters through the same restart, poison, and fuel machinery, but +/// carries no subscriptions: nothing dispatches to an adapter yet. The +/// cached boot inputs (`component`, `init_config`, the scope grants, the +/// resource knobs) are what a re-instantiation needs; they are held now +/// and read once dispatch exists. +#[allow(dead_code)] +struct LoadedAdapter { + name: String, + bindings: VenueAdapter, + store: HostStore, + /// The run this store instantiates; restarts mint a fresh one. + run: RunId, + /// Fuel budget refilled before each adapter call. + fuel_per_event: u64, + /// Memory cap applied to the store on reinstantiation. + memory_limit: usize, + /// Cached for restart: re-instantiating from the compiled component. + component: Component, + /// Cached for restart: the `[config]` passed to the adapter's `init`. + init_config: Config, + /// Operator-granted outbound HTTP allowlist for this adapter. + http_allowlist: Vec, + /// Operator-granted outbound HTTP limits. + http_limits: OutboundHttpLimits, + /// Operator-granted messaging content-topic scopes. + messaging_topics: Vec, + /// `false` once an adapter call traps; folded into the restart sweep + /// when the router lands. `init` failure leaves it `false` at boot. + alive: bool, + /// Consecutive trap-style failures since the last success. + failure_count: u32, + /// Earliest instant a trapped adapter may be retried. + next_attempt: Option, + /// Sliding-window trap timestamps for the poison-pill check. + failure_timestamps: std::collections::VecDeque, + /// Permanent quarantine flag, as for modules. + poisoned: bool, +} + impl Supervisor { /// Compile + instantiate every module declared in /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are @@ -230,10 +280,37 @@ impl Supervisor { .with_context(|| format!("load module {}", entry.path.display()))?; modules.push(loaded); } + // Adapters link only their scoped transport, so they instantiate + // against a dedicated linker built from the same core backends. + let adapter_linker = build_adapter_linker::(engine)?; + let adapter_registry = CapabilityRegistry::adapter(); + let mut adapters = Vec::with_capacity(engine_cfg.adapters.len()); + for entry in &engine_cfg.adapters { + let loaded = Self::load_adapter( + engine, + &adapter_linker, + entry, + components, + &engine_cfg.limits, + &adapter_registry, + clocks.as_ref(), + ) + .await + .with_context(|| format!("load adapter {}", entry.path.display()))?; + adapters.push(loaded); + } let alive = modules.iter().filter(|m| m.alive).count(); - info!(loaded = modules.len(), alive, "supervisor up"); + let adapters_alive = adapters.iter().filter(|a| a.alive).count(); + info!( + loaded = modules.len(), + alive, + adapters = adapters.len(), + adapters_alive, + "supervisor up" + ); Ok(Self { modules, + adapters, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -276,6 +353,9 @@ impl Supervisor { .await?; Ok(Self { modules: vec![loaded], + // The single-module override path serves `just run`; adapters + // are configured through `engine.toml` and boot via `boot`. + adapters: Vec::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -297,6 +377,7 @@ impl Supervisor { run: RunId, http_allowlist: Vec, http_limits: OutboundHttpLimits, + messaging_topics: Vec, memory_limit: usize, fuel: u64, clocks: Option<&WasiClockOverride>, @@ -347,6 +428,7 @@ impl Supervisor { limits, http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(), http_gate: HttpGate::new(namespace, http_allowlist, http_limits), + messaging_topics, run, log_router: router, ext: components.ext.clone(), @@ -368,26 +450,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, ) -> Result> { - // Canonical name is module.toml (ADR-0001). nexum.toml is accepted - // with a deprecation warning during the 0.1→0.2 transition. - let manifest_path = entry.manifest.clone().or_else(|| { - let dir = entry.path.parent()?.to_owned(); - let canonical = dir.join("module.toml"); - if canonical.exists() { - return Some(canonical); - } - let legacy = dir.join("nexum.toml"); - if legacy.exists() { - warn!( - target: "manifest", - path = %legacy.display(), - "nexum.toml is deprecated; rename to module.toml \ - (ADR-0001). Support will be removed in 0.3." - ); - return Some(legacy); - } - None - }); + 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 module manifest"); @@ -434,6 +497,9 @@ 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. + Vec::new(), limits_cfg.memory(), limits_cfg.fuel(), clocks, @@ -513,11 +579,162 @@ 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 router can later reach it. + async fn load_adapter( + engine: &Engine, + linker: &Linker>, + entry: &AdapterEntry, + components: &Components, + limits_cfg: &ModuleLimits, + registry: &CapabilityRegistry, + clocks: Option<&WasiClockOverride>, + ) -> 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"); + manifest::load(p, registry)? + } + _ => { + warn!( + component = %entry.path.display(), + "no module.toml - falling back to anonymous adapter" + ); + 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(), + )); + } + + info!(component = %entry.path.display(), "compiling adapter 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 + // 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. + manifest::enforce_capabilities( + &loaded_manifest, + component.component_type().imports(engine).map(|(n, _)| n), + registry, + ) + .with_context(|| format!("capability violation in {}", entry.path.display()))?; + + let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "adapter".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + info!( + adapter = %adapter_namespace, + 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", + ); + + let run = RunId::new(adapter_namespace.clone(), 0); + let mut store = Self::build_store( + engine, + components, + run.clone(), + entry.http_allow.clone(), + limits_cfg.http(), + entry.messaging_topics.clone(), + limits_cfg.memory(), + limits_cfg.fuel(), + clocks, + )?; + 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())] + } 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 { + name: adapter_namespace, + bindings, + store, + run, + fuel_per_event: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + component, + init_config: config, + http_allowlist: entry.http_allow.clone(), + http_limits: limits_cfg.http(), + messaging_topics: entry.messaging_topics.clone(), + alive: init_succeeded, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, + }) + } + /// Number of modules currently loaded. pub fn module_count(&self) -> usize { self.modules.len() } + /// Number of venue adapters instantiated under the supervisor. + pub fn adapter_count(&self) -> usize { + self.adapters.len() + } + + /// Number of adapters whose `init` succeeded and that are eligible for + /// routing once dispatch lands. + #[cfg_attr(not(test), allow(dead_code))] + pub fn adapter_alive_count(&self) -> usize { + self.adapters.iter().filter(|a| a.alive).count() + } + /// Chains any module asked for block events on. The caller opens /// one shared block subscription per chain and routes through /// `dispatch_block`. Sorted by numeric id and deduped (`Chain` is @@ -633,6 +850,7 @@ impl Supervisor { run.clone(), module.http_allowlist.clone(), module.http_limits, + Vec::new(), module.memory_limit, module.fuel_per_event, clocks.as_ref(), @@ -1018,6 +1236,60 @@ 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( + engine: &Engine, +) -> 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, + )?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + Ok(linker) +} + +/// Resolve a component's manifest path: the explicit `manifest` override +/// wins, else a sibling `module.toml`, else the deprecated `nexum.toml` +/// with a rename warning. `None` when neither sibling exists. Shared by the +/// module and adapter load paths. +fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { + if let Some(path) = explicit { + return Some(path.to_path_buf()); + } + // Canonical name is module.toml (ADR-0001). nexum.toml is accepted + // with a deprecation warning during the 0.1->0.2 transition. + let dir = component.parent()?.to_owned(); + let canonical = dir.join("module.toml"); + if canonical.exists() { + return Some(canonical); + } + let legacy = dir.join("nexum.toml"); + if legacy.exists() { + warn!( + target: "manifest", + path = %legacy.display(), + "nexum.toml is deprecated; rename to module.toml \ + (ADR-0001). Support will be removed in 0.3." + ); + return Some(legacy); + } + None +} + /// Assemble the capability registry from the core namespace plus every /// extension's namespace. The result must agree with the linker built from /// the same `extensions`: enforcement recognises an extension import as a diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 5568d259..666c074a 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -768,6 +768,7 @@ chain_id = 1 manifest: Some(example_manifest.clone()), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1312,6 +1313,7 @@ chain_id = 100 manifest: Some(chain_b_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1417,6 +1419,7 @@ chain_id = 100 manifest: Some(example_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1634,3 +1637,101 @@ fn chainlog_cursor_key_differs_by_each_input() { "address presence changes the key", ); } + +// ── 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 +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn adapter_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + crate::supervisor::build_adapter_linker::(&engine) + .expect("adapter linker assembles"); +} + +/// The module-kind discriminator gates the adapter 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. +#[tokio::test] +async fn boot_rejects_adapter_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 dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("cow.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!("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}", + ); +} + +/// 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 +/// rather than rejecting it on kind. +#[tokio::test] +async fn boot_admits_a_venue_adapter_manifest_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 dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"venue-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"), + manifest: Some(manifest), + http_allow: vec!["api.cow.fi".into()], + messaging_topics: vec!["/nexum/1/cow-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 msg = format!("{err:#}"); + assert!( + msg.contains("compile") || msg.contains("missing-cow"), + "boot reached the compile step past the kind gate: {msg}", + ); + assert!( + !msg.contains("requires a module.toml"), + "the kind gate passed rather than rejecting: {msg}", + ); +} diff --git a/wit/nexum-adapter/venue-adapter.wit b/wit/nexum-adapter/venue-adapter.wit new file mode 100644 index 00000000..9656bc47 --- /dev/null +++ b/wit/nexum-adapter/venue-adapter.wit @@ -0,0 +1,30 @@ +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 new file mode 100644 index 00000000..1c0a28ce --- /dev/null +++ b/wit/nexum-intent/adapter.wit @@ -0,0 +1,31 @@ +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>; +} From c3ef5474c793f23752ea948bc13fd23417c2d65e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:34:58 +0000 Subject: [PATCH 11/29] feat(runtime): route nexum:intent/pool to installed venue adapters (#250) Implement the strategy-facing pool import as a host binding linked into every module linker, dispatching to a shared router that owns the installed adapters. - Bindings: a pool-host bindgen world importing nexum:intent/pool, reusing the intent and value-flow types from the venue-adapter bindings via `with`, so the outcome and error the router hands back to a module are the very types an adapter's submit produced. - Router: resolve a venue id to its adapter, serialise invocation per adapter behind an async mutex (the wasmtime Store is not Sync), sequence derive-header, a no-op guard interposition seam, then submit. Status and cancel pass through without the header, guard, or quota. - Quota: a per-caller sliding-window submission budget, with adapter decode failures charged to the caller so a module feeding garbage bodies exhausts its own budget rather than the adapter's fuel. - Supervisor: adapters instantiate first and install into the router; modules then boot carrying the shared handle, rebuilt identically on restart. An init-failed adapter is loaded but not routable. - Manifest: register nexum:intent/pool as a declarable module capability. - Config: a [limits.quota] section resolving the per-caller budget. --- crates/nexum-runtime/src/bindings.rs | 35 + crates/nexum-runtime/src/engine_config.rs | 34 + crates/nexum-runtime/src/host/impls/mod.rs | 1 + crates/nexum-runtime/src/host/impls/pool.rs | 30 + crates/nexum-runtime/src/host/mod.rs | 1 + crates/nexum-runtime/src/host/pool_router.rs | 765 ++++++++++++++++++ crates/nexum-runtime/src/host/state.rs | 5 + .../src/manifest/capabilities.rs | 27 +- crates/nexum-runtime/src/supervisor.rs | 191 ++--- 9 files changed, 997 insertions(+), 92 deletions(-) create mode 100644 crates/nexum-runtime/src/host/impls/pool.rs create mode 100644 crates/nexum-runtime/src/host/pool_router.rs diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index c067249f..79c0729a 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -46,6 +46,41 @@ 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 `venue_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 { + wasmtime::component::bindgen!({ + inline: " + package nexum:pool-host; + world pool-host { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + imports: { default: async }, + with: { + "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, + "nexum:intent/types": super::venue_adapter::nexum::intent::types, + }, + }); +} + +/// 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, IntentHeader, IntentStatus, SubmitOutcome, 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 diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index cac75d82..55a2ba80 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,6 +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::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; /// Errors surfaced by [`load_or_default`]. @@ -309,6 +310,9 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, + /// Per-caller intent submission quota. + #[serde(default)] + pub quota: QuotaLimitsSection, } impl ModuleLimits { @@ -386,6 +390,20 @@ impl ModuleLimits { .unwrap_or(POISON_WINDOW), ) } + + /// Resolved per-caller submission quota (overrides or defaults). A zero + /// `max_charges` is saturated up to 1 by the router builder, so a + /// misconfigured budget still admits one submission rather than bricking + /// every venue. + pub fn quota(&self) -> PoolQuota { + PoolQuota::new( + self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), + self.quota + .window_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_QUOTA_WINDOW), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -459,6 +477,22 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } +/// `[limits.quota]` per-caller intent submission budget. Both optional; +/// omitted values resolve to the router 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. +#[derive(Debug, Default, Deserialize)] +pub struct QuotaLimitsSection { + /// Maximum submissions (plus charged decode failures) per caller in the + /// window. + pub max_charges: Option, + /// Sliding window the charges are counted across, in seconds. + pub window_secs: Option, +} + /// Resolved log retention limits the in-memory store enforces. Built by /// [`ModuleLimits::logs`]. #[derive(Debug, Clone, Copy)] diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 2247ed60..a5b80c14 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,5 +11,6 @@ mod identity; mod local_store; mod logging; mod messaging; +mod pool; mod remote_store; mod types; diff --git a/crates/nexum-runtime/src/host/impls/pool.rs b/crates/nexum-runtime/src/host/impls/pool.rs new file mode 100644 index 00000000..d02e29e5 --- /dev/null +++ b/crates/nexum-runtime/src/host/impls/pool.rs @@ -0,0 +1,30 @@ +//! `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/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 66e4121e..5c41e467 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -31,5 +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; diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs new file mode 100644 index 00000000..0c620d37 --- /dev/null +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -0,0 +1,765 @@ +//! The intent pool router: the strategy-facing `nexum:intent/pool` 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. +//! +//! 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 +//! 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. +//! +//! 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 +//! 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. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures::future::BoxFuture; +use tokio::sync::Mutex as AsyncMutex; +use tracing::warn; +use wasmtime::Store; + +use crate::bindings::{IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError}; +use crate::host::component::RuntimeTypes; +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); + +/// 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 { + /// 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 { + /// 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 PoolQuota { + 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 { + /// Decide whether the derived header may proceed to the adapter's submit. + fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; +} + +/// 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, + /// Adapter-derived header for the body. + pub header: &'a IntentHeader, +} + +/// The guard's decision on one egress. +pub enum GuardVerdict { + /// Forward the submission to the adapter. + Allow, + /// Refuse the egress with an operator-facing reason. + 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. +/// +/// 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. +pub trait VenueInvoker: Send { + /// Project the opaque body onto the stable header the guard runs on. + fn derive_header<'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>; + + /// Report where a previously submitted intent is in its life. The receipt + /// is owned: it is used once, unlike the body a submission re-decodes. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + 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 +/// `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 +/// calling module's store. +pub struct AdapterActor { + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, +} + +impl AdapterActor { + /// Wrap an instantiated adapter store for routing. + pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { + Self { + store, + 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::InternalError(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::InternalError(format!("adapter trapped: {}", trap.root_cause())) +} + +impl VenueInvoker for AdapterActor { + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_derive_header(&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], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_submit(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_status(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_cancel(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } +} + +/// One installed adapter behind its serialising mutex. +type AdapterSlot = Arc>; + +/// Per-caller charge history, pruned to the quota window on each touch. +#[derive(Default)] +struct QuotaLedger { + per_caller: HashMap>, +} + +/// 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, + ledger: Mutex, +} + +/// The strategy-facing pool router, cheap to clone and shared across every +/// module store. +#[derive(Clone)] +pub struct PoolRouter { + 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. + pub fn empty() -> Self { + PoolRouterBuilder::new(PoolQuota::default()).build() + } + + /// Resolve a venue id to its installed adapter slot. + fn resolve(&self, venue: &str) -> Result { + self.inner + .adapters + .get(venue) + .cloned() + .ok_or(VenueError::UnknownVenue) + } + + /// Whether `caller` has budget left in the current window. Read-only: it + /// prunes aged charges but does not record one. + fn quota_admits(&self, caller: &str) -> bool { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + (history.len() as u32) < self.inner.quota.max_charges + } + + /// Record one charge against `caller`'s budget. + fn charge(&self, caller: &str) { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + history.push_back(Instant::now()); + } + + /// 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 + /// 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. + pub async fn submit( + &self, + caller: &str, + venue: &str, + 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. + if !self.quota_admits(caller) { + return Err(VenueError::Denied(format!( + "submission quota exhausted for caller {caller}" + ))); + } + let mut adapter = slot.lock().await; + let header = match adapter.derive_header(&body).await { + Ok(header) => header, + Err(e) => { + // Charge decode failures to the caller before the adapter is + // invoked again; other venue errors are not the caller's fault. + if matches!(e, VenueError::InvalidBody(_)) { + self.charge(caller); + } + return Err(e); + } + }; + let ctx = GuardContext { + caller, + venue, + header: &header, + }; + if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { + return Err(VenueError::Denied(reason)); + } + // A forwarded submission consumes one unit of the caller's budget. + self.charge(caller); + adapter.submit(&body).await + } + + /// 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 { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.status(receipt).await + } + + /// 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> { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.cancel(receipt).await + } + + /// Number of installed, routable adapters. + pub fn venue_count(&self) -> usize { + self.inner.adapters.len() + } +} + +/// Drop charge timestamps that have aged out of the window. +fn prune(history: &mut VecDeque, window: Duration) { + let now = Instant::now(); + while let Some(&front) = history.front() { + if now.duration_since(front) > window { + history.pop_front(); + } else { + break; + } + } +} + +/// 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, +} + +impl PoolRouterBuilder { + /// Start an empty builder with the given quota and the no-op guard. + pub fn new(quota: PoolQuota) -> Self { + Self { + adapters: HashMap::new(), + guard: Arc::new(AllowAllGuard), + 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 { + self.guard = guard; + 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: String, + 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 router. + pub fn build(self) -> PoolRouter { + 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 + // than bricking every venue. Mirrors the poison-policy clamp. + warn!("pool 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 { + adapters: self.adapters, + guard: self.guard, + quota, + ledger: Mutex::new(QuotaLedger::default()), + }), + } + } +} + +/// Two installed adapters claimed the same venue id. +#[derive(Debug, thiserror::Error)] +#[error("venue id {venue:?} is claimed by more than one installed adapter")] +pub struct DuplicateVenue { + /// The colliding venue id. + pub venue: String, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::bindings::value_flow::Settlement; + use crate::bindings::{AuthScheme, IntentHeader}; + + use super::*; + + /// 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. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest number of overlapping invocations observed; proves the + /// per-adapter mutex serialises access. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + } + + impl StubAdapter { + fn new(calls: Arc) -> Self { + Self { + calls, + derive: Ok(header()), + submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + } + } + + fn with_derive(mut self, derive: Result) -> Self { + self.derive = derive; + self + } + + async fn enter(&self) { + let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; + self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); + // Yield inside the critical section so any missing serialisation + // would let a second call observe `live == 2`. + tokio::task::yield_now().await; + self.calls.live.fetch_sub(1, Ordering::SeqCst); + } + } + + impl VenueInvoker for StubAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.derive.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.derive.clone() + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.submit.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.submit.clone() + }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls.status.fetch_add(1, Ordering::SeqCst); + Ok(IntentStatus::Open) + }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.calls.cancel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + /// A guard that refuses every egress with a fixed reason. + struct DenyGuard; + impl GuardPolicy for DenyGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Deny("blocked by test policy".to_owned()) + } + } + + fn header() -> IntentHeader { + IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Unsigned, + } + } + + fn router_with( + quota: PoolQuota, + guard: Option>, + adapter: StubAdapter, + ) -> PoolRouter { + let mut builder = PoolRouterBuilder::new(quota); + if let Some(guard) = guard { + builder = builder.with_guard(guard); + } + builder + .install("cow".to_owned(), 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 outcome = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + 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), 1); + } + + #[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 err = router + .submit("mod-a", "unlisted", b"body".to_vec()) + .await + .expect_err("unknown venue rejected"); + + assert!(matches!(err, VenueError::UnknownVenue)); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn guard_deny_blocks_submit_after_deriving_the_header() { + let calls = Arc::new(StubCalls::default()); + let router = router_with( + PoolQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let err = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect_err("guard denies"); + + 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. + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn submission_quota_denies_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())); + + 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()) + .await + .expect_err("third submit over quota"); + + assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); + // 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); + } + + #[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())); + + 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(), + "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(), + "mod-b has an independent budget" + ); + } + + #[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 adapter = + StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); + let router = router_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; + 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(_)))); + assert_eq!( + calls.derive.load(Ordering::SeqCst), + 1, + "adapter derive-header was invoked exactly once", + ); + } + + #[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 adapter = StubAdapter::new(calls.clone()) + .with_derive(Err(VenueError::Unavailable("rpc down".into()))); + let router = router_with(quota, None, adapter); + + assert!(matches!( + router.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, + Err(VenueError::Unavailable(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn status_and_cancel_pass_through_without_quota() { + 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())); + + assert!(matches!( + router.status("cow", b"r".to_vec()).await, + Ok(IntentStatus::Open) + )); + assert!(router.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); + } + + #[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 mut handles = Vec::new(); + for _ in 0..8 { + let router = router.clone(); + handles.push(tokio::spawn(async move { + let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; + })); + } + for h in handles { + h.await.expect("task joins"); + } + // The adapter mutex is held across the guest await, so no two calls + // ever overlapped inside the adapter. + assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); + } + + #[test] + fn duplicate_venue_id_is_rejected() { + let mut builder = PoolRouterBuilder::new(PoolQuota::default()); + let a = Arc::new(StubCalls::default()); + let b = Arc::new(StubCalls::default()); + builder + .install("cow".to_owned(), StubAdapter::new(a)) + .expect("first install"); + let err = builder + .install("cow".to_owned(), StubAdapter::new(b)) + .expect_err("second install collides"); + 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); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 5dfbadb5..df3e2801 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -13,6 +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; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -47,6 +48,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. + /// Every module store carries the same shared handle; an adapter store, + /// which cannot call pool, carries an empty one. + pub pool_router: PoolRouter, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 05d6b79a..064aea56 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -28,6 +28,19 @@ 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, +}; + /// 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` @@ -67,10 +80,11 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with only the core namespace. + /// The registry with the core `nexum:host/` namespace plus the + /// strategy-facing `nexum:intent/pool` import every module linker carries. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], } } @@ -243,6 +257,15 @@ mod tests { ); } + #[test] + fn intent_pool_is_a_core_capability_but_intent_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); + } + #[test] fn wit_import_to_cap_non_http_wasi_is_none() { let r = registry_with_cow(); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 79a93968..8dc6293f 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -47,6 +47,7 @@ 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; @@ -57,13 +58,17 @@ use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ModuleKind, Subs /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Venue adapters instantiated into supervised stores. They boot - /// through the same store, fuel, and memory machinery as modules but - /// carry no subscriptions: nothing dispatches to them yet. A later - /// change wires the intent router - which reaches an adapter through - /// the same extension seam the `extension.rs` router note describes - - /// and folds them into the restart and poison sweeps. - adapters: Vec>, + /// The intent pool router: 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 + /// adapters into the restart and poison sweeps is still a later change. + pool_router: PoolRouter, + /// 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, /// 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 @@ -211,45 +216,19 @@ struct LoadedModule { poisoned: bool, } -/// A venue adapter instantiated into a supervised store. It mirrors -/// [`LoadedModule`] so the intent router a later change adds can drive -/// adapters through the same restart, poison, and fuel machinery, but -/// carries no subscriptions: nothing dispatches to an adapter yet. The -/// cached boot inputs (`component`, `init_config`, the scope grants, the -/// resource knobs) are what a re-instantiation needs; they are held now -/// and read once dispatch exists. -#[allow(dead_code)] +/// A venue adapter instantiated into a supervised store, ready to install in +/// the pool router. 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 +/// handling are still a later change; an `init` failure leaves `alive` false +/// so the adapter is loaded but not routable. struct LoadedAdapter { - name: String, - bindings: VenueAdapter, - store: HostStore, - /// The run this store instantiates; restarts mint a fresh one. - run: RunId, - /// Fuel budget refilled before each adapter call. - fuel_per_event: u64, - /// Memory cap applied to the store on reinstantiation. - memory_limit: usize, - /// Cached for restart: re-instantiating from the compiled component. - component: Component, - /// Cached for restart: the `[config]` passed to the adapter's `init`. - init_config: Config, - /// Operator-granted outbound HTTP allowlist for this adapter. - http_allowlist: Vec, - /// Operator-granted outbound HTTP limits. - http_limits: OutboundHttpLimits, - /// Operator-granted messaging content-topic scopes. - messaging_topics: Vec, - /// `false` once an adapter call traps; folded into the restart sweep - /// when the router lands. `init` failure leaves it `false` at boot. + /// 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, + /// Whether `init` succeeded; a failed adapter is not installed for routing. alive: bool, - /// Consecutive trap-style failures since the last success. - failure_count: u32, - /// Earliest instant a trapped adapter may be retried. - next_attempt: Option, - /// Sliding-window trap timestamps for the poison-pill check. - failure_timestamps: std::collections::VecDeque, - /// Permanent quarantine flag, as for modules. - poisoned: bool, } impl Supervisor { @@ -265,52 +244,71 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - let mut modules = Vec::with_capacity(engine_cfg.modules.len()); - for entry in &engine_cfg.modules { - let loaded = Self::load_one( + // 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. + let adapter_linker = build_adapter_linker::(engine)?; + let adapter_registry = CapabilityRegistry::adapter(); + let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); + let adapters_total = engine_cfg.adapters.len(); + let mut adapters_alive = 0; + for entry in &engine_cfg.adapters { + let loaded = Self::load_adapter( engine, - linker, + &adapter_linker, entry, components, &engine_cfg.limits, - ®istry, + &adapter_registry, clocks.as_ref(), ) .await - .with_context(|| format!("load module {}", entry.path.display()))?; - modules.push(loaded); + .with_context(|| format!("load adapter {}", entry.path.display()))?; + if loaded.alive { + adapters_alive += 1; + router_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", + ); + } } - // Adapters link only their scoped transport, so they instantiate - // against a dedicated linker built from the same core backends. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut adapters = Vec::with_capacity(engine_cfg.adapters.len()); - for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let pool_router = router_builder.build(); + + let mut modules = Vec::with_capacity(engine_cfg.modules.len()); + for entry in &engine_cfg.modules { + let loaded = Self::load_one( engine, - &adapter_linker, + linker, entry, components, &engine_cfg.limits, - &adapter_registry, + ®istry, clocks.as_ref(), + pool_router.clone(), ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - adapters.push(loaded); + .with_context(|| format!("load module {}", entry.path.display()))?; + modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); - let adapters_alive = adapters.iter().filter(|a| a.alive).count(); info!( loaded = modules.len(), alive, - adapters = adapters.len(), + adapters = adapters_total, adapters_alive, "supervisor up" ); Ok(Self { modules, - adapters, + pool_router, + adapters_total, + adapters_alive, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -341,6 +339,10 @@ 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 the router is empty here and + // every pool call resolves to `unknown-venue`. + let pool_router = PoolRouter::empty(); let loaded = Self::load_one( engine, linker, @@ -349,13 +351,14 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), + pool_router.clone(), ) .await?; Ok(Self { modules: vec![loaded], - // The single-module override path serves `just run`; adapters - // are configured through `engine.toml` and boot via `boot`. - adapters: Vec::new(), + pool_router, + adapters_total: 0, + adapters_alive: 0, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -381,6 +384,7 @@ impl Supervisor { memory_limit: usize, fuel: u64, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -434,6 +438,7 @@ impl Supervisor { ext: components.ext.clone(), chain: components.chain.clone(), store: module_store, + pool_router, }, ); store.limiter(|state| &mut state.limits); @@ -441,6 +446,9 @@ impl Supervisor { Ok(store) } + // One flat argument per shared input threaded onto the store, plus the + // pool router the module's `nexum:intent/pool` import dispatches to. + #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, linker: &Linker>, @@ -449,6 +457,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -503,6 +512,7 @@ impl Supervisor { limits_cfg.memory(), limits_cfg.fuel(), clocks, + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -655,6 +665,9 @@ 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. let mut store = Self::build_store( engine, components, @@ -665,6 +678,7 @@ impl Supervisor { limits_cfg.memory(), limits_cfg.fuel(), clocks, + PoolRouter::empty(), )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -699,22 +713,9 @@ impl Supervisor { store.set_fuel(limits_cfg.fuel())?; Ok(LoadedAdapter { - name: adapter_namespace, - bindings, - store, - run, - fuel_per_event: limits_cfg.fuel(), - memory_limit: limits_cfg.memory(), - component, - init_config: config, - http_allowlist: entry.http_allow.clone(), - http_limits: limits_cfg.http(), - messaging_topics: entry.messaging_topics.clone(), + venue_id: adapter_namespace, + actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), alive: init_succeeded, - failure_count: 0, - next_attempt: None, - failure_timestamps: std::collections::VecDeque::new(), - poisoned: false, }) } @@ -723,16 +724,16 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters instantiated under the supervisor. + /// Number of venue adapters loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { - self.adapters.len() + self.adapters_total } - /// Number of adapters whose `init` succeeded and that are eligible for - /// routing once dispatch lands. + /// Number of adapters whose `init` succeeded and that are installed in the + /// pool router for routing. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { - self.adapters.iter().filter(|a| a.alive).count() + self.adapters_alive } /// Chains any module asked for block events on. The caller opens @@ -837,9 +838,11 @@ impl Supervisor { // against the cached `Engine`. 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 as the initial boot. + // Borrowed before the `&mut self.modules[idx]` reborrow so the restart + // path applies the same clock override and the same shared pool router + // as the initial boot. let clocks = self.clocks.clone(); + let pool_router = self.pool_router.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. @@ -854,6 +857,7 @@ impl Supervisor { module.memory_limit, module.fuel_per_event, clocks.as_ref(), + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1226,6 +1230,13 @@ 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>>( + &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. From 8b698c751d00c7255485ca0046fcbf29c8d74116 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:48:00 +0000 Subject: [PATCH 12/29] feat(venue-sdk): add nexum-venue-sdk crate (#251) * feat(sdk): ship nexum-venue-sdk over the venue-adapter world Introduce the venue-author persona crate: the VenueAdapter trait and export macro over an in-crate bindgen of the adapter world, the borsh IntentBody derive whose outer version enum fails unknown tags typedly, the typed intent client core over the byte-level pool seam, and typed wrappers over the scoped chain, messaging, and wasi:http imports. The derive expansion lands in nexum-macros and re-exports from the SDK; borsh enters the workspace dependency table. * fix(venue-sdk): wrap the wit chain-error data into Bytes The SDK RpcError.data is Bytes; the wit-bindgen chain-error carries Vec, so wrap it (Vec -> Bytes is O(1)) when reprojecting. --- Cargo.lock | 12 ++ Cargo.toml | 7 + crates/nexum-macros/Cargo.toml | 2 +- crates/nexum-macros/src/intent_body.rs | 134 +++++++++++++ crates/nexum-macros/src/lib.rs | 32 +++- crates/nexum-venue-sdk/Cargo.toml | 33 ++++ crates/nexum-venue-sdk/src/adapter.rs | 97 ++++++++++ crates/nexum-venue-sdk/src/bindings.rs | 26 +++ crates/nexum-venue-sdk/src/body.rs | 94 ++++++++++ crates/nexum-venue-sdk/src/client.rs | 93 +++++++++ crates/nexum-venue-sdk/src/faults.rs | 148 +++++++++++++++ crates/nexum-venue-sdk/src/lib.rs | 78 ++++++++ crates/nexum-venue-sdk/src/transport.rs | 158 ++++++++++++++++ crates/nexum-venue-sdk/tests/adapter.rs | 238 ++++++++++++++++++++++++ 14 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 crates/nexum-macros/src/intent_body.rs create mode 100644 crates/nexum-venue-sdk/Cargo.toml create mode 100644 crates/nexum-venue-sdk/src/adapter.rs create mode 100644 crates/nexum-venue-sdk/src/bindings.rs create mode 100644 crates/nexum-venue-sdk/src/body.rs create mode 100644 crates/nexum-venue-sdk/src/client.rs create mode 100644 crates/nexum-venue-sdk/src/faults.rs create mode 100644 crates/nexum-venue-sdk/src/lib.rs create mode 100644 crates/nexum-venue-sdk/src/transport.rs create mode 100644 crates/nexum-venue-sdk/tests/adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 0669df5e..9fc47ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3620,6 +3620,18 @@ 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 = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 25604bd5..17bd563f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-venue-sdk", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", @@ -52,6 +53,12 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Borsh wire codec behind the venue SDK's versioned `IntentBody` bodies. +# The venue SDK re-exports the runtime crate for its derive's generated +# code; `derive` is on so venue payload types can `#[derive(BorshSerialize, +# BorshDeserialize)]` through the same dependency. +borsh = { version = "1", features = ["derive"] } + # Observability. tracing = "0.1" # `tracing-core` alone (no subscriber registry) backs the guest-side diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index ed9fcf2c..1f4dd26a 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-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 nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." +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." [lib] proc-macro = true diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs new file mode 100644 index 00000000..79b074cd --- /dev/null +++ b/crates/nexum-macros/src/intent_body.rs @@ -0,0 +1,134 @@ +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a +//! per-venue version enum. +//! +//! The derive enforces the outer-enum shape at compile time (an enum of +//! newtype variants, one published body version per variant) and emits +//! `to_bytes` / `from_bytes` whose wire form is the borsh enum layout: a +//! one-byte version tag (the variant's declaration index) followed by the +//! borsh-encoded payload. Decoding matches the tag itself, so an unknown +//! version surfaces as the typed `BodyError::UnknownVersion` rather than +//! a stringly borsh error, and a known version delegates the payload to +//! 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 +//! crate's re-export. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +/// Expand the derive input into the `IntentBody` impl, or a compile +/// error naming the shape rule the input broke. +pub(crate) fn expand(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[derive(IntentBody)] does not support generic version enums: a wire schema has \ + exactly one shape", + )); + } + + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] applies to the outer per-venue version enum: an enum with one \ + newtype variant per published body version", + )); + }; + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] needs at least one version variant", + )); + } + if data.variants.len() > usize::from(u8::MAX) + 1 { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] supports at most 256 versions: the wire tag is one byte", + )); + } + + let mut encode_arms = Vec::with_capacity(data.variants.len()); + let mut decode_arms = Vec::with_capacity(data.variants.len()); + for (index, variant) in data.variants.iter().enumerate() { + if let Some((eq, _)) = &variant.discriminant { + return Err(syn::Error::new_spanned( + eq, + "#[derive(IntentBody)] does not support explicit discriminants: the version tag \ + is the variant's declaration index, so append new versions at the end", + )); + } + let payload_ty = match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0].ty, + _ => { + return Err(syn::Error::new_spanned( + &variant.ident, + "#[derive(IntentBody)] version variants carry exactly one unnamed payload \ + field, e.g. `V1(BodyV1)`", + )); + } + }; + + let ident = &variant.ident; + let tag = proc_macro2::Literal::u8_suffixed( + u8::try_from(index).expect("variant count checked above"), + ); + + encode_arms.push(quote! { + Self::#ident(payload) => { + let mut out = ::std::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), + }, + )?; + ::core::result::Result::Ok(out) + } + }); + 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 { + version: #tag, + detail: ::std::string::ToString::to_string(&err), + })?, + )), + }); + } + + Ok(quote! { + #[automatically_derived] + impl ::nexum_venue_sdk::body::IntentBody for #name { + fn to_bytes( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec, + ::nexum_venue_sdk::body::BodyError, + > { + match self { + #(#encode_arms)* + } + } + + fn from_bytes( + bytes: &[u8], + ) -> ::core::result::Result { + let (version, payload) = bytes + .split_first() + .ok_or(::nexum_venue_sdk::body::BodyError::Empty)?; + match *version { + #(#decode_arms)* + version => ::core::result::Result::Err( + ::nexum_venue_sdk::body::BodyError::UnknownVersion { version }, + ), + } + } + } + }) +} diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 93d0e9a3..ad4bb770 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -6,14 +6,40 @@ //! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation //! whose `on-event` dispatches to the handlers present, and `export!`. //! -//! Consumers reach this through the `nexum_sdk::module` re-export rather -//! than depending on this crate directly. +//! [`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`, +//! `nexum_venue_sdk::IntentBody`) rather than depending on this crate +//! directly. + +mod intent_body; use std::path::{Path, PathBuf}; use proc_macro::TokenStream; use quote::quote; -use syn::{ImplItem, ItemImpl, Type}; +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 +/// `nexum_venue_sdk::IntentBody` re-export with `nexum-venue-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 handler names recognised on a `#[module]` impl. Any method not in /// this set is left untouched on the type, except that names starting diff --git a/crates/nexum-venue-sdk/Cargo.toml b/crates/nexum-venue-sdk/Cargo.toml new file mode 100644 index 00000000..f3fd250a --- /dev/null +++ b/crates/nexum-venue-sdk/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nexum-venue-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." + +[lib] +# Plain library - adapters link this and emit their own cdylib for the +# WASM Component. Building on the host target is also supported so the +# codec, client core, and conversions are unit-testable without a wasm +# toolchain (the wit-bindgen import shims compile to unreachable stubs +# off-wasm). + +[lints] +workspace = true + +[dependencies] +# Backs the `IntentBody` wire codec; re-exported (`body::__private`) for +# 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" } +# 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`. +nexum-sdk = { path = "../nexum-sdk" } +strum.workspace = true +thiserror.workspace = true +wit-bindgen.workspace = true diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs new file mode 100644 index 00000000..58fc8ffc --- /dev/null +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -0,0 +1,97 @@ +//! The [`VenueAdapter`] trait and the export glue 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 four intent functions from `nexum:intent/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}; + +/// 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 +/// 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 `?`). +pub trait VenueAdapter { + /// Configure the adapter from its `[config]` table before any + /// submission. Mirrors the event-module `init`, so the supervisor + /// boots both component kinds through the same machinery. + fn init(config: Config) -> Result<(), Fault>; + + /// 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. + fn derive_header(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. + fn submit(body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(receipt: Vec) -> 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(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. +#[macro_export] +macro_rules! export_venue_adapter { + ($adapter:ty) => { + #[doc(hidden)] + struct __NexumVenueAdapterExport; + + impl $crate::bindings::Guest for __NexumVenueAdapterExport { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), $crate::Fault> { + <$adapter as $crate::VenueAdapter>::init(config) + } + } + + impl $crate::bindings::exports::nexum::intent::adapter::Guest + for __NexumVenueAdapterExport + { + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::derive_header(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::cancel(receipt) + } + } + + $crate::bindings::__export_venue_adapter_world!( + __NexumVenueAdapterExport with_types_in $crate::bindings + ); + }; +} diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs new file mode 100644 index 00000000..c8b3c2f4 --- /dev/null +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -0,0 +1,26 @@ +//! Guest bindings for the `nexum:adapter/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 +//! [`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 `nexum:intent/types` and +//! `nexum:value-flow/types` onto these modules with `with`. + +wit_bindgen::generate!({ + path: [ + "../../wit/nexum-host", + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/venue-adapter", + generate_all, + pub_export_macro: true, + export_macro_name: "__export_venue_adapter_world", + default_bindings_module: "nexum_venue_sdk::bindings", + additional_derives: [PartialEq], +}); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs new file mode 100644 index 00000000..d542aca2 --- /dev/null +++ b/crates/nexum-venue-sdk/src/body.rs @@ -0,0 +1,94 @@ +//! The versioned intent-body codec: [`IntentBody`] and its typed +//! [`BodyError`]. +//! +//! An intent body crosses the pool and adapter boundaries as opaque +//! bytes; typing is recovered guest-side against the venue's published +//! schema. That schema is an outer version enum whose wire form is the +//! borsh enum layout: a one-byte version tag (the variant's declaration +//! index) followed by the borsh-encoded payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) implements the codec over such an +//! enum and is the intended way to get an impl; the derive owns the tag +//! handling, so an unknown version fails as the typed +//! [`BodyError::UnknownVersion`] instead of a stringly borsh error. +//! +//! The one non-obvious invariant: the tag order is the schema. Venues +//! append new versions at the end and never reorder or remove variants. + +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 { + /// Encode as the one-byte version tag plus the borsh payload. + fn to_bytes(&self) -> Result, BodyError>; + + /// Decode, failing typedly on an empty body, an unknown version + /// tag, or a payload that does not parse as the tagged version + /// (including trailing bytes). + fn from_bytes(bytes: &[u8]) -> Result; +} + +/// Why a body failed to cross the [`IntentBody`] codec. +/// +/// `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")] +pub enum BodyError { + /// No bytes at all: not even a version tag. + #[error("empty body: missing the version tag")] + Empty, + /// The version tag names no published version of this body. The + /// decodable-future-versions story lives here: a v1 adapter handed + /// a v2 body reports the exact unknown tag instead of garbling the + /// payload. + #[error("unknown 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, + }, + /// A payload failed to encode. Only reachable through a fallible + /// custom `BorshSerialize` impl; derived payloads encode + /// infallibly. + #[error("version {version} payload failed to encode: {detail}")] + Encode { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's encode failure detail. + detail: String, + }, +} + +/// 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`). +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()), + } + } +} + +/// Re-exports for `#[derive(IntentBody)]` generated code only; not a +/// public surface. +#[doc(hidden)] +pub mod __private { + pub use borsh; +} diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs new file mode 100644 index 00000000..edfc8dd7 --- /dev/null +++ b/crates/nexum-venue-sdk/src/client.rs @@ -0,0 +1,93 @@ +//! The typed intent client core: [`IntentClient`] over the byte-level +//! [`IntentPool`] seam. +//! +//! The pool 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 +//! 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 +//! (an in-process adapter works directly), and the typed layer above is +//! shared by both. + +use strum::IntoStaticStr; + +use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; + +/// Byte-level access to the strategy-facing `nexum:intent/pool` +/// interface, venue named per call as on the wire. +pub trait IntentPool { + /// Submit an opaque intent body to the named venue. + fn submit(&self, venue: &str, body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(&self, venue: &str, 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>; +} + +/// A typed intent client bound to one venue: encodes an [`IntentBody`] +/// to wire bytes and forwards through the [`IntentPool`] seam. +#[derive(Clone, Debug)] +pub struct IntentClient

{ + pool: 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 { + Self { + pool, + venue: venue.into(), + } + } + + /// The venue id every call on this client routes to. + pub fn venue(&self) -> &str { + &self.venue + } + + /// 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 + .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 + .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 + .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). +/// +/// `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. + #[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 + /// `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 new file mode 100644 index 00000000..9a0e3a1e --- /dev/null +++ b/crates/nexum-venue-sdk/src/faults.rs @@ -0,0 +1,148 @@ +//! 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. +//! +//! Every conversion here is lossy only downward (a structured case folds +//! to a payload-bearing string case, never the reverse), so `?` in an +//! adapter always preserves the most structured form the target +//! vocabulary can carry. + +use nexum_sdk::host; + +use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; +use crate::{Fault, VenueError}; + +/// 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. +pub fn fault_into_sdk(fault: Fault) -> host::Fault { + match fault { + Fault::Unsupported(s) => host::Fault::Unsupported(s), + Fault::Unavailable(s) => host::Fault::Unavailable(s), + Fault::Denied(s) => host::Fault::Denied(s), + Fault::RateLimited(rl) => host::Fault::RateLimited(host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }), + Fault::Timeout => host::Fault::Timeout, + Fault::InvalidInput(s) => host::Fault::InvalidInput(s), + Fault::Internal(s) => host::Fault::Internal(s), + } +} + +/// Lower the SDK-neutral fault back into the wire fault an adapter's +/// `init` returns, so a helper's `host::Fault` propagates with `?`. +/// +/// Carries a wildcard arm because `host::Fault` is `#[non_exhaustive]`: +/// a future SDK case lands as `internal` carrying its `Display` detail. +impl From for Fault { + fn from(fault: host::Fault) -> Self { + match fault { + host::Fault::Unsupported(s) => Fault::Unsupported(s), + host::Fault::Unavailable(s) => Fault::Unavailable(s), + host::Fault::Denied(s) => Fault::Denied(s), + host::Fault::RateLimited(rl) => Fault::RateLimited(WireRateLimit { + retry_after_ms: rl.retry_after_ms, + }), + host::Fault::Timeout => Fault::Timeout, + host::Fault::InvalidInput(s) => Fault::InvalidInput(s), + host::Fault::Internal(s) => Fault::Internal(s), + other => Fault::Internal(other.to_string()), + } + } +} + +/// 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. +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()), + } + } +} + +/// 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`. +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(_) => { + VenueError::Unavailable(err.to_string()) + } + FetchError::InvalidRequest(_) => VenueError::InternalError(err.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host; + + use crate::{Fault, VenueError}; + + #[test] + fn wire_fault_round_trips_through_sdk() { + let cases = [ + Fault::Unsupported("u".into()), + Fault::Unavailable("u".into()), + Fault::Denied("d".into()), + Fault::RateLimited(crate::bindings::nexum::host::types::RateLimit { + retry_after_ms: Some(250), + }), + Fault::Timeout, + Fault::InvalidInput("i".into()), + Fault::Internal("i".into()), + ]; + for case in cases { + let there = super::fault_into_sdk(case.clone()); + assert_eq!(Fault::from(there), case); + } + } + + #[test] + fn transport_fault_folds_to_venue_error_by_shape() { + assert_eq!( + VenueError::from(host::Fault::Denied("nope".into())), + VenueError::Denied("nope".into()), + ); + assert!(matches!( + VenueError::from(host::Fault::Timeout), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(host::Fault::InvalidInput("bug".into())), + VenueError::InternalError(_) + )); + } + + #[test] + fn fetch_error_folds_to_venue_error_by_shape() { + use nexum_sdk::http::FetchError; + assert!(matches!( + VenueError::from(FetchError::Denied), + VenueError::Denied(_) + )); + assert!(matches!( + VenueError::from(FetchError::Transport("reset".into())), + VenueError::Unavailable(_) + )); + assert!(matches!( + VenueError::from(FetchError::InvalidRequest("bad url".into())), + VenueError::InternalError(_) + )); + } +} diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs new file mode 100644 index 00000000..528c717f --- /dev/null +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -0,0 +1,78 @@ +//! # nexum-venue-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. +//! +//! ## What lives here +//! +//! - [`VenueAdapter`] - the trait mirroring the world's export face +//! (`init` plus the four intent functions), and +//! [`export_venue_adapter!`] which turns an impl into the component's +//! export glue. +//! +//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh +//! codec over the outer per-venue version enum. The wire form is a +//! 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 +//! [`IntentPool`] 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: +//! [`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`]. +//! +//! - [`faults`] - the conversions that make `?` work across the wire +//! fault, the SDK-neutral fault, and [`VenueError`]. +//! +//! ## 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. +//! +//! [`ChainHost`]: nexum_sdk::host::ChainHost +//! [`IntentClient`]: client::IntentClient +//! [`IntentPool`]: client::IntentPool + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[allow(missing_docs)] +pub mod bindings; + +pub mod adapter; +pub mod body; +pub mod client; +pub mod faults; +pub mod transport; + +pub use adapter::VenueAdapter; +pub use body::{BodyError, IntentBody}; +pub use client::{ClientError, IntentClient, IntentPool}; +/// Derive [`IntentBody`] on the outer per-venue version enum. See +/// [`nexum_macros::IntentBody`]. +pub use nexum_macros::IntentBody; + +/// 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, +}; +/// The value-flow vocabulary intent headers are expressed in. +pub use bindings::nexum::value_flow::types as value_flow; + +/// The wire config table (`nexum:host/types.config`) `init` receives. +pub use bindings::nexum::host::types::Config; +/// The wire fault (`nexum:host/types.fault`) `init` returns. Transport +/// seams speak the SDK-neutral [`nexum_sdk::host::Fault`] instead; the +/// [`faults`] conversions bridge the two. +pub use bindings::nexum::host::types::Fault; diff --git a/crates/nexum-venue-sdk/src/transport.rs b/crates/nexum-venue-sdk/src/transport.rs new file mode 100644 index 00000000..a6b8f905 --- /dev/null +++ b/crates/nexum-venue-sdk/src/transport.rs @@ -0,0 +1,158 @@ +//! Typed wrappers over the adapter world's scoped transport imports: +//! chain RPC, messaging, and outbound wasi:http. +//! +//! Each wrapper adapts this crate's bindgen import shims to the +//! SDK-neutral vocabulary (`nexum_sdk::host`), so adapter logic written +//! against the seams is unit-testable host-free and reuses the +//! `nexum-sdk` chain helpers unchanged. The wrappers only translate; +//! scoping is the host's: chain methods pass through the host's +//! permitted read-only surface, messaging is confined to the adapter's +//! `messaging_topics`, and HTTP to its `http_allow` list, each refusal +//! surfacing as a typed `denied`. + +use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; + +use crate::bindings::nexum::host::{chain, messaging}; +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 +/// converts into [`VenueError`](crate::VenueError) via `?`. +pub use nexum_sdk::http; + +/// 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. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostChain; + +impl ChainHost for HostChain { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + chain::request(chain_id, method, params).map_err(chain_error_into_sdk) + } +} + +impl HostChain { + /// Execute several JSON-RPC requests against one chain in a single + /// round trip where the host transport supports it. Entries are + /// independent: the outer error is the batch failing to execute at + /// all, the per-entry results carry each call's own outcome, in + /// request order. + pub fn request_batch( + &self, + chain_id: u64, + requests: &[RpcRequest], + ) -> Result>, ChainError> { + let wire: Vec = requests + .iter() + .map(|req| chain::RpcRequest { + method: req.method.clone(), + params: req.params.clone(), + }) + .collect(); + let results = chain::request_batch(chain_id, &wire).map_err(chain_error_into_sdk)?; + Ok(results + .into_iter() + .map(|result| match result { + chain::RpcResult::Ok(value) => Ok(value), + chain::RpcResult::Err(err) => Err(chain_error_into_sdk(err)), + }) + .collect()) + } +} + +/// One JSON-RPC call inside a [`HostChain::request_batch`], mirrored +/// from `nexum:host/chain.rpc-request`. `method` carries its namespace +/// prefix (`eth_call`); `params` is the JSON-encoded positional array. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RpcRequest { + /// JSON-RPC method, namespace prefix included. + pub method: String, + /// JSON-encoded params array. + pub params: String, +} + +/// Lift the wire chain error into the SDK-neutral [`ChainError`]. +/// Exhaustive on both the fault vocabulary and the rpc-error shape. +fn chain_error_into_sdk(err: chain::ChainError) -> ChainError { + match err { + chain::ChainError::Fault(fault) => ChainError::Fault(fault_into_sdk(fault)), + chain::ChainError::Rpc(rpc) => ChainError::Rpc(RpcError { + code: rpc.code, + message: rpc.message, + data: rpc.data.map(Into::into), + }), + } +} + +/// `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 adapter's `nexum:host/messaging` import behind the +/// [`MessagingHost`] seam. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostMessaging; + +impl MessagingHost for HostMessaging { + fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { + messaging::publish(content_topic, payload).map_err(fault_into_sdk) + } + + fn query( + &self, + content_topic: &str, + start_time: Option, + end_time: Option, + limit: Option, + ) -> Result, Fault> { + let messages = + messaging::query(content_topic, start_time, end_time, limit).map_err(fault_into_sdk)?; + Ok(messages.into_iter().map(Message::from).collect()) + } +} + +/// 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 { + content_topic: message.content_topic, + payload: message.payload, + timestamp: message.timestamp, + sender: message.sender, + } + } +} diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs new file mode 100644 index 00000000..c6afd9ca --- /dev/null +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -0,0 +1,238 @@ +//! 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 +//! [`IntentPool`] seam. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{ + AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, + IntentPool, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, +}; + +/// First published body version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV1 { + amount_wei: u64, + memo: String, +} + +/// Second published version: v1 plus an expiry. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +struct QuoteV2 { + amount_wei: u64, + memo: String, + valid_until_ms: Option, +} + +/// The outer per-venue version enum: the schema the demo venue +/// publishes. Tag order is the schema; versions append. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +enum QuoteBody { + V1(QuoteV1), + V2(QuoteV2), +} + +/// The hand-written adapter: enough venue to exercise every trait +/// function without a live transport. +struct DemoAdapter; + +/// The receipt the demo venue issues for every accepted intent. +const RECEIPT: [u8; 4] = [0xA5, 0x5A, 0xC3, 0x3C]; + +impl DemoAdapter { + fn decode(body: &[u8]) -> Result<(u64, Option), VenueError> { + // `BodyError` converts through `?`: malformed and + // unknown-version bodies surface as `invalid-body`. + let body = QuoteBody::from_bytes(body)?; + Ok(match body { + QuoteBody::V1(quote) => (quote.amount_wei, None), + QuoteBody::V2(quote) => (quote.amount_wei, quote.valid_until_ms), + }) + } +} + +impl VenueAdapter for DemoAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + let (amount_wei, valid_until) = Self::decode(&body)?; + Ok(IntentHeader { + gives: vec![AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(1)), + amount: amount_wei.to_be_bytes().to_vec(), + }], + wants: Vec::new(), + valid_until, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }) + } + + fn submit(body: Vec) -> Result { + Self::decode(&body)?; + Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) + } + + fn status(receipt: Vec) -> Result { + if receipt == RECEIPT { + Ok(IntentStatus::Open) + } else { + Err(VenueError::InvalidReceipt) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + Self::status(receipt).map(|_| ()) + } +} + +// The acceptance gate proper: the hand-written adapter exports as the +// 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; + +impl IntentPool for InProcessPool { + fn submit(&self, venue: &str, body: Vec) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::submit(body) + } + + fn status(&self, venue: &str, receipt: &[u8]) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::status(receipt.to_vec()) + } + + fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError> { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::cancel(receipt.to_vec()) + } +} + +fn v2_body() -> QuoteBody { + QuoteBody::V2(QuoteV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + }) +} + +#[test] +fn versioned_body_round_trips_through_the_derive() { + for body in [ + QuoteBody::V1(QuoteV1 { + amount_wei: 42, + memo: "one".to_owned(), + }), + v2_body(), + ] { + let bytes = body.to_bytes().expect("derived payloads encode"); + assert_eq!(QuoteBody::from_bytes(&bytes).unwrap(), body); + } +} + +#[test] +fn wire_tag_is_the_declaration_index() { + let v1 = QuoteBody::V1(QuoteV1 { + amount_wei: 1, + memo: String::new(), + }) + .to_bytes() + .unwrap(); + let v2 = v2_body().to_bytes().unwrap(); + assert_eq!(v1[0], 0); + assert_eq!(v2[0], 1); +} + +#[test] +fn unknown_version_fails_typedly() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 9; + assert_eq!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::UnknownVersion { version: 9 }) + ); +} + +#[test] +fn empty_and_malformed_bodies_fail_typedly() { + assert_eq!(QuoteBody::from_bytes(&[]), Err(BodyError::Empty)); + + // A known tag with a truncated payload. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.truncate(bytes.len() - 1); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); + + // A known tag with trailing bytes: borsh requires full consumption. + let mut bytes = v2_body().to_bytes().unwrap(); + bytes.push(0); + assert!(matches!( + QuoteBody::from_bytes(&bytes), + Err(BodyError::Malformed { version: 1, .. }) + )); +} + +#[test] +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.authorisation, AuthScheme::Eip712); +} + +#[test] +fn adapter_reports_an_unknown_version_as_invalid_body() { + let mut bytes = v2_body().to_bytes().unwrap(); + bytes[0] = 7; + let err = DemoAdapter::derive_header(bytes).unwrap_err(); + match err { + VenueError::InvalidBody(detail) => assert!(detail.contains("unknown body version 7")), + other => panic!("expected invalid-body, got {other:?}"), + } +} + +#[test] +fn typed_client_round_trips_through_the_pool_seam() { + let client = IntentClient::new(InProcessPool, "demo"); + + let outcome = 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!(matches!( + client.status(&[0, 1]).unwrap_err(), + ClientError::Venue(VenueError::InvalidReceipt) + )); +} + +#[test] +fn unbound_venue_is_unknown_at_the_pool() { + let client = IntentClient::new(InProcessPool, "nowhere"); + assert!(matches!( + client.submit(&v2_body()).unwrap_err(), + ClientError::Venue(VenueError::UnknownVenue) + )); +} From 622ec707f0e05093205cc7cc3db38ad3eb31cb48 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:52:33 +0000 Subject: [PATCH 13/29] sdk: emit per-component world from declared capabilities (#252) * feat(sdk): capability-select the wit-bindgen host adapter Split bind_host_via_wit_bindgen! into a types-only base plus one block per capability, selected through a caps list; the zero-argument form keeps emitting the full chain, local_store, logging set for modules compiled against a blanket world. Narrow read_latest_answer to ChainHost + LoggingHost, the two capabilities it exercises, so modules whose worlds omit local-store can still call it. * feat(sdk): emit a per-module world from declared capabilities Teach #[nexum_sdk::module] to read the crate's module.toml and synthesize an inline WIT world whose imports are exactly the [capabilities].required and optional declarations, replacing the blanket shepherd:cow/shepherd world every module compiled against. The built component's imports now equal its declarations by construction, so the runtime's capability check no longer leans on the toolchain eliding unused imports; an undeclared capability has no bindings at all, and an unknown capability name is a compile-time error. The emitted host adapter is capability-selected to match, and a rebuild anchor on module.toml recompiles the module when the manifest changes. Narrow price-alert's strategy bound to ChainHost + LoggingHost: its world imports only its declared chain and logging capabilities, so the full Host supertrait (which adds local-store) is unimplementable there by design. * test(runtime): pin the example component's imports to its declarations The per-module world acceptance: compile the built example component and assert its capability-bearing imports resolve to exactly the manifest's declared set, with no extension interface leaking in. Skips gracefully when the wasm fixture is not built, like the other e2e tests. * docs: record the retirement of import-elision for macro-built modules Macro-built components import what they declare by construction, so capability enforcement is a backstop for them; the elision dependency ADR-0009 flagged now applies only to hand-rolled modules still compiled against the supertype world. --- Cargo.lock | 1 + crates/nexum-macros/Cargo.toml | 1 + crates/nexum-macros/src/lib.rs | 120 +++++-- crates/nexum-macros/src/world.rs | 292 ++++++++++++++++++ .../src/manifest/capabilities.rs | 6 + crates/nexum-runtime/src/supervisor/tests.rs | 39 +++ crates/nexum-sdk/src/chain/chainlink.rs | 7 +- crates/nexum-sdk/src/wit_bindgen_macro.rs | 228 ++++++++------ docs/05-sdk-design.md | 30 +- docs/adr/0009-host-trait-surface.md | 4 +- docs/design/linker-extension-seam.md | 13 +- modules/examples/price-alert/src/strategy.rs | 11 +- 12 files changed, 605 insertions(+), 147 deletions(-) create mode 100644 crates/nexum-macros/src/world.rs diff --git a/Cargo.lock b/Cargo.lock index 9fc47ade..ba2384fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", + "toml 1.1.2+spec-1.1.0", ] [[package]] diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index 1f4dd26a..ad74099f 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-macros/Cargo.toml @@ -16,3 +16,4 @@ workspace = true 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 ad4bb770..af56b61c 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -1,8 +1,9 @@ //! Proc-macro glue for nexum runtime modules. //! //! [`module`] turns an `impl` block of named handlers into a complete -//! per-cdylib module: it emits the `wit_bindgen::generate!` call for the -//! blanket `shepherd:cow/shepherd` world, the host adapter (via +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for a +//! per-module world derived from the crate's `module.toml` +//! `[capabilities]` declarations, the host adapter (via //! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation //! whose `on-event` dispatches to the handlers present, and `export!`. //! @@ -14,8 +15,9 @@ //! directly. mod intent_body; +mod world; -use std::path::{Path, PathBuf}; +use std::path::Path; use proc_macro::TokenStream; use quote::quote; @@ -58,10 +60,21 @@ const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on /// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` /// impl, and `export!` around the untouched impl. /// -/// The one non-obvious invariant: the `wit`/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 directory is located by walking up from +/// The world is per module, not shared: the macro reads the crate's +/// `module.toml` and synthesizes a world whose imports are exactly the +/// `[capabilities].required` and `optional` declarations, so the built +/// component imports what the manifest declares and nothing else - the +/// runtime's load-time capability check passes by construction instead +/// of relying on the toolchain eliding unused imports. Corollaries: the +/// manifest must sit at the crate root and carry a `[capabilities]` +/// section, an undeclared capability's bindings simply do not exist +/// (using one is a compile error, the cue to declare it), and only the +/// host-adapter pieces for declared capabilities are emitted. +/// +/// 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 /// declare `wit-bindgen` as a direct dependency (the emitted /// `wit_bindgen::generate!` call resolves against the consumer's @@ -149,7 +162,15 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } let has = |name: &str| present.contains(&name); - let (nexum_wit, shepherd_wit) = match locate_wit() { + let (manifest_path, module_world) = match derive_module_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(&module_world.packages) { Ok(paths) => paths, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -157,8 +178,12 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } }; - let nexum_wit = nexum_wit.to_string_lossy().into_owned(); - let shepherd_wit = shepherd_wit.to_string_lossy().into_owned(); + 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(); // `init` is a required export; when the handler is absent the config // is bound but unused, so drop it to keep the module warning-clean. @@ -195,13 +220,18 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let message_arm = arm("on_message", "Message"); 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); + wit_bindgen::generate!({ - path: [#nexum_wit, #shepherd_wit], - world: "shepherd:cow/shepherd", + inline: #inline_world, + path: [#(#wit_paths),*], + world: "nexum:module-world/module", generate_all, }); - ::nexum_sdk::bind_host_via_wit_bindgen!(); + ::nexum_sdk::bind_host_via_wit_bindgen!(caps: [#(#adapter_caps),*]); #input @@ -232,23 +262,61 @@ fn is_plain_type(ty: &Type) -> bool { matches!(ty, Type::Path(tp) if tp.qself.is_none()) } -/// Locate the workspace `wit/nexum-host` and `wit/shepherd-cow` -/// directories by walking up from the consuming crate's manifest. -fn locate_wit() -> Result<(PathBuf, PathBuf), 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> { + 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 text = std::fs::read_to_string(&manifest_path).map_err(|e| { + format!( + "could not read {} ({e}); #[nexum_sdk::module] derives the module'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()))?; + let module_world = + world::synthesize(&declared).map_err(|e| format!("{}: {e}", manifest_path.display()))?; + Ok((manifest_path.to_string_lossy().into_owned(), module_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)); - while let Some(cur) = dir { + 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"); - let nexum = wit.join("nexum-host"); - let shepherd = wit.join("shepherd-cow"); - if nexum.is_dir() && shepherd.is_dir() { - return Ok((nexum, shepherd)); + if wit.join("nexum-host").is_dir() { + break wit; } dir = cur.parent(); - } - Err(format!( - "could not find a `wit/` directory containing `nexum-host` and `shepherd-cow` \ - in any ancestor of {manifest}" - )) + }; + 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() } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs new file mode 100644 index 00000000..f199b333 --- /dev/null +++ b/crates/nexum-macros/src/world.rs @@ -0,0 +1,292 @@ +//! 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. + +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 recognizes, in emission order. Mirrors +/// the runtime's core registry plus the extension namespaces the +/// workspace ships (`nexum:intent/pool`, `shepherd:cow/cow-api`). +const KNOWN: &[Capability] = &[ + Capability { + name: "chain", + import: Some("nexum:host/chain@0.2.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: "identity", + import: Some("nexum:host/identity@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "local-store", + import: Some("nexum:host/local-store@0.2.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: "remote-store", + import: Some("nexum:host/remote-store@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "messaging", + import: Some("nexum:host/messaging@0.2.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "logging", + import: Some("nexum:host/logging@0.2.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: "pool", + import: Some("nexum:intent/pool@0.1.0"), + packages: &["nexum-intent", "nexum-value-flow"], + adapter: None, + }, + Capability { + name: "cow-api", + import: Some("shepherd:cow/cow-api@0.2.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. Always starts with `nexum-host`. + 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; #[nexum_sdk::module] derives the module'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) +} + +/// 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(); + 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.2.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::*; + + #[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/chain")); + assert!(!world.wit.contains("shepherd:cow")); + assert_eq!(world.packages, vec!["nexum-host"]); + 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.2.0;")); + assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); + } + + #[test] + fn pool_pulls_the_intent_and_value_flow_packages() { + let world = synthesize(&["pool".to_string()]).unwrap(); + assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); + assert_eq!( + world.packages, + vec!["nexum-host", "nexum-intent", "nexum-value-flow"] + ); + 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, vec!["nexum-host"]); + } + + #[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 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-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 064aea56..f222b249 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -5,6 +5,12 @@ //! built in, and each runtime extension contributes its own namespace at //! the composition root via [`CapabilityRegistry::register`]. An extension //! interface is enforceable only once its namespace is registered. +//! +//! Components built through `#[nexum_sdk::module]` compile against a +//! per-module world derived from the same manifest, so their imports +//! equal their declarations by construction and this check is a pure +//! backstop for them; it retains its teeth for components built against +//! a wider world by hand, where nothing upstream narrows the imports. use std::collections::HashSet; diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 666c074a..f413cd66 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -185,6 +185,45 @@ async fn e2e_supervisor_boots_example_module() { assert_eq!(supervisor.alive_count(), 1); } +/// The per-module world contract: the example component's +/// capability-bearing imports are exactly what its manifest declares +/// (`logging`), by construction of the emitted world rather than by +/// the toolchain eliding unused imports of a blanket world. +#[test] +fn e2e_example_component_imports_equal_declared_capabilities() { + let Some(wasm) = example_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(["logging"]), + "imports were: {imports:?}" + ); + + // No extension interface leaks in either: the blanket cow world is + // gone from modules that never declared it. + assert!( + imports + .iter() + .all(|name| !name.starts_with("shepherd:cow/")), + "imports were: {imports:?}" + ); +} + /// Boot with a manifest that subscribes to block events; dispatch one /// block event and verify the module was invoked and stayed alive. #[tokio::test] diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index f589f96b..57c49b70 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -18,7 +18,7 @@ use alloy_sol_types::{SolCall, sol}; use crate::Level; use crate::chain::{eth_call_params, parse_eth_call_result}; -use crate::host::Host; +use crate::host::{ChainHost, LoggingHost}; sol! { /// Chainlink AggregatorV3Interface - only the function the @@ -45,8 +45,11 @@ sol! { /// /// `domain` is embedded in the log line so a single host log stream /// can disambiguate which module's oracle failed. +// Bounded on the two capabilities it exercises (chain + logging), not +// the full `Host` supertrait, so modules whose worlds omit local-store +// can still call it. #[must_use] -pub fn read_latest_answer( +pub fn read_latest_answer( host: &H, chain_id: u64, oracle: Address, diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 3e33c68c..25a91376 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -8,12 +8,17 @@ //! `convert_level`. The code differed across modules in zero places //! that were not bugs. //! -//! The macro assumes the module compiles against a world that -//! includes `nexum:host/event-module` with `wit_bindgen::generate!({ -//! ..., generate_all })`, so the standard wit-bindgen output paths -//! (`nexum::host::chain`, `nexum::host::local_store`, etc., plus the -//! crate-root `Fault`) are in scope at the call site. Modules -//! using a different world need to keep their own adapter for now. +//! 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. +//! 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 +//! `nexum::host::*` module, so selecting a capability the world does +//! 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 @@ -24,18 +29,23 @@ //! ```ignore //! wit_bindgen::generate!({ /* ... */ }); //! nexum_sdk::bind_host_via_wit_bindgen!(); -//! // `WitBindgenHost`, `convert_chain_err`, `convert_fault`, -//! // `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, and -//! // `install_tracing` are now in -//! // scope, 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`. +//! // 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` +//! // (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`. //! ``` -/// Generate `WitBindgenHost` + the core `*Host` trait impls + the -/// error / level converters. See module docs. +/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / +/// level converters for the selected capabilities. See module docs. /// /// Macro hygiene note: `macro_rules!` is not hygienic for type names /// or function items, so the names `WitBindgenHost`, `convert_chain_err`, @@ -43,77 +53,21 @@ /// 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 + // full adapter. () => { + $crate::bind_host_via_wit_bindgen!(caps: [chain, local_store, logging]); + }; + // Capability-selected form: the base pieces (which need only the + // always-present `nexum:host/types`) plus one block per listed + // capability. + (caps: [$($cap:ident),* $(,)?]) => { /// Wraps the module's per-cdylib wit-bindgen imports so the /// strategy can hold a `&impl Host` instead of dispatching on /// the free functions directly. Generated by /// `nexum_sdk::bind_host_via_wit_bindgen!`. struct WitBindgenHost; - impl $crate::host::ChainHost for WitBindgenHost { - fn request( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { - nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) - } - } - - impl $crate::host::LocalStoreHost for WitBindgenHost { - fn get( - &self, - key: &str, - ) -> ::core::result::Result< - ::core::option::Option<::std::vec::Vec>, - $crate::host::Fault, - > { - nexum::host::local_store::get(key).map_err(convert_fault) - } - fn set( - &self, - key: &str, - value: &[u8], - ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) - } - fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) - } - 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) - } - } - - impl $crate::host::LoggingHost for WitBindgenHost { - fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); - } - } - - /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into - /// the SDK's host-neutral `ChainError`. Exhaustive on both the - /// `Fault` vocabulary and the `RpcError` shape. - 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)) - } - nexum::host::chain::ChainError::Rpc(r) => { - $crate::host::ChainError::Rpc($crate::host::RpcError { - code: r.code, - message: r.message, - data: r.data.map(::core::convert::Into::into), - }) - } - } - } - /// 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. @@ -160,25 +114,6 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Translate a `tracing_core::Level` into the 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 - } - } - /// Rebuild the native alloy log from the per-cdylib wit-bindgen /// `chain-log` record. The one conversion home for the guest WIT /// edge: strategies receive `nexum_sdk::events::Log`, never the @@ -201,6 +136,101 @@ macro_rules! bind_host_via_wit_bindgen { } } + $($crate::__bind_host_cap_via_wit_bindgen!($cap);)* + }; +} + +/// One capability's slice of the `WitBindgenHost` adapter. Invoked by +/// [`bind_host_via_wit_bindgen!`]; not part of the public surface. +#[doc(hidden)] +#[macro_export] +macro_rules! __bind_host_cap_via_wit_bindgen { + (chain) => { + impl $crate::host::ChainHost for WitBindgenHost { + fn request( + &self, + chain_id: u64, + method: &str, + params: &str, + ) -> ::core::result::Result<::std::string::String, $crate::host::ChainError> { + nexum::host::chain::request(chain_id, method, params).map_err(convert_chain_err) + } + } + + /// Lift the wit-bindgen `chain.chain-error` (per-cdylib) into + /// the SDK's host-neutral `ChainError`. Exhaustive on both the + /// `Fault` vocabulary and the `RpcError` shape. + 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)) + } + nexum::host::chain::ChainError::Rpc(r) => { + $crate::host::ChainError::Rpc($crate::host::RpcError { + code: r.code, + message: r.message, + data: r.data.map(::core::convert::Into::into), + }) + } + } + } + }; + (local_store) => { + impl $crate::host::LocalStoreHost for WitBindgenHost { + fn get( + &self, + key: &str, + ) -> ::core::result::Result< + ::core::option::Option<::std::vec::Vec>, + $crate::host::Fault, + > { + nexum::host::local_store::get(key).map_err(convert_fault) + } + fn set( + &self, + key: &str, + value: &[u8], + ) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::set(key, value).map_err(convert_fault) + } + fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { + nexum::host::local_store::delete(key).map_err(convert_fault) + } + 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) + } + } + }; + (logging) => { + impl $crate::host::LoggingHost for WitBindgenHost { + fn log(&self, level: $crate::Level, message: &str) { + nexum::host::logging::log(convert_level(level), message); + } + } + + /// Translate a `tracing_core::Level` into the 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 + } + } + /// Routes guest `tracing` events to the bound host logging call. struct HostLogSink; diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 96699f85..93409a40 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -144,7 +144,11 @@ 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. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` +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 @@ -152,8 +156,10 @@ layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. `nexum-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 generates the -`wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` +`on_chain_logs`, `on_tick`, `on_message` - and the macro reads the +crate's `module.toml`, synthesizes the per-module world from its +`[capabilities]`, and generates the `wit_bindgen::generate!` call for +that world, the capability-selected `bind_host_via_wit_bindgen!` invocation, a `Guest` implementation whose `on_event` dispatches to whichever handlers are present (absent handlers become a no-op for that event), and `export!`: @@ -186,13 +192,17 @@ 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 currently always generates against the blanket - `shepherd:cow/shepherd` world with `generate_all` (every module gets - the full CoW-extended import set whether it uses `cow-api` or not). - Emitting a per-component world scoped to the manifest's declared - capabilities - which would retire the import-elision dependency - ADR-0009 flags - is separate follow-on work, tracked alongside the - venue-adapter macro below. + 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. - **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 diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index ef4aae32..fde9b463 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -96,6 +96,8 @@ This interacts with `wit_bindgen::generate!` in a way worth pinning here, becaus **Hardening planned for M5** (recorded here, NOT a 0.2 deliverable): generate a per-module world (`shepherd:cow/price-alert`, etc.) that only re-exports the capabilities the module declares. The M5 `#[nexum::module]` macro is the natural place to derive this world from the manifest. Eliminates the elision dependency. -Until then, **a module that adds an import of an undeclared capability will fail capability enforcement at boot**, not at compile time. This is the intended behaviour - the alternative would be to widen the supertype world or to make enforcement lenient, both of which would damage least-privilege. +**Update: the hardening shipped** (earlier than planned, in the M1 SDK-surfaces work). `#[nexum_sdk::module]` derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction, an undeclared capability is a compile-time error (its bindings do not exist), and `enforce_capabilities` is a backstop rather than an elision consumer. The paragraphs above stay accurate for hand-rolled modules still compiled against the supertype world (twap-monitor, ethflow-watcher, stop-loss). + +Until those migrate, **a hand-rolled module that adds an import of an undeclared capability will fail capability enforcement at boot**, not at compile time. This is the intended behaviour - the alternative would be to widen the supertype world or to make enforcement lenient, both of which would damage least-privilege. _Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ diff --git a/docs/design/linker-extension-seam.md b/docs/design/linker-extension-seam.md index 76525a92..f24d4b19 100644 --- a/docs/design/linker-extension-seam.md +++ b/docs/design/linker-extension-seam.md @@ -97,11 +97,14 @@ hands the extension its own entry to parse into a typed struct (cow-api's `CowConfig` reads `[extensions.cow]`, today one `orderbook_urls` per-chain map). -## Normative rule: elision and boot ordering - -Modules are compiled against the supertype world. The `wasm-tools` pipeline -elides any WIT import the produced component does not exercise, so a module -that never touches cow-api boots with a core-only linker. A module that DOES +## Normative rule: import narrowing and boot ordering + +Modules built through `#[nexum_sdk::module]` compile against a per-module +world derived from their manifest's `[capabilities]`, so a module that +never declares cow-api has no cow-api import and boots with a core-only +linker by construction. Hand-rolled modules compiled against the supertype +world reach the same shape a weaker way: the `wasm-tools` pipeline elides +any WIT import the produced component does not exercise. A module that DOES import an extension interface instantiates only if, before instantiation: - the extension's linker hook is registered (else an unsatisfied-import trap), AND diff --git a/modules/examples/price-alert/src/strategy.rs b/modules/examples/price-alert/src/strategy.rs index 0dcccd95..a6c773e8 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/modules/examples/price-alert/src/strategy.rs @@ -1,16 +1,19 @@ //! Pure strategy logic for the price-alert module. //! -//! Every interaction with the world flows through the [`Host`] trait +//! 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`. +//! hand the same function a `nexum_sdk_test::MockHost`. The bound is +//! `ChainHost + LoggingHost`, the module's two declared capabilities: +//! its world imports nothing else, so the full `Host` supertrait (which +//! adds local-store) is unimplementable here by design. use alloy_primitives::I256; use nexum_sdk::chain::chainlink::read_latest_answer; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{Fault, Host}; +use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; /// Resolved configuration, parsed from `module.toml::[config]` at @@ -44,7 +47,7 @@ pub enum Direction { /// lets the next block re-poll rather than propagating into the /// supervisor. Only host-level I/O on the persistence side would /// bubble up via `?`, and this module does not touch the store. -pub fn on_block( +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, From d71adb4b78e3167e10cba4ad7c0895054b6d32d4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:59:36 +0000 Subject: [PATCH 14/29] feat(runtime): fan intent-status events to module subscribers (#253) * feat(runtime): add the intent-status case to the host event variant The host event variant gains intent-status(intent-status-update): a venue id, the receipt, and the nexum:intent status vocabulary, so a subscriber sees where an intent is in its life without knowing how the host learnt it. nexum:host now depends on nexum:intent, so every bindgen over the host package carries the intent and value-flow packages on its resolve path, in dependency order; the runtime generates the intent types once and the adapter and pool bindgens remap onto them with 'with'. The module macro recognizes an on_intent_status handler and the example module ships one. * feat(runtime): poll venue statuses and fan intent-status events to subscribers The pool router puts every accepted receipt under watch and, on a configurable cadence ([limits.status_poll] interval_ms), polls each adapter's status export, reporting only transitions: the first poll reports the current status, repeats are deduplicated, a terminal status prunes the watch, and a receipt the venue disowns is dropped. A dedicated event-loop stream fans each transition to modules declaring [[subscription]] kind = "intent-status", optionally filtered by venue; polling only runs when there is at least one subscriber and one installed adapter. --- crates/nexum-macros/src/lib.rs | 18 +- crates/nexum-macros/src/world.rs | 37 ++- crates/nexum-runtime/src/bindings.rs | 58 ++-- crates/nexum-runtime/src/builder.rs | 16 +- crates/nexum-runtime/src/engine_config.rs | 31 ++ crates/nexum-runtime/src/host/impls/types.rs | 9 +- crates/nexum-runtime/src/host/pool_router.rs | 308 +++++++++++++++++- crates/nexum-runtime/src/manifest/load.rs | 24 ++ crates/nexum-runtime/src/manifest/types.rs | 11 + .../nexum-runtime/src/runtime/event_loop.rs | 59 ++++ crates/nexum-runtime/src/supervisor.rs | 74 ++++- crates/nexum-runtime/src/supervisor/tests.rs | 242 ++++++++++++++ crates/nexum-venue-sdk/src/bindings.rs | 2 +- crates/shepherd-cow-host/src/ext_cow.rs | 7 +- modules/ethflow-watcher/src/lib.rs | 7 +- modules/example/src/lib.rs | 12 + modules/examples/stop-loss/src/lib.rs | 7 +- modules/fixtures/clock-reader/src/lib.rs | 7 +- modules/fixtures/flaky-bomb/src/lib.rs | 7 +- modules/fixtures/fuel-bomb/src/lib.rs | 7 +- modules/fixtures/memory-bomb/src/lib.rs | 7 +- modules/fixtures/panic-bomb/src/lib.rs | 7 +- modules/twap-monitor/src/lib.rs | 7 +- wit/nexum-host/types.wit | 16 + 24 files changed, 925 insertions(+), 55 deletions(-) diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index af56b61c..6c8a81c8 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -48,14 +48,22 @@ pub fn derive_intent_body(input: TokenStream) -> TokenStream { /// with `on_` are rejected at compile time (a typo'd handler would /// otherwise silently never fire); any handler in the set that is absent /// is treated as a no-op in the generated `on-event` dispatch. -const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on_message"]; +const HANDLERS: [&str; 6] = [ + "init", + "on_block", + "on_chain_logs", + "on_tick", + "on_message", + "on_intent_status", +]; /// Generate the per-cdylib glue for a nexum module. /// /// Apply to an `impl` block whose associated functions are the event /// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, -/// `on_message`). Each handler takes the wit-bindgen payload for its -/// event and returns `Result<(), Fault>`; `init` takes the config table. +/// `on_message`, `on_intent_status`). Each handler takes the wit-bindgen +/// payload for its event and returns `Result<(), Fault>`; `init` takes +/// the config table. /// Handlers left undefined are ignored (their events become no-ops). The /// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` /// impl, and `export!` around the untouched impl. @@ -155,7 +163,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { return syn::Error::new_spanned( self_ty, "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ - of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`", + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`", ) .to_compile_error() .into(); @@ -218,6 +226,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { 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"); quote! { // Anchor a rebuild on the manifest: the emitted world is derived @@ -247,6 +256,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { #logs_arm #tick_arm #message_arm + #intent_status_arm } } } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index f199b333..f70db6d1 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -97,7 +97,9 @@ 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. Always starts with `nexum-host`. + /// 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>, @@ -154,7 +156,12 @@ pub fn synthesize(declared: &[String]) -> Result { } let mut imports = String::new(); - let mut packages = vec!["nexum-host"]; + // 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"]; let mut adapters = Vec::new(); for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { @@ -194,13 +201,18 @@ 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"]; + #[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/chain")); assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, vec!["nexum-host"]); + assert_eq!(world.packages, BASE_PACKAGES); assert_eq!(world.adapters, vec!["logging"]); } @@ -208,17 +220,22 @@ 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-host", "shepherd-cow"]); + assert_eq!( + world.packages, + vec![ + "nexum-value-flow", + "nexum-intent", + "nexum-host", + "shepherd-cow" + ] + ); } #[test] - fn pool_pulls_the_intent_and_value_flow_packages() { + fn pool_adds_no_packages_beyond_the_base_set() { let world = synthesize(&["pool".to_string()]).unwrap(); assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); - assert_eq!( - world.packages, - vec!["nexum-host", "nexum-intent", "nexum-value-flow"] - ); + assert_eq!(world.packages, BASE_PACKAGES); assert!(world.adapters.is_empty()); } @@ -226,7 +243,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, vec!["nexum-host"]); + assert_eq!(world.packages, BASE_PACKAGES); } #[test] diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 79c0729a..927861bf 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -8,29 +8,42 @@ //! //! 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. wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + ], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, + additional_derives: [PartialEq], }); /// WIT bindings for the second component kind: the /// `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` -/// 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 intent and -/// value-flow types generate here, their first non-test binding. +/// `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. mod venue_adapter { wasmtime::component::bindgen!({ path: [ - "../../wit/nexum-host", "../../wit/nexum-value-flow", "../../wit/nexum-intent", + "../../wit/nexum-host", "../../wit/nexum-adapter", ], world: "nexum:adapter/venue-adapter", @@ -40,6 +53,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, }, }); } @@ -48,11 +63,11 @@ 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 `venue_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. +/// 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 +/// structurally identical copies. Async, because the `Host` impl awaits the +/// per-adapter mutex and the adapter's own async guest calls. mod pool_host { wasmtime::component::bindgen!({ inline: " @@ -64,22 +79,23 @@ mod pool_host { path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], imports: { default: async }, with: { - "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, - "nexum:intent/types": super::venue_adapter::nexum::intent::types, + "nexum:value-flow/types": super::nexum::value_flow::types, + "nexum:intent/types": super::nexum::intent::types, }, }); } -/// 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 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 venue_adapter::nexum::intent::types::{ - AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError, -}; +pub use nexum::intent::types::{AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; /// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::nexum::value_flow::types as value_flow; +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; /// 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/builder.rs b/crates/nexum-runtime/src/builder.rs index aaad84e0..306ac323 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -190,10 +190,15 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let block_chains = supervisor.block_chains(); 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.pool_router().venue_count() > 0; // 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() { + if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { info!("no [[subscription]] entries - engine has nothing to run; exiting"); let event_loop = ctx .executor @@ -222,6 +227,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { ctx.executor, &mut reconnect_tasks, ); + let intent_status_stream = poll_statuses.then(|| { + event_loop::open_intent_status_stream( + supervisor.pool_router(), + engine_cfg.limits.status_poll_interval(), + ctx.executor, + &mut reconnect_tasks, + ) + }); let event_loop = ctx.executor.spawn(Box::pin(async move { let shutdown = async move { @@ -247,6 +260,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, + intent_status_stream, reconnect_tasks, shutdown, ) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 55a2ba80..a5046b75 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -265,6 +265,11 @@ 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 +/// 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); + /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: /// zero would fail every request instantly, and huge values overflow /// timer arithmetic. @@ -313,6 +318,9 @@ pub struct ModuleLimits { /// Per-caller intent submission quota. #[serde(default)] pub quota: QuotaLimitsSection, + /// Router-driven intent status polling cadence. + #[serde(default)] + pub status_poll: StatusPollSection, } impl ModuleLimits { @@ -391,6 +399,16 @@ impl ModuleLimits { ) } + /// Resolved status-poll cadence (override or default). A zero interval + /// saturates up to 1 ms so a misconfigured cadence busy-loops a poll + /// task instead of dividing by zero timer arithmetic. + pub fn status_poll_interval(&self) -> Duration { + self.status_poll + .interval_ms + .map(|ms| Duration::from_millis(ms.max(1))) + .unwrap_or(DEFAULT_STATUS_POLL_INTERVAL) + } + /// Resolved per-caller submission quota (overrides or defaults). A zero /// `max_charges` is saturated up to 1 by the router builder, so a /// misconfigured budget still admits one submission rather than bricking @@ -493,6 +511,19 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } +/// `[limits.status_poll]` intent 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 router 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)] +pub struct StatusPollSection { + /// Milliseconds between status poll sweeps. + pub interval_ms: Option, +} + /// Resolved log retention limits the in-memory store enforces. Built by /// [`ModuleLimits::logs`]. #[derive(Debug, Clone, Copy)] diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/crates/nexum-runtime/src/host/impls/types.rs index f9516569..a035a97d 100644 --- a/crates/nexum-runtime/src/host/impls/types.rs +++ b/crates/nexum-runtime/src/host/impls/types.rs @@ -1,8 +1,13 @@ -//! `nexum:host/types` is a type-only interface (no functions). The -//! generated trait is empty; we just provide the marker impl. +//! `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. 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 0c620d37..c33252b3 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -31,7 +31,9 @@ use tokio::sync::Mutex as AsyncMutex; use tracing::warn; use wasmtime::Store; -use crate::bindings::{IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError}; +use crate::bindings::{ + IntentHeader, IntentStatus, IntentStatusUpdate, SubmitOutcome, VenueAdapter, VenueError, +}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -248,6 +250,27 @@ struct QuotaLedger { per_caller: HashMap>, } +/// One receipt the router 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, + 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 { + matches!( + status, + IntentStatus::Settled(_) + | IntentStatus::Failed(_) + | IntentStatus::Expired + | IntentStatus::Cancelled + ) +} + /// 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. @@ -256,6 +279,9 @@ struct PoolRouterInner { guard: Arc, quota: PoolQuota, 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 @@ -341,7 +367,121 @@ impl PoolRouter { } // A forwarded submission consumes one unit of the caller's budget. self.charge(caller); - adapter.submit(&body).await + 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. + if let SubmitOutcome::Accepted(receipt) = &outcome { + self.watch(venue, receipt.clone()); + } + Ok(outcome) + } + + /// 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) { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + if watched + .iter() + .any(|w| w.venue == venue && w.receipt == receipt) + { + return; + } + watched.push(WatchedIntent { + venue: venue.to_owned(), + receipt, + last: None, + }); + } + + /// Number of receipts currently under status watch. + pub fn watched_count(&self) -> usize { + self.inner + .watched + .lock() + .expect("watch list poisoned") + .len() + } + + /// Poll every watched receipt against its adapter's status export and + /// 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. + 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 watched = self.inner.watched.lock().expect("watch list poisoned"); + watched + .iter() + .map(|w| (w.venue.clone(), w.receipt.clone())) + .collect() + }; + let mut updates = Vec::new(); + for (venue, receipt) in snapshot { + // Installed adapters never leave the router, so a resolve + // failure here is unreachable; skip defensively regardless. + let Ok(slot) = self.resolve(&venue) else { + continue; + }; + let polled = { + let mut adapter = slot.lock().await; + adapter.status(receipt.clone()).await + }; + match polled { + Ok(status) => { + if let Some(update) = self.record_polled_status(&venue, &receipt, status) { + 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, + error = ?err, + "status poll failed - retrying on the next cadence", + ); + } + } + } + updates + } + + /// 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. + fn record_polled_status( + &self, + venue: &str, + 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); + if is_terminal(&status) { + watched.remove(pos); + } else { + watched[pos].last = Some(status.clone()); + } + changed.then(|| IntentStatusUpdate { + venue: venue.to_owned(), + receipt: receipt.to_vec(), + status, + }) + } + + /// 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 @@ -436,6 +576,7 @@ impl PoolRouterBuilder { guard: self.guard, quota, ledger: Mutex::new(QuotaLedger::default()), + watched: Mutex::new(Vec::new()), }), } } @@ -453,6 +594,7 @@ pub struct DuplicateVenue { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; + use crate::bindings::nexum::intent::types::UnsignedTx; use crate::bindings::value_flow::Settlement; use crate::bindings::{AuthScheme, IntentHeader}; @@ -477,6 +619,9 @@ mod tests { calls: Arc, derive: Result, submit: Result, + /// Statuses served front-first by consecutive `status` calls; + /// once drained, every further call reports `open`. + status_script: VecDeque>, } impl StubAdapter { @@ -485,6 +630,7 @@ mod tests { calls, derive: Ok(header()), submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + status_script: VecDeque::new(), } } @@ -493,6 +639,19 @@ mod tests { self } + fn with_submit(mut self, submit: Result) -> Self { + self.submit = submit; + self + } + + fn with_status_script( + mut self, + script: impl IntoIterator>, + ) -> Self { + self.status_script = script.into_iter().collect(); + self + } + async fn enter(&self) { let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); @@ -529,7 +688,9 @@ mod tests { fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { Box::pin(async move { self.calls.status.fetch_add(1, Ordering::SeqCst); - Ok(IntentStatus::Open) + self.status_script + .pop_front() + .unwrap_or(Ok(IntentStatus::Open)) }) } @@ -762,4 +923,145 @@ mod tests { let router = PoolRouterBuilder::new(PoolQuota::new(0, Duration::from_secs(60))).build(); assert_eq!(router.inner.quota.max_charges, 1); } + + // ── status watch + polling ──────────────────────────────────────── + + #[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)); + + assert_eq!(router.watched_count(), 0); + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(router.watched_count(), 1); + + // Re-submitting the same receipt does not double-watch it. + router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(router.watched_count(), 1); + } + + #[tokio::test] + async fn requires_signing_outcome_is_not_watched() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls).with_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain_id: 1, + to: vec![0u8; 20], + value: Vec::new(), + input: Vec::new(), + }))); + let router = router_with(PoolQuota::default(), None, adapter); + + router + .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()); + } + + #[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()) + .await + .expect("submit succeeds"); + + // First poll: `last` is unset, so the current status reports. + let first = router.poll_status_transitions().await; + 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); + + // Second poll: same status, nothing to report. + assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!(calls.status.load(Ordering::SeqCst), 2); + assert_eq!(router.watched_count(), 1, "open is not terminal"); + } + + #[tokio::test] + async fn poll_reports_each_transition_and_prunes_on_terminal() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Open), + Ok(IntentStatus::Settled(Some(b"tx".to_vec()))), + ]); + let router = router_with(PoolQuota::default(), None, adapter); + router + .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); + } + let statuses: Vec<&IntentStatus> = seen.iter().map(|u| &u.status).collect(); + assert_eq!( + statuses, + vec![ + &IntentStatus::Pending, + &IntentStatus::Open, + &IntentStatus::Settled(Some(b"tx".to_vec())), + ], + "the repeated pending is deduplicated; each transition reports once", + ); + assert_eq!(router.watched_count(), 0, "settled prunes the watch"); + // A further poll has nothing left to ask the adapter about. + assert!(router.poll_status_transitions().await.is_empty()); + } + + #[tokio::test] + async fn poll_failure_keeps_the_watch_for_the_next_cadence() { + 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()) + .await + .expect("submit succeeds"); + + assert!(router.poll_status_transitions().await.is_empty()); + assert_eq!( + router.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; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].status, IntentStatus::Open); + } + + #[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", + ); + } } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 81368568..d46565a0 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -184,6 +184,30 @@ chain_id = 1 ); } + #[test] + fn load_parses_intent_status_subscription() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "intent-status" + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::IntentStatus { venue: None } + )); + assert!(matches!( + &manifest.subscriptions[1], + Subscription::IntentStatus { venue: Some(v) } if v == "cow" + )); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 4802e0d2..bcd45fea 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -93,6 +93,17 @@ 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. + #[serde(default)] + venue: Option, + }, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 1045e79d..ad3fc0c4 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,6 +40,7 @@ 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::runtime::restart_policy::backoff_for; use crate::runtime::task::{TaskExecutor, TaskExit, TaskSet}; @@ -146,6 +147,40 @@ where streams } +/// Router-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 +/// 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, + cadence: Duration, + executor: &dyn 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)))); + 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, + cadence: Duration, + tx: mpsc::Sender, +) -> TaskExit { + loop { + tokio::time::sleep(cadence).await; + for update in router.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`. @@ -457,6 +492,11 @@ 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. /// @@ -469,6 +509,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, + intent_status_stream: Option, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) { @@ -490,9 +531,14 @@ 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 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 started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -509,6 +555,7 @@ pub async fn run( Box, Option>, ), + IntentStatus(nexum::host::types::IntentStatusUpdate), Shutdown, StreamPanic(&'static str), } @@ -538,6 +585,11 @@ 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"), + }, }; match next { @@ -551,6 +603,10 @@ pub async fn run( .await; dispatched_chain_logs += 1; } + NextEvent::IntentStatus(update) => { + supervisor.dispatch_intent_status(update).await; + dispatched_intent_statuses += 1; + } NextEvent::Shutdown => { // Drop the stream-end receivers so the reconnect // tasks observe a closed channel and exit. Then drain @@ -558,10 +614,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); + drop(intent_statuses); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, + dispatched_intent_statuses, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -573,6 +631,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); + drop(intent_statuses); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 8dc6293f..b74bb102 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -924,7 +924,7 @@ impl Supervisor { .collect(); for idx in candidate_indices { if matches!( - self.dispatch_to(idx, chain, "block", block_number, &event) + self.dispatch_to(idx, chain_id, "block", block_number, &event) .await, DispatchOutcome::Ok, ) { @@ -1008,7 +1008,7 @@ impl Supervisor { let ok = matches!( self.dispatch_to( idx, - chain, + chain.id(), "chain-log", block_number.unwrap_or_default(), &event @@ -1042,20 +1042,86 @@ impl Supervisor { ok } + /// Dispatch a router-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 + /// first, poisoned modules are skipped. + pub async fn dispatch_intent_status( + &mut self, + update: nexum::host::types::IntentStatusUpdate, + ) -> usize { + let now = std::time::Instant::now(); + let restart_candidates: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + !m.poisoned && !m.alive && m.next_attempt.is_some_and(|t| t <= now) + }) + .collect(); + for idx in restart_candidates { + self.try_restart(idx).await; + } + + let candidate_indices: Vec = (0..self.modules.len()) + .filter(|&i| { + let m = &self.modules[i]; + if m.poisoned || !m.alive { + return false; + } + m.subscriptions.iter().any(|s| { + matches!( + s, + Subscription::IntentStatus { venue } + if venue.as_deref().is_none_or(|v| v == update.venue) + ) + }) + }) + .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. + if matches!( + self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + DispatchOutcome::Ok, + ) { + dispatched += 1; + } + } + 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 shared intent pool router carried by every module store. + pub fn pool_router(&self) -> PoolRouter { + self.pool_router.clone() + } + /// Shared per-module dispatch path: refuel, call `on_event`, and /// process the three outcomes (ok / fault / trap) with the /// same telemetry + lifecycle bookkeeping. Returns whether the /// guest call succeeded; the caller layers any path-specific /// follow-up (e.g. the progress marker on `dispatch_block`). + /// `chain_id` is telemetry only; chain-less event kinds pass 0. async fn dispatch_to( &mut self, idx: usize, - chain: Chain, + chain_id: u64, event_kind: &'static str, block_number: u64, event: &nexum::host::types::Event, ) -> DispatchOutcome { - let chain_id = chain.id(); let poison_policy = self.poison_policy; // Hoisted before the per-module borrow so the trap arm can // synthesize a panic record without re-borrowing `self`. diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index f413cd66..84346fd6 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -47,6 +47,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), + None, crate::runtime::task::TaskSet::new(), shutdown, ) @@ -279,6 +280,247 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the router: 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::pool_router::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: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: crate::bindings::value_flow::Settlement::EvmChain(1), + authorisation: crate::bindings::AuthScheme::Unsigned, + }) + }) + } + + 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 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(), + ); + builder.install("cow".to_owned(), adapter).expect("install"); + builder.build() +} + +/// 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 router 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 router 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([ + IntentStatus::Pending, + IntentStatus::Settled(None), + ])); + router + .submit("test-caller", "cow", b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in router.poll_status_transitions().await { + delivered += supervisor.dispatch_intent_status(update).await; + } + } + assert_eq!(delivered, 2, "pending then settled, 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: IntentStatus::Open, + }; + 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 crate::runtime::task::{TaskSet, TokioExecutor}; + + 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 router = scripted_router(ScriptedAdapter::new([])); + router + .submit("test-caller", "cow", b"body".to_vec()) + .await + .expect("submit"); + + let executor = TokioExecutor; + let mut tasks = TaskSet::new(); + let stream = crate::runtime::event_loop::open_intent_status_stream( + router, + 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::>(), + ); +} + /// 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 diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs index c8b3c2f4..3e88fff2 100644 --- a/crates/nexum-venue-sdk/src/bindings.rs +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -12,9 +12,9 @@ wit_bindgen::generate!({ path: [ - "../../wit/nexum-host", "../../wit/nexum-value-flow", "../../wit/nexum-intent", + "../../wit/nexum-host", "../../wit/nexum-adapter", ], world: "nexum:adapter/venue-adapter", diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index 55336bf5..018adaba 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -25,7 +25,12 @@ use crate::cow_orderbook::{CowApiError, OrderBookPool}; mod bindings { wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/cow-ext", imports: { default: async }, with: { "nexum:host/types": nexum_runtime::bindings::nexum::host::types }, diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 3286a566..0c04791b 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -37,7 +37,12 @@ use wit_bindgen as _; #[cfg(target_arch = "wasm32")] wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 273b299d..5c7445b0 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -66,4 +66,16 @@ impl ExampleModule { ); Ok(()) } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "intent status update from venue {} ({} receipt bytes)", + update.venue, + update.receipt.len(), + ), + ); + Ok(()) + } } diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index b5df56f9..be6ef1b7 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -23,7 +23,12 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + "../../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index d2d7a54b..b072abd0 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -16,8 +16,13 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index 63157a8b..cd5c7d41 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -21,8 +21,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use std::sync::OnceLock; diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index eb158a26..14b55f13 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -13,8 +13,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index 5feeadc3..cf1dd031 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -12,8 +12,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 086738f3..09fcb89c 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -13,8 +13,13 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: "../../../wit/nexum-host", + path: [ + "../../../wit/nexum-value-flow", + "../../../wit/nexum-intent", + "../../../wit/nexum-host", + ], world: "nexum:host/event-module", + generate_all, }); use nexum::host::{logging, types}; diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index e6f4fa62..c23eb237 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -24,7 +24,12 @@ #![allow(clippy::too_many_arguments)] wit_bindgen::generate!({ - path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + path: [ + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-host", + "../../wit/shepherd-cow", + ], world: "shepherd:cow/shepherd", generate_all, }); diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 2cd5c7e2..6c48dc29 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -5,6 +5,8 @@ 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 { @@ -58,11 +60,25 @@ interface types { fired-at: u64, } + /// 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. + 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, + } + variant event { block(block), chain-logs(chain-logs), tick(tick), message(message), + intent-status(intent-status-update), } /// Opaque config from module.toml [config] section. From 766d5324c39a8d84fb9847000b8cceabf3cb3403 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:05:59 +0000 Subject: [PATCH 15/29] feat(sdk): add the #[nexum::venue] adapter macro (#296) Extend nexum-macros with the venue-adapter counterpart to #[module]: read the crate's module.toml, synthesize a per-component venue-adapter world exporting init plus nexum:intent/adapter and importing exactly the declared scoped transport, then emit the per-cdylib wit-bindgen call, the Guest export glue wiring the adapter's associated functions, and export!. An adapter built this way imports what its manifest declares and nothing else, retiring the toolchain-elision dependency on the venue side rather than as follow-on work. A capability outside the venue-permitted set (chain, messaging, http) is rejected at expansion, so an adapter structurally cannot reach host key material or persistent state. Ship echo-venue as the reference adapter and pin its built component's capability-bearing imports to its single declared capability. --- Cargo.lock | 104 +++++++- Cargo.toml | 1 + crates/nexum-macros/src/lib.rs | 239 ++++++++++++++++++- crates/nexum-macros/src/world.rs | 112 +++++++++ crates/nexum-runtime/src/supervisor/tests.rs | 68 ++++++ crates/nexum-venue-sdk/src/lib.rs | 12 + justfile | 5 + modules/examples/echo-venue/Cargo.toml | 16 ++ modules/examples/echo-venue/module.toml | 24 ++ modules/examples/echo-venue/src/lib.rs | 71 ++++++ 10 files changed, 633 insertions(+), 19 deletions(-) create mode 100644 modules/examples/echo-venue/Cargo.toml create mode 100644 modules/examples/echo-venue/module.toml create mode 100644 modules/examples/echo-venue/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ba2384fb..bb76752e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,6 +2138,14 @@ dependencies = [ "spki", ] +[[package]] +name = "echo-venue" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", + "wit-bindgen 0.58.0", +] + [[package]] name = "educe" version = "0.6.0" @@ -6082,6 +6090,18 @@ dependencies = [ "wasmparser 0.253.0", ] +[[package]] +name = "wasm-metadata" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f998ccc6e012f7b86865eb2a106c8a0422017a1a88977ce01a69f2244be2e57" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", +] + [[package]] name = "wasm-metadata" version = "0.253.0" @@ -6822,13 +6842,33 @@ dependencies = [ "bitflags", ] +[[package]] +name = "wit-bindgen" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a43552cfa071f246cfd99e5dbb23710dfe7336b3259e09339818483359470749" +dependencies = [ + "wit-bindgen-rust-macro 0.58.0", +] + [[package]] name = "wit-bindgen" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" dependencies = [ - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.59.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4738d1c9a78e97bc7f664bfafd5d8e67d7bb26faa5c41e6d628e8bbdad3ec351" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.251.0", ] [[package]] @@ -6842,6 +6882,22 @@ dependencies = [ "wit-parser 0.253.0", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130ce1f531bc9f9a75922244aa773bf5e2117fda1ef4a86b9f98d6b8135eb46" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.118", + "wasm-metadata 0.251.0", + "wit-bindgen-core 0.58.0", + "wit-component 0.251.0", +] + [[package]] name = "wit-bindgen-rust" version = "0.59.0" @@ -6853,9 +6909,24 @@ dependencies = [ "indexmap 2.14.0", "prettyplease", "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.253.0", + "wit-bindgen-core 0.59.0", + "wit-component 0.253.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07296369e4d598e7e79b64eef66f724d83324ea671bcf677d78fc5cf92604ae5" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core 0.58.0", + "wit-bindgen-rust 0.58.0", ] [[package]] @@ -6869,8 +6940,27 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.59.0", + "wit-bindgen-rust 0.59.0", +] + +[[package]] +name = "wit-component" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a5e60173c413659c689f0581b0cf5d1a2404077568f9ffdce748a9eb2fc913" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.251.0", + "wasm-metadata 0.251.0", + "wasmparser 0.251.0", + "wit-parser 0.251.0", ] [[package]] @@ -6887,7 +6977,7 @@ dependencies = [ "serde_derive", "serde_json", "wasm-encoder 0.253.0", - "wasm-metadata", + "wasm-metadata 0.253.0", "wasmparser 0.253.0", "wit-parser 0.253.0", ] diff --git a/Cargo.toml b/Cargo.toml index 17bd563f..5e5f6abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", + "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", "modules/examples/stop-loss", diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 6c8a81c8..c760b37f 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -7,12 +7,17 @@ //! `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 `nexum:intent/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. //! //! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, -//! `nexum_venue_sdk::IntentBody`) rather than depending on this crate -//! directly. +//! `nexum_venue_sdk::venue`, `nexum_venue_sdk::IntentBody`) rather than +//! depending on this crate directly. mod intent_body; mod world; @@ -266,32 +271,242 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +/// The associated functions the `nexum:intent/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"]; + +/// 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 `nexum:intent/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 +/// 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::*` 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(), + "#[nexum_venue_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, + "#[nexum_venue_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, + "#[nexum_venue_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, + "#[nexum_venue_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!( + "#[nexum_venue_sdk::venue] requires the adapter face; this impl is missing {:?}. \ + Define all of `derive_header`, `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; + + // `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::nexum::intent::adapter::Guest for __NexumVenueAdapterExport { + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::IntentHeader, + nexum::intent::types::VenueError, + > { + <#self_ty>::derive_header(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::SubmitOutcome, + nexum::intent::types::VenueError, + > { + <#self_ty>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result< + nexum::intent::types::IntentStatus, + nexum::intent::types::VenueError, + > { + <#self_ty>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), nexum::intent::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 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> { +/// 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_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 text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( - "could not read {} ({e}); #[nexum_sdk::module] derives the module's WIT world \ - from the manifest's [capabilities] section, so the manifest must sit next to \ - Cargo.toml", + "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()))?; - let module_world = - world::synthesize(&declared).map_err(|e| format!("{}: {e}", manifest_path.display()))?; - Ok((manifest_path.to_string_lossy().into_owned(), module_world)) + Ok((manifest_path.to_string_lossy().into_owned(), declared)) +} + +/// 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> { + 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)) +} + +/// 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("#[nexum_venue_sdk::venue]")?; + let venue_world = + world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((manifest_path, venue_world)) } /// Locate the workspace `wit/` root (the ancestor directory whose `wit/` diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index f70db6d1..9a6db2c4 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -139,6 +139,68 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { Ok(names) } +/// Capabilities a venue adapter may import. A venue speaks one venue's +/// protocol over scoped transport and nothing else: chain RPC, +/// messaging, and outbound HTTP (granted through the SDK's wasi:http +/// client, so no world import). It structurally cannot reach host key +/// material or persistent state, so `local-store`, `remote-store`, +/// `identity`, and `logging` are refused rather than silently imported. +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 +/// 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 +/// persistent state is rejected at expansion, not at boot. +pub fn synthesize_venue(declared: &[String]) -> Result { + for name in declared { + if !VENUE_CAPABILITIES.contains(&name.as_str()) { + let permitted = VENUE_CAPABILITIES.join(", "); + return Err(format!( + "capability `{name}` is not available to a venue adapter; a venue may import \ + only scoped transport ({permitted}) and structurally cannot touch local-store, \ + remote-store, identity, or logging" + )); + } + } + + 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. + let packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + 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"); + } + } + + 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", + ); + 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", + ); + + Ok(ModuleWorld { + wit, + packages, + // The venue export glue wires the adapter's associated functions + // to the world's Guest traits directly; there is no host-trait + // adapter to bind, so no capability idents to pass on. + adapters: Vec::new(), + }) +} + /// 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 @@ -260,6 +322,56 @@ mod tests { assert!(err.contains("logging")); } + #[test] + fn venue_world_exports_the_adapter_face() { + let world = synthesize_venue(&["chain".to_string()]).unwrap(); + assert!(world.wit.starts_with("package nexum:venue-world;")); + assert!(world.wit.contains("world venue-adapter {")); + assert!( + world + .wit + .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!(world.adapters.is_empty()); + } + + #[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/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;")); + } + + #[test] + fn venue_world_grants_http_without_a_world_import() { + 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); + } + + #[test] + 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;")); + } + + #[test] + fn venue_world_refuses_non_transport_capabilities() { + for cap in ["local-store", "remote-store", "identity", "logging", "pool"] { + let err = synthesize_venue(&[cap.to_string()]).unwrap_err(); + assert!(err.contains(cap), "message was: {err}"); + assert!(err.contains("venue adapter"), "message was: {err}"); + } + } + #[test] fn manifest_capabilities_reads_required_and_optional() { let caps = manifest_capabilities( diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 84346fd6..13080946 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -87,6 +87,32 @@ fn example_module_toml() -> PathBuf { .join("modules/example/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 + } +} + /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -225,6 +251,48 @@ fn e2e_example_component_imports_equal_declared_capabilities() { ); } +/// 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")), + "imports were: {imports:?}" + ); +} + /// Boot with a manifest that subscribes to block events; dispatch one /// block event and verify the module was invoked and stayed alive. #[tokio::test] diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs index 528c717f..45a5dc43 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -61,6 +61,18 @@ pub use client::{ClientError, IntentClient, IntentPool}; /// 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 +/// scoped transport. See [`nexum_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; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. diff --git a/justfile b/justfile index 6e73b344..e960a4f5 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,11 @@ build-engine: 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. +build-venue: + cargo build --target wasm32-wasip2 --release -p echo-venue + # Build everything build: build-engine build-module diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml new file mode 100644 index 00000000..f3af0756 --- /dev/null +++ b/modules/examples/echo-venue/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "echo-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[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/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml new file mode 100644 index 00000000..f57607ca --- /dev/null +++ b/modules/examples/echo-venue/module.toml @@ -0,0 +1,24 @@ +# echo-venue adapter manifest - the reference #[nexum_venue_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. + +[module] +name = "echo-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# A venue adapter may declare only scoped transport (chain, messaging) +# plus the HTTP allowlist. echo-venue reads chain state to derive its +# header and needs nothing else. +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "echo-venue" diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs new file mode 100644 index 00000000..0c754291 --- /dev/null +++ b/modules/examples/echo-venue/src/lib.rs @@ -0,0 +1,71 @@ +//! # echo-venue (reference Shepherd venue adapter) +//! +//! The minimal reference venue adapter: it echoes an intent body back as +//! its receipt and reports every receipt it issued as `open`. 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 the adapter face. +//! +//! It declares one capability (`chain`), so the built component imports +//! `nexum:host/chain` and nothing else: the per-component 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::chain; +use nexum::intent::types::{IntentHeader, IntentStatus, SubmitOutcome, VenueError}; +use nexum::value_flow::types::{Asset, AssetAmount, Settlement}; + +struct EchoVenue; + +#[nexum_venue_sdk::venue] +impl EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + 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. + 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, + }) + } + + 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 + // the scoped transport import the manifest declares. + let _ = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(receipt: Vec) -> Result { + if receipt.is_empty() { + Err(VenueError::InvalidReceipt) + } else { + Ok(IntentStatus::Open) + } + } + + fn cancel(receipt: Vec) -> Result<(), VenueError> { + if receipt.is_empty() { + Err(VenueError::InvalidReceipt) + } else { + Ok(()) + } + } +} From 3be21a6e115b84344024a3419e4ef7d9a6ca7aef Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:08:43 +0000 Subject: [PATCH 16/29] feat(sdk-test): add the nexum-venue-test conformance kit (#297) Venue adapters need a way to prove their wire behaviour against the venue's published contract, in a form a non-Rust author can consume: codec vectors and header goldens ship as JSON files (bytes as lowercase hex) rather than Rust-only test code. CodecVectors publishes IntentBody wire bytes and holds a codec to them: round-trip vectors must decode and re-encode byte-exactly, and the failure vectors pin the typed empty, unknown-version, and malformed error contract. HeaderGoldens pairs published bodies with the header a conforming derive-header projects, spelled in serde mirror types whose case names match the WIT. MockTransport composes chain, messaging, and outbound HTTP mocks behind the SDK seams, including the messaging_topics scoping refusal. A reference venue (schema, derivation, and checked-in fixture files pinned by regeneration tests) documents both formats and proves the borsh primitive encodings for authors porting the codec. --- Cargo.lock | 16 + Cargo.toml | 1 + crates/nexum-venue-test/Cargo.toml | 40 ++ .../goldens/reference-header.json | 60 +++ crates/nexum-venue-test/src/codec.rs | 346 +++++++++++++ crates/nexum-venue-test/src/fixture.rs | 82 +++ crates/nexum-venue-test/src/header.rs | 459 +++++++++++++++++ crates/nexum-venue-test/src/lib.rs | 81 +++ crates/nexum-venue-test/src/reference.rs | 271 ++++++++++ crates/nexum-venue-test/src/report.rs | 51 ++ crates/nexum-venue-test/src/transport.rs | 473 ++++++++++++++++++ crates/nexum-venue-test/tests/conformance.rs | 133 +++++ .../vectors/reference-body.json | 71 +++ 13 files changed, 2084 insertions(+) create mode 100644 crates/nexum-venue-test/Cargo.toml create mode 100644 crates/nexum-venue-test/goldens/reference-header.json create mode 100644 crates/nexum-venue-test/src/codec.rs create mode 100644 crates/nexum-venue-test/src/fixture.rs create mode 100644 crates/nexum-venue-test/src/header.rs create mode 100644 crates/nexum-venue-test/src/lib.rs create mode 100644 crates/nexum-venue-test/src/reference.rs create mode 100644 crates/nexum-venue-test/src/report.rs create mode 100644 crates/nexum-venue-test/src/transport.rs create mode 100644 crates/nexum-venue-test/tests/conformance.rs create mode 100644 crates/nexum-venue-test/vectors/reference-body.json diff --git a/Cargo.lock b/Cargo.lock index bb76752e..831f6223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3641,6 +3641,22 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "nexum-venue-test" +version = "0.1.0" +dependencies = [ + "borsh", + "hex", + "http", + "nexum-sdk", + "nexum-sdk-test", + "nexum-venue-sdk", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 5e5f6abd..0b9ef958 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/nexum-sdk", "crates/nexum-sdk-test", "crates/nexum-venue-sdk", + "crates/nexum-venue-test", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/crates/nexum-venue-test/Cargo.toml b/crates/nexum-venue-test/Cargo.toml new file mode 100644 index 00000000..67eb2e3c --- /dev/null +++ b/crates/nexum-venue-test/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "nexum-venue-test" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Conformance kit for venue adapters: file-published borsh codec round-trip vectors, header-derivation golden fixtures, and an in-memory MockTransport for adapter unit tests." + +[lib] +# Plain library, host-only - adapter crates list this under +# [dev-dependencies] so it never ships in the wasm bundle. + +[lints] +workspace = true + +[dependencies] +# The reference schema's payload structs derive the borsh traits, the +# same way a real venue's payload types do. +borsh.workspace = true +# Vector and golden files carry byte fields as lowercase hex so a +# non-Rust author can read them without a borsh decoder. +hex.workspace = true +# `MockFetch` speaks the standard `http` request/response types the +# SDK's `Fetch` seam is expressed in. +http.workspace = true +# Transport seam vocabulary: `ChainHost`, `Fault`, and the `Fetch` seam +# the mocks implement. +nexum-sdk = { path = "../nexum-sdk" } +# `MockChain` is composed rather than reimplemented: one chain mock +# serves both personas. +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" } +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json new file mode 100644 index 00000000..980751be --- /dev/null +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -0,0 +1,60 @@ +{ + "venue": "nexum-venue-test/reference", + "goldens": [ + { + "name": "v1-small", + "body": "00010000000000000002000000676d", + "header": { + "gives": [ + { + "asset": { + "native-token": { + "evm-chain": 1 + } + }, + "amount": "01" + } + ], + "wants": [], + "settlement": { + "evm-chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "gives chain-1 native token, minimal big-endian amount" + }, + { + "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, + "settlement": { + "evm-chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "v2 adds the expiry and an erc20 want at the recipient address" + } + ] +} diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs new file mode 100644 index 00000000..3041326e --- /dev/null +++ b/crates/nexum-venue-test/src/codec.rs @@ -0,0 +1,346 @@ +//! Codec conformance vectors: the file format that publishes a venue's +//! `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 +//! 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 +//! derived enum. The failure vectors pin the typed error contract: +//! empty, unknown-version, and malformed bodies must fail exactly as +//! [`BodyError`] names them, not garble into a decoded value. + +use std::path::Path; + +use nexum_venue_sdk::{BodyError, IntentBody}; +use serde::{Deserialize, Serialize}; + +use crate::fixture::{self, FixtureError, 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 { + /// 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. + pub vectors: Vec, +} + +/// One published wire body and the outcome its bytes must produce. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CodecVector { + /// Stable name a violation is reported under. + pub name: String, + /// The wire bytes, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub bytes: Vec, + /// What a conforming codec does with the bytes. + pub expect: Expectation, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// The outcome a vector demands of a conforming codec. The failure +/// cases mirror [`BodyError`] minus its free-text detail: the detail +/// wording is the Rust implementation's, not part of the contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Expectation { + /// The bytes decode, and re-encoding the decoded value reproduces + /// them exactly. + RoundTrip, + /// Decoding fails: no version tag at all. + Empty, + /// Decoding fails: the tag names no published version. + UnknownVersion { + /// The unknown wire tag. + version: u8, + }, + /// Decoding fails: a known tag whose payload does not parse. + Malformed { + /// The wire tag whose payload is broken. + version: u8, + }, +} + +impl std::fmt::Display for Expectation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Expectation::RoundTrip => f.write_str("round-trip"), + Expectation::Empty => f.write_str("empty"), + Expectation::UnknownVersion { version } => write!(f, "unknown-version {version}"), + Expectation::Malformed { version } => write!(f, "malformed {version}"), + } + } +} + +impl CodecVectors { + /// An empty vector set for `schema`. + pub fn new(schema: impl Into) -> Self { + Self { + schema: schema.into(), + vectors: Vec::new(), + } + } + + /// Append a round-trip vector by encoding `body` through the codec + /// under publication. Returns the pushed vector so the caller can + /// attach [`notes`](CodecVector::notes). + pub fn push_round_trip( + &mut self, + name: impl Into, + body: &B, + ) -> Result<&mut CodecVector, BodyError> { + let bytes = body.to_bytes()?; + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect: Expectation::RoundTrip, + notes: None, + }); + Ok(self.vectors.last_mut().expect("vector was just pushed")) + } + + /// Append a failure vector: raw bytes plus the typed decode error + /// they must produce. + /// + /// # Panics + /// + /// On [`Expectation::RoundTrip`]; round-trip vectors are encoded + /// from a typed body via [`push_round_trip`](Self::push_round_trip) + /// so their bytes are canonical by construction. + pub fn push_failure( + &mut self, + name: impl Into, + bytes: Vec, + expect: Expectation, + ) -> &mut CodecVector { + assert!( + expect != Expectation::RoundTrip, + "push_failure takes a failure expectation; use push_round_trip", + ); + self.vectors.push(CodecVector { + name: name.into(), + bytes, + expect, + notes: None, + }); + self.vectors.last_mut().expect("vector was just pushed") + } + + /// Parse a vector set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a vector file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the vector file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check a codec against every vector, collecting all violations + /// rather than stopping at the first. + /// + /// 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). + pub fn check(&self) -> Result<(), ConformanceReport> { + let mut violations = Vec::new(); + for vector in &self.vectors { + if let Err(detail) = vector.check::() { + violations.push(Violation { + vector: vector.name.clone(), + detail, + }); + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any + /// violation. The assertion form for adapter test suites. + pub fn assert_conforms(&self) { + if let Err(report) = self.check::() { + panic!("codec does not conform to {}:\n{report}", self.schema); + } + } +} + +impl CodecVector { + /// Check one vector, returning the violation detail on divergence. + fn check(&self) -> Result<(), String> { + let decoded = B::from_bytes(&self.bytes); + match (&self.expect, decoded) { + (Expectation::RoundTrip, Ok(body)) => { + let reencoded = body + .to_bytes() + .map_err(|err| format!("re-encode failed: {err}"))?; + if reencoded == self.bytes { + Ok(()) + } else { + Err(format!( + "re-encoded bytes diverge from the published vector: published {}, re-encoded {}", + hex::encode(&self.bytes), + hex::encode(&reencoded), + )) + } + } + (Expectation::RoundTrip, Err(err)) => { + Err(format!("expected a round trip, decode failed: {err}")) + } + (expect, Ok(_)) => Err(format!("expected {expect}, decode succeeded")), + (expect, Err(err)) => { + let matches = match (expect, &err) { + (Expectation::Empty, BodyError::Empty) => true, + ( + Expectation::UnknownVersion { version }, + BodyError::UnknownVersion { version: got }, + ) => version == got, + ( + Expectation::Malformed { version }, + BodyError::Malformed { version: got, .. }, + ) => version == got, + _ => false, + }; + if matches { + Ok(()) + } else { + Err(format!("expected {expect}, got: {err}")) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use borsh::{BorshDeserialize, BorshSerialize}; + use nexum_venue_sdk::IntentBody; + + use super::*; + + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct PayloadV1 { + amount: u64, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum Body { + V1(PayloadV1), + } + + /// A codec with a diverging payload layout for the same tag. + #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] + struct NarrowPayload { + amount: u32, + memo: String, + } + + #[derive(IntentBody, Clone, Debug, PartialEq, Eq)] + enum NarrowBody { + V1(NarrowPayload), + } + + fn published() -> CodecVectors { + let mut vectors = CodecVectors::new("test/body"); + vectors + .push_round_trip( + "v1", + &Body::V1(PayloadV1 { + amount: 7, + memo: "gm".to_owned(), + }), + ) + .unwrap(); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + vectors.push_failure( + "unknown-version", + vec![9, 0, 0], + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure( + "truncated", + vec![0, 7], + Expectation::Malformed { version: 0 }, + ); + vectors + } + + #[test] + fn conforming_codec_passes_every_vector() { + published().check::().unwrap(); + } + + #[test] + fn diverging_codec_fails_with_named_vectors() { + let report = published().check::().unwrap_err(); + // The v1 payload no longer parses (u32 vs u64 layout); the + // failure vectors still fail as published, so the report names + // exactly the diverging vector. + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, "v1"); + assert!(report.violations[0].detail.contains("decode failed")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_panics_with_the_report() { + published().assert_conforms::(); + } + + #[test] + #[should_panic(expected = "push_failure takes a failure expectation")] + fn push_failure_rejects_round_trip() { + CodecVectors::new("test/body").push_failure("bad", Vec::new(), Expectation::RoundTrip); + } + + #[test] + fn json_form_is_stable_and_round_trips() { + let mut vectors = published(); + vectors.vectors[0].notes = Some("first published body".to_owned()); + 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("\"round-trip\"")); + assert!(json.contains("\"unknown-version\"")); + assert!(json.contains("\"notes\": \"first published body\"")); + assert!(!json.contains("null"), "absent notes are omitted: {json}"); + } + + #[test] + fn files_round_trip_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vectors.json"); + let vectors = published(); + vectors.write(&path).unwrap(); + assert_eq!(CodecVectors::load(&path).unwrap(), vectors); + } + + #[test] + fn malformed_file_fails_typedly() { + assert!(matches!( + CodecVectors::from_json("{"), + Err(FixtureError::Format(_)), + )); + assert!(matches!( + CodecVectors::load("/nonexistent/vectors.json"), + Err(FixtureError::Read { .. }), + )); + } +} diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/nexum-venue-test/src/fixture.rs new file mode 100644 index 00000000..7174573c --- /dev/null +++ b/crates/nexum-venue-test/src/fixture.rs @@ -0,0 +1,82 @@ +//! The shared fixture-file plumbing: JSON on disk, byte fields as +//! lowercase hex, and the typed [`FixtureError`] both file formats +//! load and save through. + +use std::path::Path; + +use serde::Serialize; +use serde::de::DeserializeOwned; + +/// 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 +/// stays independent of `serde_json`'s feature set. +#[derive(Debug, thiserror::Error)] +pub enum FixtureError { + /// The file could not be read. + #[error("failed to read {path}: {source}")] + Read { + /// Path the read targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The file could not be written. + #[error("failed to write {path}: {source}")] + Write { + /// Path the write targeted. + path: String, + /// The underlying io failure. + source: std::io::Error, + }, + /// The content did not parse as the fixture format. + #[error("malformed fixture json: {0}")] + Format(String), +} + +/// Render a fixture as its canonical published form: pretty-printed +/// JSON with a trailing newline, so a regenerated file diffs cleanly. +pub(crate) fn to_json(value: &T) -> String { + let mut json = serde_json::to_string_pretty(value).expect("fixture types serialize infallibly"); + json.push('\n'); + json +} + +/// Parse a fixture from its JSON text. +pub(crate) fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|err| FixtureError::Format(err.to_string())) +} + +/// Load a fixture file from disk. +pub(crate) fn load(path: &Path) -> Result { + let json = std::fs::read_to_string(path).map_err(|source| FixtureError::Read { + path: path.display().to_string(), + source, + })?; + from_json(&json) +} + +/// Write a fixture file in its canonical published form. +pub(crate) fn write(path: &Path, value: &T) -> Result<(), FixtureError> { + std::fs::write(path, to_json(value)).map_err(|source| FixtureError::Write { + path: path.display().to_string(), + source, + }) +} + +/// Serde codec for byte fields: lowercase hex, no prefix, so the file +/// is legible without a borsh decoder. +pub(crate) mod hex_bytes { + use serde::de::Error as _; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&hex::encode(bytes)) + } + + pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let text = String::deserialize(deserializer)?; + hex::decode(&text).map_err(D::Error::custom) + } +} diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs new file mode 100644 index 00000000..41840470 --- /dev/null +++ b/crates/nexum-venue-test/src/header.rs @@ -0,0 +1,459 @@ +//! Header-derivation goldens: the file format that publishes what +//! `derive-header` must project from each published body, and the +//! check that holds an adapter to it. +//! +//! 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; +//! [`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`. + +use std::fmt; +use std::path::Path; + +use nexum_venue_sdk::value_flow::{Asset, AssetAmount, Settlement}; +use nexum_venue_sdk::{AuthScheme, IntentHeader}; +use serde::{Deserialize, Serialize}; + +use crate::fixture::{self, FixtureError, 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 { + /// The venue the goldens bind. Informational: the check never + /// reads it. + pub venue: String, + /// The goldens, in publication order. + pub goldens: Vec, +} + +/// One wire body and the header a conforming adapter derives from it. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderGolden { + /// Stable name a violation is reported under. + pub name: String, + /// The intent body, lowercase hex in the file. + #[serde(with = "hex_bytes")] + pub body: Vec, + /// The header `derive-header` must produce for the body. + pub header: GoldenHeader, + /// Optional prose for readers of the file; the check ignores it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, +} + +/// Serde mirror of the wire `intent-header`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[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, + /// Where the deal settles. + pub settlement: GoldenSettlement, + /// How the venue authorizes the intent. + pub authorisation: GoldenAuthScheme, +} + +/// Serde mirror of the wire `asset-amount`. `amount` is big-endian +/// unsigned, 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. + #[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), +} + +/// Serde mirror of the wire `asset`. Token addresses and ids are hex +/// in the file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde( + rename_all = "kebab-case", + rename_all_fields = "kebab-case", + deny_unknown_fields +)] +pub enum GoldenAsset { + /// The settlement domain's own gas token. + NativeToken(GoldenSettlement), + /// An ERC-20 token. + 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, + }, +} + +/// Serde mirror of the wire `auth-scheme`. +#[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 authorization at the settlement contract. + Presign, + /// Venue-defined off-chain signature scheme. + OffchainSig, + /// No authorization travels with the body. + Unsigned, +} + +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, + settlement: header.settlement.into(), + authorisation: header.authorisation.into(), + } + } +} + +impl From for GoldenAssetAmount { + fn from(amount: AssetAmount) -> Self { + Self { + asset: amount.asset.into(), + amount: amount.amount, + } + } +} + +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), + } + } +} + +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, + }, + } + } +} + +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, + } + } +} + +impl HeaderGoldens { + /// An empty golden set for `venue`. + pub fn new(venue: impl Into) -> Self { + Self { + venue: venue.into(), + goldens: Vec::new(), + } + } + + /// Append a golden by running the publishing adapter's own + /// `derive-header` on `body`. Returns the pushed golden so the + /// caller can attach [`notes`](HeaderGolden::notes). + pub fn record( + &mut self, + name: impl Into, + body: Vec, + derive: impl FnOnce(Vec) -> Result, + ) -> Result<&mut HeaderGolden, E> + where + H: Into, + { + let header = derive(body.clone())?.into(); + self.goldens.push(HeaderGolden { + name: name.into(), + body, + header, + notes: None, + }); + Ok(self.goldens.last_mut().expect("golden was just pushed")) + } + + /// Parse a golden set from its JSON text. + pub fn from_json(json: &str) -> Result { + fixture::from_json(json) + } + + /// The canonical published form: pretty JSON, trailing newline. + pub fn to_json(&self) -> String { + fixture::to_json(self) + } + + /// Load a golden file from disk. + pub fn load(path: impl AsRef) -> Result { + fixture::load(path.as_ref()) + } + + /// Write the golden file in its canonical published form. + pub fn write(&self, path: impl AsRef) -> Result<(), FixtureError> { + fixture::write(path.as_ref(), self) + } + + /// Check an adapter's `derive-header` against every golden, + /// collecting all violations rather than stopping at the first. + /// + /// `derive` is the adapter's derivation; a trait-based adapter + /// passes `MyAdapter::derive_header` directly. + pub fn check( + &self, + mut derive: impl FnMut(Vec) -> Result, + ) -> Result<(), ConformanceReport> + where + H: Into, + E: fmt::Debug, + { + let mut violations = Vec::new(); + for golden in &self.goldens { + match derive(golden.body.clone()) { + Ok(header) => { + let derived: GoldenHeader = header.into(); + if derived != golden.header { + violations.push(Violation { + vector: golden.name.clone(), + detail: format!( + "derived header diverges from the golden: expected {:?}, derived {derived:?}", + golden.header, + ), + }); + } + } + Err(err) => violations.push(Violation { + vector: golden.name.clone(), + detail: format!("derive-header failed: {err:?}"), + }), + } + } + settle(violations) + } + + /// [`check`](Self::check), panicking with the full report on any + /// violation. The assertion form for adapter test suites. + pub fn assert_conforms(&self, derive: impl FnMut(Vec) -> Result) + where + H: Into, + E: fmt::Debug, + { + if let Err(report) = self.check(derive) { + panic!( + "derive-header does not conform to the {} goldens:\n{report}", + self.venue, + ); + } + } +} + +#[cfg(test)] +mod tests { + use nexum_venue_sdk::VenueError; + use nexum_venue_sdk::value_flow::{OffchainDesc, ServiceDesc}; + + 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(), + }), + amount: Vec::new(), + }], + valid_until: Some(1_700_000_000_000), + settlement: Settlement::Offchain("acme".to_owned()), + authorisation: AuthScheme::OffchainSig, + } + } + + #[test] + fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { + let golden: GoldenHeader = wire_header().into(); + let goldens = HeaderGoldens { + venue: "acme".to_owned(), + goldens: vec![HeaderGolden { + name: "kitchen-sink".to_owned(), + body: vec![0], + header: golden, + notes: None, + }], + }; + 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\"")); + } + + #[test] + fn conforming_derivation_passes() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("kitchen-sink", vec![1, 2, 3], |_| { + Ok::<_, VenueError>(wire_header()) + }) + .unwrap(); + goldens + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + } + + #[test] + fn diverging_derivation_and_failure_are_both_violations() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens + .record("b", vec![2], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + + let mut calls = 0; + let report = goldens + .check(|_| { + calls += 1; + if calls == 1 { + let mut header = wire_header(); + header.valid_until = None; + Ok(header) + } else { + Err(VenueError::InvalidBody("nope".to_owned())) + } + }) + .unwrap_err(); + + assert_eq!(report.violations.len(), 2); + assert_eq!(report.violations[0].vector, "a"); + assert!(report.violations[0].detail.contains("diverges")); + assert_eq!(report.violations[1].vector, "b"); + assert!(report.violations[1].detail.contains("derive-header failed")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_panics_with_the_report() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + goldens.assert_conforms(|_| Err::(VenueError::InvalidReceipt)); + } +} diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs new file mode 100644 index 00000000..e8c372e3 --- /dev/null +++ b/crates/nexum-venue-test/src/lib.rs @@ -0,0 +1,81 @@ +//! # nexum-venue-test +//! +//! Conformance kit for venue adapters: file-published codec vectors, +//! header-derivation goldens, and an in-memory transport mock, so an +//! adapter proves its wire behaviour against fixtures any +//! implementation language can read. +//! +//! ## The three pieces +//! +//! - [`CodecVectors`] - the venue's `IntentBody` wire bytes as a JSON +//! file (bytes as lowercase hex). A Rust adapter checks its derived +//! enum with [`CodecVectors::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, spelled in the +//! [`GoldenHeader`] mirror types. +//! - [`MockTransport`] - the three transports an adapter is granted +//! (chain, messaging, outbound HTTP) as programmable in-memory mocks +//! behind the SDK's own seams. +//! +//! ## Usage +//! +//! Add as a dev-dep on the adapter crate: +//! +//! ```toml +//! [dev-dependencies] +//! nexum-venue-test = { path = "../../crates/nexum-venue-test" } +//! ``` +//! +//! Hold the adapter to its published fixtures: +//! +//! ```rust +//! use nexum_venue_test::reference::{ +//! CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, +//! }; +//! use nexum_venue_test::{CodecVectors, HeaderGoldens}; +//! +//! // In a real adapter test these load the venue's own published +//! // files; the kit's reference venue stands in here. +//! let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); +//! vectors.assert_conforms::(); +//! +//! let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); +//! goldens.assert_conforms(derive_reference_header); +//! ``` +//! +//! Publishing works through the same types: build the fixtures with +//! [`CodecVectors::push_round_trip`] / [`HeaderGoldens::record`] and +//! [`write`](CodecVectors::write) them next to the venue's schema +//! documentation. +//! +//! ## 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`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod codec; +pub mod fixture; +pub mod header; +pub mod reference; +pub mod report; +pub mod transport; + +pub use codec::{CodecVector, CodecVectors, Expectation}; +pub use fixture::FixtureError; +pub use header::{ + GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, + HeaderGoldens, +}; +pub use report::{ConformanceReport, Violation}; +pub use transport::{ + ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, + PublishRecord, RecordedRequest, +}; diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs new file mode 100644 index 00000000..2dc15623 --- /dev/null +++ b/crates/nexum-venue-test/src/reference.rs @@ -0,0 +1,271 @@ +//! The kit's reference venue: a published body schema, its codec +//! vector file, and its header golden file. +//! +//! The reference exists so the fixture formats ship with a worked, +//! machine-checked example. Its payloads exercise every borsh +//! primitive a body schema is likely to carry (fixed-width integers, +//! length-prefixed strings and byte vectors, options, bools), so a +//! non-Rust author can prove their borsh implementation byte-exact +//! against [`CODEC_VECTORS_JSON`] before touching their own schema. +//! The published files are pinned by this crate's tests: regeneration +//! 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}; + +/// The published codec vector file, verbatim. +pub const CODEC_VECTORS_JSON: &str = include_str!("../vectors/reference-body.json"); + +/// The published header golden file, verbatim. +pub const HEADER_GOLDENS_JSON: &str = include_str!("../goldens/reference-header.json"); + +/// First published version: a fixed-price quote. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV1 { + /// Amount in wei; borsh encodes it as 8 little-endian bytes. + pub amount_wei: u64, + /// Free text; borsh encodes a u32 little-endian byte length then + /// the UTF-8 bytes. + pub memo: String, +} + +/// Second published version: v1 plus an expiry, a recipient, and a +/// priority flag. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, Eq, PartialEq)] +pub struct ReferenceV2 { + /// Amount in wei. + pub amount_wei: u64, + /// Free text. + pub memo: String, + /// Expiry in ms since the Unix epoch, UTC; borsh encodes a one-byte + /// presence tag (0 absent, 1 present) then the payload. + pub valid_until_ms: Option, + /// 20-byte recipient address; borsh encodes a u32 little-endian + /// element count then the bytes. + pub recipient: Vec, + /// Priority flag; borsh encodes one byte (0 false, 1 true). + pub urgent: bool, +} + +/// The reference venue's outer version enum. Tag order is the schema: +/// versions append, never reorder. +#[derive(IntentBody, Clone, Debug, Eq, PartialEq)] +pub enum ReferenceBody { + /// Version 1, wire tag 0. + V1(ReferenceV1), + /// Version 2, wire tag 1. + V2(ReferenceV2), +} + +/// 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 authorizes via EIP-712. +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()), + ReferenceBody::V2(quote) => ( + quote.amount_wei, + quote.valid_until_ms, + vec![AssetAmount { + asset: Asset::Erc20((1, quote.recipient)), + amount: minimal_be(quote.amount_wei), + }], + ), + }; + Ok(IntentHeader { + gives: vec![AssetAmount { + asset: Asset::NativeToken(Settlement::EvmChain(1)), + amount: minimal_be(amount_wei), + }], + wants, + valid_until, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }) +} + +/// Big-endian bytes with leading zeros trimmed: the minimal spelling +/// of a wire amount, 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()) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::codec::{CodecVectors, Expectation}; + use crate::header::HeaderGoldens; + + use super::*; + + fn v1_small() -> ReferenceBody { + ReferenceBody::V1(ReferenceV1 { + amount_wei: 1, + memo: "gm".to_owned(), + }) + } + + fn v2_full() -> ReferenceBody { + ReferenceBody::V2(ReferenceV2 { + amount_wei: 1_000_000, + memo: "two coffees".to_owned(), + valid_until_ms: Some(1_700_000_000_000), + recipient: (1..=20).collect(), + urgent: true, + }) + } + + /// Rebuild the published codec vectors from the reference schema. + fn build_codec_vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("nexum-venue-test/reference-body"); + + vectors + .push_round_trip("v1-small", &v1_small()) + .unwrap() + .notes = Some( + "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 \ + little-endian length then utf-8 bytes" + .to_owned(), + ); + vectors + .push_round_trip( + "v1-zero-and-empty", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: 0, + memo: String::new(), + }), + ) + .unwrap() + .notes = Some("zero integer and zero-length string".to_owned()); + vectors + .push_round_trip( + "v1-max-amount", + &ReferenceBody::V1(ReferenceV1 { + amount_wei: u64::MAX, + memo: "max".to_owned(), + }), + ) + .unwrap() + .notes = Some("endianness proof: u64::MAX is eight 0xff bytes".to_owned()); + vectors + .push_round_trip("v2-full", &v2_full()) + .unwrap() + .notes = Some( + "tag 0x01; option present is 0x01 then the payload, vec is u32 \ + little-endian element count then bytes, bool true is 0x01" + .to_owned(), + ); + vectors + .push_round_trip( + "v2-no-expiry", + &ReferenceBody::V2(ReferenceV2 { + amount_wei: 5, + memo: "later".to_owned(), + valid_until_ms: None, + recipient: vec![0xAA; 20], + urgent: false, + }), + ) + .unwrap() + .notes = Some("option absent is a bare 0x00, bool false is 0x00".to_owned()); + + vectors + .push_failure("empty-body", Vec::new(), Expectation::Empty) + .notes = Some("no version tag at all".to_owned()); + let mut unknown = v1_small().to_bytes().unwrap(); + unknown[0] = 9; + vectors + .push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ) + .notes = Some("tag 0x09 names no published version".to_owned()); + let mut truncated = v2_full().to_bytes().unwrap(); + truncated.truncate(truncated.len() - 1); + vectors + .push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 1 }, + ) + .notes = Some("known tag, payload cut one byte short".to_owned()); + let mut trailing = v1_small().to_bytes().unwrap(); + trailing.push(0); + vectors + .push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ) + .notes = Some("decoding must consume the payload exactly".to_owned()); + + vectors + } + + /// Rebuild the published header goldens from the reference + /// derivation. + fn build_header_goldens() -> HeaderGoldens { + let mut goldens = HeaderGoldens::new("nexum-venue-test/reference"); + goldens + .record( + "v1-small", + v1_small().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = Some("gives chain-1 native token, minimal big-endian amount".to_owned()); + goldens + .record( + "v2-full", + v2_full().to_bytes().unwrap(), + derive_reference_header, + ) + .unwrap() + .notes = + Some("v2 adds the expiry and an erc20 want at the recipient address".to_owned()); + goldens + } + + #[test] + fn published_codec_vectors_match_regeneration() { + assert_eq!( + CODEC_VECTORS_JSON, + build_codec_vectors().to_json(), + "vectors/reference-body.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + #[test] + fn published_header_goldens_match_regeneration() { + assert_eq!( + HEADER_GOLDENS_JSON, + build_header_goldens().to_json(), + "goldens/reference-header.json has drifted; run the ignored \ + regenerate_reference_fixtures test and commit the result", + ); + } + + /// Rewrite the published files from the reference schema. Run with + /// `cargo test -p nexum-venue-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. + #[test] + #[ignore = "writes the published fixture files in place"] + fn regenerate_reference_fixtures() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + build_codec_vectors() + .write(root.join("vectors/reference-body.json")) + .unwrap(); + build_header_goldens() + .write(root.join("goldens/reference-header.json")) + .unwrap(); + } +} diff --git a/crates/nexum-venue-test/src/report.rs b/crates/nexum-venue-test/src/report.rs new file mode 100644 index 00000000..97f56b97 --- /dev/null +++ b/crates/nexum-venue-test/src/report.rs @@ -0,0 +1,51 @@ +//! The conformance verdict: every check in this crate either passes or +//! returns a [`ConformanceReport`] naming each vector that failed. + +use std::error::Error; +use std::fmt; + +/// One vector or golden the subject under test failed, with enough +/// detail to fix the divergence without re-running under a debugger. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Violation { + /// The `name` of the failing vector or golden. + pub vector: String, + /// What diverged: the expected and observed outcome. + pub detail: String, +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.vector, self.detail) + } +} + +/// Every violation a conformance check found, one entry per failing +/// vector. A check never stops at the first failure: the report is the +/// whole distance between the subject and the published fixtures. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConformanceReport { + /// The violations, in vector order. + pub violations: Vec, +} + +impl fmt::Display for ConformanceReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{} conformance violation(s):", self.violations.len())?; + for violation in &self.violations { + writeln!(f, " {violation}")?; + } + Ok(()) + } +} + +impl Error for ConformanceReport {} + +/// Fold collected violations into the check's verdict. +pub(crate) fn settle(violations: Vec) -> Result<(), ConformanceReport> { + if violations.is_empty() { + Ok(()) + } else { + Err(ConformanceReport { violations }) + } +} diff --git a/crates/nexum-venue-test/src/transport.rs b/crates/nexum-venue-test/src/transport.rs new file mode 100644 index 00000000..7ec823f3 --- /dev/null +++ b/crates/nexum-venue-test/src/transport.rs @@ -0,0 +1,473 @@ +//! In-memory mocks for the three transports a venue adapter is +//! granted: chain RPC, messaging, and outbound HTTP. +//! +//! [`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. + +use std::cell::RefCell; +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}; + +/// Composed in-memory transport. Each field exposes the per-seam mock +/// so tests can program responses and assert on calls. +#[derive(Default)] +pub struct MockTransport { + /// `nexum:host/chain` mock. + pub chain: MockChain, + /// `nexum:host/messaging` mock. + pub messaging: MockMessaging, + /// Outbound wasi:http mock. + pub http: MockFetch, +} + +impl MockTransport { + /// Fresh empty transport. Equivalent to `Default::default`. + pub fn new() -> Self { + Self::default() + } +} + +impl ChainHost for MockTransport { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + self.chain.request(chain_id, method, params) + } +} + +impl MessagingHost for MockTransport { + 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 Fetch for MockTransport { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.http.fetch_with(request, options) + } +} + +// ------------------------------------------------------------ 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. +#[derive(Clone, Debug)] +pub struct RecordedRequest { + /// HTTP method. + pub method: http::Method, + /// Full request URI, verbatim. + pub uri: String, + /// Request body bytes. + pub body: Vec, + /// The per-phase timeouts the caller applied. + pub options: FetchOptions, +} + +/// A programmed response, rebuilt into an `http::Response` per call +/// because the standard response type is not `Clone`. +#[derive(Clone, Debug)] +struct StoredResponse { + status: http::StatusCode, + body: Vec, +} + +/// 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). +#[derive(Default)] +pub struct MockFetch { + responses: RefCell>>, + requests: RefCell>, +} + +impl MockFetch { + /// Program a response for the `(method, uri)` pair. Overwrites any + /// prior entry. + /// + /// # Panics + /// + /// On a `status` outside the valid HTTP range. + pub fn respond_to( + &self, + method: http::Method, + uri: impl Into, + status: u16, + body: impl Into>, + ) { + let status = + http::StatusCode::from_u16(status).expect("MockFetch: status must be a valid code"); + self.responses.borrow_mut().insert( + (method, uri.into()), + Ok(StoredResponse { + status, + body: body.into(), + }), + ); + } + + /// Program a failure for the `(method, uri)` pair. Overwrites any + /// prior entry. + pub fn fail_with(&self, method: http::Method, uri: impl Into, error: FetchError) { + self.responses + .borrow_mut() + .insert((method, uri.into()), Err(error)); + } + + /// All requests received, in arrival order. + pub fn requests(&self) -> Vec { + self.requests.borrow().clone() + } + + /// Last request received, if any. + pub fn last_request(&self) -> Option { + self.requests.borrow().last().cloned() + } + + /// Total request count. + pub fn request_count(&self) -> usize { + self.requests.borrow().len() + } +} + +impl Fetch for MockFetch { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + let method = request.method().clone(); + let uri = request.uri().to_string(); + self.requests.borrow_mut().push(RecordedRequest { + method: method.clone(), + uri: uri.clone(), + body: request.body().clone(), + options, + }); + match self.responses.borrow().get(&(method.clone(), uri.clone())) { + Some(Ok(stored)) => Ok(http::Response::builder() + .status(stored.status) + .body(stored.body.clone()) + .expect("a stored response always rebuilds")), + Some(Err(err)) => Err(err.clone()), + None => Err(FetchError::Transport(format!( + "MockFetch: no response configured for {method} {uri}" + ))), + } + } +} + +#[cfg(test)] +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(); + fetch.respond_to( + http::Method::GET, + "https://venue.example/api/v1/quote", + 200, + br#"{"price":"1"}"#.to_vec(), + ); + + let request = http::Request::builder() + .method(http::Method::GET) + .uri("https://venue.example/api/v1/quote") + .body(Vec::new()) + .unwrap(); + let response = fetch.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!(response.body(), br#"{"price":"1"}"#); + + assert_eq!(fetch.request_count(), 1); + let recorded = fetch.last_request().unwrap(); + assert_eq!(recorded.method, http::Method::GET); + assert_eq!(recorded.uri, "https://venue.example/api/v1/quote"); + assert_eq!(recorded.options, FetchOptions::default()); + } + + #[test] + fn fetch_unconfigured_and_programmed_failures() { + let fetch = MockFetch::default(); + fetch.fail_with( + http::Method::POST, + "https://venue.example/api/v1/orders", + FetchError::Denied, + ); + + let denied = http::Request::builder() + .method(http::Method::POST) + .uri("https://venue.example/api/v1/orders") + .body(b"order".to_vec()) + .unwrap(); + assert_eq!(fetch.fetch(denied).unwrap_err(), FetchError::Denied); + + let stray = http::Request::builder() + .uri("https://nowhere.example/") + .body(Vec::new()) + .unwrap(); + let err = fetch.fetch(stray).unwrap_err(); + assert!(matches!(err, FetchError::Transport(msg) if msg.contains("MockFetch"))); + // Refused and unconfigured requests are still recorded. + assert_eq!(fetch.request_count(), 2); + } + + #[test] + fn transport_dispatches_through_every_seam() { + let transport = MockTransport::new(); + transport + .chain + .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".to_owned())); + transport.messaging.seed_payload("/t", b"m".to_vec(), 1); + transport + .http + .respond_to(http::Method::GET, "https://venue.example/", 204, Vec::new()); + + // Through the seams an adapter's logic is written against. + let chain: &dyn ChainHost = &transport; + assert_eq!( + chain.request(1, "eth_blockNumber", "[]").unwrap(), + "\"0x1\"" + ); + + let messaging: &dyn MessagingHost = &transport; + messaging.publish("/t", b"out").unwrap(); + assert_eq!(messaging.query("/t", None, None, None).unwrap().len(), 1); + + let request = http::Request::builder() + .uri("https://venue.example/") + .body(Vec::new()) + .unwrap(); + let response = transport.fetch(request).unwrap(); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + + assert_eq!(transport.chain.call_count(), 1); + assert_eq!(transport.messaging.publish_count(), 1); + assert_eq!(transport.http.request_count(), 1); + } +} diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs new file mode 100644 index 00000000..290c2ef4 --- /dev/null +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -0,0 +1,133 @@ +//! Acceptance surface for the conformance kit: an adapter written +//! against `nexum-venue-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, Settlement}; +use nexum_venue_sdk::{ + AuthScheme, Config, Fault, IntentHeader, IntentStatus, 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}; + +/// An adapter under test: the reference venue implemented through the +/// SDK trait, transport injected through the seams so the kit's mocks +/// drive it. +struct ReferenceAdapter; + +impl VenueAdapter for ReferenceAdapter { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(body: Vec) -> Result { + derive_reference_header(body) + } + + fn submit(body: Vec) -> Result { + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +#[test] +fn adapter_codec_conforms_to_the_published_vectors() { + CodecVectors::from_json(CODEC_VECTORS_JSON) + .expect("the published vector file parses") + .assert_conforms::(); +} + +#[test] +fn adapter_derive_header_conforms_to_the_published_goldens() { + HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .expect("the published golden file parses") + .assert_conforms(ReferenceAdapter::derive_header); +} + +#[test] +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(); + } + Ok(header) + }; + let report = HeaderGoldens::from_json(HEADER_GOLDENS_JSON) + .unwrap() + .check(derive) + .unwrap_err(); + assert!(!report.violations.is_empty()); + assert!(report.violations[0].detail.contains("diverges")); +} + +#[test] +fn mock_transport_drives_seam_shaped_adapter_logic() { + // A slice of adapter logic written against the seams: announce a + // submission over messaging, confirm via the venue's HTTP API. + fn announce(messaging: &M, receipt: &[u8]) -> Result<(), VenueError> { + messaging + .publish("/reference/1/receipts/proto", receipt) + .map_err(VenueError::from) + } + + let transport = MockTransport::new(); + transport + .messaging + .scope_topics(["/reference/1/receipts/proto"]); + + let SubmitOutcome::Accepted(receipt) = ReferenceAdapter::submit(vec![1, 2, 3]).unwrap() else { + panic!("the reference venue accepts directly"); + }; + announce(&transport, &receipt).unwrap(); + assert_eq!( + transport.messaging.last_published().unwrap().payload, + receipt, + ); + + // An off-scope topic surfaces as the typed policy refusal. + let denied = transport + .messaging + .publish("/elsewhere", &receipt) + .map_err(VenueError::from) + .unwrap_err(); + assert!(matches!(denied, VenueError::Denied(_))); +} + +#[test] +fn published_files_document_the_wire_format_in_hex() { + // Non-Rust authors consume the files directly: every byte field is + // lowercase hex, and the first round-trip vector carries prose. + let vectors = CodecVectors::from_json(CODEC_VECTORS_JSON).unwrap(); + assert!(vectors.vectors.iter().any(|vector| vector.notes.is_some())); + + let goldens = HeaderGoldens::from_json(HEADER_GOLDENS_JSON).unwrap(); + let golden = &goldens.goldens[0]; + // The golden's body is a codec vector's bytes: the two files pin + // the same wire form from both sides. + assert!( + vectors + .vectors + .iter() + .any(|vector| vector.bytes == golden.body), + "header goldens reuse published codec bodies", + ); + // 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.authorisation, AuthScheme::Eip712); + let _: &AssetAmount = &derived.gives[0]; +} diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/nexum-venue-test/vectors/reference-body.json new file mode 100644 index 00000000..297d32b3 --- /dev/null +++ b/crates/nexum-venue-test/vectors/reference-body.json @@ -0,0 +1,71 @@ +{ + "schema": "nexum-venue-test/reference-body", + "vectors": [ + { + "name": "v1-small", + "bytes": "00010000000000000002000000676d", + "expect": "round-trip", + "notes": "tag 0x00, amount_wei 1 as 8 little-endian bytes, memo as u32 little-endian length then utf-8 bytes" + }, + { + "name": "v1-zero-and-empty", + "bytes": "00000000000000000000000000", + "expect": "round-trip", + "notes": "zero integer and zero-length string" + }, + { + "name": "v1-max-amount", + "bytes": "00ffffffffffffffff030000006d6178", + "expect": "round-trip", + "notes": "endianness proof: u64::MAX is eight 0xff bytes" + }, + { + "name": "v2-full", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f101112131401", + "expect": "round-trip", + "notes": "tag 0x01; option present is 0x01 then the payload, vec is u32 little-endian element count then bytes, bool true is 0x01" + }, + { + "name": "v2-no-expiry", + "bytes": "010500000000000000050000006c617465720014000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa00", + "expect": "round-trip", + "notes": "option absent is a bare 0x00, bool false is 0x00" + }, + { + "name": "empty-body", + "bytes": "", + "expect": "empty", + "notes": "no version tag at all" + }, + { + "name": "unknown-version", + "bytes": "09010000000000000002000000676d", + "expect": { + "unknown-version": { + "version": 9 + } + }, + "notes": "tag 0x09 names no published version" + }, + { + "name": "truncated-payload", + "bytes": "0140420f00000000000b00000074776f20636f6666656573010068e5cf8b010000140000000102030405060708090a0b0c0d0e0f1011121314", + "expect": { + "malformed": { + "version": 1 + } + }, + "notes": "known tag, payload cut one byte short" + }, + { + "name": "trailing-bytes", + "bytes": "00010000000000000002000000676d00", + "expect": { + "malformed": { + "version": 0 + } + }, + "notes": "decoding must consume the payload exactly" + } + ] +} From 38817d16d5c48f23d9eecdaa2995df5c3d7e699c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:11:16 +0000 Subject: [PATCH 17/29] feat(echo-venue): settle instantly, add echo-client round trip (#256) feat(examples): pair echo-venue with an echo-client and prove the round-trip Add echo-client, the strategy half of the echo pair: it submits an opaque body through nexum:intent/pool on every block and logs the intent-status transitions the router fans back. Wire both real components through an integration test that proves the intent core end to end, module -> host router -> echo adapter submit and status back, which is the first-train acceptance path. Settle the echo venue instantly (status now reports settled) so the round-trip reaches a terminal transition, and hold its header derivation to the nexum-venue-test kit so echo-venue doubles as the conformance target alongside its tutorial role. Register echo-client in the workspace, the build recipes, and CI. --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/nexum-runtime/src/supervisor/tests.rs | 152 +++++++++++++++++++ modules/examples/echo-client/Cargo.toml | 17 +++ modules/examples/echo-client/module.toml | 31 ++++ modules/examples/echo-client/src/lib.rs | 68 +++++++++ modules/examples/echo-venue/Cargo.toml | 6 + modules/examples/echo-venue/src/lib.rs | 152 ++++++++++++++++++- 8 files changed, 429 insertions(+), 7 deletions(-) create mode 100644 modules/examples/echo-client/Cargo.toml create mode 100644 modules/examples/echo-client/module.toml create mode 100644 modules/examples/echo-client/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 831f6223..5863a79a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,11 +2138,20 @@ dependencies = [ "spki", ] +[[package]] +name = "echo-client" +version = "0.1.0" +dependencies = [ + "nexum-sdk", + "wit-bindgen 0.58.0", +] + [[package]] name = "echo-venue" version = "0.1.0" dependencies = [ "nexum-venue-sdk", + "nexum-venue-test", "wit-bindgen 0.58.0", ] diff --git a/Cargo.toml b/Cargo.toml index 0b9ef958..3ac4f1a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", + "modules/examples/echo-client", "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 13080946..90d34f85 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -87,6 +87,24 @@ 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 { @@ -113,6 +131,33 @@ fn echo_venue_wasm_or_skip() -> Option { } } +/// 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(); @@ -589,6 +634,113 @@ 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 +/// 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}; + + 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 pool router; the router 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 router 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 mut delivered = 0; + 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, + ); + 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 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("submitted") && m.contains("echo-venue")), + "module submitted through the pool; 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 diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml new file mode 100644 index 00000000..95e02533 --- /dev/null +++ b/modules/examples/echo-client/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "echo-client" +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." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-sdk = { path = "../../../crates/nexum-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml new file mode 100644 index 00000000..f10708d1 --- /dev/null +++ b/modules/examples/echo-client/module.toml @@ -0,0 +1,31 @@ +# 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. + +[module] +name = "echo-client" +version = "0.1.0" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# `pool` grants the nexum:intent/pool import; `logging` the log sink. +required = ["pool", "logging"] +optional = [] + +[capabilities.http] +allow = [] + +# Submit on every chain-1 block. +[[subscription]] +kind = "block" +chain_id = 1 + +# Observe the status transitions the router polls from the echo-venue adapter. +[[subscription]] +kind = "intent-status" +venue = "echo-venue" + +[config] +name = "echo-client" diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs new file mode 100644 index 00000000..08eb05ee --- /dev/null +++ b/modules/examples/echo-client/src/lib.rs @@ -0,0 +1,68 @@ +//! # 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. +//! +//! 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. + +// 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 nexum::intent::pool; +use nexum::intent::types::SubmitOutcome; + +/// Venue id the paired echo-venue adapter answers for; the module submits +/// to and observes exactly this venue. +const ECHO_VENUE: &str = "echo-venue"; + +struct EchoClient; + +#[nexum_sdk::module] +impl EchoClient { + fn on_block(block: types::Block) -> Result<(), Fault> { + // The echo venue accepts any bytes and hands them back as the + // 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) { + Ok(SubmitOutcome::Accepted(receipt)) => logging::log( + logging::Level::Info, + &format!( + "submitted {} bytes to {ECHO_VENUE}, receipt {} bytes", + body.len(), + receipt.len(), + ), + ), + Ok(SubmitOutcome::RequiresSigning(_)) => logging::log( + logging::Level::Warn, + &format!("{ECHO_VENUE} unexpectedly asked for a signature"), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("submit to {ECHO_VENUE} was refused"), + ), + } + Ok(()) + } + + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "intent status from venue {} ({} receipt bytes)", + update.venue, + update.receipt.len(), + ), + ); + Ok(()) + } +} diff --git a/modules/examples/echo-venue/Cargo.toml b/modules/examples/echo-venue/Cargo.toml index f3af0756..f900f97b 100644 --- a/modules/examples/echo-venue/Cargo.toml +++ b/modules/examples/echo-venue/Cargo.toml @@ -14,3 +14,9 @@ crate-type = ["cdylib"] [dependencies] nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } + +[dev-dependencies] +# 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" } diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 0c754291..ef80f731 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -1,11 +1,13 @@ //! # echo-venue (reference Shepherd venue adapter) //! -//! The minimal reference venue adapter: it echoes an intent body back as -//! its receipt and reports every receipt it issued as `open`. 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 the adapter face. +//! 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 +//! 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 +//! the adapter face - 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 @@ -57,7 +59,9 @@ impl EchoVenue { if receipt.is_empty() { Err(VenueError::InvalidReceipt) } else { - Ok(IntentStatus::Open) + // Settles instantly: the intent reaches a terminal state on the + // first status poll, with no venue-side settlement proof. + Ok(IntentStatus::Settled(None)) } } @@ -69,3 +73,137 @@ impl EchoVenue { } } } + +/// 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`. +#[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, + }, + } + } + + 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::Eip712 => GoldenAuthScheme::Eip712, + AuthScheme::Eip1271 => GoldenAuthScheme::Eip1271, + AuthScheme::Presign => GoldenAuthScheme::Presign, + AuthScheme::OffchainSig => GoldenAuthScheme::OffchainSig, + AuthScheme::Unsigned => GoldenAuthScheme::Unsigned, + } + } + + 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), + 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) + } + + #[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. + 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, + }, + notes: Some("amount is the 8-byte big-endian body length".to_owned()), + }; + let goldens = HeaderGoldens { + venue: "echo-venue".to_owned(), + goldens: vec![golden], + }; + goldens.assert_conforms(derive_golden); + } + + #[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. + 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, + }, + notes: None, + }], + }; + assert!(goldens.check(derive_golden).is_err()); + } +} From 85e5d3fceae03ae8b3f0ab66daa8fb8d3ca57964 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:14:32 +0000 Subject: [PATCH 18/29] docs(wit): freeze WIT clarifications, fix macro nits (#257) * docs(wit): pin canonical amount encoding and reserve native-token(offchain) Mandate minimal-length big-endian for asset-amount and require decoders to compare by value, so the same amount has one canonical wire form across the binding targets. Document native-token(offchain) as representable-but-invalid: an off-chain settlement domain has no gas token, so consumers must reject the pairing rather than interpret it. * fix(macros): correct venue manifest error attribution and accumulate venue packages The shared missing-[capabilities] error named #[nexum_sdk::module] even on the venue path; make it macro-neutral so a venue crate is not told to use the wrong attribute. Mirror synthesize's package-accumulation loop in synthesize_venue so a future venue capability carrying an extra WIT package reaches the resolve path (all venue capabilities are packageless today, so the base set is unchanged). * docs: normalise doc comments to British -ise spelling Align the authorise/authorisation prose in the intent WIT and the conformance kit with the British spelling used elsewhere and with the authorisation identifier itself. Doc comments only; no identifier, wire format, or API name changes. * docs(runtime): document the pool submit charge asymmetry State the deliberate rule that a forwarded submission is charged once the guard admits it regardless of the venue outcome, while a derive-stage non-decode venue error is left uncharged so the caller may retry. --- crates/nexum-macros/src/world.rs | 20 +++++++++++++++----- crates/nexum-runtime/src/host/pool_router.rs | 8 ++++++++ crates/nexum-venue-test/src/header.rs | 6 +++--- crates/nexum-venue-test/src/reference.rs | 2 +- wit/nexum-intent/types.wit | 10 +++++----- wit/nexum-value-flow/types.wit | 13 ++++++++++++- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index 9a6db2c4..d8791708 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -30,7 +30,7 @@ struct Capability { adapter: Option<&'static str>, } -/// Every capability the macro recognizes, in emission order. Mirrors +/// 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`). const KNOWN: &[Capability] = &[ @@ -114,9 +114,9 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { .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; #[nexum_sdk::module] derives the module's \ - WIT world from [capabilities].required/optional, so declare it (an empty `required = []` \ - is valid)" + "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> { @@ -171,7 +171,7 @@ pub fn synthesize_venue(declared: &[String]) -> Result { // 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. - let packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { continue; @@ -179,6 +179,16 @@ pub fn synthesize_venue(declared: &[String]) -> Result { if let Some(import) = cap.import { writeln!(imports, " import {import};").expect("write to String"); } + // 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. + for package in cap.packages { + if !packages.contains(package) { + packages.push(package); + } + } } let mut wit = String::from( diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs index c33252b3..a90797f0 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -331,6 +331,14 @@ impl PoolRouter { /// 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. + /// + /// 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. pub async fn submit( &self, caller: &str, diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index 41840470..bee21583 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -61,7 +61,7 @@ pub struct GoldenHeader { pub valid_until: Option, /// Where the deal settles. pub settlement: GoldenSettlement, - /// How the venue authorizes the intent. + /// How the venue authorises the intent. pub authorisation: GoldenAuthScheme, } @@ -152,11 +152,11 @@ pub enum GoldenAuthScheme { Eip712, /// EIP-1271 contract signature. Eip1271, - /// Pre-signed authorization at the settlement contract. + /// Pre-signed authorisation at the settlement contract. Presign, /// Venue-defined off-chain signature scheme. OffchainSig, - /// No authorization travels with the body. + /// No authorisation travels with the body. Unsigned, } diff --git a/crates/nexum-venue-test/src/reference.rs b/crates/nexum-venue-test/src/reference.rs index 2dc15623..74ce00b2 100644 --- a/crates/nexum-venue-test/src/reference.rs +++ b/crates/nexum-venue-test/src/reference.rs @@ -61,7 +61,7 @@ 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 authorizes via EIP-712. +/// address, and authorises via EIP-712. 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()), diff --git a/wit/nexum-intent/types.wit b/wit/nexum-intent/types.wit index 22472e7b..32c2e16d 100644 --- a/wit/nexum-intent/types.wit +++ b/wit/nexum-intent/types.wit @@ -1,7 +1,7 @@ package nexum:intent@0.1.0; /// The venue-neutral intent ontology: what a deal gives and wants, how the -/// venue authorizes it, and how its life at the venue is reported. Built on +/// 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 @@ -16,7 +16,7 @@ package nexum:intent@0.1.0; interface types { use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; - /// How an intent is authorized at its venue. + /// 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. @@ -24,12 +24,12 @@ interface types { /// An EIP-1271 contract signature: consent happened on-chain when /// the commitment was created, so the host signs nothing here. eip1271, - /// A pre-signed authorization recorded at the settlement contract + /// A pre-signed authorisation recorded at the settlement contract /// ahead of submission. presign, /// A venue-defined off-chain signature scheme. offchain-sig, - /// No authorization travels with the body. + /// No authorisation travels with the body. unsigned, } @@ -48,7 +48,7 @@ interface types { valid-until: option, /// Where the deal settles. settlement: settlement, - /// How the venue authorizes the intent. + /// How the venue authorises the intent. authorisation: auth-scheme, } diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit index e70b4f24..d626d409 100644 --- a/wit/nexum-value-flow/types.wit +++ b/wit/nexum-value-flow/types.wit @@ -62,7 +62,12 @@ interface types { /// 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, ...). + /// 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>), @@ -80,6 +85,12 @@ interface types { /// 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, From 0f0d719696145a0b76c7c0afc1441885b1aa8aec Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:17:24 +0000 Subject: [PATCH 19/29] feat(cow-venue): scaffold venue-neutral CoW intent body crate (#258) * feat(cow-venue): scaffold default body slice with borsh IntentBody codec Stage the CoW venue as a crate of feature slices. The default `body` slice carries the venue-neutral order and composable intent body types and the borsh IntentBody codec over the outer per-venue version enum, so a future adapter component or a strategy module can link the bodies and codec without the host-side CoW machinery. The slice links only the venue SDK (for the IntentBody derive) and borsh; `--no-default-features` drops it to an empty crate. shepherd-sdk gains a re-export shim under `cow` so the module ports can move off the legacy path without a breaking rename. * style(cow-venue): use -ize spelling and drop test reach into borsh __private --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/cow-venue/Cargo.toml | 31 +++++++ crates/cow-venue/src/body.rs | 122 +++++++++++++++++++++++++ crates/cow-venue/src/composable.rs | 56 ++++++++++++ crates/cow-venue/src/lib.rs | 38 ++++++++ crates/cow-venue/src/order.rs | 141 +++++++++++++++++++++++++++++ crates/shepherd-sdk/Cargo.toml | 4 + crates/shepherd-sdk/src/cow/mod.rs | 8 ++ 9 files changed, 410 insertions(+) create mode 100644 crates/cow-venue/Cargo.toml create mode 100644 crates/cow-venue/src/body.rs create mode 100644 crates/cow-venue/src/composable.rs create mode 100644 crates/cow-venue/src/lib.rs create mode 100644 crates/cow-venue/src/order.rs diff --git a/Cargo.lock b/Cargo.lock index 5863a79a..97d824ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,6 +1553,14 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cow-venue" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-venue-sdk", +] + [[package]] name = "cowprotocol" version = "0.2.0" @@ -5155,6 +5163,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", diff --git a/Cargo.toml b/Cargo.toml index 3ac4f1a2..d462ee44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/cow-venue", "crates/nexum-cli", "crates/nexum-macros", "crates/nexum-runtime", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml new file mode 100644 index 00000000..8b2ac37d --- /dev/null +++ b/crates/cow-venue/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cow-venue" +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." + +[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. + +[lints] +workspace = true + +[dependencies] +# Payload structs derive the borsh traits directly, so the crate carries +# its own borsh declaration (the IntentBody derive reaches borsh through +# the venue SDK re-export, but the payload derives need it by name). +borsh = { workspace = true, optional = true } +# Source of the `IntentBody` derive and trait the version enum implements. +nexum-venue-sdk = { path = "../nexum-venue-sdk", optional = true } + +[features] +# The body-type + codec slice: the only slice this crate ships today. +# `--no-default-features` drops it to an empty crate so downstream can +# depend on the future `client` / `adapter` slices without pulling the +# codec transitively. +default = ["body"] +body = ["dep:borsh", "dep:nexum-venue-sdk"] diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs new file mode 100644 index 00000000..3cad2c1e --- /dev/null +++ b/crates/cow-venue/src/body.rs @@ -0,0 +1,122 @@ +//! 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 +//! [`BodyError`](nexum_venue_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 crate::composable::ComposableBody; +use crate::order::OrderBody; + +/// What the CoW venue accepts: a direct order or a conditional order. +#[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. +/// Tag order is the schema; append new versions, never reorder. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum CowIntentBody { + /// First published version: a [`CowIntent`] sum. + V1(CowIntent), +} + +#[cfg(test)] +mod tests { + use super::*; + use nexum_venue_sdk::BodyError; + + use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; + + 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, + } + } + + fn composable_body() -> ComposableBody { + ComposableBody { + handler: [0xab; 20], + salt: [0xcd; 32], + static_input: vec![9, 8, 7], + } + } + + #[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); + } + } + + #[test] + fn wire_tag_is_the_declaration_index() { + let bytes = CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .unwrap(); + assert_eq!(bytes[0], 0); + } + + #[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 }) + ); + } + + #[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, .. }) + )); + } +} diff --git a/crates/cow-venue/src/composable.rs b/crates/cow-venue/src/composable.rs new file mode 100644 index 00000000..c3535865 --- /dev/null +++ b/crates/cow-venue/src/composable.rs @@ -0,0 +1,56 @@ +//! The venue-neutral 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 +//! it, so this crate never inspects its bytes. + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::order::Address; + +/// The venue-neutral 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, + /// Salt distinguishing otherwise-identical conditional orders. + pub salt: [u8; 32], + /// Handler-specific static input; opaque to the venue. + pub static_input: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ComposableBody { + ComposableBody { + handler: [0xab; 20], + salt: [0xcd; 32], + static_input: vec![1, 2, 3, 4, 5], + } + } + + #[test] + fn composable_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); + } + + #[test] + fn empty_static_input_round_trips() { + 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); + } +} diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs new file mode 100644 index 00000000..2ccc5759 --- /dev/null +++ b/crates/cow-venue/src/lib.rs @@ -0,0 +1,38 @@ +//! # 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 body slice is dependency-light on purpose. It links only the +//! 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. +//! +//! With `--no-default-features` the slice drops out entirely and the +//! crate compiles empty, so a consumer can depend on a future slice +//! without pulling the codec transitively. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +#[cfg(feature = "body")] +pub mod body; + +#[cfg(feature = "body")] +pub mod composable; + +#[cfg(feature = "body")] +pub mod order; + +#[cfg(feature = "body")] +pub use body::{CowIntent, CowIntentBody}; +#[cfg(feature = "body")] +pub use composable::ComposableBody; +#[cfg(feature = "body")] +pub use order::{BuyTokenDestination, OrderBody, OrderKind, SellTokenSource}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs new file mode 100644 index 00000000..bfd3b677 --- /dev/null +++ b/crates/cow-venue/src/order.rs @@ -0,0 +1,141 @@ +//! The venue-neutral CoW order body. +//! +//! On the wire a CoW order is the 12-field `GPv2Order` tuple. The +//! host-side path speaks it through the on-chain alloy types; this body +//! type is the same shape reduced to plain wire primitives (byte arrays +//! for addresses and 256-bit amounts, small enums for the balance and +//! kind markers) so it borsh-encodes and links without the on-chain +//! stack. The one non-obvious invariant: `amount`, `receiver`, and the +//! 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 borsh::{BorshDeserialize, BorshSerialize}; + +/// A 20-byte EVM address in wire form. +pub type Address = [u8; 20]; + +/// A 256-bit amount as its 32-byte big-endian representation. +pub type U256 = [u8; 32]; + +/// Which side of the trade is fixed. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderKind { + /// Sell a fixed `sell_amount`; `buy_amount` is the limit. + Sell, + /// Buy a fixed `buy_amount`; `sell_amount` is the limit. + Buy, +} + +/// Where the settlement pulls the sell token from. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum SellTokenSource { + /// Ordinary ERC-20 `transferFrom`. + Erc20, + /// Balancer external balance. + External, + /// Balancer internal balance. + Internal, +} + +/// Where the settlement delivers the buy token. +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum BuyTokenDestination { + /// Ordinary ERC-20 transfer. + Erc20, + /// Balancer internal balance. + Internal, +} + +/// The venue-neutral order body: the `GPv2Order` fields in wire form. +/// +/// `receiver` is `None` for the self-receive default the orderbook +/// normalizes the zero address to; the adapter round-trips that +/// normalization on the chain edge. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct OrderBody { + /// Token the owner sells. + pub sell_token: Address, + /// Token the owner buys. + pub buy_token: Address, + /// Recipient of the buy token; `None` sends it back to the owner. + pub receiver: Option

, + /// Sell amount, or its limit when `kind` is `Buy`. + pub sell_amount: U256, + /// Buy amount, or its limit when `kind` is `Sell`. + pub buy_amount: U256, + /// Unix-seconds expiry. + pub valid_to: u32, + /// The 32-byte on-chain app-data hash. + pub app_data: [u8; 32], + /// Fee amount taken in the sell token. + pub fee_amount: U256, + /// Which side is fixed. + pub kind: OrderKind, + /// Whether the order may partially fill. + pub partially_fillable: bool, + /// Where the sell token is sourced from. + pub sell_token_balance: SellTokenSource, + /// Where the buy token is delivered to. + pub buy_token_balance: BuyTokenDestination, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> OrderBody { + OrderBody { + sell_token: [0x11; 20], + buy_token: [0x22; 20], + receiver: Some([0x33; 20]), + sell_amount: { + let mut a = [0u8; 32]; + a[31] = 0x2a; + a + }, + buy_amount: [0xff; 32], + valid_to: 0xffff_ffff, + app_data: [0x44; 32], + fee_amount: [0u8; 32], + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + } + } + + #[test] + fn order_body_borsh_round_trips() { + let body = sample(); + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).expect("decode"), body); + } + + #[test] + fn none_receiver_round_trips() { + let mut body = sample(); + body.receiver = None; + let bytes = borsh::to_vec(&body).expect("encode"); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap().receiver, None); + } + + #[test] + fn marker_enums_round_trip() { + for kind in [OrderKind::Sell, OrderKind::Buy] { + for sell in [ + SellTokenSource::Erc20, + SellTokenSource::External, + SellTokenSource::Internal, + ] { + for buy in [BuyTokenDestination::Erc20, BuyTokenDestination::Internal] { + let mut body = sample(); + body.kind = kind; + body.sell_token_balance = sell; + body.buy_token_balance = buy; + let bytes = borsh::to_vec(&body).unwrap(); + assert_eq!(OrderBody::try_from_slice(&bytes).unwrap(), body); + } + } + } + } +} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 2e4547f2..0fa46504 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -12,6 +12,10 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # 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. +cow-venue = { path = "../cow-venue" } nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 893a80e6..cb3b920b 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -25,6 +25,14 @@ pub use error::{ pub use order::{gpv2_to_order_data, order_uid_hex}; pub use run::run; +/// 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. +pub use cow_venue::{ + BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, OrderKind, + SellTokenSource, +}; + use nexum_sdk::host::Host; /// `shepherd:cow/cow-api` - orderbook submission path. The CoW-domain From 17464a13e4720b114232ebe4f61ae9a5dc28a36e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:20:02 +0000 Subject: [PATCH 20/29] feat(cow-venue): drive CoW retry classification from a data table (#259) * feat(cow-venue): drive cow retry classification from a shipped data table Ship the CoW orderbook errorType retry policy as data in crates/cow-venue/data/classification.toml, readable and editable by non-Rust authors, and add a `client` feature slice that embeds it, exposes a typed CowClient bound to the CoW venue, and a table-driven classification API returning the keeper RetryAction. Replace the hand-coded classify_api_error/is_retriable in the cow error surface with the table lookup, preserving the already-submitted and drop-on-unrecognized behaviour. A throttle errorType now maps to Backoff, so every RetryAction arm is reachable from the table alone, guarded by a data-vs-code parity test and an untyped-parse test that proves any TOML reader consumes the same file. * perf(cow-venue): generate the classification table at build time shepherd-sdk unconditionally enables the cow-venue client slice, and the wasm modules (twap-monitor, ethflow-watcher) reach classify_api_error through shepherd_sdk::cow::run, so the runtime LazyLock TOML parse linked the full toml/serde/winnow stack into every guest: the twap-monitor wasm grew ~237 KiB (350 KiB -> 586 KiB). Move the parse to build.rs. It reads data/classification.toml, validates the table invariants, and emits a static lookup table; the runtime slice carries only generated rows and no TOML parser. serde/toml/thiserror become build- and dev-only. The parse and invariants live in a shared classification_data module that build.rs and the parity test both link, so the data-vs-code parity test now re-parses the shipped file and checks the generated table against it. The guest returns to 351 KiB. --- .gitignore | 3 + Cargo.lock | 4 + crates/cow-venue/Cargo.toml | 30 ++- crates/cow-venue/build.rs | 43 ++++ crates/cow-venue/data/classification.toml | 113 +++++++++ crates/cow-venue/src/classification.rs | 244 ++++++++++++++++++++ crates/cow-venue/src/classification_data.rs | 87 +++++++ crates/cow-venue/src/client.rs | 128 ++++++++++ crates/cow-venue/src/lib.rs | 26 +++ crates/shepherd-sdk/Cargo.toml | 6 +- crates/shepherd-sdk/src/cow/error.rs | 64 ++--- 11 files changed, 702 insertions(+), 46 deletions(-) create mode 100644 crates/cow-venue/build.rs create mode 100644 crates/cow-venue/data/classification.toml create mode 100644 crates/cow-venue/src/classification.rs create mode 100644 crates/cow-venue/src/classification_data.rs create mode 100644 crates/cow-venue/src/client.rs diff --git a/.gitignore b/.gitignore index d34b1806..35532e08 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ skills-lock.json # Engine runtime state (default state_dir from engine.toml). data/ +# Shipped crate data slices are source, not runtime state: keep them. +!crates/*/data/ +!crates/*/data/** # E2E automation: rendered configs with embedded RPC keys + script state # never get committed. diff --git a/Cargo.lock b/Cargo.lock index 97d824ff..7ae97278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,7 +1558,11 @@ name = "cow-venue" version = "0.1.0" dependencies = [ "borsh", + "nexum-sdk", "nexum-venue-sdk", + "serde", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", ] [[package]] diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 8b2ac37d..975ea7a9 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -19,13 +19,33 @@ workspace = true # its own borsh declaration (the IntentBody derive reaches borsh through # the venue SDK re-export, but the payload derives need it by name). borsh = { workspace = true, optional = true } -# Source of the `IntentBody` derive and trait the version enum implements. +# 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 } +# `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 +# reach a guest that links this slice. +nexum-sdk = { path = "../nexum-sdk", optional = true } + +# `build.rs` parses `data/classification.toml` and emits the static +# lookup table; the same parse is shared with the parity tests below. +[build-dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +serde = { workspace = true } +toml = { workspace = true } +thiserror = { workspace = true } [features] -# The body-type + codec slice: the only slice this crate ships today. -# `--no-default-features` drops it to an empty crate so downstream can -# depend on the future `client` / `adapter` slices without pulling the -# codec transitively. +# 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. default = ["body"] body = ["dep:borsh", "dep:nexum-venue-sdk"] +client = ["body", "dep:nexum-sdk"] diff --git a/crates/cow-venue/build.rs b/crates/cow-venue/build.rs new file mode 100644 index 00000000..10122c97 --- /dev/null +++ b/crates/cow-venue/build.rs @@ -0,0 +1,43 @@ +//! Turn the shipped `data/classification.toml` into a generated lookup +//! table at build time, so the runtime `client` slice (and any wasm +//! guest that links it) carries no TOML parser. The parse and the table +//! invariants live in `src/classification_data.rs`, shared verbatim with +//! the crate's parity tests. + +#[path = "src/classification_data.rs"] +mod classification_data; + +use std::{env, fs, path::Path}; + +use classification_data::{Action, parse_and_validate}; + +fn main() { + let manifest = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + let data = Path::new(&manifest).join("data/classification.toml"); + println!("cargo:rerun-if-changed={}", data.display()); + println!("cargo:rerun-if-changed=src/classification_data.rs"); + + let toml = fs::read_to_string(&data).expect("read data/classification.toml"); + let entries = + parse_and_validate(&toml).expect("shipped cow classification.toml is well formed"); + + let mut out = String::new(); + out.push_str("// @generated from data/classification.toml by build.rs; do not edit.\n"); + out.push_str("static GENERATED_ROWS: &[GeneratedRow] = &[\n"); + for e in &entries { + let action = match e.action { + Action::TryNextBlock => "GenAction::TryNextBlock", + Action::Backoff => "GenAction::Backoff", + Action::Drop => "GenAction::Drop", + }; + out.push_str(&format!( + " GeneratedRow {{ error_type: {:?}, action: {action}, \ + backoff_seconds: {}, already_submitted: {} }},\n", + e.error_type, e.backoff_seconds, e.already_submitted, + )); + } + out.push_str("];\n"); + + let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("classification_table.rs"); + fs::write(&dest, out).expect("write generated classification table"); +} diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml new file mode 100644 index 00000000..589b4ac4 --- /dev/null +++ b/crates/cow-venue/data/classification.toml @@ -0,0 +1,113 @@ +# CoW orderbook order-submission retry classification. +# +# This file is the source of truth for how a submitted order's rejection +# `errorType` maps to a retry action. It is plain TOML: a non-Rust author +# (or any TOML reader in any language) can add, remove, or re-target an +# entry without touching Rust. The `cow-venue` `client` slice embeds and +# parses this exact file, and a parity test guards the Rust contract +# against it. +# +# Each entry names one orderbook `errorType` and the action the retry +# ledger takes when the orderbook returns it: +# +# action = "try-next-block" Transient: a fresh submission on a later +# block may succeed. Leave the watch in +# place. +# action = "backoff" Server- or account-throttle: retrying next +# block cannot clear the condition. Gate the +# watch for `backoff-seconds` before the next +# attempt. `backoff-seconds` is required and +# must be at least 1. +# action = "drop" Permanent: no retry can succeed. Remove the +# watch and its gates. +# +# `already-submitted = true` marks a rejection that means the orderbook +# already holds this exact order. It is success wearing an error status, +# so the action is "try-next-block" (never drop, which would kill every +# future tranche of a TWAP) and the submit path records the submitted +# receipt so the next tick short-circuits instead of re-posting. +# +# Any `errorType` absent from this table classifies as "drop": an +# unrecognized, structured contract-level rejection is treated as +# permanent rather than retried every block forever. + +# --- Transient: retry on the next block ------------------------------ + +[[entry]] +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 +# next-block retry cannot help: the slot only frees when an existing +# order settles or expires, so back off and re-check rather than hammer +# the orderbook every block. +[[entry]] +error-type = "TooManyLimitOrders" +action = "backoff" +backoff-seconds = 30 + +# --- Already submitted: keep the watch, record the receipt ----------- + +# The orderbook's canonical spelling. +[[entry]] +error-type = "DuplicatedOrder" +action = "try-next-block" +already-submitted = true + +# The spelling older deployments emit; classify identically. +[[entry]] +error-type = "DuplicateOrder" +action = "try-next-block" +already-submitted = true + +# --- Permanent: drop the watch --------------------------------------- +# +# Listed for documentation; each is also the default for any unlisted +# `errorType`. Flip one to "backoff" or "try-next-block" here to change +# the policy without touching Rust. + +[[entry]] +error-type = "InvalidSignature" +action = "drop" + +[[entry]] +error-type = "InvalidEip1271Signature" +action = "drop" + +[[entry]] +error-type = "WrongOwner" +action = "drop" + +[[entry]] +error-type = "InsufficientBalance" +action = "drop" + +[[entry]] +error-type = "InsufficientAllowance" +action = "drop" + +[[entry]] +error-type = "UnsupportedToken" +action = "drop" + +[[entry]] +error-type = "InvalidAppData" +action = "drop" + +[[entry]] +error-type = "AppDataHashMismatch" +action = "drop" + +[[entry]] +error-type = "ZeroAmount" +action = "drop" + +[[entry]] +error-type = "SameBuyAndSellToken" +action = "drop" diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs new file mode 100644 index 00000000..0aacc36f --- /dev/null +++ b/crates/cow-venue/src/classification.rs @@ -0,0 +1,244 @@ +//! Table-driven CoW retry classification. +//! +//! The `errorType -> {try-next-block, backoff, drop}` policy is shipped +//! as data in `data/classification.toml`. `build.rs` parses and +//! validates that file at build time and emits a static lookup table, so +//! [`classify`] and [`is_already_submitted`] read generated data rather +//! than a hand-coded `match` and no TOML parser reaches the guest. A +//! non-Rust author edits the TOML; a parity test re-parses the same file +//! and asserts the generated table agrees. +//! +//! The one non-obvious invariant: an `errorType` absent from the table +//! classifies as [`RetryAction::Drop`]. An unrecognized structured +//! rejection is a permanent contract-level refusal, not a transient +//! transport error, so it must not be retried every block forever. + +use nexum_sdk::keeper::RetryAction; + +/// The shipped classification data, embedded verbatim so a parity test +/// can re-parse the exact bytes `build.rs` generated the table from. +pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml"); + +/// The retry action a generated row selects, mirroring the TOML `action` +/// field. Turned into a keeper [`RetryAction`] by [`GeneratedRow`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum GenAction { + TryNextBlock, + Backoff, + Drop, +} + +/// One classification row, generated from the shipped data table. +#[derive(Clone, Copy, Debug)] +struct GeneratedRow { + error_type: &'static str, + action: GenAction, + backoff_seconds: u64, + already_submitted: bool, +} + +impl GeneratedRow { + fn retry_action(&self) -> RetryAction { + match self.action { + GenAction::TryNextBlock => RetryAction::TryNextBlock, + // `build.rs` validated backoff rows non-zero; `.max(1)` keeps + // the mapping total even if that guard is ever relaxed. + GenAction::Backoff => RetryAction::Backoff { + seconds: self.backoff_seconds.max(1), + }, + GenAction::Drop => RetryAction::Drop, + } + } +} + +// `static GENERATED_ROWS: &[GeneratedRow]`, one row per TOML entry. +include!(concat!(env!("OUT_DIR"), "/classification_table.rs")); + +/// The shipped classification: a lookup over the generated rows. +/// Unlisted types classify as [`RetryAction::Drop`]. +#[derive(Clone, Copy, Debug)] +pub struct ClassificationTable { + rows: &'static [GeneratedRow], +} + +impl ClassificationTable { + fn row(&self, error_type: &str) -> Option<&GeneratedRow> { + self.rows.iter().find(|r| r.error_type == error_type) + } + + /// The retry action for an orderbook `errorType`. Unlisted types are + /// permanent: [`RetryAction::Drop`]. + pub fn classify(&self, error_type: &str) -> RetryAction { + self.row(error_type) + .map_or(RetryAction::Drop, GeneratedRow::retry_action) + } + + /// Whether the orderbook is reporting that it already holds this + /// exact order. Such a rejection keeps the watch and records the + /// receipt rather than retrying a fresh submission. + pub fn is_already_submitted(&self, error_type: &str) -> bool { + self.row(error_type).is_some_and(|r| r.already_submitted) + } + + /// Number of classified `errorType`s, for tests and diagnostics. + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether the table carries no entries. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// The classification table generated from the shipped data. +pub fn table() -> ClassificationTable { + ClassificationTable { + rows: GENERATED_ROWS, + } +} + +/// Classify an orderbook `errorType` into a keeper [`RetryAction`] via +/// the shipped table. Unlisted types are permanent ([`RetryAction::Drop`]). +pub fn classify(error_type: &str) -> RetryAction { + table().classify(error_type) +} + +/// Whether an orderbook `errorType` means the order is already held. +pub fn is_already_submitted(error_type: &str) -> bool { + table().is_already_submitted(error_type) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::classification_data::{Action, ClassificationError, parse_and_validate}; + + /// The generated table is non-empty. + #[test] + fn shipped_data_parses() { + assert!(!table().is_empty()); + } + + /// Data-vs-code parity: re-parse the shipped file independently and + /// assert the generated table (code) agrees with it (data) on every + /// entry. This catches a data edit the generated table missed and a + /// generator bug that drifts from the file. + #[test] + fn data_matches_code_contract() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + assert_eq!(table().len(), entries.len(), "row count matches the data"); + for entry in &entries { + let expected = match entry.action { + Action::TryNextBlock => RetryAction::TryNextBlock, + Action::Backoff => RetryAction::Backoff { + seconds: entry.backoff_seconds.max(1), + }, + Action::Drop => RetryAction::Drop, + }; + assert_eq!( + classify(&entry.error_type), + expected, + "classify {}", + entry.error_type, + ); + assert_eq!( + is_already_submitted(&entry.error_type), + entry.already_submitted, + "already-submitted {}", + entry.error_type, + ); + } + } + + /// A spot check of the contract in code, independent of the parse: + /// the exemplar rows the slice must carry, including the `Backoff` + /// producer the hand-coded classifier lacked. + #[test] + fn known_rows_classify_as_documented() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert_eq!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { seconds: 30 }, + ); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + assert!(is_already_submitted("DuplicatedOrder")); + assert!(is_already_submitted("DuplicateOrder")); + } + + /// Unlisted (including newly minted) types are permanent, so a + /// contract-level rejection is never retried every block forever. + #[test] + fn unlisted_type_drops() { + assert_eq!(classify("NewlyMintedErrorType"), RetryAction::Drop); + assert!(!is_already_submitted("NewlyMintedErrorType")); + } + + /// All three retry arms are reachable from the table alone. + #[test] + fn table_reaches_every_arm() { + assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert!(matches!( + classify("TooManyLimitOrders"), + RetryAction::Backoff { .. } + )); + assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + } + + #[test] + fn duplicate_type_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Dup" + action = "drop" + [[entry]] + error-type = "Dup" + action = "drop" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::Duplicate("Dup".to_string()), + ); + } + + #[test] + fn backoff_without_delay_is_rejected() { + let toml = r#" + [[entry]] + error-type = "Slow" + action = "backoff" + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::ZeroBackoff("Slow".to_string()), + ); + } + + #[test] + fn already_submitted_must_try_next_block() { + let toml = r#" + [[entry]] + error-type = "Held" + action = "drop" + already-submitted = true + "#; + assert_eq!( + parse_and_validate(toml).unwrap_err(), + ClassificationError::AlreadySubmittedAction("Held".to_string()), + ); + } + + /// 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. + #[test] + fn non_rust_reader_sees_plain_toml() { + let value: toml::Table = + toml::from_str(CLASSIFICATION_TOML).expect("valid TOML for any reader"); + let entries = value["entry"].as_array().expect("entry is an array"); + assert!(!entries.is_empty()); + let first = entries[0].as_table().expect("entry is a table"); + assert!(first.contains_key("error-type")); + assert!(first.contains_key("action")); + } +} diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs new file mode 100644 index 00000000..3938eddd --- /dev/null +++ b/crates/cow-venue/src/classification_data.rs @@ -0,0 +1,87 @@ +//! Parse and validate the shipped classification data. +//! +//! This module is the single source of the TOML schema and the table +//! invariants. It is compiled twice, never into a guest: `build.rs` +//! includes it to turn `data/classification.toml` into a generated +//! lookup table at build time, and the crate's own tests include it to +//! re-parse the same file and assert the generated table agrees. The +//! runtime `client` slice carries only the generated table, so no TOML +//! parser reaches the wasm guest. + +use serde::Deserialize; + +/// One of the three retry actions an `errorType` maps to on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Action { + /// Transient: a fresh submission on a later block may succeed. + TryNextBlock, + /// Throttle: gate the watch for `backoff_seconds` before retrying. + Backoff, + /// Permanent: remove the watch and its gates. + Drop, +} + +/// A single classification row as it appears in the TOML. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct Entry { + /// The orderbook `errorType` this row classifies. + pub error_type: String, + /// The retry action the row selects. + pub action: Action, + /// Required (and meaningful) only for `action = "backoff"`. + #[serde(default)] + pub backoff_seconds: u64, + /// Marks a rejection meaning the orderbook already holds this order. + #[serde(default)] + pub already_submitted: bool, +} + +#[derive(Debug, Deserialize)] +struct Document { + #[serde(default)] + entry: Vec, +} + +/// Why the shipped classification data could not be turned into a table. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ClassificationError { + /// The TOML did not parse or a field had the wrong type. + #[error("classification data is not valid TOML: {0}")] + Toml(String), + /// Two entries named the same `errorType`. + #[error("duplicate errorType `{0}` in classification data")] + Duplicate(String), + /// A `backoff` entry left `backoff-seconds` at zero (or absent). + #[error("errorType `{0}` is backoff but backoff-seconds is not >= 1")] + ZeroBackoff(String), + /// An `already-submitted` entry did not classify as try-next-block. + #[error("errorType `{0}` is already-submitted but action is not try-next-block")] + AlreadySubmittedAction(String), +} + +/// Parse a classification document and validate the table invariants: no +/// duplicate `errorType`, every `backoff` carries a positive delay, and +/// `already-submitted` implies `try-next-block`. +pub fn parse_and_validate(toml: &str) -> Result, ClassificationError> { + let doc: Document = + toml::from_str(toml).map_err(|e| ClassificationError::Toml(e.to_string()))?; + + let mut seen: Vec<&str> = Vec::with_capacity(doc.entry.len()); + for entry in &doc.entry { + if entry.action == Action::Backoff && entry.backoff_seconds == 0 { + return Err(ClassificationError::ZeroBackoff(entry.error_type.clone())); + } + if entry.already_submitted && entry.action != Action::TryNextBlock { + return Err(ClassificationError::AlreadySubmittedAction( + entry.error_type.clone(), + )); + } + if seen.contains(&entry.error_type.as_str()) { + return Err(ClassificationError::Duplicate(entry.error_type.clone())); + } + seen.push(&entry.error_type); + } + Ok(doc.entry) +} diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs new file mode 100644 index 00000000..81bd4e5c --- /dev/null +++ b/crates/cow-venue/src/client.rs @@ -0,0 +1,128 @@ +//! The typed CoW intent client. +//! +//! [`CowClient`] binds the strategy-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 +//! 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::{IntentStatus, SubmitOutcome}; + +use crate::body::CowIntentBody; + +/// The venue id the CoW adapter registers under and the router resolves. +/// Every [`CowClient`] call routes here. +pub const VENUE: &str = "cow"; + +/// 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 CowClient

{ + /// Bind a pool handle to the CoW venue. + pub fn new(pool: P) -> Self { + Self { + inner: IntentClient::new(pool, VENUE), + } + } + + /// The venue id every call routes to (always [`VENUE`]). + pub fn venue(&self) -> &str { + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nexum_venue_sdk::VenueError; + use std::cell::RefCell; + use std::rc::Rc; + + /// One recorded submit: the venue it routed to and the wire bytes. + type SubmitLog = Rc)>>>; + + /// 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. + #[derive(Clone, Default)] + struct SpyPool { + submitted: SubmitLog, + } + + impl IntentPool for SpyPool { + fn submit(&self, venue: &str, 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 { + unreachable!("status not exercised") + } + + fn cancel(&self, _venue: &str, _receipt: &[u8]) -> Result<(), VenueError> { + unreachable!("cancel not exercised") + } + } + + 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, + })) + } + + #[test] + fn submit_routes_to_the_cow_venue_with_encoded_body() { + use nexum_venue_sdk::IntentBody; + + let pool = SpyPool::default(); + let body = sample_body(); + let expected = body.to_bytes().expect("body encodes"); + + let client = CowClient::new(pool.clone()); + assert_eq!(client.venue(), VENUE); + client.submit(&body).expect("submit succeeds"); + + let calls = pool.submitted.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, VENUE); + assert_eq!(calls[0].1, expected); + } +} diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 2ccc5759..1fa8a521 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -17,6 +17,14 @@ //! With `--no-default-features` the slice drops out entirely and the //! crate compiles empty, so a consumer can depend on a future slice //! 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 +//! 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, +//! so an adapter or a module that wants only the body types stays +//! dependency-light. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -30,9 +38,27 @@ pub mod composable; #[cfg(feature = "body")] pub mod order; +#[cfg(feature = "client")] +pub mod classification; + +// The shared TOML parse and table invariants. `build.rs` includes this +// file to generate the classification table; the crate links it only in +// tests, to re-parse the shipped data and check parity. It never reaches +// a guest. +#[cfg(all(feature = "client", test))] +mod classification_data; + +#[cfg(feature = "client")] +pub mod client; + #[cfg(feature = "body")] pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use composable::ComposableBody; #[cfg(feature = "body")] 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}; diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 0fa46504..ea3a1e4c 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -14,8 +14,10 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or [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. -cow-venue = { path = "../cow-venue" } +# 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"] } nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 4129cd71..c26eded3 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -84,15 +84,11 @@ impl HostFault for CowApiError { } /// Classify a decoded orderbook [`OrderRejection`] into the keeper -/// [`RetryAction`] - the CoW `errorType` classification table. -/// -/// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, -/// `PriceExceedsMarketPrice`) -> `TryNextBlock`. -/// - Already-submitted rejections ([`is_already_submitted`]) -> -/// `TryNextBlock`: the order is live, so the watch must survive; the -/// run also records the `submitted:` receipt so the next -/// tick short-circuits instead of re-posting. -/// - Every other (including unrecognised) kind -> `Drop`. +/// [`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`]. @@ -121,24 +117,18 @@ impl HostFault for CowApiError { /// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); /// ``` pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_already_submitted(rejection) || is_retriable(&rejection.error_type) { - RetryAction::TryNextBlock - } else { - RetryAction::Drop - } + cow_venue::classify(&rejection.error_type) } /// Whether the rejection says the orderbook already holds this exact -/// order: `DuplicatedOrder` (the orderbook's spelling) plus the -/// `DuplicateOrder` variant 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. +/// 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 { - matches!( - rejection.error_type.as_str(), - "DuplicatedOrder" | "DuplicateOrder" - ) + cow_venue::is_already_submitted(&rejection.error_type) } /// Classify a whole [`CowApiError`] from a submission into the keeper @@ -163,17 +153,6 @@ pub fn classify_submit_error(err: &CowApiError) -> RetryAction { } } -/// Orderbook `errorType` values the protocol treats as transient: a -/// fresh submission on a later block may succeed. Everything else -/// (including unrecognised types) is permanent. Mirrors the upstream -/// order-post retry classifier. -fn is_retriable(error_type: &str) -> bool { - matches!( - error_type, - "InsufficientFee" | "TooManyLimitOrders" | "PriceExceedsMarketPrice" - ) -} - #[cfg(test)] mod tests { use super::*; @@ -190,11 +169,7 @@ mod tests { #[test] fn retriable_kinds_yield_try_next_block() { - for kind in [ - "InsufficientFee", - "TooManyLimitOrders", - "PriceExceedsMarketPrice", - ] { + for kind in ["InsufficientFee", "PriceExceedsMarketPrice"] { assert_eq!( classify_api_error(&rejection(kind)), RetryAction::TryNextBlock, @@ -203,6 +178,17 @@ mod tests { } } + /// 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 [ From c8ce57c21e6e7d552b3bd5685bc51ad2becda660 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:22:41 +0000 Subject: [PATCH 21/29] fix(cow-venue): QA sweep for m1-cow-classification (#260) * fix(cow): use canonical InvalidEip1271Signature errorType spelling The permanent_kinds_yield_drop test asserted on "InvalidErc1271Signature", which is not a real CoW orderbook errorType (the OpenAPI OrderPostError enum and cowprotocol's OrderbookApiErrorType both spell it InvalidEip1271Signature, matching data/classification.toml). The phantom spelling passed only via the unlisted-default Drop, so it never exercised the real table row. Use the canonical spelling so the test covers the actual entry. * docs(cow-venue): ratify deliberate divergence from cowprotocol retry_hint shepherd-sdk already depends on cowprotocol, whose ApiError::retry_hint() classifies the same orderbook errorTypes and disagrees with this table on several (e.g. it retries/backs off InvalidEip1271Signature, InsufficientBalance, InsufficientAllowance and InvalidAppData where this table drops, and backs off TooManyLimitOrders for an hour rather than 30s). The table is deliberately not delegated to it: it is shepherd's own, more conservative policy, kept as data of record so a non-Rust author owns it and the guest client slice stays free of the upstream error module. Record that this divergence is intentional so a future maintainer revisits the policy here rather than swapping the source of truth. (Also fixes the -ise spelling in the same comment.) * style(cow-venue): adopt British -ise spelling to match house convention The crate used American -ize (normalizes/normalization, unrecognized) while the adjacent domain code (shepherd-sdk cow/order.rs normalised, composable.rs unrecognised, run.rs) and the repo at large use British -ise. Align the prose so the sibling modules read consistently. * fix(cow-venue): allow GenAction variants unused by shipped data GenAction's variants are constructed only by the build-generated table, so a classification.toml that carries no row of a given action leaves that variant unconstructed and trips the -D warnings build. Since which actions appear is a property of the data, not the code, mark the enum allow(dead_code) so a data-only edit cannot break the workspace lint gate. --- .github/workflows/ci.yml | 4 ++-- crates/cow-venue/data/classification.toml | 16 +++++++++++++++- crates/cow-venue/src/classification.rs | 9 ++++++++- crates/cow-venue/src/order.rs | 4 ++-- crates/shepherd-sdk/src/cow/error.rs | 2 +- justfile | 4 ++-- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ed0f134..a158c6e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,8 @@ jobs: run: | 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 clock-reader -p flaky-bomb -p fuel-bomb \ + -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 { echo "### module .wasm sizes" diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index 589b4ac4..46507d48 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -28,8 +28,22 @@ # receipt so the next tick short-circuits instead of re-posting. # # Any `errorType` absent from this table classifies as "drop": an -# unrecognized, structured contract-level rejection is treated as +# unrecognised, structured contract-level rejection is treated as # permanent rather than retried every block forever. +# +# 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`. # --- Transient: retry on the next block ------------------------------ diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index 0aacc36f..73d0e71f 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -9,7 +9,7 @@ //! and asserts the generated table agrees. //! //! The one non-obvious invariant: an `errorType` absent from the table -//! classifies as [`RetryAction::Drop`]. An unrecognized structured +//! classifies as [`RetryAction::Drop`]. An unrecognised structured //! rejection is a permanent contract-level refusal, not a transient //! transport error, so it must not be retried every block forever. @@ -21,6 +21,13 @@ pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml" /// The retry action a generated row selects, mirroring the TOML `action` /// field. Turned into a keeper [`RetryAction`] by [`GeneratedRow`]. +/// +/// The variants are constructed only by the build-generated table, so a +/// shipped `classification.toml` that happens to carry no row of a given +/// action leaves that variant unconstructed. `allow(dead_code)` keeps +/// such a data edit from tripping the `-D warnings` build: which actions +/// appear is a property of the data, not the code. +#[allow(dead_code)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GenAction { TryNextBlock, diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index bfd3b677..cc31b934 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -49,8 +49,8 @@ pub enum BuyTokenDestination { /// The venue-neutral order body: the `GPv2Order` fields in wire form. /// /// `receiver` is `None` for the self-receive default the orderbook -/// normalizes the zero address to; the adapter round-trips that -/// normalization on the chain edge. +/// normalises the zero address to; the adapter round-trips that +/// normalisation on the chain edge. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct OrderBody { /// Token the owner sells. diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index c26eded3..b626ab94 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -196,7 +196,7 @@ mod tests { "WrongOwner", "UnsupportedToken", "InvalidAppData", - "InvalidErc1271Signature", + "InvalidEip1271Signature", ] { assert_eq!( classify_api_error(&rejection(kind)), diff --git a/justfile b/justfile index e960a4f5..1a7bd4ff 100644 --- a/justfile +++ b/justfile @@ -91,6 +91,6 @@ ci: cargo doc --workspace --no-deps 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 clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb + -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 cargo test --workspace --all-features --no-fail-fast From ae846ad819a50dfb7e56f5d7556fc486a5a661d4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:25:35 +0000 Subject: [PATCH 22/29] refactor(cow): structured Verdict poll seam + LegacyRevertAdapter (Wave 1, ADR-0013) (#334) --- crates/shepherd-sdk-test/tests/mock_venue.rs | 17 +- crates/shepherd-sdk/README.md | 4 +- crates/shepherd-sdk/src/cow/composable.rs | 304 ++++++++++++------- crates/shepherd-sdk/src/cow/mod.rs | 8 +- crates/shepherd-sdk/src/cow/run.rs | 27 +- crates/shepherd-sdk/src/lib.rs | 9 +- crates/shepherd-sdk/src/proptests.rs | 12 +- crates/shepherd-sdk/tests/run.rs | 35 ++- modules/twap-monitor/src/strategy.rs | 56 ++-- 9 files changed, 287 insertions(+), 185 deletions(-) diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index ca2ba439..e9b3bef0 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; 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, PollOutcome, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; @@ -19,11 +19,11 @@ struct FnSource(F); impl ConditionalSource for FnSource where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { (self.0)(host, watch, params, tick) } } @@ -32,7 +32,7 @@ where /// construction site so inference never guesses a too-narrow lifetime. fn src(f: F) -> FnSource where - F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, { FnSource(f) } @@ -77,16 +77,17 @@ fn submittable_order() -> GPv2OrderData { } } -fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { - PollOutcome::Ready { +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) -> PollOutcome> { +) -> FnSource, &[u8], &Tick) -> Verdict> { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md index a93d3dfb..13a9ae0c 100644 --- a/crates/shepherd-sdk/README.md +++ b/crates/shepherd-sdk/README.md @@ -23,7 +23,7 @@ use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; | `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 + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | +| `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. | @@ -62,7 +62,7 @@ crates/shepherd-sdk/ │ ├── cow/ │ │ ├── mod.rs CowApiHost + CowHost │ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) +│ │ ├── 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 diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 47aa22ee..10e0fcb2 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -1,10 +1,21 @@ -//! ComposableCoW poll-revert decoding. +//! ComposableCoW poll seam: the structured [`Verdict`] and the +//! quarantined [`LegacyRevertAdapter`]. //! -//! `ComposableCoW.getTradeableOrderWithSignature` reverts with one of -//! five custom errors when the conditional order is not ready, expired, -//! or otherwise non-tradeable. This module mirrors that error surface -//! and maps each revert to the typed [`PollOutcome`] every TWAP / -//! strategy module dispatches on. +//! Every strategy poll resolves to a [`Verdict`] - the structured +//! outcome mirroring the composable-cow fork's structured generator. +//! The run and each strategy module +//! dispatch on the `Verdict` variants alone; nothing downstream knows +//! how the outcome was produced. +//! +//! The deployed ComposableCoW 1.x contract does not speak that +//! structured vocabulary. Its `getTradeableOrderWithSignature` reverts +//! with one of five custom errors when the conditional order is not +//! ready, expired, or otherwise non-tradeable. That reverting wire is +//! frozen: this module keeps decoding it, but the decode is quarantined +//! behind [`LegacyRevertAdapter`], which maps each legacy revert onto a +//! [`Verdict`]. When the fork's structured generator ships, the adapter +//! is the single seam that retires - the `Verdict` surface and every +//! dispatch site stay put. //! //! Source for the Solidity errors: //! `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. @@ -15,9 +26,10 @@ use cowprotocol::GPv2OrderData; use nexum_sdk::host::ChainError; sol! { - /// Five custom errors `IConditionalOrder.verify` reverts with. - /// Selector source for [`decode_revert`]. The wire shape mirrors - /// the Solidity definitions verbatim so the four-byte selectors + /// Five custom errors `IConditionalOrder.verify` reverts with - + /// the deployed ComposableCoW 1.x error surface. Selector source + /// for [`LegacyRevertAdapter::decode`]. The wire shape mirrors the + /// Solidity definitions verbatim so the four-byte selectors /// computed here match what the contract emits. #[derive(Debug)] interface IConditionalOrder { @@ -37,95 +49,140 @@ sol! { } } -/// Outcome of a single watch poll. Mirrors the enum shape: -/// `Ready` carries the materials the submit path needs; the other -/// variants drive the lifecycle handler. +/// Structured outcome of a single watch poll, mirroring the +/// composable-cow fork's structured generator. /// -/// `Ready` is intentionally never produced by [`decode_revert`] - it -/// only comes from the successful return path the poll module -/// constructs at the call site. +/// Every variant except `Post` carries a `reason`: the raw 4-byte +/// selector the outcome was derived from, for logging only - no +/// behaviour keys off it. It is `[0; 4]` when the outcome is synthetic +/// (no selector available, e.g. a transport fault). `Post` is the only +/// variant [`LegacyRevertAdapter`] never produces; it comes from the +/// successful return path each strategy constructs at the call site. #[derive(Debug)] -pub enum PollOutcome { +pub enum Verdict { /// Conditional order is tradeable now; submit `order` with the - /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed - /// to keep the enum cache-friendly (~300 bytes vs. ~8 for the - /// other variants). - Ready { + /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed to + /// keep the enum cache-friendly (~300 bytes vs. a few for the other + /// variants). + Post { /// The 12-field order ready to submit. order: Box, /// EIP-1271 wire-form signature (raw verifier bytes; the /// orderbook prepends `from` before settlement). signature: Bytes, + /// Advisory Unix timestamp (seconds) the fork's generator hints + /// the next poll at. `0` when synthetic - the legacy adapter + /// has no such hint, so the submit path ignores it. + next_poll_timestamp: u64, + }, + /// Retry once the wall clock (Unix seconds, UTC) reaches + /// `wait_until`. + WaitTimestamp { + /// Unix timestamp (seconds) to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry once the block number reaches `wait_until`. + WaitBlock { + /// Block number to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], }, /// Retry on the very next block - typical for time-sliced TWAP /// schedules and other handlers that re-check on every tick. - TryNextBlock, - /// Retry once block number reaches the embedded value. - TryOnBlock(u64), - /// Retry once the wall clock (Unix seconds, UTC) reaches the - /// embedded value. - TryAtEpoch(u64), - /// Order is dead - drop the watch. Aggregates `OrderNotValid` and - /// `PollNever` reverts; the original reason string is dropped - /// because the lifecycle handler does not key off it today. - DontTryAgain, + TryNextBlock { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Order is dead - drop the watch. Aggregates the legacy + /// `OrderNotValid` and `PollNever` reverts and any unrecognised + /// contract-level rejection. + Invalid { + /// Source selector, log only. + reason: [u8; 4], + }, + /// The generator needs off-chain input before it can produce an + /// order. Never produced by [`LegacyRevertAdapter`]; the + /// run parks the watch untouched. + NeedsInput { + /// Source selector, log only. + reason: [u8; 4], + }, } -/// Decode a `getTradeableOrderWithSignature` revert payload into a -/// [`PollOutcome`]. -/// -/// Returns `None` when the selector is not one of the five -/// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. [`classify_poll_error`] is the lifecycle policy on -/// top: it treats any such foreign selector as a permanent -/// contract-level rejection. -#[must_use] -pub fn decode_revert(data: &[u8]) -> Option { - if data.len() < 4 { - return None; - } - let selector: [u8; 4] = data[..4].try_into().ok()?; - let body = &data[4..]; - match selector { - s if s == IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), - s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => Some(PollOutcome::TryNextBlock), - s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryOnBlock(u256_to_u64_saturating( - decoded.blockNumber, - ))) +/// Quarantined decoder for the deployed ComposableCoW 1.x reverting +/// wire. Maps each legacy `getTradeableOrderWithSignature` revert onto +/// a [`Verdict`]; this is the single seam that retires when the fork's +/// structured generator ships. +#[derive(Debug, Clone, Copy)] +pub struct LegacyRevertAdapter; + +impl LegacyRevertAdapter { + /// Decode a `getTradeableOrderWithSignature` revert payload into a + /// [`Verdict`]. + /// + /// Returns `None` when the selector is not one of the five + /// [`IConditionalOrder`] errors - including a bare `Error(string)` + /// require-revert. [`classify`](Self::classify) is the lifecycle + /// policy on top: it treats any such foreign selector as a + /// permanent contract-level rejection. + #[must_use] + pub fn decode(data: &[u8]) -> Option { + if data.len() < 4 { + return None; } - s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( - decoded.timestamp, - ))) + let reason: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match reason { + s if s == IConditionalOrder::OrderNotValid::SELECTOR => { + Some(Verdict::Invalid { reason }) + } + s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => { + Some(Verdict::TryNextBlock { reason }) + } + s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(Verdict::WaitBlock { + wait_until: u256_to_u64_saturating(decoded.blockNumber), + reason, + }) + } + s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(Verdict::WaitTimestamp { + wait_until: u256_to_u64_saturating(decoded.timestamp), + reason, + }) + } + s if s == IConditionalOrder::PollNever::SELECTOR => Some(Verdict::Invalid { reason }), + _ => None, } - s if s == IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), - _ => None, } -} -/// Classify a failed poll `eth_call` into a [`PollOutcome`] - the one -/// policy for what a poll failure means to the watch lifecycle. -/// -/// A revert payload big enough to carry a selector that -/// [`decode_revert`] does not recognise maps to `DontTryAgain`: it is -/// a contract-level rejection outside the `IConditionalOrder` -/// vocabulary (a handler-specific error, typically permanent), and -/// retrying it on every block loops forever. Only payload-free -/// failures - transport faults and reverts whose `data` is absent or -/// shorter than a selector - stay `TryNextBlock`. -#[must_use] -pub fn classify_poll_error(err: &ChainError) -> PollOutcome { - match err { - ChainError::Rpc(rpc) => match rpc.data.as_deref() { - Some(data) if data.len() >= 4 => { - decode_revert(data).unwrap_or(PollOutcome::DontTryAgain) - } - _ => PollOutcome::TryNextBlock, - }, - ChainError::Fault(_) => PollOutcome::TryNextBlock, + /// Classify a failed poll `eth_call` into a [`Verdict`] - the one + /// policy for what a poll failure means to the watch lifecycle. + /// + /// A revert payload big enough to carry a selector that + /// [`decode`](Self::decode) does not recognise maps to `Invalid`: + /// it is a contract-level rejection outside the `IConditionalOrder` + /// vocabulary (a handler-specific error, typically permanent), and + /// retrying it on every block loops forever. Only payload-free + /// failures - transport faults and reverts whose `data` is absent + /// or shorter than a selector - stay `TryNextBlock`. + #[must_use] + pub fn classify(err: &ChainError) -> Verdict { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + let reason: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]); + Self::decode(data).unwrap_or(Verdict::Invalid { reason }) + } + _ => Verdict::TryNextBlock { reason: [0; 4] }, + }, + ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + } } } @@ -138,24 +195,24 @@ mod tests { use super::*; #[test] - fn order_not_valid_maps_to_drop() { + fn order_not_valid_maps_to_invalid() { let err = IConditionalOrder::OrderNotValid { reason: "expired".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } #[test] - fn poll_never_maps_to_drop() { + fn poll_never_maps_to_invalid() { let err = IConditionalOrder::PollNever { reason: "cancelled".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } @@ -165,8 +222,8 @@ mod tests { reason: "noop".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryNextBlock) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::TryNextBlock { .. }) )); } @@ -177,8 +234,11 @@ mod tests { reason: "wait".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryOnBlock(12_345_678)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitBlock { + wait_until: 12_345_678, + .. + }) )); } @@ -189,21 +249,36 @@ mod tests { reason: "soon".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryAtEpoch(1_700_000_000)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitTimestamp { + wait_until: 1_700_000_000, + .. + }) )); } + #[test] + fn decoded_reason_carries_the_selector() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + let Some(Verdict::TryNextBlock { reason }) = LegacyRevertAdapter::decode(&err.abi_encode()) + else { + panic!("expected TryNextBlock"); + }; + assert_eq!(reason, IConditionalOrder::PollTryNextBlock::SELECTOR); + } + #[test] fn unknown_selector_returns_none() { let mut data = vec![0xde, 0xad, 0xbe, 0xef]; data.extend_from_slice(&[0u8; 32]); - assert!(decode_revert(&data).is_none()); + assert!(LegacyRevertAdapter::decode(&data).is_none()); } #[test] fn truncated_returns_none() { - assert!(decode_revert(&[0x01, 0x02]).is_none()); + assert!(LegacyRevertAdapter::decode(&[0x01, 0x02]).is_none()); } #[test] @@ -212,7 +287,7 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } - // ---- classify_poll_error ---- + // ---- LegacyRevertAdapter::classify ---- use nexum_sdk::host::{Fault, RpcError}; @@ -232,47 +307,50 @@ mod tests { } .abi_encode(); assert!(matches!( - classify_poll_error(&rpc(Some(revert))), - PollOutcome::TryOnBlock(777) + LegacyRevertAdapter::classify(&rpc(Some(revert))), + Verdict::WaitBlock { + wait_until: 777, + .. + } )); } /// A handler-specific selector outside the `IConditionalOrder` - /// vocabulary is a permanent contract-level rejection: it must - /// drop, not re-poll every block forever. + /// vocabulary is a permanent contract-level rejection: it must map + /// to `Invalid`, not re-poll every block forever. #[test] - fn classify_unrecognised_selector_drops() { + fn classify_unrecognised_selector_is_invalid() { let mut data = vec![0x7a, 0x93, 0x32, 0x34]; data.extend_from_slice(&[0u8; 32]); assert!(matches!( - classify_poll_error(&rpc(Some(data))), - PollOutcome::DontTryAgain + LegacyRevertAdapter::classify(&rpc(Some(data))), + Verdict::Invalid { .. } )); // A bare 4-byte selector with no body classifies the same way. assert!(matches!( - classify_poll_error(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), - PollOutcome::DontTryAgain + LegacyRevertAdapter::classify(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + Verdict::Invalid { .. } )); } #[test] fn classify_payload_free_failures_stay_try_next_block() { assert!(matches!( - classify_poll_error(&rpc(None)), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(None)), + Verdict::TryNextBlock { .. } )); assert!(matches!( - classify_poll_error(&rpc(Some(Vec::new()))), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(Some(Vec::new()))), + Verdict::TryNextBlock { .. } )); // Sub-selector payloads cannot name a contract error. assert!(matches!( - classify_poll_error(&rpc(Some(vec![0x01, 0x02]))), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(Some(vec![0x01, 0x02]))), + Verdict::TryNextBlock { .. } )); assert!(matches!( - classify_poll_error(&ChainError::Fault(Fault::Timeout)), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&ChainError::Fault(Fault::Timeout)), + Verdict::TryNextBlock { .. } )); } } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index cb3b920b..fbb8fa91 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,9 +3,13 @@ //! Type conversions and ABI decoding helpers that translate between //! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, //! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`), plus [`run()`] - the +//! `Verdict`, `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 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 @@ -17,7 +21,7 @@ pub mod error; pub mod order; pub mod run; -pub use composable::{IConditionalOrder, PollOutcome, classify_poll_error, decode_revert}; +pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index f70433cf..c607058b 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -3,8 +3,8 @@ //! //! [`run`] walks the keeper watch set, polls each gate-ready //! watch through a [`ConditionalSource`], and runs the -//! [`PollOutcome`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Ready` drives one submission through 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 and the keeper [`Retrier`] //! as the failure dispatch. @@ -24,17 +24,17 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, - is_already_submitted, order_uid_hex, + CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, + order_uid_hex, }; /// Poll every gate-ready watch once at `tick` and run each outcome's -/// effect. One source poll per ready watch; a `Ready` outcome makes at +/// 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> where H: CowHost, - S: ConditionalSource, + S: ConditionalSource, { let watches = WatchSet::new(host); let gates = Gates::new(host); @@ -49,18 +49,23 @@ where continue; }; match source.poll(host, watch, ¶ms, tick) { - PollOutcome::Ready { order, signature } => { + Verdict::Post { + order, signature, .. + } => { submit_ready(host, watch, &order, signature, tick, source.label())?; } - PollOutcome::TryNextBlock => {} - PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, - PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, - PollOutcome::DontTryAgain => { + Verdict::TryNextBlock { .. } => {} + Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, + Verdict::WaitTimestamp { wait_until, .. } => gates.set_next_epoch(watch, wait_until)?, + Verdict::Invalid { .. } => { // The removal is permanent; leave a trace of it even // for sources that do not log their own outcomes. tracing::info!("{} dropped watch {}", source.label(), watch.key()); watches.remove(watch)?; } + Verdict::NeedsInput { .. } => { + tracing::info!("watch {} parked awaiting input", watch.key()); + } } } Ok(()) diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 70dacc1c..491e7752 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -16,8 +16,9 @@ //! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), the classifiers mapping submit failures into +//! 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. @@ -49,8 +50,8 @@ //! [`CowHost`]: cow::CowHost //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`PollOutcome`]: cow::PollOutcome -//! [`decode_revert`]: cow::decode_revert +//! [`Verdict`]: cow::Verdict +//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter //! [`RetryAction`]: cow::RetryAction //! [`run`]: cow::run() diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index ea46e0e3..6d5c1453 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,7 +4,7 @@ //! //! Covered here: //! -//! - `decode_revert` selector dispatch (no-panic guard). +//! - `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`) @@ -15,12 +15,12 @@ use proptest::prelude::*; proptest! { - /// `decode_revert` on arbitrary revert bytes must never panic and - /// must return `None` for inputs shorter than the 4-byte EVM - /// selector. + /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must + /// never panic and must return `None` for inputs shorter than the + /// 4-byte EVM selector. #[test] - fn decode_revert_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::decode_revert(&bytes); + 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()); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index a643803f..151e7f69 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -11,7 +11,7 @@ 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, PollOutcome, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -22,11 +22,11 @@ struct FnSource(F); impl ConditionalSource for FnSource where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { (self.0)(host, watch, params, tick) } } @@ -35,7 +35,7 @@ where /// construction site so inference never guesses a too-narrow lifetime. fn src(f: F) -> FnSource where - F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, { FnSource(f) } @@ -84,10 +84,11 @@ fn submittable_order() -> GPv2OrderData { } } -fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { - PollOutcome::Ready { +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { order: Box::new(order.clone()), signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: 0, } } @@ -111,7 +112,7 @@ fn try_next_block_leaves_the_store_untouched() { run( &host, - &src(|_, _, _, _| PollOutcome::TryNextBlock), + &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) .unwrap(); @@ -128,7 +129,10 @@ fn try_on_block_sets_the_block_gate() { run( &host, - &src(|_, _, _, _| PollOutcome::TryOnBlock(2_000)), + &src(|_, _, _, _| Verdict::WaitBlock { + wait_until: 2_000, + reason: [0; 4], + }), &sample_tick(), ) .unwrap(); @@ -147,7 +151,10 @@ fn try_at_epoch_sets_the_epoch_gate() { run( &host, - &src(|_, _, _, _| PollOutcome::TryAtEpoch(1_800_000_000)), + &src(|_, _, _, _| Verdict::WaitTimestamp { + wait_until: 1_800_000_000, + reason: [0; 4], + }), &sample_tick(), ) .unwrap(); @@ -159,7 +166,7 @@ fn try_at_epoch_sets_the_epoch_gate() { } #[test] -fn dont_try_again_removes_the_watch_and_its_gates() { +fn invalid_removes_the_watch_and_its_gates() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); @@ -167,7 +174,7 @@ fn dont_try_again_removes_the_watch_and_its_gates() { run( &host, - &src(|_, _, _, _| PollOutcome::DontTryAgain), + &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) .unwrap(); @@ -190,7 +197,7 @@ fn gated_watch_is_not_polled() { &host, &src(|_, _, _, _| { polls.set(polls.get() + 1); - PollOutcome::TryNextBlock + Verdict::TryNextBlock { reason: [0; 4] } }), &sample_tick(), ) @@ -209,7 +216,7 @@ fn malformed_watch_rows_are_skipped() { &host, &src(|_, _, _, _| { polls.set(polls.get() + 1); - PollOutcome::TryNextBlock + Verdict::TryNextBlock { reason: [0; 4] } }), &sample_tick(), ) diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 06f4d8fc..3de57d8b 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -23,7 +23,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, PollOutcome, classify_poll_error, run}; +use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -111,19 +111,19 @@ fn persist_watch( struct TwapSource; impl ConditionalSource for TwapSource { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { let Ok(params) = ConditionalOrderParams::abi_decode(params) else { tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); - return PollOutcome::TryNextBlock; + return Verdict::TryNextBlock { reason: [0; 4] }; }; let Ok(owner) = watch.owner_hex().parse::

() else { tracing::warn!( "watch {} carried an unparseable owner; skipping", watch.key() ); - return PollOutcome::TryNextBlock; + return Verdict::TryNextBlock { reason: [0; 4] }; }; let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); @@ -140,7 +140,7 @@ fn poll_one( chain_id: u64, owner: &Address, params: &ConditionalOrderParams, -) -> PollOutcome { +) -> Verdict { let call = abi::getTradeableOrderWithSignatureCall { owner: *owner, params: abi::Params { @@ -155,13 +155,13 @@ fn poll_one( match host.request(chain_id, "eth_call", ¶ms_json) { Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) - .unwrap_or(PollOutcome::TryNextBlock), - // `classify_poll_error` is the one policy for what a failed + .unwrap_or(Verdict::TryNextBlock { reason: [0; 4] }), + // `LegacyRevertAdapter::classify` is the one policy for what a failed // poll call means to the watch lifecycle; the diagnostics here // cover the cases where the raw error carries information the // outcome alone does not. Err(err) => { - let outcome = classify_poll_error(&err); + let outcome = LegacyRevertAdapter::classify(&err); match &err { ChainError::Fault(fault) => { tracing::warn!("eth_call failed ({fault}); retrying next block"); @@ -169,7 +169,7 @@ fn poll_one( // A permanent drop deserves its cause on the record: // the revert selector and the node's message are // unrecoverable once the watch is gone. - ChainError::Rpc(rpc) if matches!(outcome, PollOutcome::DontTryAgain) => { + ChainError::Rpc(rpc) if matches!(outcome, Verdict::Invalid { .. }) => { let selector = rpc .data .as_deref() @@ -190,24 +190,28 @@ fn poll_one( } /// Decode a successful `getTradeableOrderWithSignature` return into -/// `Ready { order, signature }`. The wire format is `abi.encode(order, -/// signature)` - the canonical Solidity return tuple - so the two-tuple -/// parameter decode lines up. -fn decode_return(data: &[u8]) -> Option { +/// `Post { order, signature, .. }`. The wire format is the canonical +/// Solidity return tuple `abi.encode(order, signature)`, so the +/// two-tuple parameter decode lines up. The deployed 1.x contract +/// carries no next-poll hint, so `next_poll_timestamp` is synthetic +/// (`0`). +fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { + Some(Verdict::Post { order: Box::new(order), signature, + next_poll_timestamp: 0, }) } -fn outcome_label(o: &PollOutcome) -> &'static str { +fn outcome_label(o: &Verdict) -> &'static str { match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", } } @@ -341,15 +345,17 @@ mod tests { let wire = (order.clone(), sig.clone()).abi_encode_params(); match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { + Verdict::Post { order: o, signature: s, + next_poll_timestamp, } => { assert_eq!(o.sellToken, order.sellToken); assert_eq!(o.buyAmount, order.buyAmount); assert_eq!(s, sig); + assert_eq!(next_poll_timestamp, 0, "legacy path carries no hint"); } - other => panic!("expected Ready, got {other:?}"), + other => panic!("expected Post, got {other:?}"), } } @@ -723,8 +729,8 @@ mod tests { } #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle + fn poll_invalid_drops_watch_and_gates() { + // When `LegacyRevertAdapter` produces `Invalid`, the lifecycle // layer must delete the watch and any stale gates. Simulate the // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. From df9171614e998ecab82896e565734f55140a55ba Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:26:55 +0000 Subject: [PATCH 23/29] docs: reconcile keeper prose and Verdict poll-seam references (#335) Tip-level residual after the chassis to keeper rename was folded down through the train. Carries the doc-comment prose the fold could not move mechanically (the private-keeper caveat, keeper-run wording) and the current-state design docs brought in line with the Verdict poll seam (PollOutcome to Verdict, decode_revert to LegacyRevertAdapter). --- crates/nexum-runtime/src/host/local_store_redb.rs | 2 +- crates/nexum-sdk/src/keeper.rs | 9 +++++++-- crates/shepherd-sdk/Cargo.toml | 4 ++-- crates/shepherd-sdk/src/cow/composable.rs | 4 ++-- crates/shepherd-sdk/src/cow/mod.rs | 2 +- crates/shepherd-sdk/src/cow/run.rs | 2 +- crates/shepherd-sdk/tests/run.rs | 2 +- docs/00-overview.md | 2 +- docs/05-sdk-design.md | 2 +- docs/08-platform-generalisation.md | 2 +- docs/diagrams/engine-boot.mmd | 2 +- docs/diagrams/sequence-twap.mmd | 2 +- docs/sdk.md | 6 +++--- modules/twap-monitor/src/strategy.rs | 6 +++--- 14 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 3f4e16c8..22096775 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -47,7 +47,7 @@ pub struct ModuleStore { } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the shared + /// Open (or create) the redb file at `path`. Initialises the shared /// table so subsequent read transactions never hit `TableDoesNotExist`. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 60bcbb00..637e7985 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -3,6 +3,11 @@ //! alone so they compile for any world and test against the in-memory //! mocks. //! +//! Private, single-tenant instance over the wallet's own authorised +//! orders; it submits intents to the venue and does not settle them. +//! This is not a public keeper network, fee marketplace, or MEV +//! searcher. +//! //! Three stores cover the machinery watcher modules hand-roll: //! //! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` @@ -19,7 +24,7 @@ //! one outcome out, at a given [`Tick`]. Implementations own the //! transport and the outcome shape. //! - [`Retrier`] - runs a [`RetryAction`]'s effect through the -//! stores after a failed run attempt. +//! 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 @@ -350,7 +355,7 @@ pub trait ConditionalSource { } /// What the retry ledger should do to a watch after a failed -/// run attempt. +/// keeper run attempt. /// /// `IntoStaticStr` exposes each variant as a snake_case `&'static /// str` for log and metric labels. `#[non_exhaustive]` so the diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index ea3a1e4c..c4ef8455 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -28,12 +28,12 @@ thiserror.workspace = true tracing.workspace = true [dev-dependencies] -# `capture_tracing` observes the run's diagnostics in the +# `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, -# and the run acceptance-tests against the composed MockHost +# 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/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 10e0fcb2..a7a4674e 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -3,7 +3,7 @@ //! //! Every strategy poll resolves to a [`Verdict`] - the structured //! outcome mirroring the composable-cow fork's structured generator. -//! The run and each strategy module +//! The keeper run and each strategy module //! dispatch on the `Verdict` variants alone; nothing downstream knows //! how the outcome was produced. //! @@ -105,7 +105,7 @@ pub enum Verdict { }, /// The generator needs off-chain input before it can produce an /// order. Never produced by [`LegacyRevertAdapter`]; the - /// run parks the watch untouched. + /// keeper run parks the watch untouched. NeedsInput { /// Source selector, log only. reason: [u8; 4], diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index fbb8fa91..1d216924 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -14,7 +14,7 @@ //! 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 -//! run is generic over the host traits alone. +//! keeper run is generic over the host traits alone. pub mod composable; pub mod error; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index c607058b..dbabd179 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -1,4 +1,4 @@ -//! Watch run: the poll-loop composition conditional- +//! Keeper run: the poll-loop composition conditional- //! commitment modules share. //! //! [`run`] walks the keeper watch set, polls each gate-ready diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index 151e7f69..cda235ba 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -1,4 +1,4 @@ -//! Run acceptance tests against the composed +//! 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 diff --git a/docs/00-overview.md b/docs/00-overview.md index e920b3f5..7803f497 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -277,7 +277,7 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `Verdict`, `LegacyRevertAdapter`, `RetryAction`, `classify_api_error`) | | | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 93409a40..8b62896f 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -76,7 +76,7 @@ shepherd-sdk/ ├── 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, PollOutcome, decode_revert, + ├── 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) ``` diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index d0391e04..30c1f8b7 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -987,7 +987,7 @@ graph TD - **`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. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), 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. +- **`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. 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. diff --git a/docs/diagrams/engine-boot.mmd b/docs/diagrams/engine-boot.mmd index ef7ea35b..5c08e634 100644 --- a/docs/diagrams/engine-boot.mmd +++ b/docs/diagrams/engine-boot.mmd @@ -30,7 +30,7 @@ sequenceDiagram PP-->>Bin: pool ready Bin->>LS: LocalStore::open(state_dir) - Note over LS: Creates redb file if missing,
materialises shared table. + Note over LS: Creates redb file if missing,
initialises shared table. LS-->>Bin: store ready Bin->>OBP: OrderBookPool::default() diff --git a/docs/diagrams/sequence-twap.mmd b/docs/diagrams/sequence-twap.mmd index ebb7766a..2a06efc9 100644 --- a/docs/diagrams/sequence-twap.mmd +++ b/docs/diagrams/sequence-twap.mmd @@ -31,7 +31,7 @@ sequenceDiagram RPC-->>Host: return value or revert Host-->>Mod: result (JSON encoded) Mod->>SolDec: decode return or interpret revert reason - SolDec-->>Mod: PollOutcome (module-defined enum) + SolDec-->>Mod: Verdict (module-defined enum) alt Ready (order, signature) Mod->>Cow: build OrderCreation diff --git a/docs/sdk.md b/docs/sdk.md index 1af60111..c8ee0f65 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -69,7 +69,7 @@ seam one-for-one. - `cow::order::gpv2_to_order_data` - convert the on-chain `GPv2OrderData` (12-field Solidity tuple with bytes32 markers) into the typed `OrderData` shape the orderbook signs against. - - `cow::composable::PollOutcome` + `cow::composable::decode_revert` + - `cow::composable::Verdict` + `cow::composable::LegacyRevertAdapter` - typed dispatch over the five `IConditionalOrder` custom errors (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, `PollTryAtEpoch`, `PollNever`). @@ -85,8 +85,8 @@ seam one-for-one. response into bytes. - `chain::chainlink::read_latest_answer` - Chainlink AggregatorV3 reader over the two helpers above. - (The CoW-specific `cow::decode_revert_hex(s)` - the `chain-error` - rpc revert bytes -> typed `PollOutcome` - lives in `shepherd-sdk`.) + (The CoW-specific `cow::LegacyRevertAdapter(s)` - the `chain-error` + rpc revert bytes -> typed `Verdict` - lives in `shepherd-sdk`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait seam plus the SDK's host-neutral `Fault` vocabulary (same cases diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 3de57d8b..3ca2de4a 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -69,9 +69,9 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: run every gate-ready watch through the keeper -/// composition. The block timestamp arrives in milliseconds; the tick -/// carries Unix seconds. +/// 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. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { let tick = Tick { chain_id: block.chain_id, From ddfb2b994196e410c7f154c0ff0e9a8ad011e977 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 07:18:52 +0000 Subject: [PATCH 24/29] docs(sdk-test): note MockLocalStore fidelity gaps vs redb No transaction semantics and no concurrent access (deferred to the MockRuntime refactor, #94). Salvaged from #168 before closing it as superseded by the shipped namespaced MockLocalStore. --- crates/nexum-sdk-test/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index b9e135bb..022c61bd 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -203,6 +203,15 @@ impl ChainHost for MockChain { /// so one namespace's writes can exhaust another's headroom exactly /// as two modules share one database file. Fault injection via /// [`fail_on`](Self::fail_on) stays per-view. +/// +/// # Fidelity vs the real `redb` store +/// +/// Two gaps remain (deferred to the `MockRuntime` refactor, #94): +/// - **No transaction semantics** - `redb` wraps each `on_event` in an +/// implicit write transaction (commit on `Ok`, rollback on trap); this +/// mock commits every `set` immediately. +/// - **No concurrent access** - the backing `RefCell` is single-threaded, +/// whereas `redb` uses MVCC. #[derive(Default)] pub struct MockLocalStore { shared: Rc, From 476ecdbb671044bbb3784f60e53782bbd04401e1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 3 Jul 2026 00:31:46 +0000 Subject: [PATCH 25/29] docs: record the intent architecture (venue adapters, egress guard) --- docs/09-intent-architecture.md | 215 ++++++++++++++++++ docs/adr/0010-venues-as-adapter-components.md | 52 +++++ docs/adr/0011-egress-guard-pipeline.md | 54 +++++ 3 files changed, 321 insertions(+) create mode 100644 docs/09-intent-architecture.md create mode 100644 docs/adr/0010-venues-as-adapter-components.md create mode 100644 docs/adr/0011-egress-guard-pipeline.md diff --git a/docs/09-intent-architecture.md b/docs/09-intent-architecture.md new file mode 100644 index 00000000..6f0e5c57 --- /dev/null +++ b/docs/09-intent-architecture.md @@ -0,0 +1,215 @@ +# Intent Architecture: Venue Adapters and the Egress Guard + +> **Status (design, 0.3+ direction):** This document records the target architecture agreed in the July 2026 architecture review. Nothing in it ships in 0.2. It supersedes the CoW-specific framing of the Layer 3 example in [08-platform-generalisation.md](08-platform-generalisation.md) and the compiled-in `cow-api` backend of ADR-0005. Decision records: [ADR-0010](adr/0010-venues-as-adapter-components.md) (venue adapters) and [ADR-0011](adr/0011-egress-guard-pipeline.md) (the guard pipeline). + +## Motivation + +Three pressures converge on the same design: + +1. **CoW is a concrete implementation living inside a generic runtime.** `shepherd:cow/cow-api` and the `cow_orderbook` host backend are the only domain-specific code in the engine. A CoW order is one instance of a more general thing: an intent to give some value in exchange for some other value, submitted to a venue that settles it. The next domains on the roadmap are not further ERC-20 swap venues; they are a non-trading chain domain (a Swarm postage purchase: give BZZ, want storage capacity for a duration) and an off-chain marketplace (real-world assets, where settlement is a legal process rather than a chain state transition). +2. **Venues arrive after the app ships.** On the super-app and mobile targets (doc 08), a new venue must be installable by the user without an app-store deployment. That rules out compiled-in venue backends: the venue integration must itself be a distributable, sandboxed artifact, discovered and installed exactly like a module. +3. **The same engine embeds in a wallet.** The wallet embedding places the runtime at the signing boundary, where EIP-712 typed data and transaction payloads must be decoded, simulated, and analysed for threats before the user signs. The threat analysis itself should be performed by installable wasm modules, so that security vendors ship analysers the way strategy authors ship modules. + +The unifying observation: intent submission, typed-data signing, and transaction signing are all **value egress events**. They deserve one vocabulary, one analysis pipeline, and one policy surface. + +## Component kinds + +The platform grows from one guest component kind to three. All three share the distribution pipeline (ENS discovery, Swarm fetch, content-hash verification, manifest, install-time consent) and the supervision machinery (restart policy, poison handling, fuel and epoch metering, capability enforcement). + +| Kind | World | Role | Trust position | +|---|---|---|---| +| Strategy module | `event-module` (existing) | Owns strategy: when to emit intents, polling cadence, retry policy | Arbitrary author; capability-scoped | +| Venue adapter | `venue-adapter` (new) | One venue each: encodes intent bodies, derives headers, submits, observes status, over whatever transport the venue speaks | Curated registry plus ENS escape hatch; structurally cannot move value | +| Threat analyser | `analyzer` (new; descendant of the experimental `query-module` world) | Evaluates egress fact bundles, returns verdicts under a deadline | Tiered: pure fact-fed by default; network extras behind explicit consent | + +```mermaid +flowchart LR + subgraph Guests + SM["strategy module
(event-module)"] + VA["venue adapter
(venue-adapter)"] + AN["threat analyser
(analyzer)"] + end + subgraph Host + POOL["intent pool router"] + GUARD["egress guard
(facts + policy)"] + SIM["simulate backend"] + ID["identity"] + end + SM -->|"submit(venue, body)"| POOL + POOL -->|"derive-header / submit / status"| VA + POOL --> GUARD + ID -->|"sign requests"| GUARD + GUARD -->|"fact bundle"| AN + GUARD --> SIM + VA -->|"scoped http / messaging / chain"| Transport[("venue transport:
HTTPS, Waku, PSS,
libp2p, chain")] +``` + +## The value-flow vocabulary + +One WIT types package, egress-neutral, describes value in motion. It is shared by intent headers, simulation balance diffs, and analyser verdict subjects, so that "100 USDC leaves the user's control" is written the same way in all three places. This vocabulary is the real platform contract; it must outlive any individual interface and is the part that freezes hardest. + +Sketch (illustrative, not frozen): + +```wit +package nexum:value@0.1.0; + +interface types { + variant settlement { + evm-chain(u64), + offchain(string), // jurisdiction / venue-defined domain + } + + variant asset { + native(settlement), + erc20(tuple>), // (chain, address) + erc721(tuple, list>), // (chain, address, id) + erc1155(tuple, list>), + service(service-desc), // e.g. storage capacity for a duration + external(external-desc), // RWA: deed, chattel, ... + } + + record asset-amount { asset: asset, amount: list } // big-endian unsigned +} +``` + +Design notes: + +- `settlement` is a variant from day one. The current `chain-id: u64` plumbing assumes every venue settles on an EVM chain; the off-chain marketplace target breaks that assumption. +- `service` and `external` exist so a postage purchase and a physical-asset listing fit without forcing them into token shapes. For `external` assets the host can verify nothing; policy on them is plugin-attested, not host-verified, and the consent surface must say so. +- Policy has teeth on `gives` (what leaves the user's control). `wants` is display-grade: the host can rarely verify the counterparty's obligation and must not pretend to. + +## Intents and venue adapters + +### The intent core + +```wit +package nexum:intent@0.1.0; + +interface types { + use nexum:value/types.{asset-amount, settlement}; + + record intent-header { + gives: list, + wants: list, + valid-until: option, + settlement: settlement, + authorisation: auth-scheme, // eip712 | eip1271 | presign | offchain-sig | none + } + + variant intent-status { pending, open, settled(option>), failed(fail-reason), expired, cancelled } + type receipt = list; // venue-scoped stable id (CoW: the 56-byte order UID) +} + +interface pool { + submit: func(venue: string, body: list) -> result; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} +``` + +The body is opaque bytes at the pool boundary. Typing is recovered in two places where it is real: guest-side, where venue authors publish typed SDK crates for strategy modules, and at the adapter's `derive-header` export, whose return type is the stable ontology. There is no closed `intent-body` variant to churn per venue, and no way for a module to claim a header the host has not derived. + +A fifth event variant, `intent-status(receipt, status)`, is delivered through the existing manifest-subscription mechanism. For HTTP venues the adapter polls; for Waku or PSS venues it subscribes; strategy modules receive identical events either way. Observation is first-class because one of the two flagship modules (ethflow-watcher) is observe-only: it verifies that intents created by others were indexed by the venue, and never submits. + +### The adapter world + +```wit +world venue-adapter { + // narrow, manifest-declared, per-adapter-scoped imports: + import http; // e.g. the CoW adapter: api.cow.fi only + import messaging; // e.g. a Waku venue: its content topics only + import chain; // read-only lookups where needed + + export derive-header: func(body: list) -> result; + export submit: func(body: list) -> result; + export status: func(receipt: receipt) -> result; + export cancel: func(receipt: receipt) -> result<_, venue-error>; +} +``` + +The host is a router plus a policy checkpoint: a strategy module calls `pool::submit(venue, body)`; the host resolves the venue id to the installed adapter instance, calls its `derive-header`, runs guard policy on the result (see below), and only then forwards to the adapter's `submit`. Adapters never see keys, never import `identity`, and hold no unscoped transport, so a hostile adapter can misdescribe or grief (drop, delay, leak order details the venue would see anyway) but cannot steal. + +Transport is entirely the adapter's concern. A venue reachable over HTTPS, Swarm PSS, Waku, raw libp2p, or an on-chain contract call presents the same four exports; the module and the host router are transport-blind. This is the same shape as `chain::request` (the module says what, the host decides how) extended to one more decision layer. + +### Curation and consent + +Adapters install like modules, with two provenance tiers: + +- **Curated registry (default):** a platform-signed list of adapter content hashes. Installing from it shows the standard consent sheet (publisher, venue, transport scopes). +- **ENS escape hatch:** any ENS-published adapter installs behind a stronger warning. Header trust then equals publisher trust, and the consent copy says exactly that. + +Adapters are always separate artifacts from strategy modules: one adapter per venue, shared by all modules, separately consented. A module author who also authors a venue needs two visible installs, which keeps collusion observable. + +### What stays where + +The strategy versus protocol boundary from ADR-0006 is preserved, not repealed. Strategy stays guest-side in modules: polling cadence, condition evaluation, revert-taxonomy interpretation, when to give up. What moves out of the engine and into adapters is encoding, transport, and observation. The SDK gains a venue-generic chassis for the machinery both flagship modules already implement by hand: watch-set persistence, gate keys, idempotency journals keyed on receipts, retry classification. Porting the pattern to a new venue means implementing the adapter plus a thin typed SDK crate; the tested machinery travels. + +## The egress guard + +### One pipeline for all value egress + +Three event classes produce the same fact-bundle shape and flow through the same spine: + +1. **Intent submission** (from the pool router, header derived by the venue adapter), +2. **Typed-data signing** (EIP-712 requests arriving at `identity::sign-typed-data`), +3. **Transaction signing** (raw transactions arriving at `identity` via `chain::request` signing methods). + +```mermaid +flowchart LR + E["egress event
(intent / typed-data / tx)"] --> F["fact assembly:
decode + simulate"] + F --> A["analysers
(deadline-bounded)"] + A --> P["policy engine
(binding, user override)"] + P --> C["consent surface /
auto-allow / block"] +``` + +The wallet embedding is a host profile where transaction and typed-data events dominate and the consent surface is the wallet UI (driven over the embedding API). The server runtime is a profile where intent submissions dominate and policy is operator configuration. Same pipeline, same analysers, same vocabulary. + +### Fact assembly and the `simulate` primitive + +The host assembles a typed fact bundle per event: the decoded payload (EIP-712 struct and domain, transaction fields, or intent header plus venue id), simulation results (balance diffs and approvals granted, expressed in `nexum:value` types), and context metadata (counterparty contract, chain, requesting component). + +Simulation is a pluggable host primitive, additive alongside `clock` and `http`: + +- **Server and desktop:** a local EVM (revm) over the provider pool's state access. +- **Mobile:** cold-state simulation over mobile RPC can take seconds, so the host may use an operator- or user-configured remote simulation backend. That trades transaction privacy for latency and the trade is made explicitly in configuration and surfaced in consent, never silently. + +One WIT contract either way; analysers and policy are backend-blind. + +### Analysers + +Analysers are request/response components (the `query-module` lineage): the host calls them with a fact bundle and a deadline, they return a verdict. Capabilities are tiered: + +- **Pure core (default):** no imports at all. The analyser computes on the facts it is handed. Deterministic, fast, and nothing to exfiltrate: the natural home for heuristics, decoder cross-checks, and known-bad-pattern matching. +- **Granted extras:** an analyser may request `chain` (its own reads) or scoped `http` (a vendor reputation feed). The consent sheet states the consequence plainly: this analyser sends what you sign to vendor.example. Everything the user signs is exactly the data a network-capable analyser could leak, so the tier boundary is the privacy boundary. + +Verdicts carry a severity and a typed subject (which `gives` entries they concern). They are policy-binding with per-event user override: high-severity findings block by default, the user can override with friction. Analyser timeout or crash during an interactive prompt resolves per policy profile: a wallet profile fails closed for high-value egress, a server profile may fail open with logging. The choice is explicit configuration, not an accident of scheduling. + +## Trust model summary + +| Guarantee | Enforced by | Trust required | +|---|---|---| +| Adapter cannot move value | Sandbox: no `identity` import, no keys, scoped transport | None (structural) | +| Spend limits, consent summaries | Guard policy on host-routed, adapter-derived headers | Adapter publisher (curated registry or explicit ENS consent) | +| Theft prevention on signed egress | Guard at the `identity` boundary: EIP-712 and tx payloads are self-describing, the host decodes and simulates them itself | Host only | +| Contract-authorised flows (e.g. EIP-1271 conditional orders) | Consented on-chain when the commitment was created; guests can only materialise what the contract permits | On-chain approval hygiene | +| Threat verdicts | Analyser components under deadline, tiered capabilities | Analyser publisher, proportional to granted tier | + +Honest limitations, carried deliberately: policy on `external` (RWA) assets is adapter-attested rather than host-verified; adapter misbehaviour of the griefing grade (delay, drop, leak) is handled by curation and reputation rather than mechanism; and `wants` is display-grade. + +## Sequencing + +Each step is independently shippable and the earlier steps are pure wins even if later ones change shape. + +1. **Hygiene:** move the `cow_orderbook` backend out of the engine behind the RuntimeTypes extension seam; remove `CowApiHost` from the SDK supertrait. The engine becomes domain-free; shepherd is the distribution that bundles the CoW integration. +2. **SDK chassis:** extract the conditional-commitment machinery (watch sets, gate keys, idempotency journals, retry ledgers) from twap-monitor and ethflow-watcher into venue-generic SDK traits. This delivers watch-tower-parity transportability with no WIT change. +3. **Intent core:** `nexum:value` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Port both flagship modules; ethflow proves observe-only and the status-event path. +4. **Guard, first cut:** the `simulate` primitive (local backend), fact assembly, the `analyzer` world, policy binding with override, and the identity-boundary checkpoint for typed-data and transaction signing. +5. **Postage adapter (N=2):** proves `service` wants, non-HTTP transport thinking, and settlement variance. Freeze the vocabulary at 1.0 only after this round-trips submit, status, and policy. +6. **Registry and consent:** the curated adapter/analyser registry, publisher display, the ENS escape hatch, and the wallet-profile consent surface over the embedding API. + +## Open questions + +- **Vocabulary freeze discipline:** `nexum:value` becomes forward-compatibility-critical for three consumers at once. The N=2 gate (step 5) is the guard, but the versioning policy for post-1.0 additions (new asset variants) needs its own note. +- **Analyser composition:** multiple analysers with overlapping findings need aggregation rules (max severity wins is the obvious start) and a story for contradictory verdicts. +- **A `tx` venue:** transactions are covered by the guard at the identity boundary, not modelled as an intent venue. Whether a transaction-shaped venue adapter (batching, private orderflow) is ever worth registering stays open; the policy hooks are shaped so it could be. +- **Adapter reputation:** beyond curation, whether observed adapter behaviour (submission latency, status accuracy) feeds a local score. diff --git a/docs/adr/0010-venues-as-adapter-components.md b/docs/adr/0010-venues-as-adapter-components.md new file mode 100644 index 00000000..6f98552c --- /dev/null +++ b/docs/adr/0010-venues-as-adapter-components.md @@ -0,0 +1,52 @@ +--- +status: proposed +supersedes: ADR-0005 (compiled-in cow-api backend) +--- + +# Venues are dynamically installed adapter components; CoW becomes the first adapter + +## Context + +The engine's only domain-specific code is the CoW integration: the `shepherd:cow/cow-api` WIT interface and the `cow_orderbook` host backend (ADR-0005). A CoW order is one instance of a general shape: an intent to give value for value, submitted to a venue. The next domains on the roadmap are a Swarm postage purchase and an off-chain marketplace, not further ERC-20 swap venues, so a "trading pair" abstraction is the wrong altitude; the abstraction axis is the venue itself. + +Two constraints shaped the decision: + +1. **Venues arrive after the app ships.** On mobile and super-app targets, a new venue must be user-installable without an app-store deployment. A compiled-in backend (or a Rust plugin trait resolved at build time) cannot satisfy this. +2. **Policy is core.** The host must understand what modules submit (consent, spend limits, audit). Policy inputs must not be forgeable by the components being policed. + +Two facts about the existing modules bound the design from below. Strategy is venue-coupled through on-chain contracts (a TWAP part is a ComposableCoW artifact; no abstraction makes it submittable elsewhere), so venue-portable strategy modules are not a goal. And ethflow-watcher is observe-only, so observation is a first-class venue operation, not an afterthought. + +## Decision + +A venue integration is a **venue adapter**: a sandboxed wasm component, distributed and consented through the same pipeline as modules (ENS discovery, Swarm fetch, hash verification, manifest, supervision), implementing a new `venue-adapter` world: + +- Imports: narrow, manifest-declared, per-adapter-scoped transport (`http` to the venue's endpoints, `messaging` on its topics, read-only `chain`). Never `identity`, never keys. +- Exports: `derive-header(body) -> intent-header`, `submit(body) -> receipt`, `status(receipt)`, `cancel(receipt)`. + +Strategy modules import a minimal `nexum:intent/pool` interface (`submit`/`status`/`cancel` keyed by venue id, body as opaque bytes). The host routes pool calls to the installed adapter, calls `derive-header`, runs guard policy (ADR-0011) on the derived header, and only then forwards to the adapter's `submit`. Headers are always host-obtained from the adapter, never module-supplied, so a module cannot understate what it gives away. + +Intent status flows back through the existing event mechanism as a new event variant; adapters poll or subscribe per their transport, and modules are transport-blind. + +The typed ontology lives in a shared, egress-neutral `nexum:value` types package (assets, amounts, settlement domains) plus `nexum:intent` (header, status, receipt). Venue body schemas are per-venue, typed guest-side in venue SDK crates and at `derive-header`, so adding a venue is an adapter release plus an SDK crate, not a host release and not a WIT bump of a closed body variant. + +Provenance is two-tier: a platform-signed curated registry by default, plus an install-by-ENS escape hatch behind a stronger warning. Adapters are always separate artifacts from strategy modules; a venue and a strategy by the same author are two visible installs. + +The CoW integration becomes the first adapter, built as a component from day one and bundled with the shepherd distribution. Bundled is not compiled-in: the same artifact installs dynamically on other hosts. `shepherd:cow/cow-api` and the `cow_orderbook` backend are retired once the port completes. The Swarm postage-purchase adapter is the N=2 proving ground; the `nexum:value` vocabulary does not freeze before it round-trips submit, status, and policy. + +## Considered options + +- **Compiled-in venue plugin registry (Rust trait, host release per venue).** Rejected: fails the app-store constraint outright; every venue addition redeploys every host. +- **Typed `intent-body` closed variant in WIT.** Rejected: WIT variants are closed, so the body type churns per venue and version-couples all venues to one package; with dynamic adapters the body must be opaque at the pool boundary anyway. Typing is recovered where it is real (guest SDK crates, `derive-header`). +- **Full typed intent ontology at the WIT boundary including venue payloads.** Rejected: WIT has no parametric polymorphism, so maximal generality degenerates into `(schema, bytes)` with extra steps, and a module-supplied typed envelope over an opaque payload is unverifiable (the metadata-lies problem). Deriving the header host-side from the body is the sound inversion. +- **Dissolve `cow-api` into allowlisted `http`.** Rejected: venues are not necessarily HTTP (Swarm PSS, Waku, libp2p, on-chain), and raw transport capability erases the consent surface and the policy checkpoint permanently. +- **Module-supplied headers with host spot-checking.** Rejected: spot-checking requires a venue codec host-side, which is the adapter again, minus the guarantees. +- **Bundled module+venue single artifacts.** Rejected: consent would conflate strategy and venue, venue logic duplicates per module, and collusion becomes one opaque install. + +## Consequences + +- The engine loses its last domain-specific code; shepherd is a distribution (engine + bundled CoW adapter), which is what the hygiene refactors around the RuntimeTypes lattice were already moving toward. +- A third-party adapter can misdescribe (`derive-header` lies) or grief (delay, drop, leak order details the venue would see anyway) but cannot move value: no keys, no unscoped transport. Theft prevention does not rest on adapter honesty; it rests on the guard at the identity boundary (ADR-0011) and on-chain approval hygiene. Spend-limit accuracy rests on adapter publisher trust, which is what the curated registry and consent copy manage. +- Strategy stays guest-side per ADR-0006; what leaves modules is encoding, transport, and observation. The SDK gains a venue-generic chassis (watch sets, gate keys, receipt-keyed idempotency, retry classification) extracted from twap-monitor and ethflow-watcher. +- Two sandbox hops (module, host, adapter) add marshalling per submission. Negligible at order-submission rates. +- Adapters need the module supervision surface (restart, poison, metering) and manifest capability enforcement; both exist and are reused. +- The venue catalogue is user-gated rather than host-gated: permissionless with consent, which is the platform's general posture. diff --git a/docs/adr/0011-egress-guard-pipeline.md b/docs/adr/0011-egress-guard-pipeline.md new file mode 100644 index 00000000..b584dd52 --- /dev/null +++ b/docs/adr/0011-egress-guard-pipeline.md @@ -0,0 +1,54 @@ +--- +status: proposed +--- + +# One egress guard pipeline: intent submissions, typed-data signing, and transaction signing share facts, analysers, and policy + +## Context + +Two requirements arrived together. First, the intent layer (ADR-0010) needs a policy checkpoint: consent, spend limits, and audit on what modules submit to venues. Second, the same engine embeds in a wallet, where EIP-712 typed data and transactions must be decoded, simulated, and analysed for threats before the user signs, and where the threat analysis should be performed by installable wasm components so security vendors ship analysers the way strategy authors ship modules. + +These are the same problem. An intent submission, a typed-data signature, and a transaction signature are all value egress events: something leaves the user's control. Designing two pipelines (venue policy in the runtime, signing analysis in the wallet) would duplicate the vocabulary, drift, and leave each surface weaker than the union. + +A further fact anchors the trust model: EIP-712 typed data and transaction payloads are self-describing. The host can decode and simulate them without trusting any guest component's metadata. The identity boundary is therefore the one checkpoint no guest code can route around. + +## Decision + +One guard pipeline handles all value egress: + +``` +egress event -> fact assembly (decode + simulate) -> analysers (deadline-bounded) -> policy (binding, user override) -> consent / allow / block +``` + +**Egress events.** Intent submissions (header derived by the venue adapter, routed by the pool), EIP-712 requests at `identity::sign-typed-data`, and transaction signing at the identity boundary. The wallet embedding is a host profile where signing events dominate and consent renders in the wallet UI over the embedding API; the server runtime is a profile where intent submissions dominate and policy is operator configuration. + +**Fact assembly.** The host builds a typed fact bundle per event: decoded payload, simulation results (balance diffs, approvals granted), and context metadata. All value flows are expressed in the shared `nexum:value` vocabulary, the same types intent headers use, so one asset-delta dialect serves headers, simulations, and verdicts. + +**`simulate` is a pluggable host primitive**, additive alongside `clock` and `http`. Server and desktop hosts run a local EVM (revm) over provider-pool state. Mobile hosts may configure a remote simulation backend because cold-state simulation over mobile RPC is interactively too slow; that trades transaction privacy for latency, and the trade is explicit operator/user configuration surfaced in consent, never a silent default. Analysers and policy are backend-blind. + +**Analysers** are request/response components (the `query-module` lineage): called with a fact bundle and a deadline, they return verdicts with severity and typed subjects (which `gives` entries they concern). Capabilities are tiered: + +- Pure core (default): no imports; compute on the facts handed over. Deterministic, fast, nothing to exfiltrate. +- Granted extras: `chain` reads or scoped `http` (vendor reputation feeds) behind explicit consent that states the consequence plainly: this analyser sends what you sign to the vendor. The tier boundary is the privacy boundary, because everything the user signs is exactly what a network-capable analyser could leak. + +**Policy is binding with per-event user override.** High-severity verdicts block by default; the user can override with friction. Analyser timeout or crash during an interactive prompt resolves per policy profile: wallet profiles fail closed for high-value egress, server profiles may fail open with logging. Fail-open versus fail-closed is explicit configuration. + +**Transactions are covered by the guard, not modelled as an intent venue.** The earlier deferral of "does the intent capability swallow `eth_sendTransaction`" resolves here: unification happens at the guard layer, where all egress meets the same facts and policy, while the intent pool stays scoped to venue submissions. The policy hooks are shaped so a transaction-shaped venue adapter could register later if batching or private orderflow ever wants one. + +**Capability least-privilege is the enforcement mechanism, not hygiene.** Guard soundness requires that guarded components have no unguarded egress path: a strategy module that submits intents receives the `intent` capability and does not also hold unscoped `messaging` or `http`. Consent UX must present capability combinations accordingly (a module holding `chain` plus `identity` can egress value outside intent policy only through the identity checkpoint, which is guarded). + +## Considered options + +- **Two pipelines (runtime venue policy, wallet signing guard) with a shared vocabulary.** Rejected: the fact shapes, analyser world, and policy engine would be near-duplicates that drift; the wallet profile would re-implement intent policy the day a wallet hosts strategy modules. +- **Advisory-only verdicts.** Rejected as the default: it puts the entire burden on consent-sheet reading. Retained as the effective behaviour for low-severity findings. +- **Hard veto for any installed analyser.** Rejected: one broken or compromised analyser bricks signing, and it forces fail-closed on every timeout regardless of stakes. +- **Analysers bring their own simulation** via `chain`/`http` grants. Rejected as the model: every analyser duplicates the work, the fact bundle thins to uselessness for pure analysers, and the privacy tier collapses (all analysers would need network). Network-capable analysers may still enrich beyond the host bundle. +- **Host-only heuristics, no analyser components.** Rejected: it forecloses the security-vendor ecosystem and hard-codes threat logic into host releases, the same mistake the venue layer just escaped. + +## Consequences + +- The theft-prevention anchor is host-only trust: the identity-boundary guard decodes and simulates what it signs regardless of what adapters or modules claim. Spend-limit accuracy on venue submissions remains adapter-publisher trust (ADR-0010); the two layers degrade independently and the trust table in the design doc states both. +- Interactive signing acquires a latency budget shared by simulation and analysers. Deadlines are enforced with the existing metering machinery (fuel, epoch interruption); partial verdicts render as "analysis incomplete" per profile. +- The engine grows three surfaces: the `simulate` primitive with a backend seam, the fact-bundle assembly, and the analyser host-call path with deadline scheduling. The analyser world finally gives the experimental `query-module` lineage a shipping consumer. +- Verdict aggregation across multiple analysers starts as max-severity-wins; contradictions and reputation are open questions recorded in the design doc. +- Every embedder profile (server, wallet, super-app) must declare its fail-open/fail-closed matrix; the embedding API exposes verdicts and consent hooks so wallets render them natively. From 645ae8e227c4d7f6c927486a25031715cb31dbcb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 3 Jul 2026 01:10:28 +0000 Subject: [PATCH 26/29] docs: fold red-team amendments into the intent architecture --- docs/09-intent-architecture.md | 58 ++++++++++++++++++- docs/adr/0010-venues-as-adapter-components.md | 4 +- docs/adr/0011-egress-guard-pipeline.md | 3 + 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/09-intent-architecture.md b/docs/09-intent-architecture.md index 6f0e5c57..2d3bffb8 100644 --- a/docs/09-intent-architecture.md +++ b/docs/09-intent-architecture.md @@ -109,6 +109,8 @@ interface pool { The body is opaque bytes at the pool boundary. Typing is recovered in two places where it is real: guest-side, where venue authors publish typed SDK crates for strategy modules, and at the adapter's `derive-header` export, whose return type is the stable ontology. There is no closed `intent-body` variant to churn per venue, and no way for a module to claim a header the host has not derived. +Bodies carry their own routing: `pool::submit` has no chain parameter. A multichain venue's body schema includes the chain, the adapter resolves the per-chain endpoint, and the derived header's `settlement` field exposes the choice to policy. Body encodings are borsh with an outer version enum per venue (see SDK surfaces below): deterministic bytes, a written cross-language specification, and unknown versions rejected with a typed error. + A fifth event variant, `intent-status(receipt, status)`, is delivered through the existing manifest-subscription mechanism. For HTTP venues the adapter polls; for Waku or PSS venues it subscribes; strategy modules receive identical events either way. Observation is first-class because one of the two flagship modules (ethflow-watcher) is observe-only: it verifies that intents created by others were indexed by the venue, and never submits. ### The adapter world @@ -131,6 +133,8 @@ The host is a router plus a policy checkpoint: a strategy module calls `pool::su Transport is entirely the adapter's concern. A venue reachable over HTTPS, Swarm PSS, Waku, raw libp2p, or an on-chain contract call presents the same four exports; the module and the host router are transport-blind. This is the same shape as `chain::request` (the module says what, the host decides how) extended to one more decision layer. +The minimal surface is deliberate. Both flagship modules need only `submit` and `status` (twap-monitor submits, ethflow-watcher observes), with `cancel` reserved for a future refunder. In particular there is no venue read path in the flagship set: a CoW order's `app-data` travels as the 32-byte hash exactly as returned on-chain, because the orderbook accepts hash-only submissions and joins the pre-registered document on its side; nothing needs fetching. A read-only `query` verb (quotes, venue metadata) is deferred until a strategy needs one (see open questions). + ### Curation and consent Adapters install like modules, with two provenance tiers: @@ -175,6 +179,15 @@ Simulation is a pluggable host primitive, additive alongside `clock` and `http`: One WIT contract either way; analysers and policy are backend-blind. +### Authorisation classes + +The guard classifies every egress event by where its authorisation comes from, and the class sets the default posture: + +- **Host-signed** (EIP-712 via `identity`, transaction signing): the full pipeline, blocking-capable. This is the only class where host-held keys act, so it is the theft boundary. +- **Pre-authorised** (EIP-1271 contract signatures, contract-owner schemes): non-interactive by default. The value egress was consented on-chain when the commitment was created, itself a guarded transaction; the venue accepts submissions permissionlessly, so anyone can materialise a tradeable conditional order (that is what the public watch-tower service does for everyone); and prompting per materialised part would interrupt the user repeatedly for flows they already signed for. The guard records an audit entry and runs analysers in advisory mode; it does not prompt and does not block in the default profile. Note that these flows never touch the identity checkpoint at all: the signature comes back from the chain, so there is nothing for the host to sign. + +Two consequences are stated plainly rather than implied. For pre-authorised intents, spend limits are observability, not enforcement: refusing to submit from the local runtime prevents nothing, because any third party can submit the same part; the chain is the enforcement. And advisory analysis on this class is detection, not prevention: a finding like "this part sells far below market" arrives after the commitment exists, but the user can still invalidate the conditional order on-chain before the next part, so the finding is actionable without adding friction. + ### Analysers Analysers are request/response components (the `query-module` lineage): the host calls them with a fact bundle and a deadline, they return a verdict. Capabilities are tiered: @@ -184,6 +197,48 @@ Analysers are request/response components (the `query-module` lineage): the host Verdicts carry a severity and a typed subject (which `gives` entries they concern). They are policy-binding with per-event user override: high-severity findings block by default, the user can override with friction. Analyser timeout or crash during an interactive prompt resolves per policy profile: a wallet profile fails closed for high-value egress, a server profile may fail open with logging. The choice is explicit configuration, not an accident of scheduling. +## SDK surfaces and the component boundary + +Two authoring personas share the boundary: the venue author (the adapter component plus the types module authors consume) and the module author (strategy against the chassis plus venue clients). The SDK design serves both without weakening the host's position in the middle. + +### No direct module-to-adapter linking + +Component-model composition (linking the module's `pool` import straight to the adapter's exports) looks like an optimisation and is a correctness bug three ways: the host must interpose policy between `derive-header` and `submit`; wasmtime fuel is per-store, so host-in-the-middle is what keeps module work on the module's meter and adapter work on the adapter's; and an adapter trap must not poison the calling module (separate stores, separate restart policies). Every hop is module to host to adapter, and the SDK's job is to make that feel like a typed function call. + +### Boundary cost, calibrated + +Each crossing is a canonical-ABI lift/lower: one copy of the body between linear memories per hop. Intent bodies are small control-plane payloads (an order is under a kilobyte); two hops plus a policy re-decode cost single-digit microseconds against a venue round trip of tens to hundreds of milliseconds. The boundary is optimised for determinism and type safety, not nanoseconds. Where speed genuinely matters, the design already provides for it: adapters and analysers are long-lived pre-instantiated instances, and analysers on the interactive signing path are pure fact-fed with epoch deadlines. The one accepted inefficiency is the double decode of a body (once for `derive-header`, once for `submit`); if profiling ever disagrees, the fix is a WIT resource handle so the adapter retains the decoded body between the two router-sequenced calls, and it is not built speculatively. + +### The body codec is borsh, and a venue is a specification + +Body encodings need deterministic bytes (receipts and audit records may hash them), compactness, no_std encode/decode, schema evolution, and implementations beyond Rust, because module authors are not all Rust authors. Borsh satisfies all five (a written spec, maintained Python/JS/Go implementations); versioning is an outer enum per venue, so adding a version is non-breaking and unknown versions fail typedly. + +Consequently a venue is normatively defined by language-neutral artefacts, not by a crate: the borsh body schema per version, golden vectors (body bytes and the expected derived header), and the submission error-classification table as data (a small table mapping venue error kinds to try-next-block, backoff, or drop). The venue author's Rust crate is the first-class implementation of that specification, not its definition. The conformance kit exports the vectors as files precisely so a non-Rust module can prove byte-exactness in its own test suite, and shipping the classification table as data keeps retry policy guest-side (the ADR-0006 boundary) while making it portable across languages. + +### Crate map + +| Crate | Persona | Contents | +|---|---|---| +| `nexum-sdk` | module authors | host traits, `#[nexum::module]`, the materialiser chassis, typed intent client core | +| `nexum-sdk-test` | module authors | `MockHost` plus a programmable `MockVenue` | +| `nexum-venue-sdk` | venue authors | `VenueAdapter` trait, `#[nexum::venue]`, the body-codec derive, typed wrappers over scoped transport imports | +| `nexum-venue-test` | venue authors | conformance kit: codec round-trip vectors, header-derivation goldens, `MockTransport` | +| per-venue crate (e.g. the CoW venue) | venue author publishes, both consume | default feature: body types and codec; `client`: typed client and retry classification for modules; `adapter`: the adapter component implementation | + +The one-crate-per-venue rule keeps the body schema in exactly one place, consumed from both sides of the boundary, so codec drift between a Rust module and the adapter is a compile error rather than a runtime rejection. Both proc macros exist to remove the per-cdylib glue tax recorded in ADR-0009, and they emit the per-component world matching the manifest's declared capabilities, which retires the import-elision dependency that ADR flagged as load-bearing. + +### Metering and attribution + +Guest compute is metered per component store (fuel plus epoch interruption), for adapters and analysers exactly as for modules. Fuel cannot cross stores, so a hostile module spamming undecodable bodies would burn the adapter's budget; the router closes this with per-caller submission quotas and by charging decode failures against the calling module's quota before the adapter is invoked again. Transport is governed host-side by the existing middleware (timeout, retry, rate limit) on each adapter's scoped imports. + +### Non-Rust module authors + +The WIT is the contract and the Rust SDK is an ergonomics layer, so a Python module (for example) is built with componentize-py against the module world and gets generated typed bindings for every import, including the pool. Metering, supervision, and capability enforcement apply identically; the interpreter is pre-initialised at build time so instantiation stays cheap, the component is larger and burns more fuel per unit of logic, and both costs land on the module's own budget. Pure-language dependencies only (no native extensions), which the venue's published schema, vectors, and classification table are designed for: everything protocol-critical is data, not Rust code. The chassis itself is Rust-only convenience; a non-Rust author hand-rolls the watch/gate/idempotency loop or uses a community helper package. + +### Examples + +The repository ships one example per persona plus the real thing: an echo venue (accepts any body, settles instantly; the tutorial artefact and the conformance kit's test target), an example module driving it through the chassis, and the CoW adapter as the production reference. The SDK design doc (doc 05) gains the venue persona alongside the existing module persona. + ## Trust model summary | Guarantee | Enforced by | Trust required | @@ -202,7 +257,7 @@ Each step is independently shippable and the earlier steps are pure wins even if 1. **Hygiene:** move the `cow_orderbook` backend out of the engine behind the RuntimeTypes extension seam; remove `CowApiHost` from the SDK supertrait. The engine becomes domain-free; shepherd is the distribution that bundles the CoW integration. 2. **SDK chassis:** extract the conditional-commitment machinery (watch sets, gate keys, idempotency journals, retry ledgers) from twap-monitor and ethflow-watcher into venue-generic SDK traits. This delivers watch-tower-parity transportability with no WIT change. -3. **Intent core:** `nexum:value` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Port both flagship modules; ethflow proves observe-only and the status-event path. +3. **Intent core:** `nexum:value` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Alongside it: `nexum-venue-sdk`, the `#[nexum::module]` and `#[nexum::venue]` macros, and the echo-venue example pair as the tutorial artefacts. Port both flagship modules; ethflow proves observe-only and the status-event path. 4. **Guard, first cut:** the `simulate` primitive (local backend), fact assembly, the `analyzer` world, policy binding with override, and the identity-boundary checkpoint for typed-data and transaction signing. 5. **Postage adapter (N=2):** proves `service` wants, non-HTTP transport thinking, and settlement variance. Freeze the vocabulary at 1.0 only after this round-trips submit, status, and policy. 6. **Registry and consent:** the curated adapter/analyser registry, publisher display, the ENS escape hatch, and the wallet-profile consent surface over the embedding API. @@ -213,3 +268,4 @@ Each step is independently shippable and the earlier steps are pure wins even if - **Analyser composition:** multiple analysers with overlapping findings need aggregation rules (max severity wins is the obvious start) and a story for contradictory verdicts. - **A `tx` venue:** transactions are covered by the guard at the identity boundary, not modelled as an intent venue. Whether a transaction-shaped venue adapter (batching, private orderflow) is ever worth registering stays open; the policy hooks are shaped so it could be. - **Adapter reputation:** beyond curation, whether observed adapter behaviour (submission latency, status accuracy) feeds a local score. +- **A read-only venue `query` verb:** quotes and venue metadata have no consumer among the flagship modules (app-data travels as a hash; see the adapter section), so the verb waits for a strategy that needs it. When it lands it is guard-free, because reads are not egress. diff --git a/docs/adr/0010-venues-as-adapter-components.md b/docs/adr/0010-venues-as-adapter-components.md index 6f98552c..d6d0dd38 100644 --- a/docs/adr/0010-venues-as-adapter-components.md +++ b/docs/adr/0010-venues-as-adapter-components.md @@ -23,7 +23,9 @@ A venue integration is a **venue adapter**: a sandboxed wasm component, distribu - Imports: narrow, manifest-declared, per-adapter-scoped transport (`http` to the venue's endpoints, `messaging` on its topics, read-only `chain`). Never `identity`, never keys. - Exports: `derive-header(body) -> intent-header`, `submit(body) -> receipt`, `status(receipt)`, `cancel(receipt)`. -Strategy modules import a minimal `nexum:intent/pool` interface (`submit`/`status`/`cancel` keyed by venue id, body as opaque bytes). The host routes pool calls to the installed adapter, calls `derive-header`, runs guard policy (ADR-0011) on the derived header, and only then forwards to the adapter's `submit`. Headers are always host-obtained from the adapter, never module-supplied, so a module cannot understate what it gives away. +Strategy modules import a minimal `nexum:intent/pool` interface (`submit`/`status`/`cancel` keyed by venue id, body as opaque bytes). Bodies carry their own chain routing (a multichain adapter resolves per-chain endpoints, as the current backend does), and the derived header's `settlement` field exposes the choice to policy. The host routes pool calls to the installed adapter, calls `derive-header`, runs guard policy (ADR-0011) on the derived header, and only then forwards to the adapter's `submit`. Headers are always host-obtained from the adapter, never module-supplied, so a module cannot understate what it gives away. + +A venue is normatively defined by language-neutral artefacts, not by a crate: the borsh body schema per version, golden vectors (body bytes and the expected derived header), and the submission error-classification table as data. The venue author's Rust crate (body types and codec, typed client with retry classification, adapter implementation, behind feature slices) is the first-class implementation of that specification, not its definition, so non-Rust module authors target the venue from generated WIT bindings plus the published schema. Modules never link against adapters directly: every hop is module to host to adapter, which is what keeps policy interposed, fuel attributed per store, and an adapter trap from poisoning the calling module. Intent status flows back through the existing event mechanism as a new event variant; adapters poll or subscribe per their transport, and modules are transport-blind. diff --git a/docs/adr/0011-egress-guard-pipeline.md b/docs/adr/0011-egress-guard-pipeline.md index b584dd52..89036a26 100644 --- a/docs/adr/0011-egress-guard-pipeline.md +++ b/docs/adr/0011-egress-guard-pipeline.md @@ -31,6 +31,8 @@ egress event -> fact assembly (decode + simulate) -> analysers (deadline-bounded - Pure core (default): no imports; compute on the facts handed over. Deterministic, fast, nothing to exfiltrate. - Granted extras: `chain` reads or scoped `http` (vendor reputation feeds) behind explicit consent that states the consequence plainly: this analyser sends what you sign to the vendor. The tier boundary is the privacy boundary, because everything the user signs is exactly what a network-capable analyser could leak. +**Egress events are classified by authorisation source.** Host-signed events (EIP-712 via `identity`, transaction signing) get the full pipeline and are blocking-capable; they are the only class where host-held keys act. Pre-authorised events (EIP-1271 contract signatures, contract-owner schemes) default to non-interactive audit plus advisory analysis: the consent already happened on-chain at commitment creation (itself a guarded transaction), venue submission is permissionless so local blocking prevents nothing (any third party can submit the same materialised part), and per-part prompts would be interruption without protection. These flows never reach the identity checkpoint at all; the signature comes back from the chain. For this class, spend limits are observability rather than enforcement, and advisory findings are detection rather than prevention, though still actionable: the user can invalidate the commitment on-chain before the next part. + **Policy is binding with per-event user override.** High-severity verdicts block by default; the user can override with friction. Analyser timeout or crash during an interactive prompt resolves per policy profile: wallet profiles fail closed for high-value egress, server profiles may fail open with logging. Fail-open versus fail-closed is explicit configuration. **Transactions are covered by the guard, not modelled as an intent venue.** The earlier deferral of "does the intent capability swallow `eth_sendTransaction`" resolves here: unification happens at the guard layer, where all egress meets the same facts and policy, while the intent pool stays scoped to venue submissions. The policy hooks are shaped so a transaction-shaped venue adapter could register later if batching or private orderflow ever wants one. @@ -41,6 +43,7 @@ egress event -> fact assembly (decode + simulate) -> analysers (deadline-bounded - **Two pipelines (runtime venue policy, wallet signing guard) with a shared vocabulary.** Rejected: the fact shapes, analyser world, and policy engine would be near-duplicates that drift; the wallet profile would re-implement intent policy the day a wallet hosts strategy modules. - **Advisory-only verdicts.** Rejected as the default: it puts the entire burden on consent-sheet reading. Retained as the effective behaviour for low-severity findings. +- **A uniform blocking posture for all egress regardless of authorisation source.** Rejected: for pre-authorised (EIP-1271) intents, blocking is security theatre because submission is permissionless, and prompting per TWAP part is unusable. The enforcement point for those flows is commitment creation, which the guard already covers as a transaction. - **Hard veto for any installed analyser.** Rejected: one broken or compromised analyser bricks signing, and it forces fail-closed on every timeout regardless of stakes. - **Analysers bring their own simulation** via `chain`/`http` grants. Rejected as the model: every analyser duplicates the work, the fact bundle thins to uselessness for pure analysers, and the privacy tier collapses (all analysers would need network). Network-capable analysers may still enrich beyond the host bundle. - **Host-only heuristics, no analyser components.** Rejected: it forecloses the security-vendor ecosystem and hard-codes threat logic into host releases, the same mistake the venue layer just escaped. From 44ca386ef711f72e8d9e8e7b86b0abb1b1e0a903 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 3 Jul 2026 01:22:55 +0000 Subject: [PATCH 27/29] docs: rename bindings-hostile WIT identifiers and record the hygiene rule --- docs/09-intent-architecture.md | 21 ++++++++++--------- docs/adr/0010-venues-as-adapter-components.md | 4 ++-- docs/adr/0011-egress-guard-pipeline.md | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/09-intent-architecture.md b/docs/09-intent-architecture.md index 2d3bffb8..dc750683 100644 --- a/docs/09-intent-architecture.md +++ b/docs/09-intent-architecture.md @@ -51,7 +51,7 @@ One WIT types package, egress-neutral, describes value in motion. It is shared b Sketch (illustrative, not frozen): ```wit -package nexum:value@0.1.0; +package nexum:value-flow@0.1.0; interface types { variant settlement { @@ -60,12 +60,12 @@ interface types { } variant asset { - native(settlement), + native-token(settlement), erc20(tuple>), // (chain, address) erc721(tuple, list>), // (chain, address, id) erc1155(tuple, list>), service(service-desc), // e.g. storage capacity for a duration - external(external-desc), // RWA: deed, chattel, ... + offchain(offchain-desc), // RWA: deed, chattel, ... } record asset-amount { asset: asset, amount: list } // big-endian unsigned @@ -75,8 +75,9 @@ interface types { Design notes: - `settlement` is a variant from day one. The current `chain-id: u64` plumbing assumes every venue settles on an EVM chain; the off-chain marketplace target breaks that assumption. -- `service` and `external` exist so a postage purchase and a physical-asset listing fit without forcing them into token shapes. For `external` assets the host can verify nothing; policy on them is plugin-attested, not host-verified, and the consent surface must say so. +- `service` and `offchain` exist so a postage purchase and a physical-asset listing fit without forcing them into token shapes. For `offchain` assets the host can verify nothing; policy on them is plugin-attested, not host-verified, and the consent surface must say so. The case name deliberately mirrors `settlement::offchain`: the same concept on two axes (where the asset lives, where the deal settles). - Policy has teeth on `gives` (what leaves the user's control). `wants` is display-grade: the host can rarely verify the counterparty's obligation and must not pretend to. +- Identifier hygiene is a freeze gate. Every WIT identifier in this vocabulary is checked against WIT keywords, including in-flight proposals (the package is `value-flow` rather than `value` because the component model's value-imports feature is circling that word), and against the reserved words of the binding-target languages (Rust, Python, JS, Go, C#, Java, Kotlin, Swift, Dart). Prefer a two-word kebab id whenever a single word is a keyword anywhere: `native-token` not `native` (Java), `offchain` not `external` (Dart), `unsigned` not `none` (Python) in the authorisation scheme. The future guard types package is `nexum:egress`, not `nexum:guard` (Swift). WIT parses all of the rejected spellings today; the cost lands later, as escaped identifiers in generated bindings for exactly the personas the SDK exists to serve. ## Intents and venue adapters @@ -86,14 +87,14 @@ Design notes: package nexum:intent@0.1.0; interface types { - use nexum:value/types.{asset-amount, settlement}; + use nexum:value-flow/types.{asset-amount, settlement}; record intent-header { gives: list, wants: list, valid-until: option, settlement: settlement, - authorisation: auth-scheme, // eip712 | eip1271 | presign | offchain-sig | none + authorisation: auth-scheme, // eip712 | eip1271 | presign | offchain-sig | unsigned } variant intent-status { pending, open, settled(option>), failed(fail-reason), expired, cancelled } @@ -170,7 +171,7 @@ The wallet embedding is a host profile where transaction and typed-data events d ### Fact assembly and the `simulate` primitive -The host assembles a typed fact bundle per event: the decoded payload (EIP-712 struct and domain, transaction fields, or intent header plus venue id), simulation results (balance diffs and approvals granted, expressed in `nexum:value` types), and context metadata (counterparty contract, chain, requesting component). +The host assembles a typed fact bundle per event: the decoded payload (EIP-712 struct and domain, transaction fields, or intent header plus venue id), simulation results (balance diffs and approvals granted, expressed in `nexum:value-flow` types), and context metadata (counterparty contract, chain, requesting component). Simulation is a pluggable host primitive, additive alongside `clock` and `http`: @@ -249,7 +250,7 @@ The repository ships one example per persona plus the real thing: an echo venue | Contract-authorised flows (e.g. EIP-1271 conditional orders) | Consented on-chain when the commitment was created; guests can only materialise what the contract permits | On-chain approval hygiene | | Threat verdicts | Analyser components under deadline, tiered capabilities | Analyser publisher, proportional to granted tier | -Honest limitations, carried deliberately: policy on `external` (RWA) assets is adapter-attested rather than host-verified; adapter misbehaviour of the griefing grade (delay, drop, leak) is handled by curation and reputation rather than mechanism; and `wants` is display-grade. +Honest limitations, carried deliberately: policy on `offchain` (RWA) assets is adapter-attested rather than host-verified; adapter misbehaviour of the griefing grade (delay, drop, leak) is handled by curation and reputation rather than mechanism; and `wants` is display-grade. ## Sequencing @@ -257,14 +258,14 @@ Each step is independently shippable and the earlier steps are pure wins even if 1. **Hygiene:** move the `cow_orderbook` backend out of the engine behind the RuntimeTypes extension seam; remove `CowApiHost` from the SDK supertrait. The engine becomes domain-free; shepherd is the distribution that bundles the CoW integration. 2. **SDK chassis:** extract the conditional-commitment machinery (watch sets, gate keys, idempotency journals, retry ledgers) from twap-monitor and ethflow-watcher into venue-generic SDK traits. This delivers watch-tower-parity transportability with no WIT change. -3. **Intent core:** `nexum:value` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Alongside it: `nexum-venue-sdk`, the `#[nexum::module]` and `#[nexum::venue]` macros, and the echo-venue example pair as the tutorial artefacts. Port both flagship modules; ethflow proves observe-only and the status-event path. +3. **Intent core:** `nexum:value-flow` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Alongside it: `nexum-venue-sdk`, the `#[nexum::module]` and `#[nexum::venue]` macros, and the echo-venue example pair as the tutorial artefacts. Port both flagship modules; ethflow proves observe-only and the status-event path. 4. **Guard, first cut:** the `simulate` primitive (local backend), fact assembly, the `analyzer` world, policy binding with override, and the identity-boundary checkpoint for typed-data and transaction signing. 5. **Postage adapter (N=2):** proves `service` wants, non-HTTP transport thinking, and settlement variance. Freeze the vocabulary at 1.0 only after this round-trips submit, status, and policy. 6. **Registry and consent:** the curated adapter/analyser registry, publisher display, the ENS escape hatch, and the wallet-profile consent surface over the embedding API. ## Open questions -- **Vocabulary freeze discipline:** `nexum:value` becomes forward-compatibility-critical for three consumers at once. The N=2 gate (step 5) is the guard, but the versioning policy for post-1.0 additions (new asset variants) needs its own note. +- **Vocabulary freeze discipline:** `nexum:value-flow` becomes forward-compatibility-critical for three consumers at once. The N=2 gate (step 5) is the guard, but the versioning policy for post-1.0 additions (new asset variants) needs its own note. - **Analyser composition:** multiple analysers with overlapping findings need aggregation rules (max severity wins is the obvious start) and a story for contradictory verdicts. - **A `tx` venue:** transactions are covered by the guard at the identity boundary, not modelled as an intent venue. Whether a transaction-shaped venue adapter (batching, private orderflow) is ever worth registering stays open; the policy hooks are shaped so it could be. - **Adapter reputation:** beyond curation, whether observed adapter behaviour (submission latency, status accuracy) feeds a local score. diff --git a/docs/adr/0010-venues-as-adapter-components.md b/docs/adr/0010-venues-as-adapter-components.md index d6d0dd38..3c01ac52 100644 --- a/docs/adr/0010-venues-as-adapter-components.md +++ b/docs/adr/0010-venues-as-adapter-components.md @@ -29,11 +29,11 @@ A venue is normatively defined by language-neutral artefacts, not by a crate: th Intent status flows back through the existing event mechanism as a new event variant; adapters poll or subscribe per their transport, and modules are transport-blind. -The typed ontology lives in a shared, egress-neutral `nexum:value` types package (assets, amounts, settlement domains) plus `nexum:intent` (header, status, receipt). Venue body schemas are per-venue, typed guest-side in venue SDK crates and at `derive-header`, so adding a venue is an adapter release plus an SDK crate, not a host release and not a WIT bump of a closed body variant. +The typed ontology lives in a shared, egress-neutral `nexum:value-flow` types package (assets, amounts, settlement domains) plus `nexum:intent` (header, status, receipt). Venue body schemas are per-venue, typed guest-side in venue SDK crates and at `derive-header`, so adding a venue is an adapter release plus an SDK crate, not a host release and not a WIT bump of a closed body variant. Provenance is two-tier: a platform-signed curated registry by default, plus an install-by-ENS escape hatch behind a stronger warning. Adapters are always separate artifacts from strategy modules; a venue and a strategy by the same author are two visible installs. -The CoW integration becomes the first adapter, built as a component from day one and bundled with the shepherd distribution. Bundled is not compiled-in: the same artifact installs dynamically on other hosts. `shepherd:cow/cow-api` and the `cow_orderbook` backend are retired once the port completes. The Swarm postage-purchase adapter is the N=2 proving ground; the `nexum:value` vocabulary does not freeze before it round-trips submit, status, and policy. +The CoW integration becomes the first adapter, built as a component from day one and bundled with the shepherd distribution. Bundled is not compiled-in: the same artifact installs dynamically on other hosts. `shepherd:cow/cow-api` and the `cow_orderbook` backend are retired once the port completes. The Swarm postage-purchase adapter is the N=2 proving ground; the `nexum:value-flow` vocabulary does not freeze before it round-trips submit, status, and policy. ## Considered options diff --git a/docs/adr/0011-egress-guard-pipeline.md b/docs/adr/0011-egress-guard-pipeline.md index 89036a26..c6280451 100644 --- a/docs/adr/0011-egress-guard-pipeline.md +++ b/docs/adr/0011-egress-guard-pipeline.md @@ -22,7 +22,7 @@ egress event -> fact assembly (decode + simulate) -> analysers (deadline-bounded **Egress events.** Intent submissions (header derived by the venue adapter, routed by the pool), EIP-712 requests at `identity::sign-typed-data`, and transaction signing at the identity boundary. The wallet embedding is a host profile where signing events dominate and consent renders in the wallet UI over the embedding API; the server runtime is a profile where intent submissions dominate and policy is operator configuration. -**Fact assembly.** The host builds a typed fact bundle per event: decoded payload, simulation results (balance diffs, approvals granted), and context metadata. All value flows are expressed in the shared `nexum:value` vocabulary, the same types intent headers use, so one asset-delta dialect serves headers, simulations, and verdicts. +**Fact assembly.** The host builds a typed fact bundle per event: decoded payload, simulation results (balance diffs, approvals granted), and context metadata. All value flows are expressed in the shared `nexum:value-flow` vocabulary, the same types intent headers use, so one asset-delta dialect serves headers, simulations, and verdicts. **`simulate` is a pluggable host primitive**, additive alongside `clock` and `http`. Server and desktop hosts run a local EVM (revm) over provider-pool state. Mobile hosts may configure a remote simulation backend because cold-state simulation over mobile RPC is interactively too slow; that trades transaction privacy for latency, and the trade is explicit operator/user configuration surfaced in consent, never a silent default. Analysers and policy are backend-blind. From 9121f914969e3067a034ec154d75f5ccf79161fb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 8 Jul 2026 04:15:57 +0000 Subject: [PATCH 28/29] docs(adr): renumber egress-guard ADR 0011 to 0012 The M0 train adds ADR-0011 (per-interface typed errors), which collides with the egress-guard record this branch introduced at the same number. Renumber it to 0012 and update the two cross-references so both records land with distinct numbers. --- ...011-egress-guard-pipeline.md => 0012-egress-guard-pipeline.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/adr/{0011-egress-guard-pipeline.md => 0012-egress-guard-pipeline.md} (100%) diff --git a/docs/adr/0011-egress-guard-pipeline.md b/docs/adr/0012-egress-guard-pipeline.md similarity index 100% rename from docs/adr/0011-egress-guard-pipeline.md rename to docs/adr/0012-egress-guard-pipeline.md From 23aa95d7a7556973f2371a258ef507f5cdbe5833 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 07:34:52 +0000 Subject: [PATCH 29/29] docs: reconcile intent architecture with shipped M1 and platform decisions Updates docs/09 + ADR-0010 + ADR-0012 against the shipped M1 tree and the seven platform decisions (2026-07-14): marks the shipped surfaces as shipped (venue-adapter kind, nexum:intent/pool, nexum-venue-sdk, #[nexum::venue], echo-venue, cow-venue, ADR-0013 Verdict seam), reframes the egress-guard pipeline as target design deferred to the egress-guard epic (M1 ships an advisory AllowAllGuard no-op, R3), corrects the world-composition category error (an adapter targets one world + wasi:http; there is no "cow-protocol WASI"), and folds in transport-only adapters (Q1), opaque host status (Q2/R6), install-time schema handshake (R7), EVM-only 0.1 (Q5), and #[venue] as the blessed path (Q6). --- docs/09-intent-architecture.md | 203 ++++++++++++------ docs/adr/0010-venues-as-adapter-components.md | 46 ++-- docs/adr/0012-egress-guard-pipeline.md | 35 ++- 3 files changed, 205 insertions(+), 79 deletions(-) diff --git a/docs/09-intent-architecture.md b/docs/09-intent-architecture.md index dc750683..3abe8711 100644 --- a/docs/09-intent-architecture.md +++ b/docs/09-intent-architecture.md @@ -1,6 +1,20 @@ # Intent Architecture: Venue Adapters and the Egress Guard -> **Status (design, 0.3+ direction):** This document records the target architecture agreed in the July 2026 architecture review. Nothing in it ships in 0.2. It supersedes the CoW-specific framing of the Layer 3 example in [08-platform-generalisation.md](08-platform-generalisation.md) and the compiled-in `cow-api` backend of ADR-0005. Decision records: [ADR-0010](adr/0010-venues-as-adapter-components.md) (venue adapters) and [ADR-0011](adr/0011-egress-guard-pipeline.md) (the guard pipeline). +> **Status (part shipped, part target — reconciled 2026-07-15).** The **venue-adapter half of +> this document shipped in the M1 train** and is described here as real: the `venue-adapter` +> component kind, `nexum:intent@0.1.0` + `nexum:value-flow@0.1.0`, the host `PoolRouter`, +> `nexum-venue-sdk` + the `#[nexum::venue]` macro, the `nexum-venue-test` conformance kit, and +> the `echo-venue` reference adapter. The **egress-guard half is target design, deferred +> wholesale to the egress-guard epic** — M1 ships only an advisory `AllowAllGuard` no-op +> (decision R3). Throughout, shipped and deferred are called out sharply; do not read the guard +> pipeline, the `simulate` primitive, the analyser world, or identity-boundary signing as +> present. This document supersedes the CoW-specific framing of the Layer 3 example in +> [08-platform-generalisation.md](08-platform-generalisation.md) and the compiled-in `cow-api` +> backend of ADR-0005 (which survives only as the legacy event-module read path). Decision +> records: [ADR-0010](adr/0010-venues-as-adapter-components.md) (venue adapters — **accepted, +> shipped**) and [ADR-0012](adr/0012-egress-guard-pipeline.md) (the guard pipeline — +> **target, deferred**). Full reconciliation and the seven platform decisions of 2026-07-14: +> [venue-platform-architecture.md](design/venue-platform-architecture.md). ## Motivation @@ -12,43 +26,49 @@ Three pressures converge on the same design: The unifying observation: intent submission, typed-data signing, and transaction signing are all **value egress events**. They deserve one vocabulary, one analysis pipeline, and one policy surface. +> **What of this is realised.** Pressures 1 and 2 are **shipped**: CoW is now expressible as one venue among many (`echo-venue` proves the generic seam without any CoW code), and venue adapters are distributable, sandboxed, dynamically installed components. Pressure 3 — the wallet/signing-boundary story that motivates the *guard* — is **target**: the analysis pipeline and the one-policy-surface unification are deferred to the egress-guard epic. The shared value-flow **vocabulary** ships now (`nexum:value-flow@0.1.0`); the pipeline that would consume it at the signing boundary does not. + ## Component kinds -The platform grows from one guest component kind to three. All three share the distribution pipeline (ENS discovery, Swarm fetch, content-hash verification, manifest, install-time consent) and the supervision machinery (restart policy, poison handling, fuel and epoch metering, capability enforcement). +The platform grows from one guest component kind toward three. Two are **shipped** (strategy module, venue adapter); the third (threat analyser) is **target**, deferred with the egress-guard epic. All share the distribution pipeline (ENS discovery, Swarm fetch, content-hash verification, manifest, install-time consent) and the supervision machinery (restart policy, poison handling, fuel and epoch metering, capability enforcement) — though adapters are not yet in the restart/poison sweeps (post-M1 hardening). -| Kind | World | Role | Trust position | -|---|---|---|---| -| Strategy module | `event-module` (existing) | Owns strategy: when to emit intents, polling cadence, retry policy | Arbitrary author; capability-scoped | -| Venue adapter | `venue-adapter` (new) | One venue each: encodes intent bodies, derives headers, submits, observes status, over whatever transport the venue speaks | Curated registry plus ENS escape hatch; structurally cannot move value | -| Threat analyser | `analyzer` (new; descendant of the experimental `query-module` world) | Evaluates egress fact bundles, returns verdicts under a deadline | Tiered: pure fact-fed by default; network extras behind explicit consent | +| Kind | World | Role | Trust position | Status | +|---|---|---|---|---| +| Strategy module | `event-module` | Owns strategy: when to emit intents, polling cadence, retry policy | Arbitrary author; capability-scoped | **Shipped** | +| Venue adapter | `nexum:adapter/venue-adapter` | One venue each: encodes intent bodies, derives headers, submits, observes status, over whatever transport the venue speaks | Curated registry plus ENS escape hatch; structurally cannot move value | **Shipped** (`echo-venue`; no concrete CoW adapter component yet) | +| Threat analyser | `analyzer` (descendant of the experimental `query-module` world) | Evaluates egress fact bundles, returns verdicts under a deadline | Tiered: pure fact-fed by default; network extras behind explicit consent | **Target** — deferred to the egress-guard epic (`query-module` WIT exists but is published-unhosted: no linker) | + +Solid edges are shipped in M1; dashed edges and the dashed nodes are target (egress-guard epic). ```mermaid flowchart LR subgraph Guests SM["strategy module
(event-module)"] VA["venue adapter
(venue-adapter)"] - AN["threat analyser
(analyzer)"] + AN["threat analyser
(analyzer) — TARGET"] end subgraph Host - POOL["intent pool router"] - GUARD["egress guard
(facts + policy)"] - SIM["simulate backend"] - ID["identity"] + POOL["intent pool router
(PoolRouter)"] + GUARD["egress guard — TARGET
M1: AllowAllGuard no-op"] + SIM["simulate backend — TARGET"] + ID["identity — 0.3 stub"] end SM -->|"submit(venue, body)"| POOL POOL -->|"derive-header / submit / status"| VA - POOL --> GUARD - ID -->|"sign requests"| GUARD - GUARD -->|"fact bundle"| AN - GUARD --> SIM - VA -->|"scoped http / messaging / chain"| Transport[("venue transport:
HTTPS, Waku, PSS,
libp2p, chain")] + POOL -.->|"AllowAll no-op (advisory)"| GUARD + ID -.->|"sign requests (target)"| GUARD + GUARD -.->|"fact bundle (target)"| AN + GUARD -.-> SIM + VA -->|"scoped chain / messaging + wasi:http"| Transport[("venue transport:
HTTPS, Waku, PSS,
libp2p, chain")] ``` ## The value-flow vocabulary One WIT types package, egress-neutral, describes value in motion. It is shared by intent headers, simulation balance diffs, and analyser verdict subjects, so that "100 USDC leaves the user's control" is written the same way in all three places. This vocabulary is the real platform contract; it must outlive any individual interface and is the part that freezes hardest. -Sketch (illustrative, not frozen): +**Shipped as `nexum:value-flow@0.1.0`** (`wit/nexum-value-flow/types.wit`), carrying no package dependency so it can outlive any interface built on it. Today it is consumed by intent headers only; the simulation and analyser consumers arrive with the egress-guard epic. The `@0.1.0` on it is pre-release cruft, not a compat boundary — nothing external pins it, so it carries no freeze it must round-trip a second venue before breaking. (The shipped ERC cases are still positional tuples, e.g. `erc20(tuple>)`; lifting them to named records is a queued hygiene item, not a wire concern.) + +Shape (this is now the shipped WIT, lightly abbreviated): ```wit package nexum:value-flow@0.1.0; @@ -83,56 +103,92 @@ Design notes: ### The intent core +This is the shipped `nexum:intent@0.1.0` (`wit/nexum-intent/{types,pool,adapter}.wit`), abbreviated: + ```wit package nexum:intent@0.1.0; interface types { - use nexum:value-flow/types.{asset-amount, settlement}; + use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; + + variant auth-scheme { eip712, eip1271, presign, offchain-sig, unsigned } // EVM-only in 0.1 (decision Q5) record intent-header { - gives: list, - wants: list, - valid-until: option, + gives: list, // teeth: what leaves the user's control + wants: list, // display-grade, not host-verified + valid-until: option, // ms since Unix epoch; absent = venue default settlement: settlement, - authorisation: auth-scheme, // eip712 | eip1271 | presign | offchain-sig | unsigned + authorisation: auth-scheme, } + type receipt = list; // venue-scoped stable id (CoW: the 56-byte order UID) + record fail-reason { code: string, detail: string } variant intent-status { pending, open, settled(option>), failed(fail-reason), expired, cancelled } - type receipt = list; // venue-scoped stable id (CoW: the 56-byte order UID) + + // An EVM call the host must sign and send before the intent exists on-chain. + record unsigned-tx { chain-id: u64, to: list, value: list, input: list } + // Success from day one is a variant: a held intent, or an on-chain-settlement + // (ethflow-style) venue that has no receipt until the host signs a tx. + variant submit-outcome { accepted(receipt), requires-signing(unsigned-tx) } + + variant venue-error { // carries its own transport cases; no nexum:host dependency + unknown-venue, invalid-body(string), invalid-receipt, rejected(string), + denied(string), // guard policy refused the egress + unsupported(string), unavailable(string), internal-error(string), + } } +// strategy-module face: venue named per call interface pool { - submit: 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>; } + +// venue face: the mirror, no venue arg (one adapter answers for one venue) +interface adapter { + derive-header: func(body: list) -> result; // pure projection + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; +} ``` -The body is opaque bytes at the pool boundary. Typing is recovered in two places where it is real: guest-side, where venue authors publish typed SDK crates for strategy modules, and at the adapter's `derive-header` export, whose return type is the stable ontology. There is no closed `intent-body` variant to churn per venue, and no way for a module to claim a header the host has not derived. +The body is opaque bytes at both faces. Typing is recovered in two places where it is real: guest-side, where venue authors publish typed SDK crates for strategy modules, and at the adapter's `derive-header` export, whose return type is the stable ontology. There is no closed `intent-body` variant to churn per venue, and no way for a module to claim a header the host has not derived. -Bodies carry their own routing: `pool::submit` has no chain parameter. A multichain venue's body schema includes the chain, the adapter resolves the per-chain endpoint, and the derived header's `settlement` field exposes the choice to policy. Body encodings are borsh with an outer version enum per venue (see SDK surfaces below): deterministic bytes, a written cross-language specification, and unknown versions rejected with a typed error. +`submit` returns a `submit-outcome` **variant** — `accepted(receipt)` or `requires-signing(unsigned-tx)` — present from day one so an on-chain-settlement venue is representable without a later breaking change. Per **decision Q5 the 0.1 ontology is EVM-only** (`auth-scheme` and `unsigned-tx` are EVM-shaped): a *scoping* choice, not a compatibility one. Non-EVM settlement, and **quoting** (which 0.1 does not have — the contract exposes only submit/status/cancel), version in later; nothing is pinned, so they land whenever a design partner arrives at the cost of an internal recompile, not a wire break. -A fifth event variant, `intent-status(receipt, status)`, is delivered through the existing manifest-subscription mechanism. For HTTP venues the adapter polls; for Waku or PSS venues it subscribes; strategy modules receive identical events either way. Observation is first-class because one of the two flagship modules (ethflow-watcher) is observe-only: it verifies that intents created by others were indexed by the venue, and never submits. +Bodies carry their own routing: `pool::submit` has no chain parameter. A multichain venue's body schema includes the chain, the adapter resolves the per-chain endpoint, and the derived header's `settlement` field exposes the choice to policy. Body encodings are borsh with an outer version enum per venue (see SDK surfaces below): deterministic bytes, a written cross-language specification, and unknown versions rejected with a typed error. **Per decision R7, module↔adapter body-schema agreement is checked at install time**, not only at runtime: a `body_version` (or version-set) field in the module and adapter manifests, asserted by `Supervisor::install`, which refuses to boot a mismatched pair rather than surfacing `invalid-body` on the first submission. + +Intent status flows back through the existing event mechanism; the adapter polls for HTTP venues and subscribes for Waku/PSS venues, and strategy modules are transport-blind either way. **Per decision Q2/R6, the host `event` stream carries opaque status bytes**, decoupled from `nexum:intent`, with a documented versioned destructuring contract — *not* a typed `intent-status` case borrowed into `nexum:host`. That keeps a new lifecycle case in the intent ontology from being a breaking change that recompiles every event-module. Observation is first-class because one of the two flagship modules (ethflow-watcher) is observe-only: it verifies that intents created by others were indexed by the venue, and never submits. ### The adapter world +A venue adapter is a component targeting **exactly one world** — the shipped `nexum:adapter/venue-adapter` (`wit/nexum-adapter/venue-adapter.wit`). It does not "compose" a protocol WASI package with a venue world: there is no such thing as a "cow-protocol WASI" interface, and a component targets one world, full stop. A CoW adapter reaches the orderbook as opaque bytes over `wasi:http` (allowlisted to `api.cow.fi`) plus `nexum:host/chain`; composable orders are just a body variant, needing no separate protocol interface. + ```wit +package nexum:adapter@0.1.0; + world venue-adapter { - // narrow, manifest-declared, per-adapter-scoped imports: - import http; // e.g. the CoW adapter: api.cow.fi only - import messaging; // e.g. a Waku venue: its content topics only - import chain; // read-only lookups where needed - - export derive-header: func(body: list) -> result; - export submit: func(body: list) -> result; - export status: func(receipt: receipt) -> result; - export cancel: func(receipt: receipt) -> result<_, venue-error>; + use nexum:host/types@0.2.0.{config, fault}; + + // Scoped transport only. wasi:http is linked SEPARATELY (not named here) + // and gated per-adapter by the [[adapters]].http_allow allowlist in + // engine.toml; [[adapters]].messaging_topics scopes messaging content + // topics. Time/randomness are ambient wasi:clocks / wasi:random. + import nexum:host/chain@0.2.0; + import nexum:host/messaging@0.2.0; + + export init: func(config: config) -> result<_, fault>; // mirrors event-module init + export nexum:intent/adapter@0.1.0; // derive-header / submit / status / cancel } ``` -The host is a router plus a policy checkpoint: a strategy module calls `pool::submit(venue, body)`; the host resolves the venue id to the installed adapter instance, calls its `derive-header`, runs guard policy on the result (see below), and only then forwards to the adapter's `submit`. Adapters never see keys, never import `identity`, and hold no unscoped transport, so a hostile adapter can misdescribe or grief (drop, delay, leak order details the venue would see anyway) but cannot steal. +Per **decision Q1: no venue-specific host interfaces.** An adapter is transport-only over the *generic* Nexum host set + `wasi:http`; the host interface set is kept ample enough that venues need nothing bespoke. `ADAPTER_CAPABILITIES = ["chain", "messaging"]` (`manifest/capabilities.rs`) is the source of truth, and the adapter linker withholds `local-store`, `remote-store`, `identity`, and `logging` — an adapter that reaches for them fails to instantiate. `shepherd:cow/cow-api` survives **only** as the legacy `event-module` read path, never as an adapter capability. -Transport is entirely the adapter's concern. A venue reachable over HTTPS, Swarm PSS, Waku, raw libp2p, or an on-chain contract call presents the same four exports; the module and the host router are transport-blind. This is the same shape as `chain::request` (the module says what, the host decides how) extended to one more decision layer. +The host `PoolRouter` is a router plus a (currently advisory) checkpoint: a strategy module calls `pool::submit(venue, body)`; the router resolves the venue id to the installed adapter instance, gates the caller's quota, calls its `derive-header`, runs the guard seam on the result (**M1: `AllowAllGuard`, a no-op — see below**), and only then forwards to the adapter's `submit`. Adapters never see keys, never import `identity`, and hold no unscoped transport, so a hostile adapter can misdescribe or grief (drop, delay, leak order details the venue would see anyway) but cannot steal. + +Transport is entirely the adapter's concern. A venue reachable over HTTPS, Swarm PSS, Waku, raw libp2p, or an on-chain contract call presents the same exports; the module and the host router are transport-blind. This is the same shape as `chain::request` (the module says what, the host decides how) extended to one more decision layer. The minimal surface is deliberate. Both flagship modules need only `submit` and `status` (twap-monitor submits, ethflow-watcher observes), with `cancel` reserved for a future refunder. In particular there is no venue read path in the flagship set: a CoW order's `app-data` travels as the 32-byte hash exactly as returned on-chain, because the orderbook accepts hash-only submissions and joins the pre-registered document on its side; nothing needs fetching. A read-only `query` verb (quotes, venue metadata) is deferred until a strategy needs one (see open questions). @@ -151,6 +207,19 @@ The strategy versus protocol boundary from ADR-0006 is preserved, not repealed. ## The egress guard +> **TARGET — deferred wholesale to the egress-guard epic (decision R3).** Nothing in this +> section ships in M1. What ships is an `AllowAllGuard`: a no-op `GuardPolicy` in the router's +> guard slot (`pool_router.rs`) that admits every egress. It runs on the adapter's *own* +> `derive-header` output — which `submit` re-decodes independently, a TOCTOU gap that makes it +> advisory even in principle — and it does **not** cover the signing path at all. There is no +> `simulate` primitive, no fact assembly, no `analyzer` world, no policy engine; the `identity` +> boundary named below as the theft anchor is a 0.3 stub (`accounts() -> Ok(vec![])`). Per +> decision R3 the M1 posture is advisory-only: keep `AllowAllGuard`, feature-gate the `pool` +> import, and treat the checkpoint as **not yet a boundary**. Per decision 7, **identity signing +> lands with the guard, later** — not before. The `derive → guard → submit` *seam* is real and +> tested (a `DenyGuard` test proves a deny blocks submit); the teeth are what the epic adds. +> Read the rest of this section as the design that epic will build. Decision record: **ADR-0012**. + ### One pipeline for all value egress Three event classes produce the same fact-bundle shape and flow through the same spine: @@ -218,15 +287,17 @@ Consequently a venue is normatively defined by language-neutral artefacts, not b ### Crate map -| Crate | Persona | Contents | -|---|---|---| -| `nexum-sdk` | module authors | host traits, `#[nexum::module]`, the materialiser chassis, typed intent client core | -| `nexum-sdk-test` | module authors | `MockHost` plus a programmable `MockVenue` | -| `nexum-venue-sdk` | venue authors | `VenueAdapter` trait, `#[nexum::venue]`, the body-codec derive, typed wrappers over scoped transport imports | -| `nexum-venue-test` | venue authors | conformance kit: codec round-trip vectors, header-derivation goldens, `MockTransport` | -| per-venue crate (e.g. the CoW venue) | venue author publishes, both consume | default feature: body types and codec; `client`: typed client and retry classification for modules; `adapter`: the adapter component implementation | +| Crate | Persona | Contents | Status | +|---|---|---|---| +| `nexum-sdk` | module authors | host traits, `#[nexum::module]`, the keeper chassis (parts), typed intent client core | **Shipped** | +| `nexum-sdk-test` | module authors | `MockHost` plus a programmable `MockVenue` | **Shipped** | +| `nexum-venue-sdk` | venue authors | `VenueAdapter` trait, `#[nexum::venue]`, the body-codec derive, typed wrappers over scoped transport imports | **Shipped** | +| `nexum-venue-test` | venue authors | conformance kit: codec round-trip vectors, header-derivation goldens, `MockTransport` | **Shipped** | +| `cow-venue` (the per-venue crate) | venue author publishes, both consume | default `body` feature: order + composable body types and borsh codec; `client`: typed `CowClient` + data-table retry classification for modules | **Body/client shipped; `adapter` (cdylib) slice NOT built** | -The one-crate-per-venue rule keeps the body schema in exactly one place, consumed from both sides of the boundary, so codec drift between a Rust module and the adapter is a compile error rather than a runtime rejection. Both proc macros exist to remove the per-cdylib glue tax recorded in ADR-0009, and they emit the per-component world matching the manifest's declared capabilities, which retires the import-elision dependency that ADR flagged as load-bearing. +The one-crate-per-venue rule keeps the body schema in exactly one place, consumed from both sides of the boundary, so codec drift between a Rust module and the adapter is a compile error rather than a runtime rejection. The proc macros exist to remove the per-cdylib glue tax recorded in ADR-0009, and they emit the per-component world matching the manifest's declared capabilities, which retires the import-elision dependency that ADR flagged as load-bearing. + +**`#[nexum::venue]` is the single blessed authoring path** (decision Q6). Today the macro emits the adapter's `Guest` export glue over an inherent `impl` block (as `echo-venue` uses it); the decided target is to have it emit an `impl VenueAdapter` and demote `export_venue_adapter!` — the `macro_rules!` in `nexum-venue-sdk` that routes an explicit `VenueAdapter` impl through the world — to the internal codegen the attribute expands to, not a public second door. That unification is a queued DX follow-on. The generic `Keeper::sweep` orchestrator the chassis is named for is likewise not yet assembled: `WatchSet`/`Gates`/`Journal`/`Retrier`/`ConditionalSource` ship as parts, but `Keeper::sweep(tick)` that wires them is a DX follow-on. ### Metering and attribution @@ -238,35 +309,37 @@ The WIT is the contract and the Rust SDK is an ergonomics layer, so a Python mod ### Examples -The repository ships one example per persona plus the real thing: an echo venue (accepts any body, settles instantly; the tutorial artefact and the conformance kit's test target), an example module driving it through the chassis, and the CoW adapter as the production reference. The SDK design doc (doc 05) gains the venue persona alongside the existing module persona. +The repository ships the tutorial pair today: `echo-venue` (accepts any body, settles instantly; both the tutorial artefact and the conformance kit's test target, at `modules/examples/echo-venue`) and `echo-client`, the example module driving it. The CoW adapter as the production reference is **not yet built** — `cow-venue` carries only the body/codec and typed client, no adapter component. The SDK design doc (doc 05) gains the venue persona alongside the existing module persona. ## Trust model summary -| Guarantee | Enforced by | Trust required | -|---|---|---| -| Adapter cannot move value | Sandbox: no `identity` import, no keys, scoped transport | None (structural) | -| Spend limits, consent summaries | Guard policy on host-routed, adapter-derived headers | Adapter publisher (curated registry or explicit ENS consent) | -| Theft prevention on signed egress | Guard at the `identity` boundary: EIP-712 and tx payloads are self-describing, the host decodes and simulates them itself | Host only | -| Contract-authorised flows (e.g. EIP-1271 conditional orders) | Consented on-chain when the commitment was created; guests can only materialise what the contract permits | On-chain approval hygiene | -| Threat verdicts | Analyser components under deadline, tiered capabilities | Analyser publisher, proportional to granted tier | +| Guarantee | Enforced by | Trust required | Status | +|---|---|---|---| +| Adapter cannot move value | Sandbox: no `identity` import, no keys, scoped transport | None (structural) | **Shipped** — the one live guarantee; it is structural, not guard-dependent | +| Spend limits, consent summaries | Guard policy on host-routed, adapter-derived headers | Adapter publisher (curated registry or explicit ENS consent) | **Target** — M1 guard is `AllowAllGuard` (no enforcement) | +| Theft prevention on signed egress | Guard at the `identity` boundary: EIP-712 and tx payloads are self-describing, the host decodes and simulates them itself | Host only | **Target** — deferred; `identity` is a 0.3 stub, signing lands with the guard | +| Contract-authorised flows (e.g. EIP-1271 conditional orders) | Consented on-chain when the commitment was created; guests can only materialise what the contract permits | On-chain approval hygiene | Partial — on-chain enforcement is real; the host-side audit/advisory layer is target | +| Threat verdicts | Analyser components under deadline, tiered capabilities | Analyser publisher, proportional to granted tier | **Target** — `analyzer` world unhosted | -Honest limitations, carried deliberately: policy on `offchain` (RWA) assets is adapter-attested rather than host-verified; adapter misbehaviour of the griefing grade (delay, drop, leak) is handled by curation and reputation rather than mechanism; and `wants` is display-grade. +Because only the first row is live in M1, the shipped safety story is exactly "an adapter is a sandbox that cannot move value"; every richer guarantee waits on the egress-guard epic. Honest limitations, carried deliberately even once the guard lands: policy on `offchain` (RWA) assets is adapter-attested rather than host-verified; adapter misbehaviour of the griefing grade (delay, drop, leak) is handled by curation and reputation rather than mechanism; and `wants` is display-grade. ## Sequencing -Each step is independently shippable and the earlier steps are pure wins even if later ones change shape. +Each step is independently shippable and the earlier steps are pure wins even if later ones change shape. Status against the M1 train is marked per step. -1. **Hygiene:** move the `cow_orderbook` backend out of the engine behind the RuntimeTypes extension seam; remove `CowApiHost` from the SDK supertrait. The engine becomes domain-free; shepherd is the distribution that bundles the CoW integration. -2. **SDK chassis:** extract the conditional-commitment machinery (watch sets, gate keys, idempotency journals, retry ledgers) from twap-monitor and ethflow-watcher into venue-generic SDK traits. This delivers watch-tower-parity transportability with no WIT change. -3. **Intent core:** `nexum:value-flow` and `nexum:intent` at 0.x, the `venue-adapter` world, the host router with supervisor reuse, and the CoW adapter built as a component from day one (bundled with the shepherd distribution; bundled is not compiled-in). Alongside it: `nexum-venue-sdk`, the `#[nexum::module]` and `#[nexum::venue]` macros, and the echo-venue example pair as the tutorial artefacts. Port both flagship modules; ethflow proves observe-only and the status-event path. -4. **Guard, first cut:** the `simulate` primitive (local backend), fact assembly, the `analyzer` world, policy binding with override, and the identity-boundary checkpoint for typed-data and transaction signing. -5. **Postage adapter (N=2):** proves `service` wants, non-HTTP transport thinking, and settlement variance. Freeze the vocabulary at 1.0 only after this round-trips submit, status, and policy. -6. **Registry and consent:** the curated adapter/analyser registry, publisher display, the ENS escape hatch, and the wallet-profile consent surface over the embedding API. +1. **Hygiene** *(partial):* move the `cow_orderbook` backend out of the engine behind the RuntimeTypes extension seam; remove `CowApiHost` from the SDK supertrait. **Not done in M1** — the live CoW submit still runs through `shepherd:cow/cow-api`; the clean-break port is deferred (decision Q1 keeps `cow-api` only as the legacy read path meanwhile). +2. **SDK chassis** *(shipped, as parts):* the conditional-commitment machinery (watch sets, gate keys, idempotency journals, retriers) is extracted into venue-generic SDK traits — the keeper. The assembled `Keeper::sweep` orchestrator is still a follow-on. +3. **Intent core** *(shipped, minus the CoW adapter):* `nexum:value-flow@0.1.0` and `nexum:intent@0.1.0`, the `nexum:adapter/venue-adapter` world, the host `PoolRouter` with supervisor reuse, `nexum-venue-sdk`, the `#[nexum::module]` and `#[nexum::venue]` macros, and the `echo-venue`/`echo-client` tutorial pair are all in. **The CoW adapter component is not built** (`cow-venue` is body/codec + client only), and the flagship-module port onto the generic seam has not happened — twap/ethflow still run the legacy path. +4. **Guard, first cut** *(deferred — the egress-guard epic):* the `simulate` primitive (local backend), fact assembly, the `analyzer` world, policy binding with override, and the identity-boundary checkpoint. **None shipped**; M1 has the `AllowAllGuard` no-op only (decision R3), and identity signing lands here too (decision 7). +5. **Postage adapter (N=2)** *(deferred):* proves `service` wants, non-HTTP transport thinking, and settlement variance. Note: since nothing is pinned, the vocabulary carries no 1.0 freeze this must precede — N=2 is a design-confidence gate, not a compatibility one. +6. **Registry and consent** *(deferred):* the curated adapter/analyser registry, publisher display, the ENS escape hatch, and the wallet-profile consent surface over the embedding API. ## Open questions -- **Vocabulary freeze discipline:** `nexum:value-flow` becomes forward-compatibility-critical for three consumers at once. The N=2 gate (step 5) is the guard, but the versioning policy for post-1.0 additions (new asset variants) needs its own note. +- **Vocabulary freeze discipline:** `nexum:value-flow` is meant to become forward-compatibility-critical for three consumers, but today only intent headers consume it and **nothing is pinned** — all WIT versions are pre-release cruft normalizing to a single `@0.1.0` at the true initial release (a "breaking" change is an internal recompile + train fold, not a wire break). So the near-term concern is hygiene (named records for the ERC tuples, a codec version discriminator), not a freeze policy. The N=2 gate (step 5) is a design-confidence gate, not a compatibility one. +- **The opaque-status destructuring contract (decision Q2):** the exact wording and versioning scheme of the documented contract the host `event` stream commits to for its opaque status bytes is still to be pinned. +- **The install-time handshake key (decision R7):** the precise manifest key name (`body_version` vs a version-set field) and the supported-set match semantics for `Supervisor::install` are still to be pinned. - **Analyser composition:** multiple analysers with overlapping findings need aggregation rules (max severity wins is the obvious start) and a story for contradictory verdicts. - **A `tx` venue:** transactions are covered by the guard at the identity boundary, not modelled as an intent venue. Whether a transaction-shaped venue adapter (batching, private orderflow) is ever worth registering stays open; the policy hooks are shaped so it could be. - **Adapter reputation:** beyond curation, whether observed adapter behaviour (submission latency, status accuracy) feeds a local score. -- **A read-only venue `query` verb:** quotes and venue metadata have no consumer among the flagship modules (app-data travels as a hash; see the adapter section), so the verb waits for a strategy that needs it. When it lands it is guard-free, because reads are not egress. +- **A read-only venue `query` verb / quoting:** quotes and venue metadata have no consumer among the flagship modules (app-data travels as a hash; see the adapter section), and 0.1 ships submit/status/cancel only — no quote. The verb waits for a strategy that needs it; when it lands it is guard-free, because reads are not egress. Because nothing is pinned (decision Q5/8), it can be added whenever a design partner arrives, at the cost of an internal recompile, not a wire break. diff --git a/docs/adr/0010-venues-as-adapter-components.md b/docs/adr/0010-venues-as-adapter-components.md index 3c01ac52..36461f96 100644 --- a/docs/adr/0010-venues-as-adapter-components.md +++ b/docs/adr/0010-venues-as-adapter-components.md @@ -1,10 +1,25 @@ --- -status: proposed +status: accepted supersedes: ADR-0005 (compiled-in cow-api backend) +reconciled: 2026-07-15 (against the shipped M1 tree + the 7 platform decisions of 2026-07-14) --- # Venues are dynamically installed adapter components; CoW becomes the first adapter +> **M1 status — shipped and vindicated.** The core of this ADR is real in the M1 train: +> the `venue-adapter` component kind, `nexum:intent@0.1.0` + `nexum:value-flow@0.1.0`, +> the host `PoolRouter`, `nexum-venue-sdk` + the `#[nexum::venue]` macro, the +> `nexum-venue-test` conformance kit, and the `echo-venue` reference adapter that proves the +> seam end to end. What is **not** yet built: a concrete CoW **adapter component** (the +> `cow-venue` crate carries the body/codec, composable-order, `classification`, and typed +> `client` slices but no `cdylib`/adapter slice — grep for `export_venue_adapter!`/`#[venue]` +> in it is empty), and the identity-boundary guard this ADR leans on for theft prevention. +> That guard is ADR-0012, deferred wholesale to the egress-guard epic; M1 ships an advisory +> `AllowAllGuard` no-op (decision R3). The live CoW submit path still runs through the legacy +> `shepherd:cow/cow-api` event-module extension, not through an adapter. See +> `docs/design/venue-platform-architecture.md` for the full reconciliation and the seven +> decisions folded in below. + ## Context The engine's only domain-specific code is the CoW integration: the `shepherd:cow/cow-api` WIT interface and the `cow_orderbook` host backend (ADR-0005). A CoW order is one instance of a general shape: an intent to give value for value, submitted to a venue. The next domains on the roadmap are a Swarm postage purchase and an off-chain marketplace, not further ERC-20 swap venues, so a "trading pair" abstraction is the wrong altitude; the abstraction axis is the venue itself. @@ -18,22 +33,24 @@ Two facts about the existing modules bound the design from below. Strategy is ve ## Decision -A venue integration is a **venue adapter**: a sandboxed wasm component, distributed and consented through the same pipeline as modules (ENS discovery, Swarm fetch, hash verification, manifest, supervision), implementing a new `venue-adapter` world: +A venue integration is a **venue adapter**: a sandboxed wasm component, distributed and consented through the same pipeline as modules (ENS discovery, Swarm fetch, hash verification, manifest, supervision), targeting exactly one world — the shipped `nexum:adapter/venue-adapter`: + +- Imports (shipped): the scoped Nexum host transport it needs — `nexum:host/chain` and `nexum:host/messaging` — plus `wasi:http`, which is **linked separately** and gated per-adapter by the `[[adapters]].http_allow` allowlist in `engine.toml` (the same way `event-module` treats outbound HTTP). Time and randomness are ambient `wasi:clocks`/`wasi:random`. Never `identity`, never keys, no `local-store`/`remote-store`/`logging`. The adapter linker withholds everything else, so an adapter that reaches for it fails to instantiate. `ADAPTER_CAPABILITIES = ["chain", "messaging"]` in `manifest/capabilities.rs` is the source of truth. +- Exports (shipped, `nexum:intent/adapter@0.1.0` plus the world's own `init`): `init(config)`, `derive-header(body) -> intent-header`, `submit(body) -> submit-outcome`, `status(receipt)`, `cancel(receipt)`. `submit` returns a `submit-outcome` **variant** — `accepted(receipt)` for a venue that holds the intent, or `requires-signing(unsigned-tx)` for an on-chain-settlement (ethflow-style) venue that has no receipt until the host signs and sends a transaction. That variant is present from day one precisely so bolting on the signing path later would not break every deployed module. -- Imports: narrow, manifest-declared, per-adapter-scoped transport (`http` to the venue's endpoints, `messaging` on its topics, read-only `chain`). Never `identity`, never keys. -- Exports: `derive-header(body) -> intent-header`, `submit(body) -> receipt`, `status(receipt)`, `cancel(receipt)`. +Per platform decision **Q1 (2026-07-14): no venue-specific host interfaces.** An adapter is transport-only over the *generic* Nexum host set + `wasi:http`; the host interface set is kept ample enough that venues need nothing venue-specific. There is no extension-namespace machinery in `synthesize_venue` or the adapter linker. `shepherd:cow/cow-api` survives **only** as the legacy `event-module` read path (the live CoW submit still runs through it), **not** as an adapter capability — an adapter cannot import it. -Strategy modules import a minimal `nexum:intent/pool` interface (`submit`/`status`/`cancel` keyed by venue id, body as opaque bytes). Bodies carry their own chain routing (a multichain adapter resolves per-chain endpoints, as the current backend does), and the derived header's `settlement` field exposes the choice to policy. The host routes pool calls to the installed adapter, calls `derive-header`, runs guard policy (ADR-0011) on the derived header, and only then forwards to the adapter's `submit`. Headers are always host-obtained from the adapter, never module-supplied, so a module cannot understate what it gives away. +Strategy modules import a minimal `nexum:intent/pool` interface (`submit`/`status`/`cancel` keyed by venue id, body as opaque bytes; `pool` is a declared module capability, `INTENT_CAPABILITIES = ["pool"]`). Bodies carry their own chain routing (a multichain adapter resolves per-chain endpoints, as the current backend does), and the derived header's `settlement` field exposes the choice to policy. The host `PoolRouter` resolves the venue id to the installed adapter, gates the caller's quota, calls `derive-header`, runs the guard seam on the derived header, and only then forwards to the adapter's `submit`. Headers are always host-obtained from the adapter, never module-supplied, so a module cannot understate what it gives away. **M1 caveat:** the guard is `AllowAllGuard`, an advisory no-op (ADR-0012 / decision R3); the router's `derive → guard → submit` shape is real, but the checkpoint has no teeth yet, and it inspects the adapter's *own* derived header rather than the settled bytes. A venue is normatively defined by language-neutral artefacts, not by a crate: the borsh body schema per version, golden vectors (body bytes and the expected derived header), and the submission error-classification table as data. The venue author's Rust crate (body types and codec, typed client with retry classification, adapter implementation, behind feature slices) is the first-class implementation of that specification, not its definition, so non-Rust module authors target the venue from generated WIT bindings plus the published schema. Modules never link against adapters directly: every hop is module to host to adapter, which is what keeps policy interposed, fuel attributed per store, and an adapter trap from poisoning the calling module. -Intent status flows back through the existing event mechanism as a new event variant; adapters poll or subscribe per their transport, and modules are transport-blind. +Intent status flows back through the existing event mechanism; adapters poll or subscribe per their transport, and modules are transport-blind. Per decision **Q2/R6 (2026-07-14)**, the host `event` stream carries **opaque status bytes** with a documented, versioned destructuring contract — *not* a typed `intent-status` case borrowed from `nexum:intent`. That decoupling drops the `nexum-host/types.wit` `use nexum:intent/types.{intent-status}` coupling, so a new lifecycle case in the intent ontology is no longer a breaking change to the host that recompiles every event-module. -The typed ontology lives in a shared, egress-neutral `nexum:value-flow` types package (assets, amounts, settlement domains) plus `nexum:intent` (header, status, receipt). Venue body schemas are per-venue, typed guest-side in venue SDK crates and at `derive-header`, so adding a venue is an adapter release plus an SDK crate, not a host release and not a WIT bump of a closed body variant. +The typed ontology lives in a shared, egress-neutral `nexum:value-flow@0.1.0` types package (assets, amounts, settlement domains) plus `nexum:intent@0.1.0` (header, status, receipt, `submit-outcome`, `venue-error`). `nexum:intent` deliberately does **not** depend on `nexum:host`, so the adapter contract's freeze cadence stays independent of host versioning. Venue body schemas are per-venue, typed guest-side in venue SDK crates and at `derive-header`, so adding a venue is an adapter release plus an SDK crate, not a host release and not a WIT bump of a closed body variant. Per decision **Q5**, the shipped 0.1 ontology is **EVM-only** as a scoping choice (`auth-scheme` and `unsigned-tx` are EVM-shaped); non-EVM settlement versions into a later revision. Nothing is pinned (see the versioning note below), so that reshape — and quoting, which 0.1 does not have — can land whenever a design partner arrives, at the cost of an internal recompile, not a wire break. Provenance is two-tier: a platform-signed curated registry by default, plus an install-by-ENS escape hatch behind a stronger warning. Adapters are always separate artifacts from strategy modules; a venue and a strategy by the same author are two visible installs. -The CoW integration becomes the first adapter, built as a component from day one and bundled with the shepherd distribution. Bundled is not compiled-in: the same artifact installs dynamically on other hosts. `shepherd:cow/cow-api` and the `cow_orderbook` backend are retired once the port completes. The Swarm postage-purchase adapter is the N=2 proving ground; the `nexum:value-flow` vocabulary does not freeze before it round-trips submit, status, and policy. +The CoW integration is designed to become the first adapter, built as a component and bundled with the shepherd distribution (bundled is not compiled-in: the same artifact installs dynamically on other hosts). **This has not shipped in M1.** `cow-venue` today is a `[lib]` crate of feature slices — `body` (the venue-neutral order/composable body types + borsh `IntentBody` codec), `composable`, `order`, data-table retry `classification`, and a typed `client` — carried by both a future adapter and by strategy modules; it has no `cdylib`/adapter slice. So `shepherd:cow/cow-api` and the `cow_orderbook` backend are **not** retired: they remain the live CoW submit path (`shepherd-sdk/src/cow` assembles `OrderCreation` JSON and bypasses `nexum:intent/pool` entirely). The clean-break port off the legacy surface is deferred. The Swarm postage-purchase adapter is the intended N=2 proving ground; because nothing is pinned, the `nexum:value-flow` vocabulary carries no freeze it must round-trip a second venue before breaking. ## Considered options @@ -46,9 +63,14 @@ The CoW integration becomes the first adapter, built as a component from day one ## Consequences -- The engine loses its last domain-specific code; shepherd is a distribution (engine + bundled CoW adapter), which is what the hygiene refactors around the RuntimeTypes lattice were already moving toward. -- A third-party adapter can misdescribe (`derive-header` lies) or grief (delay, drop, leak order details the venue would see anyway) but cannot move value: no keys, no unscoped transport. Theft prevention does not rest on adapter honesty; it rests on the guard at the identity boundary (ADR-0011) and on-chain approval hygiene. Spend-limit accuracy rests on adapter publisher trust, which is what the curated registry and consent copy manage. -- Strategy stays guest-side per ADR-0006; what leaves modules is encoding, transport, and observation. The SDK gains a venue-generic chassis (watch sets, gate keys, receipt-keyed idempotency, retry classification) extracted from twap-monitor and ethflow-watcher. +- **Shipped.** The `venue-adapter` kind, the two-face (`pool`/`adapter`) intent contract with opaque `list` bodies, the host `PoolRouter`, the venue-author SDK persona (`nexum-venue-sdk` + `#[nexum::venue]` + `nexum-venue-test`), and the `echo-venue` reference adapter are all in the M1 train. The abstraction axis (venue, not trading pair) held: `echo-venue` proves a non-CoW venue is implementable without any CoW code. +- The engine has **not** yet lost its last domain-specific code: `shepherd:cow/cow-api` + the `cow_orderbook` backend remain as the legacy event-module read/submit path (decision Q1 keeps them only in that role, not as an adapter capability). The clean-break to a bundled CoW adapter component is the deferred N=1 port. +- A third-party adapter can misdescribe (`derive-header` lies) or grief (delay, drop, leak order details the venue would see anyway) but cannot move value: no keys, no unscoped transport. Theft prevention does not rest on adapter honesty; it is meant to rest on the guard at the identity boundary (**ADR-0012**) and on-chain approval hygiene. **That guard is deferred to the egress-guard epic**; in M1 the only checkpoint is the advisory `AllowAllGuard`, so the theft-prevention anchor this ADR names is design intent, not a live control. Spend-limit accuracy rests on adapter publisher trust, which is what the curated registry and consent copy manage. +- **Identity signing lands with the guard, later** (decision 7): the `requires-signing(unsigned-tx)` submit outcome is representable in the shipped WIT, but the host `identity` path that would sign it is a 0.3 stub (`accounts() -> Ok(vec![])`), so no adapter can drive a signed settlement in M1. +- **Module↔venue body-schema agreement is an install-time handshake** (decision R7): bodies are opaque `list` with a guest-side version tag, so schema agreement is checked by a `body_version` (or version-set) manifest field that `Supervisor::install` asserts against the adapter's supported set, refusing to boot a mismatched pair. This is a manifest + supervisor change, not WIT-freeze-gated. +- Strategy stays guest-side per ADR-0006; what leaves modules is encoding, transport, and observation. The SDK gains a venue-generic chassis — the `keeper` (watch sets, gate keys, receipt-keyed idempotency, retry classification) — extracted from twap-monitor and ethflow-watcher; its parts ship, though the assembled `Keeper::sweep` orchestrator is still a DX follow-on. +- **`#[nexum::venue]` is the single blessed authoring path** (decision Q6). Today the macro emits the adapter's `Guest` export glue over an inherent `impl` block (as `echo-venue` uses it); the decided target is to have it emit an `impl VenueAdapter` and demote `export_venue_adapter!` to the internal codegen the macro expands to — one path, no public second door. That unification is a Phase-4 DX follow-on. - Two sandbox hops (module, host, adapter) add marshalling per submission. Negligible at order-submission rates. -- Adapters need the module supervision surface (restart, poison, metering) and manifest capability enforcement; both exist and are reused. +- Adapters need the module supervision surface (restart, poison, metering) and manifest capability enforcement; boot/install is reused, though adapters are **not** yet folded into the restart/poison sweeps (a trapped adapter stays dead until process restart — a known post-M1 hardening item). - The venue catalogue is user-gated rather than host-gated: permissionless with consent, which is the platform's general posture. +- **Versioning is pre-release.** Every WIT package version string (`nexum:host@0.2.0`, `nexum:intent@0.1.0`, …) is accumulated cruft, not a compatibility boundary — nothing external pins them, and they normalize to a single `@0.1.0` at the true initial release. Until then a "breaking" WIT change costs only an internal recompile + a train fold, never a wire break. diff --git a/docs/adr/0012-egress-guard-pipeline.md b/docs/adr/0012-egress-guard-pipeline.md index c6280451..2b80067f 100644 --- a/docs/adr/0012-egress-guard-pipeline.md +++ b/docs/adr/0012-egress-guard-pipeline.md @@ -1,9 +1,25 @@ --- -status: proposed +status: deferred (target design; deferred wholesale to the egress-guard epic) +reconciled: 2026-07-15 (against the shipped M1 tree + the 7 platform decisions of 2026-07-14) --- # One egress guard pipeline: intent submissions, typed-data signing, and transaction signing share facts, analysers, and policy +> **M1 status — advisory only; teeth deferred.** None of the pipeline below ships in M1. +> This ADR is the **target** design. What actually ships is an `AllowAllGuard` — a no-op +> `GuardPolicy` on the host `PoolRouter` that admits every egress (`pool_router.rs`). It runs +> on the adapter's *own* `derive-header` output (which `submit` re-decodes independently, so +> it is advisory even in principle — a TOCTOU gap), and it does **not** cover the signing path +> at all: there is no `simulate` primitive, no fact assembly, no `analyzer` world, no policy +> engine, and the `identity` boundary it names as the theft anchor is a 0.3 stub. Per platform +> decision **R3 (2026-07-14)** the M1 guard posture is advisory-only: keep `AllowAllGuard`, +> feature-gate the `pool` import, and document the checkpoint as **not yet a boundary**. The +> real guard — its trust model, its single-decode fix, and *where* it runs — is deferred +> wholesale to the egress-guard epic; per decision 7, **identity signing lands with the guard, +> later**, not before. Read everything below as the design that epic will build, not as +> present behaviour. See `docs/design/venue-platform-architecture.md` §6 (R3) for the +> red-team detail. + ## Context Two requirements arrived together. First, the intent layer (ADR-0010) needs a policy checkpoint: consent, spend limits, and audit on what modules submit to venues. Second, the same engine embeds in a wallet, where EIP-712 typed data and transactions must be decoded, simulated, and analysed for threats before the user signs, and where the threat analysis should be performed by installable wasm components so security vendors ship analysers the way strategy authors ship modules. @@ -14,6 +30,10 @@ A further fact anchors the trust model: EIP-712 typed data and transaction paylo ## Decision +*(Target. The M1 tree ships the `derive → guard → submit` router seam and an `AllowAllGuard` +no-op in that guard slot; everything the Decision describes below is the shape the egress-guard +epic fills in. Where the shipped seam already exists, it is called out.)* + One guard pipeline handles all value egress: ``` @@ -50,8 +70,19 @@ egress event -> fact assembly (decode + simulate) -> analysers (deadline-bounded ## Consequences +### What M1 actually ships (advisory only) + +- **A no-op guard on a real seam.** The host `PoolRouter` implements the `derive → guard → submit` sequence with a `GuardPolicy` trait and an `AllowAllGuard` default that admits every egress. The seam and its quota gate are real and tested (a `DenyGuard` test proves a deny blocks submit after the header is derived), but nothing enforcing is wired in. +- **Advisory, and on the wrong bytes even in principle.** The guard inspects the adapter's *own* `derive-header` output; `submit` re-decodes the body independently, so a buggy or hostile adapter can show a benign `gives` and settle something else (TOCTOU). The fix — pass the derived header *into* submit for a single decode, and move the checkpoint to the signed-`unsigned-tx` boundary — is part of the deferred epic. +- **The signing path is uncovered.** There is no `simulate` primitive, no fact assembly, no `analyzer` world, no policy engine. The `identity` interface exists but its backend is a 0.3 stub (`accounts() -> Ok(vec![])`), so the theft boundary this ADR anchors on is not yet a live control. Per decision 7, identity signing lands *with* the guard. +- **`pool` import feature-gated; boundary documented as absent.** Per decision R3 the honest posture is to keep `AllowAllGuard`, feature-gate the strategy-facing `pool` import, and state plainly that shipping it advertises no boundary yet. + +### What the epic will build (target consequences) + - The theft-prevention anchor is host-only trust: the identity-boundary guard decodes and simulates what it signs regardless of what adapters or modules claim. Spend-limit accuracy on venue submissions remains adapter-publisher trust (ADR-0010); the two layers degrade independently and the trust table in the design doc states both. - Interactive signing acquires a latency budget shared by simulation and analysers. Deadlines are enforced with the existing metering machinery (fuel, epoch interruption); partial verdicts render as "analysis incomplete" per profile. -- The engine grows three surfaces: the `simulate` primitive with a backend seam, the fact-bundle assembly, and the analyser host-call path with deadline scheduling. The analyser world finally gives the experimental `query-module` lineage a shipping consumer. +- The engine grows three surfaces: the `simulate` primitive with a backend seam, the fact-bundle assembly, and the analyser host-call path with deadline scheduling. The `analyzer` world finally gives the experimental `query-module` lineage a shipping consumer (the world is published-but-unhosted today: WIT exists, no linker). +- `GuardPolicy::check` goes sync → async (a breaking trait change) because a real guard needs I/O; it lands with the epic, not during M1. - Verdict aggregation across multiple analysers starts as max-severity-wins; contradictions and reputation are open questions recorded in the design doc. - Every embedder profile (server, wallet, super-app) must declare its fail-open/fail-closed matrix; the embedding API exposes verdicts and consent hooks so wallets render them natively. +- **Related M1 hardening the epic subsumes:** guard-deny must charge the caller's quota (else a denied module busy-loops the router — a latent DoS while only `AllowAllGuard` ships); `http` egress is allowlist-gated only and escapes the compile-time capability guarantee, so it needs either the world guarantee or a loud doc caveat; and adapters must fold into the restart/poison sweeps before production multi-venue.