From 1bb26758eda30d14d0429b4ef28aed5494d95307 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:18:18 +0000 Subject: [PATCH 001/139] 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 002/139] 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 003/139] 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 004/139] 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 005/139] 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 006/139] 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 007/139] 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 008/139] 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 009/139] 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 010/139] 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 011/139] 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 012/139] 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 013/139] 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 014/139] 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 015/139] 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 016/139] 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 017/139] 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 018/139] 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 019/139] 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 020/139] 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 021/139] 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 022/139] 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 023/139] 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 024/139] 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 c116a4852ee284afd0d632b6c27255a76b680b37 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 10:28:05 +0000 Subject: [PATCH 025/139] =?UTF-8?q?docs:=20multi-chain=20deployment=20patt?= =?UTF-8?q?erns=20=E2=80=94=20Mainnet/Gnosis/Arbitrum/Base=20(#351)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: multi-chain deployment patterns — Mainnet/Gnosis/Arbitrum/Base (#124) New docs/deployment/multi-chain.md covering the verbatim M5 grant deliverable: per-chain [chains.] + env-var wiring, CoW orderbook URL slugs per network, per-chain contract addresses (CREATE2-stable vs EthFlow per-network caveat), the [[subscription]] duplication pattern with twap-monitor and ethflow-watcher examples, event topic reference, and resource sizing guidance. * docs(multi-chain): correct the EthFlow address claim and scope statements Review catches: - The EthFlow section claimed the address is per-network and told operators to hunt for a different mainnet address. The current production deployment is address-identical on every supported chain (cowprotocol's ETH_FLOW_PRODUCTION, from networks.prod.json) - the old text would have steered a mainnet port toward the legacy v1.0.0 deployment and missed every OrderPlacement event. The real caveat is narrower and now stated as such: sameness is not a CREATE2 guarantee, legacy per-version deployments exist, verify on version bumps. - "every chain the orderbook supports" was false (the Chain enum also carries BNB, Polygon, Avalanche, Linea, Plasma); the table is scoped to the chains this repo's modules target. - Dropped the unsupported "Gnosis volume roughly equal to Mainnet" claim; require_ws violations are an ERROR log, not a warning; the RPC recommendation names a rate requirement instead of a specific provider's free tier. --------- Co-authored-by: Luiz Gustavo Abou Hatem de Liz --- docs/deployment/multi-chain.md | 296 +++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 docs/deployment/multi-chain.md diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md new file mode 100644 index 00000000..ed627e11 --- /dev/null +++ b/docs/deployment/multi-chain.md @@ -0,0 +1,296 @@ +# Multi-chain deployment patterns + +This guide covers running Shepherd against multiple EVM chains simultaneously. +The engine dispatches each module only to the chains it subscribes to, so a +single `nexum` process can serve modules watching Mainnet, Gnosis Chain, +Arbitrum One, and Base at the same time. + +--- + +## Chain support matrix + +The table lists the chains this repo's modules target. The CoW Protocol +orderbook supports more (the `cowprotocol` crate's `Chain` enum also carries +BNB, Polygon, Avalanche, Linea, and Plasma); the same wiring pattern applies +to any of them. Shepherd can subscribe to block and log events on any EVM +chain; add only the chains your modules actually use. + +| Chain | Chain ID | Orderbook slug | Barn (staging) | Notes | +|-------|----------|----------------|----------------|-------| +| Ethereum Mainnet | 1 | `mainnet` | ✓ | Primary production chain | +| Gnosis Chain | 100 | `xdai` | ✓ | Second-most-active CoW deployment | +| Base | 8453 | `base` | ✗ | | +| Arbitrum One | 42161 | `arbitrum_one` | ✓ | | +| Sepolia | 11155111 | `sepolia` | ✓ | Recommended testnet for soak runs | + +The CoW Protocol orderbook is not deployed on every EVM chain. A `[chains.]` +entry in `engine.toml` for a chain with no orderbook deployment will open block +and log subscriptions, but any module that calls `cow-api` will fail at runtime. + +--- + +## `engine.toml` — per-chain RPC wiring + +Add one `[chains.]` table per chain. Log-subscription modules require a +WebSocket (`wss://`) transport; request-only modules can use HTTP. + +```toml +[chains.1] # Ethereum Mainnet +rpc_url = "${MAINNET_RPC_URL}" + +[chains.100] # Gnosis Chain +rpc_url = "${GNOSIS_RPC_URL}" + +[chains.8453] # Base +rpc_url = "${BASE_RPC_URL}" + +[chains.42161] # Arbitrum One +rpc_url = "${ARBITRUM_RPC_URL}" + +[chains.11155111] # Sepolia (soak / staging) +rpc_url = "${SEPOLIA_RPC_URL}" +``` + +`${VAR}` tokens are substituted at engine boot from environment variables. A +missing variable fails fast with the exact variable name. In Docker Compose, +forward the variables from the host `.env` file: + +```yaml +# docker-compose.yml (engine service environment section) +environment: + MAINNET_RPC_URL: + GNOSIS_RPC_URL: + BASE_RPC_URL: + ARBITRUM_RPC_URL: + SEPOLIA_RPC_URL: +``` + +### Opt out of the WebSocket requirement + +By default the engine logs an ERROR at boot when a chain is configured with an +HTTP URL because `block` and `log` subscriptions need WebSocket. If a chain is +used only for `chain::request` (poll-only modules with no `[[subscription]]`), +suppress it: + +```toml +[chains.1] +rpc_url = "https://eth.llamarpc.com" +require_ws = false +``` + +--- + +## CoW Protocol orderbook URLs + +The engine's `cow-api` extension resolves the orderbook URL automatically from +the chain ID using the canonical `https://api.cow.fi//` pattern. No +extra config is needed for production. + +Override individual chains to point at a staging ("barn") instance or a local +mock: + +```toml +[extensions.cow.orderbook_urls] +# Point chain 11155111 at the barn (staging) orderbook: +11155111 = "https://barn.api.cow.fi/sepolia/" + +# Point chain 1 at a local Wiremock during integration testing: +1 = "http://localhost:9999/" +``` + +**Canonical URLs by chain:** + +| Chain | Production URL | Barn (staging) URL | +|-------|---------------|-------------------| +| Mainnet (1) | `https://api.cow.fi/mainnet/` | `https://barn.api.cow.fi/mainnet/` | +| Gnosis (100) | `https://api.cow.fi/xdai/` | `https://barn.api.cow.fi/xdai/` | +| Base (8453) | `https://api.cow.fi/base/` | — | +| Arbitrum One (42161) | `https://api.cow.fi/arbitrum_one/` | `https://barn.api.cow.fi/arbitrum_one/` | +| Sepolia (11155111) | `https://api.cow.fi/sepolia/` | `https://barn.api.cow.fi/sepolia/` | + +--- + +## Contract addresses + +### CREATE2-stable (identical on every chain) + +These addresses are the same across all supported chains: + +| Contract | Address | +|----------|---------| +| `GPv2Settlement` | `0x9008D19f58AAbD9eD0D60971565AA8510560ab41` | +| `GPv2VaultRelayer` | `0xC92E8bdf79f0507f65a392b0ab4667716BFE0110` | +| `ComposableCoW` | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | + +### EthFlow + +The **current** production EthFlow deployment is address-identical on every +chain CoW Protocol supports (the `cowprotocol` crate pins it as +`ETH_FLOW_PRODUCTION`, sourced from +[`cowprotocol/ethflowcontract`](https://github.com/cowprotocol/ethflowcontract) +`networks.prod.json`): + +``` +0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC (all supported chains) +``` + +This is what `modules/ethflow-watcher/module.toml` wires for Sepolia, and the +same value carries to Mainnet, Gnosis Chain, Arbitrum One, and Base today. + +Unlike ComposableCoW, however, the sameness is **not a CREATE2 guarantee** - +EthFlow has legacy per-version deployments at other addresses (e.g. the +v1.0.0 contracts), and a future version may land at a new address on a +per-chain schedule. When porting `ethflow-watcher` to a new chain or bumping +the contract version, verify the `[[subscription]] address` against +`networks.prod.json` for that chain rather than assuming the constant holds. + +--- + +## Module manifests — the `[[subscription]]` duplication pattern + +A module subscribes per chain. To watch the same event on multiple chains, +declare one `[[subscription]]` block per chain. The engine opens a separate +stream for each and routes dispatches independently. + +### twap-monitor on Mainnet + Gnosis + +```toml +# module.toml for twap-monitor (multi-chain) + +# ComposableCoW.ConditionalOrderCreated on Mainnet +[[subscription]] +kind = "chain-log" +chain_id = 1 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# New-block ticks on Mainnet (drives the poll loop) +[[subscription]] +kind = "block" +chain_id = 1 + +# ComposableCoW.ConditionalOrderCreated on Gnosis Chain +[[subscription]] +kind = "chain-log" +chain_id = 100 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# New-block ticks on Gnosis Chain +[[subscription]] +kind = "block" +chain_id = 100 +``` + +The module's `on_event` receives every event tagged with its `chain_id`; use +the chain ID to dispatch to the correct `chain::request` target and the correct +`cow-api` submission path. + +### ethflow-watcher on Mainnet + Sepolia + +```toml +# module.toml for ethflow-watcher (multi-chain) + +# CoWSwapEthFlow.OrderPlacement on Mainnet +# IMPORTANT: verify this address against cowprotocol/ethflowcontract +# networks.prod.json before deploying — EthFlow is NOT CREATE2-stable. +[[subscription]] +kind = "chain-log" +chain_id = 1 +address = "" +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# CoWSwapEthFlow.OrderPlacement on Sepolia +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" +``` + +--- + +## Event topic reference + +These are keccak256 hashes of the event signatures. They are the same on every +chain; only the contract `address` changes for EthFlow. + +| Event | Topic-0 | +|-------|---------| +| `ComposableCoW.ConditionalOrderCreated(address,(address,bytes32,bytes))` | `0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361` | +| `CoWSwapEthFlow.OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)` | `0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9` | + +--- + +## Resource sizing (per additional chain) + +Each new chain adds: + +- **1 block subscription** (always-on WS stream, ~zero CPU when idle). +- **N log subscriptions**, where N = number of modules with a `chain-log` + subscription on that chain. +- **M `eth_call`s per block** for polling modules (e.g. TWAP), where M scales + linearly with the number of active registered orders on that chain. + +RPC provider sizing: budget **≥ 25 sustained req/s per chain** (a paid +Alchemy / Infura / QuickNode tier - free tiers throttle `eth_subscribe` +under exactly this load). Dedicated endpoints per chain are preferable to +shared-rate plans when running `block` subscriptions simultaneously. + +Monitor `shepherd_chain_request_total{outcome="err"}` per `chain_id` — a +sustained rate above 5% on any chain indicates RPC degradation. + +--- + +## Full multi-chain `engine.docker.toml` example + +```toml +[engine] +state_dir = "/var/lib/shepherd" +log_level = "info" + +[engine.metrics] +enabled = true +bind_addr = "0.0.0.0:9100" + +[chains.1] +rpc_url = "${MAINNET_RPC_URL}" + +[chains.100] +rpc_url = "${GNOSIS_RPC_URL}" + +[chains.8453] +rpc_url = "${BASE_RPC_URL}" + +[chains.42161] +rpc_url = "${ARBITRUM_RPC_URL}" + +[chains.11155111] +rpc_url = "${SEPOLIA_RPC_URL}" + +[extensions.cow.orderbook_urls] +# Uncomment to override individual chains with barn or a local mock: +# 11155111 = "https://barn.api.cow.fi/sepolia/" + +[[modules]] +path = "/opt/shepherd/modules/twap_monitor.wasm" +manifest = "/opt/shepherd/manifests/twap-monitor.toml" + +[[modules]] +path = "/opt/shepherd/modules/ethflow_watcher.wasm" +manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" +``` + +--- + +## See also + +- [`docs/deployment.md`](../deployment.md) — `engine.toml` reference and + single-module quickstart +- [`docs/production.md`](../production.md) — systemd, Docker, RPC provider + recommendations, and alerting rules +- [`docs/deployment/docker.md`](./docker.md) — container image layout +- [`modules/twap-monitor/module.toml`](../../modules/twap-monitor/module.toml) + — canonical subscription example +- [`modules/ethflow-watcher/module.toml`](../../modules/ethflow-watcher/module.toml) + — EthFlow subscription with per-chain address caveat From 04413b1ea6d42a635623ff9091f484613fbe42f2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 10:29:48 +0000 Subject: [PATCH 026/139] =?UTF-8?q?docs:=20stale-reference=20sweep=20?= =?UTF-8?q?=E2=80=94=20engine=20rename,=20Dockerfile,=20ADR-0007=20errata?= =?UTF-8?q?=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: stale-reference sweep — engine rename, Dockerfile, ADR-0007 errata (#123, #108, #62) - deployment.md: replace the "planned for M5" Dockerfile sketch with a pointer to the real Dockerfile + docker.md; update nexum-runtime log directive examples - qa-signoff.md: nexum-engine → nexum-runtime in crate-dependency row - adr/0007: add errata noting the nexum-engine → nexum-runtime rename and the [patch.crates-io] retirement now that cowprotocol 0.1.0 is on crates.io * docs: fix the sweep's own stale claims - cowprotocol override, production.md 3.1 Review catches on the stale-reference sweep itself: - ADR-0007 errata and sdk.md Versioning both claimed cowprotocol is a clean 0.1.0 registry dependency "with no git override". The branch's own Cargo.toml pins 0.2.0 WITH an active [patch.crates-io] override to nullislabs/cow-rs (pending the hash-only OrderCreationAppData constructor release). Both now describe that state and point at the patch-block comment as the source of truth. - production.md 3.1 still shipped the "interim" hand-rolled Dockerfile recipe issue #123 flagged; replaced with a pointer to the real root Dockerfile and docs/deployment/docker.md. - 02-modules-events-packaging.md: un-garble the parenthetical the earlier rename made self-contradictory ("was module.toml in earlier drafts" -> "was nexum.toml"). --------- Co-authored-by: Luiz Gustavo Abou Hatem de Liz --- docs/02-modules-events-packaging.md | 2 +- .../0007-upstream-protocol-logic-to-cow-rs.md | 2 + docs/deployment.md | 29 +++++------- docs/production.md | 47 ++++--------------- docs/qa-signoff.md | 2 +- docs/sdk.md | 11 +++-- 6 files changed, 30 insertions(+), 63 deletions(-) diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 430d4114..dc8dfb87 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -6,7 +6,7 @@ A module is distributed as a **bundle** - a WASM component plus a manifest that ### Manifest (`module.toml`) -Every module ships with a manifest. The file is named `module.toml` in 0.2 (was `module.toml` in earlier drafts; per [ADR-0001](adr/0001-engine-toml-separate-from-nexum-toml.md) the operator/module split is now explicit). +Every module ships with a manifest. The file is named `module.toml` in 0.2 (was `nexum.toml` in earlier drafts; per [ADR-0001](adr/0001-engine-toml-separate-from-nexum-toml.md) the operator/module split is now explicit). ```toml [module] diff --git a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md index 3a828f5e..9ff06fa3 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -44,3 +44,5 @@ Lower-priority follow-ons (`OrderUid::from_slice`, retry middleware on `OrderBoo - Guest modules consume `cowprotocol` types directly (gated on the wasm32 feature in item 3). The `shepherd-sdk` crate in M3 may add ergonomic wrappers on top, but those live on the guest side, not behind a WIT boundary. - A follow-on Bleu module - the Rust-side equivalent of `cowprotocol/refunder` (permissionless `invalidateOrder` triggering for expired EthFlow orders) - becomes natural to ship once an ethflow-watcher module lands. Out of scope for M2 but explicitly enabled by the same primitives. - TWAP polling logic (decode `ConditionalOrderCreated`, eth_call `getTradeableOrderWithSignature`, decode return, build `OrderCreation`) and EthFlow event decoding stay entirely in guest module code. The `cowprotocol` crate provides only the types and the orderbook client; the strategy is the module's. + +_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor. The `cowprotocol` crate now publishes to crates.io (the workspace pins `0.2.0`), so the PR-#5-head consumption model above is historical; a `[patch.crates-io]` git override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor - see the comment above the patch block in the root `Cargo.toml` for the current state._ diff --git a/docs/deployment.md b/docs/deployment.md index 2525e2f9..955e0d96 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -151,26 +151,19 @@ For systemd-style production runs, see `docs/production.md`. ## Docker -A reference Dockerfile + Compose file is planned for M5. -Until that lands, build manually: - -```dockerfile -# (sketch — full Dockerfile is planned for M5) -FROM rust:1.91 as build -COPY . /src -WORKDIR /src -RUN cargo build --release -p nexum-cli -RUN rustup target add wasm32-wasip2 \ - && cargo build --target wasm32-wasip2 --release \ - -p twap-monitor -p ethflow-watcher - -FROM gcr.io/distroless/cc-debian12 -COPY --from=build /src/target/release/nexum /usr/local/bin/ -COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /modules/ -COPY engine.toml /etc/shepherd/engine.toml -ENTRYPOINT ["/usr/local/bin/nexum"] +A `Dockerfile` + `docker-compose.yml` ship at the repo root. See +[`docs/deployment/docker.md`](deployment/docker.md) for the full +container workflow. The quick start: + +```sh +cp .env.example .env +$EDITOR .env # paste wss:// RPC URLs +docker compose up -d ``` +The image is published to +`ghcr.io/nullislabs/shepherd:` on every merged commit to `main`. + Mount the `state_dir` as a volume so the redb file survives container restarts. diff --git a/docs/production.md b/docs/production.md index c2b22499..f845f80a 100644 --- a/docs/production.md +++ b/docs/production.md @@ -140,45 +140,14 @@ journalctl -u shepherd -f --output=json | jq '.MESSAGE | fromjson?' ## 3. Container deploy: Docker Compose -> **Status note:** the official Dockerfile is tracked as a -> separate issue. Until it lands, build the image locally with -> the multi-stage recipe below; the Compose file is forward- -> compatible with the eventual published image. - -### 3.1 Dockerfile (interim) - -```dockerfile -# syntax=docker/dockerfile:1.6 -FROM rust:1.86-slim-bookworm AS build -WORKDIR /src -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config libssl-dev cmake clang \ - && rm -rf /var/lib/apt/lists/* -RUN rustup target add wasm32-wasip2 -COPY . . -RUN cargo build -p nexum-cli --release -# Build all 5 modules. Add yours here. -RUN cargo build -p twap-monitor --target wasm32-wasip2 --release \ - && cargo build -p ethflow-watcher --target wasm32-wasip2 --release \ - && cargo build -p price-alert --target wasm32-wasip2 --release \ - && cargo build -p balance-tracker --target wasm32-wasip2 --release \ - && cargo build -p stop-loss --target wasm32-wasip2 --release - -FROM debian:bookworm-slim AS runtime -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tini \ - && rm -rf /var/lib/apt/lists/* \ - && useradd -r -s /usr/sbin/nologin -d /var/lib/shepherd shepherd \ - && install -d -o shepherd -g shepherd /var/lib/shepherd -COPY --from=build /src/target/release/nexum /usr/local/bin/ -COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /opt/shepherd/modules/ -COPY --from=build /src/modules /opt/shepherd/manifests -USER shepherd -WORKDIR /var/lib/shepherd -EXPOSE 9100 -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] -CMD ["--engine-config", "/etc/shepherd/engine.toml"] -``` +### 3.1 Dockerfile + +The official multi-stage `Dockerfile` ships at the repo root - it +builds the `nexum` binary, compiles all five module wasms, and runs as +a non-root user under `tini`. Build it with `docker build -t shepherd .` +or let the root `docker-compose.yml` build it for you. The full image +reference (published tags, pinning, .env wiring) is in +[`docs/deployment/docker.md`](deployment/docker.md). ### 3.2 docker-compose.yml diff --git a/docs/qa-signoff.md b/docs/qa-signoff.md index 808d03e6..8953a78b 100644 --- a/docs/qa-signoff.md +++ b/docs/qa-signoff.md @@ -12,7 +12,7 @@ | `cargo test --workspace` | ✅ | 145 host tests + 1 doctest passing. | | Em-dashes in `crates/`, `modules/`, `docs/` | ✅ | 0. One was in `price-alert/strategy.rs:4` (mine), fixed. | | Em-dashes in `wit/**.wit` | ⚠ | 3 in mfw78's M1 prose. Intentionally left alone; flag for him in upstream review. | -| `warn(unused_crate_dependencies)` on every crate root | ✅ | sdk, sdk-test, nexum-engine, twap, ethflow, price-alert, balance-tracker, stop-loss. | +| `warn(unused_crate_dependencies)` on every crate root | ✅ | sdk, sdk-test, nexum-runtime, twap, ethflow, price-alert, balance-tracker, stop-loss. | | WASM build (`wasm32-wasip2 --release`) | ✅ | All 5 modules build. Sizes: twap 314 KB, ethflow 282 KB, stop-loss 311 KB, price-alert 215 KB, balance-tracker 102 KB. | | String-wrapped errors outside WIT boundary | ✅ | All hits in `crates/nexum-runtime/src/host/impls/*` (FFI boundary - exception per rust-idiomatic skill). No leaks in SDK or modules. | diff --git a/docs/sdk.md b/docs/sdk.md index c8ee0f65..14d21da7 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -124,7 +124,10 @@ The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` and `crates/shepherd-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. -The `cowprotocol` crate is published to crates.io at `0.1.0`; the -workspace declares `cowprotocol = "0.1.0"` in `[workspace.dependencies]` -with no `[patch.crates-io]` or git overrides. Module Cargo.toml files -that inherit from the workspace pick it up automatically. +The `cowprotocol` crate is published to crates.io; the workspace +declares `cowprotocol = "0.2.0"` in `[workspace.dependencies]`, with a +temporary `[patch.crates-io]` git override to `nullislabs/cow-rs` +pending a release that ships the hash-only `OrderCreationAppData` +constructor (see the comment above the patch block in the root +`Cargo.toml`). Module Cargo.toml files that inherit from the workspace +pick it up automatically. From 0e160dcd526d404862303d293431010a4dab9e11 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 10:36:30 +0000 Subject: [PATCH 027/139] test(event-loop): event ordering and graceful shutdown tests (#353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(event-loop): add ordering and graceful-shutdown tests (#56, #58) Four new tests covering the two open issues: Issue #56 — event ordering guarantees: - `open_block_streams_opens_one_task_per_chain`: structural check that N chains → N independent reconnect tasks, documenting the per-chain task isolation that prevents stream-A starvation from blocking stream-B events. - `open_chain_log_streams_opens_one_task_per_subscription`: same structural check for chain-log subscriptions. - `run_delivers_block_and_chain_log_events_without_starvation`: integration test over a zero-module supervisor that pre-queues one block and one chain-log event, runs the loop, and verifies it drains without hanging — the `biased` select delivers both event kinds in the same session. - `harness_delivers_block_and_chain_log_events_without_starvation`: E2E test over the real example module (skipped when wasm not built) that pushes a block and a chain-log and waits for both dispatch log lines. Issue #58 — graceful shutdown completion: - `run_drains_reconnect_tasks_cleanly_on_shutdown`: verifies `run()` awaits `tasks.shutdown()` before returning — reconnect tasks observe a closed channel (ReceiverGone) rather than an abort, proving clean teardown. - `harness_shutdown_after_push_completes_cleanly`: E2E test that calls shutdown immediately after a block push; `wait()` returning Ok proves no partial dispatch corrupts module state. * test(event-loop): make the #56/#58 tests falsifiable Review follow-ups - three of the six tests could not fail for the property they claimed to verify: - run() now returns its (blocks, chain_logs) dispatch tally (the same numbers the shutdown log line reports). The no-starvation test asserts both queued events were drained instead of merely observing that run() terminates on the shutdown timer - a broken or reordered select arm now leaves a count at 0 and fails. - New direct test: a reconnect task parked on a dropped receiver exits with TaskExit::ReceiverGone on its own, joined on the bare handle. The previous test claimed to verify this through TaskSet::shutdown, which aborts every handle before joining, so ReceiverGone was never exercised; its doc comment now states the abort-then-join contract it actually proves. - harness_shutdown_after_push_completes_cleanly raced shutdown against dispatch and accepted either outcome. Replaced with harness_shutdown_preserves_completed_dispatch: prove the dispatch completed, tear down, then re-read the log record after teardown. - The harness no-starvation test now queues both event kinds before awaiting either, so the biased select genuinely arbitrates two ready streams instead of replaying the two pre-existing sequential tests. - New harness_delivers_blocks_in_push_order: blocks 7,8,9 pushed back-to-back must surface in the module's log records in that order - issue #56's ordering guarantee, previously untested. - run()-level tests wrapped in tokio::time::timeout so a shutdown-path hang fails in 10 s instead of stalling the suite to the CI job limit. * test(event-loop): adapt test call sites to the ChainLogSub struct The train base moved under the branch again: open_chain_log_streams now takes Vec (resume cursors + max_lookback) instead of (module, chain, filter) tuples, and the rebase left the two test call sites building tuples. Construct ChainLogSub with no cursor and no lookback - these tests exercise stream/task plumbing, not resume. --------- Co-authored-by: Luiz Gustavo Abou Hatem de Liz --- .../nexum-runtime/src/runtime/event_loop.rs | 101 ++++++++++++- crates/nexum-runtime/src/supervisor/tests.rs | 118 +++++++++++++++ .../nexum-runtime/src/test_utils/harness.rs | 135 ++++++++++++++++++ 3 files changed, 351 insertions(+), 3 deletions(-) diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index ad3fc0c4..89d697b5 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -505,6 +505,10 @@ pub type IntentStatusStream = /// mid-`call_on_event`. Each select fork either yields a fresh event /// to dispatch or signals shutdown - the in-flight wasmtime call /// finishes naturally before the loop exits. +/// +/// Returns the `(blocks, chain_logs)` tally of events drained from the +/// streams - the same numbers the shutdown log line reports. Tests +/// assert on the tally; the launch path ignores it. pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, @@ -512,7 +516,7 @@ pub async fn run( intent_status_stream: Option, tasks: TaskSet, shutdown: impl std::future::Future + Send, -) { +) -> (u64, u64) { // `select_all` over an empty Vec yields `None` immediately, which // would trip the "stream ended -> shut down" arm below before the // first block / chain-log ever flows. Engine configs that subscribe to @@ -623,7 +627,7 @@ pub async fn run( uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); - return; + return (dispatched_blocks, dispatched_chain_logs); } NextEvent::StreamPanic(kind) => { // Reconnect tasks should loop forever. @@ -637,7 +641,7 @@ pub async fn run( kind, "reconnect task ended unexpectedly - shutting down for engine restart" ); - return; + return (dispatched_blocks, dispatched_chain_logs); } } } @@ -695,6 +699,97 @@ pub async fn wait_for_shutdown_signal() -> anyhow::Result<&'static str> { mod tests { use super::*; + // ── Structural tests: per-stream task allocation (#56) ────────────────── + + /// `open_block_streams` spawns one independent reconnect task per chain. + /// Per-chain task isolation means a slow or reconnecting chain does not + /// delay events from other chains — each chain has its own mpsc channel + /// and backoff timer. + #[tokio::test] + async fn open_block_streams_opens_one_task_per_chain() { + use crate::runtime::task::{TaskSet, TokioExecutor}; + use crate::test_utils::MockChainProvider; + + let pool = MockChainProvider::new(); + let mut tasks = TaskSet::new(); + let chains = vec![ + alloy_chains::Chain::mainnet(), + alloy_chains::Chain::from_id(100), + ]; + let streams = open_block_streams(&pool, &chains, &TokioExecutor, &mut tasks); + assert_eq!(streams.len(), 2, "one stream per chain"); + tasks.shutdown().await; + } + + /// `open_chain_log_streams` spawns one independent reconnect task per + /// (module, chain, filter) subscription. Two subscriptions from different + /// modules on the same chain each get their own task. + #[tokio::test] + async fn open_chain_log_streams_opens_one_task_per_subscription() { + use crate::runtime::task::{TaskSet, TokioExecutor}; + use crate::test_utils::MockChainProvider; + + let pool = MockChainProvider::new(); + let mut tasks = TaskSet::new(); + let subs = vec![ + ChainLogSub { + module: "mod-a".to_string(), + chain: alloy_chains::Chain::mainnet(), + filter: alloy_rpc_types_eth::Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }, + ChainLogSub { + module: "mod-b".to_string(), + chain: alloy_chains::Chain::mainnet(), + filter: alloy_rpc_types_eth::Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }, + ]; + let streams = open_chain_log_streams(&pool, subs, &TokioExecutor, &mut tasks); + assert_eq!(streams.len(), 2, "one stream per subscription"); + tasks.shutdown().await; + } + + /// Issue #58's task-exit contract, asserted directly: a reconnect + /// task whose downstream receiver drops exits on its own with + /// [`TaskExit::ReceiverGone`] - it is not aborted. This cannot be + /// observed through `TaskSet::shutdown`, which aborts every handle + /// before joining, so the bare handle is joined here. + #[tokio::test] + async fn reconnect_task_exits_receiver_gone_when_receiver_drops() { + use crate::runtime::task::{TaskExecutor, TaskExit, TokioExecutor}; + use crate::test_utils::MockChainProvider; + + let pool = MockChainProvider::new(); + // Buffer one header so the task has an item to forward - the + // failing `tx.send` against the dropped receiver is the exit path + // under test. + pool.push_block(alloy_rpc_types_eth::Header::default()); + + let (tx, rx) = mpsc::channel(1); + let handle = TokioExecutor.spawn(Box::pin(reconnecting_block_task( + pool.clone(), + alloy_chains::Chain::mainnet(), + tx, + ))); + drop(rx); + + let exit = tokio::time::timeout(Duration::from_secs(5), handle.join()) + .await + .expect("task must exit promptly once the receiver is gone"); + assert_eq!( + exit, + Some(TaskExit::ReceiverGone), + "the task must exit naturally, not via abort (abort yields None)", + ); + } + + // ── block_stream_gap_to_log unit tests ────────────────────────────────── + /// The helper that decides whether to emit a /// "stream gap closed" line on the next block event. #[test] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 90d34f85..58e04515 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -64,6 +64,124 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { ); } +// ── event_loop integration tests (#56 + #58) ───────────────────────── +// +// Verify the stream-open + run() + shutdown lifecycle end to end at the +// supervisor boundary, without loading a real wasm module. + +/// Block and chain-log streams are both consumed within the same `run()` +/// session — the `biased` select does not starve either event kind. One +/// item of each kind is queued before the loop starts; `run()`'s returned +/// tally must show both were drained. A regression that breaks either +/// select arm (or reorders the `biased` polling so one side never fires) +/// leaves its count at 0 and fails the assertion. Issue #56. +#[tokio::test] +async fn run_delivers_block_and_chain_log_events_without_starvation() { + use std::time::Duration; + + use alloy_chains::Chain; + use alloy_rpc_types_eth::Filter; + + use crate::runtime::event_loop::{open_block_streams, open_chain_log_streams, run}; + use crate::runtime::task::{TaskSet, TokioExecutor}; + use crate::test_utils::MockChainProvider; + + let engine = make_wasmtime_engine(); + let mut supervisor = boot_mock_supervisor(&engine).await; + let pool = MockChainProvider::new(); + let mut tasks = TaskSet::new(); + + // Pre-push one event of each kind before the loop starts so both mpsc + // channels have an item for `run()` to drain on its first pass. + pool.push_block(alloy_rpc_types_eth::Header::default()); + pool.push_chain_log(alloy_rpc_types_eth::Log::default()); + + let block_streams = open_block_streams(&pool, &[Chain::mainnet()], &TokioExecutor, &mut tasks); + let log_subs = vec![crate::supervisor::ChainLogSub { + module: "test-module".to_string(), + chain: Chain::mainnet(), + filter: Filter::default(), + cursor_key: None, + initial_cursor: None, + max_lookback: None, + }]; + let chain_log_streams = open_chain_log_streams(&pool, log_subs, &TokioExecutor, &mut tasks); + + // The shutdown window only bounds wall time; the assertion is on the + // tally, not on timing. 500 ms is orders of magnitude more than the + // two channel hops need, so a miss means a broken select arm, not a + // slow scheduler. + let shutdown = tokio::time::sleep(Duration::from_millis(500)); + let (blocks, chain_logs) = tokio::time::timeout( + Duration::from_secs(10), + run( + &mut supervisor, + block_streams, + chain_log_streams, + None, + tasks, + shutdown, + ), + ) + .await + .expect("run() must return once shutdown fires"); + assert_eq!(blocks, 1, "the queued block must be drained and dispatched"); + assert_eq!( + chain_logs, 1, + "the queued chain-log must be drained and dispatched", + ); +} + +/// After `run()` returns on the shutdown path, all reconnect tasks are +/// drained: the Shutdown arm calls `tasks.shutdown()`, which aborts every +/// handle and then joins each one, so no task detaches and outlives the +/// engine. (The companion contract — a task parked on a dropped receiver +/// exits with `ReceiverGone` on its own — is asserted directly in +/// `event_loop::tests::reconnect_task_exits_receiver_gone_when_receiver_drops`; +/// it cannot be observed here because `TaskSet::shutdown` aborts first.) +/// Issue #58. +#[tokio::test] +async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { + use std::time::Duration; + + use alloy_chains::Chain; + + use crate::runtime::event_loop::{open_block_streams, run}; + use crate::runtime::task::{TaskSet, TokioExecutor}; + use crate::test_utils::MockChainProvider; + + let engine = make_wasmtime_engine(); + let mut supervisor = boot_mock_supervisor(&engine).await; + let pool = MockChainProvider::new(); + let mut tasks = TaskSet::new(); + + // Two subscription tasks — both must drain before `run()` returns. + let block_streams = open_block_streams( + &pool, + &[Chain::mainnet(), Chain::from_id(100)], + &TokioExecutor, + &mut tasks, + ); + + let shutdown = tokio::time::sleep(Duration::from_millis(10)); + // If the drain were absent, the spawned reconnect tasks would detach + // and outlive the supervisor; if the drain hung, the timeout fails + // fast instead of stalling the suite until the CI job limit. + tokio::time::timeout( + Duration::from_secs(10), + run( + &mut supervisor, + block_streams, + vec![], + None, + tasks, + shutdown, + ), + ) + .await + .expect("run() + task drain must complete promptly after shutdown"); +} + // ── E2E helpers ─────────────────────────────────────────────────────── /// Path to the pre-built example WASM component. Tests that need it diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index e06e163a..07a2efa2 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -579,6 +579,141 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } + /// Both block and chain-log events are dispatched to a live module in the + /// same session: the `biased` select in `run()` delivers both event kinds + /// without starvation. Addresses the ordering guarantee from issue #56. + #[tokio::test] + async fn harness_delivers_block_and_chain_log_events_without_starvation() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline( + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[[subscription]] +kind = "chain-log" +chain_id = 1 +"#, + ) + .launch() + .await + .expect("launch example subscribed to both blocks and chain-logs"); + + // Both events are queued before either is awaited, so the biased + // select genuinely arbitrates between two ready streams — a + // sequential push→wait→push→wait would never create contention. + rt.push_block(header_numbered(42)); + rt.push_chain_log(Log::default()); + + rt.wait_for_log("example", "block 42 on chain") + .await + .expect("block event dispatched"); + rt.wait_for_log("example", "received 1 chain-log entries") + .await + .expect("chain-log event dispatched — neither event kind starved the other"); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + + /// Blocks pushed in order arrive at the module in the same order — + /// the per-chain stream, the select, and the dispatch path preserve + /// delivery order. Issue #56's ordering guarantee, asserted on the + /// module's own log records rather than inferred from termination. + #[tokio::test] + async fn harness_delivers_blocks_in_push_order() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline(block_manifest("example", 1)) + .launch() + .await + .expect("launch example over the harness"); + + rt.push_block(header_numbered(7)); + rt.push_block(header_numbered(8)); + rt.push_block(header_numbered(9)); + + // The last block's log line proves all three dispatches completed. + rt.wait_for_log("example", "block 9 on chain") + .await + .expect("final block dispatched"); + + // Recover the per-block log lines in record order and assert the + // sequence matches the push order exactly. + let logs = rt.logs(); + let numbers: Vec = logs + .list_runs("example") + .into_iter() + .flat_map(|meta| logs.read(&meta.run, 0).records) + .filter_map(|record| { + let rest = record.message.strip_prefix("block ")?; + rest.split(' ').next()?.parse().ok() + }) + .collect(); + assert_eq!( + numbers, + vec![7, 8, 9], + "blocks must be dispatched in push order", + ); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + + /// Shutdown signalled while a dispatch is pending never destroys + /// completed work: the dispatch path sits outside the shutdown select, + /// so a block that was picked up finishes its wasmtime call and its + /// log record survives `wait()`. The test first proves the dispatch + /// completed (log line present), then shuts down and re-reads the same + /// record after the engine is fully torn down — if teardown dropped or + /// truncated completed work, the second read fails. Issue #58. + #[tokio::test] + async fn harness_shutdown_preserves_completed_dispatch() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + + let mut rt = TestRuntime::builder(wasm) + .manifest_inline(block_manifest("example", 1)) + .launch() + .await + .expect("launch example over the harness"); + + rt.push_block(header_numbered(1)); + rt.wait_for_log("example", "block 1 on chain") + .await + .expect("dispatch completed before shutdown"); + + let logs = rt.logs().clone(); + rt.shutdown(); + rt.wait().await.expect("no panic or corruption on shutdown"); + + let survived = logs.list_runs("example").into_iter().any(|meta| { + logs.read(&meta.run, 0) + .records + .iter() + .any(|r| r.message.contains("block 1 on chain")) + }); + assert!( + survived, + "the completed dispatch's log record must survive engine teardown", + ); + } + /// A dropped block stream is not the end of dispatch: the event loop's /// reconnect task reopens the subscription after backoff and the /// re-armed mock resumes delivery, matching a real provider that comes From a7d1ddbd668febf9d0ffecc2038598571b0d5640 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 10:59:01 +0000 Subject: [PATCH 028/139] feat(chain): cap JSON-RPC response size before lowering into the guest (#354) * feat(chain): cap JSON-RPC response size before lowering into the guest (#154) Adds a configurable `[limits.chain] response_max_bytes` knob (default 1 MiB) that is enforced host-side in `chain::request()` before the response string is copied into the guest heap. Oversized responses are rejected with an `InvalidInput` fault, a `WARN` log, and a dedicated `shepherd_chain_response_capped_total` metric. * fix(#154): bound batch aggregates, harden config, test the real wiring Review follow-ups on the chain response cap: - request_batch: the per-entry cap bounds each body, but the aggregate Vec lowered into guest memory was unbounded - ~64 entries just under the cap is ~64 MiB, exactly the guest-heap saturation issue #154 exists to prevent, reachable via the block-range chunking the issue itself recommends. A running total now converts entries past the cap into typed invalid-input results. - config: renamed [limits.chain].response_max_bytes to response_body_max_bytes (u64) for exact symmetry with the [limits.http] sibling; a degenerate 0 saturates to 1, matching the logs/poison zero handling. - new harness test drives the cap through the real path - config -> ModuleLimits -> HostState -> chain::request - with the price-alert module: an over-cap oracle response surfaces to the guest as the typed fault and never reaches classify. The existing unit tests only covered the free function, so a deleted and_then would have passed the suite. - TOML parse tests for [limits.chain] (absent -> 1 MiB default, override, zero saturation), mirroring the HTTP section's tests. - engine.example.toml documents the new section; warn message dash matches house style. --------- Co-authored-by: Luiz Gustavo Abou Hatem de Liz --- crates/nexum-runtime/src/engine_config.rs | 73 ++++++++++++++ crates/nexum-runtime/src/host/impls/chain.rs | 98 ++++++++++++++++++- crates/nexum-runtime/src/host/state.rs | 3 + crates/nexum-runtime/src/supervisor.rs | 9 ++ .../nexum-runtime/src/test_utils/harness.rs | 88 +++++++++++++++++ engine.example.toml | 9 ++ 6 files changed, 278 insertions(+), 2 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index a5046b75..c4e796b8 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -245,6 +245,12 @@ const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); /// guest heap that has to buffer it. const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; +/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough +/// for typical read responses (receipts, log batches, contract state), +/// while preventing a misbehaving or adversarial node from filling the +/// guest heap via a single large reply. +const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024; + /// Ceiling for the `[limits.http]` millisecond knobs (24 h). const HTTP_LIMIT_MS_MAX: u64 = 86_400_000; @@ -292,6 +298,9 @@ fn clamp_http_ms(ms: u64) -> Duration { /// total_deadline_ms = 60_000 /// response_body_max_bytes = 16_777_216 /// +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// /// [limits.logs] /// bytes_per_run = 262_144 /// runs_retained = 16 @@ -309,6 +318,9 @@ pub struct ModuleLimits { /// Outbound wasi:http limits. #[serde(default)] pub http: HttpLimitsSection, + /// Chain JSON-RPC response size limits. + #[serde(default)] + pub chain: ChainLimitsSection, /// Per-run log retention limits. #[serde(default)] pub logs: LogLimitsSection, @@ -334,6 +346,17 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } + /// Resolved chain response size cap (override or default). A + /// degenerate `0` saturates to 1 byte, matching the `logs` / + /// `poison` sections' zero handling, so resolution never yields a + /// cap that rejects even an empty body. + pub fn chain_response_max_bytes(&self) -> usize { + self.chain + .response_body_max_bytes + .map(|b| (b.max(1)) as usize) + .unwrap_or(DEFAULT_CHAIN_RESPONSE_MAX_BYTES) + } + /// Resolved outbound HTTP limits (overrides or defaults). pub fn http(&self) -> OutboundHttpLimits { OutboundHttpLimits { @@ -447,6 +470,20 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } +/// `[limits.chain]` chain JSON-RPC response size limit. Optional; +/// omitted values resolve to the built-in 1 MiB default. +/// +/// ```toml +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// ``` +#[derive(Debug, Default, Deserialize)] +pub struct ChainLimitsSection { + /// Cap on one chain JSON-RPC response body, in bytes. Named for + /// symmetry with `[limits.http].response_body_max_bytes`. + pub response_body_max_bytes: Option, +} + /// Resolved outbound HTTP limits the wasi:http gate enforces per /// request. Built by [`ModuleLimits::http`]. #[derive(Debug, Clone, Copy)] @@ -787,6 +824,42 @@ response_body_max_bytes = 1_024 assert_eq!(http.between_bytes_timeout_max, Duration::from_secs(30)); } + #[test] + fn chain_limits_default_when_absent() { + assert_eq!( + ModuleLimits::default().chain_response_max_bytes(), + 1024 * 1024, + ); + } + + #[test] + fn chain_limits_parse_with_override() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 2_048 +"#, + ) + .expect("limits.chain parses"); + assert_eq!(cfg.limits.chain_response_max_bytes(), 2_048); + } + + #[test] + fn chain_limits_saturate_degenerate_zero() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 0 +"#, + ) + .expect("limits.chain parses"); + assert_eq!( + cfg.limits.chain_response_max_bytes(), + 1, + "zero saturates to 1 so resolution never rejects an empty body", + ); + } + #[test] fn http_limits_saturate_degenerate_millisecond_values() { // Zero would fail every request instantly; u64::MAX would diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/crates/nexum-runtime/src/host/impls/chain.rs index 3fb67286..f9afad2a 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/crates/nexum-runtime/src/host/impls/chain.rs @@ -24,6 +24,40 @@ fn resolve_method(method: &str) -> Result { }) } +/// Return an error if `body` exceeds `cap` bytes. The check is applied +/// host-side before the response is copied into the guest, so an +/// oversized node response cannot saturate the guest heap. +fn check_response_cap( + body: &str, + cap: usize, + chain_id: u64, + method: &str, +) -> Result<(), ChainError> { + if body.len() > cap { + tracing::warn!( + chain_id, + method, + body_bytes = body.len(), + cap_bytes = cap, + "chain response exceeds size cap - rejecting before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method.to_owned(), + ) + .increment(1); + return Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "chain response ({} bytes) exceeds the configured cap ({} bytes)", + body.len(), + cap, + )), + )); + } + Ok(()) +} + impl nexum::host::chain::Host for HostState { async fn request( &mut self, @@ -57,7 +91,11 @@ impl nexum::host::chain::Host for HostState { .chain .request(chain, method, params) .await - .map_err(ChainError::from); + .map_err(ChainError::from) + .and_then(|body| { + check_response_cap(&body, self.chain_response_max_bytes, chain_id, name)?; + Ok(body) + }); tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); let outcome = if result.is_ok() { "ok" } else { "err" }; metrics::counter!( @@ -90,10 +128,44 @@ impl nexum::host::chain::Host for HostState { // per-chain timeout, so the worst-case blocking time for a batch // is N x request_timeout_secs. tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); + let cap = self.chain_response_max_bytes; let mut out = Vec::with_capacity(requests.len()); + // The per-entry cap (inside `request`) bounds each body; this + // running total bounds the aggregate `Vec` lowered into + // guest memory in one go, so a wide batch of individually-legal + // bodies cannot saturate the guest heap either - the exact failure + // the guidance in #154 (block-range chunking via request-batch) + // would otherwise re-introduce. + let mut total_bytes: usize = 0; for req in requests { + let method = req.method.clone(); match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { - Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), + Ok(s) => { + total_bytes = total_bytes.saturating_add(s.len()); + if total_bytes > cap { + tracing::warn!( + chain_id, + method = %method, + total_bytes, + cap_bytes = cap, + "chain batch aggregate exceeds size cap - rejecting entry before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method, + ) + .increment(1); + out.push(nexum::host::chain::RpcResult::Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "batch aggregate ({total_bytes} bytes) exceeds the configured \ + cap ({cap} bytes)", + )), + ))); + } else { + out.push(nexum::host::chain::RpcResult::Ok(s)); + } + } Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), } } @@ -283,4 +355,26 @@ mod tests { )); assert!(resolved[2].is_ok()); } + + // ── response size cap tests (#154) ── + + #[test] + fn response_at_cap_is_accepted() { + let body = "x".repeat(10); + assert!( + check_response_cap(&body, 10, 1, "eth_call").is_ok(), + "body exactly at cap should pass" + ); + } + + #[test] + fn response_over_cap_returns_invalid_input() { + let body = "x".repeat(11); + let err = + check_response_cap(&body, 10, 1, "eth_call").expect_err("over-cap body should fail"); + assert!( + matches!(err, ChainError::Fault(Fault::InvalidInput(_))), + "expected InvalidInput fault, got {err:?}" + ); + } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index df3e2801..d299ccd0 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -45,6 +45,9 @@ pub struct HostState { pub ext: T::Ext, /// `chain` backend - per-chain alloy `DynProvider` pool. pub chain: T::Chain, + /// Host-enforced cap on a single chain JSON-RPC response body. + /// Responses larger than this are rejected before they reach the guest. + pub chain_response_max_bytes: usize, /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index b74bb102..6543444b 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -190,6 +190,9 @@ struct LoadedModule { /// Cached for restart: outbound HTTP limits baked into the /// `HostState` we rebuild on each re-instantiation. http_limits: OutboundHttpLimits, + /// Cached for restart: chain response size cap baked into the + /// `HostState` we rebuild on each re-instantiation. + chain_response_max_bytes: usize, /// Set to `false` when `on_event` traps. Dead modules are /// excluded from dispatch until `next_attempt` is in the past. /// Modules whose `init` failed have `alive = false` @@ -383,6 +386,7 @@ impl Supervisor { messaging_topics: Vec, memory_limit: usize, fuel: u64, + chain_response_max_bytes: usize, clocks: Option<&WasiClockOverride>, pool_router: PoolRouter, ) -> Result> { @@ -437,6 +441,7 @@ impl Supervisor { log_router: router, ext: components.ext.clone(), chain: components.chain.clone(), + chain_response_max_bytes, store: module_store, pool_router, }, @@ -511,6 +516,7 @@ impl Supervisor { Vec::new(), limits_cfg.memory(), limits_cfg.fuel(), + limits_cfg.chain_response_max_bytes(), clocks, pool_router, )?; @@ -584,6 +590,7 @@ impl Supervisor { init_config: config, http_allowlist: loaded_manifest.http_allowlist.clone(), http_limits: limits_cfg.http(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), failure_timestamps: std::collections::VecDeque::new(), poisoned: false, }) @@ -677,6 +684,7 @@ impl Supervisor { entry.messaging_topics.clone(), limits_cfg.memory(), limits_cfg.fuel(), + limits_cfg.chain_response_max_bytes(), clocks, PoolRouter::empty(), )?; @@ -856,6 +864,7 @@ impl Supervisor { Vec::new(), module.memory_limit, module.fuel_per_event, + module.chain_response_max_bytes, clocks.as_ref(), pool_router, )?; diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 07a2efa2..31f2a8d7 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -714,6 +714,94 @@ chain_id = 1 ); } + /// `[limits.chain].response_body_max_bytes` is enforced on the real + /// `chain::request` path, end to end: the configured cap reaches + /// `HostState`, an over-cap node response is rejected before the guest + /// copy, and the module observes the typed `invalid-input` fault + /// instead of the body. Guards the wiring the unit tests on + /// `check_response_cap` cannot see (issue #154). + #[tokio::test] + async fn harness_enforces_chain_response_cap_on_the_request_path() { + use crate::engine_config::ChainLimitsSection; + use crate::host::component::ChainMethod; + + let Some(wasm) = module_wasm_or_skip("price_alert.wasm") else { + return; + }; + + // A syntactically valid oracle answer, ~330 bytes - far over the + // 16-byte cap below, so the module must never see it. + fn word(v: u128) -> String { + format!("{v:064x}") + } + let result = format!( + "\"0x{}{}{}{}{}\"", + word(1), + word(300_000_000_000), + word(0), + word(0), + word(1), + ); + + let builder = TestRuntime::builder(wasm) + .manifest_inline( + r#" +[module] +name = "price-alert" + +[capabilities] +required = ["logging", "chain"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[config] +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +decimals = "8" +threshold = "2500.00" +direction = "above" +"#, + ) + .limits(ModuleLimits { + chain: ChainLimitsSection { + response_body_max_bytes: Some(16), + }, + ..Default::default() + }); + builder.chain().on_method(ChainMethod::EthCall, result); + + let mut rt = builder + .launch() + .await + .expect("launch price-alert with a 16-byte chain response cap"); + + rt.push_block(header_numbered(19_000_000)); + let record = rt + .wait_for_log("price-alert", "exceeds the configured cap") + .await + .expect("the module logs the guest-visible cap fault"); + assert!( + record.message.contains("eth_call failed"), + "the cap surfaces as a failed eth_call, got: {}", + record.message, + ); + + // The module never saw the oracle answer, so it must not trigger. + let runs = rt.logs().list_runs("price-alert"); + let triggered = runs.into_iter().any(|meta| { + rt.logs() + .read(&meta.run, 0) + .records + .iter() + .any(|r| r.message.contains("TRIGGERED")) + }); + assert!(!triggered, "an over-cap response must never reach classify"); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + /// A dropped block stream is not the end of dispatch: the event loop's /// reconnect task reopens the subscription after backoff and the /// re-armed mock resumes delivery, matching a real provider that comes diff --git a/engine.example.toml b/engine.example.toml index 39624528..a3f883bd 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -52,6 +52,15 @@ log_level = "info" # total_deadline_ms = 60_000 # response_body_max_bytes = 16_777_216 +# Chain JSON-RPC response size cap, applied host-side before a response +# is copied into guest memory. A single response - and the aggregate of +# one request-batch - beyond the cap fails with a typed invalid-input +# fault instead of inflating the module toward its memory_bytes limit. +# Modules that need more data per call should paginate (block-range +# chunking, request-batch). 0 saturates to 1. Default 1 MiB. +[limits.chain] +# response_body_max_bytes = 1_048_576 + # Per-run log retention. bytes_per_run is the byte budget for one run's # in-memory ring (oldest records evict first, the newest is never evicted # to nothing); runs_retained is how many past runs are kept per module. From a463db3efeb237800c0846df1875dfeef4f9b6f8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 03:25:16 +0000 Subject: [PATCH 029/139] wit: carry opaque status bytes so the host stops importing intent (#426) Drop the nexum:intent use from nexum:host/types: the intent-status event now carries an opaque versioned status body and an opaque receipt, so nexum:host is a leaf package. The core bindgen resolves against wit/nexum-host alone, module worlds pull the intent packages only with the pool capability, and the intent vocabulary generates in the adapter bindgen. The body codec lives in the new nexum-status-body crate: a leading u8 version tag, then the version's borsh payload (v1: lifecycle status, optional settlement proof, optional fail reason). Unknown tags fail closed, a body is never empty, and goldens pin the wire form. The pool router lowers an adapter-reported status through it (settled is fulfilled plus proof, failed is cancelled plus reason) and keepers decode via nexum_sdk::status_body. --- Cargo.lock | 10 + Cargo.toml | 1 + crates/nexum-macros/src/world.rs | 56 ++--- crates/nexum-runtime/Cargo.toml | 3 + crates/nexum-runtime/src/bindings.rs | 57 +++-- crates/nexum-runtime/src/host/impls/types.rs | 9 +- crates/nexum-runtime/src/host/pool_router.rs | 115 +++++++-- crates/nexum-runtime/src/supervisor/tests.rs | 19 +- crates/nexum-sdk/Cargo.toml | 3 + crates/nexum-sdk/src/lib.rs | 8 + crates/nexum-status-body/Cargo.toml | 14 ++ crates/nexum-status-body/src/lib.rs | 247 +++++++++++++++++++ modules/example/src/lib.rs | 5 +- modules/examples/echo-client/src/lib.rs | 5 +- wit/nexum-host/types.wit | 19 +- 15 files changed, 474 insertions(+), 97 deletions(-) create mode 100644 crates/nexum-status-body/Cargo.toml create mode 100644 crates/nexum-status-body/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b7ab634f..47718667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3605,6 +3605,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "nexum-runtime", + "nexum-status-body", "nexum-tasks", "redb", "serde", @@ -3634,6 +3635,7 @@ dependencies = [ "http", "nexum-macros", "nexum-sdk-test", + "nexum-status-body", "proptest", "serde_json", "strum", @@ -3651,6 +3653,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "nexum-tasks" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6ec91e60..43f68852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-status-body", "crates/nexum-tasks", "crates/nexum-venue-sdk", "crates/nexum-venue-test", diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index d8791708..95614893 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -73,7 +73,7 @@ const KNOWN: &[Capability] = &[ Capability { name: "pool", import: Some("nexum:intent/pool@0.1.0"), - packages: &["nexum-intent", "nexum-value-flow"], + packages: &["nexum-value-flow", "nexum-intent"], adapter: None, }, Capability { @@ -168,9 +168,9 @@ pub fn synthesize_venue(declared: &[String]) -> Result { let mut imports = String::new(); // The export face (`nexum:intent/adapter`, its types, and the - // value-flow vocabulary they are expressed in) resolves against the - // same base package set every module world carries, in dependency - // order: a package precedes its dependants. + // value-flow vocabulary they are expressed in) needs the intent + // packages on the resolve path beyond the leaf host package, in + // dependency order: a package precedes its dependants. let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { @@ -228,12 +228,12 @@ pub fn synthesize(declared: &[String]) -> Result { } let mut imports = String::new(); - // The host `event` variant carries the intent vocabulary (the - // `intent-status` case), so every module world resolves against the - // intent and value-flow packages regardless of declared capabilities. - // Dependency order: each directory is parsed against the packages - // before it, so a package precedes its dependants. - let mut packages = vec!["nexum-value-flow", "nexum-intent", "nexum-host"]; + // `nexum:host` is a leaf package (the `event` variant carries an + // intent-status transition as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host"]; let mut adapters = Vec::new(); for cap in KNOWN { if !declared.iter().any(|d| d == cap.name) { @@ -273,10 +273,13 @@ pub fn synthesize(declared: &[String]) -> Result { mod tests { use super::*; - /// The base package set every module world resolves against: the host - /// package plus the intent vocabulary its `event` variant carries, in - /// dependency order. - const BASE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// The package set every venue world resolves against: the exported + /// adapter face pulls the intent vocabulary, in dependency order. + const VENUE_PACKAGES: [&str; 3] = ["nexum-value-flow", "nexum-intent", "nexum-host"]; #[test] fn logging_only_world_imports_logging_alone() { @@ -284,7 +287,7 @@ mod tests { assert!(world.wit.contains("import nexum:host/logging@0.2.0;")); assert!(!world.wit.contains("import nexum:host/chain")); assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, MODULE_PACKAGES); assert_eq!(world.adapters, vec!["logging"]); } @@ -292,22 +295,17 @@ mod tests { fn cow_api_pulls_the_shepherd_cow_package() { let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); assert!(world.wit.contains("import shepherd:cow/cow-api@0.2.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-value-flow", - "nexum-intent", - "nexum-host", - "shepherd-cow" - ] - ); + assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); } #[test] - fn pool_adds_no_packages_beyond_the_base_set() { + fn pool_pulls_the_intent_packages() { let world = synthesize(&["pool".to_string()]).unwrap(); assert!(world.wit.contains("import nexum:intent/pool@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!( + world.packages, + vec!["nexum-host", "nexum-value-flow", "nexum-intent"] + ); assert!(world.adapters.is_empty()); } @@ -315,7 +313,7 @@ mod tests { fn http_declares_no_world_import() { let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, MODULE_PACKAGES); } #[test] @@ -343,7 +341,7 @@ mod tests { .contains("export init: func(config: config) -> result<_, fault>;") ); assert!(world.wit.contains("export nexum:intent/adapter@0.1.0;")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, VENUE_PACKAGES); assert!(world.adapters.is_empty()); } @@ -363,7 +361,7 @@ mod tests { let world = synthesize_venue(&["http".to_string()]).unwrap(); assert!(!world.wit.contains("import")); assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, BASE_PACKAGES); + assert_eq!(world.packages, VENUE_PACKAGES); } #[test] diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index a4ddab3e..7abab005 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -35,6 +35,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Encoder for the opaque status body the host `event` stream carries; +# the router lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 927861bf..52e9a3fa 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -9,20 +9,16 @@ //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! The `nexum:intent` and `nexum:value-flow` packages sit on the core -//! resolve path because the host `event` variant carries the intent -//! vocabulary (the `intent-status` case). Their types therefore generate -//! here first, and the adapter and pool bindgens below remap onto them -//! with `with`, so one Rust type serves the event payload, the router, -//! and the adapter face alike. `PartialEq` is derived so the router can -//! compare a polled status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries an +//! intent-status transition as opaque bytes, so the core world resolves +//! against `wit/nexum-host` alone. The intent and value-flow vocabulary +//! generates in the adapter bindgen below, and the pool bindgen remaps +//! onto it with `with`, so one Rust type serves the router and the +//! adapter face alike. `PartialEq` is derived so the router can compare +//! a polled status against the last delivered one. wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-value-flow", - "../../wit/nexum-intent", - "../../wit/nexum-host", - ], + path: ["../../wit/nexum-host"], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, @@ -33,11 +29,13 @@ wasmtime::component::bindgen!({ /// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped /// transport it needs (chain and messaging; outbound HTTP is wasi:http, /// linked and allowlisted separately as for event-module) and exports the -/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host`, -/// `nexum:intent`, and `nexum:value-flow` interfaces are reused from the -/// `event-module` bindings above via `with`, so the `chain`/`messaging` -/// `Host` impls, the `fault` type, and the intent vocabulary an adapter -/// sees are the very ones the core host constructs. +/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host` +/// interfaces are reused from the `event-module` bindings above via +/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type +/// an adapter sees are the very ones the core host constructs. The +/// `nexum:intent` and `nexum:value-flow` types generate here (the leaf +/// host world no longer reaches them) and the pool bindgen below remaps +/// onto them. mod venue_adapter { wasmtime::component::bindgen!({ path: [ @@ -53,9 +51,8 @@ mod venue_adapter { "nexum:host/types": super::nexum::host::types, "nexum:host/chain": super::nexum::host::chain, "nexum:host/messaging": super::nexum::host::messaging, - "nexum:intent/types": super::nexum::intent::types, - "nexum:value-flow/types": super::nexum::value_flow::types, }, + additional_derives: [PartialEq], }); } @@ -63,9 +60,9 @@ pub use venue_adapter::VenueAdapter; /// The strategy-facing `nexum:intent/pool` import bound host-side. The pool /// world imports the interface a module calls; the intent and value-flow -/// types it uses are reused from the core bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the router hands back to a module are -/// the very ones an adapter's `submit` produced - no lift between two +/// types it uses are reused from the adapter bindings above via `with`, so +/// the `SubmitOutcome` and `VenueError` the router hands back to a module +/// are the very ones an adapter's `submit` produced - no lift between two /// structurally identical copies. Async, because the `Host` impl awaits the /// per-adapter mutex and the adapter's own async guest calls. mod pool_host { @@ -79,8 +76,8 @@ mod pool_host { path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], imports: { default: async }, with: { - "nexum:value-flow/types": super::nexum::value_flow::types, - "nexum:intent/types": super::nexum::intent::types, + "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, + "nexum:intent/types": super::venue_adapter::nexum::intent::types, }, }); } @@ -88,14 +85,16 @@ mod pool_host { /// The router-observed status transition delivered through the `event` /// variant, re-exported at the plain spelling the router names. pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the router -/// and the `pool::Host` impl name. -pub use nexum::intent::types::{AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; -/// The value-flow vocabulary the header is expressed in. -pub use nexum::value_flow::types as value_flow; /// The host-bound pool interface: the `Host` trait the router implements and /// the `add_to_linker` the module linker calls. pub use pool_host::nexum::intent::pool; +/// The shared intent ontology, re-exported at the plain spellings the router +/// and the `pool::Host` impl name. +pub use venue_adapter::nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::nexum::value_flow::types as value_flow; /// Bindgen smoke for the `nexum:value-flow` types package. The package has /// no host consumer yet (the intent router that will bind it lands later), diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/crates/nexum-runtime/src/host/impls/types.rs index a035a97d..f9516569 100644 --- a/crates/nexum-runtime/src/host/impls/types.rs +++ b/crates/nexum-runtime/src/host/impls/types.rs @@ -1,13 +1,8 @@ -//! `nexum:host/types` and the intent vocabulary it uses are type-only -//! interfaces (no functions). The generated traits are empty; we just -//! provide the marker impls. +//! `nexum:host/types` is a type-only interface (no functions). The +//! generated trait is empty; we just provide the marker impl. use crate::bindings::nexum; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; impl nexum::host::types::Host for HostState {} - -impl nexum::intent::types::Host for HostState {} - -impl nexum::value_flow::types::Host for HostState {} diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs index a90797f0..6353cf8a 100644 --- a/crates/nexum-runtime/src/host/pool_router.rs +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -27,6 +27,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use futures::future::BoxFuture; +use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::warn; use wasmtime::Store; @@ -271,6 +272,35 @@ fn is_terminal(status: &IntentStatus) -> bool { ) } +/// Lower an adapter-reported status onto the opaque status body the host +/// `event` stream carries: `settled` is `fulfilled` plus its proof, and +/// `failed` is `cancelled` plus its reason (the body's lifecycle enum has +/// no failed case). +fn status_body(status: &IntentStatus) -> StatusBody { + use nexum_status_body::IntentStatus as Lifecycle; + + let (status, proof, reason) = match status { + IntentStatus::Pending => (Lifecycle::Pending, None, None), + IntentStatus::Open => (Lifecycle::Open, None, None), + IntentStatus::Settled(proof) => (Lifecycle::Fulfilled, proof.clone(), None), + IntentStatus::Failed(reason) => ( + Lifecycle::Cancelled, + None, + Some(nexum_status_body::FailReason { + code: reason.code.clone(), + detail: reason.detail.clone(), + }), + ), + IntentStatus::Expired => (Lifecycle::Expired, None, None), + IntentStatus::Cancelled => (Lifecycle::Cancelled, None, None), + }; + StatusBody { + status, + proof, + reason, + } +} + /// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every /// module store carries the same handle, so a submission from any module /// reaches the same adapters and the same quota ledger. @@ -462,7 +492,9 @@ impl PoolRouter { /// Fold one polled status into the watch entry: `Some(update)` when it /// differs from the last reported status, pruning the entry when the /// status is terminal. `None` also covers an entry that disappeared - /// while the poll was in flight. + /// while the poll was in flight, and an update whose status body + /// failed to encode (the entry is left untouched for the next + /// cadence). fn record_polled_status( &self, venue: &str, @@ -474,16 +506,31 @@ impl PoolRouter { .iter() .position(|w| w.venue == venue && w.receipt == receipt)?; let changed = watched[pos].last.as_ref() != Some(&status); + let update = if changed { + match status_body(&status).encode() { + Ok(body) => Some(IntentStatusUpdate { + venue: venue.to_owned(), + receipt: receipt.to_vec(), + status: body, + }), + Err(err) => { + warn!( + venue = %venue, + error = %err, + "status body failed to encode - retrying on the next cadence", + ); + return None; + } + } + } else { + None + }; if is_terminal(&status) { watched.remove(pos); } else { - watched[pos].last = Some(status.clone()); + watched[pos].last = Some(status); } - changed.then(|| IntentStatusUpdate { - venue: venue.to_owned(), - receipt: receipt.to_vec(), - status, - }) + update } /// Drop a `(venue, receipt)` pair from the status watch. @@ -602,12 +649,27 @@ pub struct DuplicateVenue { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use crate::bindings::nexum::intent::types::UnsignedTx; + use nexum_status_body::IntentStatus as Lifecycle; + use crate::bindings::value_flow::Settlement; - use crate::bindings::{AuthScheme, IntentHeader}; + use crate::bindings::{AuthScheme, FailReason, IntentHeader, UnsignedTx}; use super::*; + /// Decode an update's opaque status body. + fn decoded(update: &IntentStatusUpdate) -> StatusBody { + StatusBody::decode(&update.status).expect("status body decodes") + } + + /// A body carrying a bare lifecycle state. + fn plain(status: Lifecycle) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + /// A programmable adapter that records call counts and returns canned /// outcomes, so the router's sequencing, guard seam, and quota are tested /// without a wasmtime store. @@ -989,7 +1051,7 @@ mod tests { assert_eq!(first.len(), 1); assert_eq!(first[0].venue, "cow"); assert_eq!(first[0].receipt, b"receipt"); - assert_eq!(first[0].status, IntentStatus::Open); + assert_eq!(decoded(&first[0]), plain(Lifecycle::Open)); // Second poll: same status, nothing to report. assert!(router.poll_status_transitions().await.is_empty()); @@ -1016,13 +1078,17 @@ mod tests { for _ in 0..4 { seen.extend(router.poll_status_transitions().await); } - let statuses: Vec<&IntentStatus> = seen.iter().map(|u| &u.status).collect(); + let statuses: Vec = seen.iter().map(decoded).collect(); assert_eq!( statuses, vec![ - &IntentStatus::Pending, - &IntentStatus::Open, - &IntentStatus::Settled(Some(b"tx".to_vec())), + plain(Lifecycle::Pending), + plain(Lifecycle::Open), + StatusBody { + status: Lifecycle::Fulfilled, + proof: Some(b"tx".to_vec()), + reason: None, + }, ], "the repeated pending is deduplicated; each transition reports once", ); @@ -1052,7 +1118,26 @@ mod tests { // The venue recovered: the next poll reports the current status. let updates = router.poll_status_transitions().await; assert_eq!(updates.len(), 1); - assert_eq!(updates[0].status, IntentStatus::Open); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); + } + + #[test] + fn failed_lowers_to_cancelled_plus_reason() { + let body = status_body(&IntentStatus::Failed(FailReason { + code: "oc".into(), + detail: "od".into(), + })); + assert_eq!( + body, + StatusBody { + status: Lifecycle::Cancelled, + proof: None, + reason: Some(nexum_status_body::FailReason { + code: "oc".into(), + detail: "od".into(), + }), + }, + ); } #[tokio::test] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index e2779f85..ec5da908 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -702,7 +702,13 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { let foreign = crate::bindings::IntentStatusUpdate { venue: "other".to_owned(), receipt: b"receipt".to_vec(), - status: IntentStatus::Open, + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), }; assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); } @@ -790,7 +796,6 @@ async fn e2e_intent_status_flows_through_the_event_loop() { /// scripted stand-ins on either side. #[tokio::test] async fn e2e_echo_module_router_adapter_round_trip() { - use crate::bindings::IntentStatus; use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; use crate::host::component::ChainMethod; use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; @@ -855,10 +860,12 @@ async fn e2e_echo_module_router_adapter_round_trip() { for _ in 0..2 { for update in router.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); - assert!( - matches!(update.status, IntentStatus::Settled(_)), - "echo settles instantly; got {:?}", - update.status, + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", ); delivered += supervisor.dispatch_intent_status(update).await; } diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index f713c5c1..655e28ed 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -23,6 +23,9 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-macros = { path = "../nexum-macros" } +# Decoder for the opaque status body an `intent-status` event carries; +# re-exported as `nexum_sdk::status_body`. +nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index bd255a8e..04e8fffd 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -47,6 +47,9 @@ //! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind //! macro fills to rebuild it from the wire record. //! +//! - [`status_body`] - decoder for the opaque versioned status body an +//! `intent-status` event carries ([`StatusBody`]). +//! //! - [`config`] - `(key, value)` config-table lookups and decimal //! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). //! @@ -95,6 +98,7 @@ //! [`read_latest_answer`]: chain::chainlink::read_latest_answer //! [`Log`]: events::Log //! [`ChainLogParts`]: events::ChainLogParts +//! [`StatusBody`]: status_body::StatusBody //! [`get_required`]: config::get_required //! [`get_optional`]: config::get_optional //! [`scale_decimal`]: config::scale_decimal @@ -125,6 +129,10 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; +/// The opaque status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use nexum_status_body as status_body; + /// The level vocabulary for every SDK log path: the host logging trait, /// the guest tracing facade sink, and the module mocks all speak /// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the diff --git a/crates/nexum-status-body/Cargo.toml b/crates/nexum-status-body/Cargo.toml new file mode 100644 index 00000000..cd4361b3 --- /dev/null +++ b/crates/nexum-status-body/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "nexum-status-body" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Versioned codec for the opaque status body the host event stream carries: a leading version tag, then that version's borsh payload; unknown tags fail closed." + +[lints] +workspace = true + +[dependencies] +borsh.workspace = true +thiserror.workspace = true diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs new file mode 100644 index 00000000..d5cc4fab --- /dev/null +++ b/crates/nexum-status-body/src/lib.rs @@ -0,0 +1,247 @@ +//! The versioned opaque status-body codec. +//! +//! The host `event` stream carries an intent-status transition as opaque +//! bytes: a leading `u8` version tag, then that version's borsh payload. +//! The emitter encodes, the keeper decodes, the host never inspects the +//! bytes. An unknown tag fails closed and a body is never empty. +//! +//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the +//! borsh `option` encodings of `proof` and `reason`. + +#![warn(missing_docs)] + +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Wire tag of the v1 payload. +pub const VERSION_V1: u8 = 1; + +/// Where an intent is in its life at the venue. The borsh discriminant +/// is the wire form: append new states, never reorder. +#[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntentStatus { + /// Accepted for processing but not yet live at the venue. + Pending, + /// Live at the venue and eligible for settlement. + Open, + /// Settled. + Fulfilled, + /// Withdrawn or terminally refused before settlement. + Cancelled, + /// Reached its expiry without settling. + Expired, +} + +/// Why an intent failed terminally, as reported by the venue. +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct FailReason { + /// Venue-scoped machine-readable code, stable enough to match on. + pub code: String, + /// Human-readable detail for logs and the consent surface. + pub detail: String, +} + +/// One decoded status body. +/// +/// `proof` is display-grade venue bytes (for an EVM venue, typically +/// the settlement transaction hash). There is no `failed` status: a +/// venue-reported terminal failure reads as a non-[`Fulfilled`] +/// terminal `status` plus a `reason`. +/// +/// [`Fulfilled`]: IntentStatus::Fulfilled +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct StatusBody { + /// Where the intent is in its life at the venue. + pub status: IntentStatus, + /// Venue-defined settlement proof. + pub proof: Option>, + /// Terminal-failure reason. + pub reason: Option, +} + +impl StatusBody { + /// Encode as the version tag plus the borsh payload. Never empty: + /// at minimum the tag and the status discriminant. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = vec![VERSION_V1]; + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode, failing typedly on an empty body, an unknown version + /// tag (fail-closed), or a payload that does not parse as the + /// tagged version (including trailing bytes). + pub fn decode(bytes: &[u8]) -> Result { + match bytes { + [] => Err(DecodeError::Empty), + [VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| DecodeError::Malformed { + version: VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(DecodeError::UnknownVersion { version: *version }), + } + } +} + +/// A payload failed to encode. Only reachable when a field's length +/// exceeds the wire's `u32` bound. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("status body failed to encode: {detail}")] +pub struct EncodeError { + /// Borsh's encode failure detail. + pub detail: String, +} + +/// Why bytes failed to decode as a status body. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum DecodeError { + /// No bytes at all: not even a version tag. + #[error("empty status body: missing the version tag")] + Empty, + /// The version tag names no published version. Fail-closed: a + /// keeper never guesses at a future layout. + #[error("unknown status-body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(status: IntentStatus) -> StatusBody { + StatusBody { + status, + proof: None, + reason: None, + } + } + + #[test] + fn golden_minimal_open() { + let encoded = body(IntentStatus::Open).encode().expect("encode"); + assert_eq!(encoded, [VERSION_V1, 1, 0, 0]); + } + + #[test] + fn golden_fulfilled_with_proof() { + let encoded = StatusBody { + status: IntentStatus::Fulfilled, + proof: Some(vec![0xaa, 0xbb]), + reason: None, + } + .encode() + .expect("encode"); + assert_eq!(encoded, [VERSION_V1, 2, 1, 2, 0, 0, 0, 0xaa, 0xbb, 0]); + } + + #[test] + fn golden_terminal_failure() { + let encoded = StatusBody { + status: IntentStatus::Cancelled, + proof: None, + reason: Some(FailReason { + code: "oc".into(), + detail: "od".into(), + }), + } + .encode() + .expect("encode"); + assert_eq!( + encoded, + [ + VERSION_V1, 3, 0, 1, 2, 0, 0, 0, b'o', b'c', 2, 0, 0, 0, b'o', b'd' + ], + ); + } + + #[test] + fn round_trips_every_status() { + for status in [ + IntentStatus::Pending, + IntentStatus::Open, + IntentStatus::Fulfilled, + IntentStatus::Cancelled, + IntentStatus::Expired, + ] { + let original = StatusBody { + status, + proof: Some(b"proof".to_vec()), + reason: Some(FailReason { + code: "code".into(), + detail: "detail".into(), + }), + }; + let decoded = StatusBody::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + } + + #[test] + fn a_body_is_never_empty() { + let encoded = body(IntentStatus::Pending).encode().expect("encode"); + assert!(encoded.len() >= 2, "at minimum the tag and the status"); + } + + #[test] + fn empty_bytes_fail_typedly() { + assert_eq!(StatusBody::decode(&[]), Err(DecodeError::Empty)); + } + + #[test] + fn unknown_version_fails_closed() { + assert_eq!( + StatusBody::decode(&[2, 1, 0, 0]), + Err(DecodeError::UnknownVersion { version: 2 }), + ); + } + + #[test] + fn unknown_status_discriminant_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 5, 0, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn trailing_bytes_are_malformed() { + let mut encoded = body(IntentStatus::Open).encode().expect("encode"); + encoded.push(0); + assert!(matches!( + StatusBody::decode(&encoded), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_payload_is_malformed() { + assert!(matches!( + StatusBody::decode(&[VERSION_V1, 1, 0]), + Err(DecodeError::Malformed { + version: VERSION_V1, + .. + }), + )); + } +} diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 5c7445b0..da1ddd26 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -68,11 +68,14 @@ impl ExampleModule { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status update from venue {} ({} receipt bytes)", + "intent status update from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 08eb05ee..62407488 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -55,11 +55,14 @@ impl EchoClient { } fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, &format!( - "intent status from venue {} ({} receipt bytes)", + "intent status from venue {}: {:?} ({} receipt bytes)", update.venue, + body.status, update.receipt.len(), ), ); diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 6c48dc29..b4349717 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -5,8 +5,6 @@ package nexum:host@0.2.0; /// All `u64` timestamps in this package are milliseconds since the Unix /// epoch, UTC. interface types { - use nexum:intent/types@0.1.0.{receipt, intent-status}; - type chain-id = u64; record block { @@ -61,16 +59,19 @@ interface types { } /// A host-observed status transition for a previously submitted - /// intent, expressed in the venue-neutral `nexum:intent` vocabulary. - /// Transport-blind: the subscriber sees only where the intent is in - /// its life, never how the host learnt it. + /// intent. Transport-blind: the subscriber sees only where the + /// intent is in its life, never how the host learnt it. record intent-status-update { /// Venue id the receipt was issued by. venue: string, - /// The venue-scoped intent identifier. - receipt: receipt, - /// Where the intent now is in its life at the venue. - status: intent-status, + /// The venue-scoped intent identifier, opaque to the host. + receipt: list, + /// Opaque versioned status body: a leading u8 version tag, + /// then that version's borsh payload (v1: lifecycle status, + /// optional settlement proof, optional failure reason). An + /// unknown tag is a decode error and the body is never empty. + /// The host never inspects it. + status: list, } variant event { From 154587bc5e530d36494e088921a458217e1fc0f2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 04:32:30 +0000 Subject: [PATCH 030/139] wit: rename the intent contract to videre (#428) wit: rename the intent contract to the pinned videre surface Rename the pre-release intent packages into the videre namespace and reshape them to the pinned 0.1.0 surface: nexum:value-flow becomes videre:value-flow, nexum:intent splits into videre:types and the two-faced videre:venue (worker client, provider adapter), and nexum:adapter retires with its venue-adapter world folded into videre:venue. nexum:host and shepherd:cow are untouched. The 0.1 shapes are EVM-only and thin: single-asset gives/wants, auth-scheme {eip1271, eip712}, settlement {chain}, uint amounts (big-endian, minimal-length), asset {native, erc20{token}}, plain-enum intent-status, the seven-case venue-error with rate-limit, unsigned-tx {chain, to, value, data}, and the quotation record. Dropped cases return as additive 0.2+ variants; quote functions land separately. Rust follows the wire: PoolRouter is VenueRegistry, AdapterActor is VenueActor, GuardPolicy/AllowAllGuard collapse into EgressGuard (the unit guard allows every egress), IntentPool is VenueClient, PoolQuota is SubmitQuota, and a VenueId newtype keys the registry. Quota exhaustion now reports rate-limited with the window; adapter traps project onto unavailable. The module capability is client; goldens and the conformance mirrors follow the single-asset header. --- crates/cow-venue/src/client.rs | 28 +- crates/nexum-macros/src/lib.rs | 24 +- crates/nexum-macros/src/world.rs | 57 +- crates/nexum-runtime/src/bindings.rs | 207 +++--- crates/nexum-runtime/src/builder.rs | 4 +- crates/nexum-runtime/src/engine_config.rs | 14 +- crates/nexum-runtime/src/host/impls/mod.rs | 2 +- crates/nexum-runtime/src/host/impls/pool.rs | 30 - .../src/host/impls/venue_client.rs | 36 ++ crates/nexum-runtime/src/host/mod.rs | 2 +- crates/nexum-runtime/src/host/state.rs | 8 +- .../{pool_router.rs => venue_registry.rs} | 606 ++++++++++-------- .../src/manifest/capabilities.rs | 42 +- .../nexum-runtime/src/runtime/event_loop.rs | 14 +- crates/nexum-runtime/src/supervisor.rs | 98 +-- crates/nexum-runtime/src/supervisor/tests.rs | 84 ++- crates/nexum-venue-sdk/src/adapter.rs | 4 +- crates/nexum-venue-sdk/src/bindings.rs | 14 +- crates/nexum-venue-sdk/src/body.rs | 7 +- crates/nexum-venue-sdk/src/client.rs | 40 +- crates/nexum-venue-sdk/src/faults.rs | 45 +- crates/nexum-venue-sdk/src/lib.rs | 13 +- crates/nexum-venue-sdk/tests/adapter.rs | 52 +- .../goldens/reference-header.json | 59 +- crates/nexum-venue-test/src/header.rs | 187 ++---- crates/nexum-venue-test/src/reference.rs | 42 +- crates/nexum-venue-test/tests/conformance.rs | 13 +- crates/shepherd-cow-host/src/ext_cow.rs | 2 - .../0013-composable-cow-structured-poll.md | 2 +- modules/ethflow-watcher/src/lib.rs | 2 - modules/examples/echo-client/Cargo.toml | 2 +- modules/examples/echo-client/module.toml | 15 +- modules/examples/echo-client/src/lib.rs | 24 +- modules/examples/echo-venue/src/lib.rs | 151 ++--- modules/examples/stop-loss/src/lib.rs | 2 - modules/fixtures/clock-reader/src/lib.rs | 2 - modules/fixtures/flaky-bomb/src/lib.rs | 2 - modules/fixtures/fuel-bomb/src/lib.rs | 2 - modules/fixtures/memory-bomb/src/lib.rs | 2 - modules/fixtures/panic-bomb/src/lib.rs | 2 - modules/fixtures/slow-host/src/lib.rs | 2 - modules/twap-monitor/src/lib.rs | 2 - wit/nexum-adapter/venue-adapter.wit | 30 - wit/nexum-intent/adapter.wit | 31 - wit/nexum-intent/pool.wit | 26 - wit/nexum-intent/types.wit | 132 ---- wit/nexum-value-flow/types.wit | 98 --- wit/videre-types/types.wit | 83 +++ wit/videre-value-flow/types.wit | 32 + wit/videre-venue/venue.wit | 39 ++ 50 files changed, 1101 insertions(+), 1316 deletions(-) delete mode 100644 crates/nexum-runtime/src/host/impls/pool.rs create mode 100644 crates/nexum-runtime/src/host/impls/venue_client.rs rename crates/nexum-runtime/src/host/{pool_router.rs => venue_registry.rs} (67%) delete mode 100644 wit/nexum-adapter/venue-adapter.wit delete mode 100644 wit/nexum-intent/adapter.wit delete mode 100644 wit/nexum-intent/pool.wit delete mode 100644 wit/nexum-intent/types.wit delete mode 100644 wit/nexum-value-flow/types.wit create mode 100644 wit/videre-types/types.wit create mode 100644 wit/videre-value-flow/types.wit create mode 100644 wit/videre-venue/venue.wit diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 81bd4e5c..174bb4e8 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,19 +1,19 @@ //! The typed CoW intent client. //! -//! [`CowClient`] binds the strategy-facing [`IntentClient`] to the CoW +//! [`CowClient`] binds the keeper-facing [`IntentClient`] to the CoW //! venue id and speaks the venue's own [`CowIntentBody`] over it, so -//! strategy code submits a typed CoW body without naming the venue on +//! keeper code submits a typed CoW body without naming the venue on //! every call or handling wire bytes. The classification API //! ([`classify`](crate::classification::classify)) travels in the same //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, IntentPool}; +use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; -/// The venue id the CoW adapter registers under and the router resolves. +/// The venue id the CoW adapter registers under and the registry resolves. /// Every [`CowClient`] call routes here. pub const VENUE: &str = "cow"; @@ -25,11 +25,11 @@ pub struct CowClient

{ inner: IntentClient

, } -impl CowClient

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

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

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

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

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

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

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

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

, + bytes: Vec, + quotation: Quotation, +} + +impl Quoted<'_, P> { + /// The venue's indicative quotation for the body. + pub fn quotation(&self) -> &Quotation { + &self.quotation + } + + /// Submit the quoted body to the venue that priced it. + pub fn submit(self) -> Result { + self.client + .venues + .submit(&self.client.venue, self.bytes) + .map_err(ClientError::Venue) + } +} + /// Why a typed intent call failed: before the wire (the body failed to /// encode) or beyond it (the registry or venue refused). /// diff --git a/crates/nexum-venue-sdk/src/lib.rs b/crates/nexum-venue-sdk/src/lib.rs index 1c0559e3..5159ef05 100644 --- a/crates/nexum-venue-sdk/src/lib.rs +++ b/crates/nexum-venue-sdk/src/lib.rs @@ -8,7 +8,7 @@ //! ## What lives here //! //! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the four intent functions), and +//! (`init` plus the five intent functions), and //! [`export_venue_adapter!`] which turns an impl into the component's //! export glue. //! @@ -57,14 +57,14 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, IntentClient, VenueClient}; +pub use client::{ClientError, IntentClient, Quoted, VenueClient}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`nexum_macros::IntentBody`]. pub use nexum_macros::IntentBody; /// Emit the per-cdylib export glue and per-component world for a venue /// adapter. Apply to an inherent `impl` of the adapter face -/// (`derive_header`, `submit`, `status`, `cancel`, plus an optional -/// `init`); the built component imports exactly the manifest's declared +/// (`derive_header`, `quote`, `submit`, `status`, `cancel`, plus an +/// optional `init`); the built component imports exactly the manifest's declared /// scoped transport. See [`nexum_macros::venue`]. /// /// The self-contained per-cdylib alternative to @@ -77,8 +77,8 @@ pub use nexum_macros::venue; /// The intent ontology at its plain spellings: the types the /// [`VenueAdapter`] face and the client core speak. pub use bindings::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, RateLimit, Settlement, SubmitOutcome, UnsignedTx, - VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, }; /// The value-flow vocabulary intent headers are expressed in. pub use bindings::videre::value_flow::types as value_flow; diff --git a/crates/nexum-venue-sdk/tests/adapter.rs b/crates/nexum-venue-sdk/tests/adapter.rs index 699915f0..21ab472a 100644 --- a/crates/nexum-venue-sdk/tests/adapter.rs +++ b/crates/nexum-venue-sdk/tests/adapter.rs @@ -9,7 +9,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ AuthScheme, BodyError, ClientError, Config, Fault, IntentBody, IntentClient, IntentHeader, - IntentStatus, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, + IntentStatus, Quotation, Settlement, SubmitOutcome, VenueAdapter, VenueClient, VenueError, }; /// First published body version: a fixed-price quote. @@ -75,6 +75,23 @@ impl VenueAdapter for DemoAdapter { }) } + fn quote(body: Vec) -> Result { + let (amount_wei, valid_until_ms) = Self::decode(&body)?; + let zero = AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }; + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: amount_wei.to_be_bytes().to_vec(), + }, + wants: zero.clone(), + fee: zero, + valid_until_ms: valid_until_ms.unwrap_or(u64::MAX), + }) + } + fn submit(body: Vec) -> Result { Self::decode(&body)?; Ok(SubmitOutcome::Accepted(RECEIPT.to_vec())) @@ -106,6 +123,13 @@ nexum_venue_sdk::export_venue_adapter!(DemoAdapter); struct InProcessClient; impl VenueClient for InProcessClient { + fn quote(&self, venue: &str, body: Vec) -> Result { + if venue != "demo" { + return Err(VenueError::UnknownVenue); + } + DemoAdapter::quote(body) + } + fn submit(&self, venue: &str, body: Vec) -> Result { if venue != "demo" { return Err(VenueError::UnknownVenue); @@ -234,6 +258,27 @@ fn typed_client_round_trips_through_the_client_seam() { )); } +#[test] +fn quote_typestate_prices_then_submits_the_quoted_body() { + fn drive(client: &IntentClient) -> Result { + // The typestate chain under test: a quotation is the only path + // from a priced body to its submission. + client.quote(&v2_body())?.submit() + } + + let client = IntentClient::new(InProcessClient, "demo"); + + let quoted = client.quote(&v2_body()).unwrap(); + assert_eq!( + quoted.quotation().gives.amount, + 1_000_000u64.to_be_bytes().to_vec() + ); + assert_eq!(quoted.quotation().valid_until_ms, 1_700_000_000_000); + + let outcome = drive(&client).unwrap(); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); +} + #[test] fn unbound_venue_is_unknown_at_the_client() { let client = IntentClient::new(InProcessClient, "nowhere"); diff --git a/crates/nexum-venue-test/tests/conformance.rs b/crates/nexum-venue-test/tests/conformance.rs index d6c37040..8f0a6b3f 100644 --- a/crates/nexum-venue-test/tests/conformance.rs +++ b/crates/nexum-venue-test/tests/conformance.rs @@ -5,7 +5,8 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{ - AuthScheme, Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError, + AuthScheme, Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, + VenueError, }; use nexum_venue_test::reference::{ CODEC_VECTORS_JSON, HEADER_GOLDENS_JSON, ReferenceBody, derive_reference_header, @@ -26,6 +27,19 @@ impl VenueAdapter for ReferenceAdapter { derive_reference_header(body) } + fn quote(body: Vec) -> Result { + let header = derive_reference_header(body)?; + Ok(Quotation { + gives: header.gives, + wants: header.wants, + fee: AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + }, + valid_until_ms: u64::MAX, + }) + } + fn submit(body: Vec) -> Result { Ok(SubmitOutcome::Accepted(body)) } diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 6a769517..185e22da 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -1,8 +1,8 @@ //! # echo-client (reference Shepherd intent module) //! -//! The keeper half of the echo pair. On every chain-1 block it submits an -//! opaque body through `videre:venue/client` to the `echo-venue` adapter and -//! logs the receipt, and it logs each `intent-status` transition the +//! The keeper half of the echo pair. On every chain-1 block it quotes and +//! submits an opaque body through `videre:venue/client` to the `echo-venue` +//! adapter and logs the receipt, and it logs each `intent-status` transition the //! registry fans back from that venue. Paired with the echo-venue adapter it //! is the smallest end-to-end demonstration of the intent core: module -> //! host registry -> venue adapter, and the status event back. @@ -33,6 +33,20 @@ impl EchoClient { // receipt, so the body content is immaterial; the block number keeps // it non-empty and legible in the logs. let body = block.number.to_be_bytes().to_vec(); + match client::quote(ECHO_VENUE, &body) { + Ok(quotation) => logging::log( + logging::Level::Info, + &format!( + "quoted {} bytes at {ECHO_VENUE}: gives {} amount bytes", + body.len(), + quotation.gives.amount.len(), + ), + ), + Err(_) => logging::log( + logging::Level::Warn, + &format!("quote at {ECHO_VENUE} was refused"), + ), + } match client::submit(ECHO_VENUE, &body) { Ok(SubmitOutcome::Accepted(receipt)) => logging::log( logging::Level::Info, diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index d99726ad..c3e675c7 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -20,7 +20,7 @@ use nexum::host::chain; use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Settlement, SubmitOutcome, VenueError, + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, }; use videre::value_flow::types::{Asset, AssetAmount}; @@ -42,15 +42,26 @@ impl EchoVenue { asset: Asset::Native, amount: minimal_be(body.len() as u64), }, - wants: AssetAmount { - asset: Asset::Native, - amount: Vec::new(), - }, + wants: zero_native(), settlement: Settlement { chain: 1 }, authorisation: AuthScheme::Eip1271, }) } + fn quote(body: Vec) -> Result { + // Echo pricing mirrors the header: gives the body length, wants + // nothing, charges no fee, and the quote never expires. + Ok(Quotation { + gives: AssetAmount { + asset: Asset::Native, + amount: minimal_be(body.len() as u64), + }, + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + fn submit(body: Vec) -> Result { // Reading chain state on submit is what justifies the declared // `chain` capability; the block height is discarded, the point is @@ -71,6 +82,14 @@ impl EchoVenue { } } +/// A zero native amount: the venue's spelling of "nothing". +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} + /// Big-endian bytes with leading zeros trimmed: the minimal `uint` /// spelling, where an empty list is zero. fn minimal_be(value: u64) -> Vec { diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 695da46a..1f01b9cc 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -3,8 +3,9 @@ package videre:venue@0.1.0; /// Worker (keeper) face. The host holds the venue registry; the keeper names /// a venue by string. interface client { - use videre:types/types@0.1.0.{receipt, intent-status, submit-outcome, venue-error}; + use videre:types/types@0.1.0.{quotation, receipt, intent-status, submit-outcome, venue-error}; + quote: func(venue: string, body: list) -> result; submit: func(venue: string, body: list) -> result; status: func(venue: string, receipt: receipt) -> result; cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; @@ -14,10 +15,11 @@ interface client { /// installed adapter answers for exactly one venue, so the registry resolves /// a venue id to its adapter and calls it directly. interface adapter { - use videre:types/types@0.1.0.{intent-header, receipt, intent-status, submit-outcome, venue-error}; + use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; /// Pure: derive the guard-facing header from a body. No I/O. derive-header: func(body: list) -> result; + quote: func(body: list) -> result; submit: func(body: list) -> result; status: func(receipt: receipt) -> result; cancel: func(receipt: receipt) -> result<_, venue-error>; From 497bf9b1d064e316d0d1d388605c2160aba7bbb2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 05:54:18 +0000 Subject: [PATCH 033/139] engine: advisory venue-agnostic acyclicity check (#432) * ci: add the advisory venue-agnostic check for nexum-runtime A local command (scripts/check-venue-agnostic.sh, just check-venue-agnostic) and a non-blocking CI job assert nexum-runtime is venue-agnostic: its crate graph reaches no videre/intent/venue/cow crate, its sources carry no venue symbol, and nexum:host resolves as a leaf WIT package. The symbol scan is red by design until the physical host cut lands; the flip to a blocking gate is tracked for M2. * ci: fail the crate-graph check when cargo tree errors * ci: fail the scan steps when rg errors instead of finding nothing * ci: scan crate graph with --all-features so feature-gated venue deps cannot slip the guard --- .github/workflows/ci.yml | 16 +++++++++ justfile | 5 +++ scripts/check-venue-agnostic.sh | 61 +++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100755 scripts/check-venue-agnostic.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9246fdc..186bce4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,19 @@ jobs: CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu + + # Advisory guard that nexum-runtime stays venue-agnostic: crate graph, symbol + # scan, and nexum:host WIT leaf-ness (scripts/check-venue-agnostic.sh). + # `continue-on-error` keeps this a signal, not a gate, until the physical + # host cut lands; the flip to a blocking gate is tracked for M2. + venue-agnostic: + name: venue-agnostic (advisory) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: wasm-tools,ripgrep + - run: ./scripts/check-venue-agnostic.sh diff --git a/justfile b/justfile index a89e8061..964bba79 100644 --- a/justfile +++ b/justfile @@ -71,6 +71,11 @@ build-e2e: build-m2 build-m3 run-e2e: build-e2e build-engine cargo run -p nexum-cli -- --engine-config engine.e2e.toml +# Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and +# the nexum:host WIT leaf. Advisory in CI until the physical cut lands. +check-venue-agnostic: + ./scripts/check-venue-agnostic.sh + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh new file mode 100755 index 00000000..e3feef76 --- /dev/null +++ b/scripts/check-venue-agnostic.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Venue-agnosticism check for nexum-runtime: the crate graph reaches no +# videre/intent/venue/cow crate, the sources carry no venue symbol, and +# nexum:host resolves as a leaf WIT package. Advisory in CI until the +# physical cut lands; run locally via `just check-venue-agnostic`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[l1 PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[l1 FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +# 1. Crate graph: nothing venue-shaped reachable from nexum-runtime +# (normal + build edges; dev-deps stay local to the crate). +if tree="$(cargo tree -p nexum-runtime -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "crate graph clean" + fi +else + fail "cargo tree failed" +fi + +# 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes +# skip std::borrow::Cow, ProviderError, and "intentional". +symbols='\b[Vv]idere|\b[Ii]ntent([_A-Z-]|s?\b)|\b[Vv]enue|\bcow|CoW|\bCow[A-Z]' +rg -n --no-heading -e "$symbols" crates/nexum-runtime +case $? in + 0) fail "venue symbols leak into nexum-runtime" ;; + 1) pass "symbol scan empty" ;; + *) fail "symbol scan errored (crates/nexum-runtime missing?)" ;; +esac + +# 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the +# package resolves standalone. +rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host +case $? in + 0) fail "nexum:host references another WIT package" ;; + 1) pass "nexum:host has no cross-package reference" ;; + *) fail "WIT scan errored (wit/nexum-host missing?)" ;; +esac +if command -v wasm-tools >/dev/null; then + if wasm-tools component wit wit/nexum-host >/dev/null; then + pass "nexum:host resolves standalone" + else + fail "nexum:host does not resolve standalone" + fi +else + printf '\033[1;33m[l1 WARN]\033[0m wasm-tools not found; WIT resolve skipped\n' >&2 +fi + +exit "$status" From 0d35086e27cfbce7f401b5e7c0c4895af82b94fc Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 05:57:20 +0000 Subject: [PATCH 034/139] runtime: make the egress-guard checkpoint advisory-only (#433) A deny verdict at the venue-registry guard seam now logs a would-deny and lets the submission proceed. The checkpoint does not enforce until the egress-guard epic installs the real policy pipeline; the seam docs and the venue-adapter docs say so. --- .../src/host/impls/venue_client.rs | 4 +- .../nexum-runtime/src/host/venue_registry.rs | 45 ++++++++++++------- crates/nexum-venue-sdk/src/adapter.rs | 3 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs index fddc8cfb..0fac7d94 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -2,8 +2,8 @@ //! thin delegation to the shared //! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the //! registry owns the venue resolution, per-adapter serialisation, guard -//! seam, and quota. The caller identity the registry meters against is this -//! store's module namespace. +//! seam (advisory-only for now), and quota. The caller identity the registry +//! meters against is this store's module namespace. use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 9cb303c0..57e029e3 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -4,7 +4,8 @@ //! A module's `client::submit(venue, body)` reaches the host here. The //! registry resolves the venue id to the one installed adapter that answers //! for it, then drives a fixed sequence against that adapter: derive the -//! header, run the guard interposition seam on it, and only then submit. +//! header, run the guard interposition seam on it (advisory-only for now: +//! see [`EgressGuard`]), and only then submit. //! Status and cancel are pass-throughs; they are not submissions, so they //! skip the header, the guard, and the quota. //! @@ -103,10 +104,13 @@ impl Default for SubmitQuota { } /// The guard interposition seam. The registry runs this on the -/// adapter-derived header after `derive-header` and before `submit`. The -/// shipped policy is the unit guard, which allows every egress; the -/// egress-guard epic replaces the installed policy with the real -/// facts-plus-analysers pipeline without the registry changing shape. +/// adapter-derived header after `derive-header` and before `submit`. +/// +/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is +/// logged as a would-deny and the submission proceeds. The shipped policy is +/// the unit guard, which allows every egress; the egress-guard epic installs +/// the real facts-plus-analysers pipeline and turns the verdict enforcing, +/// without the registry changing shape. pub trait EgressGuard: Send + Sync { /// Decide whether the derived header may proceed to the adapter's submit. fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; @@ -135,7 +139,8 @@ pub struct GuardContext<'a> { pub enum GuardVerdict { /// Forward the submission to the adapter. Allow, - /// Refuse the egress with an operator-facing reason. + /// Refuse the egress with an operator-facing reason. Logged, not + /// enforced, while the seam is advisory-only. Deny(String), } @@ -393,7 +398,8 @@ impl VenueRegistry { /// Submit an opaque body to `venue` on behalf of `caller`: resolve the /// adapter, gate on the caller's quota, derive the header, run the guard - /// seam, then forward to the adapter. A decode failure is charged to the + /// seam (advisory-only: a deny logs and the submission proceeds), then + /// forward to the adapter. A decode failure is charged to the /// caller before returning, so a caller feeding garbage exhausts its own /// budget and is stopped at the gate on the next call rather than /// re-invoking the adapter. @@ -437,8 +443,14 @@ impl VenueRegistry { venue, header: &header, }; + // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { - return Err(VenueError::Denied(reason)); + warn!( + caller, + venue = %venue, + reason, + "egress guard would deny - advisory-only, submission proceeds", + ); } // A forwarded submission consumes one unit of the caller's budget. self.charge(caller); @@ -651,7 +663,8 @@ impl VenueRegistryBuilder { } /// Override the guard policy. The egress-guard epic wires the real - /// pipeline through here; tests inject a denying policy to prove the seam. + /// pipeline through here; tests inject a denying policy to prove the + /// advisory seam. pub fn with_guard(mut self, guard: Arc) -> Self { self.guard = guard; self @@ -939,7 +952,7 @@ mod tests { } #[tokio::test] - async fn guard_deny_blocks_submit_after_deriving_the_header() { + async fn guard_deny_is_advisory_and_does_not_block_submit() { let calls = Arc::new(StubCalls::default()); let registry = registry_with( SubmitQuota::default(), @@ -947,16 +960,16 @@ mod tests { StubAdapter::new(calls.clone()), ); - let err = registry + let outcome = registry .submit("mod-a", &cow(), b"body".to_vec()) .await - .expect_err("guard denies"); + .expect("advisory deny does not block"); - assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy"))); - // The seam runs on the derived header, then blocks: derive ran, submit - // did not. + // The seam runs on the derived header but only logs: derive ran and + // the submission still reached the adapter. + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); assert_eq!(calls.derive.load(Ordering::SeqCst), 1); - assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); } #[tokio::test] diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 744c77e7..1a7195b9 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -24,7 +24,8 @@ pub trait VenueAdapter { /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to - /// submit. + /// submit. The host's guard checkpoint is advisory-only until the + /// egress-guard epic lands: a would-deny is logged, not enforced. fn derive_header(body: Vec) -> Result; /// Price an opaque intent body: an indicative quotation, not an From 6f63102c3d6ae00f1c9e5244a40804b8cd55961f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 06:00:14 +0000 Subject: [PATCH 035/139] runtime: charge quota before guard check on submit (#435) * runtime: charge the caller's quota on a guard-deny The submission charge moves ahead of the guard verdict at the venue registry, so a denied egress spends one unit exactly as an accepted submit does: a module spamming denied egress exhausts its own budget instead of looping free once the guard turns enforcing. A regression test pins the rate limit on a repeated-deny loop. * runtime: tersen the submit charge docs --- .../nexum-runtime/src/host/venue_registry.rs | 48 +++++++++++++++---- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 57e029e3..cfb24ec6 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -404,13 +404,10 @@ impl VenueRegistry { /// budget and is stopped at the gate on the next call rather than /// re-invoking the adapter. /// - /// Charging is deliberately asymmetric across the two stages. Once the - /// 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. + /// Charged once the header derives, ahead of the guard and adapter, so + /// a deny (when enforcing) or a venue outage is never a free retry. + /// Derive-stage venue errors other than a decode failure are left + /// uncharged and retryable. pub async fn submit( &self, caller: &str, @@ -443,6 +440,8 @@ impl VenueRegistry { venue, header: &header, }; + // Charge before the guard so an enforcing deny stays non-free. + self.charge(caller); // Advisory-only checkpoint: a deny is logged, never enforced. if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { warn!( @@ -452,8 +451,6 @@ impl VenueRegistry { "egress guard would deny - advisory-only, submission proceeds", ); } - // A forwarded submission consumes one unit of the caller's budget. - self.charge(caller); let outcome = adapter.submit(&body).await?; // An accepted receipt goes under status watch so subscribers see // its transitions; requires-signing has no receipt to watch yet. @@ -972,6 +969,39 @@ mod tests { assert_eq!(calls.submit.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn repeated_guard_denies_exhaust_the_caller_quota() { + let calls = Arc::new(StubCalls::default()); + let quota = SubmitQuota::new(2, Duration::from_secs(3600)); + let registry = registry_with( + quota, + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + // Each denied submit spends exactly one unit: the second is still + // admitted, so a deny is never double-charged. + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + assert!( + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .is_ok() + ); + // The deny loop is rate-limited at the gate, not free. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::RateLimited(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn quote_reaches_the_adapter_without_header_or_guard() { let calls = Arc::new(StubCalls::default()); From 06d339c5d843bc13464c0f444e18644b6c173ca0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 06:05:04 +0000 Subject: [PATCH 036/139] venue-test: version fixture files and reject empty vector sets (#437) * venue-test: version the fixture file format Fixture files lead with a format version; a reader fails closed on an unknown tag and refuses an empty vector or golden set, which would conform vacuously. Published reference fixtures regenerated. * venue-test: fail conformance checks on an empty fixture set * style: rustfmt the venue-test version-rejection tests --- .../goldens/reference-header.json | 1 + crates/nexum-venue-test/src/codec.rs | 68 ++++++++++++++++-- crates/nexum-venue-test/src/fixture.rs | 53 ++++++++++++-- crates/nexum-venue-test/src/header.rs | 69 +++++++++++++++++-- crates/nexum-venue-test/src/lib.rs | 2 +- .../vectors/reference-body.json | 1 + modules/examples/echo-venue/src/lib.rs | 6 +- 7 files changed, 181 insertions(+), 19 deletions(-) diff --git a/crates/nexum-venue-test/goldens/reference-header.json b/crates/nexum-venue-test/goldens/reference-header.json index ded49d81..a90bc3c4 100644 --- a/crates/nexum-venue-test/goldens/reference-header.json +++ b/crates/nexum-venue-test/goldens/reference-header.json @@ -1,4 +1,5 @@ { + "version": 1, "venue": "nexum-venue-test/reference", "goldens": [ { diff --git a/crates/nexum-venue-test/src/codec.rs b/crates/nexum-venue-test/src/codec.rs index 3041326e..52d46226 100644 --- a/crates/nexum-venue-test/src/codec.rs +++ b/crates/nexum-venue-test/src/codec.rs @@ -2,7 +2,8 @@ //! `IntentBody` wire bytes, and the check that holds a codec to them. //! //! A vector file is the venue's codec contract in portable form: JSON, -//! bytes as lowercase hex, one entry per published body. A non-Rust +//! a leading format version (unknown versions fail closed), bytes as +//! lowercase hex, one entry per published body, never zero. A non-Rust //! adapter author proves byte-exactness by decoding and re-encoding //! each `round-trip` vector in their own language and comparing bytes; //! a Rust author runs [`CodecVectors::assert_conforms`] against the @@ -15,17 +16,21 @@ use std::path::Path; use nexum_venue_sdk::{BodyError, IntentBody}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of codec vectors for one venue body schema. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CodecVectors { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// Name of the body schema the vectors bind, e.g. /// `acme-dex/order-body`. Informational: the check never reads it. pub schema: String, - /// The vectors, in publication order. + /// The vectors, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub vectors: Vec, } @@ -80,9 +85,11 @@ impl std::fmt::Display for Expectation { } impl CodecVectors { - /// An empty vector set for `schema`. + /// An empty vector set for `schema`. Push at least one vector + /// before publishing: a parsed file is never empty. pub fn new(schema: impl Into) -> Self { Self { + version: FormatVersion, schema: schema.into(), vectors: Vec::new(), } @@ -158,9 +165,16 @@ impl CodecVectors { /// /// A `round-trip` vector must decode and re-encode to the exact /// published bytes; a failure vector must produce the matching - /// [`BodyError`] case (the free-text detail is not compared). + /// [`BodyError`] case (the free-text detail is not compared). An + /// empty set is itself a violation: it would conform vacuously. pub fn check(&self) -> Result<(), ConformanceReport> { let mut violations = Vec::new(); + if self.vectors.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published vector set is empty".to_owned(), + }); + } for vector in &self.vectors { if let Err(detail) = vector.check::() { violations.push(Violation { @@ -317,6 +331,7 @@ mod tests { let json = vectors.to_json(); assert_eq!(CodecVectors::from_json(&json).unwrap(), vectors); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"round-trip\"")); assert!(json.contains("\"unknown-version\"")); assert!(json.contains("\"notes\": \"first published body\"")); @@ -343,4 +358,47 @@ mod tests { Err(FixtureError::Read { .. }), )); } + + #[test] + fn unknown_format_version_fails_closed() { + let json = published() + .to_json() + .replace("\"version\": 1", "\"version\": 2"); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("version 2 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 2")); + } + + #[test] + fn missing_format_version_fails() { + let json = published().to_json().replace(" \"version\": 1,\n", ""); + assert!(matches!( + CodecVectors::from_json(&json), + Err(FixtureError::Format(_)), + )); + } + + #[test] + fn empty_vector_set_fails_the_check() { + let report = CodecVectors::new("test/body").check::().unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "codec does not conform")] + fn assert_conforms_rejects_an_empty_set() { + CodecVectors::new("test/body").assert_conforms::(); + } + + #[test] + fn empty_vector_set_fails_to_parse() { + let json = CodecVectors::new("test/body").to_json(); + let Err(FixtureError::Format(detail)) = CodecVectors::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/fixture.rs b/crates/nexum-venue-test/src/fixture.rs index 7174573c..624ec1c9 100644 --- a/crates/nexum-venue-test/src/fixture.rs +++ b/crates/nexum-venue-test/src/fixture.rs @@ -1,11 +1,54 @@ -//! The shared fixture-file plumbing: JSON on disk, byte fields as -//! lowercase hex, and the typed [`FixtureError`] both file formats -//! load and save through. +//! The shared fixture-file plumbing: JSON on disk, a leading +//! [`FormatVersion`] (unknown versions fail closed), byte fields as +//! lowercase hex, non-empty entry lists, and the typed +//! [`FixtureError`] both file formats load and save through. use std::path::Path; -use serde::Serialize; -use serde::de::DeserializeOwned; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// The one published fixture file-format version. +const FORMAT_VERSION: u32 = 1; + +/// Fixture file-format discriminator: serializes as the current +/// version, refuses any other on parse (fail-closed), so a reader +/// never guesses at a future layout. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct FormatVersion; + +impl Serialize for FormatVersion { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u32(FORMAT_VERSION) + } +} + +impl<'de> Deserialize<'de> for FormatVersion { + fn deserialize>(deserializer: D) -> Result { + let version = u32::deserialize(deserializer)?; + if version == FORMAT_VERSION { + Ok(Self) + } else { + Err(D::Error::custom(format!( + "unknown fixture format version {version}; this reader speaks {FORMAT_VERSION}", + ))) + } + } +} + +/// Deserialize a fixture's entry list, refusing an empty one: an empty +/// set would conform vacuously. +pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + let entries = Vec::::deserialize(deserializer)?; + if entries.is_empty() { + return Err(D::Error::custom("a published fixture set is never empty")); + } + Ok(entries) +} /// Why a fixture file failed to load or save. The JSON case carries /// serde's rendered detail rather than the error value so the type diff --git a/crates/nexum-venue-test/src/header.rs b/crates/nexum-venue-test/src/header.rs index f2908622..6c32af65 100644 --- a/crates/nexum-venue-test/src/header.rs +++ b/crates/nexum-venue-test/src/header.rs @@ -4,8 +4,10 @@ //! //! A golden file pairs wire bodies with the intent header a conforming //! adapter derives from them, spelled in the golden mirror types below -//! (JSON, kebab-case case names matching the WIT, bytes as lowercase -//! hex). The mirrors exist because wit-bindgen types carry no serde; +//! (JSON, a leading format version that fails closed on an unknown tag, +//! kebab-case case names matching the WIT, bytes as lowercase hex, +//! never zero goldens). The mirrors exist because wit-bindgen types +//! carry no serde; //! [`GoldenHeader`] converts from the venue SDK's `IntentHeader`, and a //! macro-built adapter whose bindgen mints its own header type bridges //! with a field-for-field `From` impl on its crate boundary, the same @@ -18,17 +20,21 @@ use nexum_venue_sdk::value_flow::{Asset, AssetAmount}; use nexum_venue_sdk::{AuthScheme, IntentHeader, Settlement}; use serde::{Deserialize, Serialize}; -use crate::fixture::{self, FixtureError, hex_bytes}; +use crate::fixture::{self, FixtureError, FormatVersion, hex_bytes}; use crate::report::{ConformanceReport, Violation, settle}; /// A published set of header goldens for one venue. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HeaderGoldens { + /// File-format discriminator; an unknown version fails to parse. + pub version: FormatVersion, /// The venue the goldens bind. Informational: the check never /// reads it. pub venue: String, - /// The goldens, in publication order. + /// The goldens, in publication order. Never empty in a parsed + /// file: an empty set would conform vacuously. + #[serde(deserialize_with = "fixture::non_empty")] pub goldens: Vec, } @@ -157,9 +163,11 @@ impl From for GoldenAuthScheme { } impl HeaderGoldens { - /// An empty golden set for `venue`. + /// An empty golden set for `venue`. Record at least one golden + /// before publishing: a parsed file is never empty. pub fn new(venue: impl Into) -> Self { Self { + version: FormatVersion, venue: venue.into(), goldens: Vec::new(), } @@ -211,7 +219,8 @@ impl HeaderGoldens { /// collecting all violations rather than stopping at the first. /// /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. + /// passes `MyAdapter::derive_header` directly. An empty set is + /// itself a violation: it would conform vacuously. pub fn check( &self, mut derive: impl FnMut(Vec) -> Result, @@ -221,6 +230,12 @@ impl HeaderGoldens { E: fmt::Debug, { let mut violations = Vec::new(); + if self.goldens.is_empty() { + violations.push(Violation { + vector: "".to_owned(), + detail: "published golden set is empty".to_owned(), + }); + } for golden in &self.goldens { match derive(golden.body.clone()) { Ok(header) => { @@ -288,6 +303,7 @@ mod tests { fn golden_mirror_covers_every_wire_case_and_round_trips_as_json() { let golden: GoldenHeader = wire_header().into(); let goldens = HeaderGoldens { + version: FormatVersion, venue: "acme".to_owned(), goldens: vec![HeaderGolden { name: "kitchen-sink".to_owned(), @@ -299,6 +315,7 @@ mod tests { let json = goldens.to_json(); assert_eq!(HeaderGoldens::from_json(&json).unwrap(), goldens); // The wire spellings are the contract for non-Rust readers. + assert!(json.contains("\"version\": 1")); assert!(json.contains("\"native\"")); assert!(json.contains("\"erc20\"")); assert!(json.contains("\"token\"")); @@ -359,4 +376,44 @@ mod tests { .unwrap(); goldens.assert_conforms(|_| Err::(VenueError::Timeout)); } + + #[test] + fn unknown_format_version_fails_closed() { + let mut goldens = HeaderGoldens::new("acme"); + goldens + .record("a", vec![1], |_| Ok::<_, VenueError>(wire_header())) + .unwrap(); + let json = goldens + .to_json() + .replace("\"version\": 1", "\"version\": 7"); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("version 7 must not parse"); + }; + assert!(detail.contains("unknown fixture format version 7")); + } + + #[test] + fn empty_golden_set_fails_the_check() { + let report = HeaderGoldens::new("acme") + .check(|_| Ok::<_, VenueError>(wire_header())) + .unwrap_err(); + assert_eq!(report.violations.len(), 1, "violations: {report}"); + assert_eq!(report.violations[0].vector, ""); + assert!(report.violations[0].detail.contains("empty")); + } + + #[test] + #[should_panic(expected = "derive-header does not conform")] + fn assert_conforms_rejects_an_empty_set() { + HeaderGoldens::new("acme").assert_conforms(|_| Ok::<_, VenueError>(wire_header())); + } + + #[test] + fn empty_golden_set_fails_to_parse() { + let json = HeaderGoldens::new("acme").to_json(); + let Err(FixtureError::Format(detail)) = HeaderGoldens::from_json(&json) else { + panic!("an empty set must not parse"); + }; + assert!(detail.contains("never empty")); + } } diff --git a/crates/nexum-venue-test/src/lib.rs b/crates/nexum-venue-test/src/lib.rs index e8c372e3..a4c4fd83 100644 --- a/crates/nexum-venue-test/src/lib.rs +++ b/crates/nexum-venue-test/src/lib.rs @@ -69,7 +69,7 @@ pub mod report; pub mod transport; pub use codec::{CodecVector, CodecVectors, Expectation}; -pub use fixture::FixtureError; +pub use fixture::{FixtureError, FormatVersion}; pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, diff --git a/crates/nexum-venue-test/vectors/reference-body.json b/crates/nexum-venue-test/vectors/reference-body.json index 297d32b3..a2ffd1db 100644 --- a/crates/nexum-venue-test/vectors/reference-body.json +++ b/crates/nexum-venue-test/vectors/reference-body.json @@ -1,4 +1,5 @@ { + "version": 1, "schema": "nexum-venue-test/reference-body", "vectors": [ { diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index c3e675c7..92b0e62c 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -108,8 +108,8 @@ fn minimal_be(value: u64) -> Vec { mod conformance { use super::*; use nexum_venue_test::{ - GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, - HeaderGolden, HeaderGoldens, + FormatVersion, GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, + GoldenSettlement, HeaderGolden, HeaderGoldens, }; fn asset_to_golden(asset: Asset) -> GoldenAsset { @@ -177,6 +177,7 @@ mod conformance { notes: Some("amount is the minimal big-endian body length".to_owned()), }; let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![golden], }; @@ -188,6 +189,7 @@ mod conformance { // A non-minimal amount is the classic uint bug; the golden must // reject it, proving the check has teeth on echo-venue. let goldens = HeaderGoldens { + version: FormatVersion, venue: "echo-venue".to_owned(), goldens: vec![HeaderGolden { name: "four-byte-body".to_owned(), From bb199b7a5167a8513ed67f4f0feeb92ee5d58cca Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 06:53:51 +0000 Subject: [PATCH 037/139] runtime: bound the status-watch set with a capped, expiring policy (#438) The registry's watch list grew without bound and was polled O(N) per cadence. Each watch now carries an eviction deadline and the set carries a cap, both operator-configurable via [limits.watch]; expired entries evict unpolled, at the cap a new watch is refused and logged rather than a live watch dropped, and every successful non-terminal poll pushes the deadline a full window out, so only a venue silent for the whole window expires. --- crates/nexum-runtime/src/engine_config.rs | 80 ++++- .../nexum-runtime/src/host/venue_registry.rs | 284 ++++++++++++++++-- crates/nexum-runtime/src/supervisor.rs | 3 +- 3 files changed, 341 insertions(+), 26 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 06cbc08a..7c75f301 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,7 +26,10 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::venue_registry::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, SubmitQuota}; +use crate::host::venue_registry::{ + DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, DEFAULT_WATCH_EXPIRY, + DEFAULT_WATCH_MAX_ENTRIES, SubmitQuota, WatchLimit, +}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; @@ -357,6 +360,9 @@ pub struct ModuleLimits { /// Router-driven intent status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, + /// Status-watch set bounds. + #[serde(default)] + pub watch: WatchLimitsSection, /// Per-module dispatch rate-limit thresholds. #[serde(default)] pub dispatch: DispatchLimitsSection, @@ -500,6 +506,23 @@ impl ModuleLimits { .unwrap_or(DEFAULT_QUOTA_WINDOW), ) } + + /// Resolved status-watch bounds (overrides or defaults). A zero + /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, + /// so a misconfigured bound still watches one receipt briefly rather + /// than nothing at all. + pub fn watch(&self) -> WatchLimit { + WatchLimit::new( + self.watch + .max_entries + .map(|n| n.max(1)) + .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES), + self.watch + .expiry_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_WATCH_EXPIRY), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -616,6 +639,22 @@ pub struct StatusPollSection { pub interval_ms: Option, } +/// `[limits.watch]` status-watch set bounds. Both optional; omitted +/// values resolve to the registry defaults via [`ModuleLimits::watch`] +/// and degenerate zeroes saturate up to a usable minimum. +/// +/// The registry watches each accepted receipt until a terminal status: +/// the cap bounds the per-cadence poll fan-out, and the expiry evicts a +/// watch whose venue never reports one. At the cap a new watch is +/// refused and logged; live watches are never dropped. +#[derive(Debug, Default, Deserialize)] +pub struct WatchLimitsSection { + /// Maximum receipts under status watch at once. + pub max_entries: Option, + /// Seconds one watch stays live before it is evicted unreported. + pub expiry_secs: Option, +} + /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both /// optional; omitted values resolve to the production defaults, and a /// degenerate zero saturates up to 1 via [`ModuleLimits::dispatch_rate`]. @@ -1110,6 +1149,45 @@ refill_per_sec = 0 assert_eq!(policy.refill_per_sec, 1); } + #[test] + fn watch_limits_default_when_absent() { + let watch = ModuleLimits::default().watch(); + assert_eq!(watch.max_entries, DEFAULT_WATCH_MAX_ENTRIES); + assert_eq!(watch.expiry, DEFAULT_WATCH_EXPIRY); + } + + #[test] + fn watch_limits_parse_with_overrides() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 32 +expiry_secs = 900 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 32); + assert_eq!(watch.expiry, Duration::from_secs(900)); + } + + #[test] + fn watch_limits_saturate_zero_up_to_one() { + // A zero cap would refuse every watch; a zero expiry would evict + // each watch before its first poll. Both saturate. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +max_entries = 0 +expiry_secs = 0 +"#, + ) + .expect("limits.watch parses"); + let watch = cfg.limits.watch(); + assert_eq!(watch.max_entries, 1); + assert_eq!(watch.expiry, Duration::from_secs(1)); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index cfb24ec6..f13d87aa 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -45,6 +45,10 @@ use crate::host::state::HostState; pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; /// Default sliding window the per-caller submission budget is counted over. pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default lifetime of one status watch before it is evicted unreported. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -103,6 +107,34 @@ impl Default for SubmitQuota { } } +/// Bounds on the status-watch set. The cap bounds the per-cadence poll +/// fan-out; the expiry evicts a watch whose venue has gone silent for a +/// whole window. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// How long a watch survives without a successful poll before it is + /// evicted unreported. + pub expiry: Duration, +} + +impl WatchLimit { + /// Pair a cap with the per-entry expiry. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self { + max_entries, + expiry, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// The guard interposition seam. The registry runs this on the /// adapter-derived header after `derive-header` and before `submit`. /// @@ -307,10 +339,14 @@ struct QuotaLedger { /// One receipt the registry polls for status transitions. `last` starts /// `None` so the first successful poll always reports, giving a /// subscriber the intent's current state without waiting for a change. +/// `expires_at` is the eviction deadline, pushed a full window out on +/// every successful non-terminal poll; `None` (deadline arithmetic +/// overflowed) never expires. struct WatchedIntent { venue: VenueId, receipt: Vec, last: Option, + expires_at: Option, } /// A polled status is terminal when the intent can never change again: @@ -350,8 +386,10 @@ struct VenueRegistryInner { guard: Arc, quota: SubmitQuota, ledger: Mutex, + watch_limit: WatchLimit, /// Receipts under status watch, appended by accepted submissions and - /// pruned as they reach a terminal status. + /// pruned as they reach a terminal status, expire, or overflow + /// [`WatchLimit`]. watched: Mutex>, } @@ -483,20 +521,39 @@ impl VenueRegistry { } /// Put a `(venue, receipt)` pair under status watch. Idempotent: a - /// re-submitted receipt keeps its existing watch entry. + /// re-submitted receipt keeps its existing watch entry. Bounded: + /// expired entries evict first, and at the cap the new watch is + /// refused and logged rather than an existing live watch dropped. fn watch(&self, venue: &VenueId, receipt: Vec) { - let mut watched = self.inner.watched.lock().expect("watch list poisoned"); - if watched - .iter() - .any(|w| w.venue == *venue && w.receipt == receipt) - { - return; + let (evicted, admitted) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + if watched + .iter() + .any(|w| w.venue == *venue && w.receipt == receipt) + { + (evicted, true) + } else if watched.len() < self.inner.watch_limit.max_entries { + watched.push(WatchedIntent { + venue: venue.clone(), + receipt, + last: None, + expires_at: Instant::now().checked_add(self.inner.watch_limit.expiry), + }); + (evicted, true) + } else { + (evicted, false) + } + }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } + if !admitted { + warn!( + venue = %venue, + "status watch set full - transitions for this receipt will not be reported", + ); } - watched.push(WatchedIntent { - venue: venue.clone(), - receipt, - last: None, - }); } /// Number of receipts currently under status watch. @@ -513,16 +570,22 @@ impl VenueRegistry { /// reported for that receipt (the first successful poll always /// reports). A terminal status is reported once and the receipt is /// dropped from the watch; a failure leaves the entry untouched for - /// the next cadence. + /// the next cadence. Expired entries are evicted unpolled and + /// unreported. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. - let snapshot: Vec<(VenueId, Vec)> = { - let watched = self.inner.watched.lock().expect("watch list poisoned"); - watched + let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { + let mut watched = self.inner.watched.lock().expect("watch list poisoned"); + let evicted = prune_expired(&mut watched); + let snapshot = watched .iter() .map(|w| (w.venue.clone(), w.receipt.clone())) - .collect() + .collect(); + (evicted, snapshot) }; + if evicted > 0 { + warn!(evicted, "expired status watches evicted"); + } let mut updates = Vec::new(); for (venue, receipt) in snapshot { // Installed adapters never leave the registry, so a resolve @@ -554,10 +617,11 @@ impl VenueRegistry { /// Fold one polled status into the watch entry: `Some(update)` when it /// differs from the last reported status, pruning the entry when the - /// status is terminal. `None` also covers an entry that disappeared - /// while the poll was in flight, and an update whose status body - /// failed to encode (the entry is left untouched for the next - /// cadence). + /// status is terminal and refreshing its eviction deadline otherwise, + /// so expiry only fires on a venue that has gone silent. `None` also + /// covers an entry that disappeared while the poll was in flight, and + /// an update whose status body failed to encode (the entry is left + /// untouched for the next cadence). fn record_polled_status( &self, venue: &VenueId, @@ -592,6 +656,7 @@ impl VenueRegistry { watched.remove(pos); } else { watched[pos].last = Some(status); + watched[pos].expires_at = Instant::now().checked_add(self.inner.watch_limit.expiry); } update } @@ -627,6 +692,15 @@ fn window_ms(window: Duration) -> u64 { u64::try_from(window.as_millis()).unwrap_or(u64::MAX) } +/// Drop watch entries whose eviction deadline has passed, returning how +/// many were evicted. +fn prune_expired(watched: &mut Vec) -> usize { + let now = Instant::now(); + let before = watched.len(); + watched.retain(|w| w.expires_at.is_none_or(|at| now < at)); + before - watched.len() +} + /// Drop charge timestamps that have aged out of the window. fn prune(history: &mut VecDeque, window: Duration) { let now = Instant::now(); @@ -647,15 +721,18 @@ pub struct VenueRegistryBuilder { adapters: HashMap, guard: Arc, quota: SubmitQuota, + watch_limit: WatchLimit, } impl VenueRegistryBuilder { - /// Start an empty builder with the given quota and the unit guard. + /// Start an empty builder with the given quota, the unit guard, and + /// the default watch limit. pub fn new(quota: SubmitQuota) -> Self { Self { adapters: HashMap::new(), guard: Arc::new(()), quota, + watch_limit: WatchLimit::default(), } } @@ -667,6 +744,12 @@ impl VenueRegistryBuilder { self } + /// Override the status-watch bounds. + pub fn with_watch_limit(mut self, watch_limit: WatchLimit) -> Self { + self.watch_limit = watch_limit; + self + } + /// Install an adapter under its venue id. Rejects a duplicate id: two /// adapters answering the same venue would silently shadow one another, /// which is a config error worth failing boot over. @@ -692,11 +775,19 @@ impl VenueRegistryBuilder { warn!("submission quota max_charges is 0; clamping to 1"); } let quota = SubmitQuota::new(self.quota.max_charges.max(1), self.quota.window); + if self.watch_limit.max_entries == 0 { + // A zero cap would refuse every watch; saturate up to one so a + // misconfigured bound still tracks a single receipt. + warn!("watch limit max_entries is 0; clamping to 1"); + } + let watch_limit = + WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); VenueRegistry { inner: Arc::new(VenueRegistryInner { adapters: self.adapters, guard: self.guard, quota, + watch_limit, ledger: Mutex::new(QuotaLedger::default()), watched: Mutex::new(Vec::new()), }), @@ -762,6 +853,9 @@ mod tests { calls: Arc, derive: Result, submit: Result, + /// Accept each submission with its body as the receipt, so one + /// stub can mint distinct receipts. + echo_receipt: bool, /// Statuses served front-first by consecutive `status` calls; /// once drained, every further call reports `open`. status_script: VecDeque>, @@ -773,10 +867,16 @@ mod tests { calls, derive: Ok(header()), submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + echo_receipt: false, status_script: VecDeque::new(), } } + fn with_receipt_echo(mut self) -> Self { + self.echo_receipt = true; + self + } + fn with_derive(mut self, derive: Result) -> Self { self.derive = derive; self @@ -830,11 +930,14 @@ mod tests { fn submit<'a>( &'a mut self, - _body: &'a [u8], + body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { self.calls.submit.fetch_add(1, Ordering::SeqCst); self.enter().await; + if self.echo_receipt { + return Ok(SubmitOutcome::Accepted(body.to_vec())); + } self.submit.clone() }) } @@ -1226,6 +1329,14 @@ mod tests { assert_eq!(registry.inner.quota.max_charges, 1); } + #[test] + fn zero_watch_cap_saturates_to_one() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(0, DEFAULT_WATCH_EXPIRY)) + .build(); + assert_eq!(registry.inner.watch_limit.max_entries, 1); + } + // ── status watch + polling ──────────────────────────────────────── #[tokio::test] @@ -1353,6 +1464,131 @@ mod tests { assert_eq!(decoded(&updates[0]), plain(Lifecycle::Open)); } + /// A registry with the given watch bounds and one echo-receipt-capable + /// stub adapter under `cow`. + fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { + let mut builder = + VenueRegistryBuilder::new(SubmitQuota::default()).with_watch_limit(watch_limit); + builder.install(cow(), adapter).expect("install adapter"); + builder.build() + } + + #[tokio::test] + async fn watch_cap_refuses_the_overflow_and_never_drops_live_watches() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_receipt_echo() + .with_status_script([Ok(IntentStatus::Pending), Ok(IntentStatus::Pending)]); + let limit = WatchLimit::new(2, Duration::from_secs(3600)); + let registry = watch_bounded_registry(limit, adapter); + + for body in [b"a".to_vec(), b"b".to_vec(), b"c".to_vec()] { + registry + .submit("mod-a", &cow(), body) + .await + .expect("submit succeeds"); + } + assert_eq!(registry.watched_count(), 2, "the cap bounds the set"); + + // The live pending watches kept their tracking; only the overflow + // watch was refused. + let updates = registry.poll_status_transitions().await; + let receipts: Vec<&[u8]> = updates.iter().map(|u| u.receipt.as_slice()).collect(); + assert_eq!(receipts, vec![b"a".as_slice(), b"b".as_slice()]); + assert!( + updates + .iter() + .all(|u| decoded(u) == plain(Lifecycle::Pending)) + ); + } + + #[tokio::test] + async fn pending_polls_keep_a_live_watch_across_expiry_windows() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls).with_status_script([ + Ok(IntentStatus::Pending), + Ok(IntentStatus::Pending), + Ok(IntentStatus::Fulfilled), + ]); + let expiry = Duration::from_secs(1); + let registry = watch_bounded_registry(WatchLimit::new(8, expiry), adapter); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let deadline_at = |registry: &VenueRegistry| { + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + watched[0].expires_at + }; + let inserted = deadline_at(®istry); + + // Two pending polls, each pushing the deadline a full window out. + let mut reported = Vec::new(); + for _ in 0..2 { + reported.extend(registry.poll_status_transitions().await); + assert_eq!( + registry.watched_count(), + 1, + "a reporting venue stays watched" + ); + assert!( + deadline_at(®istry) > inserted, + "the poll refreshed the deadline" + ); + tokio::time::sleep(expiry * 7 / 10).await; + } + + // Well past the insert-time window, the terminal transition still + // reports and prunes the watch. + reported.extend(registry.poll_status_transitions().await); + let statuses: Vec = reported.iter().map(decoded).collect(); + assert_eq!( + statuses, + vec![plain(Lifecycle::Pending), plain(Lifecycle::Fulfilled)], + ); + assert_eq!(registry.watched_count(), 0); + } + + #[tokio::test] + async fn expired_watches_are_evicted_unpolled() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(8, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls.clone())); + + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + assert_eq!(registry.watched_count(), 1); + + // The entry expired before the cadence: evicted without a venue call. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!(registry.watched_count(), 0); + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn expiry_frees_room_at_the_cap() { + let calls = Arc::new(StubCalls::default()); + let limit = WatchLimit::new(1, Duration::ZERO); + let registry = watch_bounded_registry(limit, StubAdapter::new(calls).with_receipt_echo()); + + registry + .submit("mod-a", &cow(), b"a".to_vec()) + .await + .expect("submit succeeds"); + registry + .submit("mod-a", &cow(), b"b".to_vec()) + .await + .expect("submit succeeds"); + + // The expired first watch was evicted at insert, admitting the second. + let watched = registry.inner.watched.lock().expect("watch list poisoned"); + assert_eq!(watched.len(), 1); + assert_eq!(watched[0].receipt, b"b"); + } + #[test] fn every_lifecycle_state_lowers_onto_the_status_body() { for (wire, lowered) in [ diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 363b0cc9..87f1fac7 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -289,7 +289,8 @@ impl Supervisor { // client face. let adapter_linker = build_adapter_linker::(engine)?; let adapter_registry = CapabilityRegistry::adapter(); - let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()); + let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { From f51c2fb2e4b70c1da707a8745be2843ded03ad1e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 07:04:40 +0000 Subject: [PATCH 038/139] runtime: grow the extension seam to carry worker and provider roles (#439) feat: grow the extension seam to carry worker and provider roles The Extension seam becomes a trait contributing a namespace, a capability namespace, a linker hook, an optional HostService, and an optional ProviderKind. HostService is a sync, type-erased service held on a typed per-namespace HostState.services map built once at boot; ProviderKind carries a provider linker hook plus the one cold dyn async_trait install path. The worker boot path is unchanged. --- Cargo.lock | 1 + Cargo.toml | 3 + crates/nexum-runtime/Cargo.toml | 1 + crates/nexum-runtime/src/bootstrap.rs | 3 +- crates/nexum-runtime/src/builder.rs | 26 ++- crates/nexum-runtime/src/host/extension.rs | 209 +++++++++++++++--- crates/nexum-runtime/src/host/state.rs | 4 + crates/nexum-runtime/src/supervisor.rs | 37 +++- crates/nexum-runtime/src/supervisor/tests.rs | 2 +- .../nexum-runtime/src/test_utils/harness.rs | 44 ++-- crates/shepherd-cow-host/src/ext_cow.rs | 53 +++-- crates/shepherd-cow-host/tests/cow_boot.rs | 3 +- 12 files changed, 306 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47718667..8f3c72a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3597,6 +3597,7 @@ dependencies = [ "alloy-transport", "alloy-transport-ws", "anyhow", + "async-trait", "bytes", "futures", "http", diff --git a/Cargo.toml b/Cargo.toml index 43f68852..2519fd97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ anyhow = "1" thiserror = "2" tokio = { version = "1", features = ["full"] } futures = "0.3" +# Cold `dyn` boot paths only (`ProviderKind::install`); hot guest traits +# use native async-fn-in-trait. +async-trait = "0.1" # Serde + config. serde = { version = "1", features = ["derive"] } diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 7abab005..7b66aa90 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -22,6 +22,7 @@ wasmtime-wasi-http.workspace = true # Async + error plumbing. anyhow.workspace = true thiserror.workspace = true +async-trait.workspace = true # `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) # free via a snake_case `&'static str` for every variant. Used at # `tracing::warn!(error_kind = .into(), ...)` sites and diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 532e5582..4e0c3c18 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -10,6 +10,7 @@ //! [`LaunchRuntime`] directly. use std::path::Path; +use std::sync::Arc; use crate::addons::RuntimeAddOn; use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; @@ -34,7 +35,7 @@ pub async fn run( wasm: Option<&Path>, manifest: Option<&Path>, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], add_ons: &[&dyn RuntimeAddOn], ) -> anyhow::Result<()> { let runtime = AssembledRuntime { diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 34ad617b..412c4d8f 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -18,6 +18,7 @@ use std::future::{Future, IntoFuture}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::Duration; use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; @@ -127,8 +128,9 @@ fn finish_wait(joined: Option) -> anyhow::Result<()> { pub struct AssembledRuntime<'a, T: RuntimeTypes> { /// Shared backends threaded into every module store. pub components: Components, - /// Linker hooks and capability namespaces. - pub extensions: Vec>, + /// Extensions: namespaces, capabilities, linker hooks, services, and + /// provider kinds. + pub extensions: Vec>>, /// Cross-cutting facilities installed before the engine boots. pub add_ons: &'a [&'a dyn RuntimeAddOn], /// Single-module source override; `None` runs `[[modules]]`. @@ -386,7 +388,7 @@ impl<'a> RuntimeBuilder<'a> { /// optional extension hooks and module source before [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -394,11 +396,10 @@ pub struct PresetBuilder<'a, R: Runtime> { } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extension linker hooks and capability namespaces on top of the - /// preset. The default preset carries none. + /// Add extensions on top of the preset. The default preset carries none. pub fn with_extensions( mut self, - extensions: impl IntoIterator>, + extensions: impl IntoIterator>>, ) -> Self { self.extensions.extend(extensions); self @@ -459,7 +460,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { /// may be added before the component builders. pub struct TypedBuilder<'a, T: RuntimeTypes> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -467,8 +468,11 @@ pub struct TypedBuilder<'a, T: RuntimeTypes> { } impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { - /// Add the extension linker hooks and capability namespaces. - pub fn with_extensions(mut self, extensions: impl IntoIterator>) -> Self { + /// Add the extensions. + pub fn with_extensions( + mut self, + extensions: impl IntoIterator>>, + ) -> Self { self.extensions.extend(extensions); self } @@ -509,7 +513,7 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { /// The component builders are bound; the add-on set remains. pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, @@ -536,7 +540,7 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// runs. pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { config: &'a EngineConfig, - extensions: Vec>, + extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index f5b1b806..6f57e6a8 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,39 +1,196 @@ -//! The extension seam: a linker hook plus the capability namespace an -//! extension contributes, assembled at the composition root and threaded -//! into every module linker. +//! The extension seam: what one extension contributes to the host - a +//! namespace, a capability namespace, a linker hook, an optional host +//! service, and an optional provider kind. Assembled at the composition +//! root and threaded into every module linker. +use std::any::Any; +use std::collections::BTreeMap; use std::sync::Arc; -use wasmtime::component::Linker; +use async_trait::async_trait; +use wasmtime::Store; +use wasmtime::component::{Component, Linker}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::NamespaceCaps; -/// Adds an extension's WIT interfaces to a module linker. Runs after the -/// core interfaces and before instantiation. Takes only `&mut Linker`, so -/// the seam stays compatible with a future per-extension router that -/// serialises access to the non-`Sync` wasmtime `Store`. -pub type LinkerHook = Arc>) -> anyhow::Result<()> + Send + Sync>; - -/// One runtime extension: how to wire its interfaces into a module linker, -/// and the capability namespace enforcement must recognise for it. The two -/// travel together: a module that imports an extension interface boots only -/// if the linker entry AND the capability namespace are both registered -/// before instantiation. -pub struct Extension { - /// Linker contribution: adds the extension's imports to a module linker. - pub link: LinkerHook, - /// Capability namespace this extension owns, merged into enforcement so - /// a module importing the extension's interfaces still validates. - pub capabilities: NamespaceCaps, +/// One runtime extension. A module that imports an extension interface +/// boots only if the linker entry AND the capability namespace are both +/// registered before instantiation. +pub trait Extension: Send + Sync + 'static { + /// Namespace this extension owns; keys its service in [`HostServices`]. + fn namespace(&self) -> &'static str; + + /// Capability namespace merged into enforcement so a module importing + /// the extension's interfaces still validates. + fn capabilities(&self) -> NamespaceCaps; + + /// Adds the extension's imports to a worker linker. Runs after the + /// core interfaces and before instantiation. Takes only `&mut Linker`, + /// so the seam stays compatible with a future per-extension router + /// that serializes access to the non-`Sync` wasmtime `Store`. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Host service this extension owns, published under its namespace on + /// [`HostServices`]. + fn service(&self) -> Option> { + None + } + + /// Provider kind this extension installs. + fn provider(&self) -> Option>> { + None + } +} + +/// A type-erased host service an extension owns. Held per namespace on +/// `HostState::services` and downcast at the call site. Kept synchronous +/// so it stays `dyn`-compatible. +pub trait HostService: Any + Send + Sync + 'static {} + +/// A provider component kind: the host holds an instance behind the owning +/// extension's serialized service; others call it. `async_trait` carries +/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. +#[async_trait] +pub trait ProviderKind: Send + Sync + 'static { + /// Manifest kind this provider answers for. + fn kind(&self) -> &'static str; + + /// Adds the provider's imports to a provider linker. + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; + + /// Install one instantiated provider behind the extension's service. + async fn install( + &self, + component: &Component, + store: Store>, + service: &Arc, + ) -> anyhow::Result<()>; +} + +/// Immutable per-namespace service map: each extension's [`HostService`] +/// under its [`Extension::namespace`], built once at boot and shared by +/// every module store. +#[derive(Clone, Default)] +pub struct HostServices(Arc>>); + +impl std::fmt::Debug for HostServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_set().entries(self.0.keys()).finish() + } +} + +impl HostServices { + /// Collect each extension's service under its namespace. Refuses a + /// duplicate namespace. + pub fn from_extensions( + extensions: &[Arc>], + ) -> anyhow::Result { + let mut map = BTreeMap::new(); + for ext in extensions { + let Some(service) = ext.service() else { + continue; + }; + let namespace = ext.namespace(); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + } + Ok(Self(Arc::new(map))) + } + + /// The service under `namespace`, downcast to its concrete type. + /// `None` when the namespace is absent or the type does not match. + pub fn get(&self, namespace: &str) -> Option> { + let service = Arc::clone(self.0.get(namespace)?); + let erased: Arc = service; + erased.downcast().ok() + } + + /// The raw type-erased service under `namespace`. + pub fn raw(&self, namespace: &str) -> Option<&Arc> { + self.0.get(namespace) + } } -impl Clone for Extension { - fn clone(&self) -> Self { - Self { - link: Arc::clone(&self.link), - capabilities: self.capabilities, +#[cfg(test)] +mod tests { + use super::*; + use crate::supervisor::TestTypes; + + struct Registry(u64); + impl HostService for Registry {} + + struct Clockwork; + impl HostService for Clockwork {} + + struct ServiceExt { + namespace: &'static str, + service: Option>, + } + + impl Extension for ServiceExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) } + fn service(&self) -> Option> { + self.service.as_ref().map(Arc::clone) + } + } + + fn ext( + namespace: &'static str, + service: Arc, + ) -> Arc> { + Arc::new(ServiceExt { + namespace, + service: Some(service), + }) + } + + /// A registered service comes back under its namespace, downcast to + /// its concrete type; a wrong type or an absent namespace is `None`. + #[test] + fn get_downcasts_by_namespace() { + let services = + HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build"); + + let registry = services.get::("videre").expect("registered"); + assert_eq!(registry.0, 7); + assert!(services.get::("videre").is_none()); + assert!(services.get::("absent").is_none()); + assert!(services.raw("videre").is_some()); + } + + /// A serviceless extension contributes nothing to the map. + #[test] + fn serviceless_extension_is_absent() { + let serviceless: Arc> = Arc::new(ServiceExt { + namespace: "quiet", + service: None, + }); + let services = HostServices::from_extensions(&[serviceless]).expect("build"); + assert!(services.raw("quiet").is_none()); + } + + /// Two services under one namespace refuse to build. + #[test] + fn duplicate_namespace_is_refused() { + let err = HostServices::from_extensions(&[ + ext("videre", Arc::new(Registry(1))), + ext("videre", Arc::new(Clockwork)), + ]) + .expect_err("duplicate namespace"); + assert!(err.to_string().contains("videre"), "{err}"); } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 5dd07d85..3d85117d 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -11,6 +11,7 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView}; use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; +use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; use super::venue_registry::VenueRegistry; @@ -55,6 +56,9 @@ pub struct HostState { /// Every module store carries the same shared handle; an adapter store, /// which cannot call the client face, carries an empty one. pub venue_registry: VenueRegistry, + /// Extension-owned host services, keyed by extension namespace and + /// downcast at the call site. One shared map across every store. + pub services: HostServices, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 87f1fac7..f31a9a52 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -43,7 +43,7 @@ use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::Extension; +use crate::host::extension::{Extension, HostServices}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -81,7 +81,10 @@ pub struct Supervisor { /// Extensions wired at boot. Cached so the module-restart path can /// rebuild an identical linker (core interfaces plus every extension /// hook) without re-consulting the composition root. - extensions: Vec>, + extensions: Vec>>, + /// Extension-owned host services, built once at boot from the same + /// extension set and carried by every store. + services: HostServices, /// Poison-pill thresholds resolved from `[limits.poison]` at boot /// (production defaults: 5 failures / 10 min). poison_policy: crate::runtime::poison_policy::PoisonPolicy, @@ -277,10 +280,11 @@ impl Supervisor { linker: &Linker>, engine_cfg: &EngineConfig, components: &Components, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; // Adapters instantiate first: the venue registry must contain them // before any module store (which carries the built registry) is // built. Adapters link only their scoped transport, against a @@ -302,6 +306,7 @@ impl Supervisor { &engine_cfg.limits, &adapter_registry, clocks.as_ref(), + services.clone(), ) .await .with_context(|| format!("load adapter {}", entry.path.display()))?; @@ -330,6 +335,7 @@ impl Supervisor { ®istry, clocks.as_ref(), venue_registry.clone(), + services.clone(), ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -351,6 +357,7 @@ impl Supervisor { engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: engine_cfg.limits.poison(), clocks, }) @@ -370,10 +377,11 @@ impl Supervisor { manifest: Option<&Path>, components: &Components, limits: &ModuleLimits, - extensions: &[Extension], + extensions: &[Arc>], clocks: Option, ) -> Result { let registry = capability_registry(extensions); + let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), @@ -391,6 +399,7 @@ impl Supervisor { ®istry, clocks.as_ref(), venue_registry.clone(), + services.clone(), ) .await?; Ok(Self { @@ -401,6 +410,7 @@ impl Supervisor { engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), + services, poison_policy: limits.poison(), clocks, }) @@ -426,6 +436,7 @@ impl Supervisor { state_quota: u64, clocks: Option<&WasiClockOverride>, venue_registry: VenueRegistry, + services: HostServices, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -484,6 +495,7 @@ impl Supervisor { chain_response_max_bytes, store: module_store, venue_registry, + services, }, ); store.limiter(|state| &mut state.limits); @@ -503,6 +515,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, venue_registry: VenueRegistry, + services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -568,6 +581,7 @@ impl Supervisor { state_bytes, clocks, venue_registry, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -664,6 +678,9 @@ impl Supervisor { /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings /// against the adapter linker, and run `init`. Nothing dispatches to /// the result yet; it boots so the registry can later reach it. + // One flat argument per shared input threaded onto the store, matching + // the module load path. + #[allow(clippy::too_many_arguments)] async fn load_adapter( engine: &Engine, linker: &Linker>, @@ -672,6 +689,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -751,6 +769,7 @@ impl Supervisor { limits_cfg.state_bytes(), clocks, VenueRegistry::empty(), + services, )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -921,6 +940,7 @@ impl Supervisor { // as the initial boot. let clocks = self.clocks.clone(); let venue_registry = self.venue_registry.clone(); + let services = self.services.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -938,6 +958,7 @@ impl Supervisor { module.local_store_bytes, clocks.as_ref(), venue_registry, + services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1422,7 +1443,7 @@ impl Supervisor { /// and capability enforcement via the crate-internal `capability_registry`. pub fn build_linker( engine: &Engine, - extensions: &[Extension], + extensions: &[Arc>], ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; @@ -1438,7 +1459,7 @@ pub fn build_linker( // wasi:io/wasi:clocks interfaces. wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; for ext in extensions { - (ext.link)(&mut linker)?; + ext.link(&mut linker)?; } Ok(linker) } @@ -1502,11 +1523,11 @@ fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option( - extensions: &[Extension], + extensions: &[Arc>], ) -> CapabilityRegistry { let mut registry = CapabilityRegistry::core(); for ext in extensions { - registry.register(ext.capabilities); + registry.register(ext.capabilities()); } registry } diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 0de3d908..6d814a16 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -328,7 +328,7 @@ fn make_wasmtime_engine() -> wasmtime::Engine { /// The core-only extension set: no domain extensions. Domain-extension /// boot coverage lives in the extension crate that owns the backend. -fn core_extensions() -> Vec> { +fn core_extensions() -> Vec>> { Vec::new() } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 31f2a8d7..10d808d6 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -22,6 +22,7 @@ //! crate's backend through the same harness. use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use alloy_rpc_types_eth::{Header, Log}; @@ -54,7 +55,7 @@ where { wasm: PathBuf, manifest: ManifestSource, - extensions: Vec>>, + extensions: Vec>>>, ext: E, limits: ModuleLimits, chain: MockChainProvider, @@ -100,8 +101,8 @@ impl TestRuntimeBuilder { self } - /// Register an extension's linker hook and capability namespace. - pub fn extension(mut self, extension: Extension>) -> Self { + /// Register an extension. + pub fn extension(mut self, extension: Arc>>) -> Self { self.extensions.push(extension); self } @@ -109,7 +110,7 @@ impl TestRuntimeBuilder { /// Register several extensions at once. pub fn extensions( mut self, - extensions: impl IntoIterator>>, + extensions: impl IntoIterator>>>, ) -> Self { self.extensions.extend(extensions); self @@ -425,18 +426,31 @@ chain_id = {chain_id} return; }; - let calls = Arc::new(AtomicUsize::new(0)); - let hooked = calls.clone(); - let extension = Extension::>> { - link: Arc::new(move |_linker| { - hooked.fetch_add(1, Ordering::SeqCst); + struct CountingExtension(Arc); + + impl Extension>> for CountingExtension { + fn namespace(&self) -> &'static str { + "test" + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "test:ext/", + ifaces: &[], + } + } + fn link( + &self, + _linker: &mut wasmtime::component::Linker< + crate::host::state::HostState>>, + >, + ) -> anyhow::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); Ok(()) - }), - capabilities: NamespaceCaps { - prefix: "test:ext/", - ifaces: &[], - }, - }; + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let extension = Arc::new(CountingExtension(calls.clone())); let mut rt = TestRuntime::builder_with_ext(wasm, calls.clone()) .extension(extension) diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs index c98acb31..b7c93271 100644 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ b/crates/shepherd-cow-host/src/ext_cow.rs @@ -4,12 +4,14 @@ //! Shape: a local `bindgen!` for the extension world, a `Host` impl for //! the foreign `HostState` reached through [`ExtState`], a payload //! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] bundling the linker hook with the capability namespace. +//! [`Extension`] impl carrying the linker hook and capability namespace. //! //! The bindgen shares `nexum:host/types` with the core bindings via //! `with`, so the `fault` the extension's `cow-api-error` embeds is the //! same type the core host constructs. +use std::marker::PhantomData; +use std::sync::Arc; use std::time::Instant; use alloy_chains::Chain; @@ -18,7 +20,7 @@ use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTy use nexum_runtime::host::extension::Extension; use nexum_runtime::host::state::{ExtState, HostState}; use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::HasSelf; +use wasmtime::component::{HasSelf, Linker}; use crate::cow::CowApi; use crate::cow_orderbook::{CowApiError, OrderBookPool}; @@ -84,28 +86,45 @@ impl ComponentBuilder for ReferenceExtBuilder { } } +/// The cow-api extension over a lattice whose `Ext` payload carries a cow +/// backend. +struct CowExtension(PhantomData T>); + +impl Extension for CowExtension +where + T: RuntimeTypes, + T::Ext: CowBackend, +{ + fn namespace(&self) -> &'static str { + "cow" + } + + fn capabilities(&self) -> NamespaceCaps { + COW_CAPABILITIES + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + // Link only the cow-api interface. The whole-world + // `CowExt::add_to_linker` would also re-add the shared + // `nexum:host/types` instance, which the core event-module + // linker already provides, tripping a "defined twice" error. + bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } +} + /// Build the cow extension for a lattice whose `Ext` payload carries a cow /// backend. Wired at the composition root into `build_linker` and /// capability enforcement. -pub fn extension() -> Extension +pub fn extension() -> Arc> where T: RuntimeTypes, T::Ext: CowBackend, { - Extension { - link: std::sync::Arc::new(|linker| { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - }), - capabilities: COW_CAPABILITIES, - } + Arc::new(CowExtension(PhantomData)) } /// Project the backend [`CowApiError`] into the WIT `cow-api-error`. diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index f3a75d9a..559cf28e 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -7,6 +7,7 @@ //! wasm artefacts and skip gracefully when the artefact is absent. use std::path::{Path, PathBuf}; +use std::sync::Arc; use alloy_chains::Chain; use nexum_runtime::bindings::nexum; @@ -33,7 +34,7 @@ impl RuntimeTypes for CowTestTypes { type Ext = ReferenceExt; } -fn cow_extensions() -> Vec> { +fn cow_extensions() -> Vec>> { vec![extension::()] } From 435616bd29b2c4df6066f421e81a83d320c73b2f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 08:08:45 +0000 Subject: [PATCH 039/139] engine: extract world synthesis into nexum-world (#440) refactor: extract world synthesis into nexum-world and de-hardcode the known table The capability table and per-module world synthesis move into a new plain nexum-world library carrying only the core nexum:host rows. Per-namespace rows come from the composition root's extensions.toml registry, parsed and passed in by the macro layer, so no host crate carries a downstream name; synthesis rejects a row that shadows a core capability or another registration. WIT packages resolve crate-locally (wit/deps, then wit/) with an ancestor fallback for the transitional monorepo layout. --- Cargo.lock | 10 +- Cargo.toml | 1 + crates/nexum-macros/Cargo.toml | 2 +- crates/nexum-macros/src/lib.rs | 98 +++--- crates/nexum-macros/src/world.rs | 334 ++---------------- crates/nexum-world/Cargo.toml | 16 + crates/nexum-world/src/lib.rs | 579 +++++++++++++++++++++++++++++++ extensions.toml | 13 + 8 files changed, 687 insertions(+), 366 deletions(-) create mode 100644 crates/nexum-world/Cargo.toml create mode 100644 crates/nexum-world/src/lib.rs create mode 100644 extensions.toml diff --git a/Cargo.lock b/Cargo.lock index 8f3c72a1..0b867ea8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3579,10 +3579,10 @@ dependencies = [ name = "nexum-macros" version = "0.1.0" dependencies = [ + "nexum-world", "proc-macro2", "quote", "syn 2.0.118", - "toml 1.1.2+spec-1.1.0", ] [[package]] @@ -3699,6 +3699,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nexum-world" +version = "0.1.0" +dependencies = [ + "tempfile", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 2519fd97..d4ee5eb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/nexum-tasks", "crates/nexum-venue-sdk", "crates/nexum-venue-test", + "crates/nexum-world", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index ad74099f..ec7785bd 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] +nexum-world = { path = "../nexum-world" } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } -toml.workspace = true diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 328f3090..72e243a0 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -22,8 +22,6 @@ mod intent_body; mod world; -use std::path::Path; - use proc_macro::TokenStream; use quote::quote; use syn::{DeriveInput, ImplItem, ItemImpl, Type}; @@ -87,8 +85,9 @@ const HANDLERS: [&str; 6] = [ /// The other non-obvious invariant: the wit-bindgen output (`Guest`, /// `Fault`, the `nexum::host::*` modules) lands at the module crate /// root, so the emitted glue and the handler bodies resolve those names -/// there; the WIT package directories are located by walking up from -/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must +/// there; the WIT package directories resolve against the crate's own +/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the +/// package. Two corollaries: the consuming crate must /// declare `wit-bindgen` as a direct dependency (the emitted /// `wit_bindgen::generate!` call resolves against the consumer's /// namespace), and the crate root must not shadow std prelude names @@ -175,7 +174,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { } let has = |name: &str| present.contains(&name); - let (manifest_path, module_world) = match derive_module_world() { + let (anchors, module_world) = match derive_module_world() { Ok(parts) => parts, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -234,9 +233,10 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let intent_status_arm = arm("on_intent_status", "IntentStatus"); quote! { - // Anchor a rebuild on the manifest: the emitted world is derived - // from it, so an edited [capabilities] must recompile the module. - const _: &[u8] = ::core::include_bytes!(#manifest_path); + // Anchor a rebuild on the manifest and the extension registry: + // the emitted world is derived from them, so an edit to either + // must recompile the module. + #(const _: &[u8] = ::core::include_bytes!(#anchors);)* wit_bindgen::generate!({ inline: #inline_world, @@ -483,9 +483,7 @@ fn is_plain_type(ty: &Type) -> bool { /// anchor). Shared by the module and venue worlds, which differ only in /// how they turn the declarations into a world. fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), String> { - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let manifest_path = Path::new(&manifest_dir).join("module.toml"); + let manifest_path = manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); {attribute} derives the component's WIT world from the \ @@ -498,13 +496,36 @@ fn read_manifest_capabilities(attribute: &str) -> Result<(String, Vec), Ok((manifest_path.to_string_lossy().into_owned(), declared)) } +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(std::path::PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + /// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations. Returns the -/// manifest path (for the rebuild anchor) alongside the world. -fn derive_module_world() -> Result<(String, world::ModuleWorld), String> { +/// per-module world from its `[capabilities]` declarations plus the +/// extension rows registered in the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths (the manifest, then the registry +/// when one exists) alongside the world. +fn derive_module_world() -> Result<(Vec, world::ModuleWorld), String> { let (manifest_path, declared) = read_manifest_capabilities("#[nexum_sdk::module]")?; - let module_world = world::synthesize(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; - Ok((manifest_path, module_world)) + let mut anchors = vec![manifest_path.clone()]; + let extensions = match world::find_extensions_manifest(&manifest_dir()?) { + None => Vec::new(), + Some(registry) => { + let text = std::fs::read_to_string(®istry) + .map_err(|e| format!("could not read {}: {e}", registry.display()))?; + let rows = world::manifest_extensions(&text) + .map_err(|e| format!("{}: {e}", registry.display()))?; + anchors.push(registry.to_string_lossy().into_owned()); + rows + } + }; + let module_world = + world::synthesize(&declared, &extensions).map_err(|e| format!("{manifest_path}: {e}"))?; + Ok((anchors, module_world)) } /// Read the consuming crate's `module.toml` and synthesize the @@ -518,39 +539,14 @@ fn derive_venue_world() -> Result<(String, world::ModuleWorld), String> { Ok((manifest_path, venue_world)) } -/// Locate the workspace `wit/` root (the ancestor directory whose `wit/` -/// contains the `nexum-host` package) and resolve each needed package -/// directory under it. -fn resolve_wit_packages(packages: &[&str]) -> Result, String> { - let manifest = std::env::var("CARGO_MANIFEST_DIR") - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; - let mut dir: Option<&Path> = Some(Path::new(&manifest)); - let root = loop { - let Some(cur) = dir else { - return Err(format!( - "could not find a `wit/` directory containing `nexum-host` in any ancestor \ - of {manifest}" - )); - }; - let wit = cur.join("wit"); - if wit.join("nexum-host").is_dir() { - break wit; - } - dir = cur.parent(); - }; - packages - .iter() - .map(|package| { - let path = root.join(package); - if path.is_dir() { - Ok(path.to_string_lossy().into_owned()) - } else { - Err(format!( - "declared capabilities need the `{package}` WIT package, but {} is not \ - a directory", - path.display() - )) - } - }) - .collect() +/// Resolve each needed WIT package directory crate-locally (vendored +/// `wit/deps/`, then own `wit/`), falling back through +/// ancestors for the transitional monorepo layout. +fn resolve_wit_packages(packages: &[String]) -> Result, String> { + Ok( + nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + ) } diff --git a/crates/nexum-macros/src/world.rs b/crates/nexum-macros/src/world.rs index acae4eb1..82de0717 100644 --- a/crates/nexum-macros/src/world.rs +++ b/crates/nexum-macros/src/world.rs @@ -1,143 +1,11 @@ -//! Per-module world synthesis: turn the manifest's `[capabilities]` -//! declarations into an inline WIT world whose imports are exactly the -//! declared capability interfaces. -//! -//! The one non-obvious invariant: the capability table here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because this module derives the imports from -//! the same manifest, a component built through `#[nexum_sdk::module]` -//! passes that check by construction rather than by relying on the -//! toolchain eliding unused imports. +//! World wiring for the macros: the venue-adapter world synthesis. The +//! module world synthesis, the core capability table, and the extension +//! registry parsing (`extensions.toml`, the composition root's data) +//! live in `nexum-world`, so no crate here carries a downstream name. -use std::fmt::Write as _; - -/// One manifest capability and its world wiring. -struct Capability { - /// The name declared under `[capabilities].required` / `optional`. - name: &'static str, - /// The WIT import the declaration turns into, or `None` for - /// capabilities with no world import (`http` is granted through the - /// SDK's wasi:http client and the host allowlist, not the world). - import: Option<&'static str>, - /// WIT package directories (under the workspace `wit/` root) the - /// import needs on the resolve path, beyond `nexum-host`. - packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. - adapter: Option<&'static str>, -} - -/// Every capability the macro recognises, in emission order. Mirrors -/// the runtime's core registry plus the extension namespaces the -/// workspace ships (`videre:venue/client`, `shepherd:cow/cow-api`). -const KNOWN: &[Capability] = &[ - Capability { - name: "chain", - import: Some("nexum:host/chain@0.1.0"), - packages: &[], - adapter: Some("chain"), - }, - Capability { - name: "identity", - import: Some("nexum:host/identity@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "local-store", - import: Some("nexum:host/local-store@0.1.0"), - packages: &[], - adapter: Some("local_store"), - }, - Capability { - name: "remote-store", - import: Some("nexum:host/remote-store@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "messaging", - import: Some("nexum:host/messaging@0.1.0"), - packages: &[], - adapter: None, - }, - Capability { - name: "logging", - import: Some("nexum:host/logging@0.1.0"), - packages: &[], - adapter: Some("logging"), - }, - Capability { - name: "client", - import: Some("videre:venue/client@0.1.0"), - packages: &["videre-value-flow", "videre-types", "videre-venue"], - adapter: None, - }, - Capability { - name: "cow-api", - import: Some("shepherd:cow/cow-api@0.1.0"), - packages: &["shepherd-cow"], - adapter: None, - }, - Capability { - name: "http", - import: None, - packages: &[], - adapter: None, - }, -]; - -/// The synthesized world plus what the `generate!` call and the host -/// adapter need to go with it. -#[derive(Debug)] -pub struct ModuleWorld { - /// Inline WIT text defining `nexum:module-world/module`. - pub wit: String, - /// WIT package directories (relative to the workspace `wit/` root) - /// the resolve path must carry, in dependency order (a package - /// precedes its dependants). Always starts with the base set the - /// host `event` variant needs. - pub packages: Vec<&'static str>, - /// Capability idents to pass to `bind_host_via_wit_bindgen!`. - pub adapters: Vec<&'static str>, -} - -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// macro has nothing to build from without one. -pub fn manifest_capabilities(text: &str) -> Result, String> { - let value: toml::Table = text - .parse() - .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; - let caps = value.get("capabilities").ok_or_else(|| { - "module.toml has no [capabilities] section; the module/adapter macro derives the \ - component's WIT world from [capabilities].required/optional, so declare it (an empty \ - `required = []` is valid)" - .to_string() - })?; - let list = |key: &str| -> Result, String> { - match caps.get(key) { - None => Ok(Vec::new()), - Some(v) => v - .as_array() - .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? - .iter() - .map(|item| { - item.as_str() - .map(str::to_owned) - .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) - }) - .collect(), - } - }; - let mut names = list("required")?; - names.extend(list("optional")?); - Ok(names) -} +pub use nexum_world::{ + ModuleWorld, find_extensions_manifest, manifest_capabilities, manifest_extensions, synthesize, +}; /// Capabilities a venue adapter may import. A venue speaks one venue's /// protocol over scoped transport and nothing else: chain RPC, @@ -171,27 +39,30 @@ pub fn synthesize_venue(declared: &[String]) -> Result { // value-flow vocabulary they are expressed in) needs the videre // packages on the resolve path beyond the leaf host package, in // dependency order: a package precedes its dependants. - let mut packages = vec![ + let mut packages: Vec = [ "videre-value-flow", "videre-types", "nexum-host", "videre-venue", - ]; - for cap in KNOWN { + ] + .map(str::to_owned) + .into(); + for cap in nexum_world::CORE { if !declared.iter().any(|d| d == cap.name) { continue; } if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); + imports.push_str(&format!(" import {import};\n")); } - // Accumulate any extra WIT packages a venue capability needs, exactly - // as `synthesize` does. All venue-permitted capabilities are - // packageless today, so this leaves the base set untouched; mirroring - // the loop keeps a future venue capability from silently failing to - // reach its package onto the resolve path. + // Accumulate any extra WIT packages a venue capability needs, + // exactly as the module synthesis does. All venue-permitted + // capabilities are packageless today, so this leaves the base set + // untouched; mirroring the loop keeps a future venue capability + // from silently failing to reach its package onto the resolve + // path. for package in cap.packages { - if !packages.contains(package) { - packages.push(package); + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); } } } @@ -216,72 +87,10 @@ pub fn synthesize_venue(declared: &[String]) -> Result { }) } -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). Unknown names are a compile error so a typo cannot silently -/// drop an import. -pub fn synthesize(declared: &[String]) -> Result { - for name in declared { - if !KNOWN.iter().any(|c| c.name == name.as_str()) { - let known = KNOWN.iter().map(|c| c.name).collect::>().join(", "); - return Err(format!( - "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ - {known}" - )); - } - } - - let mut imports = String::new(); - // `nexum:host` is a leaf package (the `event` variant carries an - // intent-status transition as opaque bytes), so the base resolve set - // is the host package alone; capability declarations append their - // own packages. Dependency order: each directory is parsed against - // the packages before it, so a package precedes its dependants. - let mut packages = vec!["nexum-host"]; - let mut adapters = Vec::new(); - for cap in KNOWN { - if !declared.iter().any(|d| d == cap.name) { - continue; - } - if let Some(import) = cap.import { - writeln!(imports, " import {import};").expect("write to String"); - } - for package in cap.packages { - if !packages.contains(package) { - packages.push(package); - } - } - if let Some(adapter) = cap.adapter { - adapters.push(adapter); - } - } - - let mut wit = String::from( - "package nexum:module-world;\n\nworld module {\n \ - use nexum:host/types@0.1.0.{config, event, fault};\n\n", - ); - wit.push_str(&imports); - wit.push_str( - "\n export init: func(config: config) -> result<_, fault>;\n \ - export on-event: func(event: event) -> result<_, fault>;\n}\n", - ); - - Ok(ModuleWorld { - wit, - packages, - adapters, - }) -} - #[cfg(test)] mod tests { use super::*; - /// The base package set every module world resolves against: - /// `nexum:host` is a leaf package, so it stands alone. - const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; - /// The package set every venue world resolves against: the exported /// adapter face pulls the videre vocabulary, in dependency order. const VENUE_PACKAGES: [&str; 4] = [ @@ -291,60 +100,6 @@ mod tests { "videre-venue", ]; - #[test] - fn logging_only_world_imports_logging_alone() { - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); - assert!(!world.wit.contains("import nexum:host/chain")); - assert!(!world.wit.contains("shepherd:cow")); - assert_eq!(world.packages, MODULE_PACKAGES); - assert_eq!(world.adapters, vec!["logging"]); - } - - #[test] - fn cow_api_pulls_the_shepherd_cow_package() { - let world = synthesize(&["logging".to_string(), "cow-api".to_string()]).unwrap(); - assert!(world.wit.contains("import shepherd:cow/cow-api@0.1.0;")); - assert_eq!(world.packages, vec!["nexum-host", "shepherd-cow"]); - } - - #[test] - fn client_pulls_the_videre_packages() { - let world = synthesize(&["client".to_string()]).unwrap(); - assert!(world.wit.contains("import videre:venue/client@0.1.0;")); - assert_eq!( - world.packages, - vec![ - "nexum-host", - "videre-value-flow", - "videre-types", - "videre-venue" - ] - ); - assert!(world.adapters.is_empty()); - } - - #[test] - fn http_declares_no_world_import() { - let world = synthesize(&["logging".to_string(), "http".to_string()]).unwrap(); - assert!(!world.wit.contains("wasi:http")); - assert_eq!(world.packages, MODULE_PACKAGES); - } - - #[test] - fn duplicate_declarations_emit_one_import() { - let world = synthesize(&["chain".to_string(), "chain".to_string()]).unwrap(); - assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); - assert_eq!(world.adapters, vec!["chain"]); - } - - #[test] - fn unknown_capability_is_rejected_with_the_known_list() { - let err = synthesize(&["telepathy".to_string()]).unwrap_err(); - assert!(err.contains("unknown capability `telepathy`")); - assert!(err.contains("logging")); - } - #[test] fn venue_world_exports_the_adapter_face() { let world = synthesize_venue(&["chain".to_string()]).unwrap(); @@ -400,51 +155,4 @@ mod tests { assert!(err.contains("venue adapter"), "message was: {err}"); } } - - #[test] - fn manifest_capabilities_reads_required_and_optional() { - let caps = manifest_capabilities( - r#" -[capabilities] -required = ["logging", "chain"] -optional = ["remote-store"] - -[capabilities.http] -allow = [] -"#, - ) - .unwrap(); - assert_eq!(caps, vec!["logging", "chain", "remote-store"]); - } - - #[test] - fn manifest_without_capabilities_section_is_an_error() { - let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); - assert!(err.contains("[capabilities]")); - } - - #[test] - fn manifest_with_non_string_capability_is_an_error() { - let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); - assert!(err.contains("only strings")); - } - - #[test] - fn world_is_valid_wit_shape() { - // Not a full WIT parse (that is the module build's job); pin the - // structural pieces the runtime contract depends on. - let world = synthesize(&["logging".to_string()]).unwrap(); - assert!(world.wit.starts_with("package nexum:module-world;")); - assert!(world.wit.contains("world module {")); - assert!( - world - .wit - .contains("export init: func(config: config) -> result<_, fault>;") - ); - assert!( - world - .wit - .contains("export on-event: func(event: event) -> result<_, fault>;") - ); - } } diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml new file mode 100644 index 00000000..088d377a --- /dev/null +++ b/crates/nexum-world/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "nexum-world" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Per-module WIT world synthesis: the core capability table, registry-driven extension rows, manifest parsing, and crate-local WIT package resolution." + +[lints] +workspace = true + +[dependencies] +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs new file mode 100644 index 00000000..2b39f860 --- /dev/null +++ b/crates/nexum-world/src/lib.rs @@ -0,0 +1,579 @@ +//! Per-module world synthesis: turn a manifest's `[capabilities]` +//! declarations into an inline WIT world whose imports are exactly the +//! declared capability interfaces. +//! +//! The one non-obvious invariant: the capability rows here must agree +//! with the runtime's capability registry (`nexum-runtime`'s manifest +//! enforcement) on both the capability names and the WIT interfaces they +//! map to. The runtime cross-checks a component's imports against the +//! manifest at load time; because the imports are derived from the same +//! manifest, a macro-built component passes that check by construction +//! rather than by relying on the toolchain eliding unused imports. +//! +//! The table here carries only the core `nexum:host` rows. Per-namespace +//! rows come from the composition root's `extensions.toml` registry +//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], +//! so this crate carries no downstream name. + +use std::path::{Path, PathBuf}; + +/// One manifest capability and its world wiring. +pub struct Capability { + /// The name declared under `[capabilities].required` / `optional`. + pub name: &'static str, + /// The WIT import the declaration turns into, or `None` for + /// capabilities with no world import (`http` is granted through the + /// SDK's wasi:http client and the host allowlist, not the world). + pub import: Option<&'static str>, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`. + pub packages: &'static [&'static str], + /// The `bind_host_via_wit_bindgen!` capability ident carrying this + /// capability's host-adapter pieces, if the SDK has a trait seam + /// for it. + pub adapter: Option<&'static str>, +} + +/// The core capability rows, in emission order. Mirrors the runtime's +/// core registry and nothing else; extension rows are the caller's. +pub const CORE: &[Capability] = &[ + Capability { + name: "chain", + import: Some("nexum:host/chain@0.1.0"), + packages: &[], + adapter: Some("chain"), + }, + Capability { + name: "identity", + import: Some("nexum:host/identity@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "local-store", + import: Some("nexum:host/local-store@0.1.0"), + packages: &[], + adapter: Some("local_store"), + }, + Capability { + name: "remote-store", + import: Some("nexum:host/remote-store@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "messaging", + import: Some("nexum:host/messaging@0.1.0"), + packages: &[], + adapter: None, + }, + Capability { + name: "logging", + import: Some("nexum:host/logging@0.1.0"), + packages: &[], + adapter: Some("logging"), + }, + Capability { + name: "http", + import: None, + packages: &[], + adapter: None, + }, +]; + +/// One registered extension row: a per-namespace capability a +/// composition root declares in its `extensions.toml`. An extension +/// always has a WIT import and never a host-adapter ident (adapter +/// seams are core-only). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtensionRow { + /// The name modules declare under `[capabilities]`. + pub name: String, + /// The WIT import the declaration turns into. + pub import: String, + /// WIT package directories the import needs on the resolve path, + /// beyond `nexum-host`, in dependency order. + pub packages: Vec, +} + +/// The synthesized world plus what the `generate!` call and the host +/// adapter need to go with it. +#[derive(Debug)] +pub struct ModuleWorld { + /// Inline WIT text defining `nexum:module-world/module`. + pub wit: String, + /// WIT package directories the resolve path must carry, in + /// dependency order (a package precedes its dependants). Always + /// starts with the base set the host `event` variant needs. + pub packages: Vec, + /// Capability idents to pass to `bind_host_via_wit_bindgen!`. + pub adapters: Vec<&'static str>, +} + +/// Extract the declared capability names (`required` then `optional`) +/// from the manifest text. A missing or malformed `[capabilities]` +/// section is an error: the emitted world is derived from it, so the +/// synthesis has nothing to build from without one. +pub fn manifest_capabilities(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + let caps = value.get("capabilities").ok_or_else(|| { + "module.toml has no [capabilities] section; the module/adapter macro derives the \ + component's WIT world from [capabilities].required/optional, so declare it (an empty \ + `required = []` is valid)" + .to_string() + })?; + let list = |key: &str| -> Result, String> { + match caps.get(key) { + None => Ok(Vec::new()), + Some(v) => v + .as_array() + .ok_or_else(|| format!("[capabilities].{key} must be an array of strings"))? + .iter() + .map(|item| { + item.as_str() + .map(str::to_owned) + .ok_or_else(|| format!("[capabilities].{key} must contain only strings")) + }) + .collect(), + } + }; + let mut names = list("required")?; + names.extend(list("optional")?); + Ok(names) +} + +/// Parse the registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries the WIT `import` the declaration +/// turns into and the extra `packages` its resolve path needs. A file +/// without an `[extensions]` section registers nothing. +pub fn manifest_extensions(text: &str) -> Result, String> { + let value: toml::Table = text + .parse() + .map_err(|e| format!("extensions.toml is not valid TOML: {e}"))?; + let Some(extensions) = value.get("extensions") else { + return Ok(Vec::new()); + }; + let extensions = extensions + .as_table() + .ok_or_else(|| "[extensions] must be a table of `[extensions.]` rows".to_string())?; + extensions + .iter() + .map(|(name, row)| { + let row = row + .as_table() + .ok_or_else(|| format!("[extensions.{name}] must be a table"))?; + let import = row + .get("import") + .and_then(toml::Value::as_str) + .ok_or_else(|| format!("[extensions.{name}] must carry a string `import`"))? + .to_owned(); + let packages = match row.get("packages") { + None => Vec::new(), + Some(value) => value + .as_array() + .ok_or_else(|| { + format!("[extensions.{name}].packages must be an array of strings") + })? + .iter() + .map(|item| { + item.as_str().map(str::to_owned).ok_or_else(|| { + format!("[extensions.{name}].packages must contain only strings") + }) + }) + .collect::>()?, + }; + Ok(ExtensionRow { + name: name.clone(), + import, + packages, + }) + }) + .collect() +} + +/// Find the extension registry for a build rooted at `start`: the +/// nearest ancestor `extensions.toml`. `None` means no registered +/// extensions. +pub fn find_extensions_manifest(start: &Path) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let candidate = cur.join("extensions.toml"); + if candidate.is_file() { + return Some(candidate); + } + dir = cur.parent(); + } + None +} + +/// Build the per-module world from the declared capability names +/// (required and optional alike: an optional capability must still be +/// importable, the host decides at load time whether to back or stub +/// it). `extensions` carries the per-namespace rows of the registered +/// extensions, emitted after the core rows. Unknown names are an error +/// so a typo cannot silently drop an import; a registered name that +/// shadows a core row or another registration is an error so a +/// colliding registry cannot emit a duplicate import. +pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { + for (idx, ext) in extensions.iter().enumerate() { + if CORE.iter().any(|c| c.name == ext.name) + || extensions[..idx].iter().any(|prior| prior.name == ext.name) + { + return Err(format!( + "extension capability `{}` collides with an already-registered capability; \ + names must be unique across the core table and the registered extensions", + ext.name + )); + } + } + + let known = || { + CORE.iter() + .map(|c| c.name) + .chain(extensions.iter().map(|e| e.name.as_str())) + }; + for name in declared { + if !known().any(|k| k == name.as_str()) { + let names = known().collect::>().join(", "); + return Err(format!( + "unknown capability `{name}` in module.toml [capabilities]; expected one of: \ + {names}" + )); + } + } + + let mut imports = String::new(); + // `nexum:host` is a leaf package (the `event` variant carries status + // transitions as opaque bytes), so the base resolve set + // is the host package alone; capability declarations append their + // own packages. Dependency order: each directory is parsed against + // the packages before it, so a package precedes its dependants. + let mut packages = vec!["nexum-host".to_owned()]; + let mut adapters = Vec::new(); + for cap in CORE { + if !declared.iter().any(|d| d == cap.name) { + continue; + } + if let Some(import) = cap.import { + imports.push_str(&format!(" import {import};\n")); + } + for package in cap.packages { + if !packages.iter().any(|p| p == package) { + packages.push((*package).to_owned()); + } + } + if let Some(adapter) = cap.adapter { + adapters.push(adapter); + } + } + for ext in extensions { + if !declared.contains(&ext.name) { + continue; + } + imports.push_str(&format!(" import {};\n", ext.import)); + for package in &ext.packages { + if !packages.contains(package) { + packages.push(package.clone()); + } + } + } + + let mut wit = String::from( + "package nexum:module-world;\n\nworld module {\n \ + use nexum:host/types@0.1.0.{config, event, fault};\n\n", + ); + wit.push_str(&imports); + wit.push_str( + "\n export init: func(config: config) -> result<_, fault>;\n \ + export on-event: func(event: event) -> result<_, fault>;\n}\n", + ); + + Ok(ModuleWorld { + wit, + packages, + adapters, + }) +} + +/// Resolve each WIT package directory for a component build rooted at +/// `start` (the consuming crate's manifest directory). A package +/// resolves crate-locally, vendored `wit/deps/` before own +/// `wit/`; a crate not carrying it falls back to the nearest +/// ancestor `wit/` that does (the transitional monorepo layout). +pub fn resolve_wit_packages>( + start: &Path, + packages: &[S], +) -> Result, String> { + packages + .iter() + .map(|package| { + let package = package.as_ref(); + resolve_wit_package(start, package).ok_or_else(|| { + format!( + "declared capabilities need the `{package}` WIT package, but neither \ + `wit/deps/{package}` nor `wit/{package}` exists under {} or any ancestor", + start.display() + ) + }) + }) + .collect() +} + +/// Find one package directory: crate-local `wit/deps/` then +/// `wit/`, walking up on a miss. +fn resolve_wit_package(start: &Path, package: &str) -> Option { + let mut dir = Some(start); + while let Some(cur) = dir { + let wit = cur.join("wit"); + for candidate in [wit.join("deps").join(package), wit.join(package)] { + if candidate.is_dir() { + return Some(candidate); + } + } + dir = cur.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base package set every module world resolves against: + /// `nexum:host` is a leaf package, so it stands alone. + const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; + + /// A stand-in extension row, as a registered extension would pass. + fn ext() -> Vec { + vec![ExtensionRow { + name: "acme".to_owned(), + import: "acme:ext/api@0.1.0".to_owned(), + packages: vec!["acme-ext".to_owned()], + }] + } + + #[test] + fn logging_only_world_imports_logging_alone() { + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.contains("import nexum:host/logging@0.1.0;")); + assert!(!world.wit.contains("import nexum:host/chain")); + assert_eq!(world.packages, MODULE_PACKAGES); + assert_eq!(world.adapters, vec!["logging"]); + } + + #[test] + fn extension_row_emits_its_import_and_packages() { + let world = synthesize(&["logging".to_string(), "acme".to_string()], &ext()).unwrap(); + assert!(world.wit.contains("import acme:ext/api@0.1.0;")); + assert_eq!(world.packages, vec!["nexum-host", "acme-ext"]); + } + + #[test] + fn undeclared_extension_row_stays_out_of_the_world() { + let world = synthesize(&["logging".to_string()], &ext()).unwrap(); + assert!(!world.wit.contains("acme")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn extension_shadowing_a_core_name_is_rejected() { + let rows = vec![ExtensionRow { + name: "chain".to_owned(), + import: "acme:ext/chain@0.1.0".to_owned(), + packages: Vec::new(), + }]; + let err = synthesize(&["chain".to_string()], &rows).unwrap_err(); + assert!(err.contains("extension capability `chain` collides")); + } + + #[test] + fn duplicate_extension_registration_is_rejected() { + let mut rows = ext(); + rows.extend(ext()); + let err = synthesize(&[], &rows).unwrap_err(); + assert!(err.contains("extension capability `acme` collides")); + } + + #[test] + fn core_table_carries_no_extension_row() { + assert!( + CORE.iter() + .all(|c| c.import.is_none_or(|i| i.starts_with("nexum:host/"))) + ); + assert!(CORE.iter().all(|c| c.packages.is_empty())); + } + + #[test] + fn http_declares_no_world_import() { + let world = synthesize(&["logging".to_string(), "http".to_string()], &[]).unwrap(); + assert!(!world.wit.contains("wasi:http")); + assert_eq!(world.packages, MODULE_PACKAGES); + } + + #[test] + fn duplicate_declarations_emit_one_import() { + let world = synthesize(&["chain".to_string(), "chain".to_string()], &[]).unwrap(); + assert_eq!(world.wit.matches("import nexum:host/chain").count(), 1); + assert_eq!(world.adapters, vec!["chain"]); + } + + #[test] + fn unknown_capability_is_rejected_with_the_known_list() { + let err = synthesize(&["telepathy".to_string()], &ext()).unwrap_err(); + assert!(err.contains("unknown capability `telepathy`")); + assert!(err.contains("logging")); + assert!(err.contains("acme")); + } + + #[test] + fn manifest_extensions_reads_rows() { + let rows = manifest_extensions( + r#" +[extensions.acme] +import = "acme:ext/api@0.1.0" +packages = ["acme-base", "acme-ext"] + +[extensions.beta] +import = "beta:ext/api@0.1.0" +"#, + ) + .unwrap(); + assert_eq!(rows, { + let mut expected = ext(); + expected[0].packages = vec!["acme-base".to_owned(), "acme-ext".to_owned()]; + expected.push(ExtensionRow { + name: "beta".to_owned(), + import: "beta:ext/api@0.1.0".to_owned(), + packages: Vec::new(), + }); + expected + }); + } + + #[test] + fn manifest_without_extensions_section_registers_nothing() { + assert_eq!(manifest_extensions("").unwrap(), Vec::new()); + } + + #[test] + fn extension_row_without_an_import_is_an_error() { + let err = manifest_extensions("[extensions.acme]\npackages = []\n").unwrap_err(); + assert!(err.contains("[extensions.acme] must carry a string `import`")); + } + + #[test] + fn extension_row_with_non_string_package_is_an_error() { + let err = + manifest_extensions("[extensions.acme]\nimport = \"a:b/c@0.1.0\"\npackages = [1]\n") + .unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn manifest_capabilities_reads_required_and_optional() { + let caps = manifest_capabilities( + r#" +[capabilities] +required = ["logging", "chain"] +optional = ["remote-store"] + +[capabilities.http] +allow = [] +"#, + ) + .unwrap(); + assert_eq!(caps, vec!["logging", "chain", "remote-store"]); + } + + #[test] + fn manifest_without_capabilities_section_is_an_error() { + let err = manifest_capabilities("[module]\nname = \"x\"\n").unwrap_err(); + assert!(err.contains("[capabilities]")); + } + + #[test] + fn manifest_with_non_string_capability_is_an_error() { + let err = manifest_capabilities("[capabilities]\nrequired = [1]\n").unwrap_err(); + assert!(err.contains("only strings")); + } + + #[test] + fn world_is_valid_wit_shape() { + // Not a full WIT parse (that is the module build's job); pin the + // structural pieces the runtime contract depends on. + let world = synthesize(&["logging".to_string()], &[]).unwrap(); + assert!(world.wit.starts_with("package nexum:module-world;")); + assert!(world.wit.contains("world module {")); + assert!( + world + .wit + .contains("export init: func(config: config) -> result<_, fault>;") + ); + assert!( + world + .wit + .contains("export on-event: func(event: event) -> result<_, fault>;") + ); + } + + #[test] + fn resolution_prefers_vendored_deps_over_own_wit() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/deps/pkg")).unwrap(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let paths = resolve_wit_packages(root, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/deps/pkg")]); + } + + #[test] + fn resolution_falls_back_to_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![root.join("wit/pkg")]); + } + + #[test] + fn crate_local_package_shadows_the_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("wit/pkg")).unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(leaf.join("wit/deps/pkg")).unwrap(); + let paths = resolve_wit_packages(&leaf, &["pkg"]).unwrap(); + assert_eq!(paths, vec![leaf.join("wit/deps/pkg")]); + } + + #[test] + fn extension_registry_resolves_from_the_nearest_ancestor() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("extensions.toml"), "").unwrap(); + let leaf = root.join("crates/leaf"); + std::fs::create_dir_all(&leaf).unwrap(); + assert_eq!( + find_extensions_manifest(&leaf), + Some(root.join("extensions.toml")) + ); + } + + #[test] + fn absent_extension_registry_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(find_extensions_manifest(dir.path()), None); + } + + #[test] + fn missing_package_names_the_paths_tried() { + let dir = tempfile::tempdir().unwrap(); + let err = resolve_wit_packages(dir.path(), &["pkg"]).unwrap_err(); + assert!(err.contains("`pkg` WIT package")); + assert!(err.contains("wit/deps/pkg")); + } +} diff --git a/extensions.toml b/extensions.toml new file mode 100644 index 00000000..6a280f02 --- /dev/null +++ b/extensions.toml @@ -0,0 +1,13 @@ +# Extension capability registry for this composition root: the +# per-namespace rows the module world synthesis emits beyond the core +# nexum:host table. Each row names the WIT import a `[capabilities]` +# declaration turns into and the package directories its resolve path +# needs, in dependency order. + +[extensions.client] +import = "videre:venue/client@0.1.0" +packages = ["videre-value-flow", "videre-types", "videre-venue"] + +[extensions.cow-api] +import = "shepherd:cow/cow-api@0.1.0" +packages = ["shepherd-cow"] From 7963c3aac00d37ffc214a3da69530d0451867ad3 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 12:48:30 +0000 Subject: [PATCH 040/139] runtime: extract the supervised host-actor primitive from the venue adapter (#441) feat: extract the supervised-actor primitive and generic provider boot --- crates/nexum-runtime/src/host/actor.rs | 58 ++++ crates/nexum-runtime/src/host/extension.rs | 45 ++- crates/nexum-runtime/src/host/mod.rs | 3 + .../nexum-runtime/src/host/venue_registry.rs | 273 ++++++++++------- .../src/manifest/capabilities.rs | 48 +-- crates/nexum-runtime/src/manifest/load.rs | 31 +- crates/nexum-runtime/src/manifest/mod.rs | 2 +- crates/nexum-runtime/src/manifest/types.rs | 59 ++-- crates/nexum-runtime/src/supervisor.rs | 278 ++++++++++-------- crates/nexum-runtime/src/supervisor/tests.rs | 59 +++- 10 files changed, 550 insertions(+), 306 deletions(-) create mode 100644 crates/nexum-runtime/src/host/actor.rs diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs new file mode 100644 index 00000000..10d5c461 --- /dev/null +++ b/crates/nexum-runtime/src/host/actor.rs @@ -0,0 +1,58 @@ +//! The supervised host-actor primitive: one component instance the host +//! holds and others call. The store is refuelled before each guest call, +//! a trap is projected onto a typed fault instead of unwinding into the +//! caller, and each instance sits behind an [`ActorSlot`] async mutex held +//! across the guest await, so one store never runs two guest calls at once. + +use std::sync::Arc; + +use tokio::sync::Mutex as AsyncMutex; +use wasmtime::Store; + +use super::component::RuntimeTypes; +use super::state::HostState; + +/// One supervised actor behind its serialising mutex. A wasmtime `Store` +/// is not `Sync`; concurrent callers queue here. +pub type ActorSlot = Arc>; + +/// A guest call failed outside the component's typed error space. +#[derive(Debug, thiserror::Error)] +pub enum ActorFault { + /// The pre-call refuel failed; the guest was never entered. + #[error("refuel failed: {0}")] + Refuel(wasmtime::Error), + /// The guest trapped. Carries the root cause only; the wasm frame + /// list stays out of the caller-facing message. + #[error("trapped: {}", .0.root_cause())] + Trap(wasmtime::Error), +} + +/// A supervised component store: refuelled before each guest call so every +/// invocation starts from a full budget, with traps projected onto +/// [`ActorFault`]. +pub struct SupervisedStore { + store: Store>, + fuel_per_call: u64, +} + +impl SupervisedStore { + /// Supervise an instantiated store with a per-call fuel budget. + pub fn new(store: Store>, fuel_per_call: u64) -> Self { + Self { + store, + fuel_per_call, + } + } + + /// Refuel, then run one guest call against the store. + pub async fn call( + &mut self, + call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, + ) -> Result { + self.store + .set_fuel(self.fuel_per_call) + .map_err(ActorFault::Refuel)?; + call(&mut self.store).await.map_err(ActorFault::Trap) + } +} diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 6f57e6a8..ed14de95 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -60,13 +60,46 @@ pub trait ProviderKind: Send + Sync + 'static { /// Adds the provider's imports to a provider linker. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Install one instantiated provider behind the extension's service. + /// Instantiate one provider and install it behind the owning service. + /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a + /// boot error. async fn install( &self, - component: &Component, - store: Store>, + instance: ProviderInstance<'_, T>, service: &Arc, - ) -> anyhow::Result<()>; + ) -> anyhow::Result; +} + +/// One provider instance ready to install: the compiled component, the +/// linker the kind's [`ProviderKind::link`] populated, the supervised +/// store, the manifest `[config]`, and the per-call fuel budget. +pub struct ProviderInstance<'a, T: RuntimeTypes> { + /// Compiled provider component. + pub component: &'a Component, + /// Linker carrying the kind's imports plus the WASI base. + pub linker: &'a Linker>, + /// Store the instance runs in; the kind takes ownership. + pub store: Store>, + /// Manifest `[config]` handed to the guest `init`. + pub config: Vec<(String, String)>, + /// Fuel budget applied before each routed guest call. + pub fuel_per_call: u64, +} + +/// Outcome of one provider install. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Installed { + /// `init` succeeded; the instance is installed and routable. + Live, + /// `init` returned a fault; the instance is loaded but not routable. + Dead, +} + +/// Downcast a type-erased service to `S`. `None` when the type differs. +pub fn downcast_service(service: &Arc) -> Option> { + let service = Arc::clone(service); + let erased: Arc = service; + erased.downcast().ok() } /// Immutable per-namespace service map: each extension's [`HostService`] @@ -103,9 +136,7 @@ impl HostServices { /// The service under `namespace`, downcast to its concrete type. /// `None` when the namespace is absent or the type does not match. pub fn get(&self, namespace: &str) -> Option> { - let service = Arc::clone(self.0.get(namespace)?); - let erased: Arc = service; - erased.downcast().ok() + downcast_service(self.0.get(namespace)?) } /// The raw type-erased service under `namespace`. diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 1cf10f5d..ac9e0634 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -19,11 +19,14 @@ //! namespace) an extension is wired in through at the composition root. //! Domain extensions such as cow-api live in their own crates and plug //! in through this seam rather than being hard-linked into the core host. +//! - [`actor`]: the supervised host-actor primitive provider instances +//! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module //! `[capabilities.http].allow` list. //! - [`logs`]: the typed module-log pipeline (capture points -> router -> //! tracing event + retention store) and its embedder read surface. +pub mod actor; pub mod component; pub mod error; pub mod extension; diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index f13d87aa..8e93942d 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -9,9 +9,9 @@ //! Status and cancel are pass-throughs; they are not submissions, so they //! skip the header, the guard, and the quota. //! -//! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, -//! so each adapter sits behind its own async mutex: concurrent client calls -//! to the same venue queue on that mutex, while calls to different venues run +//! Invocation is serialised per adapter through the supervised-actor +//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent +//! client calls to the same venue queue while calls to different venues run //! in parallel. The lock is held across the guest await, which is the whole //! point - it is the actor boundary that keeps one adapter store //! single-threaded. @@ -28,17 +28,24 @@ use std::fmt; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use anyhow::{Context, anyhow}; +use async_trait::async_trait; use futures::future::BoxFuture; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; -use tracing::warn; +use tracing::{info, warn}; use wasmtime::Store; +use wasmtime::component::HasSelf; use crate::bindings::{ IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, + VenueAdapter, VenueError, nexum, }; +use crate::host::actor::{ActorFault, ActorSlot, SupervisedStore}; use crate::host::component::RuntimeTypes; +use crate::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; use crate::host::state::HostState; /// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. @@ -207,41 +214,31 @@ pub trait VenueInvoker: Send { fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; } -/// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` -/// bindings, refuelled before each guest call. A trap is projected onto -/// `unavailable` rather than propagated: a misbehaving adapter must not be -/// the caller's fault, and it must not unwind through the registry into the -/// calling module's store. +/// The live adapter: a [`SupervisedStore`] plus the `venue-adapter` +/// bindings. Each guest call is refuelled by the primitive; a trap is +/// projected onto `unavailable` rather than propagated, because a +/// misbehaving adapter must not be the caller's fault and must not unwind +/// through the registry into the calling module's store. pub struct VenueActor { - store: Store>, + actor: SupervisedStore, bindings: VenueAdapter, - fuel_per_call: u64, } impl VenueActor { /// Wrap an instantiated adapter store for routing. pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { Self { - store, + actor: SupervisedStore::new(store, fuel_per_call), bindings, - fuel_per_call, } } - - /// Refuel the store before a guest call so each invocation starts from a - /// full budget, mirroring the supervisor's per-event refuel. - fn refuel(&mut self) -> Result<(), VenueError> { - self.store - .set_fuel(self.fuel_per_call) - .map_err(|e| VenueError::Unavailable(format!("adapter refuel failed: {e}"))) - } } -/// Project a wasmtime trap into the venue-error space. The root cause is -/// carried so an operator sees why the adapter died without the wasm frame -/// list leaking to the calling module. -fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { - VenueError::Unavailable(format!("adapter trapped: {}", trap.root_cause())) +/// Project an actor fault into the venue-error space. The fault carries +/// the root cause only, so an operator sees why the adapter died without +/// the wasm frame list leaking to the calling module. +fn venue_fault(fault: ActorFault) -> VenueError { + VenueError::Unavailable(format!("adapter {fault}")) } impl VenueInvoker for VenueActor { @@ -250,31 +247,21 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_derive_header(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_derive_header(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn quote<'a>(&'a mut self, body: &'a [u8]) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_quote(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_quote(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } @@ -283,52 +270,37 @@ impl VenueInvoker for VenueActor { body: &'a [u8], ) -> BoxFuture<'a, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_submit(&mut self.store, body) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_submit(store, body).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_status(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_status(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { Box::pin(async move { - self.refuel()?; - match self - .bindings - .videre_venue_adapter() - .call_cancel(&mut self.store, &receipt) + let adapter = self.bindings.videre_venue_adapter(); + self.actor + .call(async |store| adapter.call_cancel(store, &receipt).await) .await - { - Ok(res) => res, - Err(trap) => Err(trap_to_venue_error(trap)), - } + .map_err(venue_fault)? }) } } -/// One installed adapter behind its serialising mutex. -type AdapterSlot = Arc>; +/// One installed adapter behind its serialising slot. +type AdapterSlot = ActorSlot; /// Per-caller charge history, pruned to the quota window on each touch. #[derive(Default)] @@ -380,9 +352,11 @@ fn status_body(status: IntentStatus) -> StatusBody { /// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; /// every module store carries the same handle, so a submission from any -/// module reaches the same adapters and the same quota ledger. +/// module reaches the same adapters and the same quota ledger. Adapters +/// install through the shared handle at provider boot, before any client +/// call routes. struct VenueRegistryInner { - adapters: HashMap, + adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, @@ -400,6 +374,9 @@ pub struct VenueRegistry { inner: Arc, } +/// The registry is the venue-routing host service. +impl HostService for VenueRegistry {} + impl VenueRegistry { /// An empty registry: no adapters, the unit guard, the default quota. /// This is what an adapter store (which cannot call the client face) and @@ -408,10 +385,28 @@ impl VenueRegistry { VenueRegistryBuilder::new(SubmitQuota::default()).build() } + /// Install an adapter under its venue id. Rejects a duplicate id: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. + pub fn install( + &self, + venue: VenueId, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + if adapters.contains_key(&venue) { + return Err(DuplicateVenue { venue }); + } + adapters.insert(venue, Arc::new(AsyncMutex::new(invoker))); + Ok(()) + } + /// Resolve a venue id to its installed adapter slot. fn resolve(&self, venue: &VenueId) -> Result { self.inner .adapters + .lock() + .expect("adapter map poisoned") .get(venue) .cloned() .ok_or(VenueError::UnknownVenue) @@ -683,7 +678,85 @@ impl VenueRegistry { /// Number of installed, routable adapters. pub fn venue_count(&self) -> usize { - self.inner.adapters.len() + self.inner + .adapters + .lock() + .expect("adapter map poisoned") + .len() + } +} + +/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` +/// component and installs its actor in the venue registry. Registered by +/// the boot path while the registry lives in-core; the videre extension +/// takes it over. +pub struct VenueAdapterKind; + +impl VenueAdapterKind { + /// The manifest kind spelling. + pub const KIND: &'static str = "venue-adapter"; +} + +#[async_trait] +impl ProviderKind for VenueAdapterKind { + fn kind(&self) -> &'static str { + Self::KIND + } + + fn link(&self, linker: &mut wasmtime::component::Linker>) -> anyhow::Result<()> { + // The scoped transport only; the WASI base is the host's, and the + // withheld core interfaces fail instantiation. + nexum::host::chain::add_to_linker::, HasSelf>>(linker, |s| s)?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + linker, + |s| s, + )?; + Ok(()) + } + + async fn install( + &self, + instance: ProviderInstance<'_, T>, + service: &Arc, + ) -> anyhow::Result { + let registry = downcast_service::(service) + .ok_or_else(|| anyhow!("the venue-adapter kind requires the venue-registry service"))?; + let ProviderInstance { + component, + linker, + mut store, + config, + fuel_per_call, + } = instance; + let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) + .await + .map_err(anyhow::Error::from) + .context("instantiate adapter")?; + // The venue id is the adapter's namespace: its manifest name. + let venue_id = VenueId::from(&*store.data().run.module); + match bindings + .call_init(&mut store, &config) + .await + .map_err(anyhow::Error::from)? + { + Ok(()) => info!(adapter = %venue_id, "adapter init succeeded"), + Err(e) => { + warn!( + adapter = %venue_id, + kind = crate::host::error::fault_label(&e), + message = crate::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + return Ok(Installed::Dead); + } + } + registry + .install( + venue_id.clone(), + VenueActor::new(store, bindings, fuel_per_call), + ) + .with_context(|| format!("install adapter {venue_id}"))?; + Ok(Installed::Live) } } @@ -713,12 +786,11 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`VenueRegistry`]: adapters install first (at supervisor -/// boot, before any module store carries the built registry), then the -/// registry freezes. The guard defaults to the unit guard; the egress-guard +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds +/// freeze at build; adapters install afterwards through the shared handle +/// at provider boot. The guard defaults to the unit guard; the egress-guard /// epic overrides it here. pub struct VenueRegistryBuilder { - adapters: HashMap, guard: Arc, quota: SubmitQuota, watch_limit: WatchLimit, @@ -729,7 +801,6 @@ impl VenueRegistryBuilder { /// the default watch limit. pub fn new(quota: SubmitQuota) -> Self { Self { - adapters: HashMap::new(), guard: Arc::new(()), quota, watch_limit: WatchLimit::default(), @@ -750,22 +821,6 @@ impl VenueRegistryBuilder { self } - /// Install an adapter under its venue id. Rejects a duplicate id: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. - pub fn install( - &mut self, - venue: VenueId, - invoker: impl VenueInvoker + 'static, - ) -> Result<(), DuplicateVenue> { - if self.adapters.contains_key(&venue) { - return Err(DuplicateVenue { venue }); - } - self.adapters - .insert(venue, Arc::new(AsyncMutex::new(invoker))); - Ok(()) - } - /// Freeze the builder into a shared registry. pub fn build(self) -> VenueRegistry { if self.quota.max_charges == 0 { @@ -784,7 +839,7 @@ impl VenueRegistryBuilder { WatchLimit::new(self.watch_limit.max_entries.max(1), self.watch_limit.expiry); VenueRegistry { inner: Arc::new(VenueRegistryInner { - adapters: self.adapters, + adapters: Mutex::new(HashMap::new()), guard: self.guard, quota, watch_limit, @@ -1009,8 +1064,9 @@ mod tests { if let Some(guard) = guard { builder = builder.with_guard(guard); } - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = builder.build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] @@ -1310,13 +1366,13 @@ mod tests { #[test] fn duplicate_venue_id_is_rejected() { - let mut builder = VenueRegistryBuilder::new(SubmitQuota::default()); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); - builder + registry .install(cow(), StubAdapter::new(a)) .expect("first install"); - let err = builder + let err = registry .install(cow(), StubAdapter::new(b)) .expect_err("second install collides"); assert_eq!(err.venue, cow()); @@ -1467,10 +1523,11 @@ mod tests { /// A registry with the given watch bounds and one echo-receipt-capable /// stub adapter under `cow`. fn watch_bounded_registry(watch_limit: WatchLimit, adapter: StubAdapter) -> VenueRegistry { - let mut builder = - VenueRegistryBuilder::new(SubmitQuota::default()).with_watch_limit(watch_limit); - builder.install(cow(), adapter).expect("install adapter"); - builder.build() + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(watch_limit) + .build(); + registry.install(cow(), adapter).expect("install adapter"); + registry } #[tokio::test] diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 4264c6e6..8ad2b4d3 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -53,20 +53,20 @@ pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: VENUE_CAPABILITIES, }; -/// The interfaces a `venue-adapter` world links: the scoped transport -/// only. An adapter has no local-store, remote-store, identity, or -/// logging - it moves bytes to and from its venue and nothing else. `http` -/// is not listed here for the same reason it is not in the core set: it +/// The interfaces a provider world links: the scoped transport only. A +/// provider has no local-store, remote-store, identity, or logging - it +/// moves bytes to and from its counterparty and nothing else. `http` is +/// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; -/// The adapter namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating an adapter manifest against +/// The provider namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating a provider manifest against /// a registry built from this namespace rejects a declaration of any core -/// interface an adapter must not reach (e.g. `local-store`) as unknown. -pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { +/// interface a provider must not reach (e.g. `local-store`) as unknown. +pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { prefix: "nexum:host/", - ifaces: ADAPTER_CAPABILITIES, + ifaces: PROVIDER_CAPABILITIES, }; /// Import prefix of the wasi:http package. Every interface under it @@ -135,14 +135,14 @@ impl CapabilityRegistry { } } - /// The registry a venue adapter validates against: only the scoped - /// transport interfaces plus `http`. An adapter manifest that declares + /// The registry a provider validates against: only the scoped + /// transport interfaces plus `http`. A provider manifest that declares /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the adapter linker withholds the same interfaces so the + /// and the provider linker withholds the same interfaces so the /// component cannot instantiate against them either. - pub fn adapter() -> Self { + pub fn provider() -> Self { Self { - namespaces: vec![ADAPTER_NAMESPACE], + namespaces: vec![PROVIDER_NAMESPACE], } } @@ -446,11 +446,11 @@ mod tests { } #[test] - fn adapter_registry_knows_only_scoped_transport() { + fn provider_registry_knows_only_scoped_transport() { // The scoped transport plus http are known; the core-only - // interfaces an adapter must not reach are not, so a manifest + // interfaces a provider must not reach are not, so a manifest // declaring them fails validation as unknown. - let r = CapabilityRegistry::adapter(); + let r = CapabilityRegistry::provider(); assert!(r.is_known("chain")); assert!(r.is_known("messaging")); assert!(r.is_known("http")); @@ -461,8 +461,8 @@ mod tests { } #[test] - fn adapter_registry_maps_transport_imports_but_not_core_only() { - let r = CapabilityRegistry::adapter(); + fn provider_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::provider(); assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.1.0"), Some("chain")); assert_eq!( r.wit_import_to_cap("nexum:host/messaging@0.1.0"), @@ -472,15 +472,15 @@ mod tests { r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), Some("http") ); - // A core-only interface is not a recognised adapter capability. + // A core-only interface is not a recognised provider capability. assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.1.0"), None); } #[test] - fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + fn provider_manifest_declaring_a_core_only_cap_is_unknown() { // The load path validates declared names against the registry; an - // adapter declaring `local-store` must surface as unknown. - let r = CapabilityRegistry::adapter(); + // provider declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::provider(); assert!(!r.is_known("local-store")); assert!(r.known_names().split(", ").all(|n| n != "local-store")); } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 8abb74ea..6ad22674 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -295,8 +295,8 @@ enabled = true } #[test] - fn module_kind_defaults_to_event_module() { - use crate::manifest::types::ModuleKind; + fn component_kind_defaults_to_the_worker() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -304,12 +304,12 @@ name = "plain" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::EventModule); + assert_eq!(manifest.module.kind, ComponentKind::Worker); } #[test] - fn module_kind_parses_venue_adapter() { - use crate::manifest::types::ModuleKind; + fn component_kind_carries_a_provider_spelling() { + use crate::manifest::types::ComponentKind; let manifest: Manifest = toml::from_str( r#" [module] @@ -318,23 +318,28 @@ kind = "venue-adapter" "#, ) .expect("parse"); - assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("venue-adapter".to_owned()), + ); } + /// An unknown spelling parses as a provider kind; boot refuses it + /// against the registered kinds, where the valid set is known. #[test] - fn module_kind_rejects_unknown_variant() { - let err = toml::from_str::( + fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { + use crate::manifest::types::ComponentKind; + let manifest: Manifest = toml::from_str( r#" [module] name = "bad" kind = "gadget" "#, ) - .expect_err("unknown kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("venue-adapter") || msg.contains("event-module"), - "error names the valid kinds: {msg}", + .expect("parse"); + assert_eq!( + manifest.module.kind, + ComponentKind::Provider("gadget".to_owned()), ); } diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index fef64e1e..e93e179b 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, ModuleKind, ResourceSection, Subscription}; +pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 78c62fb4..dbaf774a 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,6 +4,8 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::fmt; + use serde::Deserialize; /// Core capability names: the `nexum:host` interfaces the `event-module` @@ -115,31 +117,54 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Which component kind this manifest describes. Defaults to - /// `event-module` so every existing `module.toml` keeps its meaning; - /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks - /// the bindgen and the scoped capability set from this discriminator. + /// Which component kind this manifest describes. Defaults to the + /// worker kind (`event-module`) so every existing `module.toml` keeps + /// its meaning; a provider names its registered kind. The supervisor + /// resolves the boot path from this discriminator. #[serde(default)] - pub kind: ModuleKind, + pub kind: ComponentKind, /// Per-module resource overrides; each unset field inherits the engine /// `[limits]` default. #[serde(default)] pub resources: ResourceSection, } -/// The component kind a manifest declares. The runtime carries two: the -/// original event-module over the six core primitives, and the venue -/// adapter over scoped transport only. Defaulting to `event-module` -/// preserves the meaning of every manifest written before adapters -/// existed. -#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "kebab-case")] -pub enum ModuleKind { - /// Event-driven automation over the six core primitives. +/// The worker kind's manifest spelling. +pub const WORKER_KIND: &str = "event-module"; + +/// The component kind a manifest declares: the core worker kind, or the +/// manifest spelling of a provider kind an extension registers. Defaults +/// to the worker so every manifest written before providers existed keeps +/// its meaning; an unregistered provider spelling is refused at boot, +/// where the registered kinds are known. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(from = "String")] +pub enum ComponentKind { + /// Event-driven worker over the six core primitives (`event-module`). #[default] - EventModule, - /// A single-venue adapter over scoped chain, messaging, and HTTP. - VenueAdapter, + Worker, + /// A provider the host holds behind a serialised actor, named by its + /// manifest spelling. + Provider(String), +} + +impl From for ComponentKind { + fn from(kind: String) -> Self { + if kind == WORKER_KIND { + Self::Worker + } else { + Self::Provider(kind) + } + } +} + +impl fmt::Display for ComponentKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Worker => f.write_str(WORKER_KIND), + Self::Provider(kind) => f.write_str(kind), + } + } } /// `[module.resources]` overrides layered over the engine `[limits]` /// defaults. Every field is optional; an unset field keeps the default. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index f31a9a52..154e8193 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -26,6 +26,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. +use std::collections::BTreeMap; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -38,12 +39,14 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; -use crate::host::extension::{Extension, HostServices}; +use crate::host::extension::{ + Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, +}; use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; @@ -51,9 +54,9 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueActor, VenueId, VenueRegistry, VenueRegistryBuilder}; +use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ - self, CapabilityRegistry, LoadedManifest, ModuleKind, ResourceSection, Subscription, + self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; /// Owns every loaded module and exposes the dispatch surface the @@ -256,19 +259,52 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// A venue adapter instantiated into a supervised store, ready to install in -/// the venue registry. It boots through the same store, fuel, and memory -/// machinery as a module but carries no subscriptions: modules reach it -/// through the registry, not through dispatch. Adapter restart and poison -/// handling are still a later change; an `init` failure leaves `alive` false -/// so the adapter is loaded but not routable. -struct LoadedAdapter { - /// Venue id the adapter answers for (its manifest name). - venue_id: VenueId, - /// The refuelable adapter store, ready to serialise behind a registry mutex. - actor: VenueActor, - /// Whether `init` succeeded; a failed adapter is not installed for routing. - alive: bool, +/// One registered provider kind paired with the service its installs bind to. +type ProviderRow = (Box>, Arc); + +/// Registered provider kinds, keyed by their manifest spelling. +type ProviderKinds = BTreeMap<&'static str, ProviderRow>; + +/// Collect each extension's provider kind paired with that extension's +/// service. Refuses a duplicate spelling and a provider whose extension +/// owns no service to install into. +fn provider_kinds( + extensions: &[Arc>], + services: &HostServices, +) -> Result> { + let mut kinds = ProviderKinds::new(); + for ext in extensions { + let Some(provider) = ext.provider() else { + continue; + }; + let service = services.raw(ext.namespace()).cloned().ok_or_else(|| { + anyhow!( + "extension {} registers provider kind {} without a host service", + ext.namespace(), + provider.kind(), + ) + })?; + register_kind(&mut kinds, provider, service)?; + } + Ok(kinds) +} + +/// Insert one kind row, refusing a duplicate manifest spelling. +fn register_kind( + kinds: &mut ProviderKinds, + provider: Box>, + service: Arc, +) -> Result<()> { + let kind = provider.kind(); + if kinds.insert(kind, (provider, service)).is_some() { + return Err(anyhow!("provider kind {kind} is registered twice")); + } + Ok(()) +} + +/// Comma-joined registered provider kind spellings, for boot errors. +fn registered_kinds(kinds: &ProviderKinds) -> String { + kinds.keys().copied().collect::>().join(", ") } impl Supervisor { @@ -285,44 +321,44 @@ impl Supervisor { ) -> Result { let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; - // Adapters instantiate first: the venue registry must contain them - // before any module store (which carries the built registry) is - // built. Adapters link only their scoped transport, against a - // dedicated linker built from the same core backends, and their own - // stores carry an empty registry since an adapter cannot call the + let venue_registry = VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()) + .build(); + // Provider kinds the boot loop resolves manifest kinds against: + // every extension-registered kind plus the venue-adapter row, seeded + // here while the registry lives in-core; the videre extension takes + // it over. + let mut kinds = provider_kinds(extensions, &services)?; + register_kind( + &mut kinds, + Box::new(VenueAdapterKind), + Arc::new(venue_registry.clone()), + )?; + // Providers boot first into the shared registry handle, so every + // module store built below already routes to the installed venues. + // Providers link only their kind's scoped imports, and their own + // stores carry an empty registry since a provider cannot call the // client face. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut registry_builder = VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()); + let provider_registry = CapabilityRegistry::provider(); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let installed = Self::load_provider( engine, - &adapter_linker, entry, components, &engine_cfg.limits, - &adapter_registry, + &provider_registry, clocks.as_ref(), services.clone(), + &kinds, ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - if loaded.alive { + .with_context(|| format!("load provider {}", entry.path.display()))?; + if installed == Installed::Live { adapters_alive += 1; - registry_builder - .install(loaded.venue_id.clone(), loaded.actor) - .with_context(|| format!("install adapter {}", loaded.venue_id))?; - } else { - warn!( - adapter = %loaded.venue_id, - "adapter init failed - not installed for routing", - ); } } - let venue_registry = registry_builder.build(); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { @@ -672,64 +708,78 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest, verify it - /// declares the venue-adapter kind, enforce the scoped-transport - /// capability set, build a supervised store carrying the operator's - /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings - /// against the adapter linker, and run `init`. Nothing dispatches to - /// the result yet; it boots so the registry can later reach it. + /// Load one `[[adapters]]` entry: resolve its manifest, resolve the + /// declared kind against the registered provider kinds, enforce the + /// scoped-transport capability set, build a supervised store carrying + /// the operator's HTTP and messaging grants, and hand the instance to + /// its kind to instantiate and install. [`Installed::Dead`] marks a + /// failed guest `init`: loaded and counted, but not routable. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] - async fn load_adapter( + async fn load_provider( engine: &Engine, - linker: &Linker>, entry: &AdapterEntry, components: &Components, limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, - ) -> Result> { + kinds: &ProviderKinds, + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { - info!(manifest = %p.display(), "loading adapter manifest"); + info!(manifest = %p.display(), "loading provider manifest"); manifest::load(p, registry)? } _ => { warn!( component = %entry.path.display(), - "no module.toml - falling back to anonymous adapter" + "no module.toml - falling back to anonymous provider" ); manifest::fallback_manifest() } }; // The manifest kind is the discriminator: an [[adapters]] entry - // whose manifest is (or defaults to) an event-module is a config - // error, caught here before instantiation. A fallback manifest has - // the default event-module kind, so an adapter must ship a - // module.toml that declares the venue-adapter kind explicitly. - let kind = loaded_manifest.manifest.module.kind; - if kind != ModuleKind::VenueAdapter { - return Err(anyhow!( - "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ - a module.toml with [module] kind = \"venue-adapter\"", - entry.path.display(), - )); - } + // must name a registered provider kind, caught here before + // instantiation. A fallback manifest has the default worker kind, + // so a provider must ship a module.toml that declares its kind + // explicitly. + let (kind, service) = match &loaded_manifest.manifest.module.kind { + ComponentKind::Worker => { + return Err(anyhow!( + "{} declares the worker kind; an [[adapters]] entry requires a \ + module.toml declaring a registered provider kind ({})", + entry.path.display(), + registered_kinds(kinds), + )); + } + ComponentKind::Provider(spelling) => kinds.get(spelling.as_str()).ok_or_else(|| { + anyhow!( + "{} declares unregistered provider kind {spelling}; registered \ + kinds: {}", + entry.path.display(), + registered_kinds(kinds), + ) + })?, + }; - info!(component = %entry.path.display(), "compiling adapter component"); + info!( + component = %entry.path.display(), + kind = kind.kind(), + "compiling provider component", + ); let component = Component::from_file(engine, &entry.path) .map_err(Error::from) .with_context(|| format!("compile {}", entry.path.display()))?; // Enforce the scoped-transport capability set: `registry` is the - // adapter registry, so a declaration of any core-only interface + // provider registry, so a declaration of any core-only interface // fails at manifest load, and an undeclared transport import fails - // here. The linker withholds the same core-only interfaces, so an - // adapter reaching for one also fails to instantiate below. + // here. The linker withholds the same core-only interfaces, so a + // provider reaching for one also fails to instantiate. manifest::enforce_capabilities( &loaded_manifest, component.component_type().imports(engine).map(|(n, _)| n), @@ -737,29 +787,31 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "adapter".to_owned() + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() } else { loaded_manifest.manifest.module.name.clone() }; info!( - adapter = %adapter_namespace, + provider = %namespace, + kind = kind.kind(), fuel = limits_cfg.fuel(), memory_bytes = limits_cfg.memory(), http_allow = entry.http_allow.len(), messaging_topics = entry.messaging_topics.len(), - "applied adapter resource limits and transport scope", + "applied provider resource limits and transport scope", ); - let run = RunId::new(adapter_namespace.clone(), 0); - // An adapter store cannot call the client face, so it carries an + let linker = build_provider_linker::(engine, kind.as_ref())?; + let run = RunId::new(namespace.clone(), 0); + // A provider store cannot call the client face, so it carries an // empty registry; this also keeps the real registry out of the - // adapter's `HostState`, so there is no reference cycle back into + // provider's `HostState`, so there is no reference cycle back into // the registry that owns it. - let mut store = Self::build_store( + let store = Self::build_store( engine, components, - run.clone(), + run, entry.http_allow.clone(), limits_cfg.http(), entry.messaging_topics.clone(), @@ -771,43 +823,24 @@ impl Supervisor { VenueRegistry::empty(), services, )?; - let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) - .await - .map_err(Error::from) - .with_context(|| format!("instantiate {}", entry.path.display()))?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), adapter_namespace.clone())] + vec![("name".into(), namespace)] } else { loaded_manifest.config.clone() }; - let init_succeeded = match bindings - .call_init(&mut store, &config) - .await - .map_err(Error::from)? - { - Ok(()) => { - info!(adapter = %adapter_namespace, "adapter init succeeded"); - true - } - Err(e) => { - warn!( - adapter = %adapter_namespace, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), - "adapter init failed - loaded but marked dead", - ); - false - } - }; - // Refuel after init so the first routed call starts with a full budget. - store.set_fuel(limits_cfg.fuel())?; - - Ok(LoadedAdapter { - venue_id: VenueId::from(adapter_namespace), - actor: VenueActor::new(store, bindings, limits_cfg.fuel()), - alive: init_succeeded, - }) + kind.install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config, + fuel_per_call: limits_cfg.fuel(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display())) } /// Number of modules currently loaded. @@ -1464,27 +1497,20 @@ pub fn build_linker( Ok(linker) } -/// Build a `Linker` for the `venue-adapter` world: only the scoped -/// transport an adapter may reach - `chain`, `messaging`, and the -/// allowlisted `wasi:http` - plus the ambient WASI base. The core -/// `nexum:host` interfaces an adapter must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so an -/// adapter that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into adapters: an -/// adapter speaks its venue's protocol over the standard transport, not a -/// domain extension surface. -pub fn build_adapter_linker( +/// Build a `Linker` for one provider kind: the kind's own scoped imports +/// plus the ambient WASI base and the allowlisted `wasi:http`. The core +/// `nexum:host` interfaces a provider must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so a +/// provider that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into providers: a +/// provider speaks its protocol over the standard transport, not a domain +/// extension surface. +pub fn build_provider_linker( engine: &Engine, + kind: &dyn ProviderKind, ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); - nexum::host::chain::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; - nexum::host::messaging::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; + kind.link(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; Ok(linker) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6d814a16..68093413 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -646,13 +646,14 @@ impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { /// Build a registry with one scripted adapter installed under `cow`. fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let mut builder = crate::host::venue_registry::VenueRegistryBuilder::new( + let registry = crate::host::venue_registry::VenueRegistryBuilder::new( crate::host::venue_registry::SubmitQuota::default(), - ); - builder + ) + .build(); + registry .install(crate::host::venue_registry::VenueId::from("cow"), adapter) .expect("install"); - builder.build() + registry } /// Write a manifest subscribing the example module to intent-status @@ -2825,15 +2826,18 @@ fn chainlog_cursor_key_differs_by_each_input() { // ── venue-adapter boot ──────────────────────────────────────────────── -/// The venue-adapter linker binds only the scoped transport (chain, -/// messaging, wasi base, allowlisted http) and withholds the core-only -/// interfaces. Assembling it proves the scope wires without a +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a /// duplicate-definition clash between the shared `nexum:host` interfaces. #[tokio::test] -async fn adapter_linker_assembles_with_scoped_transport() { +async fn provider_linker_assembles_with_scoped_transport() { let engine = make_wasmtime_engine(); - crate::supervisor::build_adapter_linker::(&engine) - .expect("adapter linker assembles"); + crate::supervisor::build_provider_linker::( + &engine, + &crate::host::venue_registry::VenueAdapterKind, + ) + .expect("provider linker assembles"); } /// The module-kind discriminator gates the adapter load path: an @@ -2876,6 +2880,41 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ); } +/// A kind spelling no extension registered is refused at boot with a +/// message naming the registered kinds. +#[tokio::test] +async fn boot_rejects_an_unregistered_provider_kind() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write(&manifest, "[module]\nname = \"bad\"\nkind = \"gadget\"\n") + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("gadget.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + "the refusal names the unknown spelling and the registered kinds: {msg}", + ); +} + /// A venue-adapter manifest clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This /// proves the discriminator routed the entry to the adapter load path From 381cd8e70b8ac64dea74f9f56bfa01ac34fff497 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 13:02:10 +0000 Subject: [PATCH 041/139] runtime: make the log pipeline pluggable through the components seam (#442) feat: make the log pipeline pluggable through the components seam ComponentsBuilder grows a fourth logs slot, defaulting to the new LogPipelineBuilder (the in-memory pipeline sized from [limits.logs]); with_logs substitutes a custom builder. The Runtime preset names the slot as LogsBuilder and the launcher cars carry the parameter through. --- crates/nexum-runtime/src/builder.rs | 24 +++-- .../src/host/component/builder.rs | 94 ++++++++++++++++--- .../nexum-runtime/src/host/component/mod.rs | 2 +- crates/nexum-runtime/src/preset.rs | 14 ++- 4 files changed, 106 insertions(+), 28 deletions(-) diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 412c4d8f..f5559506 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -494,10 +494,10 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// Bind the component builders that open the backends at launch. - pub fn with_components( + pub fn with_components( self, - components: ComponentsBuilder, - ) -> ComponentsStage<'a, T, C, S, E> { + components: ComponentsBuilder, + ) -> ComponentsStage<'a, T, C, S, E, L> { ComponentsStage { config: self.config, extensions: self.extensions, @@ -511,19 +511,22 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { } /// The component builders are bound; the add-on set remains. -pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { +pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, _t: PhantomData T>, } -impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { +impl<'a, T: RuntimeTypes, C, S, E, L> ComponentsStage<'a, T, C, S, E, L> { /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons(self, add_ons: &'a [&'a dyn RuntimeAddOn]) -> ReadyBuilder<'a, T, C, S, E> { + pub fn with_add_ons( + self, + add_ons: &'a [&'a dyn RuntimeAddOn], + ) -> ReadyBuilder<'a, T, C, S, E, L> { ReadyBuilder { config: self.config, extensions: self.extensions, @@ -538,22 +541,23 @@ impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// The assembly is complete; [`launch`](Self::launch) opens the backends and /// runs. -pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { +pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - components: ComponentsBuilder, + components: ComponentsBuilder, add_ons: &'a [&'a dyn RuntimeAddOn], } -impl ReadyBuilder<'_, T, C, S, E> +impl ReadyBuilder<'_, T, C, S, E, L> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { /// Open the backends and launch. Builds the [`Components`] bundle from the /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 705896bf..60b734cf 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -3,8 +3,9 @@ //! //! Each core backend is wrapped as a [`ComponentBuilder`], and //! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload) into a [`Components`] bundle. The composition root names the -//! concrete builders once; boot drives them through this trait. +//! payload and the log pipeline) into a [`Components`] bundle. The +//! composition root names the concrete builders once; boot drives them +//! through this trait. use std::future::Future; use std::path::Path; @@ -81,6 +82,18 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// Builds the default [`LogPipeline`]: the byte-bounded in-memory backend +/// sized from `[limits.logs]`. +pub struct LogPipelineBuilder; + +impl ComponentBuilder for LogPipelineBuilder { + type Output = LogPipeline; + + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } +} + /// Names the component slot whose build failed. The leaf cause stays an /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). @@ -95,6 +108,9 @@ pub enum BuildError { /// The extension payload builder failed. #[error("build the extension payload: {0}")] Ext(anyhow::Error), + /// The log pipeline builder failed. + #[error("build the log pipeline: {0}")] + Logs(anyhow::Error), } /// The empty extension payload: a no-op builder for a core-only lattice @@ -107,41 +123,62 @@ impl ComponentBuilder for () { } } -/// Assembles the core backend builders and the lattice `Ext` builder into -/// a [`Components`] bundle. The log pipeline is sized from `[limits.logs]` -/// and built here; the embedder retains its read handle by cloning -/// [`Components::logs`] after the build. -pub struct ComponentsBuilder { +/// Assembles the core backend builders, the lattice `Ext` builder, and the +/// log pipeline builder into a [`Components`] bundle. The logs slot defaults +/// to [`LogPipelineBuilder`]; the embedder retains the read handle by +/// cloning [`Components::logs`] after the build. +pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, /// Builds the store backend ([`RuntimeTypes::Store`]). pub store: S, /// Builds the extension payload ([`RuntimeTypes::Ext`]). pub ext: E, + /// Builds the shared [`LogPipeline`]. + pub logs: L, } impl ComponentsBuilder { - /// Create a new [`ComponentsBuilder`]. + /// Create a new [`ComponentsBuilder`] with the default log pipeline. pub fn new(chain: C, store: S, ext: E) -> Self { - Self { chain, store, ext } + Self { + chain, + store, + ext, + logs: LogPipelineBuilder, + } + } +} + +impl ComponentsBuilder { + /// Replace the log pipeline builder. + pub fn with_logs(self, logs: L2) -> ComponentsBuilder { + ComponentsBuilder { + chain: self.chain, + store: self.store, + ext: self.ext, + logs, + } } - /// Drive each builder against `ctx`, then bundle the backends with a - /// fresh log pipeline. The builder outputs must match the lattice - /// seams: chain to [`RuntimeTypes::Chain`], store to - /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. A failing - /// sub-build returns the [`BuildError`] variant naming that slot. + /// Drive each builder against `ctx` and bundle the backends. The + /// builder outputs must match the lattice seams: chain to + /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to + /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A + /// failing sub-build returns the [`BuildError`] variant naming that + /// slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, + L: ComponentBuilder, { let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; let store = self.store.build(ctx).await.map_err(BuildError::Store)?; let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; - let logs = LogPipeline::in_memory(ctx.config.limits.logs()); + let logs = self.logs.build(ctx).await.map_err(BuildError::Logs)?; Ok(Components { chain, store, @@ -189,4 +226,31 @@ mod tests { // The bundle carries a live in-memory log pipeline. let _ = &components.logs; } + + /// `with_logs` substitutes the log pipeline builder: the bundle carries + /// the exact pipeline the custom builder yields. + #[tokio::test] + async fn with_logs_substitutes_the_pipeline() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = nexum_tasks::TaskManager::new(); + let executor = tasks.executor(); + let ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(crate::test_utils::Prebuilt(custom.clone())) + .build::(&ctx) + .await + .expect("build with a custom log pipeline"); + + assert!( + std::sync::Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the substituted pipeline", + ); + } } diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index 0adb15fa..eaa0e70a 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -11,7 +11,7 @@ mod state; pub use builder::{ BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, - ProviderPoolBuilder, + LogPipelineBuilder, ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 05556ee3..19a948a9 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -8,9 +8,11 @@ use crate::addons::{AddOns, PrometheusAddOn}; use crate::host::component::{ - ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, + ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, + ProviderPoolBuilder, RuntimeTypes, }; use crate::host::local_store_redb::LocalStore; +use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; /// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component @@ -27,9 +29,16 @@ pub trait Runtime { type StoreBuilder: ComponentBuilder::Store>; /// Builds the extension payload ([`RuntimeTypes::Ext`]). type ExtBuilder: ComponentBuilder::Ext>; + /// Builds the shared [`LogPipeline`]. + type LogsBuilder: ComponentBuilder; /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder; + fn components() -> ComponentsBuilder< + Self::ChainBuilder, + Self::StoreBuilder, + Self::ExtBuilder, + Self::LogsBuilder, + >; /// The cross-cutting add-ons installed before the engine boots. fn add_ons() -> AddOns; @@ -52,6 +61,7 @@ impl Runtime for CoreRuntime { type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; fn components() -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) From 59c357c4f513b4e5c241596b976b12aea8c837dd Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 13:09:37 +0000 Subject: [PATCH 042/139] runtime: carry the venue registry through the services map (#443) runtime: carry the venue registry through the services map and delete the privileged field The client face resolves the registry from the store's service map under the videre namespace; no service means every call resolves to unknown-venue. The boot path seeds the registry into the map until the videre extension takes it over, provider stores carry an empty map so the registry never cycles back into a store it owns, and the supervisor field is gone. --- crates/nexum-runtime/src/builder.rs | 16 ++-- crates/nexum-runtime/src/host/extension.rs | 14 ++++ .../src/host/impls/venue_client.rs | 37 +++++---- crates/nexum-runtime/src/host/state.rs | 8 +- .../nexum-runtime/src/host/venue_registry.rs | 9 +-- crates/nexum-runtime/src/supervisor.rs | 77 +++++++------------ crates/nexum-runtime/src/supervisor/tests.rs | 2 +- 7 files changed, 79 insertions(+), 84 deletions(-) diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index f5559506..beaa2eb6 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -267,10 +267,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); // Status polling runs only when it can produce something a module - // will see: at least one intent-status subscriber and at least one - // installed adapter to poll. - let poll_statuses = supervisor.has_intent_status_subscribers() - && supervisor.venue_registry().venue_count() > 0; + // will see: at least one intent-status subscriber, a registered + // venue-registry service, and at least one installed adapter to poll. + let status_registry = supervisor + .has_intent_status_subscribers() + .then(|| supervisor.venue_registry()) + .flatten() + .filter(|registry| registry.venue_count() > 0); + let poll_statuses = status_registry.is_some(); // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. @@ -308,9 +312,9 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = poll_statuses.then(|| { + let intent_status_stream = status_registry.map(|registry| { event_loop::open_intent_status_stream( - supervisor.venue_registry(), + registry, engine_cfg.limits.status_poll_interval(), &executor, &mut reconnect_tasks, diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index ed14de95..a282d4ba 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -143,6 +143,20 @@ impl HostServices { pub fn raw(&self, namespace: &str) -> Option<&Arc> { self.0.get(namespace) } + + /// Publish `service` under `namespace`, refusing a duplicate. The boot + /// path seeds a service no extension registers yet. + pub fn with_service( + self, + namespace: &'static str, + service: Arc, + ) -> anyhow::Result { + let mut map = Arc::unwrap_or_clone(self.0); + if map.insert(namespace, service).is_some() { + anyhow::bail!("duplicate extension service namespace {namespace}"); + } + Ok(Self(Arc::new(map))) + } } #[cfg(test)] diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/nexum-runtime/src/host/impls/venue_client.rs index 0fac7d94..8b86e8a4 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/nexum-runtime/src/host/impls/venue_client.rs @@ -1,25 +1,36 @@ -//! `videre:venue/client`: the keeper-facing venue import. Every method is a -//! thin delegation to the shared -//! [`VenueRegistry`](crate::host::venue_registry) carried in the store; the -//! registry owns the venue resolution, per-adapter serialisation, guard -//! seam (advisory-only for now), and quota. The caller identity the registry -//! meters against is this store's module namespace. +//! `videre:venue/client`: the keeper-facing venue import. Every method +//! resolves the shared [`VenueRegistry`] from the store's service map under +//! the videre namespace and delegates; the registry owns the venue +//! resolution, per-adapter serialisation, guard seam (advisory-only for +//! now), and quota. The caller identity the registry meters against is this +//! store's module namespace. No registry service means no venues, so every +//! call resolves to `unknown-venue`. + +use std::sync::Arc; use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::host::venue_registry::VenueId; +use crate::host::venue_registry::{VenueId, VenueRegistry}; + +/// The registry published under the videre service namespace. +fn registry(state: &HostState) -> Result, VenueError> { + state + .services + .get::(VenueRegistry::NAMESPACE) + .ok_or(VenueError::UnknownVenue) +} impl Host for HostState { async fn quote(&mut self, venue: String, body: Vec) -> Result { - self.venue_registry + registry(self)? .quote(&self.run.module, &VenueId::from(venue), body) .await } async fn submit(&mut self, venue: String, body: Vec) -> Result { - self.venue_registry + registry(self)? .submit(&self.run.module, &VenueId::from(venue), body) .await } @@ -29,14 +40,10 @@ impl Host for HostState { venue: String, receipt: Vec, ) -> Result { - self.venue_registry - .status(&VenueId::from(venue), receipt) - .await + registry(self)?.status(&VenueId::from(venue), receipt).await } async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { - self.venue_registry - .cancel(&VenueId::from(venue), receipt) - .await + registry(self)?.cancel(&VenueId::from(venue), receipt).await } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 3d85117d..b1b103cb 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -14,7 +14,6 @@ use super::component::{Handle, RuntimeTypes}; use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -use super::venue_registry::VenueRegistry; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -52,12 +51,9 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, - /// The venue registry the `videre:venue/client` import dispatches to. - /// Every module store carries the same shared handle; an adapter store, - /// which cannot call the client face, carries an empty one. - pub venue_registry: VenueRegistry, /// Extension-owned host services, keyed by extension namespace and - /// downcast at the call site. One shared map across every store. + /// downcast at the call site. One shared map across every module store; + /// a provider store carries an empty map. pub services: HostServices, } diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 8e93942d..46c5485c 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -378,12 +378,9 @@ pub struct VenueRegistry { impl HostService for VenueRegistry {} impl VenueRegistry { - /// An empty registry: no adapters, the unit guard, the default quota. - /// This is what an adapter store (which cannot call the client face) and - /// the single-module `just run` path carry. - pub fn empty() -> Self { - VenueRegistryBuilder::new(SubmitQuota::default()).build() - } + /// Service namespace the registry publishes under: the videre + /// extension's. + pub const NAMESPACE: &'static str = "videre"; /// Install an adapter under its venue id. Rejects a duplicate id: two /// adapters answering the same venue would silently shadow one another, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 154e8193..92894f6b 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -64,13 +64,6 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// The venue registry: every installed venue adapter's serialising - /// store, keyed by venue id. Cached so a module restart rebuilds a store - /// carrying the same shared handle. Adapters boot through the same store, - /// fuel, and memory machinery as modules but carry no subscriptions: - /// modules reach them through this registry, not through dispatch. Folding - /// adapters into the restart and poison sweeps is still a later change. - venue_registry: VenueRegistry, /// Venue adapters loaded at boot, whether or not `init` succeeded. adapters_total: usize, /// Adapters whose `init` succeeded and that are installed for routing. @@ -320,25 +313,22 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - let services = HostServices::from_extensions(extensions)?; - let venue_registry = VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()) - .build(); - // Provider kinds the boot loop resolves manifest kinds against: - // every extension-registered kind plus the venue-adapter row, seeded - // here while the registry lives in-core; the videre extension takes - // it over. + // The venue registry rides the generic service map under the videre + // namespace, seeded here while it lives in-core; the videre + // extension takes it over. Same for the venue-adapter kind row. + let venue_service: Arc = Arc::new( + VenueRegistryBuilder::new(engine_cfg.limits.quota()) + .with_watch_limit(engine_cfg.limits.watch()) + .build(), + ); + let services = HostServices::from_extensions(extensions)? + .with_service(VenueRegistry::NAMESPACE, Arc::clone(&venue_service))?; + // Provider kinds the boot loop resolves manifest kinds against. let mut kinds = provider_kinds(extensions, &services)?; - register_kind( - &mut kinds, - Box::new(VenueAdapterKind), - Arc::new(venue_registry.clone()), - )?; + register_kind(&mut kinds, Box::new(VenueAdapterKind), venue_service)?; // Providers boot first into the shared registry handle, so every // module store built below already routes to the installed venues. - // Providers link only their kind's scoped imports, and their own - // stores carry an empty registry since a provider cannot call the - // client face. + // Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); let adapters_total = engine_cfg.adapters.len(); let mut adapters_alive = 0; @@ -350,7 +340,6 @@ impl Supervisor { &engine_cfg.limits, &provider_registry, clocks.as_ref(), - services.clone(), &kinds, ) .await @@ -370,7 +359,6 @@ impl Supervisor { &engine_cfg.limits, ®istry, clocks.as_ref(), - venue_registry.clone(), services.clone(), ) .await @@ -387,7 +375,6 @@ impl Supervisor { ); Ok(Self { modules, - venue_registry, adapters_total, adapters_alive, engine: engine.clone(), @@ -423,9 +410,8 @@ impl Supervisor { manifest: manifest.map(Path::to_path_buf), }; // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so the registry is empty here and - // every client call resolves to `unknown-venue`. - let venue_registry = VenueRegistry::empty(); + // configured through `engine.toml`, so no registry service is + // published and every client call resolves to `unknown-venue`. let loaded = Self::load_one( engine, linker, @@ -434,13 +420,11 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), - venue_registry.clone(), services.clone(), ) .await?; Ok(Self { modules: vec![loaded], - venue_registry, adapters_total: 0, adapters_alive: 0, engine: engine.clone(), @@ -471,7 +455,6 @@ impl Supervisor { chain_response_max_bytes: usize, state_quota: u64, clocks: Option<&WasiClockOverride>, - venue_registry: VenueRegistry, services: HostServices, ) -> Result> { let namespace: &str = &run.module; @@ -530,7 +513,6 @@ impl Supervisor { chain: components.chain.clone(), chain_response_max_bytes, store: module_store, - venue_registry, services, }, ); @@ -539,8 +521,7 @@ impl Supervisor { Ok(store) } - // One flat argument per shared input threaded onto the store, plus the - // venue registry the module's `videre:venue/client` import dispatches to. + // One flat argument per shared input threaded onto the store. #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, @@ -550,7 +531,6 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - venue_registry: VenueRegistry, services: HostServices, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); @@ -616,7 +596,6 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), state_bytes, clocks, - venue_registry, services, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) @@ -724,7 +703,6 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, - services: HostServices, kinds: &ProviderKinds, ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); @@ -804,10 +782,9 @@ impl Supervisor { let linker = build_provider_linker::(engine, kind.as_ref())?; let run = RunId::new(namespace.clone(), 0); - // A provider store cannot call the client face, so it carries an - // empty registry; this also keeps the real registry out of the - // provider's `HostState`, so there is no reference cycle back into - // the registry that owns it. + // A provider links no service-consuming import, so its store carries + // an empty service map; the shared map holds the registry that owns + // the provider's store, and carrying it here would cycle. let store = Self::build_store( engine, components, @@ -820,8 +797,7 @@ impl Supervisor { limits_cfg.chain_response_max_bytes(), limits_cfg.state_bytes(), clocks, - VenueRegistry::empty(), - services, + HostServices::default(), )?; let config: Config = if loaded_manifest.config.is_empty() { @@ -969,10 +945,9 @@ impl Supervisor { let linker = build_linker::(&self.engine, &self.extensions)?; // Borrowed before the `&mut self.modules[idx]` reborrow so the restart - // path applies the same clock override and the same shared registry + // path applies the same clock override and the same shared services // as the initial boot. let clocks = self.clocks.clone(); - let venue_registry = self.venue_registry.clone(); let services = self.services.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key @@ -990,7 +965,6 @@ impl Supervisor { module.chain_response_max_bytes, module.local_store_bytes, clocks.as_ref(), - venue_registry, services, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) @@ -1244,9 +1218,12 @@ impl Supervisor { }) } - /// The shared venue registry carried by every module store. - pub fn venue_registry(&self) -> VenueRegistry { - self.venue_registry.clone() + /// The venue registry published under the videre service namespace, + /// when one is. Shared by every module store through the service map. + pub fn venue_registry(&self) -> Option { + self.services + .get::(VenueRegistry::NAMESPACE) + .map(|registry| (*registry).clone()) } /// Shared per-module dispatch path: refuel, call `on_event`, and diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 68093413..3830e6f3 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -898,7 +898,7 @@ async fn e2e_echo_module_registry_adapter_round_trip() { // Poll the registry the module submitted through and fan its transitions // back to the module. echo-venue settles instantly, so the first poll // reports a terminal status and the watch is pruned. - let registry = supervisor.venue_registry(); + let registry = supervisor.venue_registry().expect("registry service"); let mut delivered = 0; for _ in 0..2 { for update in registry.poll_status_transitions().await { From 97e029211b7938e84c488339873420a28571f0a4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 20 Jul 2026 13:16:31 +0000 Subject: [PATCH 043/139] runtime: let a preset carry extensions and pre-built backends (#444) feat: let a runtime preset carry extensions and pre-built backends --- crates/nexum-runtime/examples/embed.rs | 2 +- crates/nexum-runtime/src/builder.rs | 198 ++++++++++++++++++++++--- crates/nexum-runtime/src/preset.rs | 49 ++++-- 3 files changed, 216 insertions(+), 33 deletions(-) diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index c166e006..7d92343f 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -5,7 +5,7 @@ //! backends (chain provider pool, local redb store, empty extension slot) and //! the Prometheus add-on. A domain capability such as cow-api is added by //! writing a preset that names its extension builder in the `Ext` slot and -//! its linker hook via `with_extensions`, or by dropping to the explicit +//! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the //! in-process log read side; clone it to keep reading after `wait` consumes //! the handle. diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index beaa2eb6..49eb53aa 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -13,7 +13,9 @@ //! an embedder holding pre-built backends constructs an [`AssembledRuntime`] //! and calls [`LaunchRuntime::launch`] directly. For the common case, //! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, and add-ons in one call. +//! lattice, component builders, extensions, and add-ons in one call; +//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built +//! backends. use std::future::{Future, IntoFuture}; use std::marker::PhantomData; @@ -372,35 +374,42 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a [`Runtime`] preset that bundles the lattice, the component - /// builders, and the add-on set. Sugar over the type-state chain: an + /// Bind a [`Runtime`] preset by marker. Sugar over + /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. - pub fn runtime(self) -> PresetBuilder<'a, R> { + pub fn runtime(self) -> PresetBuilder<'a, R> { + self.with_runtime(R::default()) + } + + /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built + /// backends and extensions into the launch. + pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { PresetBuilder { config: self.config, + preset, extensions: Vec::new(), wasm: None, manifest: None, clocks: None, - _r: PhantomData, } } } /// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, and the add-on set, leaving only the -/// optional extension hooks and module source before [`launch`](Self::launch). +/// lattice, the component builders, its extensions, and the add-on set, +/// leaving only the optional extension hooks and module source before +/// [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, + preset: R, extensions: Vec>>, wasm: Option, manifest: Option, clocks: Option, - _r: PhantomData R>, } impl<'a, R: Runtime> PresetBuilder<'a, R> { - /// Add extensions on top of the preset. The default preset carries none. + /// Append extensions on top of the preset's own. pub fn with_extensions( mut self, extensions: impl IntoIterator>>, @@ -426,8 +435,9 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// from the preset's component builders, gathers the preset's extensions + /// (appended ones after), installs the preset's add-ons, then drives + /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -437,16 +447,21 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let components = R::components().build::(&build_ctx).await?; - + let mut extensions = self.preset.extensions(); + extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. - let add_ons = R::add_ons(); + let add_ons = self.preset.add_ons(); let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); + let components = self + .preset + .components() + .build::(&build_ctx) + .await?; let runtime = AssembledRuntime { components, - extensions: self.extensions, + extensions, add_ons: &add_on_refs, wasm: self.wasm.as_deref(), manifest: self.manifest.as_deref(), @@ -599,9 +614,14 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use super::*; + use crate::addons::AddOns; use crate::engine_config::EngineConfig; - use crate::host::component::{LocalStoreBuilder, ProviderPoolBuilder}; - use crate::preset::CoreRuntime; + use crate::host::component::{LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder}; + use crate::host::state::HostState; + use crate::manifest::NamespaceCaps; + use crate::preset::{CoreRuntime, Runtime as RuntimePreset}; + use crate::test_utils::Prebuilt; + use wasmtime::component::Linker; /// The preset shortcut is exercised at runtime, not just compiled: the /// component builders open the backends, the add-ons install, and the @@ -625,6 +645,150 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } + /// Counts linker hook runs, so a test observes an extension reaching the + /// launch's linker build. + struct CountingExt { + namespace: &'static str, + prefix: &'static str, + linked: Arc, + } + + impl Extension for CountingExt { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: self.prefix, + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + self.linked.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + + /// A value-bound preset carrying its own extension. + struct ExtPreset { + linked: Arc, + } + + impl RuntimePreset for ExtPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + + fn extensions(&self) -> Vec>> { + vec![Arc::new(CountingExt { + namespace: "alpha", + prefix: "alpha:ext/", + linked: self.linked.clone(), + })] + } + } + + /// The preset's own extensions and the appended ones both reach the + /// launch's linker build, each linked exactly once, before the boot + /// bails on the empty module set. + #[tokio::test] + async fn preset_extensions_and_appended_extensions_both_link() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let preset_linked = Arc::new(AtomicUsize::new(0)); + let appended_linked = Arc::new(AtomicUsize::new(0)); + let appended: Arc> = Arc::new(CountingExt { + namespace: "beta", + prefix: "beta:ext/", + linked: appended_linked.clone(), + }); + + let err = match RuntimeBuilder::new(&config) + .with_runtime(ExtPreset { + linked: preset_linked.clone(), + }) + .with_extensions([appended]) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!(preset_linked.load(Ordering::SeqCst), 1, "preset extension"); + assert_eq!( + appended_linked.load(Ordering::SeqCst), + 1, + "appended extension" + ); + } + + /// A value-bound preset handing back an already-built backend. + struct PrebuiltLogsPreset { + logs: LogPipeline, + } + + impl RuntimePreset for PrebuiltLogsPreset { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = Prebuilt; + + fn components( + self, + ) -> ComponentsBuilder> + { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + .with_logs(Prebuilt(self.logs)) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// `components(self)` hands a pre-built instance through the preset seam: + /// the built bundle carries the exact pipeline the preset owned. + #[tokio::test] + async fn preset_hands_over_a_prebuilt_backend() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = EngineConfig::default(); + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let build_ctx = BuilderContext { + config: &config, + data_dir: dir.path(), + executor: &executor, + }; + + let custom = LogPipeline::in_memory(config.limits.logs()); + let components = PrebuiltLogsPreset { + logs: custom.clone(), + } + .components() + .build::(&build_ctx) + .await + .expect("build from the preset's builders"); + + assert!( + Arc::ptr_eq(&components.logs.router(), &custom.router()), + "bundle carries the preset's pre-built pipeline", + ); + } + /// when every configured module fails `init`, launch must /// abort with an operator-facing error instead of idling behind an /// empty event loop. diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 19a948a9..17044af2 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,25 +1,34 @@ -//! Runtime presets: a preset names a lattice, its component builders, and its -//! add-on set as one bundle, so an embedder launches with +//! Runtime presets: a preset names a lattice, its component builders, its +//! extensions, and its add-on set as one bundle, so an embedder launches with //! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. [`CoreRuntime`] is the domain-free default: the reference core -//! backends (a chain provider pool and a local redb store, no extension -//! payload) with the Prometheus add-on. A domain assembly ships its own -//! preset naming its extension builder in the `Ext` slot. +//! each seam. A preset carrying pre-built backends or non-static extensions +//! binds by value through +//! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). +//! [`CoreRuntime`] is the domain-free default: the reference core backends +//! (a chain provider pool and a local redb store, no extension payload) with +//! the Prometheus add-on. A domain assembly ships its own preset naming its +//! extension builder in the `Ext` slot and returning its linker extensions. + +use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; use crate::host::component::{ ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, }; +use crate::host::extension::Extension; use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component -/// builders and add-ons the launcher needs, gathered behind one name. -/// Implemented by zero-sized markers; +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the +/// component builders, extensions, and add-ons the launcher needs, gathered +/// behind one name. /// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// one and launches it. +/// a `Default` marker; +/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) +/// binds a value, so a preset can hand back already-built backends through a +/// pass-through builder such as `Prebuilt`. pub trait Runtime { /// The lattice the preset assembles. type Types: RuntimeTypes; @@ -32,8 +41,11 @@ pub trait Runtime { /// Builds the shared [`LogPipeline`]. type LogsBuilder: ComponentBuilder; - /// The component builders that open the backends at launch. - fn components() -> ComponentsBuilder< + /// The component builders that open the backends at launch. Consumes the + /// preset, so a value-bound preset hands over owned, pre-built backends. + fn components( + self, + ) -> ComponentsBuilder< Self::ChainBuilder, Self::StoreBuilder, Self::ExtBuilder, @@ -41,7 +53,14 @@ pub trait Runtime { >; /// The cross-cutting add-ons installed before the engine boots. - fn add_ons() -> AddOns; + fn add_ons(&self) -> AddOns; + + /// The linker extensions the preset launches with. None by default; + /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) + /// appends on top. + fn extensions(&self) -> Vec>> { + Vec::new() + } } /// The domain-free default preset: the reference core backends (a chain @@ -63,11 +82,11 @@ impl Runtime for CoreRuntime { type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components() -> ComponentsBuilder { + fn components(self) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } - fn add_ons() -> AddOns { + fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } } From 6246244d8350ae07b9cb72cda23b57c598b2dc1b Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 00:15:09 +0000 Subject: [PATCH 044/139] runtime: fold venue adapters into the restart and poison sweeps (#445) A trap in a routed adapter call marks a shared Liveness dead; the dispatch-time sweeps restart the provider after the module backoff policy and quarantine a crash-looper per the poison policy. A dead venue resolves to unavailable, distinct from unknown-venue, and adapter_alive_count reports live routability. The flaky-venue fixture drives the trap-to-recovery and poison paths end to end. --- .github/workflows/ci.yml | 6 +- Cargo.lock | 8 + Cargo.toml | 1 + crates/nexum-runtime/src/host/actor.rs | 61 +++- crates/nexum-runtime/src/host/extension.rs | 4 + .../nexum-runtime/src/host/venue_registry.rs | 129 +++++-- crates/nexum-runtime/src/supervisor.rs | 325 +++++++++++++++--- crates/nexum-runtime/src/supervisor/tests.rs | 167 ++++++++- justfile | 4 +- modules/fixtures/flaky-venue/Cargo.toml | 17 + modules/fixtures/flaky-venue/module.toml | 19 + modules/fixtures/flaky-venue/src/lib.rs | 76 ++++ 12 files changed, 731 insertions(+), 86 deletions(-) create mode 100644 modules/fixtures/flaky-venue/Cargo.toml create mode 100644 modules/fixtures/flaky-venue/module.toml create mode 100644 modules/fixtures/flaky-venue/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 186bce4d..42cc34c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 15 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 16 guest module wasms ONCE (release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -88,8 +88,8 @@ jobs: cargo build --release --target wasm32-wasip2 --locked \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb \ - -p memory-bomb -p panic-bomb -p slow-host + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue \ + -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host { echo "### module .wasm sizes" echo "| module | bytes |" diff --git a/Cargo.lock b/Cargo.lock index 0b867ea8..217777ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,14 @@ dependencies = [ "wit-bindgen 0.59.0", ] +[[package]] +name = "flaky-venue" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", + "wit-bindgen 0.58.0", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/Cargo.toml b/Cargo.toml index d4ee5eb6..418564d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "modules/examples/stop-loss", "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", + "modules/fixtures/flaky-venue", "modules/fixtures/fuel-bomb", "modules/fixtures/memory-bomb", "modules/fixtures/panic-bomb", diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index 10d5c461..f55bf2c7 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -4,7 +4,8 @@ //! caller, and each instance sits behind an [`ActorSlot`] async mutex held //! across the guest await, so one store never runs two guest calls at once. -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; use tokio::sync::Mutex as AsyncMutex; use wasmtime::Store; @@ -16,6 +17,47 @@ use super::state::HostState; /// is not `Sync`; concurrent callers queue here. pub type ActorSlot = Arc>; +/// Shared liveness of one supervised component. The store marks it dead on +/// a trap, recording when, so the supervisor's restart sweep can count the +/// backoff from the death rather than from the sweep that observed it. +/// Cloning shares the flag. Starts alive. +#[derive(Clone, Debug, Default)] +pub struct Liveness(Arc>>); + +impl Liveness { + /// Whether the component is currently callable. + pub fn is_alive(&self) -> bool { + self.lock().is_none() + } + + /// When the component died, while it is dead. + pub fn dead_since(&self) -> Option { + *self.lock() + } + + /// Mark the component dead: its store trapped and is unusable. Keeps + /// the first death instant when already dead. + pub fn mark_dead(&self) { + let mut died_at = self.lock(); + if died_at.is_none() { + *died_at = Some(Instant::now()); + } + } + + /// Mark the component alive again after a restart. + pub fn mark_alive(&self) { + *self.lock() = None; + } + + /// The flag, recovered from a poisoned lock: the state is a bare + /// `Option`, valid under any interleaving. + fn lock(&self) -> MutexGuard<'_, Option> { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + /// A guest call failed outside the component's typed error space. #[derive(Debug, thiserror::Error)] pub enum ActorFault { @@ -30,22 +72,26 @@ pub enum ActorFault { /// A supervised component store: refuelled before each guest call so every /// invocation starts from a full budget, with traps projected onto -/// [`ActorFault`]. +/// [`ActorFault`] and recorded on the shared [`Liveness`]. pub struct SupervisedStore { store: Store>, fuel_per_call: u64, + liveness: Liveness, } impl SupervisedStore { - /// Supervise an instantiated store with a per-call fuel budget. - pub fn new(store: Store>, fuel_per_call: u64) -> Self { + /// Supervise an instantiated store with a per-call fuel budget, + /// reporting traps on `liveness`. + pub fn new(store: Store>, fuel_per_call: u64, liveness: Liveness) -> Self { Self { store, fuel_per_call, + liveness, } } - /// Refuel, then run one guest call against the store. + /// Refuel, then run one guest call against the store. A trap marks the + /// shared liveness dead: the store is poisoned until reinstantiated. pub async fn call( &mut self, call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, @@ -53,6 +99,9 @@ impl SupervisedStore { self.store .set_fuel(self.fuel_per_call) .map_err(ActorFault::Refuel)?; - call(&mut self.store).await.map_err(ActorFault::Trap) + call(&mut self.store).await.map_err(|trap| { + self.liveness.mark_dead(); + ActorFault::Trap(trap) + }) } } diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index a282d4ba..00d82442 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; use wasmtime::Store; use wasmtime::component::{Component, Linker}; +use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::NamespaceCaps; @@ -84,6 +85,9 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub config: Vec<(String, String)>, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, + /// Shared liveness the installed instance reports traps on and the + /// supervisor's restart sweep reads. + pub liveness: Liveness, } /// Outcome of one provider install. diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 46c5485c..301e371e 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -41,7 +41,7 @@ use crate::bindings::{ IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, nexum, }; -use crate::host::actor::{ActorFault, ActorSlot, SupervisedStore}; +use crate::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; use crate::host::component::RuntimeTypes; use crate::host::extension::{ HostService, Installed, ProviderInstance, ProviderKind, downcast_service, @@ -225,10 +225,16 @@ pub struct VenueActor { } impl VenueActor { - /// Wrap an instantiated adapter store for routing. - pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { + /// Wrap an instantiated adapter store for routing, reporting traps on + /// the shared `liveness`. + pub fn new( + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, + liveness: Liveness, + ) -> Self { Self { - actor: SupervisedStore::new(store, fuel_per_call), + actor: SupervisedStore::new(store, fuel_per_call, liveness), bindings, } } @@ -302,6 +308,15 @@ impl VenueInvoker for VenueActor { /// One installed adapter behind its serialising slot. type AdapterSlot = ActorSlot; +/// One installed venue: the adapter slot plus the liveness the supervisor's +/// sweep shares with the actor. A dead entry stays installed, so the venue +/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` +/// (never installed) until the sweep restarts it. +struct InstalledVenue { + slot: AdapterSlot, + liveness: Liveness, +} + /// Per-caller charge history, pruned to the quota window on each touch. #[derive(Default)] struct QuotaLedger { @@ -356,7 +371,7 @@ fn status_body(status: IntentStatus) -> StatusBody { /// install through the shared handle at provider boot, before any client /// call routes. struct VenueRegistryInner { - adapters: Mutex>, + adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, @@ -382,31 +397,44 @@ impl VenueRegistry { /// extension's. pub const NAMESPACE: &'static str = "videre"; - /// Install an adapter under its venue id. Rejects a duplicate id: two + /// Install an adapter under its venue id, sharing `liveness` with its + /// invoker. Rejects a duplicate id while the incumbent is alive: two /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. + /// which is a config error worth failing boot over. A dead incumbent is + /// replaced: that is the sweep restarting a trapped adapter. pub fn install( &self, venue: VenueId, + liveness: Liveness, invoker: impl VenueInvoker + 'static, ) -> Result<(), DuplicateVenue> { let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); - if adapters.contains_key(&venue) { + if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { return Err(DuplicateVenue { venue }); } - adapters.insert(venue, Arc::new(AsyncMutex::new(invoker))); + adapters.insert( + venue, + InstalledVenue { + slot: Arc::new(AsyncMutex::new(invoker)), + liveness, + }, + ); Ok(()) } - /// Resolve a venue id to its installed adapter slot. + /// Resolve a venue id to its installed adapter slot. An uninstalled + /// venue is `unknown-venue`; an installed but dead one is `unavailable` + /// pending the supervisor's restart sweep, without touching its + /// poisoned store. fn resolve(&self, venue: &VenueId) -> Result { - self.inner - .adapters - .lock() - .expect("adapter map poisoned") - .get(venue) - .cloned() - .ok_or(VenueError::UnknownVenue) + let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); + let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; + if !installed.liveness.is_alive() { + return Err(VenueError::Unavailable(format!( + "venue {venue} is dead pending restart" + ))); + } + Ok(Arc::clone(&installed.slot)) } /// Whether `caller` has budget left in the current window. Read-only: it @@ -580,8 +608,8 @@ impl VenueRegistry { } let mut updates = Vec::new(); for (venue, receipt) in snapshot { - // Installed adapters never leave the registry, so a resolve - // failure here is unreachable; skip defensively regardless. + // A dead venue fails to resolve; its watch stays for the + // cadence after the sweep restarts the adapter. let Ok(slot) = self.resolve(&venue) else { continue; }; @@ -724,6 +752,7 @@ impl ProviderKind for VenueAdapterKind { mut store, config, fuel_per_call, + liveness, } = instance; let bindings = VenueAdapter::instantiate_async(&mut store, component, linker) .await @@ -750,7 +779,8 @@ impl ProviderKind for VenueAdapterKind { registry .install( venue_id.clone(), - VenueActor::new(store, bindings, fuel_per_call), + liveness.clone(), + VenueActor::new(store, bindings, fuel_per_call, liveness), ) .with_context(|| format!("install adapter {venue_id}"))?; Ok(Installed::Live) @@ -1062,7 +1092,9 @@ mod tests { builder = builder.with_guard(guard); } let registry = builder.build(); - registry.install(cow(), adapter).expect("install adapter"); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); registry } @@ -1367,14 +1399,61 @@ mod tests { let a = Arc::new(StubCalls::default()); let b = Arc::new(StubCalls::default()); registry - .install(cow(), StubAdapter::new(a)) + .install(cow(), Liveness::default(), StubAdapter::new(a)) .expect("first install"); let err = registry - .install(cow(), StubAdapter::new(b)) + .install(cow(), Liveness::default(), StubAdapter::new(b)) .expect_err("second install collides"); assert_eq!(err.venue, cow()); } + #[tokio::test] + async fn dead_venue_is_unavailable_not_unknown() { + let calls = Arc::new(StubCalls::default()); + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls.clone())) + .expect("install adapter"); + liveness.mark_dead(); + + // Temporarily dead resolves distinctly from never installed, and + // the dead adapter's slot is never entered. + assert!(matches!( + registry.submit("mod-a", &cow(), b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"b".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_dead_incumbent_is_replaced_on_reinstall() { + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + let liveness = Liveness::default(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("first install"); + liveness.mark_dead(); + registry + .install( + cow(), + Liveness::default(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("a restart replaces the dead incumbent"); + assert_eq!(registry.venue_count(), 1); + } + #[test] fn zero_quota_saturates_to_one() { let registry = @@ -1523,7 +1602,9 @@ mod tests { let registry = VenueRegistryBuilder::new(SubmitQuota::default()) .with_watch_limit(watch_limit) .build(); - registry.install(cow(), adapter).expect("install adapter"); + registry + .install(cow(), Liveness::default(), adapter) + .expect("install adapter"); registry } diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 92894f6b..029fa1e3 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,6 +18,12 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! +//! Providers (venue adapters) ride the same sweeps: a trap inside a +//! routed call flips the [`Liveness`] their actor shares with the +//! supervisor, the venue resolves to `unavailable` (not +//! `unknown-venue`) while dead, and the sweep reinstalls the provider +//! after the same backoff and poison policies. +//! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match //! `block.chain_id`. Per-module restart / poison / fuel limits are @@ -43,6 +49,7 @@ use crate::bindings::{Config, EventModule, nexum}; use crate::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, }; +use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, @@ -64,10 +71,12 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Venue adapters loaded at boot, whether or not `init` succeeded. - adapters_total: usize, - /// Adapters whose `init` succeeded and that are installed for routing. - adapters_alive: usize, + /// Providers (venue adapters) loaded at boot, whether or not `init` + /// succeeded. Swept for restart and poison alongside the modules. + providers: Vec, + /// Registered provider kinds paired with their services, kept for the + /// provider restart sweep to reinstall through. + kinds: ProviderKinds, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -252,6 +261,41 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } +/// One loaded provider (venue adapter). Mirrors [`LoadedModule`]'s restart +/// and poison bookkeeping; liveness is shared with the installed actor, +/// which marks it dead on a trap, and read back by the sweep. +struct LoadedProvider { + /// The provider's namespace: its manifest name, and its venue id. + name: String, + /// Registered kind spelling the restart sweep reinstalls through. + kind: &'static str, + /// Cached for restart, like a module's. + component: Component, + /// Cached for restart: the manifest `[config]` handed to `init`. + init_config: Config, + /// Cached for restart: the operator's transport grants. + http_allow: Vec, + messaging_topics: Vec, + /// Cached for restart: the engine `[limits]` applied at boot. + http_limits: OutboundHttpLimits, + fuel_per_call: u64, + memory_limit: usize, + chain_response_max_bytes: usize, + local_store_bytes: u64, + /// Trap flag shared with the installed actor. + liveness: Liveness, + /// Sequence of the run currently installed; restarts increment it. + run_seq: u64, + /// The sweep's view of `liveness`: a `true` here against a dead + /// liveness is an unrecorded trap. Boot init failure leaves it `false` + /// with `next_attempt = None`, permanent like a module's. + alive: bool, + failure_count: u32, + next_attempt: Option, + failure_timestamps: std::collections::VecDeque, + poisoned: bool, +} + /// One registered provider kind paired with the service its installs bind to. type ProviderRow = (Box>, Arc); @@ -330,10 +374,9 @@ impl Supervisor { // module store built below already routes to the installed venues. // Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); - let adapters_total = engine_cfg.adapters.len(); - let mut adapters_alive = 0; + let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { - let installed = Self::load_provider( + let loaded = Self::load_provider( engine, entry, components, @@ -344,9 +387,7 @@ impl Supervisor { ) .await .with_context(|| format!("load provider {}", entry.path.display()))?; - if installed == Installed::Live { - adapters_alive += 1; - } + providers.push(loaded); } let mut modules = Vec::with_capacity(engine_cfg.modules.len()); @@ -366,17 +407,18 @@ impl Supervisor { modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); + let adapters_alive = providers.iter().filter(|p| p.alive).count(); info!( loaded = modules.len(), alive, - adapters = adapters_total, + adapters = providers.len(), adapters_alive, "supervisor up" ); Ok(Self { modules, - adapters_total, - adapters_alive, + providers, + kinds, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -425,8 +467,8 @@ impl Supervisor { .await?; Ok(Self { modules: vec![loaded], - adapters_total: 0, - adapters_alive: 0, + providers: Vec::new(), + kinds: ProviderKinds::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -691,8 +733,8 @@ impl Supervisor { /// declared kind against the registered provider kinds, enforce the /// scoped-transport capability set, build a supervised store carrying /// the operator's HTTP and messaging grants, and hand the instance to - /// its kind to instantiate and install. [`Installed::Dead`] marks a - /// failed guest `init`: loaded and counted, but not routable. + /// its kind to instantiate and install. A failed guest `init` loads the + /// provider dead and unroutable, permanently like a module's. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] @@ -704,7 +746,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, kinds: &ProviderKinds, - ) -> Result { + ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { @@ -801,22 +843,48 @@ impl Supervisor { )?; let config: Config = if loaded_manifest.config.is_empty() { - vec![("name".into(), namespace)] + vec![("name".into(), namespace.clone())] } else { loaded_manifest.config.clone() }; - kind.install( - ProviderInstance { - component: &component, - linker: &linker, - store, - config, - fuel_per_call: limits_cfg.fuel(), - }, - service, - ) - .await - .with_context(|| format!("install {}", entry.path.display())) + let liveness = Liveness::default(); + let installed = kind + .install( + ProviderInstance { + component: &component, + linker: &linker, + store, + config: config.clone(), + fuel_per_call: limits_cfg.fuel(), + liveness: liveness.clone(), + }, + service, + ) + .await + .with_context(|| format!("install {}", entry.path.display()))?; + if installed == Installed::Dead { + liveness.mark_dead(); + } + Ok(LoadedProvider { + name: namespace, + kind: kind.kind(), + component, + init_config: config, + http_allow: entry.http_allow.clone(), + messaging_topics: entry.messaging_topics.clone(), + http_limits: limits_cfg.http(), + fuel_per_call: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), + local_store_bytes: limits_cfg.state_bytes(), + liveness, + run_seq: 0, + alive: installed == Installed::Live, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, + }) } /// Number of modules currently loaded. @@ -826,14 +894,17 @@ impl Supervisor { /// Number of venue adapters loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { - self.adapters_total + self.providers.len() } - /// Number of adapters whose `init` succeeded and that are installed in the - /// venue registry for routing. + /// Number of adapters currently alive and routable. Live: a trap drops + /// it, the restart sweep raises it again. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { - self.adapters_alive + self.providers + .iter() + .filter(|p| p.liveness.is_alive()) + .count() } /// Chains any **alive** module asked for block events on. Dead modules @@ -1024,6 +1095,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let mut dispatched = 0; let candidate_indices: Vec = (0..self.modules.len()) @@ -1087,6 +1159,7 @@ impl Supervisor { cursor_key: Option<&str>, ) -> bool { let now = std::time::Instant::now(); + self.sweep_providers().await; let Some(idx) = self.modules.iter().position(|m| m.name == module_name) else { warn!(module = %module_name, "no such module - dropping chain-log"); return false; @@ -1176,6 +1249,7 @@ impl Supervisor { for idx in restart_candidates { self.try_restart(idx).await; } + self.sweep_providers().await; let candidate_indices: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1418,6 +1492,133 @@ impl Supervisor { } } + /// Fold providers into the recovery path: record any trap the shared + /// liveness reports (backoff plus poison bookkeeping), then reinstall + /// dead, unpoisoned providers whose backoff has elapsed. Runs at the + /// head of every dispatch, beside the module restart sweep. + async fn sweep_providers(&mut self) { + let now = std::time::Instant::now(); + let policy = self.poison_policy; + for idx in 0..self.providers.len() { + let provider = &mut self.providers[idx]; + if provider.alive + && let Some(died_at) = provider.liveness.dead_since() + { + provider.alive = false; + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + // Backoff counts from the death, not from this sweep, so a + // trap whose backoff already elapsed restarts right below. + provider.next_attempt = Some(died_at.checked_add(backoff).unwrap_or(now)); + warn!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + "adapter trapped - marked dead; will restart after backoff", + ); + metrics::counter!( + "shepherd_adapter_errors_total", + "adapter" => provider.name.clone(), + "error_kind" => "trap", + ) + .increment(1); + if let Some(recent) = poison_crossed(&mut provider.failure_timestamps, policy) + && !provider.poisoned + { + provider.poisoned = true; + warn!( + adapter = %provider.name, + recent_failures = recent, + window_secs = policy.window.as_secs(), + "adapter poisoned - quarantined; remove from engine.toml + restart to clear", + ); + metrics::gauge!( + "shepherd_adapter_poisoned", + "adapter" => provider.name.clone(), + ) + .set(1.0); + } + } + let provider = &self.providers[idx]; + if !provider.poisoned + && !provider.alive + && provider.next_attempt.is_some_and(|t| t <= now) + { + self.try_restart_provider(idx).await; + } + } + } + + /// Attempt to reinstall a dead provider in place: fresh store, fresh + /// instance, `init`, and a re-install replacing the dead slot. On + /// success the shared liveness is revived; on failure the backoff + /// slides further out, like a module restart. + async fn try_restart_provider(&mut self, idx: usize) { + let name = self.providers[idx].name.clone(); + let failure_count = self.providers[idx].failure_count; + info!(adapter = %name, failure_count, "adapter restart attempt"); + metrics::counter!( + "shepherd_adapter_restarts_total", + "adapter" => name.clone(), + ) + .increment(1); + let outcome = self.reinstall_provider(idx).await; + let provider = &mut self.providers[idx]; + match outcome { + Ok(Installed::Live) => { + provider.run_seq += 1; + provider.liveness.mark_alive(); + provider.alive = true; + provider.failure_count = 0; + provider.next_attempt = None; + info!(adapter = %name, "adapter restart succeeded"); + } + Ok(Installed::Dead) => { + defer_provider_restart(provider, "init returned fault on restart"); + } + Err(e) => defer_provider_restart(provider, &format!("{e:#}")), + } + } + + /// Rebuild a provider from its cached component and grants, then hand + /// it back to its kind to instantiate and install over the dead slot. + async fn reinstall_provider(&mut self, idx: usize) -> Result { + let provider = &self.providers[idx]; + let (kind, service) = self + .kinds + .get(provider.kind) + .ok_or_else(|| anyhow!("provider kind {} is not registered", provider.kind))?; + let linker = build_provider_linker::(&self.engine, kind.as_ref())?; + // A restart is a new run, like a module's. + let run = RunId::new(provider.name.clone(), provider.run_seq + 1); + let store = Self::build_store( + &self.engine, + &self.components, + run, + provider.http_allow.clone(), + provider.http_limits, + provider.messaging_topics.clone(), + provider.memory_limit, + provider.fuel_per_call, + provider.chain_response_max_bytes, + provider.local_store_bytes, + self.clocks.as_ref(), + HostServices::default(), + )?; + kind.install( + ProviderInstance { + component: &provider.component, + linker: &linker, + store, + config: provider.init_config.clone(), + fuel_per_call: provider.fuel_per_call, + liveness: provider.liveness.clone(), + }, + service, + ) + .await + } + /// Count of modules currently alive. A module is not alive when its /// `init` returned `Err` (permanent, never retried) or when `on_event` /// trapped and its restart backoff has not yet elapsed. @@ -1591,28 +1792,38 @@ enum DispatchOutcome { RateLimited, } -/// Push the current trap timestamp into the module's -/// failure-window ring, drop entries older than the policy window, -/// and flip `poisoned = true` once the window holds more than -/// `policy.max_failures` traps. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. -fn record_failure_and_maybe_poison( - module: &mut LoadedModule, +/// Push the current trap timestamp into a component's failure-window +/// ring, drop entries older than the policy window, and report the +/// recent-failure count once it crosses `policy.max_failures`. Shared by +/// the module and provider poison sweeps. +fn poison_crossed( + failure_timestamps: &mut std::collections::VecDeque, policy: crate::runtime::poison_policy::PoisonPolicy, - last_error: &str, -) { +) -> Option { let now = std::time::Instant::now(); - // Prune entries outside the window. - while let Some(&front) = module.failure_timestamps.front() { + while let Some(&front) = failure_timestamps.front() { if now.duration_since(front) > policy.window { - module.failure_timestamps.pop_front(); + failure_timestamps.pop_front(); } else { break; } } - module.failure_timestamps.push_back(now); - let recent = module.failure_timestamps.len() as u32; - if crate::runtime::poison_policy::should_poison(policy, recent) && !module.poisoned { + failure_timestamps.push_back(now); + let recent = failure_timestamps.len() as u32; + crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) +} + +/// Flip `poisoned = true` once the module's failure window crosses the +/// policy threshold. The first transition emits the +/// `shepherd_module_poisoned` gauge + a structured WARN. +fn record_failure_and_maybe_poison( + module: &mut LoadedModule, + policy: crate::runtime::poison_policy::PoisonPolicy, + last_error: &str, +) { + if let Some(recent) = poison_crossed(&mut module.failure_timestamps, policy) + && !module.poisoned + { module.poisoned = true; warn!( module = %module.name, @@ -1629,6 +1840,20 @@ fn record_failure_and_maybe_poison( } } +/// Slide a failed provider restart's next attempt further out. +fn defer_provider_restart(provider: &mut LoadedProvider, error: &str) { + provider.failure_count = provider.failure_count.saturating_add(1); + let backoff = crate::runtime::restart_policy::backoff_for(provider.failure_count); + provider.next_attempt = Some(std::time::Instant::now() + backoff); + error!( + adapter = %provider.name, + failure_count = provider.failure_count, + backoff_ms = backoff.as_millis() as u64, + error, + "adapter restart failed - will retry after backoff", + ); +} + /// Persisted per-chain progress key; must stay numeric for data compat. fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 3830e6f3..0bf06086 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -651,7 +651,11 @@ fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::V ) .build(); registry - .install(crate::host::venue_registry::VenueId::from("cow"), adapter) + .install( + crate::host::venue_registry::VenueId::from("cow"), + crate::host::actor::Liveness::default(), + adapter, + ) .expect("install"); registry } @@ -2959,3 +2963,164 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { "the kind gate passed rather than rejecting: {msg}", ); } + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: crate::engine_config::ModuleLimits, +) -> ( + Supervisor, + crate::test_utils::MockChainProvider, +) { + use crate::engine_config::AdapterEntry; + use crate::host::component::ChainMethod; + + let chain = crate::test_utils::MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = crate::test_utils::mock_components_from( + chain.clone(), + crate::test_utils::MockStateStore::new(), + ); + let engine = make_wasmtime_engine(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(fixture_module_toml( + "modules/fixtures/flaky-venue/module.toml", + )), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// A test block that drives the dispatch-time sweeps. +fn sweep_block() -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id: 1, + number: 1, + hash: vec![0; 32], + timestamp: 1_700_000_000_000, + } +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + use crate::bindings::{SubmitOutcome, VenueError}; + use crate::host::component::ChainMethod; + use crate::host::venue_registry::VenueId; + + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = + boot_flaky_venue(wasm, crate::engine_config::ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = supervisor.venue_registry().expect("registry service"); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + use crate::bindings::VenueError; + use crate::engine_config::PoisonLimitsSection; + use crate::host::venue_registry::VenueId; + + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = supervisor.venue_registry().expect("registry service"); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; + supervisor.dispatch_block(sweep_block()).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} diff --git a/justfile b/justfile index 964bba79..b1311631 100644 --- a/justfile +++ b/justfile @@ -97,6 +97,6 @@ ci: cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ - -p echo-client -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb \ - -p panic-bomb -p slow-host + -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ + -p memory-bomb -p panic-bomb -p slow-host cargo test --workspace --all-features --no-fail-fast diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/modules/fixtures/flaky-venue/Cargo.toml new file mode 100644 index 00000000..5237feb0 --- /dev/null +++ b/modules/fixtures/flaky-venue/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flaky-venue" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Evil-by-design venue adapter fixture: submit panics while the chain head reads as the poison sentinel and accepts once it stops. Drives the supervisor's provider trap-to-recovery and poison sweeps." + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +nexum-venue-sdk = { path = "../../../crates/nexum-venue-sdk" } +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/flaky-venue/module.toml b/modules/fixtures/flaky-venue/module.toml new file mode 100644 index 00000000..c7667b9d --- /dev/null +++ b/modules/fixtures/flaky-venue/module.toml @@ -0,0 +1,19 @@ +# flaky-venue adapter manifest - the trap-to-recovery test fixture. +# Declares the chain capability its poison-sentinel read requires. + +[module] +name = "flaky-venue" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["chain"] +optional = [] + +[capabilities.http] +allow = [] + +[config] +name = "flaky-venue" diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs new file mode 100644 index 00000000..af1c9d9b --- /dev/null +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -0,0 +1,76 @@ +//! # flaky-venue (test fixture) +//! +//! A venue adapter whose `submit` panics (traps the store) while the chain +//! head reads as the poison sentinel `0xdead`, and accepts once the head +//! moves on. The controlling test programs the mock chain backend, so the +//! trap is deterministic and the recovery is host-driven: the supervisor's +//! sweep must reinstantiate the adapter before a submit succeeds again. +//! +//! Not a production adapter. Lives under `modules/fixtures/` so it is +//! obviously test-only. + +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use nexum::host::chain; +use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, +}; +use videre::value_flow::types::{Asset, AssetAmount}; + +/// The chain-head response that detonates `submit`. +const POISON_HEAD: &str = "0xdead"; + +struct FlakyVenue; + +#[nexum_venue_sdk::venue] +impl FlakyVenue { + fn init(_config: Config) -> Result<(), Fault> { + Ok(()) + } + + fn derive_header(_body: Vec) -> Result { + Ok(IntentHeader { + gives: zero_native(), + wants: zero_native(), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip1271, + }) + } + + fn quote(_body: Vec) -> Result { + Ok(Quotation { + gives: zero_native(), + wants: zero_native(), + fee: zero_native(), + valid_until_ms: u64::MAX, + }) + } + + fn submit(body: Vec) -> Result { + let head = chain::request(1, "eth_blockNumber", "[]") + .map_err(|_| VenueError::Unavailable("chain read failed".into()))?; + // The sentinel detonates the fixture: a guest panic traps the + // store, which is what the sweep under test must recover from. + assert!(!head.contains(POISON_HEAD), "flaky-venue poison head"); + Ok(SubmitOutcome::Accepted(body)) + } + + fn status(_receipt: Vec) -> Result { + Ok(IntentStatus::Open) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + Ok(()) + } +} + +/// A zero native amount. +fn zero_native() -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: Vec::new(), + } +} From 1272191ace79743f2d46ce8ec2709dcbf5f1b4f1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 00:19:08 +0000 Subject: [PATCH 045/139] runtime: ride out venue outages against a grace watch deadline (#514) A status watch refreshes its give-up deadline only when the venue is reachable: a status poll returned Ok, even if the body then failed to encode. A resolve failure or an errored poll now rides out against a grace window rather than burning the deadline, so a venue outage no longer silently evicts a pending intent's watch mid-recovery. grace = min(2 * expiry, 24h), overridable via [limits.watch] grace_secs. Closes #511 Closes #438 --- crates/nexum-runtime/src/engine_config.rs | 51 ++-- .../nexum-runtime/src/host/venue_registry.rs | 223 ++++++++++++++---- 2 files changed, 217 insertions(+), 57 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 7c75f301..6d79c95c 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -510,18 +510,25 @@ impl ModuleLimits { /// Resolved status-watch bounds (overrides or defaults). A zero /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, /// so a misconfigured bound still watches one receipt briefly rather - /// than nothing at all. + /// than nothing at all. `grace_secs` overrides the give-up deadline; + /// omitted, it derives from `expiry` via [`WatchLimit::new`]. pub fn watch(&self) -> WatchLimit { - WatchLimit::new( - self.watch - .max_entries - .map(|n| n.max(1)) - .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES), - self.watch - .expiry_secs - .map(|s| Duration::from_secs(s.max(1))) - .unwrap_or(DEFAULT_WATCH_EXPIRY), - ) + let max_entries = self + .watch + .max_entries + .map(|n| n.max(1)) + .unwrap_or(DEFAULT_WATCH_MAX_ENTRIES); + let expiry = self + .watch + .expiry_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_WATCH_EXPIRY); + match self.watch.grace_secs { + Some(secs) => { + WatchLimit::with_grace(max_entries, expiry, Duration::from_secs(secs.max(1))) + } + None => WatchLimit::new(max_entries, expiry), + } } } @@ -644,15 +651,18 @@ pub struct StatusPollSection { /// and degenerate zeroes saturate up to a usable minimum. /// /// The registry watches each accepted receipt until a terminal status: -/// the cap bounds the per-cadence poll fan-out, and the expiry evicts a -/// watch whose venue never reports one. At the cap a new watch is +/// the cap bounds the per-cadence poll fan-out, and the give-up deadline +/// evicts a watch whose venue stays unreachable. At the cap a new watch is /// refused and logged; live watches are never dropped. #[derive(Debug, Default, Deserialize)] pub struct WatchLimitsSection { /// Maximum receipts under status watch at once. pub max_entries: Option, - /// Seconds one watch stays live before it is evicted unreported. + /// Base window seconds a healthy venue refreshes the deadline within. pub expiry_secs: Option, + /// Give-up deadline seconds: how long a watch rides out an unreachable + /// venue before eviction. Omitted, it derives from `expiry_secs`. + pub grace_secs: Option, } /// `[limits.dispatch]` per-module dispatch rate-limit knobs. Both @@ -1169,6 +1179,19 @@ expiry_secs = 900 let watch = cfg.limits.watch(); assert_eq!(watch.max_entries, 32); assert_eq!(watch.expiry, Duration::from_secs(900)); + // Omitted grace_secs derives from expiry (min(2 * expiry, 24h)). + assert_eq!(watch.grace, Duration::from_secs(1800)); + + // An explicit grace_secs overrides the derivation. + let cfg: EngineConfig = toml::from_str( + r#" +[limits.watch] +expiry_secs = 900 +grace_secs = 120 +"#, + ) + .expect("limits.watch parses"); + assert_eq!(cfg.limits.watch().grace, Duration::from_secs(120)); } #[test] diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/nexum-runtime/src/host/venue_registry.rs index 301e371e..c53113b9 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/nexum-runtime/src/host/venue_registry.rs @@ -54,8 +54,27 @@ pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); /// Default cap on receipts under status watch at once. pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default lifetime of one status watch before it is evicted unreported. +/// Default base window: the poll cadence a healthy venue refreshes within. +/// The give-up deadline is the derived `grace`, not this value directly. pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); +/// Derived grace defaults to this many `expiry` windows, so a watch rides +/// out a venue outage of a couple of poll cadences before giving up. +pub const WATCH_GRACE_MULTIPLIER: u64 = 2; +/// Ceiling on the derived grace window, so a long `expiry` cannot stretch +/// the give-up deadline past a day. +pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); + +/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. +/// An explicit `grace_secs` in config overrides this. +const fn derive_grace(expiry: Duration) -> Duration { + let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); + let capped = if scaled < WATCH_GRACE_MAX.as_secs() { + scaled + } else { + WATCH_GRACE_MAX.as_secs() + }; + Duration::from_secs(capped) +} /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -115,23 +134,35 @@ impl Default for SubmitQuota { } /// Bounds on the status-watch set. The cap bounds the per-cadence poll -/// fan-out; the expiry evicts a watch whose venue has gone silent for a -/// whole window. +/// fan-out; `grace` is the give-up deadline: how long a watch rides out an +/// unreachable venue before it is evicted unreported. `expiry` is the base +/// window `grace` derives from. #[derive(Debug, Clone, Copy)] pub struct WatchLimit { /// Maximum receipts under status watch at once. pub max_entries: usize, - /// How long a watch survives without a successful poll before it is - /// evicted unreported. + /// Base window a healthy venue refreshes the deadline within. pub expiry: Duration, + /// Give-up deadline: how long a watch survives an unreachable venue + /// before it is evicted unreported. A reachable poll (the venue + /// answered) resets it; a resolve failure or an errored poll rides out + /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. + pub grace: Duration, } impl WatchLimit { - /// Pair a cap with the per-entry expiry. + /// Pair a cap with the base expiry; `grace` derives from `expiry`. pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self::with_grace(max_entries, expiry, derive_grace(expiry)) + } + + /// As [`new`](Self::new) but with an explicit `grace` window, the path + /// a configured `grace_secs` takes. + pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { Self { max_entries, expiry, + grace, } } } @@ -326,9 +357,10 @@ struct QuotaLedger { /// One receipt the registry polls for status transitions. `last` starts /// `None` so the first successful poll always reports, giving a /// subscriber the intent's current state without waiting for a change. -/// `expires_at` is the eviction deadline, pushed a full window out on -/// every successful non-terminal poll; `None` (deadline arithmetic -/// overflowed) never expires. +/// `expires_at` is the give-up deadline, pushed a full `grace` window out +/// whenever the venue is reachable (it answered the poll, even if the body +/// then failed to encode); an unreachable venue rides out against it until +/// `grace` elapses. `None` (deadline arithmetic overflowed) never expires. struct WatchedIntent { venue: VenueId, receipt: Vec, @@ -558,7 +590,7 @@ impl VenueRegistry { venue: venue.clone(), receipt, last: None, - expires_at: Instant::now().checked_add(self.inner.watch_limit.expiry), + expires_at: Instant::now().checked_add(self.inner.watch_limit.grace), }); (evicted, true) } else { @@ -589,9 +621,10 @@ impl VenueRegistry { /// 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 failure leaves the entry untouched for - /// the next cadence. Expired entries are evicted unpolled and - /// unreported. + /// dropped from the watch. An unreachable venue (resolve failure or an + /// errored poll) leaves the entry to ride out against `grace` rather + /// than refreshing it; an entry whose `grace` has elapsed is evicted + /// unpolled and unreported. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { @@ -608,8 +641,10 @@ impl VenueRegistry { } let mut updates = Vec::new(); for (venue, receipt) in snapshot { - // A dead venue fails to resolve; its watch stays for the - // cadence after the sweep restarts the adapter. + // Venue unreachable: a dead venue (poisoned/mid-restart) fails + // to resolve. The watch is not refreshed but not dropped: it + // rides out against `grace` while the sweep restarts the + // adapter, and is pruned only if the outage outlasts it. let Ok(slot) = self.resolve(&venue) else { continue; }; @@ -618,11 +653,15 @@ impl VenueRegistry { adapter.status(receipt.clone()).await }; match polled { + // Reachable: `record_polled_status` refreshes the deadline. Ok(status) => { if let Some(update) = self.record_polled_status(&venue, &receipt, status) { updates.push(update); } } + // Reachable adapter, errored poll (e.g. a venue-API outage): + // like a resolve failure, the watch rides out against + // `grace` rather than being refreshed or dropped. Err(err) => { warn!( venue = %venue, @@ -636,12 +675,13 @@ impl VenueRegistry { } /// Fold one polled status into the watch entry: `Some(update)` when it - /// differs from the last reported status, pruning the entry when the - /// status is terminal and refreshing its eviction deadline otherwise, - /// so expiry only fires on a venue that has gone silent. `None` also - /// covers an entry that disappeared while the poll was in flight, and - /// an update whose status body failed to encode (the entry is left - /// untouched for the next cadence). + /// differs from the last reported status. The venue answered, so it is + /// reachable: every path here refreshes the give-up deadline (or drops + /// the entry on a terminal status reported cleanly), so a reachable + /// venue never expires this cadence. An encode failure therefore costs + /// the update, not the watch: the deadline is still refreshed and the + /// entry retried next cadence. `None` also covers an entry that + /// disappeared while the poll was in flight. fn record_polled_status( &self, venue: &VenueId, @@ -652,33 +692,39 @@ impl VenueRegistry { let pos = watched .iter() .position(|w| w.venue == *venue && w.receipt == receipt)?; - let changed = watched[pos].last != Some(status); - let update = if changed { - match status_body(status).encode() { - Ok(body) => Some(IntentStatusUpdate { + let grace = self.inner.watch_limit.grace; + if watched[pos].last == Some(status) { + // No transition, but the venue answered: refresh the deadline. + watched[pos].expires_at = Instant::now().checked_add(grace); + return None; + } + match status_body(status).encode() { + Ok(body) => { + if is_terminal(status) { + watched.remove(pos); + } else { + watched[pos].last = Some(status); + watched[pos].expires_at = Instant::now().checked_add(grace); + } + Some(IntentStatusUpdate { venue: venue.as_str().to_owned(), receipt: receipt.to_vec(), status: body, - }), - Err(err) => { - warn!( - venue = %venue, - error = %err, - "status body failed to encode - retrying on the next cadence", - ); - return None; - } + }) + } + Err(err) => { + // A host-side encode bug, not a silent venue. Refresh the + // deadline (the venue is alive) and retry next cadence + // rather than letting the watch expire. + warn!( + venue = %venue, + error = %err, + "status body failed to encode - retrying on the next cadence", + ); + watched[pos].expires_at = Instant::now().checked_add(grace); + None } - } else { - None - }; - if is_terminal(status) { - watched.remove(pos); - } else { - watched[pos].last = Some(status); - watched[pos].expires_at = Instant::now().checked_add(self.inner.watch_limit.expiry); } - update } /// Report where a previously submitted intent is in its life. Not a @@ -1724,6 +1770,97 @@ mod tests { assert_eq!(watched[0].receipt, b"b"); } + #[test] + fn grace_derives_from_expiry_and_caps_at_a_day() { + // A short base window: grace is the fixed multiple of it. + assert_eq!( + WatchLimit::new(8, Duration::from_secs(900)).grace, + Duration::from_secs(1800), + ); + // A long base window: grace saturates at the ceiling. + assert_eq!( + WatchLimit::new(8, Duration::from_secs(86_400)).grace, + WATCH_GRACE_MAX, + ); + // An explicit grace overrides the derivation. + assert_eq!( + WatchLimit::with_grace(8, Duration::from_secs(900), Duration::from_secs(60)).grace, + Duration::from_secs(60), + ); + } + + /// Read the sole watch entry's give-up deadline. + fn sole_deadline(registry: &VenueRegistry) -> Option { + registry + .inner + .watched + .lock() + .expect("watch list poisoned") + .first() + .and_then(|e| e.expires_at) + } + + #[tokio::test] + async fn a_dead_venue_rides_out_without_refreshing_the_deadline() { + let calls = Arc::new(StubCalls::default()); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()) + .with_watch_limit(WatchLimit::new(8, Duration::from_secs(3600))) + .build(); + let liveness = Liveness::default(); + registry + .install(cow(), liveness.clone(), StubAdapter::new(calls)) + .expect("install adapter"); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let inserted = sole_deadline(®istry); + + // Venue goes dead: resolve fails, so the poll neither reports nor + // refreshes - the watch rides out against grace instead of being + // dropped or having its deadline pushed out. + liveness.mark_dead(); + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "a dead venue rides out rather than being dropped", + ); + assert_eq!( + sole_deadline(®istry), + inserted, + "a resolve failure does not refresh the deadline", + ); + } + + #[tokio::test] + async fn an_errored_poll_rides_out_without_refreshing_the_deadline() { + let calls = Arc::new(StubCalls::default()); + let adapter = StubAdapter::new(calls) + .with_status_script([Err(VenueError::Unavailable("api down".into()))]); + let registry = + watch_bounded_registry(WatchLimit::new(8, Duration::from_secs(3600)), adapter); + registry + .submit("mod-a", &cow(), b"body".to_vec()) + .await + .expect("submit succeeds"); + let inserted = sole_deadline(®istry); + + // A reachable adapter whose poll errors (a venue-API outage) rides + // out exactly like a resolve failure: kept, deadline untouched. + assert!(registry.poll_status_transitions().await.is_empty()); + assert_eq!( + registry.watched_count(), + 1, + "an errored poll keeps the watch", + ); + assert_eq!( + sole_deadline(®istry), + inserted, + "an errored poll does not refresh the deadline", + ); + } + #[test] fn every_lifecycle_state_lowers_onto_the_status_body() { for (wire, lowered) in [ From 934f7c92582744f63e9dc7275d67feb1c2d6a8dc Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 00:37:31 +0000 Subject: [PATCH 046/139] runtime: extract a generic launcher and split the bare and cow binaries (#446) feat: extract a generic launcher and split the bare and cow binaries nexum-launch owns the shared CLI, config load, tracing init, and the preset-driven run; nexum-cli becomes the bare Ext=() engine binary over CoreRuntime with no cow edge; the cow composition root moves to a new shepherd binary wiring the cow-api extension as a Runtime preset. The venue-agnostic guard's crate-graph check now covers the launcher and the bare binary, and the cow deployment tooling builds shepherd. --- Cargo.lock | 23 +++++++- Cargo.toml | 2 + Dockerfile | 12 ++-- README.md | 4 +- crates/nexum-cli/Cargo.toml | 7 +-- crates/nexum-cli/src/cli.rs | 60 -------------------- crates/nexum-cli/src/launch.rs | 58 -------------------- crates/nexum-cli/src/main.rs | 47 ++-------------- crates/nexum-launch/Cargo.toml | 17 ++++++ crates/nexum-launch/src/cli.rs | 85 +++++++++++++++++++++++++++++ crates/nexum-launch/src/lib.rs | 66 ++++++++++++++++++++++ crates/nexum-runtime/src/builder.rs | 2 +- crates/shepherd/Cargo.toml | 21 +++++++ crates/shepherd/src/main.rs | 60 ++++++++++++++++++++ docker-compose.soak.yml | 2 +- justfile | 13 +++-- scripts/check-venue-agnostic.sh | 34 +++++++----- scripts/e2e-run.sh | 6 +- scripts/load-run.sh | 6 +- 19 files changed, 321 insertions(+), 204 deletions(-) delete mode 100644 crates/nexum-cli/src/cli.rs delete mode 100644 crates/nexum-cli/src/launch.rs create mode 100644 crates/nexum-launch/Cargo.toml create mode 100644 crates/nexum-launch/src/cli.rs create mode 100644 crates/nexum-launch/src/lib.rs create mode 100644 crates/shepherd/Cargo.toml create mode 100644 crates/shepherd/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 217777ba..bf3873ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3575,10 +3575,18 @@ name = "nexum-cli" version = "0.2.0" dependencies = [ "anyhow", - "clap", + "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", +] + +[[package]] +name = "nexum-launch" +version = "0.2.0" +dependencies = [ + "anyhow", + "clap", + "nexum-runtime", "tracing", "tracing-subscriber", ] @@ -5158,6 +5166,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shepherd" +version = "0.2.0" +dependencies = [ + "anyhow", + "nexum-launch", + "nexum-runtime", + "shepherd-cow-host", + "tokio", +] + [[package]] name = "shepherd-backtest" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 418564d7..0a562877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/cow-venue", "crates/nexum-cli", + "crates/nexum-launch", "crates/nexum-macros", "crates/nexum-runtime", "crates/nexum-sdk", @@ -11,6 +12,7 @@ members = [ "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", + "crates/shepherd", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", diff --git a/Dockerfile b/Dockerfile index eb7fa3f0..d7465ff5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.6 # -# Multi-stage build for `nexum` (Shepherd) - the engine binary -# plus the five production WASM modules baked into a single image. +# Multi-stage build for `shepherd` - the cow composition-root engine +# binary plus the five production WASM modules baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -65,7 +65,7 @@ FROM chef AS build # changed workspace crate. `--locked` stays on the real builds below, which # validate the committed Cargo.lock verbatim. COPY --from=planner /src/recipe.json recipe.json -RUN cargo chef cook --release -p nexum-cli --recipe-path recipe.json \ +RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss --recipe-path recipe.json @@ -77,7 +77,7 @@ COPY . . # Engine binary in release. --locked ensures the committed Cargo.lock # is used verbatim so builds are reproducible. -RUN cargo build -p nexum-cli --release --locked +RUN cargo build -p shepherd --release --locked # Five production modules. The wasm artefacts land under # `target/wasm32-wasip2/release/.wasm`. @@ -108,7 +108,7 @@ RUN apt-get update \ && install -d -o root -g root -m 0755 /etc/shepherd # Engine binary. -COPY --from=build /src/target/release/nexum /usr/local/bin/nexum +COPY --from=build /src/target/release/shepherd /usr/local/bin/shepherd # Module .wasm artefacts. The Component Model wasm files are loaded # by the engine at boot via the `[[modules]]` entries in engine.toml. @@ -138,5 +138,5 @@ EXPOSE 9100 # `--engine-config /etc/shepherd/engine.toml` matches the production # guide's expected mount point. Operators override via # `docker run ... -v /path/to/engine.toml:/etc/shepherd/engine.toml:ro`. -ENTRYPOINT ["/usr/bin/tini", "--", "nexum"] +ENTRYPOINT ["/usr/bin/tini", "--", "shepherd"] CMD ["--engine-config", "/etc/shepherd/engine.toml"] diff --git a/README.md b/README.md index 91026dd0..8c94e3ac 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | Path | Purpose | | --- | --- | | `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | -| `crates/nexum-cli/` | The `nexum` binary - a thin CLI over the runtime library. | +| `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | +| `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root wiring the cow-api extension. | | `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | | `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | | `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | diff --git a/crates/nexum-cli/Cargo.toml b/crates/nexum-cli/Cargo.toml index cc727db9..05bab22d 100644 --- a/crates/nexum-cli/Cargo.toml +++ b/crates/nexum-cli/Cargo.toml @@ -13,13 +13,8 @@ name = "nexum" path = "src/main.rs" [dependencies] +nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -# Composition root wires the cow-api host extension into the reference -# lattice; the runtime itself carries no cow dependency. -shepherd-cow-host = { path = "../shepherd-cow-host" } anyhow.workspace = true -clap.workspace = true tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true diff --git a/crates/nexum-cli/src/cli.rs b/crates/nexum-cli/src/cli.rs deleted file mode 100644 index 82b7739f..00000000 --- a/crates/nexum-cli/src/cli.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! CLI surface for the `nexum` binary, derived via clap. -//! -//! The 0.2 binary accepts either a positional ` []` -//! shortcut that synthesises a one-module engine config, or a -//! `--engine-config ` flag that points at a TOML declaring -//! multiple modules. Production deployments use the second form; the -//! positional shortcut stays for parity with the M1 reference CLI and -//! for smoke tests. - -use std::path::PathBuf; - -use clap::Parser; - -/// Parsed CLI surface. -/// -/// `nexum [ []] [--engine-config ] [--pretty-logs]` -/// -/// Positional `` is a backwards-compat shortcut that -/// synthesises a one-module engine config. Production deployments pass -/// `--engine-config` and declare modules in TOML. -/// -/// `--pretty-logs` selects the human-readable tracing formatter (the -/// historical 0.1 default). Without the flag the engine emits JSON -/// log lines per the structured-logging contract: a single -/// `jq` / Loki / Grafana stream reconstructs the full timeline of -/// any dispatch, host call, or order submission. -#[derive(Parser, Debug, Default)] -#[command( - name = "nexum", - about = "Run one or more Wasm Component modules under the Shepherd supervisor", - long_about = None, - version, -)] -pub struct Cli { - /// Optional positional path to a Wasm Component file. Synthesises - /// a one-module engine config when no `--engine-config` is given. - pub wasm: Option, - - /// Optional positional path to the module's `module.toml` manifest. - /// Only consulted alongside the positional `wasm` shortcut. - pub manifest: Option, - - /// Optional explicit path to the engine-wide `engine.toml` config. - /// When omitted, the engine resolves the default search path - /// documented in `engine_config::load_or_default`. - #[arg(long = "engine-config")] - pub engine_config: Option, - - /// Use the human-readable tracing formatter instead of the - /// default JSON formatter (structured-logging contract). - #[arg(long = "pretty-logs")] - pub pretty_logs: bool, - - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. - #[arg(long = "log-backfill-concurrency")] - pub log_backfill_concurrency: Option, -} diff --git a/crates/nexum-cli/src/launch.rs b/crates/nexum-cli/src/launch.rs deleted file mode 100644 index dd67890c..00000000 --- a/crates/nexum-cli/src/launch.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Composition root: bind the reference lattice (core backends plus the -//! cow-api extension in the `Ext` slot), build the shared backends and the -//! extension list, then hand off to the generic runtime launch. - -use std::path::Path; - -use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOn}; -use nexum_runtime::builder::RuntimeBuilder; -use nexum_runtime::engine_config::EngineConfig; -use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, RuntimeTypes, -}; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; - -/// The backends the reference engine ships: the core seams plus the -/// cow-api extension payload in the [`Ext`](RuntimeTypes::Ext) slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// Build the reference backends and extension list, then run until shutdown. -pub async fn run_from_config( - engine_cfg: &EngineConfig, - wasm: Option<&Path>, - manifest: Option<&Path>, -) -> anyhow::Result<()> { - // Attach the reference add-on set. The binary ships the Prometheus - // exporter; an embedder omits or replaces it by choosing a different - // list here. - let add_ons: [&dyn RuntimeAddOn; 1] = [&PrometheusAddOn]; - - // Assemble and launch over the type-state builder: bind the reference - // lattice, wire cow-api as an extension (linker hook plus capability - // namespace; the core runtime knows nothing of cow, it plugs in here), - // name the component builders, then drive to a running handle and block - // until the event loop returns on shutdown. - RuntimeBuilder::new(engine_cfg) - .with_types::() - .with_extensions([extension::()]) - .with_module_source(wasm.map(Path::to_path_buf), manifest.map(Path::to_path_buf)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - ReferenceExtBuilder, - )) - .with_add_ons(&add_ons) - .launch() - .await? - .wait() - .await -} diff --git a/crates/nexum-cli/src/main.rs b/crates/nexum-cli/src/main.rs index f2ce5f46..c7be67c3 100644 --- a/crates/nexum-cli/src/main.rs +++ b/crates/nexum-cli/src/main.rs @@ -1,48 +1,11 @@ -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -mod cli; -mod launch; +//! The bare `nexum` engine binary: the core lattice with no extension +//! payload, composed over the generic launcher. -use clap::Parser; -use tracing::info; -use tracing_subscriber::EnvFilter; +#![cfg_attr(not(test), warn(unused_crate_dependencies))] -use crate::cli::Cli; -use nexum_runtime::engine_config; +use nexum_runtime::preset::CoreRuntime; #[tokio::main] async fn main() -> anyhow::Result<()> { - let cli = Cli::parse(); - - let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; - if let Some(n) = cli.log_backfill_concurrency { - engine_cfg.engine.log_backfill_concurrency = n; - } - - let env_filter = EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) - .unwrap_or_else(|_| EnvFilter::new("info")); - // Structured logging: JSON by default (machine-readable - // for production; one `jq` query reconstructs any dispatch - // timeline); `--pretty-logs` opts back into the 0.1 human-readable - // formatter for local dev. The same `EnvFilter` applies to both - // so `RUST_LOG=debug` works identically. - if cli.pretty_logs { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .init(); - } else { - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .with_target(true) - .json() - .flatten_event(true) - .with_current_span(false) - .init(); - } - - info!("nexum starting"); - - launch::run_from_config(&engine_cfg, cli.wasm.as_deref(), cli.manifest.as_deref()).await + nexum_launch::run("nexum", CoreRuntime).await } diff --git a/crates/nexum-launch/Cargo.toml b/crates/nexum-launch/Cargo.toml new file mode 100644 index 00000000..aff18adc --- /dev/null +++ b/crates/nexum-launch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nexum-launch" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +nexum-runtime = { path = "../nexum-runtime" } + +anyhow.workspace = true +clap.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs new file mode 100644 index 00000000..b9af9baa --- /dev/null +++ b/crates/nexum-launch/src/cli.rs @@ -0,0 +1,85 @@ +//! Shared CLI surface for engine binaries, derived via clap. + +use std::path::PathBuf; + +use clap::{CommandFactory, FromArgMatches, Parser}; + +/// Parsed CLI surface. +/// +/// ` [ []] [--engine-config ] [--pretty-logs]` +/// +/// Positional `` synthesises a one-module engine config. +/// Production deployments pass `--engine-config` and declare modules in +/// TOML. +/// +/// `--pretty-logs` selects the human-readable tracing formatter; without +/// it the engine emits JSON log lines per the structured-logging contract. +#[derive(Parser, Debug, Default)] +#[command( + about = "Run one or more Wasm Component modules under the engine supervisor", + long_about = None, + version, +)] +pub struct Cli { + /// Optional positional path to a Wasm Component file. Synthesises + /// a one-module engine config when no `--engine-config` is given. + pub wasm: Option, + + /// Optional positional path to the module's `module.toml` manifest. + /// Only consulted alongside the positional `wasm` shortcut. + pub manifest: Option, + + /// Optional explicit path to the engine-wide `engine.toml` config. + /// When omitted, the engine resolves the default search path + /// documented in `engine_config::load_or_default`. + #[arg(long = "engine-config")] + pub engine_config: Option, + + /// Use the human-readable tracing formatter instead of the + /// default JSON formatter (structured-logging contract). + #[arg(long = "pretty-logs")] + pub pretty_logs: bool, + + /// Override the chain-log poller's per-block `eth_getLogs` + /// concurrency during backfill. Higher catches up faster at more + /// node load. Overrides `[engine] log_backfill_concurrency` when + /// set. + #[arg(long = "log-backfill-concurrency")] + pub log_backfill_concurrency: Option, +} + +impl Cli { + /// Parse the process arguments under the binary's `name`, exiting on + /// `--help`/`--version` or a usage error. + #[must_use] + pub fn parse_as(name: &'static str) -> Self { + let matches = Self::command().name(name).get_matches(); + Self::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The flags land on the parsed surface under a caller-supplied name. + #[test] + fn flags_parse_under_a_supplied_name() { + let matches = Cli::command() + .name("nexum") + .try_get_matches_from([ + "nexum", + "--engine-config", + "engine.toml", + "--pretty-logs", + "--log-backfill-concurrency", + "8", + ]) + .expect("valid arguments parse"); + let cli = Cli::from_arg_matches(&matches).expect("matches destructure"); + assert_eq!(cli.engine_config, Some(PathBuf::from("engine.toml"))); + assert!(cli.pretty_logs); + assert_eq!(cli.log_backfill_concurrency, Some(8)); + assert!(cli.wasm.is_none()); + } +} diff --git a/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs new file mode 100644 index 00000000..6994ca29 --- /dev/null +++ b/crates/nexum-launch/src/lib.rs @@ -0,0 +1,66 @@ +//! Generic engine launcher: parse the shared CLI, load the engine config, +//! initialise tracing, and drive a [`Runtime`] preset until shutdown. +//! +//! A binary is one line: `nexum_launch::run("nexum", CoreRuntime)`. The +//! preset supplies the lattice, backends, extension list, and add-ons; +//! this crate knows nothing beyond the runtime seam. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +mod cli; + +pub use cli::Cli; + +use nexum_runtime::builder::RuntimeBuilder; +use nexum_runtime::engine_config::{self, EngineConfig}; +use nexum_runtime::preset::Runtime; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Parse the process arguments as `name`, then [`launch`] the preset. +pub async fn run(name: &'static str, preset: R) -> anyhow::Result<()> { + launch(name, preset, Cli::parse_as(name)).await +} + +/// Load the config, initialise tracing, and run the preset until shutdown. +pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Result<()> { + let mut engine_cfg = engine_config::load_or_default(cli.engine_config.as_deref())?; + if let Some(n) = cli.log_backfill_concurrency { + engine_cfg.engine.log_backfill_concurrency = n; + } + + init_tracing(cli.pretty_logs, &engine_cfg); + + info!("{name} starting"); + + RuntimeBuilder::new(&engine_cfg) + .with_runtime(preset) + .with_module_source(cli.wasm, cli.manifest) + .launch() + .await? + .wait() + .await +} + +/// Install the global tracing subscriber: JSON by default (machine-readable +/// for production), the human-readable formatter behind `--pretty-logs`. +/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. +fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { + let env_filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) + .unwrap_or_else(|_| EnvFilter::new("info")); + if pretty { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .init(); + } else { + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(true) + .json() + .flatten_event(true) + .with_current_span(false) + .init(); + } +} diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 49eb53aa..7c836f07 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -9,7 +9,7 @@ //! loop - and returns a [`RuntimeHandle`] owning the manager and the //! running tasks. //! -//! The reference binary reaches this through its `run_from_config` one-liner; +//! The engine binaries reach this through the `nexum-launch` preset run; //! an embedder holding pre-built backends constructs an [`AssembledRuntime`] //! and calls [`LaunchRuntime::launch`] directly. For the common case, //! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml new file mode 100644 index 00000000..63e99085 --- /dev/null +++ b/crates/shepherd/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "shepherd" +version = "0.2.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "shepherd" +path = "src/main.rs" + +[dependencies] +nexum-launch = { path = "../nexum-launch" } +nexum-runtime = { path = "../nexum-runtime" } +shepherd-cow-host = { path = "../shepherd-cow-host" } + +anyhow.workspace = true +tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs new file mode 100644 index 00000000..4022ff57 --- /dev/null +++ b/crates/shepherd/src/main.rs @@ -0,0 +1,60 @@ +//! The `shepherd` binary: the cow composition root. Binds the reference +//! lattice with the cow-api extension payload in the `Ext` slot and hands +//! it to the generic launcher; the engine itself stays cow-free. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +use std::sync::Arc; + +use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::host::component::{ + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, +}; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::host::local_store_redb::LocalStore; +use nexum_runtime::host::provider_pool::ProviderPool; +use nexum_runtime::preset::Runtime; +use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; + +/// The reference lattice: the core backends with the cow-api payload in +/// the `Ext` slot. +#[derive(Debug, Clone, Copy, Default)] +struct ReferenceTypes; + +impl RuntimeTypes for ReferenceTypes { + type Chain = ProviderPool; + type Store = LocalStore; + type Ext = ReferenceExt; +} + +/// The cow preset: reference backends, the cow-api extension, and the +/// Prometheus add-on. +#[derive(Debug, Clone, Copy, Default)] +struct ShepherdRuntime; + +impl Runtime for ShepherdRuntime { + type Types = ReferenceTypes; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = ReferenceExtBuilder; + type LogsBuilder = LogPipelineBuilder; + + fn components( + self, + ) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ReferenceExtBuilder) + } + + fn add_ons(&self) -> AddOns { + vec![Box::new(PrometheusAddOn)] + } + + fn extensions(&self) -> Vec>> { + vec![extension::()] + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + nexum_launch::run("shepherd", ShepherdRuntime).await +} diff --git a/docker-compose.soak.yml b/docker-compose.soak.yml index 0921f186..b48a9027 100644 --- a/docker-compose.soak.yml +++ b/docker-compose.soak.yml @@ -1,7 +1,7 @@ # docker-compose.soak.yml — 7-day grant stability soak. # # Two services: -# engine — the nexum binary; restarts automatically on crash. +# engine — the shepherd binary; restarts automatically on crash. # snapshotter — Alpine loop that scrapes /metrics every hour and # writes evidence files to docs/operations/soak-reports/. # diff --git a/justfile b/justfile index b1311631..de542480 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,7 @@ -# Build the host engine +# Build the engine binaries: the bare `nexum` engine and the cow +# composition root `shepherd`. build-engine: - cargo build -p nexum-cli + cargo build -p nexum-cli -p shepherd # Build the example WASM module build-module: @@ -38,7 +39,7 @@ build-m2: # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m2: build-m2 build-engine - cargo run -p nexum-cli -- --engine-config engine.m2.toml --pretty-logs + cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) # for wasm32-wasip2. @@ -52,7 +53,7 @@ build-m3: # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m3: build-m3 build-engine - cargo run -p nexum-cli -- --engine-config engine.m3.toml --pretty-logs + cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist # denial demo) for wasm32-wasip2. @@ -69,7 +70,7 @@ build-e2e: build-m2 build-m3 # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. run-e2e: build-e2e build-engine - cargo run -p nexum-cli -- --engine-config engine.e2e.toml + cargo run -p shepherd -- --engine-config engine.e2e.toml # Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and # the nexum:host WIT leaf. Advisory in CI until the physical cut lands. @@ -80,7 +81,7 @@ check-venue-agnostic: check: cargo check --target wasm32-wasip2 -p example cargo check -p nexum-runtime - cargo check -p nexum-cli + cargo check -p nexum-cli -p shepherd # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index e3feef76..26d60d3a 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Venue-agnosticism check for nexum-runtime: the crate graph reaches no -# videre/intent/venue/cow crate, the sources carry no venue symbol, and -# nexum:host resolves as a leaf WIT package. Advisory in CI until the -# physical cut lands; run locally via `just check-venue-agnostic`. +# Venue-agnosticism check for the host layer: no host-layer crate graph +# (runtime, launcher, bare engine) reaches a videre/intent/venue/cow +# crate, the runtime sources carry no venue symbol, and nexum:host +# resolves as a leaf WIT package. Advisory in CI until the physical cut +# lands; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -16,19 +17,22 @@ command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } status=0 -# 1. Crate graph: nothing venue-shaped reachable from nexum-runtime -# (normal + build edges; dev-deps stay local to the crate). -if tree="$(cargo tree -p nexum-runtime -e normal,build --all-features --prefix none --locked)"; then - reached="$(printf '%s\n' "$tree" | - awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" - if [[ -n $reached ]]; then - fail "crate graph reaches: $(tr '\n' ' ' <<<"$reached")" +# 1. Crate graph: nothing venue-shaped reachable from the host-layer +# crates - the runtime, the generic launcher, and the bare engine +# binary (normal + build edges; dev-deps stay local to the crate). +for crate in nexum-runtime nexum-launch nexum-cli; do + if tree="$(cargo tree -p "$crate" -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "$crate crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "$crate crate graph clean" + fi else - pass "crate graph clean" + fail "cargo tree failed for $crate" fi -else - fail "cargo tree failed" -fi +done # 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes # skip std::borrow::Cow, ProviderError, and "intentional". diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index ce24b343..8ec2f4e1 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -7,7 +7,7 @@ # gitignored. # 3. Cleans data/e2e for a fresh local-store. # 4. Builds all 5 modules + the engine. -# 5. Launches nexum via nohup, redirecting stdout/stderr to +# 5. Launches shepherd via nohup, redirecting stdout/stderr to # docs/operations/e2e-reports/engine-.log. JSON logs # (no --pretty-logs) so e2e-report-gen.sh can mine them with jq. # 6. Waits up to 60 s for the `supervisor ready modules=5 chains=1` @@ -52,7 +52,7 @@ log "building 5 modules + engine (this can take a minute on first run)" cargo build -p price-alert --target wasm32-wasip2 --release >/dev/null cargo build -p balance-tracker --target wasm32-wasip2 --release >/dev/null cargo build -p stop-loss --target wasm32-wasip2 --release >/dev/null - cargo build -p nexum-cli --release >/dev/null + cargo build -p shepherd --release >/dev/null ) ts="$(date -u +%Y%m%dT%H%M%SZ)" @@ -63,7 +63,7 @@ start_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)" log "launching engine — log: $log_file" ( cd "$REPO_ROOT" - nohup "$REPO_ROOT/target/release/nexum" \ + nohup "$REPO_ROOT/target/release/shepherd" \ --engine-config "$REPO_ROOT/engine.e2e.local.toml" \ >"$log_file" 2>&1 & echo $! > "$STATE_FILE.pid.tmp" diff --git a/scripts/load-run.sh b/scripts/load-run.sh index 91cef9b0..af636aef 100755 --- a/scripts/load-run.sh +++ b/scripts/load-run.sh @@ -80,10 +80,10 @@ log "building modules + engine + load-gen (release)" ( cd "$REPO_ROOT" && cargo build --release --quiet \ --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher ) -( cd "$REPO_ROOT" && cargo build --release --quiet -p nexum-cli -p load-gen ) +( cd "$REPO_ROOT" && cargo build --release --quiet -p shepherd -p load-gen ) -log "starting nexum (engine.load.toml)" -( cd "$REPO_ROOT" && ./target/release/nexum --engine-config engine.load.toml ) \ +log "starting shepherd (engine.load.toml)" +( cd "$REPO_ROOT" && ./target/release/shepherd --engine-config engine.load.toml ) \ >"$LOG_DIR/engine.log" 2>&1 & ENGINE_PID=$! echo "ENGINE_PID=$ENGINE_PID" >>"$PID_FILE" From 7f0ea12260049d91a6a20c378e2a343cbeb155d5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 02:22:08 +0000 Subject: [PATCH 047/139] runtime: build the videre-host crate and register the venue platform (#447) feat: build the videre-host crate and register the venue platform through the seam Relocate the venue-adapter and client bindgens, the venue registry, the advisory egress guard, and the venue-adapter provider kind out of nexum-runtime into a new videre-host extension crate, registered whole via videre_host::platform(). The runtime grows two generic seams in exchange: extension-declared subscription kinds with attribute-filtered extension events through the event loop, and config-derived preset extensions. nexum-runtime now carries zero venue, intent, or cow symbols and the venue-agnostic check passes. --- Cargo.lock | 19 +- Cargo.toml | 1 + crates/nexum-runtime/Cargo.toml | 3 - crates/nexum-runtime/examples/embed.rs | 2 +- crates/nexum-runtime/src/bindings.rs | 247 +---- crates/nexum-runtime/src/bootstrap.rs | 2 +- crates/nexum-runtime/src/builder.rs | 48 +- crates/nexum-runtime/src/engine_config.rs | 168 +++- .../src/host/component/runtime_types.rs | 2 +- crates/nexum-runtime/src/host/error.rs | 2 +- crates/nexum-runtime/src/host/extension.rs | 98 +- crates/nexum-runtime/src/host/http.rs | 69 +- .../nexum-runtime/src/host/impls/messaging.rs | 20 +- crates/nexum-runtime/src/host/impls/mod.rs | 1 - crates/nexum-runtime/src/host/mod.rs | 10 +- .../nexum-runtime/src/host/provider_pool.rs | 2 +- crates/nexum-runtime/src/host/state.rs | 4 +- .../src/manifest/capabilities.rs | 93 +- crates/nexum-runtime/src/manifest/load.rs | 79 +- crates/nexum-runtime/src/manifest/types.rs | 118 ++- crates/nexum-runtime/src/preset.rs | 8 +- .../nexum-runtime/src/runtime/event_loop.rs | 75 +- .../src/runtime/poison_policy.rs | 2 +- crates/nexum-runtime/src/supervisor.rs | 154 +-- crates/nexum-runtime/src/supervisor/tests.rs | 931 +++--------------- crates/shepherd-cow-host/tests/cow_boot.rs | 48 +- crates/shepherd/Cargo.toml | 1 + crates/shepherd/src/main.rs | 17 +- crates/videre-host/Cargo.toml | 30 + crates/videre-host/src/bindings.rs | 231 +++++ .../src/client.rs} | 11 +- crates/videre-host/src/lib.rs | 145 +++ .../src/registry.rs} | 127 +-- crates/videre-host/tests/platform.rs | 714 ++++++++++++++ 34 files changed, 1984 insertions(+), 1498 deletions(-) create mode 100644 crates/videre-host/Cargo.toml create mode 100644 crates/videre-host/src/bindings.rs rename crates/{nexum-runtime/src/host/impls/venue_client.rs => videre-host/src/client.rs} (84%) create mode 100644 crates/videre-host/src/lib.rs rename crates/{nexum-runtime/src/host/venue_registry.rs => videre-host/src/registry.rs} (93%) create mode 100644 crates/videre-host/tests/platform.rs diff --git a/Cargo.lock b/Cargo.lock index bf3873ae..26dbfa62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3622,7 +3622,6 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "nexum-runtime", - "nexum-status-body", "nexum-tasks", "redb", "serde", @@ -5175,6 +5174,7 @@ dependencies = [ "nexum-runtime", "shepherd-cow-host", "tokio", + "videre-host", ] [[package]] @@ -6040,6 +6040,23 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "videre-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "nexum-runtime", + "nexum-status-body", + "nexum-tasks", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtime", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a562877..0d85dead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/shepherd-cow-host", "crates/shepherd-sdk", "crates/shepherd-sdk-test", + "crates/videre-host", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 7b66aa90..63c4be27 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,9 +36,6 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } -# Encoder for the opaque status body the host `event` stream carries; -# the router lowers an adapter-reported status through it. -nexum-status-body = { path = "../nexum-status-body" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index 7d92343f..feba0440 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -3,7 +3,7 @@ //! //! [`CoreRuntime`] is the domain-free preset: it bundles the reference core //! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability such as cow-api is added by +//! the Prometheus add-on. A domain capability is added by //! writing a preset that names its extension builder in the `Ext` slot and //! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 022fcdf1..46fb2424 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -3,19 +3,18 @@ //! The core host binds the `nexum:host/event-module` world: the six core //! primitives. Outbound HTTP is not a `nexum:host` interface: it is //! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions such as cow-api bind their own world and wire themselves in -//! at the composition root; they are not part of this core surface. +//! extensions bind their own world and wire themselves in at the +//! composition root; they are not part of this core surface. //! //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! `nexum:host` is a leaf package: the host `event` variant carries an -//! intent-status transition as opaque bytes, so the core world resolves -//! against `wit/nexum-host` alone. The videre vocabulary generates in the -//! adapter bindgen below, and the client bindgen remaps onto it with -//! `with`, so one Rust type serves the registry and the adapter face -//! alike. `PartialEq` is derived so the registry can compare a polled -//! status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries a +//! status transition as opaque bytes, so the core world resolves against +//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared +//! interfaces here with `with`, so the `Host` impls and the `fault` type +//! its components see are the very ones the core host constructs. +//! `PartialEq` is derived so extension services can compare event payloads. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], @@ -24,233 +23,3 @@ wasmtime::component::bindgen!({ exports: { default: async }, additional_derives: [PartialEq], }); - -/// WIT bindings for the second component kind: the -/// `videre:venue/venue-adapter` world. An adapter imports only the scoped -/// transport it needs (chain and messaging; outbound HTTP is wasi:http, -/// linked and allowlisted separately as for event-module) and exports the -/// `videre:venue/adapter` face plus `init`. The shared `nexum:host` -/// interfaces are reused from the `event-module` bindings above via -/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type -/// an adapter sees are the very ones the core host constructs. The -/// `videre:types` and `videre:value-flow` types generate here (the leaf -/// host world no longer reaches them) and the client bindgen below remaps -/// onto them. -mod venue_adapter { - wasmtime::component::bindgen!({ - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - world: "videre:venue/venue-adapter", - imports: { default: async }, - exports: { default: async }, - with: { - "nexum:host/types": super::nexum::host::types, - "nexum:host/chain": super::nexum::host::chain, - "nexum:host/messaging": super::nexum::host::messaging, - }, - additional_derives: [PartialEq], - }); -} - -pub use venue_adapter::VenueAdapter; - -/// The keeper-facing `videre:venue/client` import bound host-side. The -/// world imports the interface a module calls; the videre types it uses -/// are reused from the adapter bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the registry hands back to a module -/// are the very ones an adapter's `submit` produced - no lift between two -/// structurally identical copies. Async, because the `Host` impl awaits the -/// per-adapter mutex and the adapter's own async guest calls. -mod client_host { - wasmtime::component::bindgen!({ - inline: " - package videre:client-host; - world client-host { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - imports: { default: async }, - with: { - "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, - "videre:types/types": super::venue_adapter::videre::types::types, - }, - }); -} - -/// The host-bound client interface: the `Host` trait the registry implements -/// and the `add_to_linker` the module linker calls. -pub use client_host::videre::venue::client; -/// The registry-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the registry names. -pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the -/// registry and the `client::Host` impl name. -pub use venue_adapter::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, -}; -/// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::videre::value_flow::types as value_flow; - -/// Bindgen smoke for the `videre:value-flow` types package, compiled under -/// test through a throwaway world that imports the interface. Its value is -/// the identifier-hygiene gate: the test names every generated type, -/// variant, and field by its plain Rust spelling, so a WIT id that collided -/// with a Rust keyword would surface as an `r#` escape and fail to compile -/// here rather than in a downstream binding. -#[cfg(test)] -mod value_flow_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:value-flow-smoke; - world smoke { - import videre:value-flow/types@0.1.0; - } - ", - path: ["../../wit/videre-value-flow"], - }); - - #[test] - fn identifiers_bind_unescaped() { - use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - - let erc20 = Erc20 { - token: vec![0u8; 20], - }; - let _ = Asset::Native; - let asset = Asset::Erc20(erc20); - - let amount = AssetAmount { - asset, - amount: Vec::new(), - }; - assert!(amount.amount.is_empty()); - } -} - -/// Bindgen smoke for the `videre:types` and `videre:venue` packages, -/// mirroring the value-flow smoke above: a throwaway world imports the -/// client interface and, transitively, the types interface and its -/// value-flow dependency. The test names every generated type, case, and -/// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the four function signatures, so a keyword collision or an accidental -/// signature change fails this build rather than a downstream binding. -#[cfg(test)] -mod client_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:client-smoke; - world smoke { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - }); - - use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, - }; - use videre::value_flow::types::{Asset, AssetAmount}; - - struct DummyClient; - - impl videre::venue::client::Host for DummyClient { - fn quote(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn submit(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn status( - &mut self, - _venue: String, - _receipt: Vec, - ) -> Result { - Err(VenueError::UnknownVenue) - } - - fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - } - - fn amount(bytes: Vec) -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: bytes, - } - } - - #[test] - fn identifiers_bind_unescaped() { - use videre::venue::client::Host; - - let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Eip712; - - let header = IntentHeader { - gives: amount(vec![1]), - wants: amount(Vec::new()), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }; - assert!(header.wants.amount.is_empty()); - - let _ = IntentStatus::Pending; - let _ = IntentStatus::Open; - let _ = IntentStatus::Fulfilled; - let _ = IntentStatus::Cancelled; - let _ = IntentStatus::Expired; - - let tx = UnsignedTx { - chain: 1, - to: Vec::new(), - value: Vec::new(), - data: Vec::new(), - }; - let _ = SubmitOutcome::Accepted(Vec::new()); - let _ = SubmitOutcome::RequiresSigning(tx); - - let quotation = Quotation { - gives: amount(vec![1]), - wants: amount(Vec::new()), - fee: amount(Vec::new()), - valid_until_ms: 0, - }; - assert!(quotation.fee.amount.is_empty()); - - let _ = VenueError::UnknownVenue; - let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::Unsupported; - let _ = VenueError::Denied(String::new()); - let _ = VenueError::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::Timeout; - - let mut client = DummyClient; - assert!(client.quote(String::new(), Vec::new()).is_err()); - assert!(client.submit(String::new(), Vec::new()).is_err()); - assert!(client.status(String::new(), Vec::new()).is_err()); - assert!(client.cancel(String::new(), Vec::new()).is_err()); - } -} diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 4e0c3c18..9ae57a94 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -3,7 +3,7 @@ //! //! Parameterised over the [`RuntimeTypes`] lattice. The composition root //! builds the concrete [`Components`] and the extension list (including any -//! domain extension such as cow-api) and hands them here; this thin wrapper +//! domain extension) and hands them here; this thin wrapper //! forwards to the [`builder`](crate::builder) launcher and blocks until the //! event loop returns. A launcher that wants the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 7c836f07..47145902 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -32,7 +32,7 @@ use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; -use crate::host::extension::Extension; +use crate::host::extension::{EventSources, Extension}; use crate::host::logs::LogPipeline; use crate::preset::Runtime; use crate::runtime::event_loop; @@ -268,19 +268,29 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // the components. let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); - // Status polling runs only when it can produce something a module - // will see: at least one intent-status subscriber, a registered - // venue-registry service, and at least one installed adapter to poll. - let status_registry = supervisor - .has_intent_status_subscribers() - .then(|| supervisor.venue_registry()) - .flatten() - .filter(|registry| registry.venue_count() > 0); - let poll_statuses = status_registry.is_some(); + // Extension event sources open only for subscription kinds some + // loaded module declares; each extension gates further on its own + // service state and returns no stream when it has nothing to + // observe. + let subscribed = supervisor.extension_subscription_kinds(); + let mut reconnect_tasks = TaskSet::new(); + let mut extension_streams = Vec::new(); + { + let mut sources = EventSources::new( + engine_cfg, + supervisor.services(), + &subscribed, + &executor, + &mut reconnect_tasks, + ); + for ext in &extensions { + extension_streams.extend(ext.events(&mut sources)?); + } + } // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { + if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { if supervisor.dead_modules_hold_subscriptions() { anyhow::bail!( "every declared [[subscription]] belongs to an init-failed module - \ @@ -301,7 +311,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // Open per-chain block subscriptions + per-module chain-log // subscriptions through the executor, then drive them in the event // loop until shutdown. - let mut reconnect_tasks = TaskSet::new(); let block_streams = event_loop::open_block_streams( &components.chain, &block_chains, @@ -314,15 +323,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = status_registry.map(|registry| { - event_loop::open_intent_status_stream( - registry, - engine_cfg.limits.status_poll_interval(), - &executor, - &mut reconnect_tasks, - ) - }); - // The event-loop task holds the graceful guard until `run` returns // (after its final dispatch and cursor commit); shutdown ends the // loop between dispatches rather than cancelling it, so the drain @@ -333,7 +333,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, - intent_status_stream, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -447,7 +447,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let mut extensions = self.preset.extensions(); + let mut extensions = self.preset.extensions(self.config); extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. @@ -689,7 +689,7 @@ mod tests { Vec::new() } - fn extensions(&self) -> Vec>> { + fn extensions(&self, _config: &EngineConfig) -> Vec>> { vec![Arc::new(CountingExt { namespace: "alpha", prefix: "alpha:ext/", diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 6d79c95c..c950cf1f 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,15 +26,108 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::venue_registry::{ - DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, DEFAULT_WATCH_EXPIRY, - DEFAULT_WATCH_MAX_ENTRIES, SubmitQuota, WatchLimit, -}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default base window: the poll cadence a healthy provider refreshes +/// within. The give-up deadline is the derived `grace`, not this directly. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); +/// Derived grace defaults to this many `expiry` windows, so a watch rides +/// out a provider outage of a couple of poll cadences before giving up. +pub const WATCH_GRACE_MULTIPLIER: u64 = 2; +/// Ceiling on the derived grace window, so a long `expiry` cannot stretch +/// the give-up deadline past a day. +pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); + +/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. +/// An explicit `grace_secs` in config overrides this. +const fn derive_grace(expiry: Duration) -> Duration { + let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); + let capped = if scaled < WATCH_GRACE_MAX.as_secs() { + scaled + } else { + WATCH_GRACE_MAX.as_secs() + }; + Duration::from_secs(capped) +} + +/// Per-caller submission quota toward installed providers. Both a +/// forwarded submission and a charged decode failure consume one unit; +/// the window slides so a caller's budget refills as old charges age out. +/// Resolved from `[limits.quota]`; the extension service that meters +/// callers consumes it. +#[derive(Debug, Clone, Copy)] +pub struct SubmitQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl SubmitQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for SubmitQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// Bounds on a provider status-watch set. The cap bounds the per-cadence +/// poll fan-out; `grace` is the give-up deadline: how long a watch rides +/// out an unreachable provider before it is evicted unreported. `expiry` +/// is the base window `grace` derives from. Resolved from `[limits.watch]`. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// Base window a healthy provider refreshes the deadline within. + pub expiry: Duration, + /// Give-up deadline: how long a watch survives an unreachable provider + /// before it is evicted unreported. A reachable poll (the provider + /// answered) resets it; a resolve failure or an errored poll rides out + /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. + pub grace: Duration, +} + +impl WatchLimit { + /// Pair a cap with the base expiry; `grace` derives from `expiry`. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self::with_grace(max_entries, expiry, derive_grace(expiry)) + } + + /// As [`new`](Self::new) but with an explicit `grace` window, the path + /// a configured `grace_secs` takes. + pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { + Self { + max_entries, + expiry, + grace, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// Errors surfaced by [`load_or_default`]. /// /// Library-side modules must not propagate `anyhow::Error`; the rust @@ -89,11 +182,11 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, - /// Venue adapters the supervisor should boot alongside the modules. - /// Each entry resolves a `(component.wasm, module.toml)` pair like a - /// module, but the operator scopes its transport here rather than in - /// the adapter's own manifest: the installer of a venue adapter, not - /// the adapter author, decides which hosts and messaging topics it may + /// Provider components the supervisor should boot alongside the + /// modules. Each entry resolves a `(component.wasm, module.toml)` pair + /// like a module, but the operator scopes its transport here rather + /// than in the provider's own manifest: the installer of a provider, + /// not its author, decides which hosts and messaging topics it may /// reach. #[serde(default)] pub adapters: Vec, @@ -287,9 +380,9 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for registry-driven intent status polling (5 s). Fast -/// enough that a settling intent is observed within a block time or two, -/// slow enough that per-receipt venue calls stay negligible. +/// Default cadence for provider status polling (5 s). Fast enough that a +/// settling submission is observed within a block time or two, slow +/// enough that per-receipt provider calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: @@ -354,10 +447,10 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, - /// Per-caller intent submission quota. + /// Per-caller provider submission quota. #[serde(default)] pub quota: QuotaLimitsSection, - /// Router-driven intent status polling cadence. + /// Provider status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, /// Status-watch set bounds. @@ -494,9 +587,9 @@ impl ModuleLimits { } /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the registry builder, so a - /// misconfigured budget still admits one submission rather than bricking - /// every venue. + /// `max_charges` is saturated up to 1 by the consuming service, so a + /// misconfigured budget still admits one submission rather than + /// bricking every provider. pub fn quota(&self) -> SubmitQuota { SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), @@ -617,13 +710,13 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the registry defaults via [`ModuleLimits::quota`]. +/// `[limits.quota]` per-caller provider submission budget. Both optional; +/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. /// /// A caller (a strategy module, keyed by its namespace) may accrue at most /// `max_charges` submissions within a sliding `window_secs`; a decode failure /// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the adapter's fuel. +/// bodies exhausts its own budget rather than the provider's fuel. #[derive(Debug, Default, Deserialize)] pub struct QuotaLimitsSection { /// Maximum submissions (plus charged decode failures) per caller in the @@ -633,13 +726,13 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` intent status polling cadence. Optional; an +/// `[limits.status_poll]` provider status polling cadence. Optional; an /// omitted value resolves to the built-in default and a degenerate zero /// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. /// -/// The cadence is how often the registry polls each installed adapter's -/// `status` export for the receipts it watches; only observed transitions -/// fan out as `intent-status` events. +/// The cadence is how often the consuming service polls each installed +/// provider's `status` export for the receipts it watches; only observed +/// transitions fan out as events. #[derive(Debug, Default, Deserialize)] pub struct StatusPollSection { /// Milliseconds between status poll sweeps. @@ -647,13 +740,13 @@ pub struct StatusPollSection { } /// `[limits.watch]` status-watch set bounds. Both optional; omitted -/// values resolve to the registry defaults via [`ModuleLimits::watch`] -/// and degenerate zeroes saturate up to a usable minimum. +/// values resolve to the defaults via [`ModuleLimits::watch`] and +/// degenerate zeroes saturate up to a usable minimum. /// -/// The registry watches each accepted receipt until a terminal status: -/// the cap bounds the per-cadence poll fan-out, and the give-up deadline -/// evicts a watch whose venue stays unreachable. At the cap a new watch is -/// refused and logged; live watches are never dropped. +/// The consuming service watches each accepted receipt until a terminal +/// status: the cap bounds the per-cadence poll fan-out, and the give-up +/// deadline evicts a watch whose provider stays unreachable. At the cap a +/// new watch is refused and logged; live watches are never dropped. #[derive(Debug, Default, Deserialize)] pub struct WatchLimitsSection { /// Maximum receipts under status watch at once. @@ -1088,9 +1181,9 @@ window_secs = 0 let cfg: EngineConfig = toml::from_str( r#" [[adapters]] -path = "adapters/cow/cow_adapter.wasm" -http_allow = ["api.cow.fi", "*.cow.fi"] -messaging_topics = ["/nexum/1/cow-orders/proto"] +path = "providers/acme/acme_provider.wasm" +http_allow = ["api.acme.example", "*.acme.example"] +messaging_topics = ["/nexum/1/acme-orders/proto"] [[adapters]] path = "adapters/bare/bare.wasm" @@ -1100,10 +1193,13 @@ manifest = "adapters/bare/module.toml" .expect("adapters parse"); assert_eq!(cfg.adapters.len(), 2); let first = &cfg.adapters[0]; - assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert_eq!( + first.path, + PathBuf::from("providers/acme/acme_provider.wasm") + ); assert!(first.manifest.is_none(), "manifest defaults to sibling"); - assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); - assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); let second = &cfg.adapters[1]; assert_eq!( second.manifest.as_deref(), diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index a96cbeb1..0540e4d4 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -5,7 +5,7 @@ //! Time, randomness, and outbound HTTP are deliberately not members: all //! are WASI concerns serviced per store (WasiCtxBuilder for clocks and //! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends such as cow-api are not core seams: they live behind +//! Domain backends are not core seams: they live behind //! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..65f0c303 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -49,7 +49,7 @@ pub(crate) fn fault_message(fault: &Fault) -> &str { /// A structured JSON-RPC `ErrorResp` (the node returned a `code`, /// typically `-32000` for an `eth_call` revert) becomes a /// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so the SDK revert classifier can dispatch the ComposableCoW +/// so an SDK revert classifier can dispatch the revert /// envelopes. Everything else - transport failures, an unknown chain, /// bad params - becomes a shared [`Fault`]. impl From for ChainError { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 00d82442..dfa05f7f 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,16 +1,22 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, and an optional provider kind. Assembled at the composition -//! root and threaded into every module linker. +//! service, an optional provider kind, and optional event sources. +//! Assembled at the composition root and threaded into every module +//! linker. use std::any::Any; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; +use futures::Stream; +use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; use wasmtime::Store; use wasmtime::component::{Component, Linker}; +use crate::bindings::nexum::host::types::Event; +use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -43,6 +49,78 @@ pub trait Extension: Send + Sync + 'static { fn provider(&self) -> Option>> { None } + + /// Manifest subscription kinds this extension's event sources emit. + /// A `[[subscription]]` entry of any other non-core kind is refused + /// at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources once the engine is booted. The + /// event loop merges the returned streams and dispatches each item to + /// the modules its kind and attributes admit. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// One extension-observed event: dispatched to every module holding a +/// `[[subscription]]` of `kind` whose filters all match `attrs`. +pub struct ExtensionEvent { + /// Manifest subscription kind that routes this event. + pub kind: &'static str, + /// Routing attributes a subscription's filters match against. + pub attrs: Vec<(&'static str, String)>, + /// The host event delivered to each matching module. + pub event: Event, +} + +/// A stream of extension events the event loop merges and drives. +pub type ExtensionEventStream = Pin + Send>>; + +/// Ambient launch inputs for [`Extension::events`]: the loaded config, the +/// booted service map, the subscription kinds at least one module declares, +/// and the spawn surface for source tasks. +pub struct EventSources<'a> { + /// The loaded engine config. + pub config: &'a EngineConfig, + /// Extension-owned services, as booted. + pub services: &'a HostServices, + /// Extension subscription kinds declared by at least one module. + pub subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, +} + +impl<'a> EventSources<'a> { + /// Bundle the launch inputs for one [`Extension::events`] pass. + pub fn new( + config: &'a EngineConfig, + services: &'a HostServices, + subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, + ) -> Self { + Self { + config, + services, + subscribed, + executor, + tasks, + } + } + + /// Spawn one event-source task through the engine's executor. The task + /// must end when its stream's receiver drops; the engine drains it on + /// shutdown. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } } /// A type-erased host service an extension owns. Held per namespace on @@ -212,13 +290,13 @@ mod tests { #[test] fn get_downcasts_by_namespace() { let services = - HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build"); + HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); - let registry = services.get::("videre").expect("registered"); + let registry = services.get::("acme").expect("registered"); assert_eq!(registry.0, 7); - assert!(services.get::("videre").is_none()); + assert!(services.get::("acme").is_none()); assert!(services.get::("absent").is_none()); - assert!(services.raw("videre").is_some()); + assert!(services.raw("acme").is_some()); } /// A serviceless extension contributes nothing to the map. @@ -236,10 +314,10 @@ mod tests { #[test] fn duplicate_namespace_is_refused() { let err = HostServices::from_extensions(&[ - ext("videre", Arc::new(Registry(1))), - ext("videre", Arc::new(Clockwork)), + ext("acme", Arc::new(Registry(1))), + ext("acme", Arc::new(Clockwork)), ]) .expect_err("duplicate namespace"); - assert!(err.to_string().contains("videre"), "{err}"); + assert!(err.to_string().contains("acme"), "{err}"); } } diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index ec74e6c8..2624457e 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -268,26 +268,53 @@ mod tests { #[test] fn exact_host_passes() { - assert!(admit(&uri("https://api.cow.fi/v1/x"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("http://api.cow.fi/"), &allow(&["api.cow.fi"])).is_ok()); + assert!( + admit( + &uri("https://api.acme.example/v1/x"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("http://api.acme.example/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); } #[test] fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.cow.fi"])); - assert!(denied("https://api.cow.fi.evil.example/", &["api.cow.fi"])); + assert!(denied("https://evil.example/", &["api.acme.example"])); + assert!(denied( + "https://api.acme.example.evil.example/", + &["api.acme.example"] + )); } #[test] fn empty_allowlist_denies_everything() { - assert!(denied("https://api.cow.fi/", &[])); + assert!(denied("https://api.acme.example/", &[])); assert!(denied("http://127.0.0.1/", &[])); } #[test] fn matching_is_case_insensitive() { - assert!(admit(&uri("https://API.COW.FI/"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("https://api.cow.fi/"), &allow(&["API.COW.FI"])).is_ok()); + assert!( + admit( + &uri("https://API.ACME.EXAMPLE/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("https://api.acme.example/"), + &allow(&["API.ACME.EXAMPLE"]) + ) + .is_ok() + ); } #[test] @@ -301,7 +328,10 @@ mod tests { #[test] fn exact_entry_does_not_match_subdomains() { - assert!(denied("https://sub.api.cow.fi/", &["api.cow.fi"])); + assert!(denied( + "https://sub.api.acme.example/", + &["api.acme.example"] + )); } #[test] @@ -321,13 +351,16 @@ mod tests { #[test] fn ports_do_not_affect_matching() { - let list = allow(&["api.cow.fi"]); - assert!(admit(&uri("https://api.cow.fi:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.cow.fi:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.cow.fi"])); + let list = allow(&["api.acme.example"]); + assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); + assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); + assert!(denied("https://evil.example:443/", &["api.acme.example"])); // A port spelled in the allowlist entry never matches: entries // are hosts, not authorities. - assert!(denied("https://api.cow.fi:8443/", &["api.cow.fi:8443"])); + assert!(denied( + "https://api.acme.example:8443/", + &["api.acme.example:8443"] + )); } // ----------------- SSRF-style bypass regressions (#57) --------- @@ -449,14 +482,14 @@ mod tests { for scheme in ["http", "https"] { assert!( admit( - &uri(&format!("{scheme}://api.cow.fi/")), - &allow(&["api.cow.fi"]) + &uri(&format!("{scheme}://api.acme.example/")), + &allow(&["api.acme.example"]) ) .is_ok() ); assert!(denied( &format!("{scheme}://evil.example/"), - &["api.cow.fi"] + &["api.acme.example"] )); } } @@ -464,7 +497,7 @@ mod tests { #[test] fn uri_without_authority_is_invalid_not_denied() { assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.cow.fi"])), + admit(&uri("/relative/path"), &allow(&["api.acme.example"])), Err(ErrorCode::HttpRequestUriInvalid) )); } @@ -491,7 +524,7 @@ mod tests { #[tokio::test] async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.cow.fi"]), limits()); + let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { panic!("off-list host must be denied"); }; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index bd450cb3..4cf45da5 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,7 +1,7 @@ //! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so //! `publish` reports `unsupported` and `query` returns empty, the same //! posture as `identity::accounts`. The per-store topic scope is enforced -//! ahead of that stub: a venue adapter carrying a +//! ahead of that stub: a provider carrying a //! `[[adapters]].messaging_topics` grant may only publish within it, so //! the egress boundary is live even though delivery is not. @@ -15,7 +15,7 @@ use crate::host::state::HostState; /// when it equals a scope entry or descends from one read as a path prefix /// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is /// the `/` path separator, so a grant never leaks into a longer sibling -/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; @@ -63,27 +63,27 @@ mod tests { #[test] fn exact_topic_is_admitted() { - let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); } #[test] fn prefix_scope_admits_the_family_but_not_a_sibling() { let scope = vec!["/nexum/1/".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); // A sibling namespace stays out. - assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); } #[test] fn prefix_boundary_is_a_path_segment_not_a_substring() { // A scope entry without a trailing slash still bounds on the path // separator, so it cannot leak into a longer sibling segment. - let scope = vec!["/nexum/1/cow".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow", &scope)); - assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); - assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme", &scope)); + assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); } } diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 40b65ade..2247ed60 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -13,4 +13,3 @@ mod logging; mod messaging; mod remote_store; mod types; -mod venue_client; diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index ac9e0634..a2842435 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -15,10 +15,11 @@ //! - `impls` (private): the bindgen-side trait impls, one file per core //! WIT interface, that dispatch to the backends above. //! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook + capability -//! namespace) an extension is wired in through at the composition root. -//! Domain extensions such as cow-api live in their own crates and plug -//! in through this seam rather than being hard-linked into the core host. +//! - [`extension`]: the extension seam (linker hook, capability +//! namespace, service, provider kind, event sources) an extension is +//! wired in through at the composition root. Domain extensions live in +//! their own crates and plug in through this seam rather than being +//! hard-linked into the core host. //! - [`actor`]: the supervised host-actor primitive provider instances //! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module @@ -36,4 +37,3 @@ pub mod local_store_redb; pub mod logs; pub mod provider_pool; pub mod state; -pub mod venue_registry; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..55d5e912 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -428,7 +428,7 @@ pub enum ProviderError { /// Decoded `ErrorResp.data` payload - for `eth_call` reverts /// this is the abi-encoded revert body, hex-decoded from the /// upstream JSON string once here (consumed directly by - /// `shepherd_sdk::cow::decode_revert`). `None` when the failure + /// an SDK revert decoder). `None` when the failure /// was transport-level or the payload was not a hex string. data: Option>, /// Transport-side typed error. diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index b1b103cb..2023baf4 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -30,8 +30,8 @@ pub struct HostState { pub http_gate: HttpGate, /// Messaging content topics this store may publish to. Empty means /// unscoped (the module default and current messaging posture); a - /// venue adapter carries its `[[adapters]].messaging_topics` grant - /// here, so an out-of-scope publish is refused before it reaches the + /// provider carries its `[[adapters]].messaging_topics` grant here, + /// so an out-of-scope publish is refused before it reaches the /// backend. pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 8ad2b4d3..dc6edeb4 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -24,7 +24,7 @@ use super::types::{CORE_CAPABILITIES, LoadedManifest}; /// One WIT namespace prefix plus the interface names under it that count as /// capabilities. Core registers `nexum:host/`; an extension registers its -/// own (e.g. `shepherd:cow/`). +/// own. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -39,20 +39,6 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Capability names under the `videre:venue/` package a module may import. -/// Only the keeper-facing `client` interface is a capability; the -/// `videre:types` and `videre:value-flow` packages are type-only and need -/// no declaration. -pub const VENUE_CAPABILITIES: &[&str] = &["client"]; - -/// The venue namespace: the `videre:venue/client` import is linked into -/// every module linker, so a module that submits intents declares the -/// `client` capability the same way it declares a `nexum:host/` one. -pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "videre:venue/", - ifaces: VENUE_CAPABILITIES, -}; - /// The interfaces a provider world links: the scoped transport only. A /// provider has no local-store, remote-store, identity, or logging - it /// moves bytes to and from its counterparty and nothing else. `http` is @@ -127,11 +113,10 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with the core `nexum:host/` namespace plus the - /// keeper-facing `videre:venue/client` import every module linker carries. + /// The registry with the core `nexum:host/` namespace. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, VENUE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE], } } @@ -181,7 +166,7 @@ impl CapabilityRegistry { /// /// Examples: /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.1.0"` -> `Some("cow-api")` once the cow + /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) @@ -270,13 +255,13 @@ mod tests { use super::*; use crate::manifest::types::{CapabilitiesSection, Manifest}; - /// A registry with the cow extension namespace registered, mirroring - /// what the composition root assembles. - fn registry_with_cow() -> CapabilityRegistry { + /// A registry with one extension namespace registered, mirroring + /// what a composition root assembles. + fn registry_with_ext() -> CapabilityRegistry { let mut r = CapabilityRegistry::core(); r.register(NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], + prefix: "test:acme/", + ifaces: &["acme-api"], }); r } @@ -315,35 +300,21 @@ mod tests { } #[test] - fn wit_import_to_cap_shepherd_cow_needs_registration() { - // Core registry does not recognise the cow namespace. + fn wit_import_to_cap_extension_needs_registration() { + // Core registry does not recognise an extension namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), None); + assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); // Once registered, it resolves. - let r = registry_with_cow(); - assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), - Some("cow-api") - ); - } - - #[test] - fn venue_client_is_a_core_capability_but_videre_types_is_not() { - let r = CapabilityRegistry::core(); + let r = registry_with_ext(); assert_eq!( - r.wit_import_to_cap("videre:venue/client@0.1.0"), - Some("client") + r.wit_import_to_cap("test:acme/acme-api@0.1.0"), + Some("acme-api") ); - assert!(r.is_known("client")); - // The type-only interfaces are not capabilities and need no - // declaration. - assert_eq!(r.wit_import_to_cap("videre:types/types@0.1.0"), None); - assert_eq!(r.wit_import_to_cap("videre:value-flow/types@0.1.0"), None); } #[test] fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); @@ -377,20 +348,20 @@ mod tests { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); let imports = [ "nexum:host/chain@0.1.0", - "shepherd:cow/cow-api@0.1.0", + "test:acme/acme-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -401,7 +372,7 @@ mod tests { "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -419,7 +390,7 @@ mod tests { "wasi:http/outgoing-handler@0.2.12", "wasi:http/types@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } } @@ -429,7 +400,7 @@ mod tests { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -441,7 +412,7 @@ mod tests { fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -501,14 +472,14 @@ mod tests { "wasi:cli/terminal-stdout@0.2.6", "wasi:cli/environment@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn undeclared_gated_wasi_is_refused() { let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); for (import, cap) in [ ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), @@ -531,14 +502,14 @@ mod tests { "wasi:filesystem/types@0.2.6", "wasi:filesystem/preopens@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn declaring_one_gated_cap_does_not_grant_another() { let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() ); @@ -550,7 +521,7 @@ mod tests { // Even with an unrelated gated cap declared, an unrecognised wasi: // namespace is denied outright. let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); assert!(matches!(err, CapabilityError::UnknownWasi { .. })); @@ -560,7 +531,7 @@ mod tests { fn wasi_gate_ignores_version_suffix() { let declared = manifest_with_caps(&["wasi-sockets"], &[]); let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); assert!( enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() @@ -573,7 +544,7 @@ mod tests { // No [capabilities] section -> 0.1-fallback: registry imports pass, // but the WASI surface is still gated fail-closed. let loaded = manifest_no_caps(); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() @@ -588,7 +559,7 @@ mod tests { #[test] fn wasi_capability_names_are_known() { - let r = registry_with_cow(); + let r = registry_with_ext(); for cap in ["wasi-sockets", "wasi-filesystem"] { assert!(r.is_known(cap), "{cap} missing from known set"); assert!(r.known_names().split(", ").any(|n| n == cap)); diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 6ad22674..132dd838 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -172,54 +172,66 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } #[test] - fn load_rejects_the_retired_log_kind() { + fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` - // fails to parse with an unknown-variant error naming the valid - // set so a not-yet-migrated manifest surfaces clearly at load. + // parses as an extension kind and boot refuses it against the + // extension vocabulary, so a not-yet-migrated manifest still + // surfaces clearly rather than silently dropping events. let toml = r#" [module] name = "stale" [[subscription]] kind = "log" -chain_id = 1 +chain_id = "1" "#; - let err = toml::from_str::(toml).expect_err("stale kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("chain-log"), - "error names the valid set: {msg}" - ); - assert!( - !msg.contains("unknown field"), - "kind is the discriminator: {msg}" - ); + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, .. } if kind == "log" + )); } #[test] - fn load_parses_intent_status_subscription() { + fn load_parses_extension_subscriptions_with_string_filters() { let toml = r#" [module] name = "watcher" [[subscription]] -kind = "intent-status" +kind = "acme-status" [[subscription]] -kind = "intent-status" -venue = "cow" +kind = "acme-status" +scope = "primary" "#; let manifest: Manifest = toml::from_str(toml).expect("parse"); assert!(matches!( &manifest.subscriptions[0], - Subscription::IntentStatus { venue: None } + Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() )); assert!(matches!( &manifest.subscriptions[1], - Subscription::IntentStatus { venue: Some(v) } if v == "cow" + Subscription::Extension { kind, filters } + if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") )); } + /// A non-string filter value on an extension kind is refused at parse. + #[test] + fn load_rejects_a_non_string_extension_filter() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" +scope = 7 +"#; + let err = toml::from_str::(toml).expect_err("non-string filter"); + assert!(err.to_string().contains("must be a string"), "{err}"); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -313,14 +325,14 @@ name = "plain" let manifest: Manifest = toml::from_str( r#" [module] -name = "cow" -kind = "venue-adapter" +name = "acme" +kind = "acme-provider" "#, ) .expect("parse"); assert_eq!( manifest.module.kind, - ComponentKind::Provider("venue-adapter".to_owned()), + ComponentKind::Provider("acme-provider".to_owned()), ); } @@ -395,9 +407,9 @@ max_state_bytes = 52428800 #[test] fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); + let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.acme.example", &allow)); + assert!(!host_allowed("evil.api.acme.example", &allow)); assert!(host_allowed("foo.discord.com", &allow)); assert!(host_allowed("a.b.discord.com", &allow)); assert!(!host_allowed("discord.com", &allow)); @@ -406,17 +418,20 @@ max_state_bytes = 52428800 #[test] fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.COW.FI".to_string()]; - let lower = vec!["api.cow.fi".to_string()]; - assert!(host_allowed("api.cow.fi", &upper)); - assert!(host_allowed("Api.Cow.Fi", &lower)); + let upper = vec!["API.ACME.EXAMPLE".to_string()]; + let lower = vec!["api.acme.example".to_string()]; + assert!(host_allowed("api.acme.example", &upper)); + assert!(host_allowed("Api.Acme.Example", &lower)); } #[test] fn host_allowed_matches_hosts_not_authorities() { // Entries are bare hosts; a port or userinfo in a pattern can // never match a host string. - let allow = vec!["api.cow.fi:8443".to_string(), "u@api.cow.fi".to_string()]; - assert!(!host_allowed("api.cow.fi", &allow)); + let allow = vec![ + "api.acme.example:8443".to_string(), + "u@api.acme.example".to_string(), + ]; + assert!(!host_allowed("api.acme.example", &allow)); } } diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index dbaf774a..c13ca680 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,17 +4,18 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::collections::BTreeMap; use std::fmt; use serde::Deserialize; +use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` /// world links into every module linker. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities (e.g. -/// cow-api) are not listed here; each extension contributes its own -/// namespace to the [`super::capabilities::CapabilityRegistry`] at the -/// composition root. +/// separately by the registry. Domain-extension capabilities are not +/// listed here; each extension contributes its own namespace to the +/// [`super::capabilities::CapabilityRegistry`] at the composition root. pub const CORE_CAPABILITIES: &[&str] = &[ "chain", "identity", @@ -43,10 +44,11 @@ pub struct Manifest { /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] +/// validated per-kind by the supervisor. A kind outside the core set +/// parses as [`Subscription::Extension`] and is validated at boot +/// against the kinds the wired extensions declare, so a typo still +/// fails loudly rather than silently disabling an event source. +#[derive(Debug, Clone)] pub enum Subscription { /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to @@ -59,17 +61,14 @@ pub enum Subscription { /// per-module - the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. - #[serde(rename = "chain-log")] ChainLog { /// EVM chain id. chain_id: u64, /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] address: Option, /// Topic-0 of the event the module wants to consume. `0x`- /// prefixed 32-byte hex. Optional - when absent the /// subscription matches every event from the address(es). - #[serde(default)] event_signature: Option, /// Resume across engine restarts. When `true` the host persists a /// durable per-subscription cursor and re-opens the log poller @@ -77,13 +76,11 @@ pub enum Subscription { /// current head. Delivery is then at-least-once, so the module must /// tolerate redelivery (the keeper idempotency journal already /// dedups it). - #[serde(default)] resume: bool, /// Optional cap on how far back a `resume` subscription will /// backfill, in blocks. `None` (the default) backfills the entire /// gap with no loss; set it only for a consumer that explicitly /// tolerates dropping the oldest missed blocks. - #[serde(default)] max_lookback: Option, }, /// Cron-scheduled tick. 0.2 parses but does not dispatch; the @@ -95,19 +92,96 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, - /// Router-polled intent status transitions, delivered as - /// `intent-status` events. Fan-out is shared: the router polls each - /// installed adapter once per cadence and every subscribed module - /// receives the transition, filtered by `venue` when set. - #[serde(rename = "intent-status")] - IntentStatus { - /// Restrict delivery to transitions from this venue id. - /// Absent means transitions from every venue. + /// An extension-owned event kind. Every non-`kind` key is a string + /// filter matched against the event's routing attributes: an event + /// is delivered when its kind matches and every filter pair is + /// present in the event's attributes. + Extension { + /// The extension-declared subscription kind. + kind: String, + /// Attribute filters; empty admits every event of the kind. + filters: BTreeMap, + }, +} + +/// The core subscription kinds, parsed by shape. Any other kind falls +/// through to [`Subscription::Extension`]. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CoreSubscription { + Block { + chain_id: u64, + }, + #[serde(rename = "chain-log")] + ChainLog { + chain_id: u64, + #[serde(default)] + address: Option, + #[serde(default)] + event_signature: Option, + #[serde(default)] + resume: bool, #[serde(default)] - venue: Option, + max_lookback: Option, + }, + Cron { + schedule: String, }, } +impl From for Subscription { + fn from(sub: CoreSubscription) -> Self { + match sub { + CoreSubscription::Block { chain_id } => Self::Block { chain_id }, + CoreSubscription::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + } => Self::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + }, + CoreSubscription::Cron { schedule } => Self::Cron { schedule }, + } + } +} + +impl<'de> Deserialize<'de> for Subscription { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { + return Err(D::Error::missing_field("kind")); + }; + match kind { + "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + .try_into::() + .map(Into::into) + .map_err(D::Error::custom), + _ => { + let kind = kind.to_owned(); + let mut filters = BTreeMap::new(); + for (key, value) in table { + if key == "kind" { + continue; + } + let Some(value) = value.as_str() else { + return Err(D::Error::custom(format!( + "subscription filter `{key}` must be a string" + ))); + }; + filters.insert(key, value.to_owned()); + } + Ok(Self::Extension { kind, filters }) + } + } + } +} + #[derive(Debug, Deserialize, Default)] #[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. pub struct ModuleSection { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 17044af2..9ec4dd64 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; use crate::host::component::{ ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, @@ -55,10 +56,13 @@ pub trait Runtime { /// The cross-cutting add-ons installed before the engine boots. fn add_ons(&self) -> AddOns; - /// The linker extensions the preset launches with. None by default; + /// The extensions the preset launches with, derived from the loaded + /// config so an extension can carry config-resolved policy. None by + /// default; /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) /// appends on top. - fn extensions(&self) -> Vec>> { + fn extensions(&self, config: &EngineConfig) -> Vec>> { + let _ = config; Vec::new() } } diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index a63526df..3f1aa287 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,8 +40,8 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; +use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; use crate::host::provider_pool::ProviderError; -use crate::host::venue_registry::VenueRegistry; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; @@ -147,40 +147,6 @@ where streams } -/// Registry-driven intent status polling: one task that, on every cadence -/// tick, polls each installed adapter's status export through the shared -/// [`VenueRegistry`] and forwards the observed transitions. The task is -/// spawned via `executor` into `tasks` like the reconnect tasks and exits -/// cleanly when the loop's receiver drops. -pub fn open_intent_status_stream( - registry: VenueRegistry, - cadence: Duration, - executor: &TaskExecutor, - tasks: &mut TaskSet, -) -> IntentStatusStream { - let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - tasks.push(executor.spawn(Box::pin(status_poll_task(registry, cadence, tx)))); - Box::pin(receiver_stream(rx)) -} - -/// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence -/// first so the engine's boot dispatch settles before the first poll. -async fn status_poll_task( - registry: VenueRegistry, - cadence: Duration, - tx: mpsc::Sender, -) -> TaskExit { - loop { - tokio::time::sleep(cadence).await; - for update in registry.poll_status_transitions().await { - if tx.send(update).await.is_err() { - // Receiver dropped -> engine shutting down. - return TaskExit::ReceiverGone; - } - } - } -} - /// Wrap an `mpsc::Receiver` as a `Stream` using /// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just /// for `ReceiverStream`. @@ -492,12 +458,6 @@ pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; -/// Router-observed intent status transitions, fanned to subscribers by the -/// event loop. Infallible items: poll failures are retried inside the poll -/// task on the next cadence rather than surfaced here. -pub type IntentStatusStream = - std::pin::Pin + Send>>; - /// Drive the supervisor with events until `shutdown` resolves. /// /// Graceful shutdown: the dispatch path is structured so @@ -516,7 +476,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, - intent_status_stream: Option, + extension_streams: Vec, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) -> (u64, u64) { @@ -538,14 +498,15 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; - let mut intent_statuses: BoxStream<'_, _> = match intent_status_stream { - Some(stream) => stream, - None => futures::stream::pending().boxed(), + let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(extension_streams).boxed() }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; - let mut dispatched_intent_statuses: u64 = 0; + let mut dispatched_extension_events: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -562,7 +523,7 @@ pub async fn run( Box, Option>, ), - IntentStatus(nexum::host::types::IntentStatusUpdate), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -593,10 +554,10 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, - next = intent_statuses.next() => match next { - Some(update) => NextEvent::IntentStatus(update), - // The poll task loops forever; `None` means it exited. - None => NextEvent::StreamPanic("intent-status"), + next = extension_events.next() => match next { + Some(event) => NextEvent::Extension(event), + // Extension source tasks loop forever; `None` means one exited. + None => NextEvent::StreamPanic("extension-event"), }, }; @@ -611,9 +572,9 @@ pub async fn run( .await; dispatched_chain_logs += 1; } - NextEvent::IntentStatus(update) => { - supervisor.dispatch_intent_status(update).await; - dispatched_intent_statuses += 1; + NextEvent::Extension(event) => { + supervisor.dispatch_extension_event(event).await; + dispatched_extension_events += 1; } NextEvent::Shutdown(guard) => { // Drop the stream-end receivers so the reconnect @@ -622,12 +583,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, - dispatched_intent_statuses, + dispatched_extension_events, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -640,7 +601,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index 70998a6e..bfc1a427 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// Aggressive enough to catch a deterministically broken module /// without waiting out the full exponential backoff (the 5th trap /// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real cow-api submit does +/// enough that a one-off RPC blip during a real extension submit does /// not get a module quarantined. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 029fa1e3..102dd196 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,11 +18,10 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! -//! Providers (venue adapters) ride the same sweeps: a trap inside a -//! routed call flips the [`Liveness`] their actor shares with the -//! supervisor, the venue resolves to `unavailable` (not -//! `unknown-venue`) while dead, and the sweep reinstalls the provider -//! after the same backoff and poison policies. +//! Providers ride the same sweeps: a trap inside a routed call flips +//! the [`Liveness`] their actor shares with the supervisor, the owning +//! service reports the instance unavailable while dead, and the sweep +//! reinstalls the provider after the same backoff and poison policies. //! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match @@ -32,7 +31,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -51,6 +50,7 @@ use crate::engine_config::{ }; use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; +use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, }; @@ -61,7 +61,6 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; @@ -71,8 +70,8 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Providers (venue adapters) loaded at boot, whether or not `init` - /// succeeded. Swept for restart and poison alongside the modules. + /// Providers loaded at boot, whether or not `init` succeeded. Swept + /// for restart and poison alongside the modules. providers: Vec, /// Registered provider kinds paired with their services, kept for the /// provider restart sweep to reinstall through. @@ -261,11 +260,12 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// One loaded provider (venue adapter). Mirrors [`LoadedModule`]'s restart -/// and poison bookkeeping; liveness is shared with the installed actor, -/// which marks it dead on a trap, and read back by the sweep. +/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping; liveness is shared with the installed actor, which marks +/// it dead on a trap, and read back by the sweep. struct LoadedProvider { - /// The provider's namespace: its manifest name, and its venue id. + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, @@ -326,6 +326,17 @@ fn provider_kinds( Ok(kinds) } +/// The union of subscription kinds the wired extensions declare; a +/// manifest subscription of any other non-core kind fails the load. +fn extension_subscription_vocabulary( + extensions: &[Arc>], +) -> BTreeSet<&'static str> { + extensions + .iter() + .flat_map(|ext| ext.subscriptions().iter().copied()) + .collect() +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -357,22 +368,12 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - // The venue registry rides the generic service map under the videre - // namespace, seeded here while it lives in-core; the videre - // extension takes it over. Same for the venue-adapter kind row. - let venue_service: Arc = Arc::new( - VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()) - .build(), - ); - let services = HostServices::from_extensions(extensions)? - .with_service(VenueRegistry::NAMESPACE, Arc::clone(&venue_service))?; + let services = HostServices::from_extensions(extensions)?; // Provider kinds the boot loop resolves manifest kinds against. - let mut kinds = provider_kinds(extensions, &services)?; - register_kind(&mut kinds, Box::new(VenueAdapterKind), venue_service)?; - // Providers boot first into the shared registry handle, so every - // module store built below already routes to the installed venues. - // Providers link only their kind's scoped imports. + let kinds = provider_kinds(extensions, &services)?; + // Providers boot first into their extension-owned services, so + // every module store built below already routes to the installed + // instances. Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { @@ -390,6 +391,7 @@ impl Supervisor { providers.push(loaded); } + let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -401,6 +403,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -451,9 +454,9 @@ impl Supervisor { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; - // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so no registry service is - // published and every client call resolves to `unknown-venue`. + // The single-module override path serves `just run`; providers + // are configured through `engine.toml`, so none boot here. + let extension_kinds = extension_subscription_vocabulary(extensions); let loaded = Self::load_one( engine, linker, @@ -463,6 +466,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await?; Ok(Self { @@ -574,6 +578,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, + extension_kinds: &BTreeSet<&'static str>, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -630,8 +635,8 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), - // Event modules are unscoped for messaging; only venue - // adapters carry a topic grant. + // Event modules are unscoped for messaging; only providers + // carry a topic grant. Vec::new(), memory, fuel, @@ -692,13 +697,23 @@ impl Supervisor { // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events. + // manifest does not silently drop events, and refuse an + // extension kind no wired extension declares. for sub in &loaded_manifest.manifest.subscriptions { - if matches!(sub, Subscription::Cron { .. }) { - warn!( + match sub { + Subscription::Cron { .. } => warn!( module = %module_namespace, "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ); + ), + Subscription::Extension { kind, .. } + if !extension_kinds.contains(kind.as_str()) => + { + return Err(anyhow!( + "module {module_namespace} subscribes to unknown event kind {kind}; \ + no wired extension declares it" + )); + } + _ => {} } } @@ -892,7 +907,7 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters loaded at boot, alive or not. + /// Number of providers loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { self.providers.len() } @@ -1230,15 +1245,12 @@ impl Supervisor { ok } - /// Dispatch a registry-observed intent status transition to every module - /// subscribed to `intent-status` events whose venue filter admits the - /// update's venue. Returns the number of modules invoked. Mirrors + /// Dispatch one extension-observed event to every module holding a + /// subscription of its kind whose filters all match the event's + /// attributes. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted /// first, poisoned modules are skipped. - pub async fn dispatch_intent_status( - &mut self, - update: nexum::host::types::IntentStatusUpdate, - ) -> usize { + pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { let now = std::time::Instant::now(); let restart_candidates: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1260,19 +1272,20 @@ impl Supervisor { m.subscriptions.iter().any(|s| { matches!( s, - Subscription::IntentStatus { venue } - if venue.as_deref().is_none_or(|v| v == update.venue) + Subscription::Extension { kind, filters } + if kind == event.kind && filters.iter().all(|(fk, fv)| { + event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) + }) ) }) }) .collect(); - let event = nexum::host::types::Event::IntentStatus(update); let mut dispatched = 0; for idx in candidate_indices { - // Status transitions are venue-scoped, not chain-scoped: the - // telemetry chain id and block number carry the 0 sentinel. + // Extension events are not chain-scoped: the telemetry chain + // id and block number carry the 0 sentinel. if matches!( - self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, DispatchOutcome::Ok, ) { dispatched += 1; @@ -1281,23 +1294,25 @@ impl Supervisor { dispatched } - /// Whether any loaded module subscribes to `intent-status` events. - /// The launcher polls adapter statuses only when this holds: with no - /// subscriber every transition would be dropped on arrival. - pub fn has_intent_status_subscribers(&self) -> bool { - self.modules.iter().any(|m| { - m.subscriptions - .iter() - .any(|s| matches!(s, Subscription::IntentStatus { .. })) - }) + /// The extension subscription kinds at least one loaded module + /// declares. An extension opens an event source only when its kind + /// appears here: with no subscriber every event would be dropped on + /// arrival. + pub fn extension_subscription_kinds(&self) -> BTreeSet { + self.modules + .iter() + .flat_map(|m| m.subscriptions.iter()) + .filter_map(|s| match s { + Subscription::Extension { kind, .. } => Some(kind.clone()), + _ => None, + }) + .collect() } - /// The venue registry published under the videre service namespace, - /// when one is. Shared by every module store through the service map. - pub fn venue_registry(&self) -> Option { - self.services - .get::(VenueRegistry::NAMESPACE) - .map(|registry| (*registry).clone()) + /// The extension-owned services, as booted. Shared by every module + /// store through the service map. + pub fn services(&self) -> &HostServices { + &self.services } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1658,13 +1673,6 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - // The venue client import is linked into every module linker; it - // dispatches to the shared registry carried in each store's `HostState`. - // Modules that do not import it are unaffected. - crate::bindings::client::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 0bf06086..6396ffe0 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -72,7 +72,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), - None, + Vec::new(), nexum_tasks::TaskSet::new(), shutdown, ) @@ -145,7 +145,7 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { &mut supervisor, block_streams, chain_log_streams, - None, + Vec::new(), tasks, shutdown, ), @@ -202,7 +202,7 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { &mut supervisor, block_streams, vec![], - None, + Vec::new(), tasks, shutdown, ), @@ -234,77 +234,6 @@ fn example_module_toml() -> PathBuf { .join("modules/example/module.toml") } -fn echo_venue_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-venue/module.toml") -} - -fn echo_client_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-client/module.toml") -} - -/// Path to the pre-built reference venue adapter. Built by -/// `just build-venue`; the import-pinning test skips when absent. -fn echo_venue_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_venue.wasm") -} - -/// Returns `None` and prints a skip message if the venue fixture isn't -/// built. -fn echo_venue_wasm_or_skip() -> Option { - let p = echo_venue_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-venue` to enable the venue import test", - p.display() - ); - None - } -} - -/// Path to the pre-built echo-client module, the strategy half of the echo -/// pair. Built by `just build-echo-client`; the round-trip test skips when -/// absent. -fn echo_client_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_client.wasm") -} - -/// Returns `None` and prints a skip message if the echo-client fixture -/// isn't built. -fn echo_client_wasm_or_skip() -> Option { - let p = echo_client_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-echo-client` to enable the echo round-trip test", - p.display() - ); - None - } -} - /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -433,54 +362,12 @@ fn e2e_example_component_imports_equal_declared_capabilities() { "imports were: {imports:?}" ); - // No extension interface leaks in either: the blanket cow world is - // gone from modules that never declared it. + // No extension interface leaks in either: the per-module world holds + // exactly what the manifest declared. assert!( imports .iter() - .all(|name| !name.starts_with("shepherd:cow/")), - "imports were: {imports:?}" - ); -} - -/// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped -/// transport its manifest declares (`chain`), by construction of the -/// emitted world. The venue side never depended on toolchain elision; -/// this pins that it does not regress to it. -#[test] -fn e2e_echo_venue_component_imports_equal_declared_capabilities() { - let Some(wasm) = echo_venue_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["chain"]), - "imports were: {imports:?}" - ); - - // No host key-material or persistence interface leaks in: an adapter - // structurally cannot reach messaging it never declared, local-store, - // identity, or logging. - assert!( - imports.iter().all(|name| !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), + .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), "imports were: {imports:?}" ); } @@ -540,415 +427,6 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } -// ── intent-status subscription E2E ──────────────────────────────────── - -/// A scripted venue adapter for the registry: accepts every submission with -/// a fixed receipt and serves statuses front-first from a script, falling -/// back to `open` once drained. -struct ScriptedAdapter { - statuses: std::collections::VecDeque, -} - -impl ScriptedAdapter { - fn new(statuses: impl IntoIterator) -> Self { - Self { - statuses: statuses.into_iter().collect(), - } - } -} - -impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::IntentHeader { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - settlement: crate::bindings::Settlement { chain: 1 }, - authorisation: crate::bindings::AuthScheme::Eip712, - }) - }) - } - - fn quote<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::Quotation { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - fee: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - valid_until_ms: 0, - }) - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::SubmitOutcome::Accepted( - b"receipt".to_vec(), - )) - }) - } - - fn status( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture< - '_, - Result, - > { - Box::pin(async move { - Ok(self - .statuses - .pop_front() - .unwrap_or(crate::bindings::IntentStatus::Open)) - }) - } - - fn cancel( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture<'_, Result<(), crate::bindings::VenueError>> { - Box::pin(async move { Ok(()) }) - } -} - -/// Build a registry with one scripted adapter installed under `cow`. -fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let registry = crate::host::venue_registry::VenueRegistryBuilder::new( - crate::host::venue_registry::SubmitQuota::default(), - ) - .build(); - registry - .install( - crate::host::venue_registry::VenueId::from("cow"), - crate::host::actor::Liveness::default(), - adapter, - ) - .expect("install"); - registry -} - -/// Write a manifest subscribing the example module to intent-status -/// events from the `cow` venue. -fn intent_status_manifest(dir: &Path) -> PathBuf { - let manifest = dir.join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "intent-status" -venue = "cow" -"#, - ) - .unwrap(); - manifest -} - -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the registry observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. -#[tokio::test] -async fn e2e_intent_status_subscription_receives_polled_transitions() { - use crate::bindings::IntentStatus; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - assert!(supervisor.has_intent_status_subscribers()); - - // The registry watches the receipt of an accepted submission and polls - // the adapter's status export; each poll here observes a transition. - let registry = scripted_registry(ScriptedAdapter::new([ - IntentStatus::Pending, - IntentStatus::Fulfilled, - ])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // A venue outside the module's filter is not delivered. - let foreign = crate::bindings::IntentStatusUpdate { - venue: "other".to_owned(), - receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .expect("encode"), - }; - assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); -} - -/// The event-loop wiring: the poll task's stream drives the supervisor, -/// and the module's handler observably ran (its log line is retained). -#[tokio::test] -async fn e2e_intent_status_flows_through_the_event_loop() { - use std::time::Duration; - - use nexum_tasks::{TaskManager, TaskSet}; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let logs = components.logs.clone(); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let registry = scripted_registry(ScriptedAdapter::new([])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let stream = crate::runtime::event_loop::open_intent_status_stream( - registry, - Duration::from_millis(10), - &executor, - &mut tasks, - ); - crate::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - Some(stream), - tasks, - tokio::time::sleep(Duration::from_millis(300)), - ) - .await; - - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let page = logs.read(&runs[0].run, 0); - assert!( - page.records - .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", - page.records - .iter() - .map(|r| r.message.as_str()) - .collect::>(), - ); -} - -/// The first-train acceptance path, end to end over two real components: -/// the echo-client module submits through `videre:venue/client`, the host -/// registry forwards to the installed echo-venue adapter, and the module -/// receives the fulfilled `intent-status` the registry polls back. Proves -/// the intent core round-trips module -> host registry -> venue adapter -/// with no -/// scripted stand-ins on either side. -#[tokio::test] -async fn e2e_echo_module_registry_adapter_round_trip() { - use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; - use crate::host::component::ChainMethod; - use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; - - let (Some(adapter_wasm), Some(module_wasm)) = - (echo_venue_wasm_or_skip(), echo_client_wasm_or_skip()) - else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. The response body is - // discarded by the adapter, so any Ok value serves. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = crate::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(echo_venue_module_toml()), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(echo_client_module_toml()), - }], - ..Default::default() - }; - - let mut supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); - assert!(supervisor.has_intent_status_subscribers()); - - // A block drives the module's on_block, which submits to the echo venue - // through the shared registry; the registry watches the accepted receipt. - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!(supervisor.dispatch_block(block).await, 1); - - // Poll the registry the module submitted through and fan its transitions - // back to the module. echo-venue settles instantly, so the first poll - // reports a terminal status and the watch is pruned. - let registry = supervisor.venue_registry().expect("registry service"); - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); - assert_eq!( - body.status, - nexum_status_body::IntentStatus::Fulfilled, - "echo settles instantly", - ); - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!( - delivered, 1, - "one terminal status delivered to the subscriber" - ); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // The module observably completed the round trip: it quoted, it - // submitted, and it received the settled status from the echo venue. - let runs = logs.list_runs("echo-client"); - assert_eq!(runs.len(), 1, "one run recorded for echo-client"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert!( - messages - .iter() - .any(|m| m.contains("quoted") && m.contains("echo-venue")), - "module quoted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("intent status from venue echo-venue")), - "module received the settled status; records were: {messages:?}", - ); -} - /// A `ManualClock` override threads through `boot_single` onto the module /// store and is behaviour-neutral: the module boots, dispatches a block, and /// stays alive exactly as it does on the ambient clock. Locks the plumbing so @@ -1114,53 +592,6 @@ async fn boot_production_module( .expect("boot_single") } -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot once the cow extension -/// is wired at the composition root; drop the pairing and boot fails. The -/// positive direction (boots WITH the cow extension) is covered by the -/// extension crate that owns the backend. -#[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} - #[tokio::test] async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -2828,44 +2259,97 @@ fn chainlog_cursor_key_differs_by_each_input() { ); } -// ── venue-adapter boot ──────────────────────────────────────────────── +// ── provider boot gating ────────────────────────────────────────────── -/// The venue-adapter provider linker binds only the scoped transport -/// (chain, messaging, wasi base, allowlisted http) and withholds the -/// core-only interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. -#[tokio::test] -async fn provider_linker_assembles_with_scoped_transport() { - let engine = make_wasmtime_engine(); - crate::supervisor::build_provider_linker::( - &engine, - &crate::host::venue_registry::VenueAdapterKind, - ) - .expect("provider linker assembles"); +/// A stub extension registering the `acme-adapter` provider kind behind a +/// unit service, so the boot-gate tests exercise the generic kind loop +/// without a real provider component. +struct AcmeService; +impl crate::host::extension::HostService for AcmeService {} + +struct AcmeKind; + +#[async_trait::async_trait] +impl ProviderKind for AcmeKind { + fn kind(&self) -> &'static str { + "acme-adapter" + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, + _service: &Arc, + ) -> anyhow::Result { + Ok(Installed::Live) + } +} + +struct AcmeExtension; + +impl Extension for AcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(AcmeService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } } -/// The module-kind discriminator gates the adapter load path: an +/// The stub extension set registering the `acme-adapter` kind. +fn acme_extensions() -> Vec>> { + vec![Arc::new(AcmeExtension)] +} + +/// The module-kind discriminator gates the provider load path: an /// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the required -/// kind. +/// is rejected before instantiation with a message naming the registered +/// kinds. #[tokio::test] -async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { +async fn boot_rejects_provider_whose_manifest_is_an_event_module() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + "[module]\nname = \"acme\"\nkind = \"event-module\"\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("cow.wasm"), + path: dir.path().join("acme.wasm"), manifest: Some(manifest), http_allow: Vec::new(), messaging_topics: Vec::new(), @@ -2873,14 +2357,15 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("venue-adapter"), - "the kind gate names the required kind: {msg}", + msg.contains("acme-adapter"), + "the kind gate names the registered kinds: {msg}", ); } @@ -2890,8 +2375,10 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { async fn boot_rejects_an_unregistered_provider_kind() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); @@ -2908,54 +2395,58 @@ async fn boot_rejects_an_unregistered_provider_kind() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("an unregistered provider kind must be refused"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), "the refusal names the unknown spelling and the registered kinds: {msg}", ); } -/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// A registered kind clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the adapter load path +/// proves the discriminator routed the entry to the provider load path /// rather than rejecting it on kind. #[tokio::test] -async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { +async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ [capabilities]\nrequired = [\"chain\"]\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("missing-cow.wasm"), + path: dir.path().join("missing-acme.wasm"), manifest: Some(manifest), - http_allow: vec!["api.cow.fi".into()], - messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + http_allow: vec!["api.acme.example".into()], + messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], }], ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("absent adapter wasm must fail the compile step"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("absent provider wasm must fail the compile step"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("compile") || msg.contains("missing-cow"), + msg.contains("compile") || msg.contains("missing-acme"), "boot reached the compile step past the kind gate: {msg}", ); assert!( @@ -2964,163 +2455,53 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { ); } -// ── venue-adapter trap recovery ─────────────────────────────────────── - -/// Boot one flaky-venue adapter over the mock chain, whose head starts at -/// the fixture's poison sentinel. Returns the chain handle so the test can -/// let the venue recover. -async fn boot_flaky_venue( - adapter_wasm: PathBuf, - limits: crate::engine_config::ModuleLimits, -) -> ( - Supervisor, - crate::test_utils::MockChainProvider, -) { - use crate::engine_config::AdapterEntry; - use crate::host::component::ChainMethod; - - let chain = crate::test_utils::MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); - let components = crate::test_utils::mock_components_from( - chain.clone(), - crate::test_utils::MockStateStore::new(), - ); - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(fixture_module_toml( - "modules/fixtures/flaky-venue/module.toml", - )), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - limits, - ..Default::default() - }; - let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - (supervisor, chain) -} - -/// A test block that drives the dispatch-time sweeps. -fn sweep_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - } -} - -/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped -/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the -/// provider restart sweep reinstantiates it after backoff, after which a -/// submit succeeds again. +/// A module subscribing to an extension kind no wired extension declares +/// is refused at boot, preserving the unknown-kind fail-fast. #[tokio::test] -async fn e2e_trapped_adapter_is_swept_and_restarts() { - use crate::bindings::{SubmitOutcome, VenueError}; - use crate::host::component::ChainMethod; - use crate::host::venue_registry::VenueId; - - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { +async fn boot_refuses_an_undeclared_extension_subscription_kind() { + let Some(wasm) = example_wasm_or_skip() else { return; }; - let (mut supervisor, chain) = - boot_flaky_venue(wasm, crate::engine_config::ModuleLimits::default()).await; - assert_eq!(supervisor.adapter_count(), 1); - assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // The poison head detonates submit: the guest panic traps the store - // and the shared liveness drops. - let err = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect_err("the poison head traps the adapter"); - assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "the trap drops liveness" - ); + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" - // Temporarily dead resolves distinctly from never installed. - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert!(matches!( - registry - .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - - // The venue recovers; past the 1s backoff the dispatch-time sweep - // reinstalls the adapter on a fresh store. - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); - let outcome = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect("the recovered adapter accepts"); - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); -} +[capabilities] +required = ["logging"] -/// A crash-looping adapter is quarantined by the provider poison sweep: -/// at the threshold the restarts stop, and the venue stays dead past every -/// backoff until an operator intervenes. -#[tokio::test] -async fn e2e_crash_looping_adapter_is_poisoned() { - use crate::bindings::VenueError; - use crate::engine_config::PoisonLimitsSection; - use crate::host::venue_registry::VenueId; +[[subscription]] +kind = "acme-status" +"#, + ) + .expect("write manifest"); - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { - return; - }; - let limits = ModuleLimits { - poison: PoisonLimitsSection { - max_failures: Some(2), - window_secs: Some(600), - }, - ..ModuleLimits::default() - }; - // The chain head stays at the poison sentinel for the whole test: every - // submit after a restart traps again. - let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // Trap 1, then a successful restart past the 1s backoff. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); - - // Trap 2 crosses the 2-failure threshold: the sweep quarantines the - // adapter instead of scheduling another restart. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); - - // Past every backoff the poisoned adapter stays dead and unavailable. - tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "no restart while poisoned" + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await; + let err = result + .err() + .expect("an undeclared extension subscription kind must refuse boot"); + let msg = format!("{err:#}"); + assert!( + msg.contains("unknown event kind acme-status"), + "the refusal names the kind: {msg}", ); - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); } diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 559cf28e..4612f12b 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -1,7 +1,6 @@ //! Boot-order coverage for the cow-api extension: a module that imports //! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root. The negative direction (fails to boot without -//! the extension) lives in the runtime's own supervisor tests. +//! at the composition root, and fails to boot without it. //! //! These exercise the real wit-bindgen + supervisor path against pre-built //! wasm artefacts and skip gracefully when the artefact is absent. @@ -218,3 +217,48 @@ async fn e2e_stop_loss_block_dispatch() { assert_eq!(dispatched, 1); assert_eq!(supervisor.alive_count(), 1); } + +/// The boot-order invariant, exercised (not merely asserted in prose): +/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT +/// boot when the cow extension is absent from the linker AND the +/// capability registry. The paired linker-hook + capability-namespace +/// registration is what makes the same module boot in the tests above; +/// drop the pairing and boot fails. +#[tokio::test] +async fn twap_monitor_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("twap-monitor") else { + return; + }; + let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let engine = make_wasmtime_engine(); + // Core-only: no cow linker hook, no cow capability namespace. + let linker = build_linker::(&engine, &[]).expect("build_linker"); + let (_dir, store) = temp_local_store(); + let components = test_components(store).await; + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &[], + None, + ) + .await; + + let err = result + .err() + .expect("cow-importing module must not boot without the cow extension registered"); + // Pin the failure to its specific cause: twap-monitor declares the + // cow-api capability, which a core-only registry does not recognise + // (registering it is exactly what the cow extension does). Rules out + // an unrelated failure masquerading as the invariant. + let chain = format!("{err:#}"); + assert!( + chain.contains(r#"unknown capability "cow-api""#), + "expected the cow-api unknown-capability failure, got: {chain}", + ); +} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml index 63e99085..2b5ed588 100644 --- a/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } shepherd-cow-host = { path = "../shepherd-cow-host" } +videre-host = { path = "../videre-host" } anyhow.workspace = true tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 4022ff57..9a32d9fd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,12 +1,14 @@ //! The `shepherd` binary: the cow composition root. Binds the reference -//! lattice with the cow-api extension payload in the `Ext` slot and hands -//! it to the generic launcher; the engine itself stays cow-free. +//! lattice with the cow-api extension payload in the `Ext` slot, registers +//! the videre venue platform, and hands it all to the generic launcher; +//! the engine itself stays venue- and cow-free. #![cfg_attr(not(test), warn(unused_crate_dependencies))] use std::sync::Arc; use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, }; @@ -27,8 +29,8 @@ impl RuntimeTypes for ReferenceTypes { type Ext = ReferenceExt; } -/// The cow preset: reference backends, the cow-api extension, and the -/// Prometheus add-on. +/// The cow preset: reference backends, the videre venue platform, the +/// cow-api extension, and the Prometheus add-on. #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; @@ -49,8 +51,11 @@ impl Runtime for ShepherdRuntime { vec![Box::new(PrometheusAddOn)] } - fn extensions(&self) -> Vec>> { - vec![extension::()] + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![ + Arc::new(videre_host::platform(config)), + extension::(), + ] } } diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..a2d9ae30 --- /dev/null +++ b/crates/videre-host/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "videre-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." + +[lints] +workspace = true + +[dependencies] +# The runtime seam this crate plugs into; the only nexum crate edge. +nexum-runtime = { path = "../nexum-runtime" } +# Encoder for the opaque status body the host event stream carries; the +# registry lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } + +wasmtime.workspace = true +anyhow.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../nexum-tasks" } +tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..c6281016 --- /dev/null +++ b/crates/videre-host/src/bindings.rs @@ -0,0 +1,231 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. +//! +//! The shared `nexum:host` interfaces are reused from the runtime's +//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` +//! impls and the `fault` type an adapter sees are the very ones the core +//! host constructs. The `videre:types` and `videre:value-flow` types +//! generate in the adapter bindgen and the client bindgen remaps onto +//! them, so one Rust type serves the registry and the adapter face alike. +//! `PartialEq` is derived so the registry can compare a polled status +//! against the last delivered one. + +/// The provider face: the `videre:venue/venue-adapter` world. An adapter +/// imports only the scoped transport it needs (chain and messaging; +/// outbound HTTP is wasi:http, linked and allowlisted separately) and +/// exports the `videre:venue/adapter` face plus `init`. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + world: "videre:venue/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": nexum_runtime::bindings::nexum::host::types, + "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, + "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, + }, + additional_derives: [PartialEq], + }); +} + +pub use venue_adapter::VenueAdapter; + +/// The keeper-facing `videre:venue/client` import bound host-side. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module +/// are the very ones an adapter's `submit` produced. Async, because the +/// `Host` impl awaits the per-adapter mutex and the adapter's own async +/// guest calls. +mod client_host { + wasmtime::component::bindgen!({ + inline: " + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + imports: { default: async }, + with: { + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, + }, + }); +} + +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the videre extension's linker hook calls. +pub use client_host::videre::venue::client; +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:value-flow-smoke; + world smoke { + import videre:value-flow/types@0.1.0; + } + ", + path: ["../../wit/videre-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; + + let erc20 = Erc20 { + token: vec![0u8; 20], + }; + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins +/// the four function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod client_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:client-smoke; + world smoke { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + }); + + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, + }; + use videre::value_flow::types::{Asset, AssetAmount}; + + struct DummyClient; + + impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + + #[test] + fn identifiers_bind_unescaped() { + use videre::venue::client::Host; + + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Eip712; + + let header = IntentHeader { + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }; + assert!(header.wants.amount.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Fulfilled; + let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; + + let tx = UnsignedTx { + chain: 1, + to: Vec::new(), + value: Vec::new(), + data: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + + let _ = VenueError::UnknownVenue; + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::Unsupported; + let _ = VenueError::Denied(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::Timeout; + + let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/videre-host/src/client.rs similarity index 84% rename from crates/nexum-runtime/src/host/impls/venue_client.rs rename to crates/videre-host/src/client.rs index 8b86e8a4..2973b01e 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/videre-host/src/client.rs @@ -4,15 +4,18 @@ //! resolution, per-adapter serialisation, guard seam (advisory-only for //! now), and quota. The caller identity the registry meters against is this //! store's module namespace. No registry service means no venues, so every -//! call resolves to `unknown-venue`. +//! call resolves to `unknown-venue`. The `Host` trait is local to this +//! crate's bindgen, so implementing it for the runtime's `HostState` is +//! orphan-legal. use std::sync::Arc; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::state::HostState; + use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; -use crate::host::venue_registry::{VenueId, VenueRegistry}; +use crate::registry::{VenueId, VenueRegistry}; /// The registry published under the videre service namespace. fn registry(state: &HostState) -> Result, VenueError> { diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..fcd715bb --- /dev/null +++ b/crates/videre-host/src/lib.rs @@ -0,0 +1,145 @@ +//! The videre venue platform, packaged as one [`nexum_runtime`] +//! extension: the venue-adapter provider kind, the [`VenueRegistry`] +//! service, the advisory [`EgressGuard`] seam, and the keeper-facing +//! `videre:venue/client` interface. A composition root registers it all +//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod bindings; +mod client; +mod registry; + +use std::sync::Arc; +use std::time::Duration; + +use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, +}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::NamespaceCaps; +use tokio::sync::mpsc; +use wasmtime::component::{HasSelf, Linker}; + +pub use registry::{ + DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, +}; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS_KIND: &str = "intent-status"; + +/// Buffer for the status poll channel; small because the event loop +/// drains in real time. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. The single registration entrypoint. +pub fn platform(config: &EngineConfig) -> Videre { + Videre::from_registry( + VenueRegistryBuilder::new(config.limits.quota()) + .with_watch_limit(config.limits.watch()) + .build(), + ) +} + +/// The videre platform as one runtime extension. Registers the +/// `videre:venue/client` interface and its capability namespace on every +/// worker linker, publishes the [`VenueRegistry`] service, installs the +/// venue-adapter provider kind, and opens the status-poll event source. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] + /// or policy; [`platform`] covers the config-resolved default. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only the keeper-facing `client` interface is a capability; the + /// `videre:types` and `videre:value-flow` packages are type-only and + /// need no declaration. + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "videre:venue/", + ifaces: &["client"], + } + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + bindings::client::add_to_linker::, HasSelf>>(linker, |state| { + state + })?; + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::clone(&self.registry) as Arc) + } + + fn provider(&self) -> Option>> { + Some(Box::new(VenueAdapterKind)) + } + + fn subscriptions(&self) -> &'static [&'static str] { + &[INTENT_STATUS_KIND] + } + + /// The status poll source: on every cadence tick, poll each installed + /// adapter's status export through the shared registry and forward the + /// observed transitions. Opened only when a module subscribes and at + /// least one venue is installed. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + if !sources.subscribed.contains(INTENT_STATUS_KIND) { + return Ok(Vec::new()); + } + let registry = (*self.registry).clone(); + if registry.venue_count() == 0 { + return Ok(Vec::new()); + } + let cadence = sources.config.limits.status_poll_interval(); + let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); + sources.spawn(status_poll_task(registry, cadence, tx)); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(vec![Box::pin(stream)]) + } +} + +/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so +/// the engine's boot dispatch settles before the first poll; ends when +/// the event loop drops its receiver. +async fn status_poll_task( + registry: VenueRegistry, + cadence: Duration, + tx: mpsc::Sender, +) { + loop { + tokio::time::sleep(cadence).await; + for update in registry.poll_status_transitions().await { + let event = ExtensionEvent { + kind: INTENT_STATUS_KIND, + attrs: vec![("venue", update.venue.clone())], + event: Event::IntentStatus(update), + }; + if tx.send(event).await.is_err() { + // Receiver dropped -> engine shutting down. + return; + } + } + } +} diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/videre-host/src/registry.rs similarity index 93% rename from crates/nexum-runtime/src/host/venue_registry.rs rename to crates/videre-host/src/registry.rs index c53113b9..b7e961a0 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/videre-host/src/registry.rs @@ -31,50 +31,27 @@ use std::time::{Duration, Instant}; use anyhow::{Context, anyhow}; use async_trait::async_trait; use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; +use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; +use nexum_runtime::host::state::HostState; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn}; use wasmtime::Store; use wasmtime::component::HasSelf; +/// The registry-observed status transition delivered through the host +/// `event` variant, re-exported at the spelling the registry names. +pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; + use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, nexum, -}; -use crate::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; -use crate::host::component::RuntimeTypes; -use crate::host::extension::{ - HostService, Installed, ProviderInstance, ProviderKind, downcast_service, + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, }; -use crate::host::state::HostState; - -/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. -pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; -/// Default sliding window the per-caller submission budget is counted over. -pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); -/// Default cap on receipts under status watch at once. -pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default base window: the poll cadence a healthy venue refreshes within. -/// The give-up deadline is the derived `grace`, not this value directly. -pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); -/// Derived grace defaults to this many `expiry` windows, so a watch rides -/// out a venue outage of a couple of poll cadences before giving up. -pub const WATCH_GRACE_MULTIPLIER: u64 = 2; -/// Ceiling on the derived grace window, so a long `expiry` cannot stretch -/// the give-up deadline past a day. -pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); - -/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. -/// An explicit `grace_secs` in config overrides this. -const fn derive_grace(expiry: Duration) -> Duration { - let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); - let capped = if scaled < WATCH_GRACE_MAX.as_secs() { - scaled - } else { - WATCH_GRACE_MAX.as_secs() - }; - Duration::from_secs(capped) -} /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -106,73 +83,6 @@ impl fmt::Display for VenueId { } } -/// Per-caller submission quota. Both a forwarded submission and a charged -/// decode failure consume one unit; the window slides so a caller's budget -/// refills as old charges age out. -#[derive(Debug, Clone, Copy)] -pub struct SubmitQuota { - /// Maximum charges a single caller may accrue within `window`. - pub max_charges: u32, - /// Sliding window the charges are counted across. - pub window: Duration, -} - -impl SubmitQuota { - /// Pair a budget with the window it is counted over. - pub const fn new(max_charges: u32, window: Duration) -> Self { - Self { - max_charges, - window, - } - } -} - -impl Default for SubmitQuota { - fn default() -> Self { - Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) - } -} - -/// Bounds on the status-watch set. The cap bounds the per-cadence poll -/// fan-out; `grace` is the give-up deadline: how long a watch rides out an -/// unreachable venue before it is evicted unreported. `expiry` is the base -/// window `grace` derives from. -#[derive(Debug, Clone, Copy)] -pub struct WatchLimit { - /// Maximum receipts under status watch at once. - pub max_entries: usize, - /// Base window a healthy venue refreshes the deadline within. - pub expiry: Duration, - /// Give-up deadline: how long a watch survives an unreachable venue - /// before it is evicted unreported. A reachable poll (the venue - /// answered) resets it; a resolve failure or an errored poll rides out - /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. - pub grace: Duration, -} - -impl WatchLimit { - /// Pair a cap with the base expiry; `grace` derives from `expiry`. - pub const fn new(max_entries: usize, expiry: Duration) -> Self { - Self::with_grace(max_entries, expiry, derive_grace(expiry)) - } - - /// As [`new`](Self::new) but with an explicit `grace` window, the path - /// a configured `grace_secs` takes. - pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { - Self { - max_entries, - expiry, - grace, - } - } -} - -impl Default for WatchLimit { - fn default() -> Self { - Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) - } -} - /// The guard interposition seam. The registry runs this on the /// adapter-derived header after `derive-header` and before `submit`. /// @@ -758,9 +668,8 @@ impl VenueRegistry { } /// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` -/// component and installs its actor in the venue registry. Registered by -/// the boot path while the registry lives in-core; the videre extension -/// takes it over. +/// component and installs its actor in the venue registry. Registered +/// through the videre extension's provider slot. pub struct VenueAdapterKind; impl VenueAdapterKind { @@ -815,8 +724,7 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + fault = ?e, "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); @@ -939,6 +847,7 @@ mod tests { use crate::bindings::value_flow::{Asset, AssetAmount}; use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; + use nexum_runtime::engine_config::WATCH_GRACE_MAX; use super::*; @@ -1510,7 +1419,7 @@ mod tests { #[test] fn zero_watch_cap_saturates_to_one() { let registry = VenueRegistryBuilder::new(SubmitQuota::default()) - .with_watch_limit(WatchLimit::new(0, DEFAULT_WATCH_EXPIRY)) + .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) .build(); assert_eq!(registry.inner.watch_limit.max_entries, 1); } diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..05bc062e --- /dev/null +++ b/crates/videre-host/tests/platform.rs @@ -0,0 +1,714 @@ +//! E2E coverage for the videre platform over the generic runtime seam: +//! the venue-adapter provider boot, the client -> registry -> adapter +//! round trip, the status-poll event source, and the trap-to-recovery +//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when +//! an artefact is absent. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, +}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::CapabilityRegistry; +use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; +use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; +use videre_host::bindings::{ + IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, +}; +use videre_host::{ + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, +}; +use wasmtime::component::Linker; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS: &str = "intent-status"; + +// ── fixtures + assembly ─────────────────────────────────────────────── + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir, +/// or `None` with a skip message when it is not built. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None + } +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +/// The platform under test plus the extension slice the boot paths take. +/// The concrete handle stays available for the event-source calls. +fn videre_assembly(videre: &Arc) -> Vec>> { + vec![Arc::clone(videre) as Arc>] +} + +fn make_linker( + engine: &wasmtime::Engine, + extensions: &[Arc>], +) -> Linker> { + build_linker::(engine, extensions).expect("build_linker") +} + +/// The registry the booted supervisor publishes under the videre +/// namespace. +fn registry_of(supervisor: &Supervisor) -> Arc { + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service") +} + +/// A test block that drives dispatch and the dispatch-time sweeps. +fn block(chain_id: u64) -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + } +} + +/// Wrap a polled transition as the extension event the platform emits. +fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + ExtensionEvent { + kind: INTENT_STATUS, + attrs: vec![("venue", update.venue.clone())], + event: nexum::host::types::Event::IntentStatus(update), + } +} + +// ── world contract ──────────────────────────────────────────────────── + +/// The per-component venue-adapter world contract: an adapter built +/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// transport its manifest declares (`chain`), by construction of the +/// emitted world. The venue side never depended on toolchain elision; +/// this pins that it does not regress to it. +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn provider_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + build_provider_linker::(&engine, &VenueAdapterKind) + .expect("provider linker assembles"); +} + +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the registry: accepts every submission +/// with a fixed receipt and serves statuses front-first from a script; +/// once drained, every further call reports `open`. +struct ScriptedAdapter { + statuses: VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +fn native(bytes: Vec) -> value_flow::AssetAmount { + value_flow::AssetAmount { + asset: value_flow::Asset::Native, + amount: bytes, + } +} + +impl VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(IntentHeader { + gives: native(vec![1]), + wants: native(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: videre_host::bindings::AuthScheme::Eip712, + }) + }) + } + + fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Quotation { + gives: native(vec![1]), + wants: native(Vec::new()), + fee: native(Vec::new()), + valid_until_ms: 1_700_000_000_000, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// A registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(Default::default()).build(); + registry + .install( + VenueId::from("cow"), + nexum_runtime::host::actor::Liveness::default(), + adapter, + ) + .expect("install scripted adapter"); + registry +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +/// Boot the example module against the given videre platform. +async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let limits = ModuleLimits::default(); + Supervisor::boot_single( + &engine, + &linker, + wasm, + Some(manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single") +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// the transitions the registry observed by polling the adapter's status +/// export, and a transition from a venue outside its filter is not +/// delivered. +#[tokio::test] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Fulfilled, + ])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // The registry watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // A venue outside the module's filter is not delivered. + let foreign = videre_host::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(foreign)) + .await, + 0 + ); +} + +/// The event-loop wiring, through the real seam: the platform's `events` +/// source opens against the booted service map, its poll task drives the +/// supervisor, and the module's handler observably ran (its log line is +/// retained). +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use nexum_tasks::{TaskManager, TaskSet}; + + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + // A fast cadence so the 300 ms window sees the first poll. + let mut config = EngineConfig::default(); + config.limits.status_poll.interval_ms = Some(10); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subscribed = supervisor.extension_subscription_kinds(); + let streams = { + let mut sources = EventSources::new( + &config, + supervisor.services(), + &subscribed, + &executor, + &mut tasks, + ); + Extension::::events(&*videre, &mut sources).expect("open event source") + }; + assert_eq!(streams.len(), 1, "one status-poll stream opened"); + + nexum_runtime::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + streams, + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .await; + + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + let runs = logs.list_runs("example"); + assert_eq!(runs.len(), 1, "one run recorded for the example module"); + let page = logs.read(&runs[0].run, 0); + assert!( + page.records + .iter() + .any(|r| r.message.contains("intent status update from venue cow")), + "the module's on_intent_status handler ran; records were: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// With no subscriber (or no installed venue) the platform opens no +/// event source. +#[tokio::test] +async fn event_source_stays_closed_without_subscribers_or_venues() { + use nexum_tasks::{TaskManager, TaskSet}; + + let config = EngineConfig::default(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let services = nexum_runtime::host::extension::HostServices::default(); + + // A venue is installed but nothing subscribes. + let with_venue = Arc::new(Videre::from_registry(scripted_registry( + ScriptedAdapter::new([]), + ))); + let empty = std::collections::BTreeSet::new(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); + let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no subscriber, no stream"); + + // A subscriber exists but no venue is installed. + let no_venue = Arc::new(platform(&config)); + let subscribed: std::collections::BTreeSet = + std::iter::once(INTENT_STATUS.to_owned()).collect(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); + let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no venue, no stream"); +} + +// ── echo round trip ─────────────────────────────────────────────────── + +/// The acceptance path, end to end over two real components: the +/// echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no scripted stand-ins on either side. +#[tokio::test] +async fn e2e_echo_module_registry_adapter_round_trip() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared registry; the registry watches the accepted receipt. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // Poll the registry the module submitted through and fan its transitions + // back to the module. echo-venue settles instantly, so the first poll + // reports a terminal status and the watch is pruned. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", + ); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: ModuleLimits, +) -> (Supervisor, MockChainProvider) { + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = + nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/fixtures/flaky-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(Duration::from_millis(1_500)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +} From 35a99db765815a7680bcbceacadc964917060357 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 05:16:15 +0000 Subject: [PATCH 048/139] venue: install-time body-version handshake (#448) feat: refuse mismatched body-schema versions at install --- Cargo.lock | 2 + crates/nexum-macros/src/lib.rs | 22 +- crates/nexum-runtime/src/host/extension.rs | 50 +++- crates/nexum-runtime/src/manifest/load.rs | 37 +++ crates/nexum-runtime/src/manifest/mod.rs | 1 + crates/nexum-runtime/src/manifest/types.rs | 10 + crates/nexum-runtime/src/supervisor.rs | 86 +++++- crates/nexum-runtime/src/supervisor/tests.rs | 35 +++ crates/nexum-venue-sdk/src/adapter.rs | 14 +- crates/videre-host/Cargo.toml | 2 + crates/videre-host/src/handshake.rs | 285 +++++++++++++++++++ crates/videre-host/src/lib.rs | 26 +- crates/videre-host/src/registry.rs | 13 + crates/videre-host/tests/platform.rs | 126 ++++++++ modules/examples/echo-client/module.toml | 6 + modules/examples/echo-venue/module.toml | 5 + modules/examples/echo-venue/src/lib.rs | 6 + wit/videre-venue/venue.wit | 4 + 18 files changed, 714 insertions(+), 16 deletions(-) create mode 100644 crates/videre-host/src/handshake.rs diff --git a/Cargo.lock b/Cargo.lock index 26dbfa62..99a57b72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6050,9 +6050,11 @@ dependencies = [ "nexum-runtime", "nexum-status-body", "nexum-tasks", + "serde", "tempfile", "thiserror 2.0.18", "tokio", + "toml 1.1.2+spec-1.1.0", "tracing", "wasmtime", ] diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 72e243a0..52f92d95 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -281,7 +281,8 @@ const VENUE_EXPORTS: [&str; 5] = ["derive_header", "quote", "submit", "status", /// Apply to an inherent `impl` block whose associated functions are the /// adapter face: `derive_header`, `quote`, `submit`, `status`, `cancel` /// (all required, from `videre:venue/adapter`), plus an optional `init` -/// (absent means a no-op). Each takes and returns the per-cdylib +/// (absent means a no-op) and an optional `body_versions` (absent +/// declares none). Each takes and returns the per-cdylib /// wit-bindgen payloads for its signature. The macro reads the crate's /// `module.toml`, synthesizes a per-component world exporting the /// adapter face and importing exactly the manifest's declared scoped @@ -382,6 +383,23 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { }; let inline_world = &venue_world.wit; + // `body-versions` is a required adapter export; when the adapter + // omits it, declare none. Install asserts the export equals the + // manifest `[venue] body_versions` set. + let body_versions_impl = if defines("body_versions") { + quote! { + fn body_versions() -> ::std::vec::Vec { + <#self_ty>::body_versions() + } + } + } else { + quote! { + fn body_versions() -> ::std::vec::Vec { + ::std::vec::Vec::new() + } + } + }; + // `init` is a required world export; when the adapter omits it the // config is bound but unused, so drop it to stay warning-clean. let init_impl = if defines("init") { @@ -424,6 +442,8 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { } impl exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + #body_versions_impl + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result< diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index dfa05f7f..9d097fb0 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,6 +1,7 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, an optional provider kind, and optional event sources. +//! service, an optional provider kind, optional event sources, and +//! optional install predicates over the manifest sections it claims. //! Assembled at the composition root and threaded into every module //! linker. @@ -20,7 +21,7 @@ use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; -use crate::manifest::NamespaceCaps; +use crate::manifest::{ExtensionSections, NamespaceCaps}; /// One runtime extension. A module that imports an extension interface /// boots only if the linker entry AND the capability namespace are both @@ -50,6 +51,32 @@ pub trait Extension: Send + Sync + 'static { None } + /// Manifest section names this extension claims. A non-core section + /// no wired extension claims is refused at boot. + fn manifest_sections(&self) -> &'static [&'static str] { + &[] + } + + /// Admit one provider at install, over its opaque manifest sections. + /// Runs before compilation; an `Err` refuses the install fail-fast. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let _ = (provider, sections); + Ok(()) + } + + /// Admit one worker at install, over its own and the loaded + /// providers' opaque manifest sections. Runs before compilation; an + /// `Err` refuses the install fail-fast. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + let _ = (worker, sections, providers); + Ok(()) + } + /// Manifest subscription kinds this extension's event sources emit. /// A `[[subscription]]` entry of any other non-core kind is refused /// at boot. @@ -151,7 +178,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// One provider instance ready to install: the compiled component, the /// linker the kind's [`ProviderKind::link`] populated, the supervised -/// store, the manifest `[config]`, and the per-call fuel budget. +/// store, the manifest `[config]` and extension sections, and the +/// per-call fuel budget. pub struct ProviderInstance<'a, T: RuntimeTypes> { /// Compiled provider component. pub component: &'a Component, @@ -161,6 +189,9 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub store: Store>, /// Manifest `[config]` handed to the guest `init`. pub config: Vec<(String, String)>, + /// The provider's extension-owned manifest sections, so a kind can + /// hold the instance to its manifest claims at install. + pub sections: &'a ExtensionSections, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, /// Shared liveness the installed instance reports traps on and the @@ -168,6 +199,19 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub liveness: Liveness, } +/// One loaded provider as [`Extension::admit_worker`] sees it: its +/// namespace, registered kind, and opaque manifest sections. Manifest +/// data only, so the predicate is static and liveness-independent. +#[derive(Clone, Debug)] +pub struct ProviderManifest { + /// The provider's namespace: its manifest name. + pub name: String, + /// Registered kind spelling. + pub kind: &'static str, + /// The provider's extension-owned manifest sections. + pub sections: ExtensionSections, +} + /// Outcome of one provider install. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Installed { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 132dd838..a7a70e98 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -232,6 +232,43 @@ scope = 7 assert!(err.to_string().contains("must be a string"), "{err}"); } + /// A non-core top-level section parses into the opaque extension + /// map: the runtime carries it verbatim and ascribes it no meaning. + #[test] + fn load_parses_extension_sections_opaquely() { + let toml = r#" +[module] +name = "keeper" + +[venue] +body_version = 2 + +[[subscription]] +kind = "block" +chain_id = 1 +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert_eq!(manifest.module.name, "keeper"); + assert_eq!(manifest.subscriptions.len(), 1); + assert_eq!(manifest.extensions.len(), 1); + let venue = manifest.extensions.get("venue").expect("venue section"); + assert_eq!( + venue.get("body_version").and_then(toml::Value::as_integer), + Some(2), + ); + } + + /// A manifest without extension sections carries an empty map. + #[test] + fn load_defaults_to_no_extension_sections() { + let toml = r#" +[module] +name = "plain" +"#; + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(manifest.extensions.is_empty()); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index e93e179b..da7e0c06 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,6 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; +pub use types::ExtensionSections; pub(crate) use types::{ComponentKind, LoadedManifest, ResourceSection, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index c13ca680..f6fbc95c 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -39,8 +39,18 @@ pub struct Manifest { /// parsed and ignored (deferred to 0.3). #[serde(default, rename = "subscription")] pub subscriptions: Vec, + /// Extension-owned sections: every non-core top-level key, parsed + /// opaquely. The runtime ascribes them no meaning; it routes them + /// to the wired extensions' install predicates, and a section no + /// extension claims is refused at boot. + #[serde(flatten)] + pub extensions: ExtensionSections, } +/// Extension-owned manifest sections, keyed by top-level name. Opaque +/// to the runtime; each claiming extension parses its own. +pub type ExtensionSections = BTreeMap; + /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 102dd196..e7c49ecf 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -53,6 +53,7 @@ use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, + ProviderManifest, }; use crate::host::http::HttpGate; #[cfg(test)] @@ -269,6 +270,9 @@ struct LoadedProvider { name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, + /// Extension-owned manifest sections, as the worker install + /// predicates see them. + sections: manifest::ExtensionSections, /// Cached for restart, like a module's. component: Component, /// Cached for restart: the manifest `[config]` handed to `init`. @@ -337,6 +341,27 @@ fn extension_subscription_vocabulary( .collect() } +/// Refuse a manifest section no wired extension claims, so a typo'd +/// section fails loudly instead of silently skipping its extension's +/// install predicate. +fn enforce_extension_sections( + owner: &str, + sections: &manifest::ExtensionSections, + extensions: &[Arc>], +) -> Result<()> { + for key in sections.keys() { + let claimed = extensions + .iter() + .any(|ext| ext.manifest_sections().contains(&key.as_str())); + if !claimed { + return Err(anyhow!( + "{owner} declares manifest section [{key}]; no wired extension claims it" + )); + } + } + Ok(()) +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -385,11 +410,22 @@ impl Supervisor { &provider_registry, clocks.as_ref(), &kinds, + extensions, ) .await .with_context(|| format!("load provider {}", entry.path.display()))?; providers.push(loaded); } + // The loaded providers' manifests, as the worker install + // predicates see them. + let provider_manifests: Vec = providers + .iter() + .map(|p| ProviderManifest { + name: p.name.clone(), + kind: p.kind, + sections: p.sections.clone(), + }) + .collect(); let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); @@ -404,6 +440,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &provider_manifests, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -467,6 +505,8 @@ impl Supervisor { clocks.as_ref(), services.clone(), &extension_kinds, + extensions, + &[], ) .await?; Ok(Self { @@ -579,6 +619,8 @@ impl Supervisor { clocks: Option<&WasiClockOverride>, services: HostServices, extension_kinds: &BTreeSet<&'static str>, + extensions: &[Arc>], + provider_manifests: &[ProviderManifest], ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -594,6 +636,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "module".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the worker against the loaded providers' manifests. + let sections = &loaded_manifest.manifest.extensions; + enforce_extension_sections(&module_namespace, sections, extensions)?; + for ext in extensions { + ext.admit_worker(&module_namespace, sections, provider_manifests) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // Compile + instantiate. info!(component = %entry.path.display(), "compiling component"); @@ -608,11 +665,6 @@ impl Supervisor { registry, ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let module_namespace = if loaded_manifest.manifest.module.name.is_empty() { - "module".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; // Layer the manifest's `[module.resources]` over the engine `[limits]` // defaults: an unset override field keeps the engine default. let ResolvedLimits { @@ -761,6 +813,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, kinds: &ProviderKinds, + extensions: &[Arc>], ) -> Result { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -776,6 +829,21 @@ impl Supervisor { manifest::fallback_manifest() } }; + let namespace = if loaded_manifest.manifest.module.name.is_empty() { + "provider".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + + // Run the extension install predicates before any compile cost: + // every section must be claimed, and every claiming extension + // must admit the provider's own sections. + let sections = loaded_manifest.manifest.extensions.clone(); + enforce_extension_sections(&namespace, §ions, extensions)?; + for ext in extensions { + ext.admit_provider(&namespace, §ions) + .with_context(|| format!("install refused for {}", entry.path.display()))?; + } // The manifest kind is the discriminator: an [[adapters]] entry // must name a registered provider kind, caught here before @@ -822,11 +890,6 @@ impl Supervisor { ) .with_context(|| format!("capability violation in {}", entry.path.display()))?; - let namespace = if loaded_manifest.manifest.module.name.is_empty() { - "provider".to_owned() - } else { - loaded_manifest.manifest.module.name.clone() - }; info!( provider = %namespace, kind = kind.kind(), @@ -870,6 +933,7 @@ impl Supervisor { linker: &linker, store, config: config.clone(), + sections: §ions, fuel_per_call: limits_cfg.fuel(), liveness: liveness.clone(), }, @@ -883,6 +947,7 @@ impl Supervisor { Ok(LoadedProvider { name: namespace, kind: kind.kind(), + sections, component, init_config: config, http_allow: entry.http_allow.clone(), @@ -1626,6 +1691,7 @@ impl Supervisor { linker: &linker, store, config: provider.init_config.clone(), + sections: &provider.sections, fuel_per_call: provider.fuel_per_call, liveness: provider.liveness.clone(), }, diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 6396ffe0..2617869f 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -28,6 +28,41 @@ fn manifest_resource_overrides_take_effect_and_are_field_local() { assert_eq!(resolved.state_bytes, 2048); } +/// A manifest section a wired extension claims passes; an unclaimed one +/// (a typo, or a section for an unwired extension) is refused. +#[test] +fn extension_sections_must_be_claimed() { + struct Claiming; + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + "acme" + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn manifest_sections(&self) -> &'static [&'static str] { + &["venue"] + } + } + let extensions: Vec>> = vec![Arc::new(Claiming)]; + + let mut sections = manifest::ExtensionSections::new(); + sections.insert("venue".into(), toml::Value::Boolean(true)); + enforce_extension_sections("keeper", §ions, &extensions).expect("claimed section"); + + sections.insert("venu".into(), toml::Value::Boolean(true)); + let err = enforce_extension_sections("keeper", §ions, &extensions) + .expect_err("unclaimed section"); + assert!(err.to_string().contains("[venu]"), "{err}"); + assert!(err.to_string().contains("keeper"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs index 1a7195b9..25364863 100644 --- a/crates/nexum-venue-sdk/src/adapter.rs +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -2,7 +2,8 @@ //! it into the component's `venue-adapter` world surface. //! //! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the five intent functions from `videre:venue/adapter`. +//! world itself, the intent functions and the body-version declaration +//! from `videre:venue/adapter`. //! Functions are associated (no `self`): the component model instantiates //! one adapter per venue and calls exports statically, so adapter state //! lives in the adapter's own statics, exactly as in event modules. @@ -21,6 +22,13 @@ pub trait VenueAdapter { /// boots both component kinds through the same machinery. fn init(config: Config) -> Result<(), Fault>; + /// Body-schema versions this adapter decodes. Install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to + /// declaring none. + fn body_versions() -> Vec { + Vec::new() + } + /// Project an opaque intent body onto the stable header guard /// policy runs on. Must be a pure derivation: no transport, no side /// effects, so the host can inspect a header before deciding to @@ -70,6 +78,10 @@ macro_rules! export_venue_adapter { impl $crate::bindings::exports::videre::venue::adapter::Guest for __NexumVenueAdapterExport { + fn body_versions() -> ::std::vec::Vec { + <$adapter as $crate::VenueAdapter>::body_versions() + } + fn derive_header( body: ::std::vec::Vec, ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index a2d9ae30..8db90362 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -21,7 +21,9 @@ anyhow.workspace = true thiserror.workspace = true async-trait.workspace = true futures.workspace = true +serde.workspace = true tokio.workspace = true +toml.workspace = true tracing.workspace = true [dev-dependencies] diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs new file mode 100644 index 00000000..a1aa23d7 --- /dev/null +++ b/crates/videre-host/src/handshake.rs @@ -0,0 +1,285 @@ +//! Install-time body-version handshake over the `[venue]` manifest +//! section: a keeper declares the one body-schema version it encodes, +//! an adapter the set it decodes, and a keeper boots only when every +//! installed venue adapter decodes its version, since any of them is a +//! legal runtime submit target. + +use std::collections::BTreeSet; + +use anyhow::{anyhow, bail}; +use nexum_runtime::host::extension::ProviderManifest; +use nexum_runtime::manifest::ExtensionSections; +use serde::Deserialize; +use tracing::error; + +use crate::registry::VenueAdapterKind; + +/// The manifest section the videre platform claims. +pub(crate) const SECTION: &str = "venue"; + +/// The claimed-section list handed to the runtime. +pub(crate) const SECTIONS: &[&str] = &[SECTION]; + +/// Keeper-side `[venue]`: the one body-schema version it encodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KeeperSection { + body_version: u32, +} + +/// Adapter-side `[venue]`: the body-schema versions it decodes. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdapterSection { + body_versions: BTreeSet, +} + +/// Parse one `[venue]` value as `S`, tagging failures with the owner. +fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyhow::Result { + value + .clone() + .try_into() + .map_err(|e| anyhow!("{owner} [venue]: {e}")) +} + +/// Admit one provider: a `[venue]` section, when present, must be the +/// adapter shape with a non-empty version set. +pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let section: AdapterSection = parse(provider, value)?; + if section.body_versions.is_empty() { + bail!("{provider} [venue]: body_versions must not be empty"); + } + Ok(()) +} + +/// An adapter's declared decode set: its `[venue] body_versions`, empty +/// when the section is absent. +pub(crate) fn declared_versions( + provider: &str, + sections: &ExtensionSections, +) -> anyhow::Result> { + let Some(value) = sections.get(SECTION) else { + return Ok(BTreeSet::new()); + }; + let section: AdapterSection = parse(provider, value)?; + Ok(section.body_versions) +} + +/// Assert an adapter's `body-versions()` export equals its manifest +/// claim, refusing the install on divergence so the two sources of the +/// decode set cannot drift. +pub(crate) fn verify_exported_versions( + provider: &str, + declared: &BTreeSet, + exported: Vec, +) -> anyhow::Result<()> { + let exported: BTreeSet = exported.into_iter().collect(); + if exported != *declared { + bail!( + "{provider} exports body versions {exported:?}; the manifest [venue] \ + body_versions declares {declared:?}" + ); + } + Ok(()) +} + +/// The membership install predicate: a worker declaring `[venue] +/// body_version` is admitted only when every installed venue adapter's +/// `[venue] body_versions` set contains that version. Every installed +/// venue is a legal runtime submit target, so one non-decoding adapter +/// refuses the keeper. +pub(crate) fn admit_worker( + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], +) -> anyhow::Result<()> { + let Some(value) = sections.get(SECTION) else { + return Ok(()); + }; + let KeeperSection { body_version } = parse(worker, value)?; + let adapters: Vec<&ProviderManifest> = providers + .iter() + .filter(|p| p.kind == VenueAdapterKind::KIND) + .collect(); + if adapters.is_empty() { + return Err(refuse( + worker, + body_version, + "no venue adapter declares [venue] body_versions", + )); + } + for provider in adapters { + let Some(value) = provider.sections.get(SECTION) else { + return Err(refuse( + worker, + body_version, + &format!("{} declares no [venue] body_versions", provider.name), + )); + }; + let section: AdapterSection = parse(&provider.name, value)?; + if !section.body_versions.contains(&body_version) { + return Err(refuse( + worker, + body_version, + &format!("{} decodes {:?}", provider.name, section.body_versions), + )); + } + } + Ok(()) +} + +/// Log and build the refusal for one keeper/adapter pairing. +fn refuse(worker: &str, body_version: u32, decoded: &str) -> anyhow::Error { + error!( + keeper = %worker, + body_version, + %decoded, + "body-version handshake refused the keeper/adapter pair", + ); + anyhow!("keeper {worker} encodes body version {body_version}; {decoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sections(toml: &str) -> ExtensionSections { + let table: toml::Table = toml.parse().expect("parse"); + table.into_iter().collect() + } + + fn adapter(name: &str, toml: &str) -> ProviderManifest { + ProviderManifest { + name: name.to_owned(), + kind: VenueAdapterKind::KIND, + sections: sections(toml), + } + } + + /// A keeper whose version every installed adapter decodes is admitted. + #[test] + fn matching_pair_is_admitted() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [2, 3]"), + ]; + admit_worker("keeper", &keeper, &adapters).expect("admitted"); + } + + /// A keeper whose version no adapter decodes is refused, and the + /// refusal names the version and the declared set. + #[test] + fn mismatched_pair_is_refused() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [adapter("cow", "[venue]\nbody_versions = [1]")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("cow decodes {1}"), "{msg}"); + } + + /// Membership is a conjunction over the installed adapters: one + /// non-decoding adapter refuses the keeper even when another decodes + /// its version, since either is a legal runtime submit target. + #[test] + fn one_non_decoding_adapter_refuses_the_keeper() { + let keeper = sections("[venue]\nbody_version = 2"); + let adapters = [ + adapter("cow", "[venue]\nbody_versions = [1, 2]"), + adapter("uni", "[venue]\nbody_versions = [1]"), + ]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + let msg = err.to_string(); + assert!(msg.contains("body version 2"), "{msg}"); + assert!(msg.contains("uni decodes {1}"), "{msg}"); + } + + /// A declaring keeper with no installed venue adapter is refused. + #[test] + fn undeclared_adapters_refuse_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let err = admit_worker("keeper", &keeper, &[]).expect_err("refused"); + assert!(err.to_string().contains("no venue adapter declares")); + } + + /// An installed adapter without a `[venue]` section refuses a + /// declaring keeper: its decode set is undeclared, not universal. + #[test] + fn a_section_less_adapter_refuses_a_declaring_keeper() { + let keeper = sections("[venue]\nbody_version = 1"); + let adapters = [adapter("cow", "")]; + let err = admit_worker("keeper", &keeper, &adapters).expect_err("refused"); + assert!(err.to_string().contains("cow declares no [venue]")); + } + + /// A provider of another kind never satisfies the membership check. + #[test] + fn other_provider_kinds_are_ignored() { + let keeper = sections("[venue]\nbody_version = 1"); + let mut other = adapter("oracle", "[venue]\nbody_versions = [1]"); + other.kind = "price-oracle"; + admit_worker("keeper", &keeper, &[other]).expect_err("refused"); + } + + /// Workers and providers without a `[venue]` section are admitted. + #[test] + fn undeclared_sections_are_admitted() { + admit_worker("keeper", &ExtensionSections::new(), &[]).expect("worker admitted"); + admit_provider("venue", &ExtensionSections::new()).expect("provider admitted"); + } + + /// The wrong-side spelling fails loudly on both faces. + #[test] + fn wrong_side_spelling_is_refused() { + let keeper = sections("[venue]\nbody_versions = [1]"); + admit_worker("keeper", &keeper, &[]).expect_err("keeper with the adapter key"); + + let venue = sections("[venue]\nbody_version = 1"); + admit_provider("venue", &venue).expect_err("adapter with the keeper key"); + } + + /// An adapter declaring an empty decode set is refused at install. + #[test] + fn empty_adapter_set_is_refused() { + let venue = sections("[venue]\nbody_versions = []"); + let err = admit_provider("venue", &venue).expect_err("refused"); + assert!(err.to_string().contains("must not be empty")); + } + + /// A well-formed adapter declaration is admitted. + #[test] + fn adapter_declaration_is_admitted() { + let venue = sections("[venue]\nbody_versions = [1, 2]"); + admit_provider("venue", &venue).expect("admitted"); + } + + /// The exported set must equal the manifest claim exactly; either + /// direction of drift refuses the install. + #[test] + fn exported_versions_must_equal_the_manifest_claim() { + let declared = declared_versions("venue", §ions("[venue]\nbody_versions = [1, 2]")) + .expect("declared"); + verify_exported_versions("venue", &declared, vec![2, 1]).expect("equal sets"); + + let err = verify_exported_versions("venue", &declared, vec![1]).expect_err("narrower"); + assert!( + err.to_string().contains("exports body versions {1}"), + "{err}" + ); + verify_exported_versions("venue", &declared, vec![1, 2, 3]).expect_err("wider"); + } + + /// A section-less adapter must export an empty set: an undeclared + /// manifest with a declaring export is drift, not a default. + #[test] + fn a_section_less_adapter_must_export_no_versions() { + let declared = declared_versions("venue", &ExtensionSections::new()).expect("declared"); + assert!(declared.is_empty()); + verify_exported_versions("venue", &declared, Vec::new()).expect("both undeclared"); + verify_exported_versions("venue", &declared, vec![1]).expect_err("export-only drift"); + } +} diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index fcd715bb..d228e27a 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -8,6 +8,7 @@ pub mod bindings; mod client; +mod handshake; mod registry; use std::sync::Arc; @@ -18,9 +19,10 @@ use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::RuntimeTypes; use nexum_runtime::host::extension::{ EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, + ProviderManifest, }; use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::NamespaceCaps; +use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; use tokio::sync::mpsc; use wasmtime::component::{HasSelf, Linker}; @@ -94,6 +96,28 @@ impl Extension for Videre { Some(Box::new(VenueAdapterKind)) } + fn manifest_sections(&self) -> &'static [&'static str] { + handshake::SECTIONS + } + + /// Adapter-side handshake shape check: a `[venue]` section must + /// declare a non-empty `body_versions` set. + fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { + handshake::admit_provider(provider, sections) + } + + /// The body-version membership predicate: a keeper declaring + /// `[venue] body_version` boots only when every installed venue + /// adapter's `[venue] body_versions` set contains it. + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + handshake::admit_worker(worker, sections, providers) + } + fn subscriptions(&self) -> &'static [&'static str] { &[INTENT_STATUS_KIND] } diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index b7e961a0..d4228cbe 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -706,6 +706,7 @@ impl ProviderKind for VenueAdapterKind { linker, mut store, config, + sections, fuel_per_call, liveness, } = instance; @@ -715,6 +716,18 @@ impl ProviderKind for VenueAdapterKind { .context("instantiate adapter")?; // The venue id is the adapter's namespace: its manifest name. let venue_id = VenueId::from(&*store.data().run.module); + // The manifest `[venue] body_versions` is the install-time + // authority the keeper handshake reads; the export must agree, + // so a manifest claiming versions the code does not decode never + // installs. + let declared = crate::handshake::declared_versions(venue_id.as_str(), sections)?; + let exported = bindings + .videre_venue_adapter() + .call_body_versions(&mut store) + .await + .map_err(anyhow::Error::from) + .context("read adapter body-versions")?; + crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; match bindings .call_init(&mut store, &config) .await diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 05bc062e..462eae22 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -580,6 +580,132 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); } +/// The body-version handshake refuses a mismatched pair: an adapter +/// decoding only v1 against a keeper encoding v2 fails the boot at the +/// keeper's install, before instantiation, naming both sides' versions. +#[tokio::test] +async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1] +"#, + ) + .expect("write adapter manifest"); + let keeper_manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &keeper_manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[venue] +body_version = 2 +"#, + ) + .expect("write keeper manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(keeper_manifest), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("mismatched pair must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("body version 2"), "{chain}"); + assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); +} + +/// An adapter whose manifest claims versions its code does not decode +/// fails its own install: the `body-versions()` export must equal the +/// manifest `[venue] body_versions` set. +#[tokio::test] +async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { + let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + + let dir = tempfile::tempdir().expect("tempdir"); + let adapter_manifest = dir.path().join("echo-venue.toml"); + std::fs::write( + &adapter_manifest, + r#" +[module] +name = "echo-venue" +kind = "venue-adapter" + +[capabilities] +required = ["chain"] + +[venue] +body_versions = [1, 2] +"#, + ) + .expect("write adapter manifest"); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(adapter_manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + + let Err(err) = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await + else { + panic!("a diverging adapter must refuse to boot"); + }; + let chain = format!("{err:#}"); + assert!(chain.contains("exports body versions {1}"), "{chain}"); + assert!(chain.contains("declares {1, 2}"), "{chain}"); +} + // ── venue-adapter trap recovery ─────────────────────────────────────── /// Boot one flaky-venue adapter over the mock chain, whose head starts at diff --git a/modules/examples/echo-client/module.toml b/modules/examples/echo-client/module.toml index 4b1df513..9dee6673 100644 --- a/modules/examples/echo-client/module.toml +++ b/modules/examples/echo-client/module.toml @@ -30,3 +30,9 @@ venue = "echo-venue" [config] name = "echo-client" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed adapter's [venue] body_versions +# contains it. +[venue] +body_version = 1 diff --git a/modules/examples/echo-venue/module.toml b/modules/examples/echo-venue/module.toml index f57607ca..63f9a7a2 100644 --- a/modules/examples/echo-venue/module.toml +++ b/modules/examples/echo-venue/module.toml @@ -22,3 +22,8 @@ allow = [] [config] name = "echo-venue" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 92b0e62c..9af90589 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -32,6 +32,12 @@ impl EchoVenue { Ok(()) } + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + fn derive_header(body: Vec) -> Result { // The echo venue gives back exactly the bytes handed to it, so the // header's `gives` amount is the body length: enough to exercise diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 1f01b9cc..33e73f10 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -17,6 +17,10 @@ interface client { interface adapter { use videre:types/types@0.1.0.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + /// Body-schema versions this adapter decodes. Must equal the + /// manifest `[venue] body_versions` set; install asserts it. + body-versions: func() -> list; + /// Pure: derive the guard-facing header from a body. No I/O. derive-header: func(body: list) -> result; quote: func(body: list) -> result; From e845890031f6c5b82d5bf85656e5c49d2327e8bd Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 07:28:39 +0000 Subject: [PATCH 049/139] ci: align the zero-leak check to the charter symbol set (#449) Scan the runtime sources for the charter set only (nexum:intent, value-flow, VenueAdapter, synthesize_venue, nexum:adapter, PoolRouter), dropping the loose word-shape sweep that false-flagged the opaque extension-map fixtures; keep the crate-graph and WIT-leaf checks, and state the invariant in the nexum-runtime crate charter. The CI job stays advisory until the physical repo cut. --- .github/workflows/ci.yml | 8 ++++---- crates/nexum-runtime/src/lib.rs | 5 +++++ scripts/check-venue-agnostic.sh | 23 +++++++++++++---------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42cc34c0..debe6ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,10 +144,10 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu - # Advisory guard that nexum-runtime stays venue-agnostic: crate graph, symbol - # scan, and nexum:host WIT leaf-ness (scripts/check-venue-agnostic.sh). - # `continue-on-error` keeps this a signal, not a gate, until the physical - # host cut lands; the flip to a blocking gate is tracked for M2. + # Advisory zero-leak guard that nexum-runtime stays venue-agnostic: crate + # graph, charter symbol scan, and nexum:host WIT leaf-ness + # (scripts/check-venue-agnostic.sh). `continue-on-error` keeps this a + # signal, not a gate; it flips to a required check at the physical repo cut. venue-agnostic: name: venue-agnostic (advisory) runs-on: ubuntu-latest diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 1d200b90..b4cccbdd 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,6 +1,11 @@ //! Nexum runtime: a wasmtime-based host for WASM Component Model //! modules, usable as an embeddable library. The bundled binary is a //! thin consumer of the same public surface. +//! +//! Zero-leak charter: this crate is settlement-domain-agnostic. It +//! carries no domain symbol or WIT reference, `nexum:host` stays a +//! leaf WIT package, and no crate edge reaches a domain crate. The +//! zero-leak script under `scripts/` enforces this in CI. #![cfg_attr(not(test), warn(unused_crate_dependencies))] diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index 26d60d3a..3c262410 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Venue-agnosticism check for the host layer: no host-layer crate graph +# Zero-leak check for the host layer: no host-layer crate graph # (runtime, launcher, bare engine) reaches a videre/intent/venue/cow -# crate, the runtime sources carry no venue symbol, and nexum:host -# resolves as a leaf WIT package. Advisory in CI until the physical cut -# lands; run locally via `just check-venue-agnostic`. +# crate, the runtime sources carry no charter symbol +# (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow), +# and nexum:host resolves as a leaf WIT package. Advisory in CI until +# the physical cut lands; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -34,14 +35,16 @@ for crate in nexum-runtime nexum-launch nexum-cli; do fi done -# 2. Symbol scan: no venue vocabulary anywhere in the crate. Word shapes -# skip std::borrow::Cow, ProviderError, and "intentional". -symbols='\b[Vv]idere|\b[Ii]ntent([_A-Z-]|s?\b)|\b[Vv]enue|\bcow|CoW|\bCow[A-Z]' -rg -n --no-heading -e "$symbols" crates/nexum-runtime +# 2. Symbol scan: the charter set (the current venue vocabulary that would +# signal a leak - videre WIT/crate refs, the Venue* types, the egress +# guard). Section 1 guards dependency edges; this scan stays curated to +# the live post-rename names so opaque extension payloads never false-flag. +charter='videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow' +rg -n --no-heading -e "$charter" crates/nexum-runtime/src case $? in - 0) fail "venue symbols leak into nexum-runtime" ;; + 0) fail "charter symbols leak into nexum-runtime" ;; 1) pass "symbol scan empty" ;; - *) fail "symbol scan errored (crates/nexum-runtime missing?)" ;; + *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; esac # 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the From 9c99e2d2d429e899367b4278907910475ee4b57c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 07:50:05 +0000 Subject: [PATCH 050/139] ci: block the zero-leak gate on router-field and wit-namespace scans (#450) ci: flip the zero-leak gate to blocking with the echo-venue boot oracle Drop continue-on-error from the venue-agnostic job, add privileged-field and host-WIT namespace scans to the zero-leak script, and pin the invariant with two integration tests: the echo venue boots and routes a worker's submission purely through the generic extension seam, and the nexum-runtime crate graph names no venue-shaped crate. A missing wasm fixture hard-fails the boot oracle under CI so the gate cannot skip itself. --- .github/workflows/ci.yml | 11 +- crates/videre-host/tests/zero_leak.rs | 155 ++++++++++++++++++++++++++ justfile | 5 +- scripts/check-venue-agnostic.sh | 38 +++++-- 4 files changed, 193 insertions(+), 16 deletions(-) create mode 100644 crates/videre-host/tests/zero_leak.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index debe6ad1..6207c455 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,14 +144,13 @@ jobs: AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar run: cargo check --workspace --all-features --locked --target aarch64-unknown-linux-gnu - # Advisory zero-leak guard that nexum-runtime stays venue-agnostic: crate - # graph, charter symbol scan, and nexum:host WIT leaf-ness - # (scripts/check-venue-agnostic.sh). `continue-on-error` keeps this a - # signal, not a gate; it flips to a required check at the physical repo cut. + # Blocking zero-leak gate: host-layer crate graphs stay venue-free, the + # runtime Rust sources carry no charter symbol and no privileged router + # field, and nexum:host names no foreign WIT package and resolves as a + # leaf (scripts/check-venue-agnostic.sh). venue-agnostic: - name: venue-agnostic (advisory) + name: venue-agnostic runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: ./.github/actions/rust-setup diff --git a/crates/videre-host/tests/zero_leak.rs b/crates/videre-host/tests/zero_leak.rs new file mode 100644 index 00000000..43f32ea5 --- /dev/null +++ b/crates/videre-host/tests/zero_leak.rs @@ -0,0 +1,155 @@ +//! Zero-leak oracle: the host boots the echo venue and routes a worker's +//! submission purely through the generic extension seam, while the +//! `nexum-runtime` crate graph reaches no venue-shaped crate. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::Extension; +use nexum_runtime::supervisor::{Supervisor, build_linker}; +use nexum_runtime::test_utils::{ + MockChainProvider, MockStateStore, MockTypes, mock_components_from, +}; +use videre_host::{VenueRegistry, platform}; + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir. +/// A missing artefact is a hard failure under CI (the gate may not skip +/// itself) and a soft skip locally. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + return Some(p); + } + assert!( + std::env::var_os("CI").is_none(), + "{} must be prebuilt in CI", + p.display() + ); + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None +} + +/// The boot oracle: the venue adapter installs and a worker's submission +/// reaches it, with the platform supplied only as a generic extension. +#[tokio::test] +async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = mock_components_from(chain, MockStateStore::new()); + + let mut engine_config = wasmtime::Config::new(); + engine_config.wasm_component_model(true); + engine_config.consume_fuel(true); + let engine = wasmtime::Engine::new(&engine_config).expect("wasmtime engine"); + + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + }], + ..Default::default() + }; + let extensions: Vec>> = vec![Arc::new(platform(&config))]; + let linker = build_linker::(&engine, &extensions).expect("build_linker"); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "echo-venue installed"); + assert_eq!(supervisor.alive_count(), 1, "echo-client alive"); + + // One block drives the worker's on_block submission; the registry the + // extension published on the service map observes the accepted receipt. + let block = nexum::host::types::Block { + chain_id: 1, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + }; + assert_eq!(supervisor.dispatch_block(block).await, 1); + let registry = supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service"); + let updates = registry.poll_status_transitions().await; + assert!( + updates.iter().any(|u| u.venue == "echo-venue"), + "the submission reached the venue; updates were: {updates:?}" + ); +} + +/// The graph oracle: `cargo tree` for the host crate (normal + build +/// edges) names no videre, intent, venue, or cow crate. +#[test] +fn host_crate_graph_reaches_no_venue_shaped_crate() { + let output = Command::new(env!("CARGO")) + .args([ + "tree", + "-p", + "nexum-runtime", + "-e", + "normal,build", + "--all-features", + "--prefix", + "none", + "--locked", + ]) + .current_dir(workspace_path("")) + .output() + .expect("cargo tree runs"); + assert!( + output.status.success(), + "cargo tree failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let tree = String::from_utf8_lossy(&output.stdout); + let reached: Vec<&str> = tree + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| { + let name = name.to_lowercase(); + ["videre", "intent", "venue", "cow"] + .iter() + .any(|word| name.contains(word)) + }) + .collect(); + assert!( + reached.is_empty(), + "venue-shaped crates reached: {reached:?}" + ); +} diff --git a/justfile b/justfile index de542480..270a8248 100644 --- a/justfile +++ b/justfile @@ -72,8 +72,9 @@ build-e2e: build-m2 build-m3 run-e2e: build-e2e build-engine cargo run -p shepherd -- --engine-config engine.e2e.toml -# Assert nexum-runtime is venue-agnostic: crate graph, symbol scan, and -# the nexum:host WIT leaf. Advisory in CI until the physical cut lands. +# Zero-leak gate: host-layer crate graphs, runtime charter-symbol and +# router-field scans, and the nexum:host WIT leaf and foreign-namespace +# scans. Blocking in CI. check-venue-agnostic: ./scripts/check-venue-agnostic.sh diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index 3c262410..cf1df1b7 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Zero-leak check for the host layer: no host-layer crate graph -# (runtime, launcher, bare engine) reaches a videre/intent/venue/cow -# crate, the runtime sources carry no charter symbol -# (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow), -# and nexum:host resolves as a leaf WIT package. Advisory in CI until -# the physical cut lands; run locally via `just check-venue-agnostic`. +# Zero-leak check for the host layer, scoped precisely: no host-layer +# crate graph (runtime, launcher, bare engine) reaches a +# videre/intent/venue/cow crate; the runtime Rust sources carry no +# charter symbol +# (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow) +# and no privileged router field; and nexum:host names no foreign WIT +# package and resolves as a leaf. The opaque-status envelope +# (intent-status-update, its venue id string) is ratified host surface, +# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. set -uo pipefail @@ -47,8 +50,27 @@ case $? in *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; esac -# 3. WIT DAG: nexum:host is a leaf. No cross-package use/import, and the -# package resolves standalone. +# 3. Privileged-field scan: the venue registry rides the extension +# service map; no `VenueRegistry` router field may return to the +# runtime. (The charter scan above also catches the type; this stays +# as the named guard for that specific invariant.) +rg -n --no-heading -e 'VenueRegistry' crates/nexum-runtime/src +case $? in + 0) fail "a privileged router field returned to nexum-runtime" ;; + 1) pass "no privileged router field" ;; + *) fail "field scan errored (crates/nexum-runtime/src missing?)" ;; +esac + +# 4. WIT surface: nexum:host is a leaf. No foreign package named +# anywhere in its sources, no cross-package use/import, and the +# package resolves standalone. The opaque-status envelope stays. +wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' +rg -n --no-heading -e "$wit_charter" wit/nexum-host +case $? in + 0) fail "a foreign WIT namespace leaks into wit/nexum-host" ;; + 1) pass "no foreign WIT namespace named" ;; + *) fail "WIT namespace scan errored (wit/nexum-host missing?)" ;; +esac rg -n --no-heading -e '^\s*(use|import)\s+[a-z0-9-]+:' wit/nexum-host case $? in 0) fail "nexum:host references another WIT package" ;; From 02e04a9ab28fad73d49cb53d945e0f669362acb3 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 08:32:26 +0000 Subject: [PATCH 051/139] sdk: make the IntentBody derive no_std (#451) The derive reaches Vec and ToString through the venue SDK's __private alloc re-export instead of ::std, so a #![no_std] consumer needs no extern crate alloc. cow-venue flips to no_std (tests aside) and a compile-only no-std-probe crate keeps the contract under the workspace gate. --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + crates/cow-venue/src/composable.rs | 2 ++ crates/cow-venue/src/lib.rs | 11 +++++++---- crates/nexum-macros/src/intent_body.rs | 14 +++++++++----- crates/nexum-venue-sdk/src/body.rs | 5 ++++- crates/no-std-probe/Cargo.toml | 18 ++++++++++++++++++ crates/no-std-probe/src/lib.rs | 14 ++++++++++++++ 8 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 crates/no-std-probe/Cargo.toml create mode 100644 crates/no-std-probe/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 99a57b72..c0d10b14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3722,6 +3722,13 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "no-std-probe" +version = "0.1.0" +dependencies = [ + "nexum-venue-sdk", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 0d85dead..6b11a451 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", + "crates/no-std-probe", "crates/shepherd", "crates/shepherd-backtest", "crates/shepherd-cow-host", diff --git a/crates/cow-venue/src/composable.rs b/crates/cow-venue/src/composable.rs index c3535865..5e7a94a4 100644 --- a/crates/cow-venue/src/composable.rs +++ b/crates/cow-venue/src/composable.rs @@ -8,6 +8,8 @@ //! `static_input` is opaque to the venue; only the named handler parses //! it, so this crate never inspects its bytes. +use alloc::vec::Vec; + use borsh::{BorshDeserialize, BorshSerialize}; use crate::order::Address; diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 1fa8a521..230f6779 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -9,10 +9,9 @@ //! venue SDK (for the [`IntentBody`](nexum_venue_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW -//! machinery. The one non-obvious constraint: the derive's generated code -//! names `::std`, so the slice links std and is not a bare-metal -//! `#![no_std]` crate; it is guest-consumable on the runtime's -//! std-bearing wasm target rather than target-free. +//! machinery. The crate is `#![no_std]` (tests aside): the derive's +//! generated code reaches `alloc` through the venue SDK re-export, never +//! `::std`. //! //! With `--no-default-features` the slice drops out entirely and the //! crate compiles empty, so a consumer can depend on a future slice @@ -26,9 +25,13 @@ //! so an adapter or a module that wants only the body types stays //! dependency-light. +#![cfg_attr(not(test), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] +#[cfg(feature = "body")] +extern crate alloc; + #[cfg(feature = "body")] pub mod body; diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs index 79b074cd..cc8fe54b 100644 --- a/crates/nexum-macros/src/intent_body.rs +++ b/crates/nexum-macros/src/intent_body.rs @@ -12,7 +12,9 @@ //! //! Generated code names the venue SDK by its crate path //! (`::nexum_venue_sdk`), so the derive is only usable through that -//! crate's re-export. +//! crate's re-export. The expansion names only `::core` and the SDK's +//! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer +//! needs no `extern crate alloc`. use proc_macro2::TokenStream; use quote::quote; @@ -80,12 +82,12 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { encode_arms.push(quote! { Self::#ident(payload) => { - let mut out = ::std::vec::Vec::new(); + let mut out = ::nexum_venue_sdk::body::__private::alloc::vec::Vec::new(); out.push(#tag); ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( |err| ::nexum_venue_sdk::body::BodyError::Encode { version: #tag, - detail: ::std::string::ToString::to_string(&err), + detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string(&err), }, )?; ::core::result::Result::Ok(out) @@ -96,7 +98,9 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { version: #tag, - detail: ::std::string::ToString::to_string(&err), + detail: ::nexum_venue_sdk::body::__private::alloc::string::ToString::to_string( + &err, + ), })?, )), }); @@ -108,7 +112,7 @@ pub(crate) fn expand(input: &DeriveInput) -> syn::Result { fn to_bytes( &self, ) -> ::core::result::Result< - ::std::vec::Vec, + ::nexum_venue_sdk::body::__private::alloc::vec::Vec, ::nexum_venue_sdk::body::BodyError, > { match self { diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs index 1f202ce2..58a64e12 100644 --- a/crates/nexum-venue-sdk/src/body.rs +++ b/crates/nexum-venue-sdk/src/body.rs @@ -86,8 +86,11 @@ impl From for VenueError { } /// Re-exports for `#[derive(IntentBody)]` generated code only; not a -/// public surface. +/// public surface. `alloc` rides along so the expansion resolves in a +/// `#![no_std]` consumer without its own `extern crate alloc`. #[doc(hidden)] pub mod __private { + pub extern crate alloc; + pub use borsh; } diff --git a/crates/no-std-probe/Cargo.toml b/crates/no-std-probe/Cargo.toml new file mode 100644 index 00000000..88fb4444 --- /dev/null +++ b/crates/no-std-probe/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "no-std-probe" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Compile-only #![no_std] probe: the IntentBody derive must expand without the consumer's std prelude." + +[lib] +# Never shipped. Living in the workspace keeps the derive's no_std +# contract under the workspace check/clippy gate. + +[lints] +workspace = true + +[dependencies] +# Source of the `IntentBody` derive and trait the probe enum implements. +nexum-venue-sdk = { path = "../nexum-venue-sdk" } diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs new file mode 100644 index 00000000..2b91da39 --- /dev/null +++ b/crates/no-std-probe/src/lib.rs @@ -0,0 +1,14 @@ +//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand +//! without the consumer's std prelude or an `extern crate alloc`. + +#![no_std] +#![warn(missing_docs)] + +use nexum_venue_sdk::IntentBody; + +/// The probe schema: one published version over a bare byte payload. +#[derive(IntentBody, Clone, Debug, PartialEq, Eq)] +pub enum ProbeBody { + /// First published version. + V1(u8), +} From f7778ab4096b860ec941ba11a527862f637fc857 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 21 Jul 2026 13:51:34 +0000 Subject: [PATCH 052/139] sdk: emit bind-macro fault and level conversions as From impls (#452) sdk: emit the bind-macro fault and level conversions as From impls Replace the convert_fault / sdk_fault_into_wit / convert_level shim functions with From impls emitted by the macro (orphan-legal in the per-cdylib expansion), so module glue converts via ? and Into. No behaviour change. --- crates/nexum-sdk/src/wit_bindgen_macro.rs | 128 ++++++++++--------- crates/shepherd-sdk/src/wit_bindgen_macro.rs | 4 +- docs/05-sdk-design.md | 4 +- docs/tutorial-first-module.md | 10 +- modules/ethflow-watcher/src/lib.rs | 5 +- modules/examples/balance-tracker/src/lib.rs | 6 +- modules/examples/http-probe/src/lib.rs | 7 +- modules/examples/price-alert/src/lib.rs | 7 +- modules/examples/stop-loss/src/lib.rs | 6 +- modules/twap-monitor/src/lib.rs | 6 +- 10 files changed, 94 insertions(+), 89 deletions(-) diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 25a91376..6ac615e4 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -3,10 +3,9 @@ //! //! Before this macro existed, each module hand-rolled ~80 lines of //! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus -//! `convert_chain_err` / `convert_fault` / `sdk_fault_into_wit` / -//! `convert_level`. The code differed across modules in zero places -//! that were not bugs. +//! impls (`ChainHost`, `LocalStoreHost`, `LoggingHost`) plus the fault, +//! chain-error, and level conversions. The code differed across modules +//! in zero places that were not bugs. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities @@ -32,10 +31,10 @@ //! // or, capability-selected: //! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); //! -//! // `WitBindgenHost`, `convert_fault`, and `sdk_fault_into_wit` are -//! // now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `LocalStoreHost` impl (local_store), and -//! // `convert_level`, `HostLogSink`, and `install_tracing` +//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) +//! // are now in scope, plus per selected capability: `convert_chain_err` +//! // (chain), the `LocalStoreHost` impl (local_store), and the +//! // `Level` `From` impl, `HostLogSink`, and `install_tracing` //! // (logging), with the wit-bindgen and SDK types tied together //! // through identifier resolution. Call `install_tracing()` once at //! // the top of `Guest::init` to route `tracing::info!(...)` to the @@ -45,12 +44,15 @@ //! ``` /// Generate `WitBindgenHost` + the `*Host` trait impls + the error / -/// level converters for the selected capabilities. See module docs. +/// level `From` impls for the selected capabilities. See module docs. +/// +/// The fault and level conversions are `From` impls: orphan-legal here +/// because the wit-bindgen types are local to the expanding cdylib. /// /// Macro hygiene note: `macro_rules!` is not hygienic for type names /// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `convert_fault`, `sdk_fault_into_wit`, `convert_level`, `HostLogSink`, -/// and `install_tracing` are intentionally visible in the caller's scope. +/// `HostLogSink`, and `install_tracing` are intentionally visible in the +/// caller's scope. #[macro_export] macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the @@ -71,46 +73,51 @@ macro_rules! bind_host_via_wit_bindgen { /// Lift the wit-bindgen `types.fault` (per-cdylib) into the /// SDK's `Fault`. Exhaustive on the seven-case vocabulary; the /// `rate-limited` backoff record maps field for field. - fn convert_fault(f: nexum::host::types::Fault) -> $crate::host::Fault { - match f { - nexum::host::types::Fault::Unsupported(s) => $crate::host::Fault::Unsupported(s), - nexum::host::types::Fault::Unavailable(s) => $crate::host::Fault::Unavailable(s), - nexum::host::types::Fault::Denied(s) => $crate::host::Fault::Denied(s), - nexum::host::types::Fault::RateLimited(rl) => { - $crate::host::Fault::RateLimited($crate::host::RateLimit { - retry_after_ms: rl.retry_after_ms, - }) + impl ::core::convert::From for $crate::host::Fault { + fn from(f: nexum::host::types::Fault) -> Self { + match f { + nexum::host::types::Fault::Unsupported(s) => Self::Unsupported(s), + nexum::host::types::Fault::Unavailable(s) => Self::Unavailable(s), + nexum::host::types::Fault::Denied(s) => Self::Denied(s), + nexum::host::types::Fault::RateLimited(rl) => { + Self::RateLimited($crate::host::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + nexum::host::types::Fault::Timeout => Self::Timeout, + nexum::host::types::Fault::InvalidInput(s) => Self::InvalidInput(s), + nexum::host::types::Fault::Internal(s) => Self::Internal(s), } - nexum::host::types::Fault::Timeout => $crate::host::Fault::Timeout, - nexum::host::types::Fault::InvalidInput(s) => $crate::host::Fault::InvalidInput(s), - nexum::host::types::Fault::Internal(s) => $crate::host::Fault::Internal(s), } } - /// Reverse direction: lower the SDK [`Fault`]( - /// $crate::host::Fault) back into the per-cdylib wit-bindgen - /// `Fault` so `Guest::init` / `Guest::on_event` can return what - /// the export signature expects. + /// Reverse direction: lower the SDK `Fault` back into the + /// per-cdylib wit-bindgen `Fault` so `Guest::init` / + /// `Guest::on_event` can return what the export signature + /// expects (`?` applies it). /// /// Carries a wildcard arm because `$crate::host::Fault` is /// `#[non_exhaustive]`: a future SDK-side case must compile in /// module crates without source changes. Falls back to /// `internal` carrying the `Display` detail. - fn sdk_fault_into_wit(f: $crate::host::Fault) -> nexum::host::types::Fault { - use nexum::host::types::{Fault as WitFault, RateLimit as WitRateLimit}; - match f { - $crate::host::Fault::Unsupported(s) => WitFault::Unsupported(s), - $crate::host::Fault::Unavailable(s) => WitFault::Unavailable(s), - $crate::host::Fault::Denied(s) => WitFault::Denied(s), - $crate::host::Fault::RateLimited(rl) => WitFault::RateLimited(WitRateLimit { - retry_after_ms: rl.retry_after_ms, - }), - $crate::host::Fault::Timeout => WitFault::Timeout, - $crate::host::Fault::InvalidInput(s) => WitFault::InvalidInput(s), - $crate::host::Fault::Internal(s) => WitFault::Internal(s), - // `$crate::host::Fault` is `#[non_exhaustive]`; a future - // SDK case lands here as `internal`. - other => WitFault::Internal(::std::string::ToString::to_string(&other)), + impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { + fn from(f: $crate::host::Fault) -> Self { + match f { + $crate::host::Fault::Unsupported(s) => Self::Unsupported(s), + $crate::host::Fault::Unavailable(s) => Self::Unavailable(s), + $crate::host::Fault::Denied(s) => Self::Denied(s), + $crate::host::Fault::RateLimited(rl) => { + Self::RateLimited(nexum::host::types::RateLimit { + retry_after_ms: rl.retry_after_ms, + }) + } + $crate::host::Fault::Timeout => Self::Timeout, + $crate::host::Fault::InvalidInput(s) => Self::InvalidInput(s), + $crate::host::Fault::Internal(s) => Self::Internal(s), + // `$crate::host::Fault` is `#[non_exhaustive]`; a + // future SDK case lands here as `internal`. + other => Self::Internal(::std::string::ToString::to_string(&other)), + } } } @@ -163,7 +170,7 @@ macro_rules! __bind_host_cap_via_wit_bindgen { fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { match e { nexum::host::chain::ChainError::Fault(f) => { - $crate::host::ChainError::Fault(convert_fault(f)) + $crate::host::ChainError::Fault(::core::convert::Into::into(f)) } nexum::host::chain::ChainError::Rpc(r) => { $crate::host::ChainError::Rpc($crate::host::RpcError { @@ -184,31 +191,31 @@ macro_rules! __bind_host_cap_via_wit_bindgen { ::core::option::Option<::std::vec::Vec>, $crate::host::Fault, > { - nexum::host::local_store::get(key).map_err(convert_fault) + nexum::host::local_store::get(key).map_err($crate::host::Fault::from) } fn set( &self, key: &str, value: &[u8], ) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::set(key, value).map_err(convert_fault) + nexum::host::local_store::set(key, value).map_err($crate::host::Fault::from) } fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { - nexum::host::local_store::delete(key).map_err(convert_fault) + nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) } fn list_keys( &self, prefix: &str, ) -> ::core::result::Result<::std::vec::Vec<::std::string::String>, $crate::host::Fault> { - nexum::host::local_store::list_keys(prefix).map_err(convert_fault) + nexum::host::local_store::list_keys(prefix).map_err($crate::host::Fault::from) } } }; (logging) => { impl $crate::host::LoggingHost for WitBindgenHost { fn log(&self, level: $crate::Level, message: &str) { - nexum::host::logging::log(convert_level(level), message); + nexum::host::logging::log(nexum::host::logging::Level::from(level), message); } } @@ -216,18 +223,19 @@ macro_rules! __bind_host_cap_via_wit_bindgen { /// `logging::Level` wire enum. `Level` is a set of associated /// consts, not a matchable enum, so compare rather than match; /// the five tiers are total, so the final arm is `Trace`. - fn convert_level(level: $crate::Level) -> nexum::host::logging::Level { - use $crate::Level; - if level == Level::ERROR { - nexum::host::logging::Level::Error - } else if level == Level::WARN { - nexum::host::logging::Level::Warn - } else if level == Level::INFO { - nexum::host::logging::Level::Info - } else if level == Level::DEBUG { - nexum::host::logging::Level::Debug - } else { - nexum::host::logging::Level::Trace + impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { + fn from(level: $crate::Level) -> Self { + if level == $crate::Level::ERROR { + Self::Error + } else if level == $crate::Level::WARN { + Self::Warn + } else if level == $crate::Level::INFO { + Self::Info + } else if level == $crate::Level::DEBUG { + Self::Debug + } else { + Self::Trace + } } } diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs index 0a4c9b14..fa7060d2 100644 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ b/crates/shepherd-sdk/src/wit_bindgen_macro.rs @@ -3,7 +3,7 @@ //! //! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the //! core adapter (`WitBindgenHost`, the `ChainHost` / `LocalStoreHost` -//! / `LoggingHost` impls, the error and level converters, and the +//! / `LoggingHost` impls, the fault and level `From` impls, and the //! tracing wiring). This macro invokes it and adds the //! [`CowApiHost`](crate::cow::CowApiHost) impl over the //! `shepherd:cow/cow-api` import shims. @@ -37,7 +37,7 @@ macro_rules! bind_cow_host_via_wit_bindgen { fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { match e { shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(convert_fault(f)) + $crate::cow::CowApiError::Fault(::core::convert::Into::into(f)) } shepherd::cow::cow_api::CowApiError::Http(h) => { $crate::cow::CowApiError::Http($crate::cow::HttpFailure { diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 60594ad3..c5e4d812 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -176,14 +176,14 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; // ... Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) - .map_err(sdk_fault_into_wit) + .map_err(Into::into) } } ``` diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 9c4c5604..6dc7d2ae 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -304,8 +304,8 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `convert_fault`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source +// `WitBindgenHost`, the fault and level `From` impls, `HostLogSink`, +// and `install_tracing` are generated below. Single source // of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -316,7 +316,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -333,7 +333,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } @@ -344,7 +344,7 @@ export!(StopLoss); The macro generates `WitBindgenHost`, the `ChainHost` / `LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` conversions (`convert_fault`, `sdk_fault_into_wit`), and +`Fault` `From` impls (both directions), and `install_tracing`, which installs the guest `tracing` facade so the `tracing::info!`, `warn!`, and `error!` macros reach the host log call with no `Host` value to thread through. Call it once at diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 21fd3d1c..8cca9f36 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -47,7 +47,7 @@ wit_bindgen::generate!({ pub mod strategy; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` +// `WitBindgenHost` and the fault and level `From` impls // are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. // Gated on `wasm32` so the strategy can be reused in native targets // (e.g. the backtest replay harness in `crates/shepherd-backtest`). @@ -72,8 +72,7 @@ impl Guest for EthFlowWatcher { if let types::Event::ChainLogs(batch) = event { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs) - .map_err(sdk_fault_into_wit)?; + strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs)?; } // Block / Tick / Message are not used by this module. Ok(()) diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 263a7bcd..7b89ece6 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -35,7 +35,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; @@ -44,7 +44,7 @@ struct BalanceTracker; impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -58,6 +58,6 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index c48377b1..672f0001 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -43,7 +43,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// The fault `From` impls and `install_tracing` (used by the handlers) are // generated by the attribute alongside the wit-bindgen call and the // `Guest`/`export!` glue. struct HttpProbe; @@ -52,7 +52,7 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -67,7 +67,6 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index 94285268..f8e3a825 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -50,7 +50,7 @@ use nexum::host::types; static SETTINGS: OnceLock = OnceLock::new(); -// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the // wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; @@ -59,7 +59,7 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -75,7 +75,6 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index ff846bdf..5e203753 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -37,7 +37,7 @@ use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated +// `WitBindgenHost` and the fault and level `From` impls are generated // below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -48,7 +48,7 @@ struct StopLoss; impl Guest for StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + let cfg = strategy::parse_config(&config)?; tracing::info!( "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", cfg.owner, @@ -65,7 +65,7 @@ impl Guest for StopLoss { return Ok(()); }; if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; } Ok(()) } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 0483cb52..fda3ac82 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -36,7 +36,7 @@ mod strategy; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` +// `WitBindgenHost` and the fault and level `From` impls // are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. shepherd_sdk::bind_cow_host_via_wit_bindgen!(); @@ -54,7 +54,7 @@ impl Guest for TwapMonitor { types::Event::ChainLogs(batch) => { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs).map_err(sdk_fault_into_wit)?; + strategy::on_chain_logs(&WitBindgenHost, &logs)?; } types::Event::Block(block) => { let info = strategy::BlockInfo { @@ -62,7 +62,7 @@ impl Guest for TwapMonitor { number: block.number, timestamp: block.timestamp, }; - strategy::on_block(&WitBindgenHost, info).map_err(sdk_fault_into_wit)?; + strategy::on_block(&WitBindgenHost, info)?; } // Tick / Message are not used by this module. _ => {} From f9022398522ab14b0e6d58296b26ffc303a7d7f1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 04:11:17 +0000 Subject: [PATCH 053/139] sdk: add an alloy provider seam over the chain host (#453) * sdk: put an alloy provider seam over the chain host Add HostTransport, an alloy Transport dispatching JSON-RPC packets through ChainHost::request, and host.provider(chain) minting a RootProvider over it, driven by a single-poll block_on. Methods outside the typed ChainMethod read surface (mirrored guest-side) fail as -32601 before reaching the host; node errors carry code, message and decoded revert bytes; host faults surface as typed transport errors. Chain and ChainId newtypes replace bare u64 ids at the SDK edge. The hoisted alloy-provider entry drops its native transport features so the wasm guest build stays transport-free; the engine and load-gen re-add theirs at the call site. * sdk: adopt alloy_chains::Chain, drop hand-rolled chain/id.rs The guest SDK carried Chain/ChainId newtypes duplicating alloy_chains::Chain, which is already in the guest graph via alloy-provider. Delete chain/id.rs, re-export alloy_chains::Chain, and repoint the named-chain test and doc sites. ChainId was unused outside the module and the WIT edge is a raw u64, so nothing is lost; Display now renders the chain name where alloy knows it. * sdk: assert chain block_on resolves synchronously The host transport is a synchronous WIT import, so provider futures resolve on the first poll. Replace the unbounded noop-waker loop - which livelocks and burns the guest fuel budget if a future ever returns Pending - with a single poll that panics loudly on Pending. Fail-loud beats a silent spin, and the host-side unit tests (no fuel backstop) now surface a stuck future instead of hanging. Addresses #523 (item 1). * sdk: sort chain module re-exports alloy_chains sorts before eth_call; keep the pub-use group in rustfmt order after the ChainId substitution. --- Cargo.lock | 6 + Cargo.toml | 8 +- crates/nexum-runtime/Cargo.toml | 2 +- crates/nexum-sdk/Cargo.toml | 15 +- crates/nexum-sdk/src/chain/method.rs | 121 ++++++++++++ crates/nexum-sdk/src/chain/mod.rs | 18 +- crates/nexum-sdk/src/chain/provider.rs | 130 ++++++++++++ crates/nexum-sdk/src/chain/transport.rs | 252 ++++++++++++++++++++++++ crates/nexum-sdk/src/lib.rs | 10 +- tools/load-gen/Cargo.toml | 2 +- 10 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 crates/nexum-sdk/src/chain/method.rs create mode 100644 crates/nexum-sdk/src/chain/provider.rs create mode 100644 crates/nexum-sdk/src/chain/transport.rs diff --git a/Cargo.lock b/Cargo.lock index c0d10b14..d0d8f6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3645,9 +3645,14 @@ dependencies = [ name = "nexum-sdk" version = "0.1.0" dependencies = [ + "alloy-chains", + "alloy-json-rpc", "alloy-primitives", + "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-sol-types", + "alloy-transport", "http", "nexum-macros", "nexum-sdk-test", @@ -3656,6 +3661,7 @@ dependencies = [ "serde_json", "strum", "thiserror 2.0.18", + "tower", "tracing", "tracing-core", "wstd", diff --git a/Cargo.toml b/Cargo.toml index 6b11a451..d7bf9904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,9 @@ clap = { version = "4", features = ["derive"] } # moves every consumer at once. alloy-primitives = { version = "1.6", default-features = false, features = ["std", "serde"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -alloy-provider = { version = "2.1", default-features = false, features = ["ws", "ipc", "pubsub", "reqwest"] } +# Featureless here so the guest SDK's wasm build stays transport-free; +# the engine and tooling add ws/ipc/pubsub/reqwest at their call sites. +alloy-provider = { version = "2.1", default-features = false } alloy-rpc-types-eth = { version = "2.1", default-features = false, features = ["std"] } alloy-transport-ws = { version = "2.1", default-features = false } # Typed EIP-155 chain ids for config keys, provider/orderbook pools, and @@ -173,7 +175,9 @@ toml = "1" metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } -# alloy JSON-RPC client + transport (engine chain backend). +# alloy JSON-RPC client + transport (engine chain backend and the +# guest SDK's host-backed transport). +alloy-json-rpc = { version = "2.1", default-features = false } alloy-rpc-client = { version = "2.1", default-features = false } alloy-transport = { version = "2.1", default-features = false } diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 63c4be27..cb29b7b9 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -67,7 +67,7 @@ bytes.workspace = true # from a `WsConnect`/`Http` transport so the host's `request` / # `request-batch` impls can hand a raw `(method, params)` pair to # alloy's JSON-RPC layer without reimplementing the codec. -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws", "ipc", "pubsub", "reqwest"] } alloy-rpc-client.workspace = true alloy-rpc-types-eth.workspace = true alloy-transport.workspace = true diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 655e28ed..620883bf 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -27,15 +27,28 @@ nexum-macros = { path = "../nexum-macros" } # re-exported as `nexum_sdk::status_body`. nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true +# Typed EIP-155 chain id; already in the guest graph via alloy-provider. +alloy-chains.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`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The provider seam: `HostTransport` speaks alloy's JSON-RPC packet +# vocabulary over `ChainHost::request`, and `provider()` fronts it with +# a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches +# the wasm guest. +alloy-json-rpc.workspace = true +alloy-provider.workspace = true +alloy-rpc-client.workspace = true +alloy-transport.workspace = true +tower.workspace = true # Standard HTTP request/response/method vocabulary; the SDK adds only # the wasi:http-specific `fetch` seam on top. wstd re-exports the same # `http` types, so a request passes through to the client unconverted. http.workspace = true -serde_json.workspace = true +# `raw_value` backs the transport's pass-through of host JSON into +# alloy's `Box` payload slots. +serde_json = { workspace = true, features = ["std", "raw_value"] } strum.workspace = true thiserror.workspace = true # `tracing-core` backs the guest facade's subscriber plumbing; the diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs new file mode 100644 index 00000000..3f45013b --- /dev/null +++ b/crates/nexum-sdk/src/chain/method.rs @@ -0,0 +1,121 @@ +//! The typed JSON-RPC method surface, guest side. + +use strum::{EnumString, IntoStaticStr}; + +/// The permitted JSON-RPC read surface as a closed type, mirroring the +/// runtime's `ChainMethod` case for case. Signing and mutating methods +/// have no variant, so they cannot be represented and never cross the +/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything +/// outside this set before calling the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. `&'static` because the set is closed. + pub fn as_str(self) -> &'static str { + self.into() + } +} + +#[cfg(test)] +mod tests { + use super::ChainMethod; + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "admin_peers", + "debug_traceCall", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } +} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dd60ba0d..c547f921 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,10 +1,20 @@ -//! `chain::request` JSON plumbing. +//! Chain access for guest strategies. //! -//! Build the `[{to, data}, "latest"]` params array for `eth_call` and -//! parse the `"0x..."` hex result string. Pure-logic helpers so a -//! module can plumb its own `chain::request` shim around them. +//! Chain identity (alloy [`Chain`]), the closed JSON-RPC read +//! surface ([`ChainMethod`]), and the alloy provider seam: a +//! [`HostTransport`] over `ChainHost::request` fronted by +//! [`ProviderHost::provider`], driven with [`block_on`]. Plus the +//! `eth_call` JSON plumbing helpers for modules that keep their own +//! `chain::request` shim. pub mod chainlink; pub mod eth_call; +pub mod method; +pub mod provider; +pub mod transport; +pub use alloy_chains::Chain; pub use eth_call::{eth_call_params, parse_eth_call_result}; +pub use method::ChainMethod; +pub use provider::{ProviderHost, block_on}; +pub use transport::HostTransport; diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs new file mode 100644 index 00000000..426b4fd5 --- /dev/null +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -0,0 +1,130 @@ +//! `host.provider(chain)`: an alloy `Provider` over the chain host. + +use std::future::{Future, IntoFuture}; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; + +use alloy_provider::RootProvider; +use alloy_rpc_client::RpcClient; + +use super::{Chain, HostTransport}; +use crate::host::ChainHost; + +/// Mints an alloy [`Provider`](alloy_provider::Provider) over +/// [`ChainHost::request`], so a strategy calls typed provider methods +/// instead of hand-building JSON-RPC. Blanket-implemented for every +/// cloneable [`ChainHost`]; drive the returned futures with +/// [`block_on`]. +/// +/// ``` +/// use alloy_provider::Provider; +/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; +/// use nexum_sdk::host::{ChainError, ChainHost}; +/// +/// #[derive(Clone)] +/// struct StubHost; +/// impl ChainHost for StubHost { +/// fn request(&self, _: u64, _: &str, _: &str) -> Result { +/// Ok("\"0x2a\"".into()) +/// } +/// } +/// +/// let provider = StubHost.provider(Chain::mainnet()); +/// let block = block_on(provider.get_block_number()).unwrap(); +/// assert_eq!(block, 42); +/// ``` +pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { + /// Provider for `chain`, routed through the host's RPC stack. + fn provider(&self, chain: Chain) -> RootProvider { + RootProvider::new(RpcClient::new( + HostTransport::new(self.clone(), chain), + false, + )) + } +} + +impl ProviderHost for H {} + +/// Drive a host-backed provider future to completion. The host +/// transport is a synchronous WIT import, so the future resolves on the +/// first poll; a `Pending` means an async alloy layer crept in and the +/// chain SDK must move to a host-driven surface, not a poll loop. +pub fn block_on(future: F) -> F::Output { + let mut future = pin!(future.into_future()); + let mut cx = Context::from_waker(Waker::noop()); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => panic!( + "chain provider future did not resolve synchronously: the host \ + transport is a synchronous WIT import, so an alloy layer that \ + awaits a reactor or timer was added; the chain SDK must move \ + to a host-driven async surface, not a poll loop" + ), + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::{Bytes, address}; + use alloy_provider::Provider; + use alloy_rpc_types_eth::TransactionRequest; + + use super::{ProviderHost, block_on}; + use crate::chain::Chain; + use crate::host::{ChainError, ChainHost}; + + #[derive(Clone)] + struct StubHost; + + impl ChainHost for StubHost { + fn request( + &self, + chain_id: u64, + method: &str, + _params: &str, + ) -> Result { + assert_eq!(chain_id, 100); + match method { + "eth_blockNumber" => Ok("\"0x2a\"".into()), + "eth_call" => Ok("\"0x1234\"".into()), + other => panic!("unexpected method {other}"), + } + } + } + + #[test] + fn provider_reads_typed_values_through_the_host() { + let provider = StubHost.provider(Chain::from_id(100)); + let block = block_on(provider.get_block_number()).expect("block number"); + assert_eq!(block, 42); + } + + #[test] + fn provider_call_decodes_bytes() { + let provider = StubHost.provider(Chain::from_id(100)); + let tx = TransactionRequest::default() + .to(address!("0x9008D19f58AAbD9eD0D60971565AA8510560ab41")); + let out = block_on(provider.call(tx)).expect("eth_call"); + assert_eq!(out, Bytes::from(vec![0x12, 0x34])); + } + + #[test] + fn signing_methods_error_before_the_host() { + let provider = StubHost.provider(Chain::from_id(100)); + let err = block_on(provider.raw_request::<_, String>("eth_sendRawTransaction".into(), ())) + .expect_err("write method is rejected"); + let payload = err.as_error_resp().expect("json-rpc error response"); + assert_eq!(payload.code, -32601); + } + + #[test] + fn block_on_drives_plain_futures() { + assert_eq!(block_on(async { 7 }), 7); + } + + #[test] + #[should_panic(expected = "did not resolve synchronously")] + fn block_on_panics_when_a_future_is_not_synchronously_ready() { + block_on(std::future::pending::<()>()); + } +} diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs new file mode 100644 index 00000000..5ae36dcc --- /dev/null +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -0,0 +1,252 @@ +//! [`HostTransport`]: the alloy transport over [`ChainHost::request`]. + +use std::future::ready; +use std::task::{Context, Poll}; + +use alloy_json_rpc::{ + ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, SerializedRequest, +}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use serde_json::value::RawValue; +use tower::Service; + +use super::{Chain, ChainMethod}; +use crate::host::{ChainError, ChainHost}; + +/// An alloy `Transport` routing JSON-RPC through the host's chain +/// interface. Dispatch is synchronous: the host blocks the guest until +/// the response is available, so every returned future is ready on its +/// first poll and [`block_on`](super::block_on) drives it for free. +/// +/// Methods outside the typed [`ChainMethod`] surface never reach the +/// host; they fail as a JSON-RPC `-32601` error response. A structured +/// node error comes back as the error payload (code, message, revert +/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as +/// a custom transport error carrying the typed fault. +#[derive(Clone, Copy, Debug)] +pub struct HostTransport { + host: H, + chain: Chain, +} + +impl HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + /// Transport dispatching on `chain` through `host`. + pub const fn new(host: H, chain: Chain) -> Self { + Self { host, chain } + } + + fn dispatch(&self, packet: RequestPacket) -> Result { + match packet { + RequestPacket::Single(req) => Ok(ResponsePacket::Single(self.dispatch_single(&req)?)), + RequestPacket::Batch(reqs) => reqs + .iter() + .map(|req| self.dispatch_single(req)) + .collect::, _>>() + .map(ResponsePacket::Batch), + } + } + + fn dispatch_single(&self, req: &SerializedRequest) -> Result { + let Ok(method) = ChainMethod::try_from(req.method()) else { + return Ok(failure( + req, + ErrorPayload { + code: -32601, + message: format!( + "method outside the permitted read surface: {}", + req.method() + ) + .into(), + data: None, + }, + )); + }; + let params = req.params().map_or("[]", RawValue::get); + match self + .host + .request(self.chain.into(), method.as_str(), params) + { + Ok(result) => { + let payload = RawValue::from_string(result) + .map_err(|e| TransportError::deser_err(e, "host chain response"))?; + Ok(Response { + id: req.id().clone(), + payload: ResponsePayload::Success(payload), + }) + } + Err(ChainError::Rpc(rpc)) => Ok(failure( + req, + ErrorPayload { + code: rpc.code.into(), + message: rpc.message.into(), + data: rpc.data.and_then(|bytes| { + serde_json::value::to_raw_value(&alloy_primitives::hex::encode_prefixed( + bytes, + )) + .ok() + }), + }, + )), + Err(ChainError::Fault(fault)) => Err(TransportErrorKind::custom(fault)), + } + } +} + +fn failure(req: &SerializedRequest, payload: ErrorPayload) -> Response { + Response { + id: req.id().clone(), + payload: ResponsePayload::Failure(payload), + } +} + +impl Service for HostTransport +where + H: ChainHost + Clone + Send + Sync + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, packet: RequestPacket) -> Self::Future { + let result = self.dispatch(packet); + Box::pin(ready(result)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use alloy_json_rpc::{Id, Request, RequestPacket, ResponsePacket, ResponsePayload}; + use alloy_transport::TransportError; + use tower::Service; + + use super::HostTransport; + use crate::chain::{Chain, block_on}; + use crate::host::{ChainError, ChainHost, Fault, RpcError}; + + type StubFn = dyn Fn(u64, &str, &str) -> Result + Send + Sync; + + #[derive(Clone)] + struct Stub(Arc); + + impl Stub { + fn new( + f: impl Fn(u64, &str, &str) -> Result + Send + Sync + 'static, + ) -> Self { + Self(Arc::new(f)) + } + } + + impl ChainHost for Stub { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { + (self.0)(chain_id, method, params) + } + } + + fn single(method: &'static str) -> RequestPacket { + let req = Request::new(method, Id::Number(1), ()) + .serialize() + .expect("request serializes"); + RequestPacket::Single(req) + } + + fn call(transport: &mut HostTransport, packet: RequestPacket) -> super::Response { + let ResponsePacket::Single(resp) = + block_on(Service::call(transport, packet)).expect("transport dispatches") + else { + panic!("single request yields a single response"); + }; + resp + } + + #[test] + fn success_passes_host_json_through() { + let stub = Stub::new(|chain_id, method, params| { + assert_eq!(chain_id, 100); + assert_eq!(method, "eth_blockNumber"); + assert_eq!(params, "[]"); + Ok("\"0x2a\"".into()) + }); + let mut transport = HostTransport::new(stub, Chain::from_id(100)); + let resp = call(&mut transport, single("eth_blockNumber")); + let ResponsePayload::Success(payload) = resp.payload else { + panic!("expected success, got {resp:?}"); + }; + assert_eq!(payload.get(), "\"0x2a\""); + } + + #[test] + fn unlisted_method_never_reaches_the_host() { + let stub = Stub::new(|_, method, _| panic!("host must not see {method}")); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let resp = call(&mut transport, single("eth_sendRawTransaction")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32601); + assert!(err.message.contains("eth_sendRawTransaction")); + } + + #[test] + fn rpc_error_surfaces_code_message_and_revert_hex() { + let stub = Stub::new(|_, _, _| { + Err(ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: Some(vec![0x08, 0xc3, 0x79, 0xa0].into()), + })) + }); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let resp = call(&mut transport, single("eth_call")); + let ResponsePayload::Failure(err) = resp.payload else { + panic!("expected failure, got {resp:?}"); + }; + assert_eq!(err.code, -32000); + assert_eq!(err.message, "execution reverted"); + assert_eq!(err.data.expect("revert data").get(), "\"0x08c379a0\"",); + } + + #[test] + fn fault_becomes_a_typed_transport_error() { + let stub = Stub::new(|_, _, _| Err(ChainError::Fault(Fault::Timeout))); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let err = block_on(Service::call(&mut transport, single("eth_call"))) + .expect_err("fault propagates"); + let TransportError::Transport(kind) = err else { + panic!("expected transport kind, got {err:?}"); + }; + assert!(kind.to_string().contains("timeout")); + } + + #[test] + fn batches_dispatch_per_request() { + let stub = Stub::new(|_, method, _| match method { + "eth_blockNumber" => Ok("\"0x1\"".into()), + _ => Ok("\"0x64\"".into()), + }); + let mut transport = HostTransport::new(stub, Chain::mainnet()); + let reqs = vec![ + Request::new("eth_blockNumber", Id::Number(1), ()) + .serialize() + .expect("request serializes"), + Request::new("eth_chainId", Id::Number(2), ()) + .serialize() + .expect("request serializes"), + ]; + let ResponsePacket::Batch(resps) = + block_on(Service::call(&mut transport, RequestPacket::Batch(reqs))) + .expect("batch dispatches") + else { + panic!("batch request yields a batch response"); + }; + assert_eq!(resps.len(), 2); + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 04e8fffd..59794c1b 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -39,7 +39,10 @@ //! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! -//! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], +//! - [`chain`] - typed chain access: alloy [`Chain`], +//! the closed [`ChainMethod`] read surface, and the alloy provider +//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus +//! `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). //! @@ -93,6 +96,11 @@ //! [`ConditionalSource`]: keeper::ConditionalSource //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction +//! [`Chain`]: alloy_chains::Chain +//! [`ChainMethod`]: chain::ChainMethod +//! [`HostTransport`]: chain::HostTransport +//! [`provider`]: chain::ProviderHost::provider +//! [`block_on`]: chain::block_on //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer diff --git a/tools/load-gen/Cargo.toml b/tools/load-gen/Cargo.toml index 9652bdc8..4c62cd8a 100644 --- a/tools/load-gen/Cargo.toml +++ b/tools/load-gen/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true alloy-primitives.workspace = true -alloy-provider.workspace = true +alloy-provider = { workspace = true, features = ["ws"] } alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true alloy-transport-ws.workspace = true From 33372b1f5bddd8d5b96ae658243d070d78290623 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 05:45:21 +0000 Subject: [PATCH 054/139] sdk: rename to videre-sdk and add the keeper sweep assembler (#454) sdk: rename to videre-sdk and add the keeper sweep assembler and typed venue client Rename nexum-venue-sdk to videre-sdk across the workspace. Add the generic Keeper::sweep assembler (WatchSet to Gates to poll to Retrier to Journal) over a shared Sweep outcome resolving the dangling ConditionalSource::Outcome; the world-neutral primitives stay in nexum-sdk. Thread a VenueId newtype through the client seam, add the owned VenueFault mirror (retry-after-ms preserved) behind ClientError, mark the client-facing enums non-exhaustive, and seal IntentBody to its derive. --- Cargo.lock | 35 +- Cargo.toml | 2 +- crates/cow-venue/Cargo.toml | 4 +- crates/cow-venue/src/body.rs | 6 +- crates/cow-venue/src/client.rs | 22 +- crates/cow-venue/src/lib.rs | 2 +- crates/nexum-macros/src/intent_body.rs | 31 +- crates/nexum-macros/src/lib.rs | 16 +- crates/nexum-venue-test/Cargo.toml | 2 +- crates/nexum-venue-test/src/codec.rs | 4 +- crates/nexum-venue-test/src/header.rs | 8 +- crates/nexum-venue-test/src/reference.rs | 4 +- crates/nexum-venue-test/src/transport.rs | 2 +- crates/nexum-venue-test/tests/conformance.rs | 12 +- crates/no-std-probe/Cargo.toml | 2 +- crates/no-std-probe/src/lib.rs | 2 +- crates/videre-host/tests/platform.rs | 2 +- .../Cargo.toml | 8 +- .../src/adapter.rs | 8 +- .../src/bindings.rs | 2 +- .../src/body.rs | 11 +- .../src/client.rs | 98 ++-- .../src/faults.rs | 59 ++- crates/videre-sdk/src/keeper.rs | 451 ++++++++++++++++++ .../src/lib.rs | 34 +- .../src/transport.rs | 0 .../tests/adapter.rs | 43 +- docs/05-sdk-design.md | 4 +- justfile | 2 +- modules/examples/echo-venue/Cargo.toml | 2 +- modules/examples/echo-venue/module.toml | 2 +- modules/examples/echo-venue/src/lib.rs | 4 +- modules/fixtures/flaky-venue/Cargo.toml | 2 +- modules/fixtures/flaky-venue/src/lib.rs | 2 +- 34 files changed, 724 insertions(+), 164 deletions(-) rename crates/{nexum-venue-sdk => videre-sdk}/Cargo.toml (73%) rename crates/{nexum-venue-sdk => videre-sdk}/src/adapter.rs (95%) rename crates/{nexum-venue-sdk => videre-sdk}/src/bindings.rs (94%) rename crates/{nexum-venue-sdk => videre-sdk}/src/body.rs (91%) rename crates/{nexum-venue-sdk => videre-sdk}/src/client.rs (61%) rename crates/{nexum-venue-sdk => videre-sdk}/src/faults.rs (73%) create mode 100644 crates/videre-sdk/src/keeper.rs rename crates/{nexum-venue-sdk => videre-sdk}/src/lib.rs (76%) rename crates/{nexum-venue-sdk => videre-sdk}/src/transport.rs (100%) rename crates/{nexum-venue-sdk => videre-sdk}/tests/adapter.rs (87%) diff --git a/Cargo.lock b/Cargo.lock index d0d8f6e4..56f41150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,10 +1559,10 @@ version = "0.1.0" dependencies = [ "borsh", "nexum-sdk", - "nexum-venue-sdk", "serde", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "videre-sdk", ] [[package]] @@ -2162,8 +2162,8 @@ dependencies = [ name = "echo-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", "nexum-venue-test", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -2381,7 +2381,7 @@ dependencies = [ name = "flaky-venue" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -3692,18 +3692,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-venue-sdk" -version = "0.1.0" -dependencies = [ - "borsh", - "nexum-macros", - "nexum-sdk", - "strum", - "thiserror 2.0.18", - "wit-bindgen 0.59.0", -] - [[package]] name = "nexum-venue-test" version = "0.1.0" @@ -3713,11 +3701,11 @@ dependencies = [ "http", "nexum-sdk", "nexum-sdk-test", - "nexum-venue-sdk", "serde", "serde_json", "tempfile", "thiserror 2.0.18", + "videre-sdk", ] [[package]] @@ -3732,7 +3720,7 @@ dependencies = [ name = "no-std-probe" version = "0.1.0" dependencies = [ - "nexum-venue-sdk", + "videre-sdk", ] [[package]] @@ -6072,6 +6060,19 @@ dependencies = [ "wasmtime", ] +[[package]] +name = "videre-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-macros", + "nexum-sdk", + "nexum-sdk-test", + "strum", + "thiserror 2.0.18", + "wit-bindgen 0.59.0", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index d7bf9904..13067951 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ members = [ "crates/nexum-sdk-test", "crates/nexum-status-body", "crates/nexum-tasks", - "crates/nexum-venue-sdk", "crates/nexum-venue-test", "crates/nexum-world", "crates/no-std-probe", @@ -19,6 +18,7 @@ members = [ "crates/shepherd-sdk", "crates/shepherd-sdk-test", "crates/videre-host", + "crates/videre-sdk", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 975ea7a9..c7ab7fb4 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -21,7 +21,7 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -nexum-venue-sdk = { path = "../nexum-venue-sdk", optional = true } +videre-sdk = { path = "../videre-sdk", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never @@ -47,5 +47,5 @@ thiserror = { workspace = true } # depend on a future `adapter` slice without pulling the codec or the # keeper transitively. default = ["body"] -body = ["dep:borsh", "dep:nexum-venue-sdk"] +body = ["dep:borsh", "dep:videre-sdk"] client = ["body", "dep:nexum-sdk"] diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 3cad2c1e..8caed852 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -5,13 +5,13 @@ //! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` //! gives it the borsh codec: a one-byte version tag plus the borsh //! payload, with an unknown tag failing as a typed -//! [`BodyError`](nexum_venue_sdk::BodyError) rather than a stringly borsh +//! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh //! error. The one non-obvious invariant: the tag order is the schema, so //! new versions append at the end and no variant is ever reordered or //! removed. use borsh::{BorshDeserialize, BorshSerialize}; -use nexum_venue_sdk::IntentBody; +use videre_sdk::IntentBody; use crate::composable::ComposableBody; use crate::order::OrderBody; @@ -36,7 +36,7 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { use super::*; - use nexum_venue_sdk::BodyError; + use videre_sdk::BodyError; use crate::order::{BuyTokenDestination, OrderKind, SellTokenSource}; diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 7ac5c610..b8d4b302 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,8 +8,8 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use nexum_venue_sdk::client::{ClientError, IntentClient, VenueClient}; -use nexum_venue_sdk::{IntentStatus, SubmitOutcome}; +use videre_sdk::client::{ClientError, IntentClient, VenueClient, VenueId}; +use videre_sdk::{IntentStatus, SubmitOutcome}; use crate::body::CowIntentBody; @@ -34,7 +34,7 @@ impl CowClient

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

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

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

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

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

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

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

{ - inner: IntentClient

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

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

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

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

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

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

for SellToken { + fn from(address: Address) -> Self { + Self(address) + } +} + +/// The token an order buys, typed so a builder call cannot swap sides +/// with the sell token. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BuyToken(pub Address); + +impl From
for BuyToken { + fn from(address: Address) -> Self { + Self(address) + } +} + /// Which side of the trade is fixed. #[derive(BorshSerialize, BorshDeserialize, Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderKind { @@ -79,6 +103,162 @@ pub struct OrderBody { pub buy_token_balance: BuyTokenDestination, } +impl OrderBody { + /// Start a sell order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) + } + + /// Start a buy order: `amount` of `token` is the fixed side. + #[must_use] + pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { + OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + } +} + +/// Builder state: the buy-side limit is unset. +pub enum NeedsBuy {} +/// Builder state: the sell-side limit is unset. +pub enum NeedsSell {} +/// Builder state: the expiry is unset. +pub enum NeedsValidTo {} +/// Builder state: every required field is set. +pub enum Ready {} + +/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or +/// [`OrderBody::buy`] fixes the kind and its side, the counter-side +/// limit and the expiry are compile-time required, and the optionals +/// default (self-receive, zero `app_data` and `fee_amount`, +/// fill-or-kill, ERC-20 balances). +#[derive(Clone, Debug)] +pub struct OrderBuilder { + body: OrderBody, + state: PhantomData, +} + +impl OrderBuilder { + const fn start( + kind: OrderKind, + sell_token: Address, + sell_amount: U256, + buy_token: Address, + buy_amount: U256, + ) -> Self { + Self { + body: OrderBody { + sell_token, + buy_token, + receiver: None, + sell_amount, + buy_amount, + valid_to: 0, + app_data: [0; 32], + fee_amount: [0; 32], + kind, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }, + state: PhantomData, + } + } + + const fn into_state(self) -> OrderBuilder { + OrderBuilder { + body: self.body, + state: PhantomData, + } + } +} + +impl OrderBuilder { + /// Demand at least `amount` of `token` in return. + #[must_use] + pub const fn for_at_least( + mut self, + token: BuyToken, + amount: U256, + ) -> OrderBuilder { + self.body.buy_token = token.0; + self.body.buy_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Spend at most `amount` of `token`. + #[must_use] + pub const fn for_at_most( + mut self, + token: SellToken, + amount: U256, + ) -> OrderBuilder { + self.body.sell_token = token.0; + self.body.sell_amount = amount; + self.into_state() + } +} + +impl OrderBuilder { + /// Expire at `valid_to` (Unix seconds). + #[must_use] + pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { + self.body.valid_to = valid_to; + self.into_state() + } +} + +impl OrderBuilder { + /// Deliver the buy token to `receiver` instead of the owner. + #[must_use] + pub const fn receiver(mut self, receiver: Address) -> Self { + self.body.receiver = Some(receiver); + self + } + + /// Set the 32-byte on-chain app-data hash. + #[must_use] + pub const fn app_data(mut self, app_data: [u8; 32]) -> Self { + self.body.app_data = app_data; + self + } + + /// Set the fee taken in the sell token. + #[must_use] + pub const fn fee_amount(mut self, fee_amount: U256) -> Self { + self.body.fee_amount = fee_amount; + self + } + + /// Allow the order to fill partially. + #[must_use] + pub const fn partially_fillable(mut self) -> Self { + self.body.partially_fillable = true; + self + } + + /// Source the sell token from `source`. + #[must_use] + pub const fn sell_token_balance(mut self, source: SellTokenSource) -> Self { + self.body.sell_token_balance = source; + self + } + + /// Deliver the buy token to `destination`. + #[must_use] + pub const fn buy_token_balance(mut self, destination: BuyTokenDestination) -> Self { + self.body.buy_token_balance = destination; + self + } + + /// The finished body. + #[must_use] + pub const fn build(self) -> OrderBody { + self.body + } +} + #[cfg(test)] mod tests { use super::*; @@ -104,6 +284,55 @@ mod tests { } } + #[test] + fn sell_builder_matches_the_literal() { + let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount) + .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) + .valid_to(0xffff_ffff) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); + assert_eq!(built, sample()); + } + + #[test] + fn buy_builder_fixes_the_buy_side() { + let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) + .for_at_most(SellToken([0x11; 20]), [0x01; 32]) + .valid_to(100) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); + assert_eq!(built.kind, OrderKind::Buy); + assert_eq!(built.sell_token, [0x11; 20]); + assert_eq!(built.buy_token, [0x22; 20]); + assert_eq!(built.sell_amount, [0x01; 32]); + assert_eq!(built.buy_amount, [0xff; 32]); + assert_eq!(built.valid_to, 100); + assert!(built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::External); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); + assert_eq!(built.fee_amount, [0x05; 32]); + assert_eq!(built.receiver, None); + } + + #[test] + fn builder_defaults_are_the_wire_defaults() { + let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1) + .build(); + assert_eq!(built.receiver, None); + assert_eq!(built.app_data, [0; 32]); + assert_eq!(built.fee_amount, [0; 32]); + assert!(!built.partially_fillable); + assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); + assert_eq!(built.kind, OrderKind::Sell); + } + #[test] fn order_body_borsh_round_trips() { let body = sample(); diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index cb29b7b9..c867cbd3 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,6 +36,9 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } +# Single-source capability and fault-label vocabularies; the registry's +# core interface set is emitted from its table. +nexum-world = { path = "../nexum-world" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 47145902..fda2b575 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -674,6 +674,8 @@ mod tests { linked: Arc, } + impl crate::sealed::SealedRuntime for ExtPreset {} + impl RuntimePreset for ExtPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; @@ -740,6 +742,8 @@ mod tests { logs: LogPipeline, } + impl crate::sealed::SealedRuntime for PrebuiltLogsPreset {} + impl RuntimePreset for PrebuiltLogsPreset { type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index f55bf2c7..c10f8e0a 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -60,6 +60,7 @@ impl Liveness { /// A guest call failed outside the component's typed error space. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum ActorFault { /// The pre-call refuel failed; the guest was never entered. #[error("refuel failed: {0}")] diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 60b734cf..6b4111cc 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -98,6 +98,7 @@ impl ComponentBuilder for LogPipelineBuilder { /// `anyhow::Error` because the backends fail for heterogeneous reasons /// (I/O for the store, network for the chain). #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum BuildError { /// The chain backend builder failed. #[error("build the chain backend: {0}")] diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 072ebfe8..687abf3a 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -16,6 +16,7 @@ use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, /// structural ceiling; an operator allowlist narrows within it and /// never widens it. #[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { #[strum(serialize = "eth_blockNumber")] EthBlockNumber, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index eaa0e70a..fd9d24e1 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -52,6 +52,8 @@ mod tests { #[derive(Clone, Copy, Default)] struct CoreTypes; + impl crate::sealed::SealedRuntimeTypes for CoreTypes {} + impl RuntimeTypes for CoreTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index 0540e4d4..33741499 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -13,7 +13,9 @@ use crate::host::component::{ChainProvider, StateStore}; /// Names the core backend seams a runtime assembly provides, plus the /// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core /// backend an extension needs. -pub trait RuntimeTypes: 'static { +/// +/// Sealed: a lattice opts in by also implementing the sealing marker. +pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { /// JSON-RPC dispatch and subscriptions. type Chain: ChainProvider + Clone + Send + Sync + 'static; /// Process-wide store vending per-module handles. diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 65f0c303..65fbe9d7 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -15,32 +15,39 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { } /// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary. -pub(crate) fn fault_label(fault: &Fault) -> &'static str { +/// structured-log `kind` field. Emitted from the single-source +/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` +/// mirrors. +pub fn fault_label(fault: &Fault) -> &'static str { + use nexum_world::fault_labels as labels; match fault { - Fault::Unsupported(_) => "unsupported", - Fault::Unavailable(_) => "unavailable", - Fault::Denied(_) => "denied", - Fault::RateLimited(_) => "rate_limited", - Fault::Timeout => "timeout", - Fault::InvalidInput(_) => "invalid_input", - Fault::Internal(_) => "internal", + Fault::Unsupported(_) => labels::UNSUPPORTED, + Fault::Unavailable(_) => labels::UNAVAILABLE, + Fault::Denied(_) => labels::DENIED, + Fault::RateLimited(_) => labels::RATE_LIMITED, + Fault::Timeout => labels::TIMEOUT, + Fault::InvalidInput(_) => labels::INVALID_INPUT, + Fault::Internal(_) => labels::INTERNAL, } } /// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The payload-bearing cases carry their own detail; the two -/// payload-free cases render a fixed phrase. -pub(crate) fn fault_message(fault: &Fault) -> &str { +/// field. The bindgen `Display` is the `{0:?}` debug form, so operator +/// logs render through this instead. The payload-bearing cases carry +/// their own detail; a rate limit keeps its `retry-after-ms` hint; +/// `timeout` renders a fixed phrase. +pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) | Fault::Unavailable(m) | Fault::Denied(m) | Fault::InvalidInput(m) - | Fault::Internal(m) => m, - Fault::RateLimited(_) => "rate limited", - Fault::Timeout => "timeout", + | Fault::Internal(m) => std::borrow::Cow::Borrowed(m), + Fault::RateLimited(rl) => match rl.retry_after_ms { + Some(ms) => std::borrow::Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => std::borrow::Cow::Borrowed("rate limited"), + }, + Fault::Timeout => std::borrow::Cow::Borrowed("timeout"), } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 03610763..8e2d95e4 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -285,6 +285,7 @@ impl ModuleStore { /// Errors surfaced by [`LocalStore`] and [`ModuleStore`]. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StorageError { #[error("open redb: {0}")] Open(#[source] redb::DatabaseError), diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index 74e0760f..b619bf3e 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -61,6 +61,7 @@ impl RunId { /// `source` field on the host tracing event. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum LogSource { /// The `nexum:host/logging` glue: an explicit guest `log` call. HostInterface, diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index b4cccbdd..4a29dd47 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -17,6 +17,14 @@ use alloy_rpc_client as _; use alloy_transport as _; use alloy_transport_ws as _; +/// Sealing markers for [`preset::Runtime`] and +/// [`host::component::RuntimeTypes`]: implement alongside the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedRuntimeTypes {} + pub trait SealedRuntime {} +} + pub mod addons; pub mod bindings; pub mod bootstrap; diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index dc6edeb4..3492634f 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -44,7 +44,8 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { /// moves bytes to and from its counterparty and nothing else. `http` is /// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const PROVIDER_CAPABILITIES: &[&str] = &["chain", "messaging"]; +pub const PROVIDER_CAPABILITIES: &[&str] = + &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; /// The provider namespace: the same `nexum:host/` prefix as core but only /// the scoped-transport interfaces. Validating a provider manifest against @@ -62,7 +63,7 @@ const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = "http"; +const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; /// Gated WASI capability names. Declaring one grants the matching `wasi:` /// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index 470cc0bb..ed5858c7 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -51,6 +51,7 @@ pub struct CapabilityViolation { /// Error returned when a component's WIT imports exceed its declared /// capabilities. #[derive(Debug, Error)] +#[non_exhaustive] pub enum CapabilityError { /// A gated import was not declared in `[capabilities]`. #[error(transparent)] diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index f6fbc95c..1960dc61 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -11,19 +11,13 @@ use serde::Deserialize; use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker. The `http` capability is not a +/// world links into every module linker, emitted from the +/// `nexum-world` capability table. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled /// separately by the registry. Domain-extension capabilities are not /// listed here; each extension contributes its own namespace to the /// [`super::capabilities::CapabilityRegistry`] at the composition root. -pub const CORE_CAPABILITIES: &[&str] = &[ - "chain", - "identity", - "local-store", - "remote-store", - "messaging", - "logging", -]; +pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] pub struct Manifest { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 9ec4dd64..56fe1629 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -30,7 +30,9 @@ use crate::host::provider_pool::ProviderPool; /// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) /// binds a value, so a preset can hand back already-built backends through a /// pass-through builder such as `Prebuilt`. -pub trait Runtime { +/// +/// Sealed: a preset opts in by also implementing the sealing marker. +pub trait Runtime: crate::sealed::SealedRuntime { /// The lattice the preset assembles. type Types: RuntimeTypes; /// Builds the chain backend ([`RuntimeTypes::Chain`]). @@ -73,6 +75,9 @@ pub trait Runtime { #[derive(Debug, Clone, Copy, Default)] pub struct CoreRuntime; +impl crate::sealed::SealedRuntimeTypes for CoreRuntime {} +impl crate::sealed::SealedRuntime for CoreRuntime {} + impl RuntimeTypes for CoreRuntime { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 3f1aa287..3b848306 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -50,6 +50,7 @@ use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; /// supervisor consumes. Library-side code keeps `anyhow::Error` out /// of long-lived stream item types per the rust idiomatic rubric. #[derive(Debug, Error)] +#[non_exhaustive] pub enum StreamError { /// Underlying provider / transport failure while opening or /// pumping the subscription. diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index e7c49ecf..fd369eeb 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -106,6 +106,9 @@ pub struct Supervisor { #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; +#[cfg(test)] +impl crate::sealed::SealedRuntimeTypes for TestTypes {} + #[cfg(test)] impl RuntimeTypes for TestTypes { type Chain = ProviderPool; @@ -738,7 +741,7 @@ impl Supervisor { warn!( module = %module_namespace, kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + message = %crate::host::error::fault_message(&e), "init failed - module loaded but marked dead; dispatcher will skip it", ); false @@ -1483,7 +1486,7 @@ impl Supervisor { block_number, latency_ms, kind, - message = crate::host::error::fault_message(&fault), + message = %crate::host::error::fault_message(&fault), "on-event returned fault", ); metrics::counter!( diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 089d197a..4180b328 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -15,6 +15,8 @@ use crate::test_utils::{MockChainProvider, MockStateStore}; /// it derives no traits and is zero-sized at runtime. pub struct MockTypes(PhantomData E>); +impl crate::sealed::SealedRuntimeTypes for MockTypes {} + impl RuntimeTypes for MockTypes { type Chain = MockChainProvider; type Store = MockStateStore; diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index a5c53524..4118fb81 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -65,6 +65,8 @@ proptest.workspace = true # The keeper never touches the orderbook, so a CoW-layer mock would only drag # the domain crates into this crate's dev graph. nexum-sdk-test = { path = "../nexum-sdk-test" } +# Pins the strum-derived fault labels to the single-source vocabulary. +nexum-world = { path = "../nexum-world" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs index 3f45013b..58ecfae6 100644 --- a/crates/nexum-sdk/src/chain/method.rs +++ b/crates/nexum-sdk/src/chain/method.rs @@ -8,6 +8,7 @@ use strum::{EnumString, IntoStaticStr}; /// WIT edge; [`HostTransport`](super::HostTransport) rejects anything /// outside this set before calling the host. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] pub enum ChainMethod { /// `eth_blockNumber`. #[strum(serialize = "eth_blockNumber")] diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index 4a278cfc..fdfcfc9c 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -20,6 +20,7 @@ use thiserror::Error; /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] +#[non_exhaustive] pub enum ConfigError { /// The key was not present in the `entries` slice. #[error("missing key {key:?}")] diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 82b562d8..9fc0cb68 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -45,7 +45,7 @@ pub enum Fault { Denied(String), /// Rate-limited by an upstream service; may carry backoff guidance /// when the host knows the retry window. - #[error("rate limited")] + #[error("rate limited{}", .0.retry_after_ms.map_or_else(String::new, |ms| format!(", retry after {ms} ms")))] RateLimited(RateLimit), /// Operation took too long. #[error("timeout")] @@ -66,13 +66,32 @@ pub struct RateLimit { pub retry_after_ms: Option, } +/// Sealing markers for [`Host`] and [`HostFault`]: implement alongside +/// the trait. +#[doc(hidden)] +pub mod sealed { + pub trait SealedHost {} + pub trait SealedHostFault {} +} + +impl sealed::SealedHost for T where + T: ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost +{ +} + +impl sealed::SealedHostFault for Fault {} +impl sealed::SealedHostFault for ChainError {} + /// Recovers the shared [`Fault`] from a richer, per-interface error. /// /// Typed interface errors that embed a fault case implement this so a /// caller can dispatch on the structured cause and pull a stable /// snake_case [`label`](HostFault::label) for logs and metrics without /// matching the outer type. -pub trait HostFault { +/// +/// Sealed: an error type opts in by also implementing the sealing +/// marker. +pub trait HostFault: sealed::SealedHostFault { /// The embedded fault, when this value represents one. fn fault(&self) -> Option<&Fault>; /// Stable snake_case label for logs and metrics. @@ -121,6 +140,7 @@ pub struct RpcError { /// [`HostFault`] recovers the embedded [`Fault`] (present only on the /// `Fault` case) and a stable snake_case label for logs and metrics. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum ChainError { /// A shared host fault. #[error(transparent)] @@ -397,8 +417,15 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { /// # } /// record_block(&StubHost, 1, "block:42").unwrap(); /// ``` +/// Sealed: the blanket impl is the only implementation. pub trait Host: - ChainHost + IdentityHost + LocalStoreHost + RemoteStoreHost + MessagingHost + LoggingHost + sealed::SealedHost + + ChainHost + + IdentityHost + + LocalStoreHost + + RemoteStoreHost + + MessagingHost + + LoggingHost { } impl Host for T where @@ -481,15 +508,19 @@ mod tests { } #[test] - fn fault_labels_are_stable_snake_case() { + fn fault_labels_match_the_single_source_vocabulary() { + use nexum_world::fault_labels as labels; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), "unsupported"), - (Fault::Unavailable(String::new()), "unavailable"), - (Fault::Denied(String::new()), "denied"), - (Fault::RateLimited(RateLimit::default()), "rate_limited"), - (Fault::Timeout, "timeout"), - (Fault::InvalidInput(String::new()), "invalid_input"), - (Fault::Internal(String::new()), "internal"), + (Fault::Unsupported(String::new()), labels::UNSUPPORTED), + (Fault::Unavailable(String::new()), labels::UNAVAILABLE), + (Fault::Denied(String::new()), labels::DENIED), + ( + Fault::RateLimited(RateLimit::default()), + labels::RATE_LIMITED, + ), + (Fault::Timeout, labels::TIMEOUT), + (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), + (Fault::Internal(String::new()), labels::INTERNAL), ]; for (fault, label) in cases { assert_eq!(fault.label(), label); @@ -497,6 +528,18 @@ mod tests { } } + #[test] + fn rate_limit_display_carries_the_retry_hint() { + let hinted = Fault::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!(hinted.to_string(), "rate limited, retry after 250 ms"); + assert_eq!( + Fault::RateLimited(RateLimit::default()).to_string(), + "rate limited" + ); + } + #[test] fn host_fault_is_object_safe() { let boxed: Box = Box::new(Fault::Timeout); diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index 7ca0c551..e4a6dec0 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -56,6 +56,7 @@ impl Default for FetchOptions { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum FetchError { /// The host's `[capabilities.http].allow` list refused the request /// before any connection was made. diff --git a/crates/nexum-status-body/src/lib.rs b/crates/nexum-status-body/src/lib.rs index d5cc4fab..2bd9c009 100644 --- a/crates/nexum-status-body/src/lib.rs +++ b/crates/nexum-status-body/src/lib.rs @@ -97,6 +97,7 @@ pub struct EncodeError { /// Why bytes failed to decode as a status body. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] pub enum DecodeError { /// No bytes at all: not even a version tag. #[error("empty status body: missing the version tag")] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index aae0b9db..5ca9e1ca 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -17,6 +17,54 @@ use std::path::{Path, PathBuf}; +/// Capability name consts: the single source the [`CORE`] table and the +/// runtime's capability registry emit from. +pub mod caps { + /// `nexum:host/chain`. + pub const CHAIN: &str = "chain"; + /// `nexum:host/identity`. + pub const IDENTITY: &str = "identity"; + /// `nexum:host/local-store`. + pub const LOCAL_STORE: &str = "local-store"; + /// `nexum:host/remote-store`. + pub const REMOTE_STORE: &str = "remote-store"; + /// `nexum:host/messaging`. + pub const MESSAGING: &str = "messaging"; + /// `nexum:host/logging`. + pub const LOGGING: &str = "logging"; + /// Gates `wasi:http/*`; no world import. + pub const HTTP: &str = "http"; +} + +/// Snake_case labels of the `nexum:host/types.fault` cases, in +/// declaration order: the single source every label mirror emits from. +pub mod fault_labels { + /// `fault.unsupported`. + pub const UNSUPPORTED: &str = "unsupported"; + /// `fault.unavailable`. + pub const UNAVAILABLE: &str = "unavailable"; + /// `fault.denied`. + pub const DENIED: &str = "denied"; + /// `fault.rate-limited`. + pub const RATE_LIMITED: &str = "rate_limited"; + /// `fault.timeout`. + pub const TIMEOUT: &str = "timeout"; + /// `fault.invalid-input`. + pub const INVALID_INPUT: &str = "invalid_input"; + /// `fault.internal`. + pub const INTERNAL: &str = "internal"; + /// All seven, in declaration order. + pub const ALL: [&str; 7] = [ + UNSUPPORTED, + UNAVAILABLE, + DENIED, + RATE_LIMITED, + TIMEOUT, + INVALID_INPUT, + INTERNAL, + ]; +} + /// One manifest capability and its world wiring. pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. @@ -38,49 +86,79 @@ pub struct Capability { /// core registry and nothing else; extension rows are the caller's. pub const CORE: &[Capability] = &[ Capability { - name: "chain", + name: caps::CHAIN, import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { - name: "identity", + name: caps::IDENTITY, import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: Some("identity"), }, Capability { - name: "local-store", + name: caps::LOCAL_STORE, import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { - name: "remote-store", + name: caps::REMOTE_STORE, import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: Some("remote_store"), }, Capability { - name: "messaging", + name: caps::MESSAGING, import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: Some("messaging"), }, Capability { - name: "logging", + name: caps::LOGGING, import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, Capability { - name: "http", + name: caps::HTTP, import: None, packages: &[], adapter: None, }, ]; +/// Number of import-bearing [`CORE`] rows. +const fn core_iface_count() -> usize { + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + n += 1; + } + i += 1; + } + n +} + +/// Names of the import-bearing [`CORE`] rows, in emission order: the +/// `nexum:host` interface set the runtime's capability registry +/// enforces. `http` is absent (no world import). +pub const CORE_IFACES: [&str; core_iface_count()] = { + let mut out = [""; core_iface_count()]; + let mut n = 0; + let mut i = 0; + while i < CORE.len() { + if CORE[i].import.is_some() { + out[n] = CORE[i].name; + n += 1; + } + i += 1; + } + out +}; + /// One registered extension row: a per-namespace capability a /// composition root declares in its `extensions.toml`. An extension /// always has a WIT import and never a host-adapter ident (adapter @@ -411,6 +489,33 @@ mod tests { assert!(err.contains("extension capability `acme` collides")); } + #[test] + fn core_ifaces_are_the_import_bearing_rows() { + assert_eq!( + CORE_IFACES, + [ + caps::CHAIN, + caps::IDENTITY, + caps::LOCAL_STORE, + caps::REMOTE_STORE, + caps::MESSAGING, + caps::LOGGING, + ], + ); + assert!(!CORE_IFACES.contains(&caps::HTTP)); + } + + #[test] + fn fault_labels_are_snake_case_and_distinct() { + for label in fault_labels::ALL { + assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); + } + let mut labels = fault_labels::ALL.to_vec(); + labels.sort_unstable(); + labels.dedup(); + assert_eq!(labels.len(), fault_labels::ALL.len()); + } + #[test] fn core_table_carries_no_extension_row() { assert!( diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 4612f12b..99080dac 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -27,6 +27,8 @@ const SEPOLIA: u64 = 11_155_111; #[derive(Debug, Clone, Copy, Default)] struct CowTestTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} + impl RuntimeTypes for CowTestTypes { type Chain = ProviderPool; type Store = LocalStore; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index a7a4674e..fdb28adf 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -181,7 +181,9 @@ impl LegacyRevertAdapter { } _ => Verdict::TryNextBlock { reason: [0; 4] }, }, - ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + // `ChainError` is `#[non_exhaustive]`: transport faults and + // any future case are payload-free, so they stay retryable. + _ => Verdict::TryNextBlock { reason: [0; 4] }, } } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index b626ab94..3f676861 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -67,6 +67,8 @@ pub enum CowApiError { Rejected(OrderRejection), } +impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} + impl HostFault for CowApiError { fn fault(&self) -> Option<&Fault> { match self { diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 1d216924..19e00a6d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -33,8 +33,8 @@ pub use run::run; /// codec, re-exported from the `cow-venue` default slice. The shim keeps /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ - BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, OrderKind, - SellTokenSource, + BuyToken, BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, + OrderBuilder, OrderKind, SellToken, SellTokenSource, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 9a32d9fd..f7c629cd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -23,6 +23,8 @@ use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; #[derive(Debug, Clone, Copy, Default)] struct ReferenceTypes; +impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} + impl RuntimeTypes for ReferenceTypes { type Chain = ProviderPool; type Store = LocalStore; @@ -34,6 +36,8 @@ impl RuntimeTypes for ReferenceTypes { #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; +impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} + impl Runtime for ShepherdRuntime { type Types = ReferenceTypes; type ChainBuilder = ProviderPoolBuilder; diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index c6281016..c07618a9 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -77,6 +77,45 @@ pub use venue_adapter::videre::types::types::{ /// The value-flow vocabulary the header is expressed in. pub use venue_adapter::videre::value_flow::types as value_flow; +/// Operator-log rendering of the wire `venue-error`: the bindgen +/// `Display` is the `{0:?}` debug form, so logs render through this +/// instead and the rate-limit `retry-after-ms` hint survives. +pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { + use std::borrow::Cow; + match err { + VenueError::UnknownVenue => Cow::Borrowed("unknown venue"), + VenueError::InvalidBody(detail) => Cow::Owned(format!("invalid body: {detail}")), + VenueError::Unsupported => Cow::Borrowed("unsupported"), + VenueError::Denied(detail) => Cow::Owned(format!("denied: {detail}")), + VenueError::RateLimited(rate_limit) => match rate_limit.retry_after_ms { + Some(ms) => Cow::Owned(format!("rate limited, retry after {ms} ms")), + None => Cow::Borrowed("rate limited"), + }, + VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), + VenueError::Timeout => Cow::Borrowed("timeout"), + } +} + +#[cfg(test)] +mod display_smoke { + use super::{RateLimit, VenueError, venue_error_message}; + + #[test] + fn venue_error_message_keeps_the_rate_limit_hint() { + let hinted = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + assert_eq!( + venue_error_message(&hinted), + "rate limited, retry after 250 ms" + ); + let unhinted = VenueError::RateLimited(RateLimit { + retry_after_ms: None, + }); + assert_eq!(venue_error_message(&unhinted), "rate limited"); + } +} + /// Bindgen smoke for the `videre:value-flow` types package, compiled under /// test through a throwaway world that imports the interface. Its value is /// the identifier-hygiene gate: the test names every generated type, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index d4228cbe..0f1057e9 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -575,7 +575,7 @@ impl VenueRegistry { Err(err) => { warn!( venue = %venue, - error = ?err, + error = %crate::bindings::venue_error_message(&err), "status poll failed - retrying on the next cadence", ); } @@ -737,7 +737,8 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - fault = ?e, + kind = nexum_runtime::host::error::fault_label(&e), + fault = %nexum_runtime::host::error::fault_message(&e), "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); diff --git a/crates/videre-sdk/src/body.rs b/crates/videre-sdk/src/body.rs index eb3a64dd..33c40378 100644 --- a/crates/videre-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -38,6 +38,7 @@ pub trait IntentBody: Sized + __private::Derived { /// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum BodyError { /// No bytes at all: not even a version tag. #[error("empty body: missing the version tag")] diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index baa40118..c3e9b1af 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -75,11 +75,20 @@ pub trait Venue { type Body: IntentBody; } +/// Sealing marker for [`VenueTransport`]: a transport opts in by also +/// implementing it. +#[doc(hidden)] +pub mod sealed { + pub trait SealedTransport {} +} + /// The byte-level seam under the typed client: `videre:venue/client` /// with the venue named per call. Native AFIT, so a [`VenueClient`] /// over any transport dispatches statically. [`HostVenues`] binds it to /// the module's own import; tests implement it in memory. -pub trait VenueTransport { +/// +/// Sealed: a transport opts in by also implementing the sealing marker. +pub trait VenueTransport: sealed::SealedTransport { /// Price an opaque intent body at the named venue. fn quote( &self, @@ -117,6 +126,8 @@ pub trait VenueTransport { #[derive(Clone, Copy, Debug, Default)] pub struct HostVenues; +impl sealed::SealedTransport for HostVenues {} + impl VenueTransport for HostVenues { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { shims::quote(venue.as_str(), &body).map_err(VenueFault::from) diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index 00d6840c..ac57203f 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -163,9 +163,9 @@ impl From for VenueError { match err { FetchError::Denied => VenueError::Denied(err.to_string()), FetchError::Timeout(_) => VenueError::Timeout, - FetchError::Transport(_) | FetchError::InvalidRequest(_) => { - VenueError::Unavailable(err.to_string()) - } + // `FetchError` is `#[non_exhaustive]`: a future transport + // case folds to retryable `unavailable` with its detail. + _ => VenueError::Unavailable(err.to_string()), } } } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 3240faed..f94a88f8 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -237,6 +237,8 @@ mod tests { } } + impl crate::client::sealed::SealedTransport for &StubVenue {} + impl VenueTransport for &StubVenue { async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { unreachable!("quote not exercised") diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index 9c0814f9..0c490331 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -141,6 +141,8 @@ impl Venue for NowhereVenue { /// binds. struct InProcessClient; +impl videre_sdk::client::sealed::SealedTransport for InProcessClient {} + impl VenueTransport for InProcessClient { async fn quote(&self, venue: &VenueId, body: Vec) -> Result { if venue.as_str() != "demo" { diff --git a/crates/videre-test/src/fixture.rs b/crates/videre-test/src/fixture.rs index 624ec1c9..6b2b5118 100644 --- a/crates/videre-test/src/fixture.rs +++ b/crates/videre-test/src/fixture.rs @@ -54,6 +54,7 @@ where /// serde's rendered detail rather than the error value so the type /// stays independent of `serde_json`'s feature set. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum FixtureError { /// The file could not be read. #[error("failed to read {path}: {source}")] diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 09eb6980..3d3d80b7 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -86,7 +86,9 @@ fn fetch_err(url: &str, error: &FetchError) -> Fault { FetchError::Denied => Fault::Denied(detail), FetchError::InvalidRequest(_) => Fault::InvalidInput(detail), FetchError::Timeout(_) => Fault::Timeout, - FetchError::Transport(_) => Fault::Unavailable(detail), + // `FetchError` is `#[non_exhaustive]`: a future case folds to + // retryable `unavailable` with its detail. + _ => Fault::Unavailable(detail), } } From 3588bdb2fc6a53ca1fdfb09168729aa46158b216 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 16:07:09 +0000 Subject: [PATCH 062/139] runtime: make the module event type extensible via a generic custom channel (#520) * runtime: extract intent-status behind a generic custom event The core nexum:host `event` variant dropped `intent-status(intent-status-update)` for a generic `custom(custom-event)` channel, so the venue-agnostic core no longer names venue/receipt at the WIT boundary and a second extension can add its own event kind with no core WIT change. The intent-status payload moved into videre's own WIT (`videre:types` intent-status-update) and rides the custom channel: videre-host emits `custom` with kind "intent-status" and the borsh envelope as payload, and `videre_sdk::event::intent_status_update` recovers it typed by matching the kind and decoding. nexum-status-body gained the shared kind const and the envelope codec while the status body stays the payload's inner encoding. The core `#[module]` macro swapped its `on_intent_status` handler for a generic `on_custom`; the `#[keeper]` macro keeps the typed `on_intent_status`, decoding it from the custom event. check-venue-agnostic gained a WIT-vocabulary gate that fails on venue/receipt/intent-status in nexum:host. Closes #518 * sdk: move the venue status codec into the videre layer The status-body codec is wholly venue-domain: the IntentStatus lifecycle at the venue, the venue-reported FailReason, the opaque StatusBody bytes, and the {venue, receipt, status} IntentStatusUpdate envelope keyed by INTENT_STATUS_KIND. Rename the crate nexum-status-body to videre-status-body so it sits in the videre (L2) layer, and stop the generic guest SDK re-exporting it. nexum-sdk no longer depends on the codec nor re-exports it as status_body, so venue vocabulary no longer leaks into the venue-agnostic guest SDK. The guest-facing surface now lives on videre-sdk, which depends on videre-status-body and re-exports it as videre_sdk::status_body; the event decode path and the keeper macro reach it there. Venue-consuming example modules gain a videre-sdk dependency and decode through videre_sdk::status_body. Extend check-venue-agnostic.sh with a nexum-sdk scan: its crate graph must reach no videre or venue crate, and its sources must name none of the intent-status codec vocabulary. The wire form and behaviour are unchanged; the intent-status E2E tests pass unaltered. * sdk: drop the dead intent-status wit record and generalise the example The videre:types intent-status-update record was declaration-only: wit-bindgen prunes it (no world function references it, the payload rides as opaque bytes), so it generated no binding; the wire contract lives in videre-status-body's Rust type. The example module decoded intent-status in on_custom despite never submitting an intent, pulling videre-sdk in gratuitously. It now logs the raw custom event generically and drops the videre-sdk dependency. echo-client keeps its decode: it submits through videre:venue/client. --- Cargo.lock | 21 +++--- Cargo.toml | 2 +- crates/nexum-module-macros/src/lib.rs | 10 +-- crates/nexum-sdk/Cargo.toml | 3 - crates/nexum-sdk/src/lib.rs | 8 --- crates/videre-host/Cargo.toml | 2 +- crates/videre-host/src/lib.rs | 28 ++++++-- crates/videre-host/src/registry.rs | 13 ++-- crates/videre-host/tests/platform.rs | 55 ++++++++++---- crates/videre-macros/src/keeper.rs | 33 ++++++++- crates/videre-sdk/Cargo.toml | 3 + crates/videre-sdk/src/event.rs | 72 +++++++++++++++++++ crates/videre-sdk/src/lib.rs | 18 +++++ .../Cargo.toml | 2 +- .../src/lib.rs | 45 ++++++++++++ modules/example/src/lib.rs | 11 ++- modules/examples/echo-client/Cargo.toml | 3 + modules/examples/echo-client/src/lib.rs | 9 ++- modules/examples/echo-keeper/src/lib.rs | 4 +- scripts/check-venue-agnostic.sh | 47 ++++++++++-- wit/nexum-host/types.wit | 27 ++++--- 21 files changed, 328 insertions(+), 88 deletions(-) create mode 100644 crates/videre-sdk/src/event.rs rename crates/{nexum-status-body => videre-status-body}/Cargo.toml (93%) rename crates/{nexum-status-body => videre-status-body}/src/lib.rs (82%) diff --git a/Cargo.lock b/Cargo.lock index 242c99f1..53a199de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2156,6 +2156,7 @@ name = "echo-client" version = "0.1.0" dependencies = [ "nexum-sdk", + "videre-sdk", "wit-bindgen 0.58.0", ] @@ -3667,7 +3668,6 @@ dependencies = [ "http", "nexum-module-macros", "nexum-sdk-test", - "nexum-status-body", "nexum-world", "proptest", "serde_json", @@ -3687,14 +3687,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "nexum-status-body" -version = "0.1.0" -dependencies = [ - "borsh", - "thiserror 2.0.18", -] - [[package]] name = "nexum-tasks" version = "0.1.0" @@ -6045,7 +6037,6 @@ dependencies = [ "async-trait", "futures", "nexum-runtime", - "nexum-status-body", "nexum-tasks", "serde", "tempfile", @@ -6053,6 +6044,7 @@ dependencies = [ "tokio", "toml 1.1.2+spec-1.1.0", "tracing", + "videre-status-body", "wasmtime", ] @@ -6076,9 +6068,18 @@ dependencies = [ "strum", "thiserror 2.0.18", "videre-macros", + "videre-status-body", "wit-bindgen 0.59.0", ] +[[package]] +name = "videre-status-body" +version = "0.1.0" +dependencies = [ + "borsh", + "thiserror 2.0.18", +] + [[package]] name = "videre-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 94377aa5..54cf5075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,6 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", - "crates/nexum-status-body", "crates/nexum-tasks", "crates/nexum-world", "crates/no-std-probe", @@ -19,6 +18,7 @@ members = [ "crates/videre-host", "crates/videre-macros", "crates/videre-sdk", + "crates/videre-status-body", "crates/videre-test", "modules/ethflow-watcher", "modules/example", diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index bb90eb72..ca2d73aa 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -28,14 +28,14 @@ const HANDLERS: [&str; 6] = [ "on_chain_logs", "on_tick", "on_message", - "on_intent_status", + "on_custom", ]; /// 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`, `on_intent_status`). Each handler takes the wit-bindgen +/// `on_message`, `on_custom`). 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 @@ -138,7 +138,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`, `on_intent_status`", + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`", ) .to_compile_error() .into(); @@ -201,7 +201,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"); + let custom_arm = arm("on_custom", "Custom"); quote! { // Anchor a rebuild on the manifest and the extension registry: @@ -232,7 +232,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { #logs_arm #tick_arm #message_arm - #intent_status_arm + #custom_arm } } } diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 4118fb81..07260cfd 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -23,9 +23,6 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-module-macros = { path = "../nexum-module-macros" } -# Decoder for the opaque status body an `intent-status` event carries; -# re-exported as `nexum_sdk::status_body`. -nexum-status-body = { path = "../nexum-status-body" } alloy-primitives.workspace = true # Typed EIP-155 chain id; already in the guest graph via alloy-provider. alloy-chains.workspace = true diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index cce15425..201aa1fd 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -51,9 +51,6 @@ //! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind //! macro fills to rebuild it from the wire record. //! -//! - [`status_body`] - decoder for the opaque versioned status body an -//! `intent-status` event carries ([`StatusBody`]). -//! //! - [`config`] - `(key, value)` config-table lookups and decimal //! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). //! @@ -110,7 +107,6 @@ //! [`read_latest_answer`]: chain::chainlink::read_latest_answer //! [`Log`]: events::Log //! [`ChainLogParts`]: events::ChainLogParts -//! [`StatusBody`]: status_body::StatusBody //! [`get_required`]: config::get_required //! [`get_optional`]: config::get_optional //! [`scale_decimal`]: config::scale_decimal @@ -141,10 +137,6 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; -/// The opaque status-body codec: decode an `intent-status` event's -/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). -pub use nexum_status_body as status_body; - /// The level vocabulary for every SDK log path: the host logging trait, /// the guest tracing facade sink, and the module mocks all speak /// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index 8db90362..a8c6dd11 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -14,7 +14,7 @@ workspace = true nexum-runtime = { path = "../nexum-runtime" } # Encoder for the opaque status body the host event stream carries; the # registry lowers an adapter-reported status through it. -nexum-status-body = { path = "../nexum-status-body" } +videre-status-body = { path = "../videre-status-body" } wasmtime.workspace = true anyhow.workspace = true diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index d228e27a..34187ca9 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -14,7 +14,7 @@ mod registry; use std::sync::Arc; use std::time::Duration; -use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::bindings::nexum::host::types::{CustomEvent, Event}; use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::RuntimeTypes; use nexum_runtime::host::extension::{ @@ -24,6 +24,8 @@ use nexum_runtime::host::extension::{ use nexum_runtime::host::state::HostState; use nexum_runtime::manifest::{ExtensionSections, NamespaceCaps}; use tokio::sync::mpsc; +use tracing::warn; +use videre_status_body::INTENT_STATUS_KIND; use wasmtime::component::{HasSelf, Linker}; pub use registry::{ @@ -31,9 +33,6 @@ pub use registry::{ VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, }; -/// The subscription kind the platform's status poller emits. -const INTENT_STATUS_KIND: &str = "intent-status"; - /// Buffer for the status poll channel; small because the event loop /// drains in real time. const STATUS_CHANNEL_BUF: usize = 64; @@ -155,10 +154,27 @@ async fn status_poll_task( loop { tokio::time::sleep(cadence).await; for update in registry.poll_status_transitions().await { + let attrs = vec![("venue", update.venue.clone())]; + // The transition rides the generic `custom` channel: the + // envelope is borsh, the status body its inner encoding. A + // keeper recovers it through `videre_sdk::event`. + let payload = match update.encode() { + Ok(payload) => payload, + Err(err) => { + warn!( + error = %err, + "intent-status envelope failed to encode - dropping transition", + ); + continue; + } + }; let event = ExtensionEvent { kind: INTENT_STATUS_KIND, - attrs: vec![("venue", update.venue.clone())], - event: Event::IntentStatus(update), + attrs, + event: Event::Custom(CustomEvent { + kind: INTENT_STATUS_KIND.to_owned(), + payload, + }), }; if tx.send(event).await.is_err() { // Receiver dropped -> engine shutting down. diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 0f1057e9..d872d804 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -39,15 +39,16 @@ use nexum_runtime::host::extension::{ HostService, Installed, ProviderInstance, ProviderKind, downcast_service, }; use nexum_runtime::host::state::HostState; -use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn}; +use videre_status_body::StatusBody; use wasmtime::Store; use wasmtime::component::HasSelf; -/// The registry-observed status transition delivered through the host -/// `event` variant, re-exported at the spelling the registry names. -pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; +/// The registry-observed status transition, carried in the `custom` +/// event's opaque payload, re-exported at the spelling the registry +/// names. +pub use videre_status_body::IntentStatusUpdate; use crate::bindings::{ IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, @@ -291,7 +292,7 @@ fn is_terminal(status: IntentStatus) -> bool { /// stream carries. The registry attests the lifecycle state alone; proof /// and failure reason ride the body only when the venue supplies them. fn status_body(status: IntentStatus) -> StatusBody { - use nexum_status_body::IntentStatus as Lifecycle; + use videre_status_body::IntentStatus as Lifecycle; let status = match status { IntentStatus::Pending => Lifecycle::Pending, @@ -857,7 +858,7 @@ pub struct DuplicateVenue { mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; - use nexum_status_body::IntentStatus as Lifecycle; + use videre_status_body::IntentStatus as Lifecycle; use crate::bindings::value_flow::{Asset, AssetAmount}; use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 9c44c425..c068fdca 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -99,12 +99,19 @@ fn block(chain_id: u64) -> nexum::host::types::Block { } } -/// Wrap a polled transition as the extension event the platform emits. +/// Wrap a polled transition as the extension event the platform emits: +/// the transition rides the generic `custom` channel, its borsh envelope +/// the opaque payload. fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + let attrs = vec![("venue", update.venue.clone())]; + let payload = update.encode().expect("encode intent-status envelope"); ExtensionEvent { kind: INTENT_STATUS, - attrs: vec![("venue", update.venue.clone())], - event: nexum::host::types::Event::IntentStatus(update), + attrs, + event: nexum::host::types::Event::Custom(nexum::host::types::CustomEvent { + kind: INTENT_STATUS.to_owned(), + payload, + }), } } @@ -244,6 +251,26 @@ fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { /// Write a manifest subscribing the example module to intent-status /// events from the `cow` venue. +fn echo_client_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + fn intent_status_manifest(dir: &Path) -> PathBuf { let manifest = dir.join("module.toml"); std::fs::write( @@ -331,8 +358,8 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { let foreign = videre_host::IntentStatusUpdate { venue: "other".to_owned(), receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, + status: videre_status_body::StatusBody { + status: videre_status_body::IntentStatus::Open, proof: None, reason: None, } @@ -355,11 +382,11 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { async fn e2e_intent_status_flows_through_the_event_loop() { use nexum_tasks::{TaskManager, TaskSet}; - let Some(wasm) = module_wasm_or_skip("example") else { + let Some(wasm) = module_wasm_or_skip("echo-client") else { return; }; let dir = tempfile::tempdir().expect("tempdir"); - let manifest = intent_status_manifest(dir.path()); + let manifest = echo_client_status_manifest(dir.path()); let registry = scripted_registry(ScriptedAdapter::new([])); let videre = Arc::new(Videre::from_registry(registry.clone())); @@ -419,14 +446,14 @@ async fn e2e_intent_status_flows_through_the_event_loop() { .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 runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for the echo-client 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: {:?}", + .any(|r| r.message.contains("intent status from venue cow")), + "the module's on_custom handler decoded the transition; records were: {:?}", page.records .iter() .map(|r| r.message.as_str()) @@ -536,11 +563,11 @@ async fn e2e_echo_module_registry_adapter_round_trip() { for _ in 0..2 { for update in registry.poll_status_transitions().await { assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + let body = videre_status_body::StatusBody::decode(&update.status) + .expect("status body decodes"); assert_eq!( body.status, - nexum_status_body::IntentStatus::Fulfilled, + videre_status_body::IntentStatus::Fulfilled, "echo settles instantly", ); delivered += supervisor diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs index 568796d8..9d21c79f 100644 --- a/crates/videre-macros/src/keeper.rs +++ b/crates/videre-macros/src/keeper.rs @@ -165,7 +165,36 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { 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"); + // The intent-status transition rides the generic `custom` channel; + // recover it typed through `videre_sdk::event` and dispatch to the + // keeper's `on_intent_status` when the kind matches. A malformed + // payload is the caller's `invalid-input`; a foreign kind is another + // extension's event and no-ops. + let custom_arm = match handler("on_intent_status") { + Some((_, is_async)) => { + let call = quote! { <#self_ty>::on_intent_status(update) }; + let body = if is_async { drive(call) } else { call }; + quote! { + nexum::host::types::Event::Custom(payload) => { + match ::videre_sdk::event::intent_status_update( + &payload.kind, + &payload.payload, + ) { + ::core::option::Option::Some(::core::result::Result::Ok(update)) => #body, + ::core::option::Option::Some(::core::result::Result::Err(err)) => { + ::core::result::Result::Err(nexum::host::types::Fault::InvalidInput( + ::std::string::ToString::to_string(&err), + )) + } + ::core::option::Option::None => ::core::result::Result::Ok(()), + } + } + } + } + None => quote! { + nexum::host::types::Event::Custom(_) => ::core::result::Result::Ok(()), + }, + }; Ok(quote! { // Anchor a rebuild on the manifest and the extension registry: @@ -210,7 +239,7 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { #logs_arm #tick_arm #message_arm - #intent_status_arm + #custom_arm } } } diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 54701fd4..41755813 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -28,6 +28,9 @@ videre-macros = { path = "../videre-macros" } # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. nexum-sdk = { path = "../nexum-sdk" } +# The venue status-body codec, re-exported as `videre_sdk::status_body`; +# venue guests reach the `intent-status` decode path through here. +videre-status-body = { path = "../videre-status-body" } strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs new file mode 100644 index 00000000..fe450ba6 --- /dev/null +++ b/crates/videre-sdk/src/event.rs @@ -0,0 +1,72 @@ +//! Typed recovery of videre events from the core `custom` channel. +//! +//! A venue transition reaches a module as a `custom` event; this module +//! decodes it back into the typed [`IntentStatusUpdate`] a keeper handler +//! works with. The `#[keeper]` macro calls it for `on_intent_status`. + +use crate::IntentStatusUpdate; + +/// The `custom-event.kind` an intent-status transition rides on. +pub use crate::status_body::INTENT_STATUS_KIND; + +/// Why an intent-status `custom` payload did not decode. +pub use crate::status_body::EnvelopeError; + +/// Recover an [`IntentStatusUpdate`] from a `custom` event, keyed by its +/// `kind` and `payload`. `None` when the kind is another extension's; +/// `Some(Err)` when the payload is malformed. +pub fn intent_status_update( + kind: &str, + payload: &[u8], +) -> Option> { + if kind != INTENT_STATUS_KIND { + return None; + } + Some(IntentStatusUpdate::decode(payload)) +} + +#[cfg(test)] +mod tests { + use crate::status_body::{IntentStatus, StatusBody}; + + use super::*; + + fn envelope() -> Vec { + IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: b"receipt".to_vec(), + status: StatusBody { + status: IntentStatus::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("encode body"), + } + .encode() + .expect("encode envelope") + } + + #[test] + fn recovers_a_matching_kind() { + let recovered = intent_status_update(INTENT_STATUS_KIND, &envelope()) + .expect("kind matches") + .expect("payload decodes"); + assert_eq!(recovered.venue, "cow"); + assert_eq!(recovered.receipt, b"receipt"); + } + + #[test] + fn ignores_a_foreign_kind() { + assert!(intent_status_update("other-kind", &envelope()).is_none()); + } + + #[test] + fn reports_a_malformed_payload() { + assert!( + intent_status_update(INTENT_STATUS_KIND, b"\xff") + .expect("kind matches") + .is_err() + ); + } +} diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 149f0a3e..c4be49d6 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -45,6 +45,14 @@ //! fault, the SDK-neutral fault, and [`VenueError`]; plus //! [`VenueFault`], the owned client-side mirror. //! +//! - [`event`] - typed recovery of videre events from the core `custom` +//! escape hatch: [`event::intent_status_update`] decodes an +//! intent-status transition a keeper subscribes to. +//! +//! - [`status_body`] - the venue status-body codec: decode an +//! `intent-status` event's `status` bytes into a typed +//! [`StatusBody`](status_body::StatusBody). +//! //! ## Why the bindgen lives in this crate //! //! The shared interfaces generate once, in [`bindings`], from an @@ -67,6 +75,7 @@ pub mod bindings; pub mod adapter; pub mod body; pub mod client; +pub mod event; pub mod faults; pub mod keeper; pub mod rt; @@ -105,6 +114,15 @@ pub use bindings::videre::types::types::{ }; /// The value-flow vocabulary intent headers are expressed in. pub use bindings::videre::value_flow::types as value_flow; +/// The venue status-body codec: decode an `intent-status` event's +/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +pub use videre_status_body as status_body; + +/// The intent-status transition a keeper recovers from a `custom` event +/// through [`event::intent_status_update`]. Its wire form is a borsh +/// envelope defined by this struct, not a WIT record: it crosses the +/// `custom` event as opaque bytes. The status body rides its inner codec. +pub use videre_status_body::IntentStatusUpdate; /// The wire config table (`nexum:host/types.config`) `init` receives. pub use bindings::nexum::host::types::Config; diff --git a/crates/nexum-status-body/Cargo.toml b/crates/videre-status-body/Cargo.toml similarity index 93% rename from crates/nexum-status-body/Cargo.toml rename to crates/videre-status-body/Cargo.toml index cd4361b3..67967b12 100644 --- a/crates/nexum-status-body/Cargo.toml +++ b/crates/videre-status-body/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nexum-status-body" +name = "videre-status-body" version = "0.1.0" edition.workspace = true license.workspace = true diff --git a/crates/nexum-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs similarity index 82% rename from crates/nexum-status-body/src/lib.rs rename to crates/videre-status-body/src/lib.rs index 2bd9c009..271b6f22 100644 --- a/crates/nexum-status-body/src/lib.rs +++ b/crates/videre-status-body/src/lib.rs @@ -15,6 +15,51 @@ use borsh::{BorshDeserialize, BorshSerialize}; /// Wire tag of the v1 payload. pub const VERSION_V1: u8 = 1; +/// The extension event kind an intent-status transition rides on: the +/// `custom-event.kind` the venue platform stamps and a subscribing +/// module matches. Shared by the emit side and the decode side so the +/// one string is never spelt twice. +pub const INTENT_STATUS_KIND: &str = "intent-status"; + +/// The intent-status transition an intent-status `custom` event carries +/// in its opaque payload: borsh `{venue, receipt, status}`, where +/// `status` is a [`StatusBody`]-encoded body (the inner codec above). +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] +pub struct IntentStatusUpdate { + /// Venue id the receipt was issued by. + pub venue: String, + /// The venue-scoped intent identifier, opaque to the host. + pub receipt: Vec, + /// The [`StatusBody`]-encoded status body. + pub status: Vec, +} + +impl IntentStatusUpdate { + /// Borsh-encode the envelope. + pub fn encode(&self) -> Result, EncodeError> { + let mut out = Vec::new(); + borsh::to_writer(&mut out, self).map_err(|err| EncodeError { + detail: err.to_string(), + })?; + Ok(out) + } + + /// Decode a borsh envelope, failing typedly on malformed bytes. + pub fn decode(bytes: &[u8]) -> Result { + borsh::from_slice(bytes).map_err(|err| EnvelopeError { + detail: err.to_string(), + }) + } +} + +/// Why bytes failed to decode as an [`IntentStatusUpdate`] envelope. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[error("malformed intent-status envelope: {detail}")] +pub struct EnvelopeError { + /// Borsh's decode failure detail. + pub detail: String, +} + /// Where an intent is in its life at the venue. The borsh discriminant /// is the wire form: append new states, never reorder. #[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index da1ddd26..5597f442 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -67,16 +67,13 @@ impl ExampleModule { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) - .map_err(|err| Fault::InvalidInput(err.to_string()))?; + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { logging::log( logging::Level::Info, &format!( - "intent status update from venue {}: {:?} ({} receipt bytes)", - update.venue, - body.status, - update.receipt.len(), + "custom event kind {} ({} payload bytes)", + event.kind, + event.payload.len(), ), ); Ok(()) diff --git a/modules/examples/echo-client/Cargo.toml b/modules/examples/echo-client/Cargo.toml index f4cf47a0..c5c20896 100644 --- a/modules/examples/echo-client/Cargo.toml +++ b/modules/examples/echo-client/Cargo.toml @@ -14,4 +14,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } +# The venue status-body codec the `on_custom` handler decodes an +# intent-status transition through, reached via `videre_sdk::status_body`. +videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 185e22da..33025943 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -68,8 +68,13 @@ impl EchoClient { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + fn on_custom(event: types::CustomEvent) -> Result<(), Fault> { + if event.kind != videre_sdk::status_body::INTENT_STATUS_KIND { + return Ok(()); + } + let update = videre_sdk::status_body::IntentStatusUpdate::decode(&event.payload) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + let body = videre_sdk::status_body::StatusBody::decode(&update.status) .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, diff --git a/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs index 05e41619..b840f7c0 100644 --- a/modules/examples/echo-keeper/src/lib.rs +++ b/modules/examples/echo-keeper/src/lib.rs @@ -92,8 +92,8 @@ impl EchoKeeper { Ok(()) } - fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { - let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + let body = videre_sdk::status_body::StatusBody::decode(&update.status) .map_err(|err| Fault::InvalidInput(err.to_string()))?; logging::log( logging::Level::Info, diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index cf1df1b7..fe52c029 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -5,9 +5,9 @@ # charter symbol # (videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow) # and no privileged router field; and nexum:host names no foreign WIT -# package and resolves as a leaf. The opaque-status envelope -# (intent-status-update, its venue id string) is ratified host surface, -# not a leak. Blocking in CI; run locally via `just check-venue-agnostic`. +# package, carries no venue-domain vocabulary (venue|receipt|intent-status), +# and resolves as a leaf. Blocking in CI; run locally via +# `just check-venue-agnostic`. set -uo pipefail @@ -38,6 +38,33 @@ for crate in nexum-runtime nexum-launch nexum-cli; do fi done +# 1b. Guest SDK: the generic guest SDK stays venue-free too. Its crate +# graph must reach no videre/venue crate (normal + build edges), so +# the venue status-codec never re-enters the domain-free SDK through a +# dependency; and its sources must name none of the intent-status +# codec's own vocabulary. The symbol scan is curated (not a bare +# `venue|receipt` sweep): the keeper stores legitimately speak of +# receipt-keyed journals and generic venue prose, so only the codec's +# distinctive names flag. +if tree="$(cargo tree -p nexum-sdk -e normal,build --all-features --prefix none --locked)"; then + reached="$(printf '%s\n' "$tree" | + awk '{print $1}' | sort -u | rg -i 'videre|intent|venue|cow' || true)" + if [[ -n $reached ]]; then + fail "nexum-sdk crate graph reaches: $(tr '\n' ' ' <<<"$reached")" + else + pass "nexum-sdk crate graph clean" + fi +else + fail "cargo tree failed for nexum-sdk" +fi +sdk_charter='intent-status|IntentStatusUpdate|INTENT_STATUS_KIND|[Ss]tatus[Bb]ody|status[-_]body' +rg -n --no-heading -e "$sdk_charter" crates/nexum-sdk/src +case $? in + 0) fail "venue status-codec vocabulary leaks into nexum-sdk" ;; + 1) pass "nexum-sdk carries no venue status-codec vocabulary" ;; + *) fail "nexum-sdk scan errored (crates/nexum-sdk/src missing?)" ;; +esac + # 2. Symbol scan: the charter set (the current venue vocabulary that would # signal a leak - videre WIT/crate refs, the Venue* types, the egress # guard). Section 1 guards dependency edges; this scan stays curated to @@ -62,8 +89,8 @@ case $? in esac # 4. WIT surface: nexum:host is a leaf. No foreign package named -# anywhere in its sources, no cross-package use/import, and the -# package resolves standalone. The opaque-status envelope stays. +# anywhere in its sources, no cross-package use/import, no venue-domain +# vocabulary, and the package resolves standalone. wit_charter='nexum:intent|nexum:adapter|value-flow|videre:|shepherd:cow' rg -n --no-heading -e "$wit_charter" wit/nexum-host case $? in @@ -77,6 +104,16 @@ case $? in 1) pass "nexum:host has no cross-package reference" ;; *) fail "WIT scan errored (wit/nexum-host missing?)" ;; esac +# Venue-domain vocabulary must not appear in the core event surface: the +# intent-status envelope is a videre-side borsh struct crossing `custom` +# as opaque bytes, so a term leaking back into nexum:host is a regression +# of the extraction. +rg -n --no-heading -i -e 'venue|receipt|intent-status' wit/nexum-host +case $? in + 0) fail "venue-domain vocabulary leaks into wit/nexum-host" ;; + 1) pass "nexum:host carries no venue-domain vocabulary" ;; + *) fail "WIT vocabulary scan errored (wit/nexum-host missing?)" ;; +esac if command -v wasm-tools >/dev/null; then if wasm-tools component wit wit/nexum-host >/dev/null; then pass "nexum:host resolves standalone" diff --git a/wit/nexum-host/types.wit b/wit/nexum-host/types.wit index 8e834263..0142f119 100644 --- a/wit/nexum-host/types.wit +++ b/wit/nexum-host/types.wit @@ -58,20 +58,17 @@ interface types { fired-at: u64, } - /// A host-observed status transition for a previously submitted - /// intent. Transport-blind: the subscriber sees only where the - /// intent is in its life, never how the host learnt it. - record intent-status-update { - /// Venue id the receipt was issued by. - venue: string, - /// The venue-scoped intent identifier, opaque to the host. - receipt: list, - /// Opaque versioned status body: a leading u8 version tag, - /// then that version's borsh payload (v1: lifecycle status, - /// optional settlement proof, optional failure reason). An - /// unknown tag is a decode error and the body is never empty. - /// The host never inspects it. - status: list, + /// The generic extension event: a domain extension's own event kind + /// and its opaque payload. The core routes by `kind` and never reads + /// `payload`; the subscribing module decodes it against the extension + /// that emitted it. + record custom-event { + /// Extension-scoped event kind, matched against a module's + /// `[[subscription]]` kind. + kind: string, + /// Opaque bytes the emitting extension defines and the module + /// decodes. + payload: list, } variant event { @@ -79,7 +76,7 @@ interface types { chain-logs(chain-logs), tick(tick), message(message), - intent-status(intent-status-update), + custom(custom-event), } /// Opaque config from module.toml [config] section. From 8c5ababf70da2395167a20760d2591d56cb17a62 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 22 Jul 2026 23:54:37 +0000 Subject: [PATCH 063/139] macros: hoist duplicated helpers into nexum-world (#550) `is_plain_type`, `manifest_dir` and `resolve_wit_packages` were byte-identical in `nexum-module-macros` and `videre-macros`. Both already depend on `nexum-world`, where the substantive package resolution lives, so the helpers move there: `manifest_dir`, a `manifest_wit_packages` wrapper returning the strings `wit_bindgen::generate!` takes, and `is_plain_type` behind a `macros` feature, so non-macro consumers take `nexum-world` without `syn`. Also add `-p videre-macros` to the `docs/sdk.md` doc-lint invocation; it passes with `-D warnings -D missing-docs`. Closes #542 --- Cargo.lock | 1 + crates/nexum-module-macros/Cargo.toml | 2 +- crates/nexum-module-macros/src/lib.rs | 37 +++++---------------------- crates/nexum-world/Cargo.toml | 6 +++++ crates/nexum-world/src/lib.rs | 24 +++++++++++++++++ crates/videre-macros/Cargo.toml | 2 +- crates/videre-macros/src/keeper.rs | 9 ++++--- crates/videre-macros/src/lib.rs | 34 +++--------------------- docs/sdk.md | 2 +- 9 files changed, 49 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53a199de..adc5b212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,6 +3700,7 @@ dependencies = [ name = "nexum-world" version = "0.1.0" dependencies = [ + "syn 2.0.118", "tempfile", "toml 1.1.2+spec-1.1.0", ] diff --git a/crates/nexum-module-macros/Cargo.toml b/crates/nexum-module-macros/Cargo.toml index 4bef46f5..96c35dc9 100644 --- a/crates/nexum-module-macros/Cargo.toml +++ b/crates/nexum-module-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] -nexum-world = { path = "../nexum-world" } +nexum-world = { path = "../nexum-world", features = ["macros"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index ca2d73aa..9aa28bb0 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -15,7 +15,7 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{ImplItem, ItemImpl, Type}; +use syn::{ImplItem, ItemImpl}; /// The handler names recognised on a `#[module]` impl. Any method not in /// this set is left untouched on the type, except that names starting @@ -78,7 +78,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as ItemImpl); let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { + if !nexum_world::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", @@ -153,7 +153,7 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } }; - let wit_paths = match resolve_wit_packages(&module_world.packages) { + let wit_paths = match nexum_world::manifest_wit_packages(&module_world.packages) { Ok(paths) => paths, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -242,27 +242,14 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -fn is_plain_type(ty: &Type) -> bool { - matches!(ty, Type::Path(tp) if tp.qself.is_none()) -} - -/// The consuming crate's manifest directory, the root every crate-local -/// lookup starts from. -fn manifest_dir() -> Result { - std::env::var("CARGO_MANIFEST_DIR") - .map(std::path::PathBuf::from) - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) -} - /// Read the consuming crate's `module.toml` and synthesize the /// per-module world from its `[capabilities]` declarations plus the /// extension rows registered in the nearest ancestor `extensions.toml`. /// Returns the rebuild anchor paths (the manifest, then the registry /// when one exists) alongside the world. fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { - let manifest_path = manifest_dir()?.join("module.toml"); + let crate_dir = nexum_world::manifest_dir()?; + let manifest_path = crate_dir.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); #[nexum_sdk::module] derives the component's WIT world \ @@ -276,7 +263,7 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri let manifest_path = manifest_path.to_string_lossy().into_owned(); let mut anchors = vec![manifest_path.clone()]; - let extensions = match nexum_world::find_extensions_manifest(&manifest_dir()?) { + let extensions = match nexum_world::find_extensions_manifest(&crate_dir) { None => Vec::new(), Some(registry) => { let text = std::fs::read_to_string(®istry) @@ -291,15 +278,3 @@ fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri .map_err(|e| format!("{manifest_path}: {e}"))?; Ok((anchors, module_world)) } - -/// Resolve each needed WIT package directory crate-locally (vendored -/// `wit/deps/`, then own `wit/`), falling back through -/// ancestors for the transitional monorepo layout. -fn resolve_wit_packages(packages: &[String]) -> Result, String> { - Ok( - nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - ) -} diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml index 088d377a..5bb38f0b 100644 --- a/crates/nexum-world/Cargo.toml +++ b/crates/nexum-world/Cargo.toml @@ -9,7 +9,13 @@ description = "Per-module WIT world synthesis: the core capability table, regist [lints] workspace = true +[features] +# The syn-typed helper (`is_plain_type`), for the proc-macro crates only: +# non-macro consumers take nexum-world without `syn`. +macros = ["dep:syn"] + [dependencies] +syn = { workspace = true, optional = true } toml.workspace = true [dev-dependencies] diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 5ca9e1ca..c412e5a1 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -414,6 +414,30 @@ pub fn resolve_wit_packages>( .collect() } +/// The consuming crate's manifest directory, the root every crate-local +/// lookup starts from. +pub fn manifest_dir() -> Result { + std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) +} + +/// [`resolve_wit_packages`] rooted at [`manifest_dir`], as the strings +/// `wit_bindgen::generate!` takes for its `path`. +pub fn manifest_wit_packages>(packages: &[S]) -> Result, String> { + Ok(resolve_wit_packages(&manifest_dir()?, packages)? + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect()) +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +#[cfg(feature = "macros")] +pub fn is_plain_type(ty: &syn::Type) -> bool { + matches!(ty, syn::Type::Path(tp) if tp.qself.is_none()) +} + /// Find one package directory: crate-local `wit/deps/` then /// `wit/`, walking up on a miss. fn resolve_wit_package(start: &Path, package: &str) -> Option { diff --git a/crates/videre-macros/Cargo.toml b/crates/videre-macros/Cargo.toml index 7bb32f49..7cf8ec6f 100644 --- a/crates/videre-macros/Cargo.toml +++ b/crates/videre-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] -nexum-world = { path = "../nexum-world" } +nexum-world = { path = "../nexum-world", features = ["macros"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs index 9d21c79f..9af9c569 100644 --- a/crates/videre-macros/src/keeper.rs +++ b/crates/videre-macros/src/keeper.rs @@ -39,7 +39,7 @@ const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one /// error naming the rule the input broke. pub(crate) fn expand(input: &ItemImpl) -> syn::Result { let self_ty = &input.self_ty; - if !crate::is_plain_type(self_ty) { + if !nexum_world::is_plain_type(self_ty) { return Err(syn::Error::new_spanned( self_ty, "#[videre_sdk::keeper] must be applied to an inherent impl of a named type", @@ -102,7 +102,7 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { let (anchors, module_world) = derive_keeper_world() .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; - let wit_paths = crate::resolve_wit_packages(&module_world.packages) + let wit_paths = nexum_world::manifest_wit_packages(&module_world.packages) .map_err(|msg| syn::Error::new(proc_macro2::Span::call_site(), msg))?; let inline_world = &module_world.wit; let adapter_caps: Vec = module_world @@ -263,7 +263,8 @@ fn client_row() -> nexum_world::ExtensionRow { /// per-module world with the client extension row guaranteed. Returns /// the rebuild anchor paths alongside the world. fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { - let manifest_path = crate::manifest_dir()?.join("module.toml"); + let crate_dir = nexum_world::manifest_dir()?; + let manifest_path = crate_dir.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); #[videre_sdk::keeper] derives the component's WIT world \ @@ -293,7 +294,7 @@ fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), Stri let manifest_path = manifest_path.to_string_lossy().into_owned(); let mut anchors = vec![manifest_path.clone()]; - let mut extensions = match nexum_world::find_extensions_manifest(&crate::manifest_dir()?) { + let mut extensions = match nexum_world::find_extensions_manifest(&crate_dir) { None => Vec::new(), Some(registry) => { let text = std::fs::read_to_string(®istry) diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index 665fb88a..a9cb5677 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -26,7 +26,7 @@ mod world; use proc_macro::TokenStream; use quote::quote; -use syn::{DeriveInput, ItemImpl, Type}; +use syn::{DeriveInput, ItemImpl}; /// Derive the venue SDK's `IntentBody` codec on the outer per-venue /// version enum: one newtype variant per published body version, each @@ -113,7 +113,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } let self_ty = &input.self_ty; - if !is_plain_type(self_ty) { + if !nexum_world::is_plain_type(self_ty) { return syn::Error::new_spanned( self_ty, "#[videre_sdk::venue] must be applied to an impl on a named type", @@ -138,7 +138,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } }; - let wit_paths = match resolve_wit_packages(&venue_world.packages) { + let wit_paths = match nexum_world::manifest_wit_packages(&venue_world.packages) { Ok(paths) => paths, Err(msg) => { return syn::Error::new(proc_macro2::Span::call_site(), msg) @@ -211,26 +211,12 @@ pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Whether a type is a plain named path (`Foo`), the only shape a module -/// export type may take. -pub(crate) fn is_plain_type(ty: &Type) -> bool { - matches!(ty, Type::Path(tp) if tp.qself.is_none()) -} - -/// The consuming crate's manifest directory, the root every crate-local -/// lookup starts from. -pub(crate) fn manifest_dir() -> Result { - std::env::var("CARGO_MANIFEST_DIR") - .map(std::path::PathBuf::from) - .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string()) -} - /// Read the consuming crate's `module.toml`, assert it declares the /// venue-adapter kind, and synthesize the per-component venue-adapter /// world from its `[capabilities]` declarations. Returns the manifest /// path (for the rebuild anchor) alongside the world. fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { - let manifest_path = manifest_dir()?.join("module.toml"); + let manifest_path = nexum_world::manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { format!( "could not read {} ({e}); #[videre_sdk::venue] derives the component's WIT world \ @@ -256,15 +242,3 @@ fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { world::synthesize_venue(&declared).map_err(|e| format!("{manifest_path}: {e}"))?; Ok((manifest_path, venue_world)) } - -/// Resolve each needed WIT package directory crate-locally (vendored -/// `wit/deps/`, then own `wit/`), falling back through -/// ancestors for the transitional monorepo layout. -pub(crate) fn resolve_wit_packages(packages: &[String]) -> Result, String> { - Ok( - nexum_world::resolve_wit_packages(&manifest_dir()?, packages)? - .into_iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(), - ) -} diff --git a/docs/sdk.md b/docs/sdk.md index c4f07f2f..4d48a7fa 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -13,7 +13,7 @@ site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-module-macros --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-module-macros -p videre-macros --no-deps --open ``` ## Authoring a module From c57f729957b13a582504362ec1582098f160824e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 00:09:29 +0000 Subject: [PATCH 064/139] world: enums over const strings for the vocabularies (#551) `caps` becomes `Cap` with a hand-written `const fn as_str`, so the `CORE` table, `core_iface_count` and `CORE_IFACES` still evaluate in const context; `Capability::name` is now typed. `fault_labels` becomes `FaultLabel` with `IntoStaticStr` for the label and `VariantNames` in place of the hand-maintained `ALL` array. `EnumString` gives both a fail-closed parse; new tests pin the hand-written accessor to the derived vocabulary. Closes #547 --- Cargo.lock | 1 + crates/nexum-runtime/src/host/error.rs | 19 +-- .../src/manifest/capabilities.rs | 8 +- crates/nexum-sdk/src/host.rs | 19 ++- crates/nexum-world/Cargo.toml | 3 + crates/nexum-world/src/lib.rs | 144 +++++++++++------- crates/videre-macros/src/world.rs | 2 +- 7 files changed, 123 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index adc5b212..b5d973d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,6 +3700,7 @@ dependencies = [ name = "nexum-world" version = "0.1.0" dependencies = [ + "strum", "syn 2.0.118", "tempfile", "toml 1.1.2+spec-1.1.0", diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 65fbe9d7..e31f803c 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -16,19 +16,20 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { /// Stable snake_case label for a [`Fault`], used as a metric label and /// structured-log `kind` field. Emitted from the single-source -/// `nexum_world::fault_labels` vocabulary the SDK `HostFault::label` +/// [`nexum_world::FaultLabel`] vocabulary the SDK `HostFault::label` /// mirrors. pub fn fault_label(fault: &Fault) -> &'static str { - use nexum_world::fault_labels as labels; + use nexum_world::FaultLabel as Label; match fault { - Fault::Unsupported(_) => labels::UNSUPPORTED, - Fault::Unavailable(_) => labels::UNAVAILABLE, - Fault::Denied(_) => labels::DENIED, - Fault::RateLimited(_) => labels::RATE_LIMITED, - Fault::Timeout => labels::TIMEOUT, - Fault::InvalidInput(_) => labels::INVALID_INPUT, - Fault::Internal(_) => labels::INTERNAL, + Fault::Unsupported(_) => Label::Unsupported, + Fault::Unavailable(_) => Label::Unavailable, + Fault::Denied(_) => Label::Denied, + Fault::RateLimited(_) => Label::RateLimited, + Fault::Timeout => Label::Timeout, + Fault::InvalidInput(_) => Label::InvalidInput, + Fault::Internal(_) => Label::Internal, } + .into() } /// Human-readable detail carried by a [`Fault`], for the log `message` diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 3492634f..12b3a53a 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -44,8 +44,10 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { /// moves bytes to and from its counterparty and nothing else. `http` is /// not listed here for the same reason it is not in the core set: it /// gates `wasi:http/*` and is handled by the registry directly. -pub const PROVIDER_CAPABILITIES: &[&str] = - &[nexum_world::caps::CHAIN, nexum_world::caps::MESSAGING]; +pub const PROVIDER_CAPABILITIES: &[&str] = &[ + nexum_world::Cap::Chain.as_str(), + nexum_world::Cap::Messaging.as_str(), +]; /// The provider namespace: the same `nexum:host/` prefix as core but only /// the scoped-transport interfaces. Validating a provider manifest against @@ -63,7 +65,7 @@ const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. -const HTTP_CAPABILITY: &str = nexum_world::caps::HTTP; +const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str(); /// Gated WASI capability names. Declaring one grants the matching `wasi:` /// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 9fc0cb68..254c8787 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -509,18 +509,21 @@ mod tests { #[test] fn fault_labels_match_the_single_source_vocabulary() { - use nexum_world::fault_labels as labels; + use nexum_world::FaultLabel as Label; let cases: [(Fault, &str); 7] = [ - (Fault::Unsupported(String::new()), labels::UNSUPPORTED), - (Fault::Unavailable(String::new()), labels::UNAVAILABLE), - (Fault::Denied(String::new()), labels::DENIED), + (Fault::Unsupported(String::new()), Label::Unsupported.into()), + (Fault::Unavailable(String::new()), Label::Unavailable.into()), + (Fault::Denied(String::new()), Label::Denied.into()), ( Fault::RateLimited(RateLimit::default()), - labels::RATE_LIMITED, + Label::RateLimited.into(), ), - (Fault::Timeout, labels::TIMEOUT), - (Fault::InvalidInput(String::new()), labels::INVALID_INPUT), - (Fault::Internal(String::new()), labels::INTERNAL), + (Fault::Timeout, Label::Timeout.into()), + ( + Fault::InvalidInput(String::new()), + Label::InvalidInput.into(), + ), + (Fault::Internal(String::new()), Label::Internal.into()), ]; for (fault, label) in cases { assert_eq!(fault.label(), label); diff --git a/crates/nexum-world/Cargo.toml b/crates/nexum-world/Cargo.toml index 5bb38f0b..8b33d24b 100644 --- a/crates/nexum-world/Cargo.toml +++ b/crates/nexum-world/Cargo.toml @@ -15,6 +15,9 @@ workspace = true macros = ["dep:syn"] [dependencies] +# Derives the closed capability / fault-label vocabularies: `VariantNames` +# supersedes a hand-maintained list, `EnumString` parses fail-closed. +strum.workspace = true syn = { workspace = true, optional = true } toml.workspace = true diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index c412e5a1..9a451c70 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -16,59 +16,74 @@ //! so this crate carries no downstream name. use std::path::{Path, PathBuf}; +use strum::{EnumString, IntoStaticStr, VariantNames}; -/// Capability name consts: the single source the [`CORE`] table and the +/// A core capability name: the single source the [`CORE`] table and the /// runtime's capability registry emit from. -pub mod caps { +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, VariantNames)] +#[strum(serialize_all = "kebab-case")] +#[non_exhaustive] +pub enum Cap { /// `nexum:host/chain`. - pub const CHAIN: &str = "chain"; + Chain, /// `nexum:host/identity`. - pub const IDENTITY: &str = "identity"; + Identity, /// `nexum:host/local-store`. - pub const LOCAL_STORE: &str = "local-store"; + LocalStore, /// `nexum:host/remote-store`. - pub const REMOTE_STORE: &str = "remote-store"; + RemoteStore, /// `nexum:host/messaging`. - pub const MESSAGING: &str = "messaging"; + Messaging, /// `nexum:host/logging`. - pub const LOGGING: &str = "logging"; + Logging, /// Gates `wasi:http/*`; no world import. - pub const HTTP: &str = "http"; + Http, } -/// Snake_case labels of the `nexum:host/types.fault` cases, in +impl Cap { + /// The declared name, as a manifest spells it. Hand-written rather + /// than derived: [`CORE`] and [`CORE_IFACES`] evaluate it in const + /// context, and strum's `IntoStaticStr` emits a non-const `From`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Chain => "chain", + Self::Identity => "identity", + Self::LocalStore => "local-store", + Self::RemoteStore => "remote-store", + Self::Messaging => "messaging", + Self::Logging => "logging", + Self::Http => "http", + } + } +} + +/// A `nexum:host/types.fault` case as a stable snake_case label, in WIT /// declaration order: the single source every label mirror emits from. -pub mod fault_labels { +/// `IntoStaticStr` yields the label, `VARIANTS` the whole vocabulary. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr, VariantNames)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum FaultLabel { /// `fault.unsupported`. - pub const UNSUPPORTED: &str = "unsupported"; + Unsupported, /// `fault.unavailable`. - pub const UNAVAILABLE: &str = "unavailable"; + Unavailable, /// `fault.denied`. - pub const DENIED: &str = "denied"; + Denied, /// `fault.rate-limited`. - pub const RATE_LIMITED: &str = "rate_limited"; + RateLimited, /// `fault.timeout`. - pub const TIMEOUT: &str = "timeout"; + Timeout, /// `fault.invalid-input`. - pub const INVALID_INPUT: &str = "invalid_input"; + InvalidInput, /// `fault.internal`. - pub const INTERNAL: &str = "internal"; - /// All seven, in declaration order. - pub const ALL: [&str; 7] = [ - UNSUPPORTED, - UNAVAILABLE, - DENIED, - RATE_LIMITED, - TIMEOUT, - INVALID_INPUT, - INTERNAL, - ]; + Internal, } /// One manifest capability and its world wiring. pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. - pub name: &'static str, + pub name: Cap, /// 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). @@ -86,43 +101,43 @@ pub struct Capability { /// core registry and nothing else; extension rows are the caller's. pub const CORE: &[Capability] = &[ Capability { - name: caps::CHAIN, + name: Cap::Chain, import: Some("nexum:host/chain@0.1.0"), packages: &[], adapter: Some("chain"), }, Capability { - name: caps::IDENTITY, + name: Cap::Identity, import: Some("nexum:host/identity@0.1.0"), packages: &[], adapter: Some("identity"), }, Capability { - name: caps::LOCAL_STORE, + name: Cap::LocalStore, import: Some("nexum:host/local-store@0.1.0"), packages: &[], adapter: Some("local_store"), }, Capability { - name: caps::REMOTE_STORE, + name: Cap::RemoteStore, import: Some("nexum:host/remote-store@0.1.0"), packages: &[], adapter: Some("remote_store"), }, Capability { - name: caps::MESSAGING, + name: Cap::Messaging, import: Some("nexum:host/messaging@0.1.0"), packages: &[], adapter: Some("messaging"), }, Capability { - name: caps::LOGGING, + name: Cap::Logging, import: Some("nexum:host/logging@0.1.0"), packages: &[], adapter: Some("logging"), }, Capability { - name: caps::HTTP, + name: Cap::Http, import: None, packages: &[], adapter: None, @@ -151,7 +166,7 @@ pub const CORE_IFACES: [&str; core_iface_count()] = { let mut i = 0; while i < CORE.len() { if CORE[i].import.is_some() { - out[n] = CORE[i].name; + out[n] = CORE[i].name.as_str(); n += 1; } i += 1; @@ -311,7 +326,7 @@ pub fn find_extensions_manifest(start: &Path) -> Option { /// colliding registry cannot emit a duplicate import. pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { for (idx, ext) in extensions.iter().enumerate() { - if CORE.iter().any(|c| c.name == ext.name) + if CORE.iter().any(|c| c.name.as_str() == ext.name) || extensions[..idx].iter().any(|prior| prior.name == ext.name) { return Err(format!( @@ -324,7 +339,7 @@ pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result Result = CORE.iter().map(|c| c.name.as_str()).collect(); + assert_eq!(names, Cap::VARIANTS); + for name in Cap::VARIANTS { + assert_eq!(name.parse::().unwrap().as_str(), *name); + } } #[test] fn fault_labels_are_snake_case_and_distinct() { - for label in fault_labels::ALL { + for label in FaultLabel::VARIANTS { assert!(label.chars().all(|c| c.is_ascii_lowercase() || c == '_')); } - let mut labels = fault_labels::ALL.to_vec(); + let mut labels = FaultLabel::VARIANTS.to_vec(); labels.sort_unstable(); labels.dedup(); - assert_eq!(labels.len(), fault_labels::ALL.len()); + assert_eq!(labels.len(), FaultLabel::VARIANTS.len()); + } + + #[test] + fn fault_label_parses_back_from_its_label() { + for label in FaultLabel::VARIANTS { + let parsed: FaultLabel = label.parse().unwrap(); + assert_eq!(<&'static str>::from(parsed), *label); + } + assert!("nonesuch".parse::().is_err()); } #[test] @@ -554,13 +589,18 @@ mod tests { // `http` has no world import (SDK wasi:http client) and no // adapter; every other core row has both. for cap in CORE { - assert_eq!(cap.import.is_some(), cap.adapter.is_some(), "{}", cap.name); + assert_eq!( + cap.import.is_some(), + cap.adapter.is_some(), + "{}", + cap.name.as_str() + ); } } #[test] fn full_declaration_emits_the_six_adapters_in_core_order() { - let declared: Vec = CORE.iter().map(|c| c.name.to_string()).collect(); + let declared: Vec = CORE.iter().map(|c| c.name.as_str().to_owned()).collect(); let world = synthesize(&declared, &[]).unwrap(); assert_eq!( world.adapters, diff --git a/crates/videre-macros/src/world.rs b/crates/videre-macros/src/world.rs index 30a059ad..081a4b3f 100644 --- a/crates/videre-macros/src/world.rs +++ b/crates/videre-macros/src/world.rs @@ -45,7 +45,7 @@ pub fn synthesize_venue(declared: &[String]) -> Result { .map(str::to_owned) .into(); for cap in nexum_world::CORE { - if !declared.iter().any(|d| d == cap.name) { + if !declared.iter().any(|d| d == cap.name.as_str()) { continue; } if let Some(import) = cap.import { From bbadf8a446058c4ab20528d7eaf75a9f5c73266d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 01:06:51 +0000 Subject: [PATCH 065/139] world: single-source the ChainMethod read surface (#552) ChainMethod was declared byte-identically in nexum-sdk and nexum-runtime, with #[non_exhaustive] added to each by hand in #461. Fold it into nexum-world beside Cap and FaultLabel, with the derive superset, and re-export from both sides so the guest allowlist and the host dispatch table cannot drift. Consumer paths are unchanged: nexum_sdk::chain::ChainMethod and nexum_runtime::host::component::ChainMethod still resolve. nexum-world becomes a normal dependency of nexum-sdk, so guests link toml and the world-synthesis code they never call. No feature gate. Closes #523 --- .../nexum-runtime/src/host/component/chain.rs | 110 +--------------- crates/nexum-sdk/Cargo.toml | 6 +- crates/nexum-sdk/src/chain/method.rs | 122 ------------------ crates/nexum-sdk/src/chain/mod.rs | 5 +- crates/nexum-world/src/lib.rs | 122 ++++++++++++++++++ 5 files changed, 133 insertions(+), 232 deletions(-) delete mode 100644 crates/nexum-sdk/src/chain/method.rs diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 687abf3a..294eba71 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -5,67 +5,13 @@ use std::future::Future; use alloy_chains::Chain; use alloy_rpc_types_eth::Filter; -use strum::{EnumString, IntoStaticStr}; use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, ProviderPool}; -/// The permitted JSON-RPC read surface as a closed type. Methods that -/// sign or mutate node state have no variant, so a guest-supplied -/// signing method (for example `eth_sign` or `eth_sendTransaction`) -/// cannot be represented and never reaches the provider. This is the -/// structural ceiling; an operator allowlist narrows within it and -/// never widens it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumString, IntoStaticStr)] -#[non_exhaustive] -pub enum ChainMethod { - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - #[strum(serialize = "eth_call")] - EthCall, - #[strum(serialize = "eth_chainId")] - EthChainId, - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - #[strum(serialize = "eth_getCode")] - EthGetCode, - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - #[strum(serialize = "eth_getProof")] - EthGetProof, - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name forwarded to the provider. `&'static` - /// because the permitted set is closed, so the name drops straight - /// into alloy's `Cow<'static, str>` method slot without allocating. - pub fn as_str(self) -> &'static str { - self.into() - } -} +/// The read surface is defined once in `nexum-world`; host and guest +/// re-export the same type, so the dispatch table and the guest +/// allowlist cannot drift. +pub use nexum_world::ChainMethod; /// Async chain backend. Methods mirror [`ProviderPool`] one-to-one; /// the `impl Future + Send` form bakes in the Send bound generic @@ -134,51 +80,3 @@ impl ChainProvider for ProviderPool { ProviderPool::request(self, chain, method, params_json) } } - -#[cfg(test)] -mod tests { - use super::ChainMethod; - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "personal_unlockAccount", - "admin_peers", - "debug_traceCall", - "miner_start", - "eth_notAMethod", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()).unwrap(), - ChainMethod::EthGetBalance, - ); - } -} diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 07260cfd..7d28b7a4 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -23,6 +23,10 @@ stderr-echo = [] # calls back into this crate (`bind_host_via_wit_bindgen!`, the host # trait seam, the tracing facade). nexum-module-macros = { path = "../nexum-module-macros" } +# The single-source vocabularies: `ChainMethod` (re-exported as +# `chain::ChainMethod`) and the fault labels. Its world-synthesis half +# is unused here, so the guest links `toml` and never calls it. +nexum-world = { path = "../nexum-world" } alloy-primitives.workspace = true # Typed EIP-155 chain id; already in the guest graph via alloy-provider. alloy-chains.workspace = true @@ -62,8 +66,6 @@ proptest.workspace = true # The keeper never touches the orderbook, so a CoW-layer mock would only drag # the domain crates into this crate's dev graph. nexum-sdk-test = { path = "../nexum-sdk-test" } -# Pins the strum-derived fault labels to the single-source vocabulary. -nexum-world = { path = "../nexum-world" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/chain/method.rs b/crates/nexum-sdk/src/chain/method.rs deleted file mode 100644 index 58ecfae6..00000000 --- a/crates/nexum-sdk/src/chain/method.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! The typed JSON-RPC method surface, guest side. - -use strum::{EnumString, IntoStaticStr}; - -/// The permitted JSON-RPC read surface as a closed type, mirroring the -/// runtime's `ChainMethod` case for case. Signing and mutating methods -/// have no variant, so they cannot be represented and never cross the -/// WIT edge; [`HostTransport`](super::HostTransport) rejects anything -/// outside this set before calling the host. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, IntoStaticStr)] -#[non_exhaustive] -pub enum ChainMethod { - /// `eth_blockNumber`. - #[strum(serialize = "eth_blockNumber")] - EthBlockNumber, - /// `eth_call`. - #[strum(serialize = "eth_call")] - EthCall, - /// `eth_chainId`. - #[strum(serialize = "eth_chainId")] - EthChainId, - /// `eth_estimateGas`. - #[strum(serialize = "eth_estimateGas")] - EthEstimateGas, - /// `eth_feeHistory`. - #[strum(serialize = "eth_feeHistory")] - EthFeeHistory, - /// `eth_gasPrice`. - #[strum(serialize = "eth_gasPrice")] - EthGasPrice, - /// `eth_maxPriorityFeePerGas`. - #[strum(serialize = "eth_maxPriorityFeePerGas")] - EthMaxPriorityFeePerGas, - /// `eth_getBalance`. - #[strum(serialize = "eth_getBalance")] - EthGetBalance, - /// `eth_getBlockByHash`. - #[strum(serialize = "eth_getBlockByHash")] - EthGetBlockByHash, - /// `eth_getBlockByNumber`. - #[strum(serialize = "eth_getBlockByNumber")] - EthGetBlockByNumber, - /// `eth_getBlockReceipts`. - #[strum(serialize = "eth_getBlockReceipts")] - EthGetBlockReceipts, - /// `eth_getCode`. - #[strum(serialize = "eth_getCode")] - EthGetCode, - /// `eth_getLogs`. - #[strum(serialize = "eth_getLogs")] - EthGetLogs, - /// `eth_getProof`. - #[strum(serialize = "eth_getProof")] - EthGetProof, - /// `eth_getStorageAt`. - #[strum(serialize = "eth_getStorageAt")] - EthGetStorageAt, - /// `eth_getTransactionByHash`. - #[strum(serialize = "eth_getTransactionByHash")] - EthGetTransactionByHash, - /// `eth_getTransactionCount`. - #[strum(serialize = "eth_getTransactionCount")] - EthGetTransactionCount, - /// `eth_getTransactionReceipt`. - #[strum(serialize = "eth_getTransactionReceipt")] - EthGetTransactionReceipt, - /// `net_version`. - #[strum(serialize = "net_version")] - NetVersion, -} - -impl ChainMethod { - /// The wire method name. `&'static` because the set is closed. - pub fn as_str(self) -> &'static str { - self.into() - } -} - -#[cfg(test)] -mod tests { - use super::ChainMethod; - - #[test] - fn read_surface_methods_parse() { - for m in [ - "eth_call", - "eth_blockNumber", - "eth_getBalance", - "eth_getLogs", - "eth_getTransactionReceipt", - "net_version", - ] { - assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); - } - } - - #[test] - fn signing_and_mutating_methods_have_no_variant() { - for m in [ - "eth_sign", - "eth_signTransaction", - "eth_sendTransaction", - "eth_sendRawTransaction", - "eth_accounts", - "personal_sign", - "admin_peers", - "debug_traceCall", - "", - ] { - assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); - } - } - - #[test] - fn as_str_round_trips_the_wire_name() { - assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); - assert_eq!( - ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), - Ok(ChainMethod::EthGetBalance), - ); - } -} diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index c547f921..dc8ff363 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -9,12 +9,13 @@ pub mod chainlink; pub mod eth_call; -pub mod method; pub mod provider; pub mod transport; pub use alloy_chains::Chain; pub use eth_call::{eth_call_params, parse_eth_call_result}; -pub use method::ChainMethod; +/// The read surface is defined once in `nexum-world`; guest and host +/// re-export the same type, so the allowlist cannot drift. +pub use nexum_world::ChainMethod; pub use provider::{ProviderHost, block_on}; pub use transport::HostTransport; diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 9a451c70..4a6aae1e 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -80,6 +80,85 @@ pub enum FaultLabel { Internal, } +/// The permitted JSON-RPC read surface as a closed type: the single +/// source both the guest allowlist (`nexum_sdk::chain::ChainMethod`) +/// and the host dispatch table (`nexum_runtime::host::component:: +/// ChainMethod`) emit from. Methods that sign or mutate node state have +/// no variant, so a guest-supplied signing method (for example +/// `eth_sign` or `eth_sendTransaction`) cannot be represented and never +/// crosses the WIT edge. This is the structural ceiling; an operator +/// allowlist narrows within it and never widens it. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr)] +#[non_exhaustive] +pub enum ChainMethod { + /// `eth_blockNumber`. + #[strum(serialize = "eth_blockNumber")] + EthBlockNumber, + /// `eth_call`. + #[strum(serialize = "eth_call")] + EthCall, + /// `eth_chainId`. + #[strum(serialize = "eth_chainId")] + EthChainId, + /// `eth_estimateGas`. + #[strum(serialize = "eth_estimateGas")] + EthEstimateGas, + /// `eth_feeHistory`. + #[strum(serialize = "eth_feeHistory")] + EthFeeHistory, + /// `eth_gasPrice`. + #[strum(serialize = "eth_gasPrice")] + EthGasPrice, + /// `eth_maxPriorityFeePerGas`. + #[strum(serialize = "eth_maxPriorityFeePerGas")] + EthMaxPriorityFeePerGas, + /// `eth_getBalance`. + #[strum(serialize = "eth_getBalance")] + EthGetBalance, + /// `eth_getBlockByHash`. + #[strum(serialize = "eth_getBlockByHash")] + EthGetBlockByHash, + /// `eth_getBlockByNumber`. + #[strum(serialize = "eth_getBlockByNumber")] + EthGetBlockByNumber, + /// `eth_getBlockReceipts`. + #[strum(serialize = "eth_getBlockReceipts")] + EthGetBlockReceipts, + /// `eth_getCode`. + #[strum(serialize = "eth_getCode")] + EthGetCode, + /// `eth_getLogs`. + #[strum(serialize = "eth_getLogs")] + EthGetLogs, + /// `eth_getProof`. + #[strum(serialize = "eth_getProof")] + EthGetProof, + /// `eth_getStorageAt`. + #[strum(serialize = "eth_getStorageAt")] + EthGetStorageAt, + /// `eth_getTransactionByHash`. + #[strum(serialize = "eth_getTransactionByHash")] + EthGetTransactionByHash, + /// `eth_getTransactionCount`. + #[strum(serialize = "eth_getTransactionCount")] + EthGetTransactionCount, + /// `eth_getTransactionReceipt`. + #[strum(serialize = "eth_getTransactionReceipt")] + EthGetTransactionReceipt, + /// `net_version`. + #[strum(serialize = "net_version")] + NetVersion, +} + +impl ChainMethod { + /// The wire method name. `&'static` because the permitted set is + /// closed, so the name drops straight into alloy's + /// `Cow<'static, str>` method slot without allocating. + pub fn as_str(self) -> &'static str { + self.into() + } +} + /// One manifest capability and its world wiring. pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. @@ -804,4 +883,47 @@ allow = [] assert!(err.contains("`pkg` WIT package")); assert!(err.contains("wit/deps/pkg")); } + + #[test] + fn read_surface_methods_parse() { + for m in [ + "eth_call", + "eth_blockNumber", + "eth_getBalance", + "eth_getLogs", + "eth_getTransactionReceipt", + "net_version", + ] { + assert!(ChainMethod::try_from(m).is_ok(), "{m} should parse"); + } + } + + #[test] + fn signing_and_mutating_methods_have_no_variant() { + for m in [ + "eth_sign", + "eth_signTransaction", + "eth_sendTransaction", + "eth_sendRawTransaction", + "eth_accounts", + "personal_sign", + "personal_unlockAccount", + "admin_peers", + "debug_traceCall", + "miner_start", + "eth_notAMethod", + "", + ] { + assert!(ChainMethod::try_from(m).is_err(), "{m} must be rejected"); + } + } + + #[test] + fn as_str_round_trips_the_wire_name() { + assert_eq!(ChainMethod::EthCall.as_str(), "eth_call"); + assert_eq!( + ChainMethod::try_from(ChainMethod::EthGetBalance.as_str()), + Ok(ChainMethod::EthGetBalance), + ); + } } From adb16059f3b9fc7a940a7560011fc3d8925345c9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 01:30:19 +0000 Subject: [PATCH 066/139] flake: add cargo-nextest to the dev shell (#554) CI runs the suite with cargo nextest, but the dev shell shipped no nextest at all, so local gating silently diverged onto cargo test and workflow tooling had to nest a `nix shell nixpkgs#cargo-nextest` call to match CI. Pin it here and report its version in the banner. Unlike sccache, which is deliberately not bundled because a client must version-match the host's server, nextest is a standalone runner with no such constraint. --- flake.nix | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flake.nix b/flake.nix index 410ffa14..a861afc7 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,7 @@ devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ rustToolchain + cargo-nextest wasm-tools wabt just @@ -72,6 +73,7 @@ fi echo "nexum dev shell — $(rustc --version)" command -v sccache >/dev/null && echo " compiler cache: $(sccache --version)" + echo " test runner: $(cargo nextest --version | head -n1)" ${lib.optionalString stdenv.isLinux ''command -v mold >/dev/null && echo " linker (native): mold $(mold --version | head -n1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"''} ''; }; From 55d0f99724ed0c6b97f2f700d828d4ffb9fa29c3 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 01:51:18 +0000 Subject: [PATCH 067/139] cow: cleave the cow venue from the composable-cow keeper (#462) The venue crate is the orderbook alone. ComposableBody and the structured poll seam (Verdict, LegacyRevertAdapter, IConditionalOrder) move to the new composable-cow keeper crate, the Composable variant drops from the venue body, the goldens shed the composable vectors, and a blocking CI gate keeps composable symbols out of crates/cow-venue. --- .github/workflows/ci.yml | 12 +++++++ Cargo.lock | 16 ++++++++- Cargo.toml | 1 + crates/composable-cow/Cargo.toml | 24 ++++++++++++++ .../src/body.rs} | 25 +++++++------- crates/composable-cow/src/lib.rs | 15 +++++++++ .../src/poll.rs} | 14 ++++++++ crates/cow-venue/Cargo.toml | 2 +- crates/cow-venue/src/body.rs | 33 +++++-------------- crates/cow-venue/src/lib.rs | 14 +++----- crates/shepherd-sdk-test/Cargo.toml | 1 + crates/shepherd-sdk-test/tests/mock_venue.rs | 3 +- crates/shepherd-sdk/Cargo.toml | 5 +-- crates/shepherd-sdk/src/cow/mod.rs | 18 +++++----- crates/shepherd-sdk/src/cow/run.rs | 3 +- crates/shepherd-sdk/src/lib.rs | 14 ++++---- crates/shepherd-sdk/src/proptests.rs | 17 ++-------- crates/shepherd-sdk/tests/run.rs | 3 +- justfile | 5 +++ modules/twap-monitor/Cargo.toml | 1 + modules/twap-monitor/src/strategy.rs | 5 +-- scripts/check-cow-orderbook-only.sh | 29 ++++++++++++++++ 22 files changed, 171 insertions(+), 89 deletions(-) create mode 100644 crates/composable-cow/Cargo.toml rename crates/{cow-venue/src/composable.rs => composable-cow/src/body.rs} (72%) create mode 100644 crates/composable-cow/src/lib.rs rename crates/{shepherd-sdk/src/cow/composable.rs => composable-cow/src/poll.rs} (96%) create mode 100755 scripts/check-cow-orderbook-only.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2ca1d19..74809b37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,3 +158,15 @@ jobs: with: tool: wasm-tools,ripgrep - run: ./scripts/check-venue-agnostic.sh + + # Blocking orderbook-only gate: the CoW venue crate carries no + # composable symbol (scripts/check-cow-orderbook-only.sh). + cow-orderbook-only: + name: cow-orderbook-only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ripgrep + - run: ./scripts/check-cow-orderbook-only.sh diff --git a/Cargo.lock b/Cargo.lock index b5d973d0..d63586bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1483,6 +1483,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "composable-cow" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "borsh", + "cowprotocol", + "nexum-sdk", + "proptest", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -5213,7 +5225,7 @@ name = "shepherd-sdk" version = "0.1.0" dependencies = [ "alloy-primitives", - "alloy-sol-types", + "composable-cow", "cow-venue", "cowprotocol", "nexum-sdk", @@ -5231,6 +5243,7 @@ name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ "alloy-primitives", + "composable-cow", "cowprotocol", "nexum-sdk", "nexum-sdk-test", @@ -5902,6 +5915,7 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "composable-cow", "cowprotocol", "nexum-sdk", "nexum-sdk-test", diff --git a/Cargo.toml b/Cargo.toml index 54cf5075..327bc2dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/composable-cow", "crates/cow-venue", "crates/nexum-cli", "crates/nexum-launch", diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml new file mode 100644 index 00000000..ceb66cc6 --- /dev/null +++ b/crates/composable-cow/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "composable-cow" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "ComposableCoW keeper machinery: the conditional-order body and the structured poll Verdict, with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter." + +[lib] +# Plain library, keeper-side only. The CoW venue crate is orderbook-only +# and never links this. + +[lints] +workspace = true + +[dependencies] +alloy-primitives.workspace = true +alloy-sol-types.workspace = true +borsh.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } +nexum-sdk = { path = "../nexum-sdk" } + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/cow-venue/src/composable.rs b/crates/composable-cow/src/body.rs similarity index 72% rename from crates/cow-venue/src/composable.rs rename to crates/composable-cow/src/body.rs index 5e7a94a4..b5f6bf02 100644 --- a/crates/cow-venue/src/composable.rs +++ b/crates/composable-cow/src/body.rs @@ -1,28 +1,24 @@ -//! The venue-neutral composable (conditional) order body. +//! The composable (conditional) order body. //! //! ComposableCoW expresses a conditional order as the //! `ConditionalOrderParams` tuple: the handler contract that mints the //! tradeable order, a salt that distinguishes otherwise-identical -//! conditional orders, and the opaque handler-specific static input. This -//! body type is that tuple in wire form. The one non-obvious invariant: -//! `static_input` is opaque to the venue; only the named handler parses +//! conditional orders, and the opaque handler-specific static input. +//! This body type is that tuple in wire form. The one non-obvious +//! invariant: `static_input` is opaque; only the named handler parses //! it, so this crate never inspects its bytes. -use alloc::vec::Vec; - use borsh::{BorshDeserialize, BorshSerialize}; -use crate::order::Address; - -/// The venue-neutral conditional order body: `ConditionalOrderParams` in -/// wire form. +/// The conditional order body: `ConditionalOrderParams` in wire form. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct ComposableBody { /// The `IConditionalOrder` handler that mints the tradeable order. - pub handler: Address, + pub handler: [u8; 20], /// Salt distinguishing otherwise-identical conditional orders. pub salt: [u8; 32], - /// Handler-specific static input; opaque to the venue. + /// Handler-specific static input; opaque to everything but the + /// named handler. pub static_input: Vec, } @@ -53,6 +49,9 @@ mod tests { let mut body = sample(); body.static_input = Vec::new(); let bytes = borsh::to_vec(&body).expect("encode"); - assert_eq!(ComposableBody::try_from_slice(&bytes).unwrap(), body); + assert_eq!( + ComposableBody::try_from_slice(&bytes).expect("decode"), + body + ); } } diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs new file mode 100644 index 00000000..505fa115 --- /dev/null +++ b/crates/composable-cow/src/lib.rs @@ -0,0 +1,15 @@ +//! # composable-cow +//! +//! ComposableCoW keeper machinery, kept out of the CoW venue: the +//! conditional-order body ([`ComposableBody`]) and the structured poll +//! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined +//! behind [`LegacyRevertAdapter`]. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![warn(missing_docs)] + +pub mod body; +pub mod poll; + +pub use body::ComposableBody; +pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/composable-cow/src/poll.rs similarity index 96% rename from crates/shepherd-sdk/src/cow/composable.rs rename to crates/composable-cow/src/poll.rs index fdb28adf..590549cc 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/composable-cow/src/poll.rs @@ -355,4 +355,18 @@ mod tests { Verdict::TryNextBlock { .. } )); } + + use proptest::prelude::*; + + proptest! { + /// `decode` on arbitrary revert bytes never panics and returns + /// `None` for inputs shorter than the 4-byte EVM selector. + #[test] + fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = LegacyRevertAdapter::decode(&bytes); + if bytes.len() < 4 { + prop_assert!(outcome.is_none()); + } + } + } } diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index eb061887..8e303765 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW venue slices. The default `body` slice carries the venue-neutral order/composable intent body types and their borsh IntentBody codec, linkable by adapters and modules." +description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." [lib] # Plain library. The default `body` slice is dependency-light so a venue diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index e7e40fe4..feb7d3c8 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,10 +1,10 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is either a direct order or a composable (conditional) -//! order; [`CowIntent`] is that sum. [`CowIntentBody`] is the outer -//! per-venue version enum the venue publishes, and `#[derive(IntentBody)]` -//! gives it the borsh codec: a one-byte version tag plus the borsh -//! payload, with an unknown tag failing as a typed +//! A CoW intent is a direct order for the orderbook; [`CowIntent`] is +//! that sum, open for future intent kinds. [`CowIntentBody`] is the +//! outer per-venue version enum the venue publishes, and +//! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version +//! tag plus the borsh payload, with an unknown tag failing as a typed //! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh //! error. The one non-obvious invariant: the tag order is the schema, so //! new versions append at the end and no variant is ever reordered or @@ -13,16 +13,13 @@ use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; -use crate::composable::ComposableBody; use crate::order::OrderBody; -/// What the CoW venue accepts: a direct order or a conditional order. +/// What the CoW venue accepts: a direct order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), - /// A ComposableCoW conditional order that mints tradeable orders. - Composable(ComposableBody), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -49,15 +46,7 @@ mod tests { .build() } - fn composable_body() -> ComposableBody { - ComposableBody { - handler: [0xab; 20], - salt: [0xcd; 32], - static_input: vec![9, 8, 7], - } - } - - /// The codec conformance set: both v1 intents as round-trip vectors + /// The codec conformance set: the v1 intent as a round-trip vector /// plus the typed failure contract, in the kit's published form. fn vectors() -> CodecVectors { let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); @@ -67,12 +56,6 @@ mod tests { &CowIntentBody::V1(CowIntent::Order(order_body())), ) .expect("order body encodes"); - vectors - .push_round_trip( - "v1-composable", - &CowIntentBody::V1(CowIntent::Composable(composable_body())), - ) - .expect("composable body encodes"); let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); let mut unknown = bytes(CowIntent::Order(order_body())); @@ -90,7 +73,7 @@ mod tests { truncated, Expectation::Malformed { version: 0 }, ); - let mut trailing = bytes(CowIntent::Composable(composable_body())); + let mut trailing = bytes(CowIntent::Order(order_body())); trailing.push(0); vectors.push_failure( "trailing-bytes", diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 440fa77b..fc4392b5 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -1,9 +1,10 @@ //! # cow-venue //! -//! The CoW venue, staged as a crate of feature slices. Today only the -//! default [`body`] slice exists: the venue-neutral order and composable -//! intent body types and the borsh `IntentBody` codec over them. The -//! typed client and the adapter component are later slices. +//! The CoW venue, staged as a crate of feature slices: the orderbook +//! and nothing else. The default [`body`] slice carries the +//! venue-neutral order intent body types and the borsh `IntentBody` +//! codec over them; conditional-order keeper machinery lives in its +//! own crate and never here. //! //! The body slice is dependency-light on purpose. It links only the //! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) @@ -35,9 +36,6 @@ extern crate alloc; #[cfg(feature = "body")] pub mod body; -#[cfg(feature = "body")] -pub mod composable; - #[cfg(feature = "body")] pub mod order; @@ -57,8 +55,6 @@ pub mod client; #[cfg(feature = "body")] pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] -pub use composable::ComposableBody; -#[cfg(feature = "body")] pub use order::{ BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, }; diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index 83a3525c..ea2a4a47 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -20,4 +20,5 @@ serde_json = { workspace = true, features = ["std"] } # Order construction for the MockVenue acceptance tests that drive # the keeper run end to end. alloy-primitives.workspace = true +composable-cow = { path = "../composable-cow" } cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index e9b3bef0..6867ecdd 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -4,10 +4,11 @@ //! strategy code polling the venue directly. use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index c4ef8455..5fe969d0 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, revert decoding, and prelude on top of cowprotocol types." +description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, and prelude on top of cowprotocol types." [lib] # Plain library - modules link this and emit their own cdylib for the @@ -18,10 +18,11 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # the table-driven retry classification the cow error surface delegates # to. cow-venue = { path = "../cow-venue", features = ["client"] } +# The structured poll seam the keeper run dispatches on. +composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true -alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 19e00a6d..620e379d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,14 +1,14 @@ //! CoW Protocol bridging. //! //! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, -//! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `Verdict`, `RetryAction`), plus [`run()`] - the +//! the on-chain shape (`GPv2OrderData`, orderbook JSON) and the typed +//! Rust surface (`OrderData`, `RetryAction`), plus [`run()`] - the //! poll/submit composition over the keeper stores. //! -//! The poll seam is the structured [`Verdict`]; the deployed -//! ComposableCoW 1.x reverting wire is decoded behind the quarantined -//! [`LegacyRevertAdapter`]. +//! The poll seam is the structured +//! [`Verdict`](composable_cow::Verdict), carried by the +//! `composable-cow` keeper crate together with the quarantined revert +//! decoding; only orderbook concerns live here. //! //! The codec submodules stay purely host-neutral: helpers take //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can @@ -16,12 +16,10 @@ //! unchanged by TWAP, EthFlow, and future strategy modules. The //! keeper run is generic over the host traits alone. -pub mod composable; pub mod error; pub mod order; pub mod run; -pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, @@ -33,8 +31,8 @@ pub use run::run; /// codec, re-exported from the `cow-venue` default slice. The shim keeps /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ - BuyToken, BuyTokenDestination, ComposableBody, CowIntent, CowIntentBody, OrderBody, - OrderBuilder, OrderKind, SellToken, SellTokenSource, + BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, + SellToken, SellTokenSource, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index dbabd179..910090e1 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -17,6 +17,7 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes}; +use composable_cow::Verdict; use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ @@ -24,7 +25,7 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, + CowApiError, CowHost, classify_submit_error, gpv2_to_order_data, is_already_submitted, order_uid_hex, }; diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 491e7752..40798f85 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -16,12 +16,11 @@ //! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! the structured poll seam ([`Verdict`]) with the deployed 1.x -//! revert decoding quarantined behind [`LegacyRevertAdapter`], the -//! classifiers mapping submit failures into -//! the keeper [`RetryAction`], and [`run`] - the poll -> -//! outcome -> gate/journal/submit composition over the keeper -//! stores. +//! the classifiers mapping submit failures into the keeper +//! [`RetryAction`], and [`run`] - the poll -> outcome -> +//! gate/journal/submit composition over the keeper stores, +//! dispatching the structured [`Verdict`] from the `composable-cow` +//! keeper crate. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,8 +49,7 @@ //! [`CowHost`]: cow::CowHost //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`Verdict`]: cow::Verdict -//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter +//! [`Verdict`]: composable_cow::Verdict //! [`RetryAction`]: cow::RetryAction //! [`run`]: cow::run() diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index 6d5c1453..a9b4d5a2 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,29 +4,16 @@ //! //! Covered here: //! -//! - `LegacyRevertAdapter::decode` selector dispatch (no-panic guard). //! - `gpv2_to_order_data` marker mapping (no-panic guard). //! //! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`. +//! live in `nexum-sdk`; the revert-decode guard lives in +//! `composable-cow`. #![cfg(test)] use proptest::prelude::*; -proptest! { - /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must - /// never panic and must return `None` for inputs shorter than the - /// 4-byte EVM selector. - #[test] - fn legacy_revert_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::LegacyRevertAdapter::decode(&bytes); - if bytes.len() < 4 { - prop_assert!(outcome.is_none()); - } - } -} - proptest! { /// `gpv2_to_order_data` is exhaustive over the marker enum; /// fuzzing the inputs as raw u8 (not the typed enum) is the only diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index cda235ba..76677e0a 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -7,11 +7,12 @@ use std::cell::Cell; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, OrderRejection, order_uid_hex, run}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; diff --git a/justfile b/justfile index 696d781f..9c89a2f9 100644 --- a/justfile +++ b/justfile @@ -78,6 +78,11 @@ run-e2e: build-e2e build-engine check-venue-agnostic: ./scripts/check-venue-agnostic.sh +# Orderbook-only gate: the CoW venue crate carries no composable +# symbol. Blocking in CI. +check-cow-orderbook-only: + ./scripts/check-cow-orderbook-only.sh + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 91a37a90..5533d59a 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] +composable-cow = { path = "../../crates/composable-cow" } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } cowprotocol = { version = "0.2.0", default-features = false } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index dd6da48c..0223f5b8 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -16,6 +16,7 @@ use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; +use composable_cow::{LegacyRevertAdapter, Verdict}; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; @@ -23,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; +use shepherd_sdk::cow::{CowHost, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -735,8 +736,8 @@ mod tests { // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. use alloy_sol_types::SolError; + use composable_cow::IConditionalOrder; use nexum_sdk::host::RpcError; - use shepherd_sdk::cow::IConditionalOrder; let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh new file mode 100755 index 00000000..52229ed2 --- /dev/null +++ b/scripts/check-cow-orderbook-only.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Orderbook-only check for the CoW venue crate: crates/cow-venue carries +# no composable symbol (Composable*, getTradeableOrder*, the +# IConditionalOrder revert selectors, LegacyRevertAdapter) and no +# dependency edge to the composable-cow keeper crate - the Cargo.toml +# scan covers the edge, since the dep line names the crate. Blocking in +# CI; run locally via `just check-cow-orderbook-only`. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." || exit 2 + +pass() { printf '\033[1;32m[cow PASS]\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31m[cow FAIL]\033[0m %s\n' "$*" >&2; status=1; } + +command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } + +status=0 + +symbols='composable|getTradeableOrder|IConditionalOrder|LegacyRevertAdapter|\bVerdict\b|OrderNotValid|PollTryNextBlock|PollTryAtBlock|PollTryAtEpoch|PollNever' +rg -in --no-heading -e "$symbols" crates/cow-venue +case $? in + 0) fail "composable symbols leak into crates/cow-venue" ;; + 1) pass "cow-venue symbol scan empty" ;; + *) fail "symbol scan errored (crates/cow-venue missing?)" ;; +esac + +exit "$status" From db3aa91cc7d32ec19f3ab8946c46c2d7372eefe0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 03:17:48 +0000 Subject: [PATCH 068/139] wit: own the cow event ABIs at the bundle layer (#463) Pin ConditionalOrderCreated and EthFlow OrderPlacement in a new shepherd:cow/cow-events package of record; keepers resolve topic-0s from the mirrored shepherd-sdk constants, parity-tested against the WIT, the sol! decoders and each module.toml. Mark the legacy cow-api extension surface retiring and gate the generic WIT packages against any shepherd:cow reference. --- Cargo.lock | 1 + crates/shepherd-sdk/Cargo.toml | 1 + crates/shepherd-sdk/src/cow/events.rs | 111 ++++++++++++++++++++++++ crates/shepherd-sdk/src/cow/mod.rs | 1 + docs/deployment/multi-chain.md | 3 +- modules/ethflow-watcher/module.toml | 4 +- modules/ethflow-watcher/src/strategy.rs | 27 +++--- modules/twap-monitor/module.toml | 7 +- modules/twap-monitor/src/strategy.rs | 24 +++-- wit/shepherd-cow/cow-api.wit | 2 + wit/shepherd-cow/cow-events.wit | 20 +++++ wit/shepherd-cow/cow-ext.wit | 1 + 12 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/events.rs create mode 100644 wit/shepherd-cow/cow-events.wit diff --git a/Cargo.lock b/Cargo.lock index d63586bc..e25aca46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5225,6 +5225,7 @@ name = "shepherd-sdk" version = "0.1.0" dependencies = [ "alloy-primitives", + "alloy-sol-types", "composable-cow", "cow-venue", "cowprotocol", diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 5fe969d0..a6cc1049 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -32,6 +32,7 @@ tracing.workspace = true # `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } +alloy-sol-types.workspace = true proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the keeper run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs new file mode 100644 index 00000000..20acdd42 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/events.rs @@ -0,0 +1,111 @@ +//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. +//! +//! `wit/shepherd-cow/cow-events.wit` is the package of record; the +//! constants here are parity-tested against it, the `cowprotocol` +//! `sol!` types, and each keeper's `module.toml`. + +use alloy_primitives::{B256, b256}; + +/// One on-chain event surface: the canonical Solidity signature and +/// its keccak256 topic-0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EventAbi { + /// Canonical Solidity event signature. + pub signature: &'static str, + /// keccak256 of [`Self::signature`]: the log's topic-0. + pub topic0: B256, +} + +/// `ComposableCoW.ConditionalOrderCreated`. +pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { + signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", + topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), +}; + +/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). +pub const ORDER_PLACEMENT: EventAbi = EventAbi { + signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ + uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", + topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), +}; + +/// Every event surface the keepers decode. +pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; + +#[cfg(test)] +mod tests { + use alloy_primitives::keccak256; + use alloy_sol_types::SolEvent; + use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; + + use super::*; + + #[test] + fn topic0_is_keccak_of_signature() { + for abi in ALL { + assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); + } + } + + #[test] + fn matches_the_sol_decoder_types() { + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE, + CONDITIONAL_ORDER_CREATED.signature, + ); + assert_eq!( + ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, + CONDITIONAL_ORDER_CREATED.topic0, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, + ORDER_PLACEMENT.signature, + ); + assert_eq!( + CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, + ORDER_PLACEMENT.topic0, + ); + } + + #[test] + fn wit_package_of_record_pins_every_surface() { + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); + let flat: String = wit + .lines() + .map(|l| l.trim().trim_start_matches("/// ")) + .collect(); + for abi in ALL { + assert!( + flat.contains(abi.signature), + "cow-events.wit must pin the signature {}", + abi.signature, + ); + assert!( + flat.contains(&format!("{:#x}", abi.topic0)), + "cow-events.wit must pin the topic-0 {:#x}", + abi.topic0, + ); + } + } + + /// Layering gate: no generic WIT package references `shepherd:cow`. + #[test] + fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } + } +} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 620e379d..84a97643 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -17,6 +17,7 @@ //! keeper run is generic over the host traits alone. pub mod error; +pub mod events; pub mod order; pub mod run; diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index ed627e11..5ed09852 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -213,7 +213,8 @@ event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362b ## Event topic reference These are keccak256 hashes of the event signatures. They are the same on every -chain; only the contract `address` changes for EthFlow. +chain; only the contract `address` changes for EthFlow. Package of record: +`wit/shepherd-cow/cow-events.wit`. | Event | Topic-0 | |-------|---------| diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 45b6519a..47cc57c0 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -26,7 +26,9 @@ allow = [] # CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, -# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). +# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. # `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from # `cowprotocol/ethflowcontract/networks.prod.json`. Unlike # ComposableCoW's CREATE2 address, EthFlow has had multiple per-network diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 99e25ba1..4d24db00 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -42,7 +42,7 @@ use cowprotocol::{ use nexum_sdk::events::Log; use nexum_sdk::host::Fault; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; +use shepherd_sdk::cow::{CowApiError, CowHost, events, gpv2_to_order_data}; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -89,13 +89,16 @@ pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Resul /// `ETH_FLOW_STAGING` (defensive - the host's `[[subscription]]` /// filter already pins the address, but a misconfigured engine could /// still leak through); -/// - topic0 does not match the event signature; or +/// - topic-0 does not match the `shepherd:cow/cow-events` pin; or /// - the ABI body fails to decode. pub(crate) fn decode_order_placement(log: &Log) -> Option { let contract = log.address(); if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { return None; } + if log.topics().first() != Some(&events::ORDER_PLACEMENT.topic0) { + return None; + } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; Some(DecodedPlacement { contract, @@ -178,7 +181,7 @@ fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; @@ -495,29 +498,29 @@ mod tests { ); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `OrderPlacement` signature. - /// A typo or ABI drift would silently miss every EthFlow event. + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every EthFlow event. #[test] fn topic0_matches_order_placement_canonical_signature() { assert_eq!( OrderPlacement::SIGNATURE_HASH, - b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::ORDER_PLACEMENT.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// Stronger guard than the constant check above: read the shipped /// `module.toml` and assert its pinned `event_signature` actually - /// equals `OrderPlacement::SIGNATURE_HASH` - catches a manifest/code - /// drift the ABI-hash assertion cannot see. (Ported from #164.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_order_placement_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", OrderPlacement::SIGNATURE_HASH); + let expected = format!("{:#x}", events::ORDER_PLACEMENT.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal OrderPlacement::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index e49b8dec..3ef1883d 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -26,9 +26,10 @@ allow = [] # --- subscriptions ------------------------------------------------------ # ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = -# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"). -# Both `address` and `event_signature` are pinned so the supervisor -# does not deliver unrelated logs to the module. +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), +# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in +# strategy.rs. Both `address` and `event_signature` are pinned so the +# supervisor does not deliver unrelated logs to the module. [[subscription]] kind = "chain-log" chain_id = 11155111 diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 0223f5b8..a9bcee04 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -24,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, run}; +use shepherd_sdk::cow::{CowHost, events, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -84,7 +84,12 @@ pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { // ---- indexing path ---- +/// Topic-0 resolves from the `shepherd:cow/cow-events` package of +/// record before the ABI decode. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { + if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + return None; + } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; Some((decoded.data.owner, decoded.data.params)) } @@ -800,28 +805,29 @@ mod tests { }); } - /// Guard: the topic-0 hardcoded in `module.toml` matches the - /// keccak256 of the canonical `ConditionalOrderCreated` signature. - /// A typo or ABI drift would silently miss every registration event. + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every registration event. #[test] fn topic0_matches_conditional_order_created_canonical_signature() { assert_eq!( ConditionalOrderCreated::SIGNATURE_HASH, - b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), - "module.toml event_signature must equal keccak256 of the canonical ABI signature", + events::CONDITIONAL_ORDER_CREATED.topic0, + "sol! topic-0 must match the shepherd:cow/cow-events pin", ); } /// Stronger guard than the constant check above: read the shipped /// `module.toml` and assert its pinned `event_signature` actually - /// equals `ConditionalOrderCreated::SIGNATURE_HASH`. (Ported from #164.) + /// equals the package-of-record topic-0 - catches a manifest/code + /// drift the decoder assertion cannot see. #[test] fn manifest_topic0_matches_conditional_order_created_signature_hash() { let manifest = include_str!("../module.toml"); - let expected = format!("0x{:x}", ConditionalOrderCreated::SIGNATURE_HASH); + let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); assert!( manifest.contains(&expected), - "module.toml event_signature must equal ConditionalOrderCreated::SIGNATURE_HASH ({expected})", + "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", ); } } diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit index 4d0df3ae..0dbaa940 100644 --- a/wit/shepherd-cow/cow-api.wit +++ b/wit/shepherd-cow/cow-api.wit @@ -1,5 +1,7 @@ package shepherd:cow@0.1.0; +/// Legacy host-extension surface: retiring. Submission moves to the +/// generic videre pool seam; deleted at the fork-gated poll wire-swap. interface cow-api { use nexum:host/types@0.1.0.{chain-id, fault}; diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit new file mode 100644 index 00000000..b5f54bc1 --- /dev/null +++ b/wit/shepherd-cow/cow-events.wit @@ -0,0 +1,20 @@ +package shepherd:cow@0.1.0; + +/// CoW on-chain event surfaces the keepers decode. Package of record +/// for the canonical event signatures and their keccak256 topic-0 +/// hashes; guest constants and module manifests are parity-tested +/// against this file. +interface cow-events { + /// A decoded CoW on-chain event surface. Each variant doc pins the + /// canonical Solidity signature and its topic-0. + enum cow-event { + /// ComposableCoW registration. + /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) + /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 + conditional-order-created, + /// CoWSwapOnchainOrders (EthFlow) placement. + /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) + /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 + order-placement, + } +} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit index d78889c8..2a81b5f7 100644 --- a/wit/shepherd-cow/cow-ext.wit +++ b/wit/shepherd-cow/cow-ext.wit @@ -3,6 +3,7 @@ package shepherd:cow@0.1.0; /// Extension world: the cow-api interface alone, wired into a module /// linker by the cow extension. Kept separate from `shepherd` so the /// extension contributes only its own import, never the core interfaces. +/// Retiring with `cow-api`. world cow-ext { import cow-api; } From a3465fd635dd254a4d9e379f07b67c9e268beb91 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 03:21:06 +0000 Subject: [PATCH 069/139] composable-cow: type ComposableBody with alloy primitives (#557) cow: type ComposableBody with alloy primitives Retype the `ConditionalOrderParams` tuple with the types that describe it: `handler` becomes `Address`, `salt` becomes `B256` and `static_input` becomes `Bytes`, so the body carries EVM address semantics instead of raw arrays. No borsh adapter is needed. alloy-primitives 1.6 ships a `borsh` feature (absent at 1.3, when the issue was raised), enabled here on the crate's existing non-optional dependency. Its impls write `Address` and `B256` as bare bytes and `Bytes` length-prefixed, so the wire is byte-identical to the arrays it replaces; a test pins that layout alongside the round-trips. Closes #555 --- Cargo.lock | 2 ++ crates/composable-cow/Cargo.toml | 4 +++- crates/composable-cow/src/body.rs | 40 +++++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e25aca46..bc3b5cf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -227,6 +227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" dependencies = [ "alloy-rlp", + "borsh", "bytes", "cfg-if", "const-hex", @@ -4649,6 +4650,7 @@ dependencies = [ "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "borsh", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml index ceb66cc6..f12a385e 100644 --- a/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -14,7 +14,9 @@ description = "ComposableCoW keeper machinery: the conditional-order body and th workspace = true [dependencies] -alloy-primitives.workspace = true +# `borsh` supplies the `Address`/`B256`/`Bytes` borsh impls the body +# derives against, byte-identical to the fields they replace. +alloy-primitives = { workspace = true, features = ["borsh"] } alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/composable-cow/src/body.rs b/crates/composable-cow/src/body.rs index b5f6bf02..1ca8466c 100644 --- a/crates/composable-cow/src/body.rs +++ b/crates/composable-cow/src/body.rs @@ -7,19 +7,25 @@ //! This body type is that tuple in wire form. The one non-obvious //! invariant: `static_input` is opaque; only the named handler parses //! it, so this crate never inspects its bytes. +//! +//! Borsh comes from `alloy-primitives`' own `borsh` feature, so no +//! adapter is needed: `Address` and `B256` encode as their bare bytes +//! and `Bytes` length-prefixed, leaving the wire byte-identical to the +//! `[u8; 20]`/`[u8; 32]`/`Vec` these fields replaced. +use alloy_primitives::{Address, B256, Bytes}; use borsh::{BorshDeserialize, BorshSerialize}; /// The conditional order body: `ConditionalOrderParams` in wire form. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct ComposableBody { /// The `IConditionalOrder` handler that mints the tradeable order. - pub handler: [u8; 20], + pub handler: Address, /// Salt distinguishing otherwise-identical conditional orders. - pub salt: [u8; 32], + pub salt: B256, /// Handler-specific static input; opaque to everything but the /// named handler. - pub static_input: Vec, + pub static_input: Bytes, } #[cfg(test)] @@ -28,9 +34,9 @@ mod tests { fn sample() -> ComposableBody { ComposableBody { - handler: [0xab; 20], - salt: [0xcd; 32], - static_input: vec![1, 2, 3, 4, 5], + handler: Address::repeat_byte(0xab), + salt: B256::repeat_byte(0xcd), + static_input: Bytes::from_static(&[1, 2, 3, 4, 5]), } } @@ -47,11 +53,31 @@ mod tests { #[test] fn empty_static_input_round_trips() { let mut body = sample(); - body.static_input = Vec::new(); + body.static_input = Bytes::new(); let bytes = borsh::to_vec(&body).expect("encode"); assert_eq!( ComposableBody::try_from_slice(&bytes).expect("decode"), body ); } + + /// The alloy types must encode exactly as the raw arrays did: 20 + /// bare handler bytes, 32 bare salt bytes, then a `u32`-length- + /// prefixed static input. + #[test] + fn wire_matches_the_raw_array_layout() { + let mut expected = Vec::new(); + expected.extend_from_slice(&[0xab; 20]); + expected.extend_from_slice(&[0xcd; 32]); + expected.extend_from_slice(&5_u32.to_le_bytes()); + expected.extend_from_slice(&[1, 2, 3, 4, 5]); + assert_eq!(borsh::to_vec(&sample()).expect("encode"), expected); + } + + /// A truncated handler is a decode error, not a silent short read. + #[test] + fn truncated_input_fails_to_decode() { + let bytes = borsh::to_vec(&sample()).expect("encode"); + assert!(ComposableBody::try_from_slice(&bytes[..10]).is_err()); + } } From 20d38c7f4b69d21691de67b682fe51f3f1171bb4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 03:43:33 +0000 Subject: [PATCH 070/139] status-body: version-tag the intent-status envelope (#556) `IntentStatusUpdate::encode` now leads with its own version tag and `decode` refuses an unknown one, mirroring the inner `StatusBody` codec. The envelope tag frames venue/receipt/status, the body tag frames the status payload, and the two version independently. `EnvelopeError` becomes the same typed empty/unknown-version/malformed enum as `DecodeError`. Skew tests cover a future envelope tag and the pre-tag framing at both the codec and the SDK event seam, and a golden vector pins the whole v1 envelope framing. Closes #548 --- crates/videre-host/src/lib.rs | 5 +- crates/videre-host/tests/platform.rs | 4 +- crates/videre-sdk/src/event.rs | 31 ++++- crates/videre-sdk/src/lib.rs | 7 +- crates/videre-status-body/src/lib.rs | 175 +++++++++++++++++++++++++-- 5 files changed, 197 insertions(+), 25 deletions(-) diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index 34187ca9..82784126 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -156,8 +156,9 @@ async fn status_poll_task( for update in registry.poll_status_transitions().await { let attrs = vec![("venue", update.venue.clone())]; // The transition rides the generic `custom` channel: the - // envelope is borsh, the status body its inner encoding. A - // keeper recovers it through `videre_sdk::event`. + // envelope is a version tag plus borsh, the status body its + // inner encoding. A keeper recovers it through + // `videre_sdk::event`. let payload = match update.encode() { Ok(payload) => payload, Err(err) => { diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index c068fdca..ccfce70b 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -100,8 +100,8 @@ fn block(chain_id: u64) -> nexum::host::types::Block { } /// Wrap a polled transition as the extension event the platform emits: -/// the transition rides the generic `custom` channel, its borsh envelope -/// the opaque payload. +/// the transition rides the generic `custom` channel, its tagged borsh +/// envelope the opaque payload. fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { let attrs = vec![("venue", update.venue.clone())]; let payload = update.encode().expect("encode intent-status envelope"); diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs index fe450ba6..31a188a0 100644 --- a/crates/videre-sdk/src/event.rs +++ b/crates/videre-sdk/src/event.rs @@ -14,7 +14,8 @@ pub use crate::status_body::EnvelopeError; /// Recover an [`IntentStatusUpdate`] from a `custom` event, keyed by its /// `kind` and `payload`. `None` when the kind is another extension's; -/// `Some(Err)` when the payload is malformed. +/// `Some(Err)` when the payload is empty, tagged to an envelope version +/// this build does not publish, or malformed. pub fn intent_status_update( kind: &str, payload: &[u8], @@ -61,12 +62,30 @@ mod tests { assert!(intent_status_update("other-kind", &envelope()).is_none()); } + /// A host framing the envelope to a version this guest does not + /// publish is refused at the seam, not misread into a plausible + /// update. + #[test] + fn reports_a_skewed_envelope_version() { + let mut skewed = envelope(); + skewed[0] = crate::status_body::ENVELOPE_VERSION_V1 + 1; + assert!(matches!( + intent_status_update(INTENT_STATUS_KIND, &skewed).expect("kind matches"), + Err(EnvelopeError::UnknownVersion { .. }), + )); + } + + /// A payload tagged to a version this build does publish, whose body + /// is garbage, is the caller's `invalid-input`, not a skew report. #[test] fn reports_a_malformed_payload() { - assert!( - intent_status_update(INTENT_STATUS_KIND, b"\xff") - .expect("kind matches") - .is_err() - ); + assert!(matches!( + intent_status_update( + INTENT_STATUS_KIND, + &[crate::status_body::ENVELOPE_VERSION_V1, 0xff], + ) + .expect("kind matches"), + Err(EnvelopeError::Malformed { .. }), + )); } } diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index c4be49d6..9145681f 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -119,9 +119,10 @@ pub use bindings::videre::value_flow::types as value_flow; pub use videre_status_body as status_body; /// The intent-status transition a keeper recovers from a `custom` event -/// through [`event::intent_status_update`]. Its wire form is a borsh -/// envelope defined by this struct, not a WIT record: it crosses the -/// `custom` event as opaque bytes. The status body rides its inner codec. +/// through [`event::intent_status_update`]. Its wire form is a version +/// tag plus the borsh envelope defined by this struct, not a WIT record: +/// it crosses the `custom` event as opaque bytes, and an unknown tag +/// fails closed. The status body rides its own inner codec. pub use videre_status_body::IntentStatusUpdate; /// The wire config table (`nexum:host/types.config`) `init` receives. diff --git a/crates/videre-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs index 271b6f22..327c285c 100644 --- a/crates/videre-status-body/src/lib.rs +++ b/crates/videre-status-body/src/lib.rs @@ -7,6 +7,13 @@ //! //! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the //! borsh `option` encodings of `proof` and `reason`. +//! +//! The [`IntentStatusUpdate`] envelope that carries such a body is tagged +//! the same way and fails closed the same way, on its own version line: +//! the envelope tag describes the `venue`/`receipt`/`status` framing, the +//! body tag describes the status payload, and the two move independently. +//! v1 envelope wire form: `0x01`, then the borsh `{venue, receipt, +//! status}` payload. #![warn(missing_docs)] @@ -15,6 +22,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; /// Wire tag of the v1 payload. pub const VERSION_V1: u8 = 1; +/// Wire tag of the v1 [`IntentStatusUpdate`] envelope. Independent of +/// [`VERSION_V1`]: the framing versions apart from the body it carries. +pub const ENVELOPE_VERSION_V1: u8 = 1; + /// The extension event kind an intent-status transition rides on: the /// `custom-event.kind` the venue platform stamps and a subscribing /// module matches. Shared by the emit side and the decode side so the @@ -22,8 +33,9 @@ pub const VERSION_V1: u8 = 1; pub const INTENT_STATUS_KIND: &str = "intent-status"; /// The intent-status transition an intent-status `custom` event carries -/// in its opaque payload: borsh `{venue, receipt, status}`, where -/// `status` is a [`StatusBody`]-encoded body (the inner codec above). +/// in its opaque payload: [`ENVELOPE_VERSION_V1`], then borsh +/// `{venue, receipt, status}`, where `status` is a [`StatusBody`]-encoded +/// body (the inner codec above). #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub struct IntentStatusUpdate { /// Venue id the receipt was issued by. @@ -35,29 +47,55 @@ pub struct IntentStatusUpdate { } impl IntentStatusUpdate { - /// Borsh-encode the envelope. + /// Encode as the envelope version tag plus the borsh payload. pub fn encode(&self) -> Result, EncodeError> { - let mut out = Vec::new(); + let mut out = vec![ENVELOPE_VERSION_V1]; borsh::to_writer(&mut out, self).map_err(|err| EncodeError { detail: err.to_string(), })?; Ok(out) } - /// Decode a borsh envelope, failing typedly on malformed bytes. + /// Decode, failing typedly on an empty envelope, an unknown version + /// tag (fail-closed), or a payload that does not parse as the tagged + /// version (including trailing bytes). pub fn decode(bytes: &[u8]) -> Result { - borsh::from_slice(bytes).map_err(|err| EnvelopeError { - detail: err.to_string(), - }) + match bytes { + [] => Err(EnvelopeError::Empty), + [ENVELOPE_VERSION_V1, payload @ ..] => { + borsh::from_slice(payload).map_err(|err| EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + detail: err.to_string(), + }) + } + [version, ..] => Err(EnvelopeError::UnknownVersion { version: *version }), + } } } /// Why bytes failed to decode as an [`IntentStatusUpdate`] envelope. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] -#[error("malformed intent-status envelope: {detail}")] -pub struct EnvelopeError { - /// Borsh's decode failure detail. - pub detail: String, +#[non_exhaustive] +pub enum EnvelopeError { + /// No bytes at all: not even a version tag. + #[error("empty intent-status envelope: missing the version tag")] + Empty, + /// The version tag names no published envelope version. Fail-closed: + /// a skewed peer's framing is refused, never guessed at. + #[error("unknown intent-status envelope 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} intent-status envelope: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, } /// Where an intent is in its life at the venue. The borsh discriminant @@ -177,6 +215,119 @@ mod tests { } } + fn envelope(venue: &str) -> IntentStatusUpdate { + IntentStatusUpdate { + venue: venue.to_owned(), + receipt: b"receipt".to_vec(), + status: body(IntentStatus::Open).encode().expect("encode body"), + } + } + + #[test] + fn envelope_leads_with_its_version_tag() { + let encoded = envelope("cow").encode().expect("encode"); + assert_eq!(encoded[0], ENVELOPE_VERSION_V1); + } + + /// Pins the whole v1 envelope framing, not just the tag's position: + /// a borsh layout drift is a wire break, so it must fail here. + #[test] + fn golden_envelope() { + let encoded = envelope("cow").encode().expect("encode"); + let expected = [ + &[ENVELOPE_VERSION_V1, 3, 0, 0, 0][..], + b"cow", + &[7, 0, 0, 0], + b"receipt", + &[4, 0, 0, 0, VERSION_V1, 1, 0, 0], + ] + .concat(); + assert_eq!(encoded, expected); + } + + #[test] + fn envelope_round_trips() { + let original = envelope("cow"); + let decoded = + IntentStatusUpdate::decode(&original.encode().expect("encode")).expect("decode"); + assert_eq!(decoded, original); + } + + #[test] + fn empty_envelope_fails_typedly() { + assert_eq!(IntentStatusUpdate::decode(&[]), Err(EnvelopeError::Empty)); + } + + /// A peer that framed the envelope to a version this build does not + /// publish is refused, not misparsed into plausible-looking fields. + #[test] + fn future_envelope_version_fails_closed() { + let mut skewed = envelope("cow").encode().expect("encode"); + skewed[0] = ENVELOPE_VERSION_V1 + 1; + assert_eq!( + IntentStatusUpdate::decode(&skewed), + Err(EnvelopeError::UnknownVersion { + version: ENVELOPE_VERSION_V1 + 1, + }), + ); + } + + /// The pre-tag framing (bare borsh, no leading tag) is skew too: it + /// must never decode, whatever the leading length byte happens to be. + #[test] + fn untagged_envelope_never_decodes() { + for venue in ["c", "cow", "a longer venue id"] { + let mut untagged = Vec::new(); + borsh::to_writer(&mut untagged, &envelope(venue)).expect("encode"); + assert!( + IntentStatusUpdate::decode(&untagged).is_err(), + "untagged {venue} envelope decoded", + ); + } + } + + #[test] + fn envelope_trailing_bytes_are_malformed() { + let mut encoded = envelope("cow").encode().expect("encode"); + encoded.push(0); + assert!(matches!( + IntentStatusUpdate::decode(&encoded), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + #[test] + fn truncated_envelope_is_malformed() { + let encoded = envelope("cow").encode().expect("encode"); + assert!(matches!( + IntentStatusUpdate::decode(&encoded[..encoded.len() - 1]), + Err(EnvelopeError::Malformed { + version: ENVELOPE_VERSION_V1, + .. + }), + )); + } + + /// The envelope tag and the body tag are separate wire lines: the + /// envelope decodes even when the body it carries is a version this + /// build refuses. + #[test] + fn envelope_and_body_versions_are_independent() { + let mut update = envelope("cow"); + update.status[0] = VERSION_V1 + 1; + let decoded = + IntentStatusUpdate::decode(&update.encode().expect("encode")).expect("decode"); + assert_eq!( + StatusBody::decode(&decoded.status), + Err(DecodeError::UnknownVersion { + version: VERSION_V1 + 1, + }), + ); + } + #[test] fn golden_minimal_open() { let encoded = body(IntentStatus::Open).encode().expect("encode"); From 675ee464b425db2b4cde0b330ec7fb62ceabf757 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 04:34:29 +0000 Subject: [PATCH 071/139] cow: ratify the retry classification table against the upstream errorType enum (#464) Prune the phantom PriceExceedsMarketPrice row, record the table (not cowprotocol RetryHint) as the classification source of truth in the data header, and pin the reconciliation with parity tests: every row must name a real upstream errorType, and the divergence from retry_hint() must be exactly the ratified set. --- Cargo.lock | 1 + crates/cow-venue/Cargo.toml | 3 ++ crates/cow-venue/data/classification.toml | 35 +++++++------ crates/cow-venue/src/classification.rs | 60 +++++++++++++++++++++++ crates/shepherd-sdk/src/cow/error.rs | 13 ++--- 5 files changed, 89 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc3b5cf9..4645f0e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1571,6 +1571,7 @@ name = "cow-venue" version = "0.1.0" dependencies = [ "borsh", + "cowprotocol", "nexum-sdk", "serde", "thiserror 2.0.18", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 8e303765..3a780947 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -41,6 +41,9 @@ toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. videre-test = { path = "../videre-test" } +# Parity tests only: the upstream errorType enum and `retry_hint()` the +# shipped table is reconciled against. Never a runtime dependency. +cowprotocol = { version = "0.2.0", default-features = false } [features] # The body-type + codec slice ships by default; the `client` slice layers diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index 46507d48..f7910590 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -33,17 +33,26 @@ # # Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream # `cowprotocol` crate (a shepherd-sdk dependency) also classifies -# orderbook `errorType`s, via `RetryHint`. This table is deliberately -# NOT delegated to it: it is shepherd's own, more conservative retry -# policy, kept as data of record here so a non-Rust author owns it and -# so the guest `client` slice stays free of the upstream error module. -# The two intentionally diverge on several types - e.g. this table drops -# `InvalidEip1271Signature`, `InsufficientBalance`, `InsufficientAllowance` -# and `InvalidAppData` where upstream retries or backs off, and backs off -# `TooManyLimitOrders` for 30s rather than an hour. These are ratified -# shepherd decisions (a permanent-looking contract rejection is dropped -# rather than retried every block); revisit them here, not by switching -# the source of truth to `RetryHint`. +# orderbook `errorType`s, via `RetryHint`. Ratified: this table, not +# `RetryHint`, is shepherd's classification source of truth. It is +# shepherd's own, more conservative retry policy, kept as data of record +# here so a non-Rust author owns it and so the guest `client` slice +# stays free of the upstream error module. The ratified divergences +# (a permanent-looking contract rejection is dropped rather than +# retried, and the limit-order backoff is shorter) are exactly: +# +# InvalidEip1271Signature drop upstream: retry next block +# InsufficientBalance drop upstream: backoff 10 min +# InsufficientAllowance drop upstream: backoff 10 min +# InvalidAppData drop upstream: backoff 60 s +# TooManyLimitOrders backoff 30 s upstream: backoff 1 h +# +# Every `error-type` below must name a member of the upstream orderbook +# errorType enum (`cowprotocol::OrderbookApiErrorType`). Parity tests +# reject phantom types and pin the divergence set to the list above, so +# both a data edit and an upstream policy change force re-ratification. +# Revisit policy here, not by switching the source of truth to +# `RetryHint`. # --- Transient: retry on the next block ------------------------------ @@ -51,10 +60,6 @@ error-type = "InsufficientFee" action = "try-next-block" -[[entry]] -error-type = "PriceExceedsMarketPrice" -action = "try-next-block" - # --- Throttle: wait, then retry -------------------------------------- # The account already holds the maximum number of open limit orders. A diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index 73d0e71f..aabda91f 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -235,6 +235,66 @@ mod tests { ); } + /// Every listed `error-type` names a member of the upstream + /// orderbook errorType enum, in its exact wire spelling: no phantom + /// rows. + #[test] + fn every_row_names_a_real_error_type() { + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + for entry in &entries { + let kind = cowprotocol::OrderbookApiErrorType::from(entry.error_type.as_str()); + assert!( + !matches!(kind, cowprotocol::OrderbookApiErrorType::Unknown(_)), + "phantom errorType {}", + entry.error_type, + ); + assert_eq!(kind.as_str(), entry.error_type, "wire spelling"); + } + } + + /// The table's divergence from upstream `retry_hint()` is exactly + /// the ratified set in the data header. A data edit or an upstream + /// policy change lands here and forces re-ratification. + #[test] + fn divergence_from_upstream_is_exactly_the_ratified_set() { + const RATIFIED: [&str; 5] = [ + "InsufficientAllowance", + "InsufficientBalance", + "InvalidAppData", + "InvalidEip1271Signature", + "TooManyLimitOrders", + ]; + let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); + let mut divergent: Vec<&str> = Vec::new(); + for entry in &entries { + let api = cowprotocol::ApiError { + error_type: entry.error_type.clone(), + description: String::new(), + data: None, + }; + // Project the upstream hint into the table's model; a hint + // variant this projection does not know is a divergence. + let upstream = match api.retry_hint() { + cowprotocol::RetryHint::Retry => Some((RetryAction::TryNextBlock, false)), + cowprotocol::RetryHint::Backoff { seconds } => { + Some((RetryAction::Backoff { seconds }, false)) + } + cowprotocol::RetryHint::Drop => Some((RetryAction::Drop, false)), + cowprotocol::RetryHint::AlreadySubmitted => Some((RetryAction::TryNextBlock, true)), + _ => None, + }; + let shepherd = ( + classify(&entry.error_type), + is_already_submitted(&entry.error_type), + ); + if upstream != Some(shepherd) { + divergent.push(&entry.error_type); + } + } + divergent.sort_unstable(); + assert_eq!(divergent, RATIFIED); + } + /// A non-Rust reader sees the same file as plain data: parsing it /// with the untyped TOML value model (no Rust schema) exposes the /// entries and their fields, proving any TOML library reads it. diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 3f676861..a5e0de03 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -170,14 +170,11 @@ mod tests { } #[test] - fn retriable_kinds_yield_try_next_block() { - for kind in ["InsufficientFee", "PriceExceedsMarketPrice"] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } + fn retriable_kind_yields_try_next_block() { + assert_eq!( + classify_api_error(&rejection("InsufficientFee")), + RetryAction::TryNextBlock, + ); } /// A throttle errorType backs off rather than retrying next block, From d88dc995bb2da940fff9ac1e7a3ff34ccf112bab Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 04:37:48 +0000 Subject: [PATCH 072/139] docs: note the grant-M2 contract-mods divergence in milestone reporting (#465) docs: call out the grant-M2 contract-mods divergence in milestone reporting --- docs/00-overview.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/00-overview.md b/docs/00-overview.md index 4deeecd5..4e5d12ce 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -342,11 +342,13 @@ The mobile/wallet host story - including the experimental `query-module` world's | # | Milestone | Effort | Key Deliverables | |---|-----------|--------|------------------| | 1 | Core Runtime & Event System | 120h | wasmtime Component Model host, WIT interfaces, event sources, redb local store, CLI | -| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods | +| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods\* | | 3 | SDK & Developer Experience | 60h | `shepherd-sdk` + `shepherd-sdk-test` crates (host-trait seam per ADR-0009), example modules, tutorial, docs | | 4 | Production Hardening | 60h | Resource limits, restart policy, logging, metrics, health checks | | 5 | Multi-Chain & Deployment | 40h | Multi-chain config, Docker image, deployment docs | +\* **M2 divergence.** The "ComposableCoW contract mods" deliverable (enhanced polling interfaces, optimized getters, monitoring events) was intentionally met off-chain: no Solidity was modified. The TWAP module polls `getTradeableOrderWithSignature` via raw `eth_call` with SDK helpers. Contract-side interfaces would fix one concrete TWAP implementation behind the boundary and block competing strategies (ADR-0006). The functional goal stands; the literal deliverable does not. + ## Repository Structure ``` From 93994bc10b6c08d5dd9028abf07365e38e1b6473 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 05:20:39 +0000 Subject: [PATCH 073/139] cow: settle the idempotency seam on the venue-and-body intent-id (#466) The submitted: journal now keys on the deterministic intent-id (the generic sweep's venue-and-body submission key over the encoded CowIntentBody), derived pre-submit without assembling OrderCreation. SubmitOutcome's accepted receipt is fixed as the canonical 56-byte order UID (OrderUid), and the CowIntent schema gains the Signed kind a conditional-order keeper emits. A regression test covers resubmit after restart with a single orderbook POST. --- crates/cow-venue/src/body.rs | 19 +++- crates/cow-venue/src/client.rs | 48 +++++++++- crates/cow-venue/src/lib.rs | 8 +- crates/cow-venue/src/order.rs | 98 ++++++++++++++++++++ crates/shepherd-sdk-test/tests/mock_venue.rs | 25 ++++- crates/shepherd-sdk/src/cow/mod.rs | 4 +- crates/shepherd-sdk/src/cow/order.rs | 55 +++++++++++ crates/shepherd-sdk/src/cow/run.rs | 83 +++++++++-------- crates/shepherd-sdk/tests/run.rs | 81 ++++++++++++---- crates/videre-sdk/src/keeper.rs | 8 +- modules/twap-monitor/src/strategy.rs | 53 ++++++----- 11 files changed, 380 insertions(+), 102 deletions(-) diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index feb7d3c8..6bb39165 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,6 +1,6 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is a direct order for the orderbook; [`CowIntent`] is +//! A CoW intent is an order for the orderbook; [`CowIntent`] is //! that sum, open for future intent kinds. [`CowIntentBody`] is the //! outer per-venue version enum the venue publishes, and //! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version @@ -13,13 +13,16 @@ use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; -use crate::order::OrderBody; +use crate::order::{OrderBody, SignedOrder}; -/// What the CoW venue accepts: a direct order for the orderbook. +/// What the CoW venue accepts: an order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), + /// An owner-signed order with its EIP-1271 signature: what a + /// conditional-order keeper emits after a poll. + Signed(SignedOrder), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -56,6 +59,16 @@ mod tests { &CowIntentBody::V1(CowIntent::Order(order_body())), ) .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + })), + ) + .expect("signed order encodes"); let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); let mut unknown = bytes(CowIntent::Order(order_body())); diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 892e4f4e..9dd86963 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,12 +8,17 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. +use alloc::string::String; + use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; use crate::body::CowIntentBody; /// The CoW venue marker: every [`CowClient`] call routes to -/// [`Venue::ID`] and encodes a [`CowIntentBody`]. +/// [`Venue::ID`] and encodes a [`CowIntentBody`]. An accepted submit's +/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. #[derive(Clone, Copy, Debug)] pub struct CowVenue; @@ -26,6 +31,19 @@ impl Venue for CowVenue { /// or submit a foreign body. pub type CowClient = VenueClient; +/// Deterministic intent-id for `body`: the sweep's +/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any +/// network work, so a keeper journals the same key whether it submits +/// through the sweep or directly. +/// +/// The key covers the encoded body, so a signed payload +/// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its +/// signature: it scopes to that exact payload, not to the economic +/// order, which dedups only through the venue's duplicate response. +pub fn intent_id(body: &CowIntentBody) -> Result { + Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) +} + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -91,6 +109,34 @@ mod tests { )) } + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + use videre_sdk::IntentBody; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + let body = sample_body(); + let id = intent_id(&body).expect("body encodes"); + assert_eq!(id, intent_id(&body.clone()).expect("body encodes")); + assert_eq!( + id, + submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), + "the id must be exactly the key the generic sweep journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .build(), + owner: [0x55; 20], + signature: vec![0xC0], + })); + assert_ne!(id, intent_id(&other).expect("body encodes")); + } + #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { use videre_sdk::IntentBody; diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index fc4392b5..4e0879ba 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -19,7 +19,8 @@ //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the -//! CoW venue plus the table-driven retry [`classification`] generated at +//! CoW venue, the deterministic [`intent_id`] journal key, and the +//! table-driven retry [`classification`] generated at //! build time from the shipped `data/classification.toml` (the TOML //! parser stays a build-time dependency, off the guest). It links the //! strategy keeper (for the retry action type) and is off by default, @@ -56,10 +57,11 @@ pub mod client; pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use order::{ - BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, OrderUid, SellToken, + SellTokenSource, SignedOrder, }; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] -pub use client::{CowClient, CowVenue}; +pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index a8ac4a57..8bb1d725 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,8 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use alloc::vec::Vec; +use core::fmt; use core::marker::PhantomData; use borsh::{BorshDeserialize, BorshSerialize}; @@ -259,6 +261,70 @@ impl OrderBuilder { } } +/// An owner-signed order ready for the orderbook: what a +/// conditional-order keeper emits after a poll. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct SignedOrder { + /// The order to place. + pub order: OrderBody, + /// Order owner: the EIP-1271 verifier and the `from` of the + /// orderbook submission. + pub owner: Address, + /// Raw EIP-1271 signature bytes; the settlement verifies them + /// against `owner`. + pub signature: Vec, +} + +/// Canonical 56-byte orderbook order UID (order digest, owner, +/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit +/// carries. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// The raw 56 bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 56] { + &self.0 + } +} + +impl From<[u8; 56]> for OrderUid { + fn from(bytes: [u8; 56]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for OrderUid { + type Error = core::array::TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self(<[u8; 56]>::try_from(bytes)?)) + } +} + +impl From for Vec { + fn from(uid: OrderUid) -> Self { + uid.0.to_vec() + } +} + +impl fmt::Display for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("0x")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + #[cfg(test)] mod tests { use super::*; @@ -367,4 +433,36 @@ mod tests { } } } + + #[test] + fn signed_order_borsh_round_trips() { + let signed = SignedOrder { + order: sample(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + }; + let bytes = borsh::to_vec(&signed).expect("encode"); + assert_eq!(SignedOrder::try_from_slice(&bytes).expect("decode"), signed); + } + + #[test] + fn order_uid_converts_only_from_56_bytes() { + let uid = OrderUid([0xAB; 56]); + assert_eq!(OrderUid::try_from(&uid.0[..]).expect("56 bytes"), uid); + assert!(OrderUid::try_from(&uid.0[..55]).is_err()); + assert_eq!(Vec::from(uid), vec![0xAB; 56]); + } + + #[test] + fn order_uid_displays_as_prefixed_hex() { + let mut bytes = [0u8; 56]; + bytes[0] = 0x01; + bytes[55] = 0xFF; + let uid = OrderUid(bytes); + let hex = uid.to_string(); + assert_eq!(hex.len(), 2 + 56 * 2); + assert!(hex.starts_with("0x01")); + assert!(hex.ends_with("ff")); + assert_eq!(format!("{uid:?}"), hex); + } } diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index 6867ecdd..d8af0612 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -8,7 +8,10 @@ use composable_cow::Verdict; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowHost, CowIntent, CowIntentBody, OrderRejection, SignedOrder, + gpv2_to_order_data, order_data_to_body, order_uid_hex, run, +}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; @@ -107,6 +110,18 @@ fn client_uid(order: &GPv2OrderData) -> String { order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") } +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") +} + fn rejection(error_type: &str) -> CowApiError { CowApiError::Rejected(OrderRejection { status: 400, @@ -136,7 +151,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( !Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); @@ -144,7 +159,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); assert_eq!( @@ -190,7 +205,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } @@ -220,7 +235,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 84a97643..e733da5d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -25,7 +25,7 @@ pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_uid_hex}; +pub use order::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use run::run; /// The venue-neutral intent body types and their borsh `IntentBody` @@ -33,7 +33,7 @@ pub use run::run; /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, - SellToken, SellTokenSource, + OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d0a79bfb..79431954 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -92,6 +92,37 @@ pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Op Some(format!("{}", order_data.uid(&domain, owner))) } +/// Project a typed [`OrderData`] into the venue wire +/// [`OrderBody`](cow_venue::OrderBody) a keeper emits. Total: every +/// typed field has exactly one wire form. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { + cow_venue::OrderBody { + sell_token: order.sell_token.into_array(), + buy_token: order.buy_token.into_array(), + receiver: order.receiver.map(Address::into_array), + sell_amount: order.sell_amount.to_be_bytes(), + buy_amount: order.buy_amount.to_be_bytes(), + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount.to_be_bytes(), + kind: match order.kind { + OrderKind::Sell => cow_venue::OrderKind::Sell, + OrderKind::Buy => cow_venue::OrderKind::Buy, + }, + partially_fillable: order.partially_fillable, + sell_token_balance: match order.sell_token_balance { + SellTokenSource::Erc20 => cow_venue::SellTokenSource::Erc20, + SellTokenSource::External => cow_venue::SellTokenSource::External, + SellTokenSource::Internal => cow_venue::SellTokenSource::Internal, + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenDestination::Erc20 => cow_venue::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => cow_venue::BuyTokenDestination::Internal, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -159,6 +190,30 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } + // ---- order_data_to_body ---- + + #[test] + fn order_data_to_body_projects_every_field() { + let g = submittable_gpv2(); + let order = gpv2_to_order_data(&g).expect("known markers"); + let body = order_data_to_body(&order); + assert_eq!(body.sell_token, g.sellToken.into_array()); + assert_eq!(body.buy_token, g.buyToken.into_array()); + assert_eq!(body.receiver, Some(g.receiver.into_array())); + assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); + assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + assert_eq!(body.kind, cow_venue::OrderKind::Sell); + assert!(!body.partially_fillable); + assert_eq!(body.sell_token_balance, cow_venue::SellTokenSource::Erc20); + assert_eq!( + body.buy_token_balance, + cow_venue::BuyTokenDestination::Erc20 + ); + } + // ---- order_uid_hex ---- const SEPOLIA: u64 = 11_155_111; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index 910090e1..f66d73dc 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -6,8 +6,9 @@ //! [`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. +//! journal as the idempotency guard - keyed on the venue-and-body +//! [`intent_id`] - and the keeper [`Retrier`] as the failure +//! dispatch. //! //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they classify into a @@ -25,8 +26,8 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, classify_submit_error, gpv2_to_order_data, is_already_submitted, - order_uid_hex, + CowApiError, CowHost, CowIntent, CowIntentBody, SignedOrder, classify_submit_error, + gpv2_to_order_data, intent_id, is_already_submitted, order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's @@ -76,9 +77,12 @@ where /// `submitted:` journal and dispatching any failure through the retry /// ledger. /// -/// The UID is deterministic from on-chain inputs, so the idempotency -/// check runs before any network work; the same value keys the journal -/// marker after, so the read and write paths agree. +/// The journal keys on the deterministic venue-and-body +/// [`intent_id`], derived before any network work from the same body +/// bytes a venue submit carries - never from the assembled +/// `OrderCreation` - so the guard survives assembly moving into the +/// venue adapter. The orderbook's UID is the receipt; it rides the +/// log only. fn submit_ready( host: &H, watch: WatchRef<'_>, @@ -95,15 +99,6 @@ fn submit_ready( return Ok(()); }; - let journal = Journal::submitted(host); - let client_uid = order_uid_hex(tick.chain_id, order, owner); - if let Some(uid) = client_uid.as_deref() - && journal.contains(uid)? - { - tracing::info!("{label} {uid} already submitted; skipping re-submit"); - return Ok(()); - } - let Some(order_data) = gpv2_to_order_data(order) else { // An unknown enum marker means the SDK cannot express this // payload yet; skip rather than drop so an SDK upgrade can @@ -113,6 +108,24 @@ fn submit_ready( ); return Ok(()); }; + + let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + })); + let intent_id = match intent_id(&intent) { + Ok(id) => id, + Err(err) => { + tracing::error!("intent body encode failed: {err}"); + return Ok(()); + } + }; + let journal = Journal::submitted(host); + if journal.contains(&intent_id)? { + tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); + return Ok(()); + } let creation = match build_order_creation(&order_data, signature, owner) { Ok(creation) => creation, Err(err) => { @@ -137,41 +150,29 @@ fn submit_ready( }; match host.submit_order(tick.chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID so the guard above reads - // what this writes; a divergence would be a protocol bug - // worth a warning, never a silently split keyspace. - let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + Ok(receipt) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. - if let Err(fault) = journal.record(marker) { - tracing::error!("submitted {marker} but journal write failed: {fault}"); + if let Err(fault) = journal.record(&intent_id) { + tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } - if let Some(client) = client_uid.as_deref() - && client != server_uid - { - tracing::warn!( - "{label} UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ); - } - tracing::info!("submitted {marker}"); + tracing::info!("submitted {intent_id} (receipt {receipt})"); } Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { // Success wearing an error status: the orderbook already - // holds this order. Record the receipt and keep the watch - // so the next tick short-circuits instead of re-posting. - // As above, a journal fault post-submit only forfeits the - // short-circuit; it must not abort the sweep. - if let Some(uid) = client_uid.as_deref() - && let Err(fault) = journal.record(uid) - { - tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); + // holds this order. Journal the intent-id and keep the + // watch so the next tick short-circuits instead of + // re-posting. As above, a journal fault post-submit only + // forfeits the short-circuit; it must not abort the sweep. + if let Err(fault) = journal.record(&intent_id) { + tracing::error!( + "orderbook already holds {intent_id} but journal write failed: {fault}" + ); } tracing::info!( - "orderbook already holds this order ({}); receipt recorded", + "orderbook already holds this order ({}); intent-id journalled", rejection.error_type, ); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index 76677e0a..bff0f238 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -12,7 +12,10 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowIntent, CowIntentBody, OrderRejection, SignedOrder, gpv2_to_order_data, + order_data_to_body, run, +}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -99,8 +102,16 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") } // ---- lifecycle outcomes ---- @@ -229,11 +240,11 @@ fn malformed_watch_rows_are_skipped() { // ---- ready -> submission ---- #[test] -fn ready_submits_once_and_journals_the_client_uid() { +fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok(client_uid(&order))); + host.cow_api.respond(Ok("0xserveruid".to_string())); let source = { let order = order.clone(); @@ -244,15 +255,15 @@ fn ready_submits_once_and_journals_the_client_uid() { assert_eq!(host.cow_api.call_count(), 1); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "submitted:{{client_uid}} receipt must be recorded", + "submitted:{{intent_id}} marker must be recorded", ); assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); } #[test] -fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { +fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -262,25 +273,23 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); - result.unwrap(); + run(&host, &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); assert!( !snapshot.contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID", + "marker must key on the pre-submit intent-id, not the server receipt", ); - assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] -fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { +fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); Journal::submitted(&host) - .record(&client_uid(&order)) + .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); @@ -422,9 +431,9 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "already-submitted must record the receipt", + "already-submitted must journal the intent-id", ); // The next tick must not touch the orderbook again. @@ -432,6 +441,44 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert_eq!(host.cow_api.call_count(), 1); } +/// Restart regression: a keeper that posted, journalled, and then +/// restarted over the same persistent local store must not post the +/// same order again - one orderbook POST across both lives. +#[test] +fn restart_with_a_journalled_intent_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xserveruid".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // A restarted keeper: fresh instance, the local store carried over. + let restarted = MockHost::new(); + for (key, value) in host.store.snapshot() { + restarted.store.set(&key, &value).unwrap(); + } + restarted.cow_api.respond(Ok("0xserveruid".to_string())); + + run(&restarted, &source, &sample_tick()).unwrap(); + + assert_eq!( + host.cow_api.call_count() + restarted.cow_api.call_count(), + 1, + "resubmit after restart must make no second orderbook POST", + ); + assert!( + Journal::submitted(&restarted) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + /// A rate-limit fault with server guidance backs the watch off on the /// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index f94a88f8..7255efd9 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -63,7 +63,7 @@ impl Keeper { /// Sweep the watch set once at `tick`: poll every ready watch, /// submit [`Sweep::Submit`] bodies through the venue seam, and /// run every other outcome and every venue refusal through the - /// [`Retrier`]. A venue-and-body key is checked against the + /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on /// acceptance, so an accepted body never reaches the venue twice; /// a `requires-signing` answer journals nothing and is surfaced @@ -156,8 +156,10 @@ pub struct SweepReport { /// Deterministic pre-submit journal key: the venue id and the /// keccak-256 of the body. The hash is a fixed-length suffix, so the -/// key is unambiguous whatever the venue id contains. -fn submission_key(venue: &VenueId, body: &[u8]) -> String { +/// key is unambiguous whatever the venue id contains. Public so a +/// keeper journalling outside [`Keeper::sweep`] writes the key the +/// sweep checks. +pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a9bcee04..540a6c7e 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -236,8 +236,15 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: shepherd_sdk::cow::order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + }))) + .ok() } #[cfg(test)] @@ -493,7 +500,7 @@ mod tests { } #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { + fn poll_ready_submits_order_and_persists_the_intent_id() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -509,30 +516,22 @@ mod tests { ); host.cow_api.respond(Ok("0xfeedface".to_string())); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); + on_block(&host, sample_block(1_000)).unwrap(); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.cow_api.call_count(), 1); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "expected submitted:{{client_uid}} marker" + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" + "marker must key on the pre-submit intent-id, not the server receipt" ); - // The MockHost orderbook stub returns `0xfeedface` instead of - // the canonical UID; the strategy logs a Warn about the - // divergence (real orderbooks would not diverge). - let ev = logs - .expect_one(|e| e.level == Level::WARN && e.message.contains("twap UID divergence")); - assert!(ev.message.contains(&format!("client={expected_uid}"))); - assert!(ev.message.contains("server=0xfeedface")); } /// Regression guard: when `getTradeableOrderWithSignature` @@ -541,10 +540,10 @@ mod tests { /// POSTed it), the second tick must NOT call `submit_order` /// again. Without the guard the orderbook responds with /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{uid}` + /// correct, finished work. The guard is the `submitted:{intent_id}` /// short-circuit at the top of `submit_ready`. #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { + fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -562,10 +561,10 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the // orderbook submit must not be attempted. - let already_submitted_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let already_submitted = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store - .set(&format!("submitted:{already_submitted_uid}"), b"") + .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); on_block(&host, sample_block(1_000)).unwrap(); @@ -578,7 +577,7 @@ mod tests { assert_eq!( host.cow_api.call_count(), 0, - "submit_order must NOT be called when submitted:{{uid}} already exists", + "submit_order must NOT be called when submitted:{{intent_id}} already exists", ); assert_eq!( host.cow_api.request_calls().len(), @@ -636,13 +635,13 @@ mod tests { body.get("appDataHash").is_none(), "hash-only body must omit appDataHash, got: {body}" ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" ); } From 4d358caa453573682dcd4af7f422f23cb4321683 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 06:21:52 +0000 Subject: [PATCH 074/139] cow: build the cow venue adapter component with bounded request timeouts (#467) --- .github/workflows/ci.yml | 8 +- Cargo.lock | 7 + Dockerfile | 17 +- crates/cow-venue/Cargo.toml | 45 +- crates/cow-venue/module.toml | 29 + crates/cow-venue/src/adapter.rs | 936 ++++++++++++++++++ .../order.rs => cow-venue/src/assembly.rs} | 198 +++- crates/cow-venue/src/lib.rs | 29 +- crates/cow-venue/tests/conformance.rs | 29 + .../tests/vectors/cow-header-goldens.json | 60 ++ .../tests/vectors/cow-intent-body.json | 48 + crates/nexum-sdk/src/http.rs | 11 + crates/shepherd-sdk/Cargo.toml | 5 +- crates/shepherd-sdk/src/cow/mod.rs | 13 +- crates/shepherd-sdk/src/cow/run.rs | 22 +- crates/shepherd-sdk/src/proptests.rs | 2 +- crates/videre-host/tests/platform.rs | 41 + crates/videre-sdk/Cargo.toml | 3 + crates/videre-sdk/src/lib.rs | 6 +- crates/videre-sdk/src/transport.rs | 112 ++- engine.docker.toml | 11 + engine.example.toml | 13 + justfile | 5 + 23 files changed, 1574 insertions(+), 76 deletions(-) create mode 100644 crates/cow-venue/module.toml create mode 100644 crates/cow-venue/src/adapter.rs rename crates/{shepherd-sdk/src/cow/order.rs => cow-venue/src/assembly.rs} (52%) create mode 100644 crates/cow-venue/tests/conformance.rs create mode 100644 crates/cow-venue/tests/vectors/cow-header-goldens.json create mode 100644 crates/cow-venue/tests/vectors/cow-intent-body.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74809b37..9e0f3b9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,8 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 17 guest module wasms ONCE (release/wasm32-wasip2): the single + # Build all 18 guest wasms ONCE (17 modules + the cow adapter, + # release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled # the shared wasm dependency graph ~9x cold. Per-module size report folded in; @@ -90,6 +91,11 @@ jobs: -p balance-tracker -p stop-loss -p http-probe -p echo-venue \ -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host + # Separate invocation on purpose: unifying `cow-venue/adapter` + # into the module build would link the adapter's component + # export glue into every keeper module wasm. + cargo build --release --target wasm32-wasip2 --locked \ + -p cow-venue --features cow-venue/adapter { echo "### module .wasm sizes" echo "| module | bytes |" diff --git a/Cargo.lock b/Cargo.lock index 4645f0e3..9dc66e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1570,14 +1570,20 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" name = "cow-venue" version = "0.1.0" dependencies = [ + "alloy-primitives", + "alloy-sol-types", "borsh", "cowprotocol", + "http", "nexum-sdk", "serde", + "serde_json", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "url", "videre-sdk", "videre-test", + "wit-bindgen 0.59.0", ] [[package]] @@ -6083,6 +6089,7 @@ name = "videre-sdk" version = "0.1.0" dependencies = [ "borsh", + "http", "nexum-sdk", "nexum-sdk-test", "strum", diff --git a/Dockerfile b/Dockerfile index d7465ff5..1fbaba16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ # syntax=docker/dockerfile:1.6 # # Multi-stage build for `shepherd` - the cow composition-root engine -# binary plus the five production WASM modules baked into a single image. +# binary plus the five production WASM modules and the bundled cow +# venue adapter baked into a single image. # # Stage 1 (`build`): full Rust toolchain + wasm32-wasip2 target, builds # the engine in release mode + each module to a Component Model wasm @@ -68,7 +69,9 @@ COPY --from=planner /src/recipe.json recipe.json RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json + -p balance-tracker -p stop-loss --recipe-path recipe.json \ + && cargo chef cook --release --target wasm32-wasip2 \ + -p cow-venue --features cow-venue/adapter --recipe-path recipe.json # Now the workspace sources. `.dockerignore` keeps the context lean # (no `target/`, no `data/`, no large baseline / backtest fixtures). @@ -79,13 +82,15 @@ COPY . . # is used verbatim so builds are reproducible. RUN cargo build -p shepherd --release --locked -# Five production modules. The wasm artefacts land under +# Five production modules plus the bundled cow venue adapter. The wasm +# artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ - && cargo build -p stop-loss --target wasm32-wasip2 --release --locked + && cargo build -p stop-loss --target wasm32-wasip2 --release --locked \ + && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -123,6 +128,10 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml +# The bundled cow venue adapter's manifest; installed via the +# engine.toml [[adapters]] stanza, never compiled into the engine. +COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml + # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and # binds 127.0.0.1:9100 inside the container. diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 3a780947..d718ad35 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -7,9 +7,11 @@ repository.workspace = true description = "CoW venue slices, orderbook-only. The default `body` slice carries the venue-neutral order intent body types and their borsh IntentBody codec, linkable by adapters and modules." [lib] -# Plain library. The default `body` slice is dependency-light so a venue -# adapter component or a strategy module can link the intent body types -# and codec without the host-side CoW machinery. +# The default `body` slice is dependency-light so a venue adapter +# component or a strategy module can link the intent body types and +# codec without the host-side CoW machinery. The cdylib is the +# `adapter` slice's component build (wasm32-wasip2). +crate-type = ["lib", "cdylib"] [lints] workspace = true @@ -27,6 +29,19 @@ videre-sdk = { path = "../videre-sdk", optional = true } # `build.rs`, so serde/toml/thiserror are build- and dev-only and never # reach a guest that links this slice. nexum-sdk = { path = "../nexum-sdk", optional = true } +# `assembly` slice: the chain-edge order projections and orderbook +# submission bodies. Express-declared (not workspace-inherited) so the +# guest build never inherits the native `http-client` feature. +cowprotocol = { version = "0.2.0", default-features = false, optional = true } +alloy-primitives = { workspace = true, optional = true } +alloy-sol-types = { workspace = true, optional = true } +# `adapter` slice: the orderbook REST speaker over the scoped +# wasi:http transport. +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +http = { workspace = true, optional = true } +url = { workspace = true, optional = true } +wit-bindgen = { workspace = true, optional = true } # `build.rs` parses `data/classification.toml` and emits the static # lookup table; the same parse is shared with the parity tests below. @@ -48,9 +63,27 @@ cowprotocol = { version = "0.2.0", default-features = false } [features] # The body-type + codec slice ships by default; the `client` slice layers # the typed client and the table-driven retry classification on top. -# `--no-default-features` drops both to an empty crate so downstream can -# depend on a future `adapter` slice without pulling the codec or the -# keeper transitively. +# `--no-default-features` drops everything so downstream can depend on a +# single slice without pulling the codec or the keeper transitively. default = ["body"] body = ["dep:borsh", "dep:videre-sdk"] client = ["body", "dep:nexum-sdk"] +# Chain-edge order assembly, shared by the adapter's submit and the +# keeper's legacy submit path. Carries no component glue, so a keeper +# module can link it without exporting the adapter face. +assembly = ["body", "dep:cowprotocol", "dep:alloy-primitives", "dep:alloy-sol-types"] +# The venue-adapter component slice: the `#[videre_sdk::venue]` export +# and the orderbook transport. Only the cdylib wasm build enables it. +adapter = [ + "assembly", + "client", + "dep:serde", + "dep:serde_json", + "dep:http", + "dep:url", + "dep:wit-bindgen", +] + +[[test]] +name = "conformance" +required-features = ["adapter"] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml new file mode 100644 index 00000000..b660014b --- /dev/null +++ b/crates/cow-venue/module.toml @@ -0,0 +1,29 @@ +# cow adapter manifest - the CoW venue's `#[videre_sdk::venue]` +# component. The manifest name is the venue id the registry installs +# the adapter under. Outbound orderbook HTTP is the only transport; +# the operator's `[[adapters]].http_allow` grant scopes it at install. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +# One adapter instance speaks one chain's orderbook. `orderbook-url`, +# `owner` (enables the pre-sign path), and `http-timeout-ms` are +# optional overrides. +[config] +chain = "1" + +# Body-schema versions this adapter decodes: the handshake authority. +# Install asserts the adapter's body-versions export equals it. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs new file mode 100644 index 00000000..333475e7 --- /dev/null +++ b/crates/cow-venue/src/adapter.rs @@ -0,0 +1,936 @@ +//! The CoW venue adapter: the `venue-adapter` component slice. +//! +//! [`CowAdapter`] decodes [`CowIntentBody`], assembles the orderbook +//! wire bodies through [`crate::assembly`], and speaks the +//! orderbook REST API over the scoped wasi:http transport, every +//! request bounded by the configured per-request timeout +//! ([`BoundedFetch`](videre_sdk::transport::BoundedFetch)). Orderbook +//! `errorType` rejections project onto +//! `venue-error` through the shipped classification table so the retry +//! hint survives the collapse: transient rows are `unavailable`, +//! throttles are `rate-limited`, permanent rows are `denied` (never +//! retried). An unsigned order submits as pre-sign: success is +//! `requires-signing` carrying the `setPreSignature` call the host +//! signs and sends. +//! +//! `[config]` keys: `chain` (required, decimal chain id), and optional +//! `orderbook-url` (base URL override), `owner` (hex address enabling +//! the pre-sign path), `http-timeout-ms` (per-request bound, +//! defaulting to the SDK's per-phase timeout). + +use core::time::Duration; +use std::sync::{PoisonError, RwLock}; + +use alloy_primitives::{Address, U256}; +use cowprotocol::{ + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, +}; +use nexum_sdk::keeper::RetryAction; +use serde::Deserialize; +use url::Url; +use videre_sdk::transport::http::Fetch; +use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::{ + AuthScheme, IntentBody as _, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, + SubmitOutcome, UnsignedTx, VenueError, +}; + +use crate::assembly; +use crate::body::{CowIntent, CowIntentBody}; +use crate::classification; +use crate::order::OrderUid; + +/// The CoW venue's protocol speaker: the `venue-adapter` export type. +/// The component face is supplied by `#[videre_sdk::venue]` on the +/// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. +pub struct CowAdapter; + +/// Default per-request timeout bound: the SDK's per-phase default. +const DEFAULT_TIMEOUT: Duration = videre_sdk::transport::http::DEFAULT_TIMEOUT; + +/// Parsed `[config]`: one adapter instance speaks one chain's orderbook. +#[derive(Clone, Debug)] +pub(crate) struct AdapterConfig { + pub(crate) chain: Chain, + pub(crate) base: Url, + pub(crate) owner: Option
, + pub(crate) timeout: Duration, +} + +impl AdapterConfig { + /// Parse the wire config table. Unknown keys are ignored; a + /// malformed value fails init typedly. + pub(crate) fn parse(config: &[(String, String)]) -> Result { + let invalid = |key: &str, value: &str| { + videre_sdk::Fault::InvalidInput(format!("config {key} is invalid: {value}")) + }; + let mut chain = None; + let mut base = None; + let mut owner = None; + let mut timeout = DEFAULT_TIMEOUT; + for (key, value) in config { + match key.as_str() { + "chain" => { + let id: u64 = value.parse().map_err(|_| invalid(key, value))?; + chain = Some(Chain::try_from(id).map_err(|_| invalid(key, value))?); + } + "orderbook-url" => { + let mut url: Url = value.parse().map_err(|_| invalid(key, value))?; + // Path joining relies on a trailing slash. + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + base = Some(url); + } + "owner" => { + owner = Some(value.parse::
().map_err(|_| invalid(key, value))?); + } + "http-timeout-ms" => { + let ms: u64 = value.parse().map_err(|_| invalid(key, value))?; + timeout = Duration::from_millis(ms.max(1)); + } + _ => {} + } + } + let chain = chain.ok_or_else(|| { + videre_sdk::Fault::InvalidInput("config requires a chain id".to_owned()) + })?; + Ok(Self { + chain, + base: base.unwrap_or_else(|| chain.orderbook_base_url()), + owner, + timeout, + }) + } +} + +/// The configured adapter state; `init` replaces it whole, so a +/// supervisor restart re-configures cleanly. +static CONFIG: RwLock> = RwLock::new(None); + +pub(crate) fn store_config(config: AdapterConfig) { + *CONFIG.write().unwrap_or_else(PoisonError::into_inner) = Some(config); +} + +/// The stored config, or the typed refusal for an uninitialised call. +/// The world contract calls `init` before any submission, so this is +/// only reachable on a host driving the exports out of order. +pub(crate) fn config() -> Result { + CONFIG + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + .ok_or_else(|| VenueError::Unavailable("adapter not initialised".to_owned())) +} + +// ── intent functions, transport-injected for host-free tests ───────── + +/// Decode the versioned wire body into its single published intent sum. +fn decode(body: &[u8]) -> Result { + let CowIntentBody::V1(intent) = CowIntentBody::from_bytes(body)?; + Ok(intent) +} + +/// Pure header derivation for `chain`: gives the sell side, wants the +/// buy side, authorisation by intent kind (a signed order carries its +/// EIP-1271 signature; an unsigned order is authorised by host-held +/// keys through the pre-sign flow). +pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result { + let intent = decode(body)?; + let (order, authorisation) = match &intent { + CowIntent::Order(order) => (order, AuthScheme::Eip712), + CowIntent::Signed(signed) => (&signed.order, AuthScheme::Eip1271), + }; + Ok(IntentHeader { + gives: erc20(order.sell_token, minimal_be(&order.sell_amount)), + wants: erc20(order.buy_token, minimal_be(&order.buy_amount)), + settlement: Settlement { chain }, + authorisation, + }) +} + +/// Submit one intent to the orderbook. A signed order posts EIP-1271 +/// and its receipt is the canonical 56-byte UID; an unsigned order +/// posts pre-sign and success is `requires-signing` carrying the +/// `setPreSignature` call. Either way an already-held rejection is +/// success wearing an error status: the UID is derived client-side, so +/// the outcome is identical to a fresh accept. +pub(crate) fn submit_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + match decode(body)? { + CowIntent::Signed(signed) => { + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec())) + } + CowIntent::Order(wire) => { + // Pre-sign needs an owner for `from` and the on-chain call; + // an unconfigured deployment refuses rather than guesses. + let owner = config.owner.ok_or(VenueError::Unsupported)?; + let order = assembly::body_to_order_data(&wire); + let creation = assembly::build_presign_creation(&order, owner) + .map_err(|e| VenueError::InvalidBody(e.to_string()))?; + let uid = match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => uid, + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + }; + Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: config.chain.id(), + to: config.chain.settlement().as_slice().to_vec(), + value: Vec::new(), + data: assembly::set_pre_signature_calldata(&uid), + })) + } + } +} + +/// Poll one receipt's orderbook lifecycle state. +pub(crate) fn status_with( + fetch: &impl Fetch, + config: &AdapterConfig, + receipt: &[u8], +) -> Result { + let uid = OrderUid::try_from(receipt) + .map_err(|_| VenueError::InvalidBody("receipt is not a 56-byte order uid".to_owned()))?; + let url = join(config, &format!("api/v1/orders/{uid}"))?; + let response = call(fetch, http::Method::GET, url, None)?; + if response.status() == http::StatusCode::NOT_FOUND { + // A just-accepted order can lag the read path; not-found stays + // retryable rather than killing the watch. + return Err(VenueError::Unavailable("order not found".to_owned())); + } + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + /// The one server field the lifecycle projection reads. + #[derive(Deserialize)] + struct OrderStatusView { + status: OrderStatus, + } + let view: OrderStatusView = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("order decode failed: {e}")))?; + Ok(match view.status { + OrderStatus::PresignaturePending => IntentStatus::Pending, + OrderStatus::Open => IntentStatus::Open, + OrderStatus::Fulfilled => IntentStatus::Fulfilled, + OrderStatus::Cancelled => IntentStatus::Cancelled, + OrderStatus::Expired => IntentStatus::Expired, + }) +} + +/// Price one intent body: an indicative orderbook quote. +pub(crate) fn quote_with( + fetch: &impl Fetch, + config: &AdapterConfig, + body: &[u8], +) -> Result { + let intent = decode(body)?; + let (wire, from) = match &intent { + CowIntent::Order(order) => (order, config.owner.ok_or(VenueError::Unsupported)?), + CowIntent::Signed(signed) => (&signed.order, Address::from(signed.owner)), + }; + let order = assembly::body_to_order_data(wire); + let request = serde_json::to_vec("e_request(&order, from)) + .map_err(|e| VenueError::Unavailable(format!("quote encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/quote")?, + Some(request), + )?; + if !response.status().is_success() { + return Err(refusal(&response).into_error()); + } + let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; + Ok(Quotation { + gives: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.sell_amount), + ), + wants: erc20( + order.buy_token.into_array(), + minimal_be_u256(quoted.quote.buy_amount), + ), + fee: erc20( + order.sell_token.into_array(), + minimal_be_u256(quoted.quote.fee_amount), + ), + valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), + }) +} + +/// The quote request pinned to the body's own terms, so the indicative +/// price answers for exactly the order the keeper would place. +fn quote_request(order: &OrderData, from: Address) -> QuoteRequest { + let mut request = match order.kind { + OrderKind::Sell => QuoteRequest::sell_before_fee( + order.sell_token, + order.buy_token, + from, + order.sell_amount, + ), + OrderKind::Buy => { + QuoteRequest::buy_after_fee(order.sell_token, order.buy_token, from, order.buy_amount) + } + }; + request.receiver = order.receiver; + request.valid_to = Some(order.valid_to); + request.app_data = Some(QuoteAppData::Hash(order.app_data)); + request.partially_fillable = Some(order.partially_fillable); + request.sell_token_balance = Some(order.sell_token_balance); + request.buy_token_balance = Some(order.buy_token_balance); + request +} + +// ── orderbook wire plumbing ────────────────────────────────────────── + +/// What a `POST /api/v1/orders` produced. +enum Posted { + /// The orderbook accepted and assigned this UID. + Accepted(cowprotocol::OrderUid), + /// The orderbook already holds this exact order. + AlreadyHeld, +} + +fn post_order( + fetch: &impl Fetch, + config: &AdapterConfig, + creation: &OrderCreation, +) -> Result { + let body = serde_json::to_vec(creation) + .map_err(|e| VenueError::Unavailable(format!("order encode failed: {e}")))?; + let response = call( + fetch, + http::Method::POST, + join(config, "api/v1/orders")?, + Some(body), + )?; + if response.status().is_success() { + let uid: cowprotocol::OrderUid = serde_json::from_slice(response.body()) + .map_err(|e| VenueError::Unavailable(format!("uid decode failed: {e}")))?; + return Ok(Posted::Accepted(uid)); + } + match refusal(&response) { + Refusal::AlreadyHeld => Ok(Posted::AlreadyHeld), + Refusal::Error(err) => Err(err), + } +} + +fn join(config: &AdapterConfig, path: &str) -> Result { + config + .base + .join(path) + .map_err(|e| VenueError::Unavailable(format!("orderbook url: {e}"))) +} + +/// One bounded request; every transport failure arrives as a typed +/// [`VenueError`] through the fetch conversion. +fn call( + fetch: &impl Fetch, + method: http::Method, + url: Url, + json: Option>, +) -> Result>, VenueError> { + let mut builder = http::Request::builder().method(method).uri(url.as_str()); + if json.is_some() { + builder = builder.header(http::header::CONTENT_TYPE, "application/json"); + } + let request = builder + .body(json.unwrap_or_default()) + .map_err(|e| VenueError::Unavailable(format!("request build failed: {e}")))?; + Ok(fetch.fetch(request)?) +} + +/// A non-2xx orderbook reply, projected for the wire. +enum Refusal { + /// The structured already-held rejection: success wearing an error + /// status. + AlreadyHeld, + /// Everything else, as the `venue-error` the caller reports. + Error(VenueError), +} + +impl Refusal { + /// Collapse for call sites where already-held is not a success + /// shape (reads); the orderbook only emits it on submission. + fn into_error(self) -> VenueError { + match self { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } + } +} + +/// Project a non-2xx reply: throttles first, server failures stay +/// retryable, and only a structured 4xx envelope reaches the +/// classification table. +fn refusal(response: &http::Response>) -> Refusal { + let status = response.status(); + if status == http::StatusCode::TOO_MANY_REQUESTS { + return Refusal::Error(VenueError::RateLimited(RateLimit { + retry_after_ms: retry_after_ms(response), + })); + } + if status.is_server_error() { + return Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))); + } + match serde_json::from_slice::(response.body()) { + Ok(api) if classification::is_already_submitted(&api.error_type) => Refusal::AlreadyHeld, + Ok(api) => Refusal::Error(classified(&api)), + Err(_) => Refusal::Error(VenueError::Unavailable(format!( + "orderbook status {status}" + ))), + } +} + +/// `Retry-After` in milliseconds, when the reply carries the +/// delta-seconds form. +fn retry_after_ms(response: &http::Response>) -> Option { + response + .headers() + .get(http::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(|seconds| seconds.saturating_mul(1000)) +} + +/// Fold a structured rejection through the shipped table: transient +/// rows retry as `unavailable`, throttle rows carry their backoff as +/// `rate-limited`, permanent rows (and any future action) are `denied`. +fn classified(api: &ApiError) -> VenueError { + let detail = format!("{}: {}", api.error_type, api.description); + match classification::classify(&api.error_type) { + RetryAction::TryNextBlock => VenueError::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }), + RetryAction::Drop => VenueError::Denied(detail), + _ => VenueError::Denied(detail), + } +} + +// ── value projections ──────────────────────────────────────────────── + +/// Big-endian bytes with leading zeros trimmed: the minimal `uint` +/// spelling, where an empty list is zero. +fn minimal_be(bytes: &[u8; 32]) -> Vec { + let first = bytes.iter().position(|byte| *byte != 0); + first.map_or(Vec::new(), |index| bytes[index..].to_vec()) +} + +fn minimal_be_u256(value: U256) -> Vec { + minimal_be(&value.to_be_bytes::<32>()) +} + +fn erc20(token: [u8; 20], amount: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Erc20(Erc20 { + token: token.to_vec(), + }), + amount, + } +} + +// The component-ABI export glue only exists on the wasm build; the +// native build keeps the same trait impl (for conformance suites) +// without export symbols no native linker accepts. +#[cfg(not(target_arch = "wasm32"))] +use wit_bindgen as _; + +/// The component face: `#[videre_sdk::venue]` derives the world from +/// `module.toml` and wires the trait through it. The real transport is +/// wasi:http behind the configured +/// [`BoundedFetch`](videre_sdk::transport::BoundedFetch) bound. +mod export { + use videre_sdk::VenueAdapter; + use videre_sdk::transport::BoundedFetch; + use videre_sdk::transport::http::WasiFetch; + #[cfg(not(target_arch = "wasm32"))] + use videre_sdk::{Config, Fault}; + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; + + use super::{AdapterConfig, CowAdapter}; + + #[cfg_attr(target_arch = "wasm32", videre_sdk::venue)] + impl VenueAdapter for CowAdapter { + fn init(config: Config) -> Result<(), Fault> { + AdapterConfig::parse(&config).map(super::store_config) + } + + fn body_versions() -> Vec { + // Must equal the manifest `[venue] body_versions`; install + // asserts it. + vec![1] + } + + fn derive_header(body: Vec) -> Result { + super::derive_header_with(super::config()?.chain.id(), &body) + } + + fn quote(body: Vec) -> Result { + let config = super::config()?; + super::quote_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &body, + ) + } + + fn submit(body: Vec) -> Result { + let config = super::config()?; + super::submit_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &body, + ) + } + + fn status(receipt: Vec) -> Result { + let config = super::config()?; + super::status_with( + &BoundedFetch::new(WasiFetch, config.timeout), + &config, + &receipt, + ) + } + + fn cancel(_receipt: Vec) -> Result<(), VenueError> { + // Off-chain cancellation is an owner-signed request; the + // adapter structurally holds no keys. + Err(VenueError::Unsupported) + } + } +} + +#[cfg(test)] +mod tests { + use videre_sdk::IntentBody as _; + use videre_sdk::transport::BoundedFetch; + use videre_sdk::transport::http::FetchError; + use videre_test::MockFetch; + + use super::*; + use crate::body::{CowIntent, CowIntentBody}; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + const SEPOLIA: u64 = 11_155_111; + const ORDERS: &str = "https://orderbook.test/api/v1/orders"; + const QUOTE: &str = "https://orderbook.test/api/v1/quote"; + + fn config() -> AdapterConfig { + AdapterConfig { + chain: Chain::try_from(SEPOLIA).expect("sepolia is supported"), + base: Url::parse("https://orderbook.test/").expect("test url parses"), + owner: None, + timeout: Duration::from_secs(5), + } + } + + fn with_owner(owner: Address) -> AdapterConfig { + AdapterConfig { + owner: Some(owner), + ..config() + } + } + + fn owner() -> Address { + Address::repeat_byte(0x55) + } + + fn order_body() -> OrderBody { + OrderBody::sell(SellToken([0x11; 20]), amount(42)) + .for_at_least(BuyToken([0x22; 20]), amount(41)) + .valid_to(1_700_000_000) + .app_data([0x44; 32]) + .build() + } + + fn amount(value: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[31] = value; + bytes + } + + fn signed_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: owner().into_array(), + signature: vec![0xC0, 0xFF, 0xEE], + })) + .to_bytes() + .expect("body encodes") + } + + fn order_bytes() -> Vec { + CowIntentBody::V1(CowIntent::Order(order_body())) + .to_bytes() + .expect("body encodes") + } + + fn expected_uid(config: &AdapterConfig) -> cowprotocol::OrderUid { + let order = assembly::body_to_order_data(&order_body()); + assembly::order_uid(config.chain, &order, owner()) + } + + fn reject(fetch: &MockFetch, error_type: &str) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 400, + format!(r#"{{"errorType":"{error_type}","description":"d"}}"#), + ); + } + + // ── config ─────────────────────────────────────────────────────── + + #[test] + fn config_defaults_resolve_from_the_chain() { + let pairs = [("chain".to_owned(), "1".to_owned())]; + let parsed = AdapterConfig::parse(&pairs).expect("chain alone suffices"); + assert_eq!(parsed.chain.id(), 1); + assert_eq!(parsed.base.as_str(), "https://api.cow.fi/mainnet/"); + assert_eq!(parsed.owner, None); + assert_eq!(parsed.timeout, DEFAULT_TIMEOUT); + } + + #[test] + fn config_overrides_parse_and_the_base_gains_its_slash() { + let pairs = [ + ("chain".to_owned(), SEPOLIA.to_string()), + ( + "orderbook-url".to_owned(), + "https://barn.test/sepolia".to_owned(), + ), + ("owner".to_owned(), format!("{:#x}", owner())), + ("http-timeout-ms".to_owned(), "1500".to_owned()), + ("name".to_owned(), "cow".to_owned()), + ]; + let parsed = AdapterConfig::parse(&pairs).expect("overrides parse"); + assert_eq!(parsed.base.as_str(), "https://barn.test/sepolia/"); + assert_eq!(parsed.owner, Some(owner())); + assert_eq!(parsed.timeout, Duration::from_millis(1500)); + } + + #[test] + fn config_refuses_a_missing_or_malformed_chain() { + assert!(matches!( + AdapterConfig::parse(&[]), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + for bad in ["x", "0"] { + let pairs = [("chain".to_owned(), bad.to_owned())]; + assert!(matches!( + AdapterConfig::parse(&pairs), + Err(videre_sdk::Fault::InvalidInput(_)) + )); + } + } + + // ── derive-header ──────────────────────────────────────────────── + + #[test] + fn header_projects_sides_minimally_and_auth_by_kind() { + let header = derive_header_with(SEPOLIA, &signed_bytes()).expect("valid body"); + assert_eq!( + header.gives.asset, + Asset::Erc20(Erc20 { + token: vec![0x11; 20] + }) + ); + assert_eq!(header.gives.amount, vec![42]); + assert_eq!( + header.wants.asset, + Asset::Erc20(Erc20 { + token: vec![0x22; 20] + }) + ); + assert_eq!(header.wants.amount, vec![41]); + assert_eq!(header.settlement.chain, SEPOLIA); + assert!(matches!(header.authorisation, AuthScheme::Eip1271)); + + let presign = derive_header_with(SEPOLIA, &order_bytes()).expect("valid body"); + assert!(matches!(presign.authorisation, AuthScheme::Eip712)); + } + + #[test] + fn header_refuses_a_malformed_body() { + assert!(matches!( + derive_header_with(SEPOLIA, &[9, 9, 9]), + Err(VenueError::InvalidBody(_)) + )); + } + + // ── submit ─────────────────────────────────────────────────────── + + #[test] + fn signed_submit_posts_eip1271_and_returns_the_uid_receipt() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("accepted"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("signed submit must accept"); + }; + assert_eq!(receipt, uid.as_slice()); + + let request = fetch.last_request().expect("one request"); + assert_eq!(request.uri, ORDERS); + let posted: serde_json::Value = serde_json::from_slice(&request.body).expect("posted JSON"); + assert_eq!(posted["signingScheme"], "eip1271"); + assert_eq!( + posted["from"].as_str().map(str::to_lowercase), + Some(format!("{:#x}", owner())), + ); + assert_eq!(posted["appData"], format!("0x{}", "44".repeat(32))); + } + + #[test] + fn already_held_is_success_with_the_derived_uid() { + let config = config(); + let fetch = MockFetch::default(); + reject(&fetch, "DuplicatedOrder"); + + let outcome = submit_with(&fetch, &config, &signed_bytes()).expect("held is success"); + let SubmitOutcome::Accepted(receipt) = outcome else { + panic!("already-held must accept"); + }; + assert_eq!(receipt, expected_uid(&config).as_slice()); + } + + #[test] + fn unsigned_submit_requires_signing_the_presign_call() { + let config = with_owner(owner()); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let outcome = submit_with(&fetch, &config, &order_bytes()).expect("accepted"); + let SubmitOutcome::RequiresSigning(tx) = outcome else { + panic!("unsigned submit must require signing"); + }; + assert_eq!(tx.chain, SEPOLIA); + assert_eq!(tx.to, config.chain.settlement().as_slice()); + assert!(tx.value.is_empty()); + assert_eq!(tx.data, assembly::set_pre_signature_calldata(&uid)); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["signingScheme"], "presign"); + } + + #[test] + fn unsigned_submit_without_an_owner_is_unsupported() { + let fetch = MockFetch::default(); + assert!(matches!( + submit_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } + + #[test] + fn rejections_project_through_the_classification_table() { + let config = config(); + let fetch = MockFetch::default(); + + reject(&fetch, "InvalidSignature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") + )); + + reject(&fetch, "TooManyLimitOrders"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms == Some(30_000) + )); + + reject(&fetch, "InsufficientFee"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(detail)) if detail.contains("InsufficientFee") + )); + } + + #[test] + fn transport_shapes_stay_typed() { + let config = config(); + let fetch = MockFetch::default(); + + fetch.respond_to(http::Method::POST, ORDERS, 429, "slow down"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::RateLimited(rl)) if rl.retry_after_ms.is_none() + )); + + fetch.respond_to(http::Method::POST, ORDERS, 503, "maintenance"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Unavailable(_)) + )); + + fetch.fail_with( + http::Method::POST, + ORDERS, + FetchError::Timeout("first byte".to_owned()), + ); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Timeout) + )); + + fetch.fail_with(http::Method::POST, ORDERS, FetchError::Denied); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(_)) + )); + } + + #[test] + fn requests_ride_the_configured_timeout_bound() { + let config = config(); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{uid}\"")); + + let timed = BoundedFetch::new(&fetch, config.timeout); + submit_with(&timed, &config, &signed_bytes()).expect("accepted"); + let options = fetch.last_request().expect("one request").options; + assert_eq!(options.connect_timeout, config.timeout); + assert_eq!(options.first_byte_timeout, config.timeout); + assert_eq!(options.between_bytes_timeout, config.timeout); + } + + #[test] + fn retry_after_header_survives_as_milliseconds() { + let response = http::Response::builder() + .status(429) + .header(http::header::RETRY_AFTER, "7") + .body(Vec::new()) + .expect("test response builds"); + let Refusal::Error(VenueError::RateLimited(rl)) = refusal(&response) else { + panic!("429 must rate-limit"); + }; + assert_eq!(rl.retry_after_ms, Some(7_000)); + } + + // ── status ─────────────────────────────────────────────────────── + + fn status_url(uid: &OrderUid) -> String { + format!("https://orderbook.test/api/v1/orders/{uid}") + } + + #[test] + fn status_maps_the_orderbook_lifecycle() { + let config = config(); + let uid = OrderUid([0xAB; 56]); + let fetch = MockFetch::default(); + for (wire, status) in [ + ("presignaturePending", IntentStatus::Pending), + ("open", IntentStatus::Open), + ("fulfilled", IntentStatus::Fulfilled), + ("cancelled", IntentStatus::Cancelled), + ("expired", IntentStatus::Expired), + ] { + fetch.respond_to( + http::Method::GET, + status_url(&uid), + 200, + format!(r#"{{"status":"{wire}","uid":"{uid}"}}"#), + ); + assert_eq!( + status_with(&fetch, &config, uid.as_bytes()).expect("known status"), + status, + ); + } + } + + #[test] + fn status_refuses_a_short_receipt_and_retries_not_found() { + let config = config(); + let fetch = MockFetch::default(); + assert!(matches!( + status_with(&fetch, &config, &[0xAB; 3]), + Err(VenueError::InvalidBody(_)) + )); + + let uid = OrderUid([0xAB; 56]); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + assert!(matches!( + status_with(&fetch, &config, uid.as_bytes()), + Err(VenueError::Unavailable(_)) + )); + } + + // ── quote ──────────────────────────────────────────────────────── + + #[test] + fn quote_prices_the_body_and_pins_its_terms() { + let config = config(); + let fetch = MockFetch::default(); + let quote = serde_json::json!({ + "quote": { + "sellToken": format!("0x{}", "11".repeat(20)), + "buyToken": format!("0x{}", "22".repeat(20)), + "receiver": null, + "sellAmount": "42", + "buyAmount": "40", + "validTo": 1_700_000_000u32, + "appData": format!("0x{}", "44".repeat(32)), + "feeAmount": "2", + "kind": "sell", + "partiallyFillable": false, + "sellTokenBalance": "erc20", + "buyTokenBalance": "erc20", + "signingScheme": "eip1271", + }, + "from": format!("{:#x}", owner()), + "expiration": "2026-01-01T00:00:00Z", + "id": 7, + "verified": true, + }); + fetch.respond_to(http::Method::POST, QUOTE, 200, quote.to_string()); + + let quotation = quote_with(&fetch, &config, &signed_bytes()).expect("quoted"); + assert_eq!(quotation.gives.amount, vec![42]); + assert_eq!(quotation.wants.amount, vec![40]); + assert_eq!(quotation.fee.amount, vec![2]); + assert_eq!(quotation.valid_until_ms, 1_700_000_000_000); + + let posted: serde_json::Value = + serde_json::from_slice(&fetch.last_request().expect("one request").body) + .expect("posted JSON"); + assert_eq!(posted["kind"], "sell"); + assert_eq!(posted["sellAmountBeforeFee"], "42"); + assert_eq!(posted["validTo"], 1_700_000_000u32); + } + + #[test] + fn quote_for_an_unsigned_body_needs_the_configured_owner() { + let fetch = MockFetch::default(); + assert!(matches!( + quote_with(&fetch, &config(), &order_bytes()), + Err(VenueError::Unsupported) + )); + assert_eq!(fetch.request_count(), 0); + } +} diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/cow-venue/src/assembly.rs similarity index 52% rename from crates/shepherd-sdk/src/cow/order.rs rename to crates/cow-venue/src/assembly.rs index 79431954..6e04db1c 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/cow-venue/src/assembly.rs @@ -1,16 +1,26 @@ -//! `GPv2OrderData` -> `OrderData` bridging. +//! Chain-edge order assembly: the projections between the on-chain +//! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire +//! [`OrderBody`], plus the orderbook submission bodies built from them. //! -//! ComposableCoW and CoWSwapEthFlow both emit / return the 12-field -//! `GPv2OrderData` Solidity tuple, with `kind` / `sellTokenBalance` / -//! `buyTokenBalance` as 32-byte keccak markers. The orderbook signs -//! against the typed `OrderData` shape, with those markers projected -//! into Rust enums. [`gpv2_to_order_data`] is the bridge. +//! This is the venue's protocol knowledge: `kind` / +//! `sellTokenBalance` / `buyTokenBalance` ride the chain as 32-byte +//! keccak markers and the orderbook signs against the typed shape, so +//! the adapter, not the keeper, owns every projection across that +//! edge. -use alloy_primitives::Address; +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use alloy_primitives::{Address, Bytes}; +use alloy_sol_types::SolCall; use cowprotocol::{ - BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, + BuyTokenDestination, Chain, GPv2OrderData, GPv2Settlement, OrderCreation, OrderData, OrderKind, + SellTokenSource, Signature, }; +use crate::order::OrderBody; + /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the /// typed [`OrderData`] shape `OrderCreation::new` expects. /// @@ -29,11 +39,11 @@ use cowprotocol::{ /// # Example /// /// ``` +/// use cow_venue::assembly::gpv2_to_order_data; /// use cowprotocol::{ /// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, /// }; -/// use shepherd_sdk::cow::gpv2_to_order_data; -/// use nexum_sdk::prelude::{Address, U256}; +/// use alloy_primitives::{Address, U256}; /// /// let gpv2 = GPv2OrderData { /// sellToken: Address::repeat_byte(1), @@ -87,17 +97,22 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { #[must_use] pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) + Some(format!("{}", order_uid(chain, &order_data, owner))) +} + +/// The canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain: what an accepted submit's receipt carries. +#[must_use] +pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol::OrderUid { + order.uid(&chain.settlement_domain(), owner) } -/// Project a typed [`OrderData`] into the venue wire -/// [`OrderBody`](cow_venue::OrderBody) a keeper emits. Total: every -/// typed field has exactly one wire form. +/// Project a typed [`OrderData`] into the venue wire [`OrderBody`] a +/// keeper emits. Total: every typed field has exactly one wire form. #[must_use] -pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { - cow_venue::OrderBody { +pub fn order_data_to_body(order: &OrderData) -> OrderBody { + OrderBody { sell_token: order.sell_token.into_array(), buy_token: order.buy_token.into_array(), receiver: order.receiver.map(Address::into_array), @@ -107,26 +122,97 @@ pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { app_data: order.app_data.0, fee_amount: order.fee_amount.to_be_bytes(), kind: match order.kind { - OrderKind::Sell => cow_venue::OrderKind::Sell, - OrderKind::Buy => cow_venue::OrderKind::Buy, + OrderKind::Sell => crate::order::OrderKind::Sell, + OrderKind::Buy => crate::order::OrderKind::Buy, }, partially_fillable: order.partially_fillable, sell_token_balance: match order.sell_token_balance { - SellTokenSource::Erc20 => cow_venue::SellTokenSource::Erc20, - SellTokenSource::External => cow_venue::SellTokenSource::External, - SellTokenSource::Internal => cow_venue::SellTokenSource::Internal, + SellTokenSource::Erc20 => crate::order::SellTokenSource::Erc20, + SellTokenSource::External => crate::order::SellTokenSource::External, + SellTokenSource::Internal => crate::order::SellTokenSource::Internal, }, buy_token_balance: match order.buy_token_balance { - BuyTokenDestination::Erc20 => cow_venue::BuyTokenDestination::Erc20, - BuyTokenDestination::Internal => cow_venue::BuyTokenDestination::Internal, + BuyTokenDestination::Erc20 => crate::order::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => crate::order::BuyTokenDestination::Internal, + }, + } +} + +/// Project a venue wire [`OrderBody`] back onto the typed [`OrderData`] +/// the orderbook signs against: [`order_data_to_body`]'s total inverse. +#[must_use] +pub fn body_to_order_data(body: &OrderBody) -> OrderData { + OrderData { + sell_token: Address::from(body.sell_token), + buy_token: Address::from(body.buy_token), + receiver: body.receiver.map(Address::from), + sell_amount: alloy_primitives::U256::from_be_bytes(body.sell_amount), + buy_amount: alloy_primitives::U256::from_be_bytes(body.buy_amount), + valid_to: body.valid_to, + app_data: body.app_data.into(), + fee_amount: alloy_primitives::U256::from_be_bytes(body.fee_amount), + kind: match body.kind { + crate::order::OrderKind::Sell => OrderKind::Sell, + crate::order::OrderKind::Buy => OrderKind::Buy, + }, + partially_fillable: body.partially_fillable, + sell_token_balance: match body.sell_token_balance { + crate::order::SellTokenSource::Erc20 => SellTokenSource::Erc20, + crate::order::SellTokenSource::External => SellTokenSource::External, + crate::order::SellTokenSource::Internal => SellTokenSource::Internal, + }, + buy_token_balance: match body.buy_token_balance { + crate::order::BuyTokenDestination::Erc20 => BuyTokenDestination::Erc20, + crate::order::BuyTokenDestination::Internal => BuyTokenDestination::Internal, }, } } +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; the caller drops the watch. +pub fn build_order_creation( + order_data: &OrderData, + signature: &[u8], + from: Address, +) -> Result { + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) +} + +/// Assemble the pre-sign `OrderCreation` for an unsigned order: the +/// orderbook holds it as signature-pending until `from` settles the +/// authorisation on chain via [`set_pre_signature_calldata`]. +pub fn build_presign_creation( + order_data: &OrderData, + from: Address, +) -> Result { + OrderCreation::new_app_data_hash_only(order_data, Signature::PreSign, from, None) +} + +/// ABI-encoded `GPv2Settlement::setPreSignature(uid, true)` calldata: +/// the call the order's owner must sign and send to activate a +/// pre-sign order. +#[must_use] +pub fn set_pre_signature_calldata(uid: &cowprotocol::OrderUid) -> Vec { + GPv2Settlement::setPreSignatureCall { + orderUid: Bytes::copy_from_slice(uid.as_slice()), + signed: true, + } + .abi_encode() +} + #[cfg(test)] mod tests { + use alloy_primitives::{B256, U256, address, keccak256}; + use cowprotocol::GPV2_SETTLEMENT; + use super::*; - use alloy_primitives::{B256, U256, address}; fn submittable_gpv2() -> GPv2OrderData { GPv2OrderData { @@ -190,7 +276,7 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } - // ---- order_data_to_body ---- + // ---- order_data_to_body / body_to_order_data ---- #[test] fn order_data_to_body_projects_every_field() { @@ -205,15 +291,44 @@ mod tests { assert_eq!(body.valid_to, g.validTo); assert_eq!(body.app_data, g.appData.0); assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); - assert_eq!(body.kind, cow_venue::OrderKind::Sell); + assert_eq!(body.kind, crate::order::OrderKind::Sell); assert!(!body.partially_fillable); - assert_eq!(body.sell_token_balance, cow_venue::SellTokenSource::Erc20); + assert_eq!( + body.sell_token_balance, + crate::order::SellTokenSource::Erc20 + ); assert_eq!( body.buy_token_balance, - cow_venue::BuyTokenDestination::Erc20 + crate::order::BuyTokenDestination::Erc20 ); } + #[test] + fn body_round_trips_back_to_order_data() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + assert_eq!(body_to_order_data(&order_data_to_body(&order)), order); + + for (kind, sell, buy) in [ + ( + OrderKind::Buy, + SellTokenSource::External, + BuyTokenDestination::Internal, + ), + ( + OrderKind::Sell, + SellTokenSource::Internal, + BuyTokenDestination::Erc20, + ), + ] { + let mut varied = order; + varied.kind = kind; + varied.sell_token_balance = sell; + varied.buy_token_balance = buy; + varied.receiver = None; + assert_eq!(body_to_order_data(&order_data_to_body(&varied)), varied); + } + } + // ---- order_uid_hex ---- const SEPOLIA: u64 = 11_155_111; @@ -243,4 +358,29 @@ mod tests { bad.kind = B256::repeat_byte(0x42); assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); } + + // ---- submission bodies ---- + + #[test] + fn presign_creation_carries_the_presign_scheme() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let creation = build_presign_creation(&order, owner).expect("valid order"); + assert_eq!(creation.signature, Signature::PreSign); + assert_eq!(creation.from, owner); + + assert!(build_presign_creation(&order, Address::ZERO).is_err()); + } + + #[test] + fn set_pre_signature_calldata_encodes_the_selector_and_uid() { + let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid(Chain::Mainnet, &order, owner); + let data = set_pre_signature_calldata(&uid); + assert_eq!(&data[..4], &keccak256("setPreSignature(bytes,bool)")[..4]); + assert!(data.windows(56).any(|w| w == uid.as_slice())); + // The call targets the deterministic settlement deployment. + assert_ne!(GPV2_SETTLEMENT, Address::ZERO); + } } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 4e0879ba..4d31bbe2 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -10,12 +10,12 @@ //! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) //! and borsh, so a venue adapter component or a strategy module can carry //! the body types and codec without dragging in the host-side CoW -//! machinery. The crate is `#![no_std]` (tests aside): the derive's -//! generated code reaches `alloc` through the venue SDK re-export, never -//! `::std`. +//! machinery. The crate is `#![no_std]` (tests and the `adapter` slice +//! aside): the derive's generated code reaches `alloc` through the +//! venue SDK re-export, never `::std`. //! //! With `--no-default-features` the slice drops out entirely and the -//! crate compiles empty, so a consumer can depend on a future slice +//! crate compiles empty, so a consumer can depend on a single slice //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the @@ -26,10 +26,20 @@ //! strategy keeper (for the retry action type) and is off by default, //! so an adapter or a module that wants only the body types stays //! dependency-light. +//! +//! The `assembly` slice carries the chain-edge order projections and +//! orderbook submission bodies; the `adapter` slice on top of it is +//! the venue-adapter component itself (`CowAdapter` under +//! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and +//! never linked by a keeper module (linking it would export the +//! adapter face). -#![cfg_attr(not(test), no_std)] +#![cfg_attr(not(any(test, feature = "adapter")), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] +// wit_bindgen::generate! expands to host-import shims whose arity can +// exceed clippy's too-many-arguments threshold. +#![cfg_attr(feature = "adapter", allow(clippy::too_many_arguments))] #[cfg(feature = "body")] extern crate alloc; @@ -40,6 +50,12 @@ pub mod body; #[cfg(feature = "body")] pub mod order; +#[cfg(feature = "adapter")] +pub mod adapter; + +#[cfg(feature = "assembly")] +pub mod assembly; + #[cfg(feature = "client")] pub mod classification; @@ -61,6 +77,9 @@ pub use order::{ SellTokenSource, SignedOrder, }; +#[cfg(feature = "adapter")] +pub use adapter::CowAdapter; + #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] diff --git a/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs new file mode 100644 index 00000000..4c176300 --- /dev/null +++ b/crates/cow-venue/tests/conformance.rs @@ -0,0 +1,29 @@ +//! Published-fixture conformance: the codec vectors and header goldens +//! under `tests/vectors/` replayed through the shipped body codec and +//! the adapter's own derivation. The files are the contract a non-Rust +//! adapter author reads. + +use cow_venue::{CowAdapter, CowIntentBody}; +use videre_sdk::VenueAdapter; +use videre_test::{CodecVectors, HeaderGoldens}; + +fn fixture(name: &str) -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/vectors") + .join(name) +} + +#[test] +fn codec_conforms_to_the_published_vectors() { + let vectors = CodecVectors::load(fixture("cow-intent-body.json")).expect("vectors parse"); + vectors.assert_conforms::(); +} + +#[test] +fn derive_header_conforms_to_the_published_goldens() { + // The goldens pin mainnet; init drives the same configured path + // the host boots the component through. + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let goldens = HeaderGoldens::load(fixture("cow-header-goldens.json")).expect("goldens parse"); + goldens.assert_conforms(CowAdapter::derive_header); +} diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json new file mode 100644 index 00000000..d482f6a2 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "venue": "cow", + "goldens": [ + { + "name": "v1-order-presign", + "body": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip712" + }, + "notes": "unsigned order: authorised by host-held keys (pre-sign)" + }, + { + "name": "v1-signed", + "body": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "header": { + "gives": { + "asset": { + "erc20": { + "token": "1111111111111111111111111111111111111111" + } + }, + "amount": "2a" + }, + "wants": { + "asset": { + "erc20": { + "token": "2222222222222222222222222222222222222222" + } + }, + "amount": "29" + }, + "settlement": { + "chain": 1 + }, + "authorisation": "eip1271" + }, + "notes": "owner-signed order: EIP-1271" + } + ] +} diff --git a/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json new file mode 100644 index 00000000..651231e7 --- /dev/null +++ b/crates/cow-venue/tests/vectors/cow-intent-body.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "schema": "cow-venue/cow-intent-body", + "vectors": [ + { + "name": "v1-order", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": "round-trip" + }, + { + "name": "v1-signed", + "bytes": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "expect": "round-trip" + }, + { + "name": "unknown-version", + "bytes": "09001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "unknown-version": { + "version": 9 + } + } + }, + { + "name": "empty", + "bytes": "", + "expect": "empty" + }, + { + "name": "truncated-payload", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + }, + { + "name": "trailing-bytes", + "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "expect": { + "malformed": { + "version": 0 + } + } + } + ] +} diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index e4a6dec0..cac11bc3 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -96,6 +96,17 @@ pub trait Fetch { } } +/// A shared reference forwards, so a wrapper can borrow its transport. +impl Fetch for &F { + fn fetch_with( + &self, + request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + (**self).fetch_with(request, options) + } +} + /// [`Fetch`] adapter over the host's wasi:http outgoing handler. /// /// Guest-only glue: the type exists on every target so module diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index a6cc1049..b73fae63 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -16,8 +16,9 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or # cow-venue default slice; this crate re-exports them under `cow` until # the module ports move off the legacy path. The `client` slice carries # the table-driven retry classification the cow error surface delegates -# to. -cow-venue = { path = "../cow-venue", features = ["client"] } +# to; the `assembly` slice carries the chain-edge order projections the +# keeper run still drives. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } # The structured poll seam the keeper run dispatches on. composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index e733da5d..86ecdf03 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,9 +1,9 @@ //! CoW Protocol bridging. //! -//! Type conversions and ABI decoding helpers that translate between -//! the on-chain shape (`GPv2OrderData`, orderbook JSON) and the typed -//! Rust surface (`OrderData`, `RetryAction`), plus [`run()`] - the -//! poll/submit composition over the keeper stores. +//! ABI decoding helpers, the orderbook error surface, and [`run()`] - +//! the poll/submit composition over the keeper stores. The chain-edge +//! order projections live in the `cow-venue` `assembly` slice (the +//! venue adapter owns them) and are re-exported here. //! //! The poll seam is the structured //! [`Verdict`](composable_cow::Verdict), carried by the @@ -18,14 +18,15 @@ pub mod error; pub mod events; -pub mod order; pub mod run; +/// Chain-edge order assembly, re-exported from the `cow-venue` +/// `assembly` slice the venue adapter owns. +pub use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use run::run; /// The venue-neutral intent body types and their borsh `IntentBody` diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index f66d73dc..6ad79803 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -19,7 +19,8 @@ use alloy_primitives::{Address, Bytes}; use composable_cow::Verdict; -use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; +use cow_venue::assembly::build_order_creation; +use cowprotocol::GPv2OrderData; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, @@ -126,7 +127,7 @@ fn submit_ready( tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); return Ok(()); } - let creation = match build_order_creation(&order_data, signature, owner) { + let creation = match build_order_creation(&order_data, &signature, owner) { Ok(creation) => creation, Err(err) => { // A constructor rejection (zero `from`, `validTo` beyond @@ -196,20 +197,3 @@ fn submit_ready( } Ok(()) } - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// polled conditional order. The signed `appData` digest goes out -/// verbatim in the hash-only wire shape (watch-tower parity), and the -/// signature is EIP-1271 - the conditional-order contract is the -/// verifier. -/// -/// An `Err` is a client-side precondition failure that would recur on -/// every retry of the same payload; the caller drops the watch. -fn build_order_creation( - order_data: &OrderData, - signature: Bytes, - from: Address, -) -> Result { - let signature = Signature::Eip1271(signature.to_vec()); - OrderCreation::new_app_data_hash_only(order_data, signature, from, None) -} diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index a9b4d5a2..c300c65f 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -32,7 +32,7 @@ proptest! { // We do not call `gpv2_to_order_data` here because building // a `GPv2OrderData` requires a full alloy-sol-encoded struct // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow::order::tests` + // test for the marker dispatch lives in `cow_venue::assembly` // example-based; this proptest stands in as a no-panic // guard for the inputs the strategy ABI can produce. } diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index ccfce70b..2a6d745b 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -159,6 +159,47 @@ fn e2e_echo_venue_component_imports_equal_declared_capabilities() { ); } +/// The shipped cow adapter honours the same contract: outbound HTTP is +/// its only capability, so the component structurally cannot reach +/// chain, messaging, host key material, or persistence. +#[test] +fn e2e_cow_venue_component_imports_equal_declared_capabilities() { + let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - build with `just build-cow-venue`", + wasm.display() + ); + return; + } + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["http"]), + "imports were: {imports:?}" + ); + assert!( + imports.iter().all(|name| !name.contains("nexum:host/chain") + && !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + /// The venue-adapter provider linker binds only the scoped transport /// (chain, messaging, wasi base, allowlisted http) and withholds the /// core-only interfaces. Assembling it proves the scope wires without a diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 41755813..5ccad1f2 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -31,6 +31,9 @@ nexum-sdk = { path = "../nexum-sdk" } # The venue status-body codec, re-exported as `videre_sdk::status_body`; # venue guests reach the `intent-status` decode path through here. videre-status-body = { path = "../videre-status-body" } +# The standard request/response types the `Fetch` seam (and the +# `BoundedFetch` clamp over it) is expressed in. +http.workspace = true strum.workspace = true thiserror.workspace = true wit-bindgen.workspace = true diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 9145681f..99e16d9a 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -38,8 +38,10 @@ //! - [`transport`] - typed wrappers over the world's scoped imports: //! [`HostChain`](transport::HostChain) behind the SDK [`ChainHost`] //! seam (plus batch), [`HostMessaging`](transport::HostMessaging) -//! behind [`MessagingHost`](transport::MessagingHost), and the -//! wasi:http surface re-exported as [`transport::http`]. +//! behind [`MessagingHost`](transport::MessagingHost), the +//! wasi:http surface re-exported as [`transport::http`], and +//! [`BoundedFetch`](transport::BoundedFetch), which caps the +//! wasi:http phase timeouts of every adapter request. //! //! - [`faults`] - the conversions that make `?` work across the wire //! fault, the SDK-neutral fault, and [`VenueError`]; plus diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs index a5d62bd1..93add1e0 100644 --- a/crates/videre-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -10,7 +10,10 @@ //! `messaging_topics`, and HTTP to its `http_allow` list, each refusal //! surfacing as a typed `denied`. +use core::time::Duration; + use nexum_sdk::host::{ChainError, ChainHost, Fault, RpcError}; +use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; use crate::bindings::nexum::host::{chain, messaging}; use crate::faults::fault_into_sdk; @@ -18,10 +21,46 @@ use crate::faults::fault_into_sdk; /// Outbound HTTP for adapters: the SDK's wasi:http surface re-exported. /// [`fetch`](nexum_sdk::http::Fetch::fetch) speaks the standard `http` /// crate's request/response types; an off-allowlist request fails as -/// [`FetchError::Denied`](nexum_sdk::http::FetchError::Denied), which +/// [`FetchError::Denied`], which /// converts into [`VenueError`](crate::VenueError) via `?`. pub use nexum_sdk::http; +/// Caps the wasi:http phase timeouts of any [`Fetch`]: every request +/// through it, including plain [`Fetch::fetch`], has its connect, +/// first-byte and between-bytes timeouts clamped to `bound`, so a hung +/// endpoint errors within it rather than stalling the export call. A +/// caller may ask for less; it can never exceed the bound. +#[derive(Clone, Copy, Debug)] +pub struct BoundedFetch { + inner: F, + bound: Duration, +} + +impl BoundedFetch { + /// Bound every phase (connect, first byte, between bytes) of every + /// request to at most `bound`. + pub const fn new(inner: F, bound: Duration) -> Self { + Self { inner, bound } + } +} + +impl Fetch for BoundedFetch { + fn fetch_with( + &self, + request: ::http::Request>, + options: FetchOptions, + ) -> Result<::http::Response>, FetchError> { + self.inner.fetch_with( + request, + FetchOptions { + connect_timeout: options.connect_timeout.min(self.bound), + first_byte_timeout: options.first_byte_timeout.min(self.bound), + between_bytes_timeout: options.between_bytes_timeout.min(self.bound), + }, + ) + } +} + /// The adapter's `nexum:host/chain` import behind the SDK's /// [`ChainHost`] seam. Unit-struct handle: hold it where strategy logic /// takes `&impl ChainHost` and slot a mock in host-side tests. @@ -124,3 +163,74 @@ impl From for Message { } } } + +#[cfg(test)] +mod tests { + use core::cell::Cell; + use core::time::Duration; + + use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; + + use super::BoundedFetch; + + struct Spy { + seen: Cell>, + } + + impl Fetch for Spy { + fn fetch_with( + &self, + _request: http::Request>, + options: FetchOptions, + ) -> Result>, FetchError> { + self.seen.set(Some(options)); + Ok(http::Response::new(Vec::new())) + } + } + + fn request() -> http::Request> { + http::Request::get("https://api.cow.fi/") + .body(Vec::new()) + .expect("test request builds") + } + + #[test] + fn plain_fetch_is_bounded() { + let bound = Duration::from_secs(5); + let timed = BoundedFetch::new( + Spy { + seen: Cell::new(None), + }, + bound, + ); + timed.fetch(request()).expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, bound); + assert_eq!(seen.first_byte_timeout, bound); + assert_eq!(seen.between_bytes_timeout, bound); + } + + #[test] + fn caller_options_clamp_to_the_bound_but_tighter_ones_pass() { + let timed = BoundedFetch::new( + Spy { + seen: Cell::new(None), + }, + Duration::from_secs(5), + ); + timed + .fetch_with( + request(), + FetchOptions { + connect_timeout: Duration::from_secs(60), + first_byte_timeout: Duration::from_secs(1), + between_bytes_timeout: Duration::from_secs(60), + }, + ) + .expect("spy accepts"); + let seen = timed.inner.seen.get().expect("options recorded"); + assert_eq!(seen.connect_timeout, Duration::from_secs(5)); + assert_eq!(seen.first_byte_timeout, Duration::from_secs(1)); + assert_eq!(seen.between_bytes_timeout, Duration::from_secs(5)); + } +} diff --git a/engine.docker.toml b/engine.docker.toml index 72141a15..151f3de8 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -77,3 +77,14 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# ---- adapters ---- +# +# The bundled cow venue adapter: the pool router resolves the `cow` +# venue id through it. The operator grant scopes its outbound HTTP to +# the production orderbook. + +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index a3f883bd..77dc3145 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -100,3 +100,16 @@ rpc_url = "${ARBITRUM_RPC_URL}" [chains.8453] # Base rpc_url = "${BASE_RPC_URL}" + +# ---- adapters ---- +# +# Venue-adapter components the pool router resolves venue ids through. +# The operator, not the adapter author, grants the transport scope: +# `http_allow` is the outbound wasi:http host allowlist. The bundled +# cow adapter builds with `just build-cow-venue`; its venue id is the +# manifest name (`cow`). + +# [[adapters]] +# path = "target/wasm32-wasip2/release/cow_venue.wasm" +# manifest = "crates/cow-venue/module.toml" +# http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 9c89a2f9..6f410367 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,11 @@ build-module: build-venue: cargo build --target wasm32-wasip2 --release -p echo-venue +# Build the bundled cow venue adapter component. Install via the +# engine.toml [[adapters]] stanza; the venue id is its manifest name. +build-cow-venue: + cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter + # Build everything build: build-engine build-module From a926f79f976cb6a122964466949e81d4c01062e8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 06:45:08 +0000 Subject: [PATCH 075/139] cow: run the cow keeper on the generic venue client (#468) cow: flip the keeper run onto the typed venue client --- Cargo.lock | 1 + crates/shepherd-sdk-test/tests/mock_venue.rs | 27 ++- crates/shepherd-sdk/Cargo.toml | 3 + crates/shepherd-sdk/src/cow/mod.rs | 28 ++- crates/shepherd-sdk/src/cow/run.rs | 135 +++++------ crates/shepherd-sdk/src/cow/transport.rs | 226 +++++++++++++++++++ crates/shepherd-sdk/src/lib.rs | 20 +- crates/shepherd-sdk/tests/run.rs | 132 ++++++++++- crates/videre-sdk/src/keeper.rs | 8 +- crates/videre-sdk/src/lib.rs | 2 +- modules/twap-monitor/src/strategy.rs | 10 +- 11 files changed, 473 insertions(+), 119 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 9dc66e23..f3c42cd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5246,6 +5246,7 @@ dependencies = [ "strum", "thiserror 2.0.18", "tracing", + "videre-sdk", ] [[package]] diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index d8af0612..ec371ca2 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -9,8 +9,8 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; use shepherd_sdk::cow::{ - CowApiError, CowHost, CowIntent, CowIntentBody, OrderRejection, SignedOrder, - gpv2_to_order_data, order_data_to_body, order_uid_hex, run, + CowApiError, CowApiTransport, CowClient, CowHost, CowIntent, CowIntentBody, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, order_uid_hex, run, }; use shepherd_sdk_test::{MockHost, MockVenue}; @@ -18,6 +18,12 @@ const SEPOLIA: u64 = 11_155_111; type VenueHost = MockHost; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the scripted venue host. +fn venue(host: &VenueHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome. struct FnSource(F); @@ -146,7 +152,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { host.cow_api.enqueue_submit(Ok(client_uid(&order))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( @@ -155,7 +161,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { .unwrap() ); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -185,7 +191,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { let t0 = sample_tick(); let source = ready_source(&order); - run(&host, &source, &t0).unwrap(); + run(&host, &venue(&host), &source, &t0).unwrap(); assert_eq!(host.cow_api.call_count(), 1); // 2500ms rounds up to a 3s epoch gate: a tick inside it never @@ -194,14 +200,14 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { epoch_s: t0.epoch_s + 2, ..t0 }; - run(&host, &source, &gated).unwrap(); + run(&host, &venue(&host), &source, &gated).unwrap(); assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); let clear = Tick { epoch_s: t0.epoch_s + 3, ..t0 }; - run(&host, &source, &clear).unwrap(); + run(&host, &venue(&host), &source, &clear).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -222,7 +228,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); let source = ready_source(&order); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key)); assert!(!snapshot.contains_key(&watch_key.next_block_key())); @@ -231,7 +237,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { host.cow_api.clear_fault(); host.cow_api.enqueue_submit(Ok(client_uid(&order))); - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) @@ -249,7 +255,7 @@ fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { host.cow_api .enqueue_submit(Err(rejection("InvalidSignature"))); - run(&host, &ready_source(&order), &sample_tick()).unwrap(); + run(&host, &venue(&host), &ready_source(&order), &sample_tick()).unwrap(); assert!(host.store.is_empty(), "watch and gates must go"); assert_eq!(host.cow_api.call_count(), 1); @@ -276,6 +282,7 @@ fn keeper_sweep_ignores_sibling_namespace_watches() { let polls = std::cell::Cell::new(0_u32); run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index b73fae63..a2c7f216 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -22,6 +22,9 @@ cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } # The structured poll seam the keeper run dispatches on. composable-cow = { path = "../composable-cow" } nexum-sdk = { path = "../nexum-sdk" } +# The typed client seam the keeper run submits through; also the +# `VenueTransport` contract the legacy cow-api bridge implements. +videre-sdk = { path = "../videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true serde_json.workspace = true diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 86ecdf03..8909134c 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -1,9 +1,10 @@ //! CoW Protocol bridging. //! //! ABI decoding helpers, the orderbook error surface, and [`run()`] - -//! the poll/submit composition over the keeper stores. The chain-edge -//! order projections live in the `cow-venue` `assembly` slice (the -//! venue adapter owns them) and are re-exported here. +//! the poll/submit composition over the keeper stores, submitting +//! through the typed [`CowClient`] on the `videre:venue/client` seam. +//! The chain-edge order projections live in the `cow-venue` `assembly` +//! slice (the venue adapter owns them) and are re-exported here. //! //! The poll seam is the structured //! [`Verdict`](composable_cow::Verdict), carried by the @@ -14,11 +15,14 @@ //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can //! be unit-tested without wit-bindgen scaffolding and re-used //! unchanged by TWAP, EthFlow, and future strategy modules. The -//! keeper run is generic over the host traits alone. +//! keeper run is generic over the host traits and the venue transport +//! alone; [`CowApiTransport`] carries it over the legacy +//! `shepherd:cow/cow-api` import until the module worlds flip. pub mod error; pub mod events; pub mod run; +pub mod transport; /// Chain-edge order assembly, re-exported from the `cow-venue` /// `assembly` slice the venue adapter owns. @@ -28,19 +32,23 @@ pub use error::{ classify_submit_error, is_already_submitted, }; pub use run::run; +pub use transport::CowApiTransport; /// The venue-neutral intent body types and their borsh `IntentBody` -/// codec, re-exported from the `cow-venue` default slice. The shim keeps -/// this path stable while the module ports move off the legacy surface. +/// codec, re-exported from the `cow-venue` default slice, plus the +/// typed CoW venue client. The shim keeps this path stable while the +/// module ports move off the legacy surface. pub use cow_venue::{ - BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, - OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, + BuyToken, BuyTokenDestination, CowClient, CowIntent, CowIntentBody, CowVenue, OrderBody, + OrderBuilder, OrderKind, OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; -/// `shepherd:cow/cow-api` - orderbook submission path. The CoW-domain -/// sibling of the core host traits in [`nexum_sdk::host`]. +/// `shepherd:cow/cow-api` - the legacy orderbook submission path, +/// retiring. The keeper [`run()`] submits through the typed +/// [`CowClient`]; [`CowApiTransport`] bridges it onto this seam until +/// the module worlds flip to `videre:venue/client`. pub trait CowApiHost { /// Submit an `OrderCreation` JSON body. The host returns the /// canonical order UID on success. A rejection surfaces as a typed diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index 6ad79803..0a19bc8e 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -4,40 +4,43 @@ //! [`run`] walks the keeper watch set, polls each gate-ready //! watch through a [`ConditionalSource`], and runs the //! [`Verdict`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Post` drives one submission through the -//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` -//! journal as the idempotency guard - keyed on the venue-and-body -//! [`intent_id`] - and the keeper [`Retrier`] as the failure -//! dispatch. +//! watch stores, `Post` drives one submission through the typed +//! [`CowClient`] onto the `videre:venue/client` seam with the +//! `submitted:` journal as the idempotency guard - keyed on the +//! venue-and-body [`intent_id`] - and the keeper [`Retrier`] +//! as the failure dispatch. //! //! Store faults abort the sweep (the next tick replays it); -//! submission failures never do - they classify into a -//! [`RetryAction`], the ledger applies the effect, and the sweep -//! moves on. Diagnostics go through the guest `tracing` facade - +//! submission failures never do - they fold into a +//! [`RetryAction`] through the videre +//! [`retry_action`] table, the ledger applies the effect, and the +//! sweep moves on. Diagnostics go through the guest `tracing` facade - //! the same channel strategy code logs on - so module tests observe //! the composed behaviour with one capture. -use alloy_primitives::{Address, Bytes}; +use alloy_primitives::{Address, Bytes, hex}; use composable_cow::Verdict; -use cow_venue::assembly::build_order_creation; use cowprotocol::GPv2OrderData; -use nexum_sdk::host::Fault; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; use super::{ - CowApiError, CowHost, CowIntent, CowIntentBody, SignedOrder, classify_submit_error, - gpv2_to_order_data, intent_id, is_already_submitted, order_data_to_body, + CowClient, CowIntent, CowIntentBody, SignedOrder, gpv2_to_order_data, intent_id, + order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's /// effect. One source poll per ready watch; a `Post` outcome makes at -/// most one `submit_order` call. -pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +/// most one venue submit through `venue`. +pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where - H: CowHost, + H: LocalStoreHost, S: ConditionalSource, + T: VenueTransport, { let watches = WatchSet::new(host); let gates = Gates::new(host); @@ -55,7 +58,7 @@ where Verdict::Post { order, signature, .. } => { - submit_ready(host, watch, &order, signature, tick, source.label())?; + submit_ready(host, venue, watch, &order, signature, tick, source.label())?; } Verdict::TryNextBlock { .. } => {} Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, @@ -74,24 +77,27 @@ where Ok(()) } -/// Submit one freshly-polled `Ready` order, guarding on the -/// `submitted:` journal and dispatching any failure through the retry -/// ledger. +/// Submit one freshly-polled `Ready` order through the typed client, +/// guarding on the `submitted:` journal and dispatching any venue +/// refusal through the retry ledger. /// -/// The journal keys on the deterministic venue-and-body -/// [`intent_id`], derived before any network work from the same body -/// bytes a venue submit carries - never from the assembled -/// `OrderCreation` - so the guard survives assembly moving into the -/// venue adapter. The orderbook's UID is the receipt; it rides the -/// log only. -fn submit_ready( +/// The journal keys on the deterministic venue-and-body [`intent_id`], +/// derived before any network work from the same body bytes the venue +/// submit carries, so the guard is independent of where assembly +/// happens. The venue's receipt rides the log only. +fn submit_ready( host: &H, + venue: &CowClient, watch: WatchRef<'_>, order: &GPv2OrderData, signature: Bytes, tick: &Tick, label: &str, -) -> Result<(), Fault> { +) -> Result<(), Fault> +where + H: LocalStoreHost, + T: VenueTransport, +{ let Ok(owner) = watch.owner_hex().parse::
() else { tracing::warn!( "watch {} carries an unparseable owner; skipping submit", @@ -127,31 +133,16 @@ fn submit_ready( tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); return Ok(()); } - let creation = match build_order_creation(&order_data, &signature, owner) { - Ok(creation) => creation, - Err(err) => { - // A constructor rejection (zero `from`, `validTo` beyond - // the client-side max horizon) is deterministic for this - // polled payload: keeping the watch would re-poll and - // re-warn on every block forever. Drop through the ledger - // - the same net effect as the pre-keeper flow, where - // the orderbook rejected the shipped body and the - // classifier dropped the watch. - tracing::warn!("{label} submit dropped watch for {owner:#x}: {err}"); - Retrier::new(host).apply(watch, RetryAction::Drop, tick.epoch_s)?; - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(body) => body, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(tick.chain_id, &body) { - Ok(receipt) => { + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; a pending future means a + // foreign transport misbehaved. The watch stays for the next + // tick. + tracing::error!("{label} submit future suspended; retrying next tick"); + return Ok(()); + }; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the @@ -159,41 +150,39 @@ fn submit_ready( if let Err(fault) = journal.record(&intent_id) { tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } - tracing::info!("submitted {intent_id} (receipt {receipt})"); - } - Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { - // Success wearing an error status: the orderbook already - // holds this order. Journal the intent-id and keep the - // watch so the next tick short-circuits instead of - // re-posting. As above, a journal fault post-submit only - // forfeits the short-circuit; it must not abort the sweep. - if let Err(fault) = journal.record(&intent_id) { - tracing::error!( - "orderbook already holds {intent_id} but journal write failed: {fault}" - ); - } tracing::info!( - "orderbook already holds this order ({}); intent-id journalled", - rejection.error_type, + "submitted {intent_id} (receipt {})", + hex::encode_prefixed(&receipt), ); } - Err(err) => { - let action = classify_submit_error(&err); + Ok(SubmitOutcome::RequiresSigning(_)) => { + // A sweep cannot sign; nothing is journalled, so the next + // tick surfaces the same ask afresh. + tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); + } + Err(ClientError::Body(err)) => { + tracing::error!("intent body encode failed: {err}"); + } + Err(ClientError::Venue(fault)) => { + let action = retry_action(&fault); Retrier::new(host).apply(watch, action, tick.epoch_s)?; match action { - RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), RetryAction::Backoff { seconds } => { - tracing::warn!("submit backoff {seconds}s: {err}"); + tracing::warn!("submit backoff {seconds}s: {fault}"); } - RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), // `RetryAction` is non-exhaustive; the ledger already // ran the effect, so the log needs only the name. other => { let action_label: &'static str = other.into(); - tracing::warn!("submit retry action {action_label}: {err}"); + tracing::warn!("submit retry action {action_label}: {fault}"); } } } + // `ClientError` is non-exhaustive; a future case leaves the + // watch for the next tick. + Err(err) => tracing::error!("submit failed: {err}"), } Ok(()) } diff --git a/crates/shepherd-sdk/src/cow/transport.rs b/crates/shepherd-sdk/src/cow/transport.rs new file mode 100644 index 00000000..ece99f70 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/transport.rs @@ -0,0 +1,226 @@ +//! Transitional venue transport over the legacy `shepherd:cow/cow-api` +//! seam. +//! +//! [`CowApiTransport`] implements the videre [`VenueTransport`] +//! contract by assembling the orderbook `OrderCreation` from the +//! decoded [`CowIntentBody`] and driving +//! [`CowApiHost::submit_order`], so the keeper [`run`](super::run()) +//! submits through the typed [`CowClient`](super::CowClient) while +//! module worlds still import the legacy host extension. Deleted when +//! the worlds flip to `videre:venue/client`. + +use alloy_primitives::{Address, hex}; +use cow_venue::assembly; +use cow_venue::body::{CowIntent, CowIntentBody}; +use cowprotocol::Chain; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::RetryAction; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, VenueFault, VenueId, VenueTransport, +}; + +use super::{CowApiError, CowApiHost, classify_api_error, is_already_submitted}; + +/// The `videre:venue/client` verbs carried over the legacy +/// `shepherd:cow/cow-api` import: submit only, pre-bound to one chain's +/// orderbook. Quote, status, and cancel have no legacy submission-path +/// counterpart and refuse as `unsupported`. +pub struct CowApiTransport<'h, H> { + host: &'h H, + chain_id: u64, +} + +impl<'h, H: CowApiHost> CowApiTransport<'h, H> { + /// Bind the legacy seam to one chain's orderbook. + #[must_use] + pub const fn new(host: &'h H, chain_id: u64) -> Self { + Self { host, chain_id } + } +} + +impl SealedTransport for CowApiTransport<'_, H> {} + +impl VenueTransport for CowApiTransport<'_, H> { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + let CowIntentBody::V1(intent) = + CowIntentBody::from_bytes(&body).map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + // The legacy seam posts EIP-1271 only; the pre-sign flow needs + // the adapter. + let CowIntent::Signed(signed) = intent else { + return Err(VenueFault::Unsupported); + }; + let order = assembly::body_to_order_data(&signed.order); + let owner = Address::from(signed.owner); + let creation = assembly::build_order_creation(&order, &signed.signature, owner) + .map_err(|e| VenueFault::InvalidBody(e.to_string()))?; + let json = serde_json::to_vec(&creation) + .map_err(|e| VenueFault::Unavailable(format!("order encode failed: {e}")))?; + match self.host.submit_order(self.chain_id, &json) { + Ok(uid) => Ok(SubmitOutcome::Accepted(receipt_bytes(&uid))), + // Already-held is success wearing an error status; the + // receipt is the client-derived UID (empty on a chain the + // SDK cannot derive for). + Err(CowApiError::Rejected(r)) if is_already_submitted(&r) => { + let receipt = Chain::try_from(self.chain_id) + .map(|chain| { + assembly::order_uid(chain, &order, owner) + .as_slice() + .to_vec() + }) + .unwrap_or_default(); + Ok(SubmitOutcome::Accepted(receipt)) + } + Err(err) => Err(venue_fault(&err)), + } + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + Err(VenueFault::Unsupported) + } +} + +/// The server UID at its wire spelling; a non-hex receipt rides through +/// as raw bytes rather than failing an accepted submit. +fn receipt_bytes(uid: &str) -> Vec { + hex::decode(uid).unwrap_or_else(|_| uid.as_bytes().to_vec()) +} + +/// Project a legacy submission failure onto the venue fault the typed +/// client reports, mirroring the adapter: throttles keep their hint, +/// host and server failures stay retryable, and only a structured +/// rejection, folded through the shipped classification table, carries +/// a permanent venue verdict. +fn venue_fault(err: &CowApiError) -> VenueFault { + match err { + CowApiError::Fault(Fault::RateLimited(limit)) => VenueFault::RateLimited { + retry_after_ms: limit.retry_after_ms, + }, + CowApiError::Fault(Fault::Timeout) => VenueFault::Timeout, + // Any other host fault is infrastructure, not a venue verdict: + // it stays retryable so an unprovisioned capability or unknown + // chain never drops a still-valid order. + CowApiError::Fault(fault) => VenueFault::Unavailable(fault.to_string()), + CowApiError::Http(http) if http.status == 429 => VenueFault::RateLimited { + retry_after_ms: None, + }, + CowApiError::Http(http) => { + VenueFault::Unavailable(format!("orderbook http {}", http.status)) + } + CowApiError::Rejected(rejection) => { + let detail = format!("{}: {}", rejection.error_type, rejection.description); + match classify_api_error(rejection) { + RetryAction::TryNextBlock => VenueFault::Unavailable(detail), + RetryAction::Backoff { seconds } => VenueFault::RateLimited { + retry_after_ms: Some(seconds.saturating_mul(1000)), + }, + _ => VenueFault::Denied(detail), + } + } + } +} + +#[cfg(test)] +mod tests { + use nexum_sdk::host::RateLimit; + use videre_sdk::keeper::retry_action; + + use super::super::{HttpFailure, OrderRejection}; + use super::*; + + fn rejected(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "d".into(), + data: None, + }) + } + + #[test] + fn legacy_failures_project_onto_the_venue_fault_by_shape() { + assert!(matches!( + venue_fault(&rejected("InsufficientFee")), + VenueFault::Unavailable(detail) if detail.contains("InsufficientFee") + )); + assert!(matches!( + venue_fault(&rejected("TooManyLimitOrders")), + VenueFault::RateLimited { + retry_after_ms: Some(30_000) + } + )); + assert!(matches!( + venue_fault(&rejected("InvalidSignature")), + VenueFault::Denied(detail) if detail.contains("InvalidSignature") + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + }))), + VenueFault::RateLimited { + retry_after_ms: Some(2_500) + } + )); + assert!(matches!( + venue_fault(&CowApiError::Fault(Fault::Timeout)), + VenueFault::Timeout + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 429, + body: None, + })), + VenueFault::RateLimited { + retry_after_ms: None + } + )); + assert!(matches!( + venue_fault(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + VenueFault::Unavailable(_) + )); + } + + #[test] + fn host_faults_stay_retryable_and_never_drop_the_watch() { + for fault in [ + Fault::Unsupported("cow-api not provisioned".into()), + Fault::Denied("allowlist".into()), + Fault::Unavailable("rpc down".into()), + Fault::Internal("host bug".into()), + Fault::InvalidInput("mangled".into()), + ] { + let projected = venue_fault(&CowApiError::Fault(fault)); + assert!(matches!(projected, VenueFault::Unavailable(_))); + assert_eq!(retry_action(&projected), RetryAction::TryNextBlock); + } + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::Timeout))), + RetryAction::TryNextBlock + ); + assert_eq!( + retry_action(&venue_fault(&CowApiError::Fault(Fault::RateLimited( + RateLimit { + retry_after_ms: Some(2_500), + } + )))), + RetryAction::Backoff { seconds: 3 } + ); + } + + #[test] + fn server_uid_decodes_to_wire_bytes_with_a_raw_fallback() { + assert_eq!(receipt_bytes("0xc0ffee"), vec![0xC0, 0xFF, 0xEE]); + assert_eq!(receipt_bytes("not-hex"), b"not-hex".to_vec()); + } +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 40798f85..2201ad35 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -13,14 +13,16 @@ //! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], //! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). //! -//! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` -//! (and the [`CowHost`] bound over the core [`Host`]), -//! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! the classifiers mapping submit failures into the keeper -//! [`RetryAction`], and [`run`] - the poll -> outcome -> -//! gate/journal/submit composition over the keeper stores, -//! dispatching the structured [`Verdict`] from the `composable-cow` -//! keeper crate. +//! - [`cow`] - [`run`], the poll -> outcome -> gate/journal/submit +//! composition over the keeper stores, dispatching the structured +//! [`Verdict`] from the `composable-cow` keeper crate and submitting +//! through the typed [`CowClient`] on the `videre:venue/client` +//! seam; `GPv2OrderData` -> `OrderData` bridging +//! ([`gpv2_to_order_data`]) and the classifiers mapping submit +//! failures into the keeper [`RetryAction`]. The legacy +//! [`CowApiHost`] trait for `shepherd:cow/cow-api` (and the +//! [`CowHost`] bound over the core [`Host`]) stays for the read +//! paths and the transitional [`CowApiTransport`] bridge. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -47,6 +49,8 @@ //! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON //! [`CowApiHost`]: cow::CowApiHost //! [`CowHost`]: cow::CowHost +//! [`CowClient`]: cow::CowClient +//! [`CowApiTransport`]: cow::CowApiTransport //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data //! [`Verdict`]: composable_cow::Verdict diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index bff0f238..f4e32081 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -13,13 +13,19 @@ use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; use shepherd_sdk::cow::{ - CowApiError, CowIntent, CowIntentBody, OrderRejection, SignedOrder, gpv2_to_order_data, - order_data_to_body, run, + CowApiError, CowApiTransport, CowClient, CowIntent, CowIntentBody, CowVenue, OrderRejection, + SignedOrder, gpv2_to_order_data, order_data_to_body, run, }; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; +/// The typed client every test drives `run` with: the transitional +/// cow-api bridge over the composed mock host. +fn venue(host: &MockHost) -> CowClient> { + CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +} + /// Closure-backed source so each test scripts its own outcome and /// observes its own poll calls. struct FnSource(F); @@ -124,6 +130,7 @@ fn try_next_block_leaves_the_store_untouched() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) @@ -141,6 +148,7 @@ fn try_on_block_sets_the_block_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitBlock { wait_until: 2_000, reason: [0; 4], @@ -163,6 +171,7 @@ fn try_at_epoch_sets_the_epoch_gate() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::WaitTimestamp { wait_until: 1_800_000_000, reason: [0; 4], @@ -186,6 +195,7 @@ fn invalid_removes_the_watch_and_its_gates() { run( &host, + &venue(&host), &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) @@ -207,6 +217,7 @@ fn gated_watch_is_not_polled() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -226,6 +237,7 @@ fn malformed_watch_rows_are_skipped() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -250,7 +262,7 @@ fn ready_submits_once_and_journals_the_intent_id() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); assert!( @@ -273,7 +285,7 @@ fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); @@ -295,6 +307,7 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { run( &host, + &venue(&host), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) @@ -320,6 +333,7 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -343,7 +357,7 @@ fn ready_beyond_the_valid_to_horizon_drops_the_watch() { order.validTo = valid_to_in(2 * 365 * 24 * 3_600); let source = src(move |_, _, _, _| ready_outcome(&order)); - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + let (result, logs) = capture_tracing(|| run(&host, &venue(&host), &source, &sample_tick())); result.unwrap(); assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); @@ -377,6 +391,7 @@ fn transient_rejection_keeps_the_watch_ungated() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -401,6 +416,7 @@ fn permanent_rejection_drops_the_watch_through_the_ledger() { run( &host, + &venue(&host), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -426,7 +442,7 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert!(host.store.snapshot().contains_key(&key)); assert!( @@ -437,7 +453,7 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { ); // The next tick must not touch the orderbook again. - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); } @@ -455,7 +471,7 @@ fn restart_with_a_journalled_intent_does_not_repost() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + run(&host, &venue(&host), &source, &sample_tick()).unwrap(); assert_eq!(host.cow_api.call_count(), 1); // A restarted keeper: fresh instance, the local store carried over. @@ -465,7 +481,7 @@ fn restart_with_a_journalled_intent_does_not_repost() { } restarted.cow_api.respond(Ok("0xserveruid".to_string())); - run(&restarted, &source, &sample_tick()).unwrap(); + run(&restarted, &venue(&restarted), &source, &sample_tick()).unwrap(); assert_eq!( host.cow_api.call_count() + restarted.cow_api.call_count(), @@ -493,7 +509,13 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { })))); let tick = sample_tick(); - run(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + run( + &host, + &venue(&host), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&key), "backoff must keep the watch"); @@ -504,3 +526,93 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { ); assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } + +// ---- the generic seam ---- + +/// The seam proof: a `Post` verdict reaches the venue transport as the +/// encoded `CowIntentBody` under the CoW venue id, the journal keys on +/// the generic submission key, and the legacy cow-api seam is never +/// touched. +#[test] +fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { + use std::cell::RefCell; + + use videre_sdk::keeper::submission_key; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + VenueTransport, + }; + + /// Records the venue and wire bytes of every submit. + struct SpyTransport { + calls: RefCell)>>, + } + + impl videre_sdk::client::sealed::SealedTransport for &SpyTransport {} + + impl VenueTransport for &SpyTransport { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.calls.borrow_mut().push((venue.to_string(), body)); + Ok(SubmitOutcome::Accepted(vec![0xAA])) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let spy = SpyTransport { + calls: RefCell::new(Vec::new()), + }; + let client = CowClient::with_transport(&spy); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &client, &source, &sample_tick()).unwrap(); + + let order_data = gpv2_to_order_data(&order).expect("known markers"); + let expected = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + })) + .to_bytes() + .expect("body encodes"); + + let calls = spy.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, CowVenue::ID.as_str()); + assert_eq!(calls[0].1, expected, "the wire carries the intent body"); + assert!( + Journal::submitted(&host) + .contains(&submission_key(&CowVenue::ID, &expected)) + .unwrap(), + "the journal keys on the generic submission key", + ); + assert_eq!( + host.cow_api.call_count(), + 0, + "the legacy cow-api seam is never touched", + ); +} diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 7255efd9..8ca91a3e 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -112,7 +112,7 @@ impl Keeper { report.unsigned.push(tx); continue; } - Err(fault) => retry_action(fault), + Err(fault) => retry_action(&fault), } } Sweep::WaitBlock => RetryAction::TryNextBlock, @@ -165,8 +165,10 @@ pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { /// Fold a venue refusal into the retry action the ledger runs: the /// throttle hint becomes an epoch gate, transient failures retry next -/// block, and refusals no retry can cure drop the watch. -fn retry_action(fault: VenueFault) -> RetryAction { +/// block, and refusals no retry can cure drop the watch. Public so a +/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same +/// way. +pub fn retry_action(fault: &VenueFault) -> RetryAction { match fault { VenueFault::RateLimited { retry_after_ms: Some(ms), diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 99e16d9a..007eb2e2 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -87,7 +87,7 @@ pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; pub use faults::VenueFault; -pub use keeper::{Keeper, Sweep, SweepReport}; +pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 540a6c7e..d5426d58 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -24,7 +24,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, events, run}; +use shepherd_sdk::cow::{CowApiTransport, CowClient, CowHost, events, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -71,15 +71,17 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition. The block timestamp arrives in milliseconds; the -/// tick carries Unix seconds. +/// shared composition, submitting through the typed client on the +/// transitional cow-api bridge. The block timestamp arrives in +/// milliseconds; the tick carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - run(host, &TwapSource, &tick) + let venue = CowClient::with_transport(CowApiTransport::new(host, tick.chain_id)); + run(host, &venue, &TwapSource, &tick) } // ---- indexing path ---- From b3997598399bdd3051828d9f974c440479519e02 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 07:54:21 +0000 Subject: [PATCH 076/139] modules: re-point twap-monitor onto pool submit via the cow adapter (#469) * twap: re-point the monitor onto pool submit through the cow adapter The module flips from the shepherd:cow world onto #[videre_sdk::keeper]: the manifest declares the client capability and body version 1, the keeper run submits through the typed CowClient over the module's own videre:venue/client import, and the direct cow-api import and legacy cow client bridge drop out. Status transitions the registry polls back arrive on a cow intent-status subscription. Behaviour identity is proven at the VenueTransport seam: the dispatch tests script submit outcomes against the mock host and assert the same journal, gate, and retry effects as the legacy bridge, including the throttle hint surviving as an epoch backoff and the appData digest riding the body verbatim. The bundle boot proof moves to the videre platform suite (twap against the installed cow adapter); the cow-api boot-order invariant re-pins on ethflow-watcher. Engine configs that boot twap install the bundled adapter. * cow: match the adapter manifest chain to each engine config's run The adapter fixes its orderbook at init from its manifest chain, so a Sepolia run wired to the mainnet manifest submits to the wrong orderbook. Add per-chain manifest variants (sepolia, load-mock) and point every twap-wired engine config at the one matching the chain it indexes. --- Cargo.lock | 3 +- Dockerfile | 5 +- crates/cow-venue/module.load.toml | 24 ++ crates/cow-venue/module.sepolia.toml | 24 ++ crates/cow-venue/module.toml | 3 +- crates/shepherd-cow-host/tests/cow_boot.rs | 44 +-- crates/videre-host/tests/platform.rs | 49 +++ docs/deployment/multi-chain.md | 12 + engine.docker.toml | 6 +- engine.e2e.toml | 10 + engine.example.toml | 4 +- engine.load.toml | 9 + engine.m2.toml | 8 + engine.soak.docker.toml | 9 + engine.soak.toml | 12 + justfile | 4 +- modules/twap-monitor/Cargo.toml | 4 +- modules/twap-monitor/module.toml | 20 +- modules/twap-monitor/src/lib.rs | 85 +++--- modules/twap-monitor/src/strategy.rs | 334 ++++++++++++++------- 20 files changed, 467 insertions(+), 202 deletions(-) create mode 100644 crates/cow-venue/module.load.toml create mode 100644 crates/cow-venue/module.sepolia.toml diff --git a/Cargo.lock b/Cargo.lock index f3c42cd6..4c7526c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5927,13 +5927,14 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "composable-cow", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] diff --git a/Dockerfile b/Dockerfile index 1fbaba16..bc53f0fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,9 +128,12 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml -# The bundled cow venue adapter's manifest; installed via the +# The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. +# One manifest per chain: mainnet (cow-venue.toml) and Sepolia +# (cow-venue.sepolia.toml); pick the one matching the run's chain. COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and diff --git a/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/crates/cow-venue/module.load.toml @@ -0,0 +1,24 @@ +# Load-test variant of the cow adapter manifest: Sepolia chain id with +# the orderbook re-pointed at tools/orderbook-mock (no live cow.fi). + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["localhost"] + +[config] +chain = "11155111" +orderbook-url = "http://localhost:9999" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/crates/cow-venue/module.sepolia.toml @@ -0,0 +1,24 @@ +# Sepolia variant of the cow adapter manifest: same component, chain +# 11155111, so submits land on the Sepolia orderbook. Wire this from +# any engine config whose watchers index Sepolia. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +[config] +chain = "11155111" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml index b660014b..0e5b555c 100644 --- a/crates/cow-venue/module.toml +++ b/crates/cow-venue/module.toml @@ -19,7 +19,8 @@ allow = ["api.cow.fi"] # One adapter instance speaks one chain's orderbook. `orderbook-url`, # `owner` (enables the pre-sign path), and `http-timeout-ms` are -# optional overrides. +# optional overrides. Sepolia and load-mock variants sit alongside +# (`module.sepolia.toml`, `module.load.toml`). [config] chain = "1" diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 99080dac..3953ab6a 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -144,32 +144,6 @@ async fn boot_production_module( .expect("boot_single") } -/// twap-monitor imports `shepherd:cow/cow-api`; with the cow extension -/// registered it boots, and a block dispatch reaches it and keeps it alive. -#[tokio::test] -async fn e2e_twap_monitor_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // twap-monitor subscribes to Sepolia blocks (poll path). A real poll - // would call chain::request, which ProviderPool::empty() does not - // satisfy - the module surfaces a fault and warns; the supervisor - // must keep the module alive because the strategy catches the error - // and returns Ok(()). - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - /// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; /// it boots with the cow extension and a synthetic log is delivered. #[tokio::test] @@ -221,17 +195,17 @@ async fn e2e_stop_loss_block_dispatch() { } /// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the +/// a module that imports `shepherd:cow/cow-api` (ethflow-watcher) must +/// NOT boot when the cow extension is absent from the linker AND the /// capability registry. The paired linker-hook + capability-namespace /// registration is what makes the same module boot in the tests above; /// drop the pairing and boot fails. #[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { +async fn ethflow_watcher_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { return; }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); let engine = make_wasmtime_engine(); // Core-only: no cow linker hook, no cow capability namespace. let linker = build_linker::(&engine, &[]).expect("build_linker"); @@ -254,10 +228,10 @@ async fn twap_monitor_without_cow_extension_fails_to_boot() { let err = result .err() .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. + // Pin the failure to its specific cause: ethflow-watcher declares + // the cow-api capability, which a core-only registry does not + // recognise (registering it is exactly what the cow extension does). + // Rules out an unrelated failure masquerading as the invariant. let chain = format!("{err:#}"); assert!( chain.contains(r#"unknown capability "cow-api""#), diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 2a6d745b..9ed8c2d7 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -736,6 +736,55 @@ async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { } } +/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` +/// worker) boots against the installed cow adapter - the body-version +/// handshake admits the pair - and a Sepolia block dispatch reaches it +/// and keeps it alive. The chainless poll surfaces a fault the strategy +/// absorbs, so no orderbook traffic occurs. +#[tokio::test] +async fn e2e_twap_monitor_boots_against_the_cow_adapter() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("cow-venue"), + module_wasm_or_skip("twap-monitor"), + ) else { + return; + }; + + let components = mock_components(); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + // Sepolia variant: twap-monitor pins chain 11155111, so the + // adapter manifest must name the same chain for the pair to + // submit to the right orderbook. + manifest: Some(workspace_path("crates/cow-venue/module.sepolia.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/twap-monitor/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "cow is routable"); + assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); + + // twap-monitor subscribes to Sepolia blocks (poll path); with no + // watches indexed the sweep is empty and the keeper stays alive. + assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); + assert_eq!(supervisor.alive_count(), 1); +} + /// The body-version handshake refuses a mismatched pair: an adapter /// decoding only v1 against a keeper encoding v2 fails the boot at the /// keeper's install, before instantiation, naming both sides' versions. diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index 5ed09852..e68794a2 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -5,6 +5,18 @@ The engine dispatches each module only to the chains it subscribes to, so a single `nexum` process can serve modules watching Mainnet, Gnosis Chain, Arbitrum One, and Base at the same time. +That covers keeper modules, which subscribe per chain. It does not extend to +venue adapters. The CoW adapter fixes its orderbook chain at `init` from its +own manifest `[config] chain`, and registers under the fixed venue id `cow` +(`CowVenue::ID`), which carries no chain component. Two cow adapters installed +in one process would therefore register under the same id, with nothing at the +pool router to tell them apart. + +Single-process multi-chain *submission* is an explicit non-goal for M4. Run one +engine process per submitting chain, each paired with the matching adapter +manifest (`module.toml` for Mainnet, `module.sepolia.toml` for Sepolia). +Modules that only watch chains are unaffected and may span chains freely. + --- ## Chain support matrix diff --git a/engine.docker.toml b/engine.docker.toml index 151f3de8..e2e697a3 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -82,9 +82,11 @@ manifest = "/opt/shepherd/manifests/stop-loss.toml" # # The bundled cow venue adapter: the pool router resolves the `cow` # venue id through it. The operator grant scopes its outbound HTTP to -# the production orderbook. +# the production orderbook host. The Sepolia manifest matches +# twap-monitor's Sepolia subscriptions; a mainnet run swaps in +# /opt/shepherd/manifests/cow-venue.toml. [[adapters]] path = "/opt/shepherd/modules/cow_venue.wasm" -manifest = "/opt/shepherd/manifests/cow-venue.toml" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.e2e.toml b/engine.e2e.toml index 330774b7..31960778 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -65,3 +65,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 77dc3145..be346456 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -107,7 +107,9 @@ rpc_url = "${BASE_RPC_URL}" # The operator, not the adapter author, grants the transport scope: # `http_allow` is the outbound wasi:http host allowlist. The bundled # cow adapter builds with `just build-cow-venue`; its venue id is the -# manifest name (`cow`). +# manifest name (`cow`). One manifest per chain: `module.toml` is +# mainnet, `module.sepolia.toml` Sepolia - wire the one matching the +# chain the submitting modules index. # [[adapters]] # path = "target/wasm32-wasip2/release/cow_venue.wasm" diff --git a/engine.load.toml b/engine.load.toml index 7999669d..44924b49 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -41,6 +41,15 @@ rpc_url = "ws://localhost:8545" path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Load-variant manifest: Sepolia chain id with the +# orderbook re-pointed at tools/orderbook-mock, matching +# [extensions.cow.orderbook_urls] above. +[[adapters]] +path = "./target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "./crates/cow-venue/module.load.toml" +http_allow = ["localhost"] + [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "./modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..11f29389 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -33,3 +33,11 @@ manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "modules/ethflow-watcher/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 3f29ea10..7efd05cc 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -46,3 +46,12 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# --- adapters ----------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through. Sepolia +# manifest: the adapter's orderbook must match the chain twap indexes. +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 8f6cabf8..ba3e78d7 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -9,6 +9,8 @@ # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ # -p balance-tracker -p stop-loss +# cargo build --target wasm32-wasip2 --release \ +# -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml # # Swap rpc_url below for your paid endpoint before starting, or set it @@ -66,3 +68,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 6f410367..1d22c5c2 100644 --- a/justfile +++ b/justfile @@ -43,7 +43,7 @@ build-m2: # (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m2: build-m2 build-engine +run-m2: build-m2 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) @@ -74,7 +74,7 @@ build-e2e: build-m2 build-m3 # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-engine +run-e2e: build-e2e build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.e2e.toml # Zero-leak gate: host-layer crate graphs, runtime charter-symbol and diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 5533d59a..acacdadb 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -10,8 +10,10 @@ crate-type = ["cdylib"] [dependencies] composable-cow = { path = "../../crates/composable-cow" } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -19,6 +21,6 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index 3ef1883d..d7f45ca2 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. +# submit ready ones to the CoW venue through the pool. [module] name = "twap-monitor" @@ -14,13 +14,13 @@ component = "sha256:000000000000000000000000000000000000000000000000000000000000 # - local-store -> watch: / next_block: / next_epoch: / submitted: / # backoff: / dropped: persistence # - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature -# - cow-api -> POST /api/v1/orders submission path -required = ["logging", "local-store", "chain", "cow-api"] +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. +# All outbound HTTP is the cow adapter's; the keeper makes no direct +# `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ @@ -40,3 +40,13 @@ event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2c [[subscription]] kind = "block" chain_id = 11155111 + +# Status transitions the registry polls back for submitted orders. +[[subscription]] +kind = "intent-status" +venue = "cow" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed venue adapter decodes it. +[venue] +body_version = 1 diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index fda3ac82..aa6495db 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,21 +1,19 @@ -//! # twap-monitor (Shepherd module) +//! # twap-monitor (Shepherd keeper module) //! //! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. +//! watched conditional order on every block, submitting tranches to the +//! CoW venue through the pool as they go live. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates each event variant to `strategy`. -//! -//! Same recipe as `modules/examples/price-alert` and -//! `modules/examples/stop-loss`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -23,52 +21,45 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; +use cow_venue::CowClient; use nexum::host::types; -// `WitBindgenHost` and the fault and level `From` impls -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - struct TwapMonitor; -impl Guest for TwapMonitor { +#[videre_sdk::keeper] +impl TwapMonitor { fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); tracing::info!("twap-monitor init"); Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match event { - types::Event::ChainLogs(batch) => { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs)?; - } - types::Event::Block(block) => { - let info = strategy::BlockInfo { - chain_id: block.chain_id, - number: block.number, - timestamp: block.timestamp, - }; - strategy::on_block(&WitBindgenHost, info)?; - } - // Tick / Message are not used by this module. - _ => {} - } + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + strategy::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = strategy::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } -} -export!(TwapMonitor); + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + let body = videre_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + tracing::info!( + "cow intent status {:?} ({} receipt bytes)", + body.status, + update.receipt.len(), + ); + Ok(()) + } +} diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index d5426d58..15affe60 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -1,30 +1,34 @@ //! Pure strategy logic for the twap-monitor module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under -//! `#[cfg(test)]` hand the same functions a -//! `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through a trait seam: the +//! `nexum_sdk::host` traits for chain and store access and the videre +//! [`VenueTransport`] under the typed [`CowClient`] for submission - +//! no direct calls to wit-bindgen-generated free functions live here. +//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the +//! `WitBindgenHost` adapter and the client over the module's own +//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the +//! same functions a `nexum_sdk_test::MockHost` and a scripted +//! transport. //! //! The module owns decode and evaluate only: log decoding into the //! keeper watch set, and the `getTradeableOrderWithSignature` poll //! behind [`ConditionalSource`]. Gate discipline, the `submitted:` -//! journal, submission, and retry dispatch live in the shared -//! composition (`shepherd_sdk::cow::run`). +//! journal, submission through the pool, and retry dispatch live in +//! the shared composition (`shepherd_sdk::cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use composable_cow::{LegacyRevertAdapter, Verdict}; +use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; -use nexum_sdk::host::{ChainError, Fault}; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowApiTransport, CowClient, CowHost, events, run}; +use shepherd_sdk::cow::{events, run}; +use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -61,7 +65,7 @@ mod abi { /// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` /// chain-log in a dispatch batch and persist its watch. -pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { +pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { persist_watch(host, owner, ¶ms)?; @@ -71,17 +75,20 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition, submitting through the typed client on the -/// transitional cow-api bridge. The block timestamp arrives in -/// milliseconds; the tick carries Unix seconds. -pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { +/// shared composition, submitting through the typed client onto the +/// pool. The block timestamp arrives in milliseconds; the tick carries +/// Unix seconds. +pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> +where + H: ChainHost + LocalStoreHost, + T: VenueTransport, +{ let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - let venue = CowClient::with_transport(CowApiTransport::new(host, tick.chain_id)); - run(host, &venue, &TwapSource, &tick) + run(host, venue, &TwapSource, &tick) } // ---- indexing path ---- @@ -99,7 +106,7 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr /// The watch set overwrites in place, so re-indexing the same log /// (re-org replay, overlapping subscription windows) produces no /// observable side effect. -fn persist_watch( +fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, @@ -118,7 +125,7 @@ fn persist_watch( /// down the sweep. struct TwapSource; -impl ConditionalSource for TwapSource { +impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { @@ -143,7 +150,7 @@ impl ConditionalSource for TwapSource { } } -fn poll_one( +fn poll_one( host: &H, chain_id: u64, owner: &Address, @@ -225,7 +232,7 @@ fn outcome_label(o: &Verdict) -> &'static str { // ---- test-only seam mirrors ---- // -// Thin views over the keeper / SDK canon so the dispatch tests can +// Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. #[cfg(test)] @@ -238,30 +245,108 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { - use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; - let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: shepherd_sdk::cow::order_data_to_body(&order_data), +fn signed_intent_body( + order: &GPv2OrderData, + signature: &Bytes, + owner: Address, +) -> Option { + use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; + use cow_venue::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = gpv2_to_order_data(order)?; + Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), owner: owner.into_array(), signature: signature.to_vec(), }))) - .ok() +} + +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::{B256, U256, address, b256, hex}; + use cow_venue::CowVenue; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::{CowApiError, OrderRejection}; - use shepherd_sdk_test::MockHost; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + }; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted [`VenueTransport`]: one submit outcome per queued entry, + /// every submit recorded. Quote, status, and cancel are off the + /// module's poll path. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Dispatch one block through `on_block` with the typed client over + /// the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } + /// `validTo` a given number of seconds from now. The constructor's /// client-side max-horizon policy reads the wall clock (not the /// block clock), so test orders must expire relative to it. @@ -384,7 +469,7 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - // ---- MockHost dispatch tests ---- + // ---- MockHost + MockVenue dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed /// `ConditionalOrderCreated`, assembled through the same WIT-edge @@ -478,6 +563,7 @@ mod tests { #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); let key = seed_watch(&host, owner, ¶ms); @@ -491,19 +577,20 @@ mod tests { ) .unwrap(); - on_block(&host, sample_block(100)).unwrap(); + dispatch(&host, &venue, sample_block(100)).unwrap(); assert_eq!( host.chain.call_count(), 0, "gated watch must not issue eth_call" ); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] - fn poll_ready_submits_order_and_persists_the_intent_id() { + fn poll_ready_submits_the_intent_body_through_the_pool() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -516,14 +603,28 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + let expected_body = signed_intent_body(&ready_order, &signature, owner) + .expect("canonical markers") + .to_bytes() + .expect("body encodes"); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!( + submits[0].0, + CowVenue::ID.as_str(), + "routed to the cow venue" + ); + assert_eq!( + submits[0].1, expected_body, + "the wire carries the intent body" + ); assert!( host.store .snapshot() @@ -532,21 +633,22 @@ mod tests { ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the pre-submit intent-id, not the server receipt" + "marker must key on the pre-submit intent-id, not the venue receipt" ); } /// Regression guard: when `getTradeableOrderWithSignature` /// returns the same Ready tuple in consecutive poll-ticks (the /// on-chain conditional order does not know shepherd already - /// POSTed it), the second tick must NOT call `submit_order` - /// again. Without the guard the orderbook responds with - /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{intent_id}` - /// short-circuit at the top of `submit_ready`. + /// posted it), the second tick must NOT submit again. Without the + /// guard the venue refuses the duplicate and a Warn fires for what + /// is in fact correct, finished work. The guard is the + /// `submitted:{intent_id}` short-circuit at the top of + /// `submit_ready`. #[test] fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -562,14 +664,14 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the - // orderbook submit must not be attempted. + // venue submit must not be attempted. let already_submitted = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), @@ -577,28 +679,20 @@ mod tests { "poll still consults the chain to see Ready", ); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, - "submit_order must NOT be called when submitted:{{intent_id}} already exists", - ); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "the REST passthrough must NOT be touched - the guard short-circuits early", + "the pool must NOT be touched when submitted:{{intent_id}} already exists", ); } - /// A Ready order with a non-empty `appData` digest submits the - /// digest verbatim as the hash-only wire shape: `appData` carries - /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook - /// GET runs first - watch-tower parity. The absence of - /// `appDataHash` is load-bearing: with both fields present the - /// orderbook reads the body as the full-document shape and rejects - /// it for a digest mismatch. + /// A Ready order with a non-empty `appData` digest rides the intent + /// body verbatim: assembly into the orderbook wire shape is the + /// adapter's, so the keeper ships exactly the digest the chain + /// returned - watch-tower parity. #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; + fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -614,28 +708,25 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), 1, "exactly one eth_call to poll Ready" ); - assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); - assert!( - host.cow_api.request_calls().is_empty(), - "no appData GET before submit - the digest goes out verbatim", - ); - let body = host.cow_api.last_body_as_json().expect("body is JSON"); + let submits = venue.submits(); + assert_eq!(submits.len(), 1, "exactly one pool submit"); + let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = + cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") + else { + panic!("expected a signed V1 intent"); + }; assert_eq!( - body["appData"], - format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), - ); - assert!( - body.get("appDataHash").is_none(), - "hash-only body must omit appDataHash, got: {body}" + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", ); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); @@ -648,8 +739,9 @@ mod tests { } #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { + fn submit_transient_fault_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -663,17 +755,13 @@ mod tests { Ok(quoted_hex(&wire)), ); - // InsufficientFee classifies as TryNextBlock per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + // The adapter projects a retriable rejection onto `unavailable`, + // which the retry table folds to TryNextBlock. + venue.enqueue_submit(Err(VenueFault::Unavailable( + "InsufficientFee: fee too low".into(), + ))); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); // Watch still present, no gate written, no submitted marker. @@ -697,9 +785,13 @@ mod tests { }); } + /// The venue's throttle hint survives the pool seam: a rate-limited + /// refusal backs the watch off on the epoch clock instead of + /// hot-looping the submit every block. #[test] - fn submit_permanent_error_drops_watch() { + fn submit_rate_limited_backs_off_on_the_epoch_gate() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -712,22 +804,55 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - // InvalidSignature classifies as Drop. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - on_block(&host, sample_block(1_000)).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&watch_key_str), + "backoff must keep the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert_eq!( + snapshot + .get(&format!("next_epoch:{owner_hex}:{hash_hex}")) + .unwrap(), + &(1_700_000_000_u64 + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn submit_denied_drops_watch() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a permanent rejection onto `denied`, + // which the retry table folds to Drop. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); let store = host.store.snapshot(); assert!( !store.contains_key(&watch_key_str), - "permanent error must drop the watch" + "permanent refusal must drop the watch" ); let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); @@ -746,6 +871,7 @@ mod tests { use nexum_sdk::host::RpcError; let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -771,7 +897,7 @@ mod tests { })), ); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); @@ -781,11 +907,7 @@ mod tests { .snapshot() .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" - ); + assert_eq!(venue.submit_count(), 0, "revert-to-drop path never submits"); // The destructive drop carries its cause: the revert selector // and the node's message ride the Warn, and the keeper logs // the removal itself. From f83f3592c1454d01100f6e53ad284ece91b88aef Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 08:56:47 +0000 Subject: [PATCH 077/139] modules: re-point ethflow-watcher onto the venue observe path (#470) --- Cargo.lock | 9 +- crates/shepherd-backtest/Cargo.toml | 4 +- crates/shepherd-backtest/src/main.rs | 4 +- crates/shepherd-backtest/src/replay.rs | 242 +++++--- crates/shepherd-backtest/src/report.rs | 11 +- crates/shepherd-cow-host/Cargo.toml | 1 - crates/shepherd-cow-host/tests/cow_boot.rs | 43 +- crates/videre-host/src/bindings.rs | 7 +- crates/videre-host/src/client.rs | 4 + crates/videre-host/src/registry.rs | 110 +++- crates/videre-host/tests/platform.rs | 38 ++ crates/videre-sdk/src/client.rs | 23 + docs/deployment/multi-chain.md | 2 +- engine.docker.toml | 2 +- engine.example.toml | 2 +- modules/ethflow-watcher/Cargo.toml | 4 +- modules/ethflow-watcher/module.toml | 25 +- modules/ethflow-watcher/src/lib.rs | 97 ++-- modules/ethflow-watcher/src/strategy.rs | 611 +++++++++++---------- wit/videre-venue/venue.wit | 4 + 20 files changed, 770 insertions(+), 473 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c7526c3..eedd4359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,12 +2302,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5195,12 +5195,14 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "cow-venue", "ethflow-watcher", "hex", "nexum-sdk", + "nexum-sdk-test", "serde", "serde_json", - "shepherd-sdk-test", + "videre-sdk", ] [[package]] @@ -5209,7 +5211,6 @@ version = "0.2.0" dependencies = [ "alloy-chains", "alloy-primitives", - "alloy-rpc-types-eth", "anyhow", "cowprotocol", "http", diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2ec14aa6..e424d50c 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -15,8 +15,10 @@ path = "src/main.rs" # (alongside its wasm cdylib) specifically so this crate can drive # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } +cow-venue = { path = "../cow-venue", features = ["client"] } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +nexum-sdk-test = { path = "../nexum-sdk-test" } +videre-sdk = { path = "../videre-sdk" } anyhow.workspace = true clap.workspace = true diff --git a/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs index 45d446b6..6d0ff80d 100644 --- a/crates/shepherd-backtest/src/main.rs +++ b/crates/shepherd-backtest/src/main.rs @@ -3,8 +3,8 @@ //! Offline replay harness for Shepherd modules. Loads a fixtures //! JSON produced by `tools/backtest-collect/backtest_collect.py`, //! drives each on-chain event through the production strategy code -//! via `shepherd_sdk_test::MockHost`, classifies the result, and -//! emits a Markdown report at +//! over `nexum_sdk_test::MockHost` and a recording pool transport, +//! classifies the result, and emits a Markdown report at //! `docs/operations/backtest-reports/backtest-7d-YYYY-MM-DD.md`. //! //! ## Scope diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 71a0aef1..10ad421b 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -1,32 +1,37 @@ -//! Per-event replay against `ethflow_watcher::strategy::on_chain_logs`. +//! Per-event replay against the ethflow-watcher strategy pair. //! -//! Each [`EthFlowFixture`] is driven through the production strategy -//! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, a catch-all 200 response is programmed for any -//! `cow_api_request` call (the observe+verify strategy GETs -//! `/api/v1/orders/{uid}` to confirm the orderbook has indexed the -//! order), and `strategy::on_chain_logs` is invoked with an alloy -//! `Log` reconstructed from the raw `eth_getLogs` payload. +//! Each [`EthFlowFixture`] is driven the way the live engine does it, +//! minus the wasm boundary: `strategy::on_chain_logs` runs over a +//! recording venue transport (the pool seam), then the status +//! transition the registry would poll for the watched receipt is +//! delivered through `strategy::on_intent_status`. In backtest context +//! all fixtures are confirmed real orders, so the simulated transition +//! is `open`. //! -//! The classification falls into one of the four buckets defined in -//! the issue: +//! The classification falls into one of four buckets: //! -//! - `Observed`: the strategy verified the order with exactly one -//! `GET /api/v1/orders/{uid}` and wrote `observed:{uid}` to the -//! local store. This is the success case under the observe+verify -//! strategy. -//! - `RejectedExpected`: the strategy returned without observing in a -//! documented case (reserved for fixtures where the mock returns 404 -//! — not applicable when all fixtures program 200). +//! - `Observed`: the strategy registered exactly one `cow` watch whose +//! receipt is the fixture's UID and wrote `observed:{uid}` on the +//! delivered transition. The success case. +//! - `RejectedExpected`: reserved for a documented skip path; not +//! emitted in the current batch. //! - `RejectedUnexpected`: the strategy returned Ok but the observe -//! contract was violated (no `observed:{uid}` marker, an unexpected -//! orderbook call shape, or a `submit_order` attempt); a follow-up -//! should be filed before the report closes. -//! - `StrategyError`: `on_chain_logs` returned `Err(fault)`. A test +//! contract was violated (a wrong watch shape, a non-observe venue +//! call, or a missing `observed:{uid}` marker); a follow-up should +//! be filed before the report closes. +//! - `StrategyError`: a strategy call returned `Err(fault)`. A test //! bug or an `unreachable!` we want to investigate. +use std::cell::RefCell; +use std::rc::Rc; + +use cow_venue::client::CowClient; use ethflow_watcher::strategy; -use shepherd_sdk_test::MockHost; +use nexum_sdk_test::{CapturedEvents, MockHost, capture_tracing}; +use videre_sdk::client::{VenueId, VenueTransport}; +use videre_sdk::rt::complete; +use videre_sdk::status_body::{IntentStatus, StatusBody}; +use videre_sdk::{Quotation, SubmitOutcome, VenueFault}; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -45,9 +50,8 @@ pub struct ReplayOutcome { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Classification { Observed, - /// Reserved for any documented skip path (e.g. a fixture where the mock - /// returns 404 for an un-indexed order). Not emitted in the current batch; - /// retained so the acceptance-ratio formula is complete. + /// Reserved for any documented skip path. Not emitted in the current + /// batch; retained so the acceptance-ratio formula is complete. #[allow(dead_code)] RejectedExpected(String), RejectedUnexpected(String), @@ -74,16 +78,94 @@ impl Classification { } } -/// Replay one EthFlow fixture through the production strategy. +/// One recorded transport call: the verb, and for `observe` the routed +/// venue and receipt. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, +} + +impl Call { + fn label(&self) -> &'static str { + match self { + Call::Quote => "quote", + Call::Submit => "submit", + Call::Observe(..) => "observe", + Call::Status => "status", + Call::Cancel => "cancel", + } + } +} + +/// Records every pool call; `observe` accepts, the other verbs refuse. +/// Cloneable over shared state so the replay keeps a handle after one +/// moves into the client. +#[derive(Clone, Default)] +struct RecordingVenues { + calls: Rc>>, +} + +impl RecordingVenues { + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +impl videre_sdk::client::sealed::SealedTransport for RecordingVenues {} + +impl VenueTransport for RecordingVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + Ok(()) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } +} + +/// The `open` transition the registry would report for an indexed order. +fn open_status() -> Result, String> { + StatusBody { + status: IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .map_err(|e| format!("status body encode: {e}")) +} + +/// Replay one EthFlow fixture through the production strategy pair. pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { let host = MockHost::new(); - - // Program a catch-all 200 response for any cow_api_request. In - // the observe+verify strategy the module GETs - // `/api/v1/orders/{uid}` to confirm the orderbook has indexed the - // order. In backtest context all fixtures are confirmed real orders, - // so the mock orderbook always returns 200 (indexed). - host.cow_api.respond_to_request(Ok("{}".to_string())); + let venues = RecordingVenues::default(); + let client = CowClient::with_transport(venues.clone()); // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow @@ -120,18 +202,32 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } .into(); - // Drive the strategy. - let result = strategy::on_chain_logs(&host, chain_id, &[log]); - let log_lines: Vec = host - .logging - .lines() - .into_iter() - .map(|l| format!("[{:?}] {}", l.level, l.message)) - .collect(); + // Drive the strategy: register the watch, then deliver the status + // transition the registry would poll for the watched receipt. + let (result, logs) = capture_tracing(|| { + complete(strategy::on_chain_logs(&host, &client, chain_id, &[log])) + .ok_or_else(|| "strategy future suspended".to_owned()) + .and_then(|r| r.map_err(|e| e.to_string()))?; + let calls = venues.calls(); + let [Call::Observe(venue, receipt)] = calls.as_slice() else { + let shapes: Vec<&str> = calls.iter().map(Call::label).collect(); + return Err(format!( + "expected exactly one observe; saw [{}]", + shapes.join(", ") + )); + }; + if venue != "cow" { + return Err(format!("watch routed to venue `{venue}`, not `cow`")); + } + strategy::on_intent_status(&host, venue, receipt, &open_status()?) + .map_err(|e| e.to_string())?; + Ok(receipt.clone()) + }); + let log_lines = log_lines(&logs); let class = match result { - Err(e) => Classification::StrategyError(e.to_string()), - Ok(()) => classify_ok(&host, &fx.uid, &log_lines), + Err(detail) => classify_err(&venues, detail), + Ok(receipt) => classify_ok(&host, &receipt, &fx.uid, &log_lines), }; ReplayOutcome { @@ -143,6 +239,13 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { } } +fn log_lines(logs: &CapturedEvents) -> Vec { + logs.events() + .into_iter() + .map(|e| format!("[{:?}] {}", e.level, e.message)) + .collect() +} + fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { ReplayOutcome { uid: fx.uid.clone(), @@ -153,31 +256,40 @@ fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { } } -/// Classify an `Ok(())` replay by inspecting the mock's recorded -/// side effects, independent of the strategy's own logging. +/// A failed replay is an observe-contract violation when the strategy +/// itself returned Ok but touched the pool wrongly; otherwise a +/// strategy error. +fn classify_err(venues: &RecordingVenues, detail: String) -> Classification { + if venues + .calls() + .iter() + .any(|c| !matches!(c, Call::Observe(..))) + { + return Classification::RejectedUnexpected(format!( + "strategy called a non-observe venue verb; observe-only must never submit ({detail})" + )); + } + if detail.starts_with("expected exactly one observe") + || detail.starts_with("watch routed to venue") + { + return Classification::RejectedUnexpected(detail); + } + Classification::StrategyError(detail) +} + +/// Classify an `Ok` replay by inspecting the recorded side effects, +/// independent of the strategy's own logging. /// /// `Observed` demands the full observe contract, not just the marker: -/// exactly one orderbook call, shaped `GET /api/v1/orders/{uid}`, -/// zero `submit_order` attempts, and the `observed:{uid}` store key. -/// The exact-UID match (never an `observed:` prefix scan) means a -/// compute_uid divergence between module and collector cannot produce -/// a false `Observed`. -fn classify_ok(host: &MockHost, uid: &str, log_lines: &[String]) -> Classification { - if host.cow_api.call_count() > 0 { - return Classification::RejectedUnexpected( - "strategy called submit_order; observe+verify must never submit".into(), - ); - } - let requests = host.cow_api.request_calls(); - let expected_path = format!("/api/v1/orders/{uid}"); - if requests.len() != 1 || requests[0].method != "GET" || requests[0].path != expected_path { - let shapes: Vec = requests - .iter() - .map(|r| format!("{} {}", r.method, r.path)) - .collect(); +/// the one watch's receipt renders to exactly the fixture UID (never a +/// prefix scan, so a compute_uid divergence between module and +/// collector cannot produce a false `Observed`), and the delivered +/// transition wrote the `observed:{uid}` store key. +fn classify_ok(host: &MockHost, receipt: &[u8], uid: &str, log_lines: &[String]) -> Classification { + let receipt_hex = format!("0x{}", hex::encode(receipt)); + if receipt_hex != uid { return Classification::RejectedUnexpected(format!( - "expected exactly one GET {expected_path}; saw [{}]", - shapes.join(", ") + "watched receipt {receipt_hex} does not match the collector UID" )); } if host diff --git a/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs index 9e64b9fd..a27b28ae 100644 --- a/crates/shepherd-backtest/src/report.rs +++ b/crates/shepherd-backtest/src/report.rs @@ -37,11 +37,12 @@ pub fn render(fx: &Fixtures, outcomes: &[ReplayOutcome], threshold: f64) -> Stri )); out.push_str( "Replays every collected EthFlow `OrderPlacement` event through the production \ - `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. \ - The orderbook is **never hit**: the MockHost programs a catch-all 200 for all \ - `cow_api_request` calls so the observe+verify strategy sees every fixture as \ - already indexed. Success is measured by whether the strategy wrote the exact \ - `observed:{uid}` marker to the local store after the 200 confirmation.\n\n", + `ethflow_watcher::strategy` pair (`on_chain_logs` then `on_intent_status`) over \ + `nexum_sdk_test::MockHost` and a recording pool transport. The orderbook is \ + **never hit**: the transport accepts every watch and the replay delivers the \ + `open` transition the registry would poll for it, so every fixture reads as \ + indexed. Success is measured by whether the strategy watched exactly the \ + fixture UID and wrote the exact `observed:{uid}` marker on the transition.\n\n", ); out.push_str("## Run metadata\n\n"); out.push_str("| Field | Value |\n|---|---|\n"); diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml index a5113911..47ba5e1e 100644 --- a/crates/shepherd-cow-host/Cargo.toml +++ b/crates/shepherd-cow-host/Cargo.toml @@ -50,4 +50,3 @@ toml.workspace = true tokio.workspace = true wiremock.workspace = true tempfile.workspace = true -alloy-rpc-types-eth.workspace = true diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 3953ab6a..b195b537 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use alloy_chains::Chain; use nexum_runtime::bindings::nexum; use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; use nexum_runtime::host::component::{Components, RuntimeTypes}; @@ -144,38 +143,6 @@ async fn boot_production_module( .expect("boot_single") } -/// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; -/// it boots with the cow extension and a synthetic log is delivered. -#[tokio::test] -async fn e2e_ethflow_watcher_log_dispatch() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { - return; - }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.alive_count(), 1); - - // A log with an unrecognised topic is silently skipped by the module's - // decoder (returns `None` from `decode_order_placement`), so the test - // only proves: supervisor delivered, module did not trap, module stayed - // alive. - let synthetic_log = alloy_rpc_types_eth::Log::default(); - let dispatched = supervisor - .dispatch_chain_log( - "ethflow-watcher", - Chain::from_id(SEPOLIA), - synthetic_log, - None, - ) - .await; - assert!(dispatched); - assert_eq!(supervisor.alive_count(), 1); -} - /// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow /// extension and a block dispatch reaches it. #[tokio::test] @@ -195,17 +162,17 @@ async fn e2e_stop_loss_block_dispatch() { } /// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (ethflow-watcher) must -/// NOT boot when the cow extension is absent from the linker AND the +/// a module that imports `shepherd:cow/cow-api` (stop-loss) must NOT +/// boot when the cow extension is absent from the linker AND the /// capability registry. The paired linker-hook + capability-namespace /// registration is what makes the same module boot in the tests above; /// drop the pairing and boot fails. #[tokio::test] -async fn ethflow_watcher_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { +async fn stop_loss_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("stop-loss") else { return; }; - let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); + let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); let engine = make_wasmtime_engine(); // Core-only: no cow linker hook, no cow capability namespace. let linker = build_linker::(&engine, &[]).expect("build_linker"); diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index c07618a9..06e2b887 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -157,7 +157,7 @@ mod value_flow_smoke { /// client interface and, transitively, the types interface and its /// value-flow dependency. The test names every generated type, case, and /// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the four function signatures, so a keyword collision or an accidental +/// the five function signatures, so a keyword collision or an accidental /// signature change fails this build rather than a downstream binding. #[cfg(test)] mod client_smoke { @@ -193,6 +193,10 @@ mod client_smoke { Err(VenueError::UnknownVenue) } + fn observe(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + fn status( &mut self, _venue: String, @@ -264,6 +268,7 @@ mod client_smoke { let mut client = DummyClient; assert!(client.quote(String::new(), Vec::new()).is_err()); assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.observe(String::new(), Vec::new()).is_err()); assert!(client.status(String::new(), Vec::new()).is_err()); assert!(client.cancel(String::new(), Vec::new()).is_err()); } diff --git a/crates/videre-host/src/client.rs b/crates/videre-host/src/client.rs index 2973b01e..cbe27e70 100644 --- a/crates/videre-host/src/client.rs +++ b/crates/videre-host/src/client.rs @@ -38,6 +38,10 @@ impl Host for HostState { .await } + async fn observe(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + registry(self)?.observe(&VenueId::from(venue), receipt) + } + async fn status( &mut self, venue: String, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index d872d804..88a44392 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -7,7 +7,9 @@ //! header, run the guard interposition seam on it (advisory-only for now: //! see [`EgressGuard`]), and only then submit. //! Status and cancel are pass-throughs; they are not submissions, so they -//! skip the header, the guard, and the quota. +//! skip the header, the guard, and the quota. Observe puts an +//! externally-obtained receipt under status watch without touching the +//! adapter at all. //! //! Invocation is serialised per adapter through the supervised-actor //! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent @@ -320,8 +322,8 @@ struct VenueRegistryInner { ledger: Mutex, watch_limit: WatchLimit, /// Receipts under status watch, appended by accepted submissions and - /// pruned as they reach a terminal status, expire, or overflow - /// [`WatchLimit`]. + /// explicit observes, pruned as they reach a terminal status, expire, + /// or overflow [`WatchLimit`]. watched: Mutex>, } @@ -483,11 +485,26 @@ impl VenueRegistry { adapter.quote(&body).await } - /// Put a `(venue, receipt)` pair under status watch. Idempotent: a - /// re-submitted receipt keeps its existing watch entry. Bounded: - /// expired entries evict first, and at the cap the new watch is - /// refused and logged rather than an existing live watch dropped. - fn watch(&self, venue: &VenueId, receipt: Vec) { + /// Put an externally-obtained `(venue, receipt)` pair under status + /// watch: the `observe` half of the client face, for receipts the + /// registry never submitted (an accepted submit is watched implicitly). + /// Not a submission: no header, no guard, no quota; the watch cap + /// bounds it. Idempotent. + pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { + let _ = self.resolve(venue)?; + if self.watch(venue, receipt) { + Ok(()) + } else { + Err(VenueError::Unavailable("status watch set full".to_owned())) + } + } + + /// Put a `(venue, receipt)` pair under status watch, reporting + /// whether it is watched. Idempotent: a re-submitted receipt keeps + /// its existing watch entry. Bounded: expired entries evict first, + /// and at the cap the new watch is refused and logged rather than + /// an existing live watch dropped. + fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { let (evicted, admitted) = { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); let evicted = prune_expired(&mut watched); @@ -517,6 +534,7 @@ impl VenueRegistry { "status watch set full - transitions for this receipt will not be reported", ); } + admitted } /// Number of receipts currently under status watch. @@ -1461,6 +1479,82 @@ mod tests { assert_eq!(registry.watched_count(), 1); } + #[tokio::test] + async fn observe_watches_an_externally_obtained_receipt() { + let calls = Arc::new(StubCalls::default()); + let adapter = + StubAdapter::new(calls.clone()).with_status_script([Ok(IntentStatus::Fulfilled)]); + let registry = registry_with(SubmitQuota::default(), None, adapter); + + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe succeeds"); + // Re-observing keeps the existing entry. + registry + .observe(&cow(), b"onchain".to_vec()) + .expect("observe is idempotent"); + assert_eq!(registry.watched_count(), 1); + // No adapter work happened at observe time. + assert_eq!(calls.status.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + + // The watch polls like a submitted one: the terminal status + // reports once and prunes the entry. + let updates = registry.poll_status_transitions().await; + assert_eq!(updates.len(), 1); + assert_eq!(updates[0].receipt, b"onchain"); + assert_eq!(decoded(&updates[0]), plain(Lifecycle::Fulfilled)); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_rejects_an_unknown_venue() { + let registry = registry_with( + SubmitQuota::default(), + None, + StubAdapter::new(Arc::new(StubCalls::default())), + ); + assert!(matches!( + registry.observe(&VenueId::from("unlisted"), b"r".to_vec()), + Err(VenueError::UnknownVenue) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_of_a_dead_venue_is_unavailable() { + let liveness = Liveness::default(); + let registry = VenueRegistryBuilder::new(SubmitQuota::default()).build(); + registry + .install( + cow(), + liveness.clone(), + StubAdapter::new(Arc::new(StubCalls::default())), + ) + .expect("install adapter"); + liveness.mark_dead(); + assert!(matches!( + registry.observe(&cow(), b"r".to_vec()), + Err(VenueError::Unavailable(_)) + )); + assert_eq!(registry.watched_count(), 0); + } + + #[test] + fn observe_at_the_watch_cap_is_refused_typedly() { + let limit = WatchLimit::new(1, Duration::from_secs(3600)); + let registry = + watch_bounded_registry(limit, StubAdapter::new(Arc::new(StubCalls::default()))); + + registry.observe(&cow(), b"a".to_vec()).expect("admitted"); + let err = registry + .observe(&cow(), b"b".to_vec()) + .expect_err("overflow refused"); + assert!(matches!(err, VenueError::Unavailable(_))); + // The live watch is kept; the overflow was refused. + assert_eq!(registry.watched_count(), 1); + } + #[tokio::test] async fn requires_signing_outcome_is_not_watched() { let calls = Arc::new(StubCalls::default()); diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 9ed8c2d7..ff3506f2 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -415,6 +415,44 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { ); } +/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots on the venue +/// platform with its shipped manifest and handles a delivered cow status +/// transition without trapping. +#[tokio::test] +async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { + return; + }; + let manifest = workspace_path("modules/ethflow-watcher/module.toml"); + let videre = Arc::new(platform(&EngineConfig::default())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert_eq!(supervisor.alive_count(), 1); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + let update = videre_host::IntentStatusUpdate { + venue: "cow".to_owned(), + receipt: vec![0xAB; 56], + status: videre_status_body::StatusBody { + status: videre_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(update)) + .await, + 1 + ); + assert_eq!(supervisor.alive_count(), 1); +} + /// The event-loop wiring, through the real seam: the platform's `events` /// source opens against the booted service map, its poll task drives the /// supervisor, and the module's handler observably ran (its log line is diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index c3e9b1af..63ff375d 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -103,6 +103,19 @@ pub trait VenueTransport: sealed::SealedTransport { body: Vec, ) -> impl Future>; + /// Put an externally-obtained receipt under the host's status + /// watch; an accepted submit is watched implicitly. Defaults to + /// `unsupported`: a transport that can watch foreign receipts opts + /// in. + fn observe( + &self, + venue: &VenueId, + receipt: &[u8], + ) -> impl Future> { + let _ = (venue, receipt); + async { Err(VenueFault::Unsupported) } + } + /// Report where a previously submitted intent is in its life. fn status( &self, @@ -137,6 +150,10 @@ impl VenueTransport for HostVenues { shims::submit(venue.as_str(), &body).map_err(VenueFault::from) } + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + shims::observe(venue.as_str(), receipt).map_err(VenueFault::from) + } + async fn status(&self, venue: &VenueId, receipt: &[u8]) -> Result { shims::status(venue.as_str(), receipt).map_err(VenueFault::from) } @@ -208,6 +225,12 @@ impl VenueClient { Ok(self.transport.submit(&V::ID, bytes).await?) } + /// Put an externally-obtained receipt under the host's status + /// watch at the bound venue. + pub async fn observe(&self, receipt: &[u8]) -> Result<(), ClientError> { + Ok(self.transport.observe(&V::ID, receipt).await?) + } + /// Report where a previously submitted intent is in its life. pub async fn status(&self, receipt: &[u8]) -> Result { Ok(self.transport.status(&V::ID, receipt).await?) diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index e68794a2..b15e3a80 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -10,7 +10,7 @@ venue adapters. The CoW adapter fixes its orderbook chain at `init` from its own manifest `[config] chain`, and registers under the fixed venue id `cow` (`CowVenue::ID`), which carries no chain component. Two cow adapters installed in one process would therefore register under the same id, with nothing at the -pool router to tell them apart. +venue registry to tell them apart. Single-process multi-chain *submission* is an explicit non-goal for M4. Run one engine process per submitting chain, each paired with the matching adapter diff --git a/engine.docker.toml b/engine.docker.toml index e2e697a3..80331746 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -80,7 +80,7 @@ manifest = "/opt/shepherd/manifests/stop-loss.toml" # ---- adapters ---- # -# The bundled cow venue adapter: the pool router resolves the `cow` +# The bundled cow venue adapter: the venue registry resolves the `cow` # venue id through it. The operator grant scopes its outbound HTTP to # the production orderbook host. The Sepolia manifest matches # twap-monitor's Sepolia subscriptions; a mainnet run swaps in diff --git a/engine.example.toml b/engine.example.toml index be346456..69184ca4 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -103,7 +103,7 @@ rpc_url = "${BASE_RPC_URL}" # ---- adapters ---- # -# Venue-adapter components the pool router resolves venue ids through. +# Venue-adapter components the venue registry resolves venue ids through. # The operator, not the adapter author, grants the transport scope: # `http_allow` is the outbound wasi:http host allowlist. The bundled # cow adapter builds with `just build-cow-venue`; its venue id is the diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 3beb4710..eff3f0ba 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -14,7 +14,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -22,5 +23,4 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 47cc57c0..cb99e038 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -1,6 +1,6 @@ -# ethflow-watcher: see `CoWSwapEthFlow.OrderPlacement`, lift the embedded -# `GPv2OrderData` into an `OrderCreation`, and submit it via the CoW -# Protocol orderbook with the EIP-1271 signing scheme. +# ethflow-watcher: decode `CoWSwapEthFlow.OrderPlacement` logs, compute +# the orderbook UID, and put it under the host's status watch via the +# cow venue adapter. Observe-only: the module never submits. [module] name = "ethflow-watcher" @@ -10,16 +10,13 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# Least-privilege: the module exercises logging, local-store and -# cow-api today; `chain` is listed as optional so a follow-up (e.g. -# adding an eth_call to read the EthFlow refund pointer) -# can use it without manifest churn, without widening the required -# grant for a capability the module does not call yet. -required = ["logging", "local-store", "cow-api"] -optional = ["chain"] +# Least-privilege: `client` for the venue observe path, `logging` for +# structured runtime logs, `local-store` for the `observed:` journal. +required = ["client", "logging", "local-store"] +optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api`; no direct `http` calls. +# All venue I/O rides the venue registry; no direct `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ @@ -39,3 +36,9 @@ kind = "chain-log" chain_id = 11155111 address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" + +# Status transitions the registry polls for watched receipts at the cow +# venue; the module journals `observed:{uid}` on the first one. +[[subscription]] +kind = "intent-status" +venue = "cow" diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 8cca9f36..c26150b4 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -1,23 +1,21 @@ //! # ethflow-watcher (Shepherd module) //! //! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the -//! canonical CoWSwap EthFlow contracts and verifies the orderbook's -//! native indexer caught each placement via `GET /api/v1/orders/{uid}`. -//! See `strategy.rs` for the design rationale: the orderbook -//! backend indexes EthFlow `OrderPlacement` events server-side with -//! its own dual-validTo bookkeeping, so `POST /api/v1/orders` is -//! structurally the wrong endpoint for on-chain EthFlow orders. The -//! module observes and verifies, it does not submit. +//! canonical CoWSwap EthFlow contracts, computes each placement's +//! orderbook UID, and puts it under the host's status watch through the +//! typed cow venue client. The registry polls the cow adapter and fans +//! transitions back as `intent-status` events; the module journals +//! `observed:{uid}` on the first one. Observe-only: it never submits +//! (see `strategy.rs`). //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates the `chain-logs` event variant to `strategy::on_chain_logs`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` and `videre_sdk` client seams. It does not know +//! `wit-bindgen` exists. +//! - `lib.rs` (this file) is the per-cdylib glue: the +//! `#[videre_sdk::keeper]` handler impl that binds the world derived +//! from `module.toml` and delegates each event to `strategy`. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -25,59 +23,48 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -// The wit-bindgen-generated import shims only resolve against the -// engine's wasm component host - they have no native-target -// equivalent. Cfg-gate the entire glue layer so the `rlib` artefact -// (consumed by `shepherd-backtest`) carries just the -// strategy code without dangling `extern "C"` imports. The -// `use wit_bindgen as _` line below silences the unused-crate -// lint on native targets where the macro never expands. +// The keeper glue only resolves against the engine's wasm component +// host. Cfg-gate it so the `rlib` artefact (consumed by +// `shepherd-backtest`) carries just the strategy code without dangling +// `extern "C"` imports; the `use wit_bindgen as _` line silences the +// unused-crate lint on native targets where the macro never expands. #[cfg(not(target_arch = "wasm32"))] use wit_bindgen as _; -#[cfg(target_arch = "wasm32")] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - pub mod strategy; -// `WitBindgenHost` and the fault and level `From` impls -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -// Gated on `wasm32` so the strategy can be reused in native targets -// (e.g. the backtest replay harness in `crates/shepherd-backtest`). #[cfg(target_arch = "wasm32")] -use nexum::host::types; +mod glue { + use cow_venue::client::CowClient; -#[cfg(target_arch = "wasm32")] -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); + use crate::strategy; -#[cfg(target_arch = "wasm32")] -struct EthFlowWatcher; + struct EthFlowWatcher; -#[cfg(target_arch = "wasm32")] -impl Guest for EthFlowWatcher { - fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - tracing::info!("ethflow-watcher init"); - Ok(()) - } + #[videre_sdk::keeper] + impl EthFlowWatcher { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + tracing::info!("ethflow-watcher init"); + Ok(()) + } - fn on_event(event: types::Event) -> Result<(), Fault> { - if let types::Event::ChainLogs(batch) = event { + async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, batch.chain_id, &logs)?; + strategy::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + .await?; + Ok(()) + } + + fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { + strategy::on_intent_status( + &WitBindgenHost, + &update.venue, + &update.receipt, + &update.status, + )?; + Ok(()) } - // Block / Tick / Message are not used by this module. - Ok(()) } } - -#[cfg(target_arch = "wasm32")] -export!(EthFlowWatcher); diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 4d24db00..69456554 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -1,11 +1,11 @@ //! Pure strategy logic for the ethflow-watcher module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`]; tests under `#[cfg(test)]` -//! drive the same function with `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through two seams: the +//! `nexum_sdk::host` traits for the `observed:` journal and the typed +//! [`CowClient`] over [`VenueTransport`] for the venue. The `lib.rs` +//! glue binds both to the module's own imports; tests drive the same +//! functions with `nexum_sdk_test::MockHost` and an in-memory spy +//! transport. //! //! ## Design (redesign) //! @@ -13,36 +13,36 @@ //! to `/api/v1/orders` with the EthFlow contract as the EIP-1271 owner. //! Empirical evidence (2026-06-22 Sepolia soak) showed that path cannot //! succeed: the orderbook backend indexes EthFlow `OrderPlacement` -//! events natively and writes server-only fields (`onchainUser`, -//! `onchainOrderData`, `ethflowData.userValidTo`) the public POST body -//! does not carry. Submissions through `/api/v1/orders` are rejected -//! with `ExcessiveValidTo` even though the same UID is `fulfilled` on -//! the orderbook by the time we look. +//! events natively and writes server-only fields the public POST body +//! does not carry. //! -//! This strategy therefore **observes + verifies** instead of -//! submitting: +//! This strategy therefore **observes + verifies** through the venue +//! registry: //! //! 1. Decode the `OrderPlacement` log against the canonical EthFlow //! contract addresses. -//! 2. Compute the orderbook UID from the on-chain order shape -//! (`OrderData::uid(domain, contract)`). -//! 3. GET `/api/v1/orders/{uid}` to confirm the orderbook indexer -//! picked up the placement. On 200, record `observed:{uid}` in the -//! keeper idempotency journal so log re-delivery is a no-op. On -//! 404, log at Info - typical indexer lag, do not write the marker -//! so the next re-delivery rechecks. Any other error is logged at -//! Warn for operator follow-up. +//! 2. Compute the orderbook UID from the on-chain order shape: the +//! externally-obtained receipt at the cow venue. +//! 3. `observe` the receipt through the venue registry, which polls the +//! cow adapter's `status` and fans transitions back as +//! `intent-status` events. On the first one, record `observed:{uid}` +//! in the keeper idempotency journal so log re-delivery is a no-op. +//! A refused observe is logged and left unjournalled, so the next +//! re-delivery retries. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolEvent; +use cow_venue::assembly; +use cow_venue::client::{CowClient, CowVenue}; use cowprotocol::{ Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, OnchainSignature, OrderUid, }; use nexum_sdk::events::Log; -use nexum_sdk::host::Fault; +use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::Journal; -use shepherd_sdk::cow::{CowApiError, CowHost, events, gpv2_to_order_data}; +use videre_sdk::client::{Venue, VenueTransport}; +use videre_sdk::status_body::StatusBody; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays @@ -70,16 +70,51 @@ pub(crate) struct DecodedPlacement { } /// Entry point: decode every `OrderPlacement` chain-log in a dispatch batch -/// and feed each decoded placement to the observe path. -pub fn on_chain_logs(host: &H, chain_id: u64, logs: &[Log]) -> Result<(), Fault> { +/// and put each decoded placement's UID under the host's status watch. +pub async fn on_chain_logs( + host: &H, + venue: &CowClient, + chain_id: u64, + logs: &[Log], +) -> Result<(), Fault> { for log in logs { if let Some(placement) = decode_order_placement(log) { - observe_placement(host, chain_id, &placement)?; + observe_placement(host, venue, chain_id, &placement).await?; } } Ok(()) } +/// A registry status transition for a watched receipt. Foreign venues +/// are ignored; the first transition for a cow receipt records the +/// `observed:{uid}` marker (the orderbook indexed the placement), and +/// every transition is logged. +pub fn on_intent_status( + host: &H, + venue: &str, + receipt: &[u8], + status: &[u8], +) -> Result<(), Fault> { + if venue != CowVenue::ID.as_str() { + return Ok(()); + } + let Ok(uid) = OrderUid::try_from(receipt) else { + tracing::warn!( + "ethflow status update with a non-uid receipt ({} bytes)", + receipt.len(), + ); + return Ok(()); + }; + let body = StatusBody::decode(status).map_err(|e| Fault::InvalidInput(e.to_string()))?; + let uid_hex = format!("{uid}"); + let journal = Journal::observed(host); + if !journal.contains(&uid_hex)? { + journal.record(&uid_hex)?; + } + tracing::info!("ethflow observed {uid_hex}: {:?}", body.status); + Ok(()) +} + // ---- decode ---- /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. @@ -96,7 +131,7 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { return None; } - if log.topics().first() != Some(&events::ORDER_PLACEMENT.topic0) { + if log.topics().first() != Some(&OrderPlacement::SIGNATURE_HASH) { return None; } let decoded = OrderPlacement::decode_log(&log.inner).ok()?; @@ -109,57 +144,43 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { }) } -// ---- observe + verify (redesign) ---- +// ---- observe + verify (venue registry) ---- -/// Compute the orderbook UID for the placement and confirm the -/// orderbook's native EthFlow indexer picked it up. -fn observe_placement( +/// Compute the orderbook UID for the placement and put it under the +/// host's status watch. A refused observe (venue down, watch set full) +/// is logged and left unjournalled, so re-delivery retries. +async fn observe_placement( host: &H, + venue: &CowClient, chain_id: u64, placement: &DecodedPlacement, ) -> Result<(), Fault> { - let uid_hex = match compute_uid(chain_id, placement) { - Some(uid) => format!("{uid}"), - None => { - tracing::warn!( - "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", - placement.sender, - ); - return Ok(()); - } + let Some(uid) = compute_uid(chain_id, placement) else { + tracing::warn!( + "ethflow uid build skipped (sender={:#x}): unsupported chain {chain_id} or unknown order marker", + placement.sender, + ); + return Ok(()); }; + let uid_hex = format!("{uid}"); - // Idempotency: once verified, do not re-check on log re-delivery + // Idempotency: once observed, do not re-watch on log re-delivery // (engine restart, reorg replay, supervisor restart). let journal = Journal::observed(host); if journal.contains(&uid_hex)? { return Ok(()); } - let path = format!("/api/v1/orders/{uid_hex}"); - match host.cow_api_request(chain_id, "GET", &path, None) { - Ok(_) => { - journal.record(&uid_hex)?; + match venue.observe(uid.as_slice()).await { + Ok(()) => { tracing::info!( - "ethflow observed {uid_hex} (orderbook indexed, sender={:#x})", - placement.sender, - ); - } - Err(CowApiError::Http(http)) if http.status == 404 => { - // Indexer lag is expected immediately after the block lands - - // shepherd's WebSocket can deliver the log a few hundred - // milliseconds before the orderbook's own indexer commits. - // Do NOT write the marker so a later re-delivery (or a future - // block-tick poll) can recheck. Info keeps the soak dashboard - // quiet on normal lag. - tracing::info!( - "ethflow not yet indexed {uid_hex} (sender={:#x}); will recheck on re-delivery", + "ethflow watching {uid_hex} (sender={:#x})", placement.sender, ); } Err(err) => { tracing::warn!( - "ethflow indexer check failed {uid_hex}: {err} (sender={:#x})", + "ethflow watch failed {uid_hex}: {err} (sender={:#x})", placement.sender, ); } @@ -168,30 +189,140 @@ fn observe_placement( } /// Compute the canonical 56-byte orderbook UID for the placement. -/// `OrderData::uid` packs `digest || owner || valid_to`; the owner -/// input is the EthFlow contract (which signs via EIP-1271), not the -/// native-token sender. +/// The UID packs `digest || owner || valid_to`; the owner input is the +/// EthFlow contract (which signs via EIP-1271), not the native-token +/// sender. fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(&placement.order)?; - Some(order_data.uid(&domain, placement.contract)) + let order = assembly::gpv2_to_order_data(&placement.order)?; + Some(assembly::order_uid(chain, &order, placement.contract)) } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; - use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::HttpFailure; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::LocalStoreHost as _; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::VenueId; + use videre_sdk::rt::complete; + use videre_sdk::status_body::IntentStatus as Lifecycle; + use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// One recorded transport call: which verb, and for `observe` the + /// routed venue and receipt. + #[derive(Clone, Debug, Eq, PartialEq)] + enum Call { + Quote, + Submit, + Observe(String, Vec), + Status, + Cancel, + } + + /// Records every call; `observe` pops a scripted response and + /// defaults to accepted once the script drains. The other verbs + /// refuse: the observe-only strategy must never reach them. + /// Cloneable over shared state so the test keeps a handle after + /// one moves into the client. + #[derive(Clone, Default)] + struct SpyVenues { + calls: Rc>>, + observe_script: Rc>>>, + } + + impl SpyVenues { + fn script_observe(&self, result: Result<(), VenueFault>) { + self.observe_script.borrow_mut().push_back(result); + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + fn observe_count(&self) -> usize { + self.calls + .borrow() + .iter() + .filter(|c| matches!(c, Call::Observe(..))) + .count() + } + } + + impl videre_sdk::client::sealed::SealedTransport for SpyVenues {} + + impl VenueTransport for SpyVenues { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + self.calls.borrow_mut().push(Call::Quote); + Err(VenueFault::Unsupported) + } + + async fn submit( + &self, + _venue: &VenueId, + _body: Vec, + ) -> Result { + self.calls.borrow_mut().push(Call::Submit); + Err(VenueFault::Unsupported) + } + + async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { + self.calls + .borrow_mut() + .push(Call::Observe(venue.to_string(), receipt.to_vec())); + self.observe_script + .borrow_mut() + .pop_front() + .unwrap_or(Ok(())) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + self.calls.borrow_mut().push(Call::Status); + Err(VenueFault::Unsupported) + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + self.calls.borrow_mut().push(Call::Cancel); + Err(VenueFault::Unsupported) + } + } + + /// Drive the async strategy on the synchronous test boundary. + fn run_logs( + host: &MockHost, + spy: &SpyVenues, + chain_id: u64, + logs: &[Log], + ) -> Result<(), Fault> { + let client = CowClient::with_transport(spy.clone()); + complete(on_chain_logs(host, &client, chain_id, logs)) + .expect("guest futures complete in one poll") + } + + fn open_status() -> Vec { + StatusBody { + status: Lifecycle::Open, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes") + } + fn sample_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), @@ -246,11 +377,14 @@ mod tests { .into() } - fn computed_uid(placement: &DecodedPlacement) -> String { - format!( - "{}", - compute_uid(SEPOLIA, placement).expect("sepolia + canonical markers") - ) + fn sample_log() -> Log { + let (topics, data) = encode_log(&sample_event()); + make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data) + } + + fn sample_uid() -> OrderUid { + let placement = decode_order_placement(&sample_log()).expect("decode succeeds"); + compute_uid(SEPOLIA, &placement).expect("sepolia + canonical markers") } // ---- decode (invariants preserved) ---- @@ -258,9 +392,7 @@ mod tests { #[test] fn decodes_well_formed_placement() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).expect("decode succeeds"); + let decoded = decode_order_placement(&sample_log()).expect("decode succeeds"); assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); assert_eq!(decoded.sender, event.sender); assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); @@ -294,11 +426,7 @@ mod tests { #[test] fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); - - let uid = compute_uid(SEPOLIA, &decoded).expect("sepolia + canonical markers"); + let uid = sample_uid(); let bytes: [u8; 56] = uid.into(); // owner suffix (bytes 32..52) = EthFlow contract address. assert_eq!(&bytes[32..52], ETH_FLOW_PRODUCTION.as_slice()); @@ -311,273 +439,202 @@ mod tests { #[test] fn compute_uid_returns_none_on_unsupported_chain() { - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let decoded = decode_order_placement(&log).unwrap(); + let decoded = decode_order_placement(&sample_log()).unwrap(); assert!(compute_uid(9999, &decoded).is_none()); } - // ---- observe + verify dispatch (Host-trait integration) ---- + // ---- observe via the venue registry (transport integration) ---- - /// 200 from `GET /api/v1/orders/{uid}` → `observed:{uid}` written - /// + Info log + zero submit attempts. + /// A placement registers exactly one status watch at the cow venue + /// with the computed UID as the receipt, and journals nothing yet: + /// the marker waits for the first status transition. #[test] - fn placement_log_marks_observed_on_orderbook_200() { + fn placement_log_registers_the_uid_watch() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - // Minimal stub of the orderbook's GET response - strategy only - // checks for 200 vs 404 vs other, the body is opaque to it. - host.cow_api.respond_to_request_for( - "GET", - format!("/api/v1/orders/{uid}"), - Ok(r#"{"status":"fulfilled"}"#.to_string()), - ); + let spy = SpyVenues::default(); + let uid = sample_uid(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 response must write observed:{{uid}} marker" - ); assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one orderbook GET per log" + spy.calls(), + vec![Call::Observe("cow".to_owned(), uid.as_slice().to_vec())], + "exactly one observe, nothing else", ); - assert_eq!( - host.cow_api.call_count(), - 0, - "observe path must never call submit_order" - ); - } - - /// 404 from `GET /api/v1/orders/{uid}` → no marker written + Info - /// log + the next re-delivery rechecks (no early dedup). - #[test] - fn placement_log_does_not_mark_observed_on_orderbook_404() { - let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); - - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "404 must NOT write observed: so re-delivery can recheck" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - let ev = logs.expect_one(|e| e.message.contains("not yet indexed")); - assert_eq!( - ev.level, - Level::INFO, - "indexer lag is expected; Info keeps soak dashboards quiet" + host.store.snapshot().is_empty(), + "observed:{{uid}} waits for the first status transition", ); } - /// Non-404 error from the orderbook check → Warn log + no marker. + /// A refused observe warns, journals nothing, and the next + /// re-delivery retries the watch. #[test] - fn placement_log_warns_on_orderbook_other_error() { + fn watch_refusal_warns_and_redelivery_retries() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - - host.cow_api - .respond_to_request(Err(CowApiError::Fault(Fault::Unavailable( - "bad gateway".into(), - )))); + let spy = SpyVenues::default(); + spy.script_observe(Err(VenueFault::Unavailable("venue down".to_owned()))); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, SEPOLIA, &[sample_log()])); result.unwrap(); - assert!( - host.store.snapshot().is_empty(), - "non-404 error must not write any marker" - ); - assert_eq!( - host.cow_api.request_calls().len(), - 1, - "the orderbook GET was attempted" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("watch failed")); + + // Unjournalled, so the re-delivered log observes again. + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); + assert_eq!(spy.observe_count(), 2); } /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT trigger a fresh GET on re-delivery. + /// local store does NOT touch the venue on re-delivery. #[test] fn previously_observed_placement_is_skipped_on_redelivery() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let spy = SpyVenues::default(); + let uid = sample_uid(); host.store .set(&format!("observed:{uid}"), b"") .expect("seed observed marker"); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "observed:{{uid}} must short-circuit before the orderbook GET" - ); - assert_eq!( - host.cow_api.call_count(), - 0, - "and certainly no submit_order" + assert!( + spy.calls().is_empty(), + "observed:{{uid}} must short-circuit before the venue call", ); } /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the orderbook. + /// panic and does not touch the venue. #[test] - fn unsupported_chain_logs_warn_without_orderbook_call() { + fn unsupported_chain_logs_warn_without_venue_call() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let spy = SpyVenues::default(); // 9999 is not in cowprotocol::Chain. - let (result, logs) = capture_tracing(|| on_chain_logs(&host, 9999, &[log])); + let (result, logs) = capture_tracing(|| run_logs(&host, &spy, 9999, &[sample_log()])); result.unwrap(); - assert_eq!(host.cow_api.request_calls().len(), 0); - assert_eq!(host.cow_api.call_count(), 0); + assert!(spy.calls().is_empty()); assert!(host.store.snapshot().is_empty()); logs.expect_one(|e| { e.level == Level::WARN && e.message.contains("ethflow uid build skipped") }); } - /// Strategy must never call `submit_order` - the trait still - /// exposes it for other modules (twap-monitor legitimately - /// submits), but ethflow-watcher's observe design never does. - /// Belt-and-suspenders regression guard. + /// The strategy is observer-only: no call path reaches quote, + /// submit, status, or cancel. Belt-and-suspenders regression guard. #[test] - fn strategy_never_calls_submit_order() { + fn strategy_never_submits() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - host.cow_api.respond_to_request(Ok("{}".to_string())); + let spy = SpyVenues::default(); - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + run_logs(&host, &spy, SEPOLIA, &[sample_log()]).unwrap(); - assert_eq!( - host.cow_api.call_count(), - 0, - "submit_order count must stay at zero - ethflow-watcher is observer-only" + assert!( + spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), + "observe is the only verb the strategy may use", ); } - /// Guard: the `sol!` decoder's topic-0 matches the - /// `shepherd:cow/cow-events` package of record. A typo or ABI - /// drift would silently miss every EthFlow event. - #[test] - fn topic0_matches_order_placement_canonical_signature() { - assert_eq!( - OrderPlacement::SIGNATURE_HASH, - events::ORDER_PLACEMENT.topic0, - "sol! topic-0 must match the shepherd:cow/cow-events pin", - ); - } + // ---- intent-status transitions ---- - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals the package-of-record topic-0 - catches a manifest/code - /// drift the decoder assertion cannot see. + /// The first cow transition journals `observed:{uid}` and logs it. #[test] - fn manifest_topic0_matches_order_placement_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("{:#x}", events::ORDER_PLACEMENT.topic0); + fn status_update_journals_the_observed_marker() { + let host = MockHost::new(); + let uid = sample_uid(); + + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", uid.as_slice(), &open_status())); + result.unwrap(); + assert!( - manifest.contains(&expected), - "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", + host.store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "the first transition must write observed:{{uid}}", ); + let ev = logs.expect_one(|e| e.message.contains("ethflow observed")); + assert_eq!(ev.level, Level::INFO); } - /// 429 (rate-limit) from the orderbook check → Warn log + no marker. - /// Verifies the strategy does not conflate 429 with 404 (which would - /// suppress the warning) and does not panic or return an error. + /// Later transitions keep the single marker and stay Ok. #[test] - fn placement_log_warns_on_429_rate_limit() { + fn repeated_transitions_keep_one_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let uid = sample_uid(); - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 429, - body: Some("Too Many Requests".to_string()), - }))); + on_intent_status(&host, "cow", uid.as_slice(), &open_status()).unwrap(); + let fulfilled = StatusBody { + status: Lifecycle::Fulfilled, + proof: None, + reason: None, + } + .encode() + .expect("status body encodes"); + on_intent_status(&host, "cow", uid.as_slice(), &fulfilled).unwrap(); - let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); - result.unwrap(); + assert_eq!(host.store.snapshot().len(), 1); + } - assert!( - !host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "429 must NOT write observed: marker" - ); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + /// A transition from a foreign venue is not ethflow's: ignored. + #[test] + fn foreign_venue_status_update_is_ignored() { + let host = MockHost::new(); + on_intent_status(&host, "echo-venue", sample_uid().as_slice(), &open_status()).unwrap(); + assert!(host.store.snapshot().is_empty()); } - /// HTTP 200 with a malformed (non-JSON) body → `observed:{uid}` still - /// written. The strategy only inspects Ok vs Err, never parses the body, - /// so any successful response confirms indexer pickup regardless of body - /// content. + /// A cow receipt that is not a 56-byte UID warns without a marker. #[test] - fn placement_log_marks_observed_on_malformed_response_body() { + fn non_uid_receipt_warns_without_marker() { let host = MockHost::new(); - let event = sample_event(); - let (topics, data) = encode_log(&event); - let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); - let placement = decode_order_placement(&log).unwrap(); - let uid = computed_uid(&placement); + let (result, logs) = + capture_tracing(|| on_intent_status(&host, "cow", b"abc", &open_status())); + result.unwrap(); + assert!(host.store.snapshot().is_empty()); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("non-uid receipt")); + } - host.cow_api - .respond_to_request(Ok("not-valid-json{{{{".to_string())); + /// A status body the SDK cannot decode is a typed fault, never a + /// silent marker. + #[test] + fn malformed_status_body_is_a_typed_fault() { + let host = MockHost::new(); + let err = on_intent_status(&host, "cow", sample_uid().as_slice(), &[0xFF, 0x00]) + .expect_err("undecodable status body"); + assert!(matches!(err, Fault::InvalidInput(_))); + assert!(host.store.snapshot().is_empty()); + } - on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + // ---- package-of-record parity ---- + /// Guard: the `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` package of record. A typo or ABI + /// drift would silently miss every EthFlow event. + #[test] + fn topic0_matches_the_cow_events_package_of_record() { + let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); assert!( - host.store - .snapshot() - .contains_key(&format!("observed:{uid}")), - "200 with malformed body must still write observed: — strategy does not parse the response", + wit.contains(&expected), + "sol! topic-0 must match the shepherd:cow/cow-events pin ({expected})", + ); + } + + /// Read the shipped `module.toml` and assert its pinned + /// `event_signature` equals the decoder topic-0 - catches a + /// manifest/code drift the wit assertion cannot see. + #[test] + fn manifest_topic0_matches_order_placement_signature_hash() { + let manifest = include_str!("../module.toml"); + let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); + assert!( + manifest.contains(&expected), + "module.toml event_signature must equal the decoder topic-0 ({expected})", ); } } diff --git a/wit/videre-venue/venue.wit b/wit/videre-venue/venue.wit index 33e73f10..8a9685f2 100644 --- a/wit/videre-venue/venue.wit +++ b/wit/videre-venue/venue.wit @@ -7,6 +7,10 @@ interface client { quote: func(venue: string, body: list) -> result; submit: func(venue: string, body: list) -> result; + /// Put an externally-obtained receipt (e.g. an on-chain placement) + /// under the host's status watch; an accepted submit is watched + /// implicitly. Idempotent. + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; status: func(venue: string, receipt: receipt) -> result; cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; } From 12106d1ea5424d418f8dc0632506320bc6a955df Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 10:12:18 +0000 Subject: [PATCH 078/139] build: delete the offline backtest harness (#562) --- .dockerignore | 3 - Cargo.lock | 16 - Cargo.toml | 5 +- Dockerfile | 2 +- crates/nexum-sdk/Cargo.toml | 2 +- crates/nexum-sdk/src/address.rs | 4 +- crates/shepherd-backtest/Cargo.toml | 27 - crates/shepherd-backtest/src/fixtures.rs | 109 - crates/shepherd-backtest/src/main.rs | 139 - crates/shepherd-backtest/src/replay.rs | 306 - crates/shepherd-backtest/src/report.rs | 239 - docs/00-overview.md | 3 +- docs/operations/load-testnet-runbook.md | 5 +- modules/ethflow-watcher/Cargo.toml | 7 +- modules/ethflow-watcher/src/lib.rs | 8 +- tools/backtest-collect/backtest_collect.py | 578 - .../backtest-collect/fixtures-2026-06-22.json | 9873 ----------------- tools/orderbook-mock/src/main.rs | 4 +- 18 files changed, 18 insertions(+), 11312 deletions(-) delete mode 100644 crates/shepherd-backtest/Cargo.toml delete mode 100644 crates/shepherd-backtest/src/fixtures.rs delete mode 100644 crates/shepherd-backtest/src/main.rs delete mode 100644 crates/shepherd-backtest/src/replay.rs delete mode 100644 crates/shepherd-backtest/src/report.rs delete mode 100644 tools/backtest-collect/backtest_collect.py delete mode 100644 tools/backtest-collect/fixtures-2026-06-22.json diff --git a/.dockerignore b/.dockerignore index 7f9e68cd..248ee7db 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,9 +14,6 @@ target/ /data/ data/ -# Backtest tooling output: large JSON fixtures + Python venv state. -# Re-collected on demand via `tools/backtest-collect/backtest_collect.py`. -tools/backtest-collect/fixtures-*.json tools/baseline-latency/data/ tools/**/__pycache__/ tools/**/*.pyc diff --git a/Cargo.lock b/Cargo.lock index eedd4359..45d44d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5189,22 +5189,6 @@ dependencies = [ "videre-host", ] -[[package]] -name = "shepherd-backtest" -version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "cow-venue", - "ethflow-watcher", - "hex", - "nexum-sdk", - "nexum-sdk-test", - "serde", - "serde_json", - "videre-sdk", -] - [[package]] name = "shepherd-cow-host" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 327bc2dc..421a2529 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ members = [ "crates/nexum-world", "crates/no-std-probe", "crates/shepherd", - "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", "crates/shepherd-sdk-test", @@ -105,7 +104,7 @@ auto_impl = "1" derive_more = { version = "2", default-features = false, features = ["full"] } # CLI parser. Used by every binary crate (engine, load-gen, -# orderbook-mock, shepherd-backtest) via the derive macro. +# orderbook-mock) via the derive macro. clap = { version = "4", features = ["derive"] } # alloy stack. Engine uses the full provider/transport surface; @@ -196,7 +195,7 @@ axum = "0.8" # Randomness for tooling. rand = "0.10" -# Hex codec for the backtest harness. +# Hex codec for test fixtures and receipt rendering. hex = "0.4" # Dev/test helpers. diff --git a/Dockerfile b/Dockerfile index bc53f0fb..fa19d90f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,7 @@ RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ -p cow-venue --features cow-venue/adapter --recipe-path recipe.json # Now the workspace sources. `.dockerignore` keeps the context lean -# (no `target/`, no `data/`, no large baseline / backtest fixtures). +# (no `target/`, no `data/`, no large baseline fixtures). # Only the workspace crates recompile here; deps come from the cooked layer. COPY . . diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 7d28b7a4..844b50c6 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -68,7 +68,7 @@ proptest.workspace = true 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 +# consumers (tests, native tooling) compile the `http` module's types # without it. [target.'cfg(all(target_arch = "wasm32", target_os = "wasi"))'.dependencies] wstd.workspace = true diff --git a/crates/nexum-sdk/src/address.rs b/crates/nexum-sdk/src/address.rs index 2106e4a5..e1c8cb3a 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/crates/nexum-sdk/src/address.rs @@ -2,8 +2,8 @@ //! //! Multiple Shepherd modules need to read a `[config]` value such as //! `addresses = "0xabc..., 0xdef..."` and surface a typed error when -//! one of the entries is malformed; the offline backtest harness -//! parses single `0x...` strings out of fixture JSON. Each module +//! one of the entries is malformed, and native tooling parses single +//! `0x...` strings out of JSON. Each module //! previously rolled its own `AddressListParseError` / //! `AddressParseError`. The shapes were near-identical; the audit //! pass consolidates them here so future modules pick up the same diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml deleted file mode 100644 index e424d50c..00000000 --- a/crates/shepherd-backtest/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "shepherd-backtest" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Offline replay harness for Shepherd modules — drives strategy code against a fixtures dump of real Sepolia events via MockHost." - -[[bin]] -name = "shepherd-backtest" -path = "src/main.rs" - -[dependencies] -# Strategy code under test. ethflow-watcher exposes a native rlib -# (alongside its wasm cdylib) specifically so this crate can drive -# `strategy::on_chain_logs` directly without an embedded runtime. -ethflow-watcher = { path = "../../modules/ethflow-watcher" } -cow-venue = { path = "../cow-venue", features = ["client"] } -nexum-sdk = { path = "../nexum-sdk" } -nexum-sdk-test = { path = "../nexum-sdk-test" } -videre-sdk = { path = "../videre-sdk" } - -anyhow.workspace = true -clap.workspace = true -serde.workspace = true -serde_json = { workspace = true, features = ["std"] } -hex.workspace = true diff --git a/crates/shepherd-backtest/src/fixtures.rs b/crates/shepherd-backtest/src/fixtures.rs deleted file mode 100644 index 92f1f0f4..00000000 --- a/crates/shepherd-backtest/src/fixtures.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! JSON deserialization for the Python collector's -//! `tools/backtest-collect/fixtures-YYYY-MM-DD.json` output. -//! -//! Mirrors `tools/backtest-collect/backtest_collect.py` exactly: -//! every field present in the JSON must round-trip into a -//! [`Fixtures`] without information loss, since the replay -//! harness relies on raw `eth_getLogs` topics + data to reconstruct -//! a faithful alloy log. TWAP fields are deserialised but not yet -//! consumed by the replay (Phase 2B); keep them on the struct so -//! the fixture file is the canonical schema. - -#![allow(dead_code)] - -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -pub struct Fixtures { - pub metadata: Metadata, - pub ethflow_orders: Vec, - pub twap_conditionals: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct Metadata { - pub collected_at: String, - pub chain_id: u64, - pub chain_name: String, - pub window_days: u32, - pub from_block: u64, - pub to_block: u64, - pub rpc_url: String, - pub cow_api: String, - pub ethflow_owner: String, - pub composable_cow: String, - #[serde(default)] - pub notes: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct EthFlowFixture { - pub uid: String, - pub block_number: u64, - pub block_timestamp: u64, - pub tx_hash: Option, - pub log_index: u64, - pub contract: String, - pub sender: Option, - pub app_data_hash: String, - /// Resolved app_data document fetched from - /// `GET /api/v1/app_data/{hash}` at collection time. `None` if - /// the hash 404'd (no mirror in the orderbook's app_data store). - pub app_data_resolved: Option, - pub raw_log: RawLog, -} - -#[derive(Debug, Deserialize)] -pub struct TwapFixture { - pub owner: Option, - pub block_number: u64, - pub block_timestamp: u64, - pub tx_hash: Option, - pub log_index: u64, - pub params: TwapParams, - pub raw_log: RawLog, -} - -#[derive(Debug, Deserialize)] -pub struct TwapParams { - pub handler: String, - pub salt: String, - pub static_input: String, -} - -#[derive(Debug, Deserialize)] -pub struct RawLog { - /// Each topic is a 32-byte hex string with `0x` prefix. The - /// `OrderPlacement` and `ConditionalOrderCreated` events both - /// carry exactly 2 topics: `topic0` (the signature hash) and - /// `topic1` (the indexed `sender` / `owner` address). - pub topics: Vec, - /// ABI-encoded payload, hex-prefixed. - pub data: String, -} - -impl RawLog { - /// Decode each `0x...` topic into a 32-byte vector. The strategy - /// layer reads topics as `&[u8]` (right-padded address in topic1 - /// for indexed parameters), so we preserve the byte order. - pub fn topics_bytes(&self) -> Result>, hex::FromHexError> { - self.topics - .iter() - .map(|t| hex::decode(t.strip_prefix("0x").unwrap_or(t.as_str()))) - .collect() - } - - /// Decode the `data` hex string. - pub fn data_bytes(&self) -> Result, hex::FromHexError> { - hex::decode(self.data.strip_prefix("0x").unwrap_or(self.data.as_str())) - } -} - -/// Decode a `0x...` address string into the 20-byte representation -/// the strategy consumes. Thin wrapper around the shared -/// [`nexum_sdk::address::parse_address`] helper (JC5 -/// consolidation) so this crate, balance-tracker, and any future -/// strategy module surface the same typed error. -pub fn parse_address(s: &str) -> Result<[u8; 20], nexum_sdk::address::AddressParse> { - nexum_sdk::address::parse_address(s).map(|addr| addr.into_array()) -} diff --git a/crates/shepherd-backtest/src/main.rs b/crates/shepherd-backtest/src/main.rs deleted file mode 100644 index 6d0ff80d..00000000 --- a/crates/shepherd-backtest/src/main.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! # shepherd-backtest -//! -//! Offline replay harness for Shepherd modules. Loads a fixtures -//! JSON produced by `tools/backtest-collect/backtest_collect.py`, -//! drives each on-chain event through the production strategy code -//! over `nexum_sdk_test::MockHost` and a recording pool transport, -//! classifies the result, and emits a Markdown report at -//! `docs/operations/backtest-reports/backtest-7d-YYYY-MM-DD.md`. -//! -//! ## Scope -//! -//! v1 covers the EthFlow lane end-to-end. The TWAP lane requires -//! per-part eth_call walking against an archive RPC which the -//! current public-tier endpoints refuse (see the -//! `tools/baseline-latency` finding). TWAP fixtures are -//! still loaded and counted in the report so the gap is visible, -//! but the replay is gated on a paid endpoint (Phase 2B). - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] - -use std::path::PathBuf; - -use clap::Parser; - -mod fixtures; -mod replay; -mod report; - -use fixtures::Fixtures; -use replay::{Classification, replay_ethflow}; - -#[derive(Parser, Debug)] -#[command( - name = "shepherd-backtest", - about = "Replay collected Sepolia events through production strategies" -)] -struct Args { - /// Fixtures JSON produced by `tools/backtest-collect/backtest_collect.py`. - #[arg(long)] - fixtures: PathBuf, - - /// Markdown report output. The default path follows the - /// `backtest-{window}d-{date}.md` convention the - /// `docs/operations/backtest-reports/` directory expects. - #[arg(long)] - out: Option, - - /// Acceptance threshold for the report's sign-off line. The - /// acceptance criterion is ≥ 95% of replayed events - /// land in `Observed` or `RejectedExpected`; the threshold is - /// surfaced as a CLI flag so a soak-team override is possible - /// without re-editing the binary. - #[arg(long, default_value_t = 0.95)] - accept_threshold: f64, -} - -fn main() -> anyhow::Result<()> { - let args = Args::parse(); - eprintln!( - "=== shepherd-backtest - loading {} ===", - args.fixtures.display() - ); - let raw = std::fs::read_to_string(&args.fixtures)?; - let fx: Fixtures = serde_json::from_str(&raw)?; - eprintln!( - " chain: {} (id={}) window: {}d blocks {}..{}", - fx.metadata.chain_name, - fx.metadata.chain_id, - fx.metadata.window_days, - fx.metadata.from_block, - fx.metadata.to_block, - ); - eprintln!(" ethflow fixtures: {}", fx.ethflow_orders.len()); - eprintln!(" twap fixtures: {}", fx.twap_conditionals.len()); - - // ---- replay EthFlow ---- - let mut outcomes = Vec::with_capacity(fx.ethflow_orders.len()); - for (idx, order) in fx.ethflow_orders.iter().enumerate() { - let outcome = replay_ethflow(order, fx.metadata.chain_id); - if idx < 3 || idx == fx.ethflow_orders.len() - 1 { - eprintln!( - " [{}/{}] {} {}", - idx + 1, - fx.ethflow_orders.len(), - outcome.class.label(), - outcome.uid, - ); - } - outcomes.push(outcome); - } - - let report_md = report::render(&fx, &outcomes, args.accept_threshold); - let out_path = args.out.unwrap_or_else(|| { - let date = fx - .metadata - .collected_at - .split('T') - .next() - .unwrap_or("unknown"); - PathBuf::from(format!( - "docs/operations/backtest-reports/backtest-{}d-{}.md", - fx.metadata.window_days, date - )) - }); - if let Some(parent) = out_path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&out_path, &report_md)?; - eprintln!("\nreport written: {}", out_path.display()); - - // ---- summary + exit code ---- - let total = outcomes.len(); - let accepted = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::Observed | Classification::RejectedExpected(_) - ) - }) - .count(); - let ratio = if total == 0 { - 0.0 - } else { - accepted as f64 / total as f64 - }; - eprintln!( - "summary: {}/{} ({:.1}%) Observed+RejectedExpected (threshold {:.1}%)", - accepted, - total, - ratio * 100.0, - args.accept_threshold * 100.0, - ); - if total > 0 && ratio < args.accept_threshold { - eprintln!("FAIL: below threshold"); - std::process::exit(1); - } - Ok(()) -} diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs deleted file mode 100644 index 10ad421b..00000000 --- a/crates/shepherd-backtest/src/replay.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! Per-event replay against the ethflow-watcher strategy pair. -//! -//! Each [`EthFlowFixture`] is driven the way the live engine does it, -//! minus the wasm boundary: `strategy::on_chain_logs` runs over a -//! recording venue transport (the pool seam), then the status -//! transition the registry would poll for the watched receipt is -//! delivered through `strategy::on_intent_status`. In backtest context -//! all fixtures are confirmed real orders, so the simulated transition -//! is `open`. -//! -//! The classification falls into one of four buckets: -//! -//! - `Observed`: the strategy registered exactly one `cow` watch whose -//! receipt is the fixture's UID and wrote `observed:{uid}` on the -//! delivered transition. The success case. -//! - `RejectedExpected`: reserved for a documented skip path; not -//! emitted in the current batch. -//! - `RejectedUnexpected`: the strategy returned Ok but the observe -//! contract was violated (a wrong watch shape, a non-observe venue -//! call, or a missing `observed:{uid}` marker); a follow-up should -//! be filed before the report closes. -//! - `StrategyError`: a strategy call returned `Err(fault)`. A test -//! bug or an `unreachable!` we want to investigate. - -use std::cell::RefCell; -use std::rc::Rc; - -use cow_venue::client::CowClient; -use ethflow_watcher::strategy; -use nexum_sdk_test::{CapturedEvents, MockHost, capture_tracing}; -use videre_sdk::client::{VenueId, VenueTransport}; -use videre_sdk::rt::complete; -use videre_sdk::status_body::{IntentStatus, StatusBody}; -use videre_sdk::{Quotation, SubmitOutcome, VenueFault}; - -use crate::fixtures::{EthFlowFixture, parse_address}; - -/// The collected outcome for one replayed event. -#[derive(Debug)] -pub struct ReplayOutcome { - pub uid: String, - pub block_number: u64, - pub block_timestamp: u64, - pub class: Classification, - /// Log lines the strategy emitted while processing this fixture. - /// Surfaced in the report for failure triage. - pub log_lines: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Classification { - Observed, - /// Reserved for any documented skip path. Not emitted in the current - /// batch; retained so the acceptance-ratio formula is complete. - #[allow(dead_code)] - RejectedExpected(String), - RejectedUnexpected(String), - StrategyError(String), -} - -impl Classification { - pub fn label(&self) -> &'static str { - match self { - Classification::Observed => "Observed", - Classification::RejectedExpected(_) => "RejectedExpected", - Classification::RejectedUnexpected(_) => "RejectedUnexpected", - Classification::StrategyError(_) => "StrategyError", - } - } - - pub fn detail(&self) -> &str { - match self { - Classification::Observed => "", - Classification::RejectedExpected(d) - | Classification::RejectedUnexpected(d) - | Classification::StrategyError(d) => d, - } - } -} - -/// One recorded transport call: the verb, and for `observe` the routed -/// venue and receipt. -#[derive(Clone, Debug, PartialEq, Eq)] -enum Call { - Quote, - Submit, - Observe(String, Vec), - Status, - Cancel, -} - -impl Call { - fn label(&self) -> &'static str { - match self { - Call::Quote => "quote", - Call::Submit => "submit", - Call::Observe(..) => "observe", - Call::Status => "status", - Call::Cancel => "cancel", - } - } -} - -/// Records every pool call; `observe` accepts, the other verbs refuse. -/// Cloneable over shared state so the replay keeps a handle after one -/// moves into the client. -#[derive(Clone, Default)] -struct RecordingVenues { - calls: Rc>>, -} - -impl RecordingVenues { - fn calls(&self) -> Vec { - self.calls.borrow().clone() - } -} - -impl videre_sdk::client::sealed::SealedTransport for RecordingVenues {} - -impl VenueTransport for RecordingVenues { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - self.calls.borrow_mut().push(Call::Quote); - Err(VenueFault::Unsupported) - } - - async fn submit(&self, _venue: &VenueId, _body: Vec) -> Result { - self.calls.borrow_mut().push(Call::Submit); - Err(VenueFault::Unsupported) - } - - async fn observe(&self, venue: &VenueId, receipt: &[u8]) -> Result<(), VenueFault> { - self.calls - .borrow_mut() - .push(Call::Observe(venue.to_string(), receipt.to_vec())); - Ok(()) - } - - async fn status( - &self, - _venue: &VenueId, - _receipt: &[u8], - ) -> Result { - self.calls.borrow_mut().push(Call::Status); - Err(VenueFault::Unsupported) - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - self.calls.borrow_mut().push(Call::Cancel); - Err(VenueFault::Unsupported) - } -} - -/// The `open` transition the registry would report for an indexed order. -fn open_status() -> Result, String> { - StatusBody { - status: IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .map_err(|e| format!("status body encode: {e}")) -} - -/// Replay one EthFlow fixture through the production strategy pair. -pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { - let host = MockHost::new(); - let venues = RecordingVenues::default(); - let client = CowClient::with_transport(venues.clone()); - - // Reconstruct the log fields. Topics + data come straight from the - // collector's `raw_log`; the contract address is the EthFlow - // owner the fixture pins. - let topics = match fx.raw_log.topics_bytes() { - Ok(t) => t, - Err(e) => { - return error_outcome(fx, format!("topics hex decode: {e}")); - } - }; - let data = match fx.raw_log.data_bytes() { - Ok(d) => d, - Err(e) => { - return error_outcome(fx, format!("data hex decode: {e}")); - } - }; - let address = match parse_address(&fx.contract) { - Ok(a) => a, - Err(e) => { - return error_outcome(fx, format!("contract address: {e}")); - } - }; - // Assemble the alloy log the strategy consumes, threading the - // fixture's block-scoped fields through the same WIT-edge conversion - // the runtime uses. - let log: nexum_sdk::events::Log = nexum_sdk::events::ChainLogParts { - address: &address, - topics: &topics, - data: &data, - block_number: Some(fx.block_number), - block_timestamp: Some(fx.block_timestamp), - log_index: Some(fx.log_index), - ..Default::default() - } - .into(); - - // Drive the strategy: register the watch, then deliver the status - // transition the registry would poll for the watched receipt. - let (result, logs) = capture_tracing(|| { - complete(strategy::on_chain_logs(&host, &client, chain_id, &[log])) - .ok_or_else(|| "strategy future suspended".to_owned()) - .and_then(|r| r.map_err(|e| e.to_string()))?; - let calls = venues.calls(); - let [Call::Observe(venue, receipt)] = calls.as_slice() else { - let shapes: Vec<&str> = calls.iter().map(Call::label).collect(); - return Err(format!( - "expected exactly one observe; saw [{}]", - shapes.join(", ") - )); - }; - if venue != "cow" { - return Err(format!("watch routed to venue `{venue}`, not `cow`")); - } - strategy::on_intent_status(&host, venue, receipt, &open_status()?) - .map_err(|e| e.to_string())?; - Ok(receipt.clone()) - }); - let log_lines = log_lines(&logs); - - let class = match result { - Err(detail) => classify_err(&venues, detail), - Ok(receipt) => classify_ok(&host, &receipt, &fx.uid, &log_lines), - }; - - ReplayOutcome { - uid: fx.uid.clone(), - block_number: fx.block_number, - block_timestamp: fx.block_timestamp, - class, - log_lines, - } -} - -fn log_lines(logs: &CapturedEvents) -> Vec { - logs.events() - .into_iter() - .map(|e| format!("[{:?}] {}", e.level, e.message)) - .collect() -} - -fn error_outcome(fx: &EthFlowFixture, reason: String) -> ReplayOutcome { - ReplayOutcome { - uid: fx.uid.clone(), - block_number: fx.block_number, - block_timestamp: fx.block_timestamp, - class: Classification::StrategyError(reason), - log_lines: vec![], - } -} - -/// A failed replay is an observe-contract violation when the strategy -/// itself returned Ok but touched the pool wrongly; otherwise a -/// strategy error. -fn classify_err(venues: &RecordingVenues, detail: String) -> Classification { - if venues - .calls() - .iter() - .any(|c| !matches!(c, Call::Observe(..))) - { - return Classification::RejectedUnexpected(format!( - "strategy called a non-observe venue verb; observe-only must never submit ({detail})" - )); - } - if detail.starts_with("expected exactly one observe") - || detail.starts_with("watch routed to venue") - { - return Classification::RejectedUnexpected(detail); - } - Classification::StrategyError(detail) -} - -/// Classify an `Ok` replay by inspecting the recorded side effects, -/// independent of the strategy's own logging. -/// -/// `Observed` demands the full observe contract, not just the marker: -/// the one watch's receipt renders to exactly the fixture UID (never a -/// prefix scan, so a compute_uid divergence between module and -/// collector cannot produce a false `Observed`), and the delivered -/// transition wrote the `observed:{uid}` store key. -fn classify_ok(host: &MockHost, receipt: &[u8], uid: &str, log_lines: &[String]) -> Classification { - let receipt_hex = format!("0x{}", hex::encode(receipt)); - if receipt_hex != uid { - return Classification::RejectedUnexpected(format!( - "watched receipt {receipt_hex} does not match the collector UID" - )); - } - if host - .store - .snapshot() - .contains_key(&format!("observed:{uid}")) - { - return Classification::Observed; - } - // The strategy returned Ok without writing an observed marker. - // Surface for triage. - let last_log = log_lines.last().cloned().unwrap_or_default(); - Classification::RejectedUnexpected(format!("Ok with no observed marker; last log: {last_log}")) -} diff --git a/crates/shepherd-backtest/src/report.rs b/crates/shepherd-backtest/src/report.rs deleted file mode 100644 index a27b28ae..00000000 --- a/crates/shepherd-backtest/src/report.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! Markdown report renderer for the backtest run. Modelled on the -//! E2E report shape - run metadata, per-module counts, -//! per-event appendix table, anomalies, sign-off. - -use std::collections::BTreeMap; - -use crate::fixtures::Fixtures; -use crate::replay::{Classification, ReplayOutcome}; - -pub fn render(fx: &Fixtures, outcomes: &[ReplayOutcome], threshold: f64) -> String { - let mut by_class: BTreeMap<&'static str, usize> = BTreeMap::new(); - for o in outcomes { - *by_class.entry(o.class.label()).or_default() += 1; - } - let total = outcomes.len(); - let accepted = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::Observed | Classification::RejectedExpected(_) - ) - }) - .count(); - let ratio = if total == 0 { - 0.0 - } else { - accepted as f64 / total as f64 - }; - let pass = total == 0 || ratio >= threshold; - - let now = chrono_like_now(); - let mut out = String::new(); - out.push_str(&format!( - "# Pre-soak backtest - {}d window on {} ({})\n\n", - fx.metadata.window_days, fx.metadata.chain_name, now, - )); - out.push_str( - "Replays every collected EthFlow `OrderPlacement` event through the production \ - `ethflow_watcher::strategy` pair (`on_chain_logs` then `on_intent_status`) over \ - `nexum_sdk_test::MockHost` and a recording pool transport. The orderbook is \ - **never hit**: the transport accepts every watch and the replay delivers the \ - `open` transition the registry would poll for it, so every fixture reads as \ - indexed. Success is measured by whether the strategy watched exactly the \ - fixture UID and wrote the exact `observed:{uid}` marker on the transition.\n\n", - ); - out.push_str("## Run metadata\n\n"); - out.push_str("| Field | Value |\n|---|---|\n"); - out.push_str(&format!( - "| Chain | {} (id={}) |\n", - fx.metadata.chain_name, fx.metadata.chain_id - )); - out.push_str(&format!( - "| Window | {}d ({}..{}) |\n", - fx.metadata.window_days, fx.metadata.from_block, fx.metadata.to_block - )); - out.push_str(&format!( - "| Collected at | {} |\n", - fx.metadata.collected_at - )); - out.push_str(&format!("| RPC | `{}` |\n", fx.metadata.rpc_url)); - out.push_str(&format!("| Orderbook | `{}` |\n", fx.metadata.cow_api)); - out.push_str(&format!( - "| EthFlow owner | `{}` |\n", - fx.metadata.ethflow_owner - )); - out.push_str(&format!( - "| ComposableCoW | `{}` |\n", - fx.metadata.composable_cow - )); - out.push_str(&format!( - "| Accept threshold | {:.0}% |\n", - threshold * 100.0 - )); - out.push('\n'); - - if !fx.metadata.notes.is_empty() { - out.push_str("### Collector notes\n\n"); - for n in &fx.metadata.notes { - out.push_str(&format!("- {n}\n")); - } - out.push('\n'); - } - - out.push_str("## EthFlow replay summary\n\n"); - out.push_str(&format!("- Events replayed: **{total}**\n")); - for (label, count) in &by_class { - out.push_str(&format!( - "- {label}: **{count}** ({:.1}%)\n", - *count as f64 / total.max(1) as f64 * 100.0 - )); - } - out.push_str(&format!( - "\nAccepted (Observed + RejectedExpected): **{accepted}/{total} = {:.1}%** - {} threshold ({:.0}%).\n\n", - ratio * 100.0, - if pass { "PASS vs." } else { "**FAIL** vs." }, - threshold * 100.0, - )); - - // ---- anomalies ---- - let anomalies: Vec<&ReplayOutcome> = outcomes - .iter() - .filter(|o| { - matches!( - o.class, - Classification::RejectedUnexpected(_) | Classification::StrategyError(_) - ) - }) - .collect(); - out.push_str("## Anomalies\n\n"); - if anomalies.is_empty() { - out.push_str("None. Every replayed event landed in `Observed` or `RejectedExpected`.\n\n"); - } else { - out.push_str(&format!( - "**{} event(s) need a follow-up before this report can be signed off.** \ - File one issue per uid (use the gitBranchName conventions).\n\n", - anomalies.len(), - )); - out.push_str("| uid | block | class | detail | last log |\n"); - out.push_str("|---|---:|---|---|---|\n"); - for o in anomalies { - let last_log = o.log_lines.last().map(String::as_str).unwrap_or(""); - out.push_str(&format!( - "| `{}` | {} | {} | {} | {} |\n", - shorten(&o.uid), - o.block_number, - o.class.label(), - escape_md(o.class.detail()), - escape_md(last_log), - )); - } - out.push('\n'); - } - - // ---- TWAP lane status ---- - out.push_str("## TWAP lane status\n\n"); - out.push_str(&format!( - "{} `ConditionalOrderCreated` events were collected in this window. \ - **Replay deferred to Phase 2B** because driving `twap_monitor::strategy::on_block` \ - requires walking each watch's `eth_call(getTradeableOrderWithSignature)` per-block - \ - a workload public-tier RPCs refuse (see the baseline-latency finding). The \ - fixtures are committed for the future re-run; the TWAP gap on the sign-off is \ - intentional and tracked separately.\n\n", - fx.twap_conditionals.len(), - )); - - // ---- sign-off ---- - out.push_str("## Sign-off\n\n"); - if pass { - out.push_str(&format!( - "**PASS.** EthFlow replay clears the {:.0}% acceptance bar with no \ - outstanding anomalies. All fixtures were Observed (strategy wrote \ - `observed:{{uid}}` to local store). Soak is unblocked from the backtest \ - side; remaining blockers are external (paid RPC + VM for the wall-clock run).\n\n", - threshold * 100.0, - )); - } else { - out.push_str(&format!( - "**FAIL.** EthFlow replay landed at {:.1}%, below the {:.0}% bar. \ - Anomalies above must be resolved (or formally classified as \ - RejectedExpected with a corresponding code change in the strategy) before \ - this report can be re-rendered.\n\n", - ratio * 100.0, - threshold * 100.0, - )); - } - - out.push_str("## Reproducing\n\n```bash\n"); - out.push_str(&format!( - "python3 tools/backtest-collect/backtest_collect.py --days {}\n", - fx.metadata.window_days, - )); - out.push_str( - "cargo run -p shepherd-backtest -- \\\n --fixtures tools/backtest-collect/fixtures-YYYY-MM-DD.json\n```\n\n", - ); - - out.push_str("## Appendix: per-event classification\n\n"); - out.push_str("| # | uid | block | timestamp | class |\n|---:|---|---:|---:|---|\n"); - for (i, o) in outcomes.iter().enumerate() { - out.push_str(&format!( - "| {} | `{}` | {} | {} | {} |\n", - i + 1, - shorten(&o.uid), - o.block_number, - o.block_timestamp, - o.class.label(), - )); - } - out.push('\n'); - out -} - -fn shorten(uid: &str) -> String { - if uid.len() > 18 { - format!("{}..{}", &uid[..10], &uid[uid.len() - 6..]) - } else { - uid.to_owned() - } -} - -fn escape_md(s: &str) -> String { - s.replace('|', "\\|").replace('\n', " ") -} - -fn chrono_like_now() -> String { - // Avoid pulling chrono just for a UTC string; UNIX epoch + ISO - // formatter the report renderer doesn't need to be wall-clock - // accurate to the second. - use std::time::{SystemTime, UNIX_EPOCH}; - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - // YYYY-MM-DDTHH:MM:SSZ - derived without leap-year handling - // because the report only uses this for a header line; the - // ground truth is the fixtures' `collected_at` field. - let days = secs / 86400; - let day_secs = secs % 86400; - let (h, m, s) = (day_secs / 3600, (day_secs % 3600) / 60, day_secs % 60); - // 1970-01-01 + days; rough date for the report timestamp. Good - // enough to grep on. - let (year, month, day) = days_to_ymd(days as i64); - format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z") -} - -fn days_to_ymd(mut days: i64) -> (i32, u32, u32) { - // Adapted from the classic civil-date conversion. - days += 719468; - let era = if days >= 0 { days } else { days - 146096 } / 146097; - let doe = (days - era * 146097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = if m <= 2 { y + 1 } else { y }; - (y as i32, m, d) -} diff --git a/docs/00-overview.md b/docs/00-overview.md index 4e5d12ce..3e8427e9 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -359,8 +359,7 @@ shepherd/ │ ├── nexum-sdk/ Generic guest SDK: host-trait seam, Fault, chain/config/address helpers, wasi:http fetch, tracing facade (ADR-0009) │ ├── nexum-sdk-test/ Generic mock host (MockChain / MockLocalStore / MockLogging) for strategy tests │ ├── shepherd-sdk/ CoW-domain SDK: cow-api trait + CoW Protocol helpers on top of nexum-sdk -│ ├── shepherd-sdk-test/ CoW mock host (MockCowApi + composed MockHost) for strategy tests -│ └── shepherd-backtest/ Backtest harness against captured chain fixtures +│ └── shepherd-sdk-test/ CoW mock host (MockCowApi + composed MockHost) for strategy tests ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ ├── ethflow-watcher/ Ethflow order monitoring module diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 717f74b4..d4bfb659 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -166,9 +166,10 @@ Look at: ## 4. What this does NOT prove - WS reconnect resilience (7-day soak). -- Diverse appData / order-shape correctness (the backtest). +- Diverse appData / order-shape correctness (the watcher replays real + placements from contract genesis at startup). - Multi-day memory drift (7-day soak). -- Real-orderbook 4xx variety (the backtest). +- Real-orderbook 4xx variety (only a live-orderbook run exercises this). - Provider rate-limit handling on the live network. This test answers exactly one question: "How many TWAP+EthFlow events diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index eff3f0ba..1902df84 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -6,11 +6,8 @@ license.workspace = true repository.workspace = true [lib] -# `cdylib` is the wasm-component artefact the engine loads at -# runtime. `rlib` exposes the pure-Rust strategy module so native -# tools (e.g. `shepherd-backtest`) can drive `on_chain_logs` -# directly without an embedded runtime. -crate-type = ["cdylib", "rlib"] +# `cdylib` is the wasm-component artefact the engine loads at runtime. +crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index c26150b4..35b800a8 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -24,10 +24,10 @@ #![allow(clippy::too_many_arguments)] // The keeper glue only resolves against the engine's wasm component -// host. Cfg-gate it so the `rlib` artefact (consumed by -// `shepherd-backtest`) carries just the strategy code without dangling -// `extern "C"` imports; the `use wit_bindgen as _` line silences the -// unused-crate lint on native targets where the macro never expands. +// host. Cfg-gate it so a native build of this crate carries just the +// strategy code without dangling `extern "C"` imports; the +// `use wit_bindgen as _` line silences the unused-crate lint on native +// targets where the macro never expands. #[cfg(not(target_arch = "wasm32"))] use wit_bindgen as _; diff --git a/tools/backtest-collect/backtest_collect.py b/tools/backtest-collect/backtest_collect.py deleted file mode 100644 index 19ef9cd8..00000000 --- a/tools/backtest-collect/backtest_collect.py +++ /dev/null @@ -1,578 +0,0 @@ -#!/usr/bin/env python3 -"""Collect a Sepolia event window for the pre-soak backtest. - -Pulls every on-chain -- `CoWSwapEthFlow.OrderPlacement` (EthFlow lane), and -- `ComposableCoW.ConditionalOrderCreated` (TWAP lane) - -in the trailing `--days` window on Sepolia, ABI-decodes the payloads, -derives the EthFlow `OrderUid` via EIP-712, resolves any non-empty -`appData` hashes via the orderbook's `/api/v1/app_data/{hash}` lookup, -and emits a single fixtures JSON the Rust replay harness -(`crates/shepherd-backtest`) consumes. - -The script is read-only (no on-chain submissions, no orderbook PUTs). -It only hits the configured RPC endpoint + `GET` against the cow.fi -orderbook. - -## Scope - -Phase 1 MVP collects events + decoded payloads + app_data only. It -does NOT walk every TWAP watch with `eth_call(getTradeableOrderWith -Signature)` per block — that requires an archive-tier RPC plan. -The replay harness will perform that walk on demand -once a paid endpoint is wired; until then the TWAP replay is bounded -to "would the strategy assemble a child body on the first `Ready` -window?" and the EthFlow replay is fully exercisable from the -collected fixtures alone. - -## Output shape - -``` -{ - "metadata": { - "collected_at": "2026-06-22T15:00:00Z", - "chain_id": 11155111, - "chain_name": "Sepolia", - "window_days": 7, - "from_block": 11065713, - "to_block": 11116113, - "rpc_url": "https://sepolia.drpc.org", - "cow_api": "https://api.cow.fi/sepolia/api/v1", - "ethflow_owner": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "composable_cow": "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74" - }, - "ethflow_orders": [ { "uid": "0x...", "block_number": ..., "block_timestamp": ..., "tx_hash": "0x...", "log_index": ..., "sender": "0x...", "contract": "0x...", "gpv2_order": {...}, "signature": {"scheme": 0, "payload": "0x..."}, "extra_data": "0x...", "app_data_resolved": null | {"hash": "0x...", "document": "..."} } ], - "twap_conditionals": [ { "owner": "0x...", "block_number": ..., "block_timestamp": ..., "tx_hash": "0x...", "log_index": ..., "params": {"handler": "0x...", "salt": "0x...", "static_input": "0x..."} } ] -} -``` - -Usage: - - python3 tools/backtest-collect/backtest_collect.py \ - --days 7 \ - --out tools/backtest-collect/fixtures-$(date -u +%Y-%m-%d).json -""" - -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -try: - import requests - from eth_abi import decode as abi_decode - from eth_utils import keccak -except ImportError: - sys.stderr.write( - "missing deps. install with: " - "pip3 install requests eth-abi eth-utils \"eth-hash[pycryptodome]\"\n" - ) - sys.exit(1) - - -# ----------------------------------------------------------------- pinned identities - -# EthFlow contract Sepolia deployment (see docs/operations/e2e-prep.md). -ETH_FLOW_SEPOLIA = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" - -# ComposableCoW is CREATE2'd to the same address on every chain. -COMPOSABLE_COW = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" - -# GPv2Settlement is also identical across chains. -GPV2_SETTLEMENT = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" - -# topic0 = keccak("OrderPlacement(address,(...12 GPv2Order fields...),(uint8,bytes),bytes)") -ORDER_PLACEMENT_TOPIC = ( - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" -) - -# topic0 = keccak("ConditionalOrderCreated(address,(address,bytes32,bytes))") -CONDITIONAL_ORDER_CREATED_TOPIC = ( - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" -) - - -# ----------------------------------------------------------------- EIP-712 - -EIP712_DOMAIN_TYPEHASH = keccak( - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" -) -GPV2_DOMAIN_NAME_HASH = keccak(b"Gnosis Protocol") -GPV2_DOMAIN_VERSION_HASH = keccak(b"v2") -ORDER_TYPEHASH = keccak( - b"Order(address sellToken,address buyToken,address receiver," - b"uint256 sellAmount,uint256 buyAmount,uint32 validTo," - b"bytes32 appData,uint256 feeAmount,string kind," - b"bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)" -) - - -def domain_separator(chain_id: int) -> bytes: - """GPv2Settlement EIP-712 domain separator for a given chain id.""" - return keccak( - EIP712_DOMAIN_TYPEHASH - + GPV2_DOMAIN_NAME_HASH - + GPV2_DOMAIN_VERSION_HASH - + chain_id.to_bytes(32, "big") - + bytes(12) + bytes.fromhex(GPV2_SETTLEMENT[2:]) - ) - - -def _pad20(addr_str: str) -> bytes: - raw = bytes.fromhex(addr_str[2:] if addr_str.startswith("0x") else addr_str) - if len(raw) == 20: - return bytes(12) + raw - if len(raw) == 32: - return raw - raise ValueError(f"bad address length: {len(raw)}") - - -def order_uid(order: dict, owner: str, chain_id: int) -> str: - """Derive the 56-byte OrderUid for a GPv2OrderData + owner.""" - struct_hash = keccak( - ORDER_TYPEHASH - + _pad20(order["sellToken"]) - + _pad20(order["buyToken"]) - + _pad20(order["receiver"]) - + order["sellAmount"].to_bytes(32, "big") - + order["buyAmount"].to_bytes(32, "big") - + order["validTo"].to_bytes(32, "big") - + bytes(order["appData"]) - + order["feeAmount"].to_bytes(32, "big") - + bytes(order["kind"]) - + (b"\x00" * 31 + (b"\x01" if order["partiallyFillable"] else b"\x00")) - + bytes(order["sellTokenBalance"]) - + bytes(order["buyTokenBalance"]) - ) - order_digest = keccak(b"\x19\x01" + domain_separator(chain_id) + struct_hash) - owner_b = bytes.fromhex(owner[2:] if owner.startswith("0x") else owner) - if len(owner_b) != 20: - raise ValueError(f"bad owner length: {len(owner_b)}") - return "0x" + (order_digest + owner_b + order["validTo"].to_bytes(4, "big")).hex() - - -# ----------------------------------------------------------------- decoding - -def decode_order_placement(log_data_hex: str) -> dict | None: - """ABI-decode `OrderPlacement.data` into GPv2OrderData + signature + extra. - - Event signature: - OrderPlacement( - address indexed sender, // topic1 - GPv2Order order, - OnchainSignature signature, // (uint8 scheme, bytes payload) - bytes data, - ) - - The data payload encodes `(order, signature, data)`. - """ - raw = bytes.fromhex(log_data_hex[2:] if log_data_hex.startswith("0x") else log_data_hex) - try: - order, sig, extra = abi_decode( - [ - "(address,address,address,uint256,uint256,uint32," - "bytes32,uint256,bytes32,bool,bytes32,bytes32)", - "(uint8,bytes)", - "bytes", - ], - raw, - ) - except Exception: - return None - return { - "order": { - "sellToken": order[0], - "buyToken": order[1], - "receiver": order[2], - "sellAmount": order[3], - "buyAmount": order[4], - "validTo": order[5], - "appData": order[6], - "feeAmount": order[7], - "kind": order[8], - "partiallyFillable": order[9], - "sellTokenBalance": order[10], - "buyTokenBalance": order[11], - }, - "signature": {"scheme": sig[0], "payload": "0x" + sig[1].hex()}, - "extra_data": "0x" + extra.hex(), - } - - -def decode_conditional_order_params(log_data_hex: str) -> dict | None: - """ABI-decode `ConditionalOrderCreated.data` into the ConditionalOrderParams tuple. - - Event signature: - ConditionalOrderCreated( - address indexed owner, // topic1 - ConditionalOrderParams params, // (address handler, bytes32 salt, bytes staticInput) - ) - """ - raw = bytes.fromhex(log_data_hex[2:] if log_data_hex.startswith("0x") else log_data_hex) - try: - (params,) = abi_decode(["(address,bytes32,bytes)"], raw) - except Exception: - return None - handler, salt, static_input = params - return { - "handler": handler, - "salt": "0x" + salt.hex(), - "static_input": "0x" + static_input.hex(), - } - - -def order_to_json(order: dict) -> dict: - """Re-serialise a decoded GPv2Order as JSON-safe types.""" - return { - "sellToken": order["sellToken"], - "buyToken": order["buyToken"], - "receiver": order["receiver"], - "sellAmount": str(order["sellAmount"]), - "buyAmount": str(order["buyAmount"]), - "validTo": order["validTo"], - "appData": "0x" + order["appData"].hex(), - "feeAmount": str(order["feeAmount"]), - "kind": "0x" + order["kind"].hex(), - "partiallyFillable": order["partiallyFillable"], - "sellTokenBalance": "0x" + order["sellTokenBalance"].hex(), - "buyTokenBalance": "0x" + order["buyTokenBalance"].hex(), - } - - -# ----------------------------------------------------------------- rpc - -def rpc_call(url: str, method: str, params: list, timeout: int = 30) -> Any: - """Minimal JSON-RPC helper. Raises on transport or response errors.""" - r = requests.post( - url, - json={"jsonrpc": "2.0", "method": method, "params": params, "id": 1}, - timeout=timeout, - ) - r.raise_for_status() - data = r.json() - if "error" in data: - raise RuntimeError(f"rpc {method} error: {data['error']}") - return data["result"] - - -def get_block_number(url: str) -> int: - return int(rpc_call(url, "eth_blockNumber", []), 16) - - -def get_block_timestamp(url: str, block_number: int) -> int: - block = rpc_call(url, "eth_getBlockByNumber", [hex(block_number), False]) - if not block: - raise RuntimeError(f"block {block_number} not found") - return int(block["timestamp"], 16) - - -class RpcLimited(RuntimeError): - """Endpoint refused even our smallest chunk size — paid RPC needed.""" - - -def get_logs_chunked( - rpc_url: str, - address: str, - topic0: str, - from_block: int, - to_block: int, - chunk: int = 2000, - consecutive_fail_budget: int = 3, -) -> list[dict]: - """`eth_getLogs` in chunks with halving retry. Mirrors the - baseline-latency tool's behaviour (PR #57): if the endpoint - rejects a chunk we halve it down to a 50-block floor; if we hit - `consecutive_fail_budget` failures even at the floor we raise - `RpcLimited` so the caller can record the constraint.""" - out: list[dict] = [] - cursor = from_block - consecutive_fails = 0 - while cursor <= to_block: - end = min(cursor + chunk - 1, to_block) - try: - logs = rpc_call( - rpc_url, - "eth_getLogs", - [ - { - "fromBlock": hex(cursor), - "toBlock": hex(end), - "address": address, - "topics": [topic0], - } - ], - ) - out.extend(logs) - cursor = end + 1 - consecutive_fails = 0 - except Exception as e: - if chunk > 50: - chunk //= 2 - sys.stderr.write(f" chunk halving to {chunk} after error: {e}\n") - continue - consecutive_fails += 1 - if consecutive_fails >= consecutive_fail_budget: - raise RpcLimited( - f"endpoint refused {consecutive_fails} consecutive calls at chunk={chunk}: {e}" - ) from e - sys.stderr.write( - f" WARN: skipping blocks {cursor}-{end} on chunk={chunk}: {e}\n" - ) - cursor = end + 1 - return out - - -# ----------------------------------------------------------------- app_data - -def fetch_app_data(cow_api: str, app_data_hash_hex: str) -> dict | None: - """`GET /api/v1/app_data/{hash}`. Returns the resolved JSON - document (a dict with `fullAppData` etc.), or `None` on 404. - - The orderbook's `app_data` endpoint exists specifically so - relayers can look up the user-supplied app_data JSON - associated with a given hash (the on-chain order only carries - the hash, not the JSON). Replays that re-submit need the JSON - so the digest matches; the live equivalent is - in twap-monitor / ethflow-watcher.""" - r = requests.get(f"{cow_api}/app_data/{app_data_hash_hex}", timeout=30) - if r.status_code == 404: - return None - r.raise_for_status() - return r.json() - - -# ----------------------------------------------------------------- main - -EMPTY_BYTES32_HEX = "0x" + "00" * 32 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--days", type=int, default=7) - parser.add_argument( - "--rpc", - default=os.environ.get( - "RPC_URL_SEPOLIA_HTTP", "https://sepolia.drpc.org" - ), - ) - parser.add_argument( - "--cow-api", - default="https://api.cow.fi/sepolia/api/v1", - ) - parser.add_argument( - "--out", - type=Path, - default=Path("tools/backtest-collect") - / f"fixtures-{datetime.now(timezone.utc):%Y-%m-%d}.json", - ) - parser.add_argument( - "--max-events-per-stream", - type=int, - default=500, - help="cap per event type so app_data resolution stays bounded", - ) - args = parser.parse_args() - - chain_id = 11155111 # Sepolia - sys.stderr.write(f"=== backtest-collect (Sepolia, days={args.days}) ===\n") - sys.stderr.write(f" rpc: {args.rpc}\n") - sys.stderr.write(f" cow-api: {args.cow_api}\n") - - head = get_block_number(args.rpc) - head_ts = get_block_timestamp(args.rpc, head) - from_block = max(0, head - args.days * 86400 // 12) - sys.stderr.write(f" scanning blocks {from_block}..{head}\n") - - # ---- EthFlow OrderPlacement ---- - sys.stderr.write("\n[ethflow] fetching OrderPlacement logs\n") - notes: list[str] = [] - try: - ethflow_logs = get_logs_chunked( - args.rpc, ETH_FLOW_SEPOLIA, ORDER_PLACEMENT_TOPIC, from_block, head - ) - except RpcLimited as e: - notes.append(f"ethflow eth_getLogs RPC-LIMITED: {e}") - ethflow_logs = [] - sys.stderr.write(f" events: {len(ethflow_logs)}\n") - if args.max_events_per_stream and len(ethflow_logs) > args.max_events_per_stream: - notes.append( - f"ethflow capped to last {args.max_events_per_stream} of {len(ethflow_logs)}" - ) - ethflow_logs = ethflow_logs[-args.max_events_per_stream:] - - # ---- ComposableCoW ConditionalOrderCreated ---- - sys.stderr.write("\n[twap] fetching ConditionalOrderCreated logs\n") - try: - twap_logs = get_logs_chunked( - args.rpc, COMPOSABLE_COW, CONDITIONAL_ORDER_CREATED_TOPIC, from_block, head - ) - except RpcLimited as e: - notes.append(f"twap eth_getLogs RPC-LIMITED: {e}") - twap_logs = [] - sys.stderr.write(f" events: {len(twap_logs)}\n") - if args.max_events_per_stream and len(twap_logs) > args.max_events_per_stream: - notes.append( - f"twap capped to last {args.max_events_per_stream} of {len(twap_logs)}" - ) - twap_logs = twap_logs[-args.max_events_per_stream:] - - # ---- block timestamp cache (one eth_getBlockByNumber per unique block) ---- - block_ts_cache: dict[int, int] = {head: head_ts} - - def block_ts(b: int) -> int: - if b not in block_ts_cache: - block_ts_cache[b] = get_block_timestamp(args.rpc, b) - return block_ts_cache[b] - - # ---- EthFlow fixtures ---- - sys.stderr.write("\n[ethflow] decoding + UID derivation\n") - ethflow_fixtures: list[dict] = [] - decode_failed = 0 - app_data_hashes_seen: set[str] = set() - for log in ethflow_logs: - decoded = decode_order_placement(log["data"]) - if decoded is None: - decode_failed += 1 - continue - # The OrderPlacement.sender is the indexed topic1 (32 bytes, - # right-padded address). - sender_topic = log["topics"][1] if len(log["topics"]) > 1 else None - sender = "0x" + sender_topic[-40:] if sender_topic else None - # Derive UID via EIP-712 against the EthFlow contract owner. - try: - uid = order_uid(decoded["order"], ETH_FLOW_SEPOLIA, chain_id).lower() - except Exception as e: - sys.stderr.write(f" uid derive failed for {log.get('transactionHash')}: {e}\n") - decode_failed += 1 - continue - block_num = int(log["blockNumber"], 16) - app_data_hex = "0x" + decoded["order"]["appData"].hex() - if app_data_hex.lower() != EMPTY_BYTES32_HEX: - app_data_hashes_seen.add(app_data_hex.lower()) - ethflow_fixtures.append( - { - "uid": uid, - "block_number": block_num, - "block_timestamp": block_ts(block_num), - "tx_hash": log.get("transactionHash"), - "log_index": int(log.get("logIndex", "0x0"), 16), - "contract": ETH_FLOW_SEPOLIA.lower(), - "sender": sender, - "gpv2_order": order_to_json(decoded["order"]), - "signature": decoded["signature"], - "extra_data": decoded["extra_data"], - "app_data_hash": app_data_hex, - "app_data_resolved": None, # filled in below - # Raw eth_getLogs payload so the Rust replay harness - # can reconstruct an exact `ChainLogView` (topics + data - # bytes) without re-encoding from the decoded - # fields. The strategy decodes from raw bytes; fidelity - # matters when the goal is "would the strategy have - # done the same thing it does live?" - "raw_log": { - "topics": log["topics"], - "data": log["data"], - }, - } - ) - if decode_failed: - notes.append(f"ethflow: {decode_failed} events failed to decode/derive") - sys.stderr.write( - f" fixtures: {len(ethflow_fixtures)} (failed: {decode_failed})\n" - ) - - # ---- TWAP fixtures ---- - sys.stderr.write("\n[twap] decoding ConditionalOrderParams\n") - twap_fixtures: list[dict] = [] - twap_decode_failed = 0 - for log in twap_logs: - params = decode_conditional_order_params(log["data"]) - if params is None: - twap_decode_failed += 1 - continue - owner_topic = log["topics"][1] if len(log["topics"]) > 1 else None - owner = "0x" + owner_topic[-40:] if owner_topic else None - block_num = int(log["blockNumber"], 16) - twap_fixtures.append( - { - "owner": owner, - "block_number": block_num, - "block_timestamp": block_ts(block_num), - "tx_hash": log.get("transactionHash"), - "log_index": int(log.get("logIndex", "0x0"), 16), - "params": params, - "raw_log": { - "topics": log["topics"], - "data": log["data"], - }, - } - ) - if twap_decode_failed: - notes.append(f"twap: {twap_decode_failed} events failed to decode") - sys.stderr.write( - f" fixtures: {len(twap_fixtures)} (failed: {twap_decode_failed})\n" - ) - - # ---- app_data resolution (EthFlow only — TWAP staticInput carries its own data) ---- - if app_data_hashes_seen: - sys.stderr.write( - f"\n[app_data] resolving {len(app_data_hashes_seen)} unique hashes\n" - ) - resolved: dict[str, dict | None] = {} - for h in sorted(app_data_hashes_seen): - doc = fetch_app_data(args.cow_api, h) - resolved[h] = doc - if doc is None: - sys.stderr.write(f" {h[:14]}.. 404\n") - not_found = sum(1 for v in resolved.values() if v is None) - if not_found: - notes.append( - f"app_data: {not_found}/{len(app_data_hashes_seen)} hashes 404'd " - f"(not mirrored by orderbook — expected for some external app_data flows)" - ) - # Stitch the resolved documents back into each fixture row. - for fx in ethflow_fixtures: - fx["app_data_resolved"] = resolved.get(fx["app_data_hash"]) - sys.stderr.write( - f" resolved: {len(resolved) - not_found}/{len(app_data_hashes_seen)}\n" - ) - - # ---- write fixtures file ---- - out_doc = { - "metadata": { - "collected_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - "chain_id": chain_id, - "chain_name": "Sepolia", - "window_days": args.days, - "from_block": from_block, - "to_block": head, - "rpc_url": args.rpc, - "cow_api": args.cow_api, - "ethflow_owner": ETH_FLOW_SEPOLIA.lower(), - "composable_cow": COMPOSABLE_COW.lower(), - "notes": notes, - }, - "ethflow_orders": ethflow_fixtures, - "twap_conditionals": twap_fixtures, - } - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(out_doc, indent=2)) - sys.stderr.write( - f"\nfixtures written: {args.out}\n" - f" ethflow_orders: {len(ethflow_fixtures)}\n" - f" twap_conditionals: {len(twap_fixtures)}\n" - f" notes: {len(notes)}\n" - ) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tools/backtest-collect/fixtures-2026-06-22.json b/tools/backtest-collect/fixtures-2026-06-22.json deleted file mode 100644 index 6344593d..00000000 --- a/tools/backtest-collect/fixtures-2026-06-22.json +++ /dev/null @@ -1,9873 +0,0 @@ -{ - "metadata": { - "collected_at": "2026-06-22T15:47:06Z", - "chain_id": 11155111, - "chain_name": "Sepolia", - "window_days": 7, - "from_block": 11066372, - "to_block": 11116772, - "rpc_url": "https://sepolia.drpc.org", - "cow_api": "https://api.cow.fi/sepolia/api/v1", - "ethflow_owner": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "composable_cow": "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74", - "notes": [] - }, - "ethflow_orders": [ - { - "uid": "0x5e43c58407ded1f8efb366d8172bd37b5219dfe52ca8381d5a5664006625df61ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066776, - "block_timestamp": 1781541696, - "tx_hash": "0x2ed8b17bb6e600ecfb3c8238a29461291992e921714c48a9660388b4e9f3d239", - "log_index": 231, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "sellAmount": "3000000000000000", - "buyAmount": "893897411", - "validTo": 4294967295, - "appData": "0xa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b49", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711b36a30323b", - "app_data_hash": "0xa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b49", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":552,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003547cac300000000000000000000000000000000000000000000000000000000ffffffffa6ccf4bf36287699d17afd1871cb9d30074b6525f15b80c0cac2d4e8b3df1b490000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711b36a30323b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf56cba95511a3d7cb0aa311c420cc403f51c6b18f76c25043163d5e048266788ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066777, - "block_timestamp": 1781541708, - "tx_hash": "0x14a46b4eb9bc6e94fbaa07a9a13b2d3a8440c9bc4c5e78948f5821e33d07189f", - "log_index": 47, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5aaa986eac50c844f866c6a8a6d3cfab5792b694", - "sellAmount": "3000000000000000", - "buyAmount": "57610257793", - "validTo": 4294967295, - "appData": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711b66a303246", - "app_data_hash": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":518,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005aaa986eac50c844f866c6a8a6d3cfab5792b694000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000d69d6c58100000000000000000000000000000000000000000000000000000000ffffffff387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711b66a3032460000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb47d7c7fa5b80751bd9d012b4414fd5e0ecc3b72615ff8a492c0a60e65f3943bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066798, - "block_timestamp": 1781541960, - "tx_hash": "0xaca14558c97e2966e5fb51d00a8a217bda4f0474b2c7d3693a6ba0a95aa1972e", - "log_index": 281, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "sellAmount": "3000000000000000", - "buyAmount": "875843830", - "validTo": 4294967295, - "appData": "0xd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711bd6a303344", - "app_data_hash": "0xd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":563,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000343450f600000000000000000000000000000000000000000000000000000000ffffffffd7fe9aef70f98f3d6663542be4ba448ac0b7d2f98b893d14e5716763cdb9d49d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711bd6a3033440000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4069c497a70a51e913fa21c89e88bf79a521feb2d831e442fa9a0e9b87de6858ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066799, - "block_timestamp": 1781541972, - "tx_hash": "0xb24d400f4a8b7f6d3b6cd516404b5839c46bb897e41ac50bcba57a7a10819672", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5d36e5b6c1155d57053b14917c7e3dbb1522ecb7", - "sellAmount": "3000000000000000", - "buyAmount": "72317308401", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711c06a303350", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005d36e5b6c1155d57053b14917c7e3dbb1522ecb7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000010d6728df100000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711c06a3033500000000000000000000000000000000000000000" - } - }, - { - "uid": "0xed31c79320e02a546523805a8586247db50dc8cb8cfececa8cb04428deffe30eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066818, - "block_timestamp": 1781542200, - "tx_hash": "0xb03bee8862a49aa6502bb889163ce11c6c08d40ff7305ce7bae3eb1ce55fde36", - "log_index": 181, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "sellAmount": "3000000000000000", - "buyAmount": "874340793", - "validTo": 4294967295, - "appData": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711c86a303430", - "app_data_hash": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":554,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000341d61b900000000000000000000000000000000000000000000000000000000ffffffff6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad23210000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711c86a3034300000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5da63e4eb1ae72ba1b6d9ba7848a28052a9d381f9a066be8d7c9644be9a9e509ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066819, - "block_timestamp": 1781542212, - "tx_hash": "0x63adf8a8a7593c296380cf050e612fada0122b004cd5c7f70a3e844b86ab26c2", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x000fb5b28fefa72f5252e7e82ffe46dc67594faf", - "sellAmount": "3000000000000000", - "buyAmount": "70059533490", - "validTo": 4294967295, - "appData": "0xf6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e298", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711ca6a30343b", - "app_data_hash": "0xf6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e298", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":539,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000000fb5b28fefa72f5252e7e82ffe46dc67594faf000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000104fdfa4b200000000000000000000000000000000000000000000000000000000fffffffff6c610744bb68398a39b86e84c66c3fe3614b3f49974bc955be42d2e6a01e2980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711ca6a30343b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6c3227cbd2bd33b3e550ea4bd700c264356c382c9868a770898dd47d39bd96e8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066844, - "block_timestamp": 1781542512, - "tx_hash": "0xf5447d774e1d8b343c98a66904616bf270d528aed658bd22cacc33c1a59725f4", - "log_index": 57, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "sellAmount": "3000000000000000", - "buyAmount": "861864447", - "validTo": 4294967295, - "appData": "0x8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711d96a303564", - "app_data_hash": "0x8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":529,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000335f01ff00000000000000000000000000000000000000000000000000000000ffffffff8591b2ac2a2de1d38c5297f143cf311fd46ad9b3c9eb04cfcd40ba810e25c20c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711d96a3035640000000000000000000000000000000000000000" - } - }, - { - "uid": "0x97fa5d54846ee99feee01f8323378584110e5584d5e98401456092e770b021e1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066844, - "block_timestamp": 1781542512, - "tx_hash": "0x8135d17f6866504867a51b8c49c2f551de61f8b5e20d7da5a899aaabb7de9df4", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdc3b40688cdd7f098c7e0651b6008e0a2fd2f772", - "sellAmount": "3000000000000000", - "buyAmount": "68704353178", - "validTo": 4294967295, - "appData": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711da6a30356d", - "app_data_hash": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":511,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dc3b40688cdd7f098c7e0651b6008e0a2fd2f772000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000fff193b9a00000000000000000000000000000000000000000000000000000000ffffffff1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c810000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711da6a30356d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe5197c475a0b9889a27248a2b74fe0cbadfddae0d458e757a30e5f605a646d9bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066863, - "block_timestamp": 1781542740, - "tx_hash": "0x6bbddfbb73ea21b8fe6d625600275fe79cfa4e2e255d45e4f3b9644cc027b38b", - "log_index": 104, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "sellAmount": "3000000000000000", - "buyAmount": "839961443", - "validTo": 4294967295, - "appData": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711e26a30364b", - "app_data_hash": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":579,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003210cb6300000000000000000000000000000000000000000000000000000000ffffffff12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711e26a30364b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf13dc9a187aab785bded8dbecf92fba5e598d2285ed27535f73ebd8d28deb122ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11066863, - "block_timestamp": 1781542740, - "tx_hash": "0x976895ebb161a590de944a65850d4f63e2716099667c06e1359ddb087499d620", - "log_index": 108, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7f52ef75763939cc397ed91612ee654cb69840d9", - "sellAmount": "3000000000000000", - "buyAmount": "65718536214", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001711e36a303652", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007f52ef75763939cc397ed91612ee654cb69840d9000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000f4d21481600000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001711e36a3036520000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6118ba38dfcce88864c9b5c91107ecd13fb61aedfb01fd04efe7e12e0be5b19aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067068, - "block_timestamp": 1781545200, - "tx_hash": "0x6de4b3ad33ddc765168c0108e457351c748f9c9cee4772056e3785fadf644752", - "log_index": 344, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "104011000000000000", - "buyAmount": "5880686427776813191", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017124d6a303fe7", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b0000000000000000000000000000000000000000000000000171857413bbb000000000000000000000000000000000000000000000000000519c65261b19088700000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017124d6a303fe70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb2efc9f12e071f643bed89a7d278ce74f13d981edac7143f40bbe30ce9c6c59fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067190, - "block_timestamp": 1781546664, - "tx_hash": "0x1232bdd6cbf0afb5c0a3ca6f357886a8329483936ca2a74ae062776aa1dd4fca", - "log_index": 422, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "6000000000000000", - "buyAmount": "130555809198", - "validTo": 4294967295, - "appData": "0x16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001712826a3045a2", - "app_data_hash": "0x16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":286,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000001550f7dca700000000000000000000000000000000000000000000000000000000001e65bb8dae00000000000000000000000000000000000000000000000000000000ffffffff16c818092983304daad176cee4a83c07de4adf516f34e83bea3d1603b05233fa0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001712826a3045a20000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7f76bfa162f20f42438fd164be5126cf5231e76f744694eda4c21845e8e9b873ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067200, - "block_timestamp": 1781546784, - "tx_hash": "0x90566fd038873990ab5db1d634b6f73810c0211426ec3e9918ff8875a2d78f7f", - "log_index": 236, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "5649000718389264639", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017128d6a304611", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000004e6548394384c0ff00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017128d6a3046110000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2c6cca8204aa358431960a0eefcf58959a1c05a4e3db4ef9bcf9c970f5caeddcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11067729, - "block_timestamp": 1781553132, - "tx_hash": "0x4a81ee44791c6a1de2631323035854a596f3dd5569d4de7d5a9051b3731f6df1", - "log_index": 22, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "578956100796", - "validTo": 4294967295, - "appData": "0x99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f9233", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001713446a305ec6", - "app_data_hash": "0x99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f9233", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000086cc7904bc00000000000000000000000000000000000000000000000000000000ffffffff99ecc17f6c6c9c69e2436d17b5928a17465525de93755e72afbed1b8d52f92330000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001713446a305ec60000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe32c85412667253adcf7cb0ea81f26cc98a620dc34a2e18c787a647ebfa3cb88ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11068198, - "block_timestamp": 1781558760, - "tx_hash": "0x7f148d5b878488be5e4477b9a1517f7e3639e352f25d90f457a3395befa22b4c", - "log_index": 146, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "10000000000000000", - "buyAmount": "3048799539", - "validTo": 4294967295, - "appData": "0x71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017140e6a3074d3", - "app_data_hash": "0x71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":171,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000b5b8fd3300000000000000000000000000000000000000000000000000000000ffffffff71364a17193fae1c28deab9c7c71b12c0bdb9863699b9ca62cb36ff8f63a14fc0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017140e6a3074d30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5af30b3e1fda697a33e6a3b4a25baddc48bd0107799510bd1fbd4420db4103f1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11068620, - "block_timestamp": 1781563836, - "tx_hash": "0x9772ca0fd1f1194222bf606861394b9cc70c3266ff4f2f76f18fcdce43c2c141", - "log_index": 23, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "190771165678", - "validTo": 4294967295, - "appData": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001714ce6a3088a3", - "app_data_hash": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000002c6ad8f9ee00000000000000000000000000000000000000000000000000000000ffffffff64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b6650000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001714ce6a3088a30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfa067d014ba286bd6ed78e671122083db93a2676d94536181143ff9c9cb3f8baba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069102, - "block_timestamp": 1781569632, - "tx_hash": "0xc6fd5e60d24961151bd5619c99856f7b57842882cad5c18ecbdc0fd477771de7", - "log_index": 985, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "sellAmount": "1000000000000000000", - "buyAmount": "69250732514", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017152d6a309f44", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d891" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d8910000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000101faa51e200000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017152d6a309f440000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf242a25b6da691a5e8a0b05fc51acbf1796e5cea40799aaaefbb80e37530c04fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069119, - "block_timestamp": 1781569836, - "tx_hash": "0x750d70ee942aa52a1ca2f0a9a546992c0c1c884e6f936fce6b5d38d4006b7a0e", - "log_index": 368, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8fbaa25c419790eb5dc0aad10429bf3f91c4d891", - "sellAmount": "1000000000000000000", - "buyAmount": "541394958706", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715306a30a019", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d891" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008fbaa25c419790eb5dc0aad10429bf3f91c4d8910000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000007e0da7797200000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715306a30a0190000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bd36dc7509f06176e1f014c9a98a5b3a7b3f0358a2e12b96576f8bfd9a013f9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069495, - "block_timestamp": 1781574348, - "tx_hash": "0xf2c736b9009ba16a237118c5f2d575ebec080ed0fd9421a616a73775772da406", - "log_index": 166, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "19870009077", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017155c6a30b1c6", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000004a05846f500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017155c6a30b1c60000000000000000000000000000000000000000" - } - }, - { - "uid": "0x591da4be8d1d0eaafe73f6bed1ae5d3df69e1b8ae125cebde6299502650f47cfba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069501, - "block_timestamp": 1781574420, - "tx_hash": "0xbf7a6f32d894960eee415dd823c00dcd49f73ae2df838858d6a933bb5fc72cbe", - "log_index": 131, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "89218327641", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017155e6a30b20b", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000014c5d3a45900000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017155e6a30b20b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa9a747d544a3cd20a53140da95a008b2fb46fea417499fc6b33850e0258c5f88ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11069948, - "block_timestamp": 1781579796, - "tx_hash": "0xc42df3d9eb8d640848f103615521b0ea6709610d0044148062114639c3d3967a", - "log_index": 114, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4e1e429b50de3bf686858acb771590a5b99cb019", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x4e1e429b50de3bf686858acb771590a5b99cb019", - "sellAmount": "50000000000000000", - "buyAmount": "83120067359", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715af6a30c70b", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004e1e429b50de3bf686858acb771590a5b99cb019" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000004e1e429b50de3bf686858acb771590a5b99cb01900000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000135a57931f00000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715af6a30c70b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b778cc7d7e269088a375b3ae33b82b171dd54eb43e0eaef75f05a2e1a3ec5e9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070077, - "block_timestamp": 1781581344, - "tx_hash": "0x708362d1cf729b96124c8c027fb98c750726089389c891973f6b14328c7ebccc", - "log_index": 154, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "500000000000000000", - "buyAmount": "132444734549", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715b36a30cd0d", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c400000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000001ed652445500000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715b36a30cd0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0a56f14f14f30cb5026b5c379b003031a1d72bc543c2e798043255a094087399ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070107, - "block_timestamp": 1781581704, - "tx_hash": "0xb090e903904bbfd0c2f98815ebf9147d02c1b77e6d9b6744d6a3af72d55a4294", - "log_index": 101, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "500000000000000000", - "buyAmount": "319493841113", - "validTo": 4294967295, - "appData": "0x46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715b56a30ce7f", - "app_data_hash": "0x46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":53,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c400000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000004a635120d900000000000000000000000000000000000000000000000000000000ffffffff46bf24a40af1ee801d88ca976876d9ddf899c6e101a3ba7dc6acbdd6ab50e2a70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715b56a30ce7f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4344512447bfa709d91a7e0eaf234fb48b19c6b8d83a2c7d96f08def593697a9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070306, - "block_timestamp": 1781584104, - "tx_hash": "0x9fa96f5bec18125c9561588321bcfd6044ca16f4eb49986a284b0fcf2e764e87", - "log_index": 548, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "1000000000000000000", - "buyAmount": "446250020796", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715bd6a30d36c", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db500000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000067e692efbc00000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715bd6a30d36c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x01026a746ad179cd13bbf8cc1952c15246e90d0a23c52acf97264081f2bd971dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070324, - "block_timestamp": 1781584320, - "tx_hash": "0x09cb5a006b001cc1b29d45425c999739abccb2cf4ae06a999b6b22bac5112e2e", - "log_index": 600, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "1000000000000000000", - "buyAmount": "79256498744", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715cd6a30d8be", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db500000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000012740e323800000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715cd6a30d8be0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x71b74cf65745a5b32d108cdfc9afef39cbf23031120895665cbc06b5e324ec59ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070394, - "block_timestamp": 1781585160, - "tx_hash": "0x3921052be15a828321da0c7ba50342045430184d76f65da30ae27e88c34db72d", - "log_index": 89, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xf7e7c1aa347f441c716948f186b0ef6c6234d8c4", - "sellAmount": "100000000000000000", - "buyAmount": "26138260745", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001715de6a30dbfc", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000f7e7c1aa347f441c716948f186b0ef6c6234d8c4000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000615f6350900000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001715de6a30dbfc0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8834595af5f88e9129f12f62859b821c9cc81137595f9ff4940be6cfe757e55dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11070716, - "block_timestamp": 1781589024, - "tx_hash": "0x11edfdcb9c2b414083cc2abaa14fed6b67a8bd33f7ad95d01001858127e779c3", - "log_index": 285, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f708151adaa0786803dd647934f1d0481df2b01", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8f708151adaa0786803dd647934f1d0481df2b01", - "sellAmount": "2000000000000000", - "buyAmount": "603498383", - "validTo": 4294967295, - "appData": "0x6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e83", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001716526a30eb15", - "app_data_hash": "0x6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e83", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":795,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f708151adaa0786803dd647934f1d0481df2b01" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008f708151adaa0786803dd647934f1d0481df2b0100000000000000000000000000000000000000000000000000071afd498d00000000000000000000000000000000000000000000000000000000000023f8a78f00000000000000000000000000000000000000000000000000000000ffffffff6f9554a900ec9ffaf1ae8d45a17b5b78e80dd977782af02739da113104596e830000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001716526a30eb150000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6f4fae101582d61c82bd155446182394295c797a51119f38f8d90b4ee5e24e6dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071674, - "block_timestamp": 1781600520, - "tx_hash": "0x4c39afc25d8527f6fc73af58485dd7148abb883b92be02415fc10a318179297a", - "log_index": 13, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27010997047", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717936a3117f9", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000649fb1b3700000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717936a3117f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x50211d94802faefd058477b136f5cfb8948e1963e878277038d4477b0455a70fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071675, - "block_timestamp": 1781600532, - "tx_hash": "0x0641e0eebe66e93ad9572d1192e30d9d8a13da8f66ed18f4365a1da71ffd8eb4", - "log_index": 6, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27001132773", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717956a31180d", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000006496496e500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717956a31180d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd925b4dc34f565a21d12238b5cbb5e2f82cfa5be72db5573f042c7909e0d7d9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071676, - "block_timestamp": 1781600544, - "tx_hash": "0x1ae827c196ce7d32d2dfcbba231d239d5c3bf6e829200ad28d3cdab921633567", - "log_index": 6, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "27011897771", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717976a31181a", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000064a08d9ab00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717976a31181a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x297cb16dfdf1b35bf093df7d9bada0df328d7de685a6845fb2025a56a82a37c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071677, - "block_timestamp": 1781600556, - "tx_hash": "0x5ec223cdf81a56b1a54ac6dafeaf62f51aa00fd9359dc1791e543b1745849024", - "log_index": 16, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15471675566", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017179a6a31182a", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000039a2f08ae00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017179a6a31182a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x57e246419eaa6bd5ef694ab05e4ecdb2b0ce8d416790413a2921ec1af8a275c0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071679, - "block_timestamp": 1781600580, - "tx_hash": "0x5285537ed66fc9e8f5b334b570ea778c15ae654c0b1d49b57d8c05a44efe37a6", - "log_index": 41, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15468548658", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017179c6a311835", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000399ff523200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017179c6a3118350000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb30c35c2a132725a956fe6e642e3a66cfd74e5e79d7eababd699144677eb3b9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071681, - "block_timestamp": 1781600604, - "tx_hash": "0x4dab8bf7b0f6e6777dbe78933594f2faa5a8e41824de3ea747f9d18e8bdf1156", - "log_index": 41, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "15472079403", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717a16a31184b", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000039a35322b00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717a16a31184b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x743f96095cc6bd65ad14eec58e23b8f9dd386c5f2931710ba4025c41f9049209ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071682, - "block_timestamp": 1781600616, - "tx_hash": "0x0fe33600fd357441d049a09965014ecc0929da005353e6cd1a2576f8510e11cc", - "log_index": 26, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13735449713", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717a66a311862", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000332b2547100000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717a66a3118620000000000000000000000000000000000000000" - } - }, - { - "uid": "0x713bc2862800b9e39458735758a79a4ab41a86d73948e89df308e6d76a94dc72ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071683, - "block_timestamp": 1781600628, - "tx_hash": "0x4d80ae379f4acc953561fe59567a04ac6c14340a905ef227aadc8c60d2ca84c6", - "log_index": 22, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13730353734", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717aa6a311870", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000033264924600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717aa6a3118700000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7925f2365b42ff994ae507d0fa423a5d0ea1670879b82a05bb5b473b67b48764ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071684, - "block_timestamp": 1781600640, - "tx_hash": "0xe134ba4a1256429ac69942ddbfa4fc6f0d7809bd45317dbef495c4d4f07cf537", - "log_index": 5, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "13727635152", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717ad6a31187a", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000003323b16d000000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717ad6a31187a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x11d613bf846ad2854276646c262d1fdffa4da25073fe8a4e527ce25cd1d5bbd6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11071687, - "block_timestamp": 1781600676, - "tx_hash": "0x4bde12bf86a0b46d72673f6cac98a22a54da8ed76c2a2c28032e77eb2818179f", - "log_index": 7, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x708344758be51a28ecd022440889a4e3e1cf7006", - "sellAmount": "100000000000000000", - "buyAmount": "12876644477", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001717b86a31189a", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000708344758be51a28ecd022440889a4e3e1cf7006000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000002ff82007d00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001717b86a31189a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd42de36dcd8b95635744e8779bbc25e160cee28a1cdd13388d05f8a4273bffe8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072025, - "block_timestamp": 1781604732, - "tx_hash": "0xd1ca15f5a2148b77247ea19788f686d06b28361e8d7ae15c78b2f04f31d24ddc", - "log_index": 459, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xc6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b", - "sellAmount": "1000000000000000", - "buyAmount": "52714844299323364", - "validTo": 4294967295, - "appData": "0x738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff719", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017187f6a31286a", - "app_data_hash": "0x738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff719", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1919,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000c6c1be3a571a9ebaaef228d3a6c950e3b8ffc17b00000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000bb47df20d9e7e400000000000000000000000000000000000000000000000000000000ffffffff738dff43ec778d7c45a9d00783e24b8c49757ba2e308e0ea01d1c6ebe97ff7190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017187f6a31286a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe81f36153af14af0302c2db20738ca3b00480d8a03401f2e14ba7d7b1a0b9bf4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072165, - "block_timestamp": 1781606412, - "tx_hash": "0x7c280e716fb0f6ef5a727e38a52a4162117f3a808fc987a423688e5fe893d584", - "log_index": 78, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "50000000000000000", - "buyAmount": "321861324592", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001718dc6a312f03", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000004af06e0f3000000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001718dc6a312f030000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfe8b70cc3481aecea2727b2212175cd9d79c2f56bce67474e7037e4ce3292a5cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072663, - "block_timestamp": 1781612388, - "tx_hash": "0x33ce7545031a104e4dd4e5740156b607b94fdeb761f15373f5a92bcf22b5362d", - "log_index": 212, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "sellAmount": "100000000000000000", - "buyAmount": "92561484587", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001719d76a314658", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000158d182b2b00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001719d76a3146580000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfb9ebfe279eb19122c6e7b9125e00c2c7f7cb80a5808ea873452c3b1235c3701ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072665, - "block_timestamp": 1781612412, - "tx_hash": "0x4877f1293f630dac7f92738eaa406fe120213f57f582f87ecf46a69089566c07", - "log_index": 334, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x898116872cc7a0c093bfaec82d9ddd3f0de65a0a", - "sellAmount": "100000000000000000", - "buyAmount": "92566945390", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001719da6a314673", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000898116872cc7a0c093bfaec82d9ddd3f0de65a0a000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000158d6b7e6e00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001719da6a3146730000000000000000000000000000000000000000" - } - }, - { - "uid": "0x76c0a5ea09f8289b5bd03f3a2bb4220ab2726b39071f7717ddee3e30f5799d04ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072730, - "block_timestamp": 1781613192, - "tx_hash": "0x412838658e207389b7e10a23d74c59c53704d6d45123de938fcb725ef0801b07", - "log_index": 505, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "26333692857", - "validTo": 4294967295, - "appData": "0x9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff98", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a016a314972", - "app_data_hash": "0x9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff98", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":190,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000006219c43b900000000000000000000000000000000000000000000000000000000ffffffff9967387c9a79ca88e1dc6ff8d198c78348ff54d54a408f8afe44ebfca97aff980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a016a3149720000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb2e7c4b5f72f18f275774d47d19b096b409dc5a886c12a9f9a91432593ea8fccba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072738, - "block_timestamp": 1781613288, - "tx_hash": "0x6492482df5d321f2caf21d051e9426260f618c4e306db622ddcc8f10c2b349f3", - "log_index": 194, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "21789124716", - "validTo": 4294967295, - "appData": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a066a3149d9", - "app_data_hash": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":179,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000512bba86c00000000000000000000000000000000000000000000000000000000ffffffff1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf224340000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a066a3149d90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x20484bb9f64267699903e513fe6c49b3dc7a1726533ff26dd6f5fe60d6798f94ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072742, - "block_timestamp": 1781613336, - "tx_hash": "0xbbc3e3cb7b2b34a8609e744b8e5df354b564c542e31044b09f40b65ccab1797d", - "log_index": 150, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x51229afb8db031d9bc864e191f84c543cd4f9527", - "sellAmount": "10000000000000000", - "buyAmount": "18236648912", - "validTo": 4294967295, - "appData": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a096a314a0a", - "app_data_hash": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":187,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000051229afb8db031d9bc864e191f84c543cd4f9527000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000043efd2dd000000000000000000000000000000000000000000000000000000000ffffffff62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a096a314a0a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x541fb237960f6d8153b157c77bde23e934b4d6f2ce0dca4bc9d8566571ad701bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072774, - "block_timestamp": 1781613720, - "tx_hash": "0x35ca0c32803283544d7742e5a6c2d9fa8e87b106c4228883414520c9a53a930e", - "log_index": 225, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "4441821088", - "validTo": 4294967295, - "appData": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a1d6a314b85", - "app_data_hash": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":531,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000108c0cfa000000000000000000000000000000000000000000000000000000000ffffffff33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab15750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a1d6a314b850000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2404f184f0512bb7e2a8b6b0b82bde41e259ba6529bbc69e89c581eed6186dafba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072774, - "block_timestamp": 1781613720, - "tx_hash": "0x2adc37446bd8ea5458d9e5cb44e557bb4b5e765bcba7872b09245c22efcb2394", - "log_index": 252, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "2387543146", - "validTo": 4294967295, - "appData": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a1e6a314b92", - "app_data_hash": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":513,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008e4f046a00000000000000000000000000000000000000000000000000000000ffffffffa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a1e6a314b920000000000000000000000000000000000000000" - } - }, - { - "uid": "0x30e44c5398175513ef4700b3a38e2cea113a9921fa3ce323e2736531e9bedc01ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072791, - "block_timestamp": 1781613924, - "tx_hash": "0x01dfa4ffcae8a50424897ebb859e872895f81432b4c88785063f8c1182891302", - "log_index": 240, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "3857852888", - "validTo": 4294967295, - "appData": "0x499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f20", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a276a314c61", - "app_data_hash": "0x499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f20", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":572,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000e5f229d800000000000000000000000000000000000000000000000000000000ffffffff499a1601c6014dd5f047d6a71f5a4be621a5e26b098dcd7a375a7aa3f8bc0f200000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a276a314c610000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9a340499f139e282ecf0815e3429781261a4ecc7d016444749f2c3dae58b16dbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072801, - "block_timestamp": 1781614044, - "tx_hash": "0x453ef40ffcd481e37423ec06980dcebeea5a369bc2b83559d2909cf415c8f25c", - "log_index": 169, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x384f7724d79f5a7b583a220de92a30904194b212", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x384f7724d79f5a7b583a220de92a30904194b212", - "sellAmount": "3000000000000000", - "buyAmount": "3628945089", - "validTo": 4294967295, - "appData": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a2f6a314cd1", - "app_data_hash": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":516,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000384f7724d79f5a7b583a220de92a30904194b212000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000d84d4ec100000000000000000000000000000000000000000000000000000000ffffffff15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a2f6a314cd10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7f7b151d3a1c04eab0c0aadbd4ff781e50f523310cbb23a9bcf66e6cca1fd560ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072849, - "block_timestamp": 1781614620, - "tx_hash": "0xfb4608e9f984b2917bea5cb950e9696ded0d43f8b3d125137151770671f492a6", - "log_index": 199, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "sellAmount": "3000000000000000", - "buyAmount": "3332943805", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a4c6a314f18", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c6a8afbd00000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a4c6a314f180000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb68eeaf4ca2524fde27b8e79827619475a60bad6b3c881f09ca9ba893be6835fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072850, - "block_timestamp": 1781614632, - "tx_hash": "0xee50f593bc102291a85509339e00fb27ef085636da8430a904b194c21a2860a5", - "log_index": 106, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xda7ffa0d9e85c521fd320fced929bc903efb4fe7", - "sellAmount": "3000000000000000", - "buyAmount": "1991265682", - "validTo": 4294967295, - "appData": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a4b6a314f1e", - "app_data_hash": "0x6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad2321", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":554,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000da7ffa0d9e85c521fd320fced929bc903efb4fe7000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000076b04d9200000000000000000000000000000000000000000000000000000000ffffffff6971197d13e6bdc6ca676e2d7c86dc2d224e12a0faaf362dd296ccdbd9ad23210000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a4b6a314f1e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5395405d6c65df4b6a42b9f8c60139a52cda62d3ecdebbc4e7a8387ab02837d0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072881, - "block_timestamp": 1781615004, - "tx_hash": "0xe66e7407a3105e94fe878f1f1ede17fd0c506b0a6cd162d88982502decdd3b6f", - "log_index": 162, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "sellAmount": "3000000000000000", - "buyAmount": "3140775257", - "validTo": 4294967295, - "appData": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a646a315098", - "app_data_hash": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":510,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000bb346d5900000000000000000000000000000000000000000000000000000000ffffffff0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a646a3150980000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45d5563b3d4c9e5ecc9ecfce7d67ce7d36c7d0a40fa5a9f737f54d388e54953dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072881, - "block_timestamp": 1781615004, - "tx_hash": "0xba8287dac4856aad97cb5fd38c3c2c6c17376cc05d33d75db0e1210b936564fb", - "log_index": 166, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xc83673385fc52f3bcbac6fed3225e216788f4919", - "sellAmount": "3000000000000000", - "buyAmount": "2006882155", - "validTo": 4294967295, - "appData": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a676a31509d", - "app_data_hash": "0x1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c81", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":511,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000c83673385fc52f3bcbac6fed3225e216788f4919000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000779e976b00000000000000000000000000000000000000000000000000000000ffffffff1874540551cf00f1a41fdd9b040fad35aef82b22a33c971b368af4dca9c11c810000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a676a31509d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x15431ff47d42cca6ee7501a570f0d73612d0ac5070b62c00b7440b9512c4e71cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072907, - "block_timestamp": 1781615316, - "tx_hash": "0x51890dec865bf35c02f7bda9ece8b8193680bef10b5b93cdc633c4de4c2e41bc", - "log_index": 150, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "230514135456539324", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a736a3151cd", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000332f3628797e2bc00000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a736a3151cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xab2d5a8134aa4e175f7bf81e59c62b118ace616fdc7880feb6f597be86ffcc8fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072907, - "block_timestamp": 1781615316, - "tx_hash": "0xcd40bb1cac946ed5b9fefe773e2e95206832c8bef085a8c8c1b6d69eee936f3d", - "log_index": 158, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "2885323190", - "validTo": 4294967295, - "appData": "0xaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d2", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a726a3151d1", - "app_data_hash": "0xaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d2", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":564,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000abfa89b600000000000000000000000000000000000000000000000000000000ffffffffaaef87acb8c6ef79296ec6f1eeb0f6139807717f74b992b66a084f2d9667c9d20000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a726a3151d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3918584cfaffc3f5a561a06e6b625c1b8e33f1c3daf2209356262a53c85b793eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072915, - "block_timestamp": 1781615412, - "tx_hash": "0x3f97018516748e8456bbbac405a4017f84476a75a8d90d08043a301fcc97a9ef", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe9efa5cea161220a0ed136560df741783d821ace", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xe9efa5cea161220a0ed136560df741783d821ace", - "sellAmount": "3000000000000000", - "buyAmount": "1805702375", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a796a315235", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000e9efa5cea161220a0ed136560df741783d821ace000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006ba0d4e700000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a796a3152350000000000000000000000000000000000000000" - } - }, - { - "uid": "0x433ded5d6fe27454cc358a92e81df6d8706d169950a0874c00aee5bb6ed83495ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072937, - "block_timestamp": 1781615676, - "tx_hash": "0xac05ddeb0612687ee5e531d2e78b71ae1969988dbc0df307a7d837d11a452f77", - "log_index": 178, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7070c60252118e238594af6b351675953732163f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7070c60252118e238594af6b351675953732163f", - "sellAmount": "3000000000000000", - "buyAmount": "1054702801", - "validTo": 4294967295, - "appData": "0xd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a836a315332", - "app_data_hash": "0xd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":520,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007070c60252118e238594af6b351675953732163f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007070c60252118e238594af6b351675953732163f000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000003edd7cd100000000000000000000000000000000000000000000000000000000ffffffffd32917116b84b2989ba64ed7365b1433d543b6311d54f467457817c612489ca40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a836a3153320000000000000000000000000000000000000000" - } - }, - { - "uid": "0x38e4a8d56d59f444f58688539a896771696fa639882a5a2164b48e42d48d9604ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11072938, - "block_timestamp": 1781615688, - "tx_hash": "0x55e1326ec4f70606033540e111079e3b6d87dcf6140cd1f9b6d536b5afccdb8c", - "log_index": 159, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7070c60252118e238594af6b351675953732163f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7070c60252118e238594af6b351675953732163f", - "sellAmount": "3000000000000000", - "buyAmount": "1788664892", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171a876a315341", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007070c60252118e238594af6b351675953732163f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007070c60252118e238594af6b351675953732163f000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006a9cdc3c00000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171a876a3153410000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2391bfae00a69a1eec3c7514405867f7e0342e4882de0bde41beee6c9cac0353ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073096, - "block_timestamp": 1781617584, - "tx_hash": "0xb43fac202fe357c43e491ce513fc1b0ddea3b66333d1150f181bf61427691d0b", - "log_index": 17, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "14682257116", - "validTo": 4294967295, - "appData": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ada6a315aa7", - "app_data_hash": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":75,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000036b2176dc00000000000000000000000000000000000000000000000000000000ffffffff1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ada6a315aa70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf6cd036ee01440077ddbbead27c00c75addde136aa83c69fe6023c577dced7c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073102, - "block_timestamp": 1781617656, - "tx_hash": "0x857d9c0b382f141e2d0d5f3c302776c29ed951cd0596962d3394b93bbe8b15d3", - "log_index": 138, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "sellAmount": "3000000000000000", - "buyAmount": "686919870", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ae26a315aeb", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000028f190be00000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ae26a315aeb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x157fa6bcd877fe40fe487109c21fb45d5430043179c709d5e1ae49c4f275d6a5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073102, - "block_timestamp": 1781617656, - "tx_hash": "0x1d1f3fa611dbe00ed52e1cf5f5bea9e7e5b2a991915f9eef02809da7b1fe4576", - "log_index": 156, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xc61aa42ce3af01501792ff9b5556fff1e6276e56", - "sellAmount": "3000000000000000", - "buyAmount": "1786114895", - "validTo": 4294967295, - "appData": "0xe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd75", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ae56a315af8", - "app_data_hash": "0xe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd75", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":561,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000c61aa42ce3af01501792ff9b5556fff1e6276e56000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006a75f34f00000000000000000000000000000000000000000000000000000000ffffffffe7e648ef11f8b2b44a56f88d0296447a11912ea586d4d6995b6d6b983fe4dd750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ae56a315af80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x70aeba19202765e0d07a449afbac383c09fe44be8b1c8c12a133253dcffc0ebeba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073107, - "block_timestamp": 1781617716, - "tx_hash": "0x9f55651c935e26c0c11ce8f52c4c69e19de50bb42f4ea4e7c1897e236c7f1c80", - "log_index": 59, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "62580370391", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171aef6a315b29", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000e9214abd700000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171aef6a315b290000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8d4ca0b6f37a815fbe67c9d7fb1e61dbe269de358b619244057f6c519eda0aa5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073145, - "block_timestamp": 1781618172, - "tx_hash": "0xf8dde98f0dc48f8ee5b394b7587d5a6918e478ba16c25a818e4604ce0e9bf0a9", - "log_index": 144, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "sellAmount": "3000000000000000", - "buyAmount": "694500747", - "validTo": 4294967295, - "appData": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b186a315cf9", - "app_data_hash": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":474,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000029653d8b00000000000000000000000000000000000000000000000000000000ffffffffb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c5470000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b186a315cf90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45902e3ec7c9085a3da0cb55cb8b6dec3984374bd932f6c8b3f944b3176c4e84ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073145, - "block_timestamp": 1781618172, - "tx_hash": "0x77c93eec4c8033450dcec4b48461ed9709aa1eae7f4fda9ab3c515dc215ad211", - "log_index": 152, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5ea2b6636fe1394513e76cc04f0355668785a00b", - "sellAmount": "3000000000000000", - "buyAmount": "1593757918", - "validTo": 4294967295, - "appData": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b1b6a315cfc", - "app_data_hash": "0xb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c547", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":474,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005ea2b6636fe1394513e76cc04f0355668785a00b000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000005efed0de00000000000000000000000000000000000000000000000000000000ffffffffb46844b7a39d6e8495500a4a4f741c54cbdc406f49a0cb5f8768d715d307c5470000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b1b6a315cfc0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x19d6b26a5bedf930daed07eb01fc701a27422cd0395242627c86669b099a1a75ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073178, - "block_timestamp": 1781618568, - "tx_hash": "0x55e96eec17c21c5948e01661f36574f76e619d569229250ba7572b1c9cc803b1", - "log_index": 699, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "sellAmount": "100000000000000000", - "buyAmount": "21015361334", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b4f6a315e78", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000004e49cf73600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b4f6a315e780000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8ecd3588048db716f4306de3fd22cf27ce9004fc4b2403b749785a8f5867a3f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073179, - "block_timestamp": 1781618580, - "tx_hash": "0x830525807f75e6520f2a8da4265174b725dd46248973780fc514a5515e90a357", - "log_index": 148, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "sellAmount": "4000000000000000", - "buyAmount": "1011594396", - "validTo": 4294967295, - "appData": "0xca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b546a315e8b", - "app_data_hash": "0xca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":408,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9000000000000000000000000000000000000000000000000000e35fa931a0000000000000000000000000000000000000000000000000000000000003c4bb49c00000000000000000000000000000000000000000000000000000000ffffffffca5142f1728cd4f20825330f3d86dbf935f3b2fb8743f04614f3b919110eafc50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b546a315e8b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2baee5d91be5143757f83a52708a428a3440afe7e2d87d1465818f6efcf510c5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073185, - "block_timestamp": 1781618652, - "tx_hash": "0xb84ccd16027867aa988bd349df2b9af3d88b59e7a4662e9008709edc17f67a62", - "log_index": 347, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7918399ce6ef12c8c422d4d52a56ef02b9c0cab9", - "sellAmount": "3000000000000000", - "buyAmount": "2910018946", - "validTo": 4294967295, - "appData": "0x6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b666a315ed8", - "app_data_hash": "0x6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":568,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007918399ce6ef12c8c422d4d52a56ef02b9c0cab9000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ad735d8200000000000000000000000000000000000000000000000000000000ffffffff6afa204df74e6e5f755bc405262584b2f471acb833756043540156dbedf9e6e60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b666a315ed80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0a684eb75ffc7ead51b5d0df5e59a452746f4550602050fe0bfa449f43ed1706ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073186, - "block_timestamp": 1781618664, - "tx_hash": "0x81806348218070d5f3fe7516088d390a3c37dd2f4ea11e25126db2471058bf8d", - "log_index": 357, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x8f7bc29dea8d59f8d42fcf5089230e15e84eedc0", - "sellAmount": "1000000000000000000", - "buyAmount": "88599873389744932852", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b656a315ee7", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008f7bc29dea8d59f8d42fcf5089230e15e84eedc00000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000004cd91fb6cfc54c7f400000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b656a315ee70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xccf652d3d6b0b5c7b05cdf2f6b0a864c2edbeddf18f61f9b791e842976167000ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073220, - "block_timestamp": 1781619072, - "tx_hash": "0x08b769677be1402769b3e5a76dc0cf6a0be36e4bd774e2f28bb13fe82fa66aca", - "log_index": 215, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "sellAmount": "3000000000000000", - "buyAmount": "619113046", - "validTo": 4294967295, - "appData": "0x6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b986a316072", - "app_data_hash": "0x6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":558,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000024e6ea5600000000000000000000000000000000000000000000000000000000ffffffff6d5310f10496a960dcc1346999a4b1069b0aa86913fffbf43bc05fdd9e045de70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b986a3160720000000000000000000000000000000000000000" - } - }, - { - "uid": "0x51bd84d817e6f56748d0fd39d1aa340d75b38326cdb92c0b8ce7c7fad587b308ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073220, - "block_timestamp": 1781619072, - "tx_hash": "0xb1a81b5705fa1c54224e838156e2e5fdc1d7a0154f77b0da840c88332b2ace13", - "log_index": 258, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xe5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a", - "sellAmount": "3000000000000000", - "buyAmount": "2289564261", - "validTo": 4294967295, - "appData": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171b9a6a316078", - "app_data_hash": "0x33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab1575", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":531,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000e5ab8b34fb7b8c06ca183c7da2d0c14fbcd5e80a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008877fa6500000000000000000000000000000000000000000000000000000000ffffffff33704e0fd722698ca109301285107e0c368723a5eef29ecc64275822c1ab15750000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171b9a6a3160780000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f2f050fc8d390b6b9c12f4ba62288e91c22bf6543b5dbdc3beba36a2ddc82f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073251, - "block_timestamp": 1781619444, - "tx_hash": "0x8b040427c88d3a95163a665fad80617889de0c617e2f45901bc48c0ff5ae897b", - "log_index": 222, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "541790631", - "validTo": 4294967295, - "appData": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171bd96a3161ea", - "app_data_hash": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":524,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000204b11a700000000000000000000000000000000000000000000000000000000ffffffff05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f91130000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171bd96a3161ea0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9b53383bfa026929eb6cda36da0bd4e4cbad109dcbf9417dd99395cca265c53cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073251, - "block_timestamp": 1781619444, - "tx_hash": "0x3814dbdb4ab267e920b38f80071d176f6990ff551767cd79639486a8d2d20c74", - "log_index": 224, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "2973790710", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171bdd6a3161ec", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b14071f600000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171bdd6a3161ec0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd55d2e7ed4f86b6caa210f3ff7ee7d900dc951277031281c993beefbb69ef2bcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073259, - "block_timestamp": 1781619540, - "tx_hash": "0xdd663860708fb57ff6dfc265457f8bdbb4d129bdb9307a6ad05663ba8326e544", - "log_index": 229, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3982ceb2f7d53b11c6bc484926af26b7e765b7cc", - "sellAmount": "3000000000000000", - "buyAmount": "484371166", - "validTo": 4294967295, - "appData": "0x531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b6861", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171be86a316255", - "app_data_hash": "0x531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b6861", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":545,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003982ceb2f7d53b11c6bc484926af26b7e765b7cc000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001cdeeade00000000000000000000000000000000000000000000000000000000ffffffff531c0e30d6f26adc9a153a50f49a9d9bcbf34369ceeb89c926257327749b68610000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171be86a3162550000000000000000000000000000000000000000" - } - }, - { - "uid": "0x355e6c2e302cefd0ada31df38c686d8f38b7bd032adc266084dcc10ade0eb609ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073304, - "block_timestamp": 1781620080, - "tx_hash": "0xd958908a6f6a378f0c012eacfb9de658d5c4898b440ead38ab0311f2b5e0b096", - "log_index": 409, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "23095671606", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c086a31645f", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb14000000000000000000000000000000000000000000000000000000000005609bfb3600000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c086a31645f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb75a1e65b9ba881279d9a5b1ca3326142d8b5ced928c4907f2f07d8b44a65be6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073309, - "block_timestamp": 1781620140, - "tx_hash": "0x734d84de93efb3579a2cc102c1b3b0def3e2733385f510935ca48ad9793dea98", - "log_index": 403, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "194420597291", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c0d6a31649d", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002d445ee22b00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c0d6a31649d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x812f257ae548aa1b5b889e7aaee8efd95a659d04cc4d1d3a04172671f501c085ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073316, - "block_timestamp": 1781620224, - "tx_hash": "0x5c2e710f41053a5019bf97680649c2274a969d4844099d4c874e10cc3c338aa6", - "log_index": 203, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "200000000000000000", - "buyAmount": "192429522577", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c176a3164da", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f100000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002ccdb17e9100000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c176a3164da0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x25efa78a27f938c4e2b8e63eaffb88c699fe50cbb45208bf43e2599e4869d04dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073319, - "block_timestamp": 1781620260, - "tx_hash": "0x43a69de0db6b04095d7fc14eed4a140d429195ba21c7aea7228d0e5803a55783", - "log_index": 85, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "300000000000000000", - "buyAmount": "267776716581", - "validTo": 4294967295, - "appData": "0x9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce16", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c196a3164f8", - "app_data_hash": "0x9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce16", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":54,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f10000000000000000000000000000000000000000000000000429d069189e00000000000000000000000000000000000000000000000000000000003e58bc6f2500000000000000000000000000000000000000000000000000000000ffffffff9cb1341bb7179aabe49761f7302e04843953058e67a02e6b6166325de14dce160000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c196a3164f80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x72aee1ae1c433703176cde121bec85139a44458535b3bcd36968ed55df3948e4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073340, - "block_timestamp": 1781620512, - "tx_hash": "0x027f9c1142c666432f96ab6567fde58a18a972b0ae1576cbd6cc9abc75a9ec93", - "log_index": 105, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "189997026899", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c2e6a316608", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000002c3cb48e5300000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c2e6a3166080000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe10e143e6c9d66cdbbbbf93d7a3fa23630b308ddb0d3d61827f4aacc663bea87ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073345, - "block_timestamp": 1781620572, - "tx_hash": "0x8438318f21a950eaf032774557ac00be217590a753d17759308e5620f68e198f", - "log_index": 213, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "200000000000000000", - "buyAmount": "61186347867", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c376a316654", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000000e3efd935b00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c376a3166540000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1cb540d1a57080ddb38eb4b129e0317b601c2e28b5ef08d8c33f3d435ce12536ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073377, - "block_timestamp": 1781620956, - "tx_hash": "0xc2e54e0b13d9e691e19fd9aa9d1db77ce1c438402a4a9522d477d1352896a79c", - "log_index": 187, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5c47952bc0e6b41688635449a30317c7e885d4f1", - "sellAmount": "1000000000000000000", - "buyAmount": "580008099158", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c4b6a3167cb", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005c47952bc0e6b41688635449a30317c7e885d4f10000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000870b2d3d5600000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c4b6a3167cb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4a27b3b7a1a975a22d75c855995cca9113d823a11ff6c3b1a3e413a370838115ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073446, - "block_timestamp": 1781621784, - "tx_hash": "0x2c0211b8cea8caec994007ed82d3070fb1dd8e481bdec702331b1f695653f79d", - "log_index": 2790, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "sellAmount": "3000000000000000000", - "buyAmount": "1370106414465977223159", - "validTo": 4294967295, - "appData": "0x3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c6c6a316b0d", - "app_data_hash": "0x3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e00000000000000000000000000000000000000000000000029a2241af62c000000000000000000000000000000000000000000000000004a460bccd268b107f700000000000000000000000000000000000000000000000000000000ffffffff3c0b3ab24f873a4919868710a5c5790a77284691f530562748fae381debe19d10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c6c6a316b0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6f60c9b78933f488946333ab27e5c1f3c215f43cce0a747be9a826bc3050264cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073450, - "block_timestamp": 1781621832, - "tx_hash": "0xbd0c6b586a4f19ae2108f9a120430fe5c79c244692a57d694e124085001c614d", - "log_index": 359, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0x6085a14ae513d765d5157e2d24fa4380ed92412e", - "sellAmount": "1500000000000000000", - "buyAmount": "699127275", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171c736a316b3c", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc910000000000000000000000006085a14ae513d765d5157e2d24fa4380ed92412e00000000000000000000000000000000000000000000000014d1120d7b1600000000000000000000000000000000000000000000000000000000000029abd5eb00000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171c736a316b3c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2afac9af61ab99e267d1de96a6ca82c4d33e4647927e7596a59b3e416d459bc7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073618, - "block_timestamp": 1781623884, - "tx_hash": "0x1312cec7fef9a9a5e0abbf4ec73dba23233c0609e234bbaf948d0d418c9c9e9e", - "log_index": 218, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0c231dc9f63d19646097e20304bf192aafd6a191", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0c231dc9f63d19646097e20304bf192aafd6a191", - "sellAmount": "5000000000000000", - "buyAmount": "833074990", - "validTo": 4294967295, - "appData": "0x122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ccf6a31732b", - "app_data_hash": "0x122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":311,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000c231dc9f63d19646097e20304bf192aafd6a191" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000c231dc9f63d19646097e20304bf192aafd6a1910000000000000000000000000000000000000000000000000011c37937e080000000000000000000000000000000000000000000000000000000000031a7b72e00000000000000000000000000000000000000000000000000000000ffffffff122dd7fc123267a29234f97dfe8c89658ef81527a38b73f9c93983a5949c36d60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ccf6a31732b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x09eed0819f2b14d64402166002ea97bbfe3055e35e13e4e403e05dc564df93a7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073639, - "block_timestamp": 1781624136, - "tx_hash": "0xc45711228524d1c10c288e93be4ecb68965d945ccf8f52945dff8e57f2afb6b6", - "log_index": 201, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "sellAmount": "3000000000000000", - "buyAmount": "453116641", - "validTo": 4294967295, - "appData": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ce26a317440", - "app_data_hash": "0x05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f9113", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":524,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001b0202e100000000000000000000000000000000000000000000000000000000ffffffff05fafe32e4d71be1dcd68af804027d7acd76d5049a3dfc68ee255a7e0b5f91130000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ce26a3174400000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0f04118e0cba57091b0b0d2a2f764e70e0e2374368340211e1e7c994f9081cf9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073639, - "block_timestamp": 1781624136, - "tx_hash": "0xc590b9e369ba16d3b8ab2d5f3fd4f7ff5910284608afe032620c43b260ec1d33", - "log_index": 208, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xeab41fb7d5bc649782c4accde66722058faf79ce", - "sellAmount": "3000000000000000", - "buyAmount": "11588302619", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171ce56a317440", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000eab41fb7d5bc649782c4accde66722058faf79ce000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002b2b7771b00000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171ce56a3174400000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb1445f4662e90380c4ce16df62586e761c0c02ce108fe27b02b0da7480e68c47ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073661, - "block_timestamp": 1781624400, - "tx_hash": "0x0b1f2b9a8091f80e673a52ca58cf17e3705b172dd348809046c5aacbc8a0051f", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "sellAmount": "3000000000000000", - "buyAmount": "438953318", - "validTo": 4294967295, - "appData": "0x5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171cf46a31754f", - "app_data_hash": "0x5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":542,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001a29e56600000000000000000000000000000000000000000000000000000000ffffffff5d6969410256527653fa247b30fcaabf2dd6bc590e6739ebe92c8b6bedbf220b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171cf46a31754f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1b9f5ae8f056774d43acb0af838b27fc37f35058038cabcf3acd2790952f40f4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073662, - "block_timestamp": 1781624412, - "tx_hash": "0x8cec9fd3998b86d41d8632fbf824458ed8f2ba2fe1b94a9d598c1491bf660028", - "log_index": 185, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x5a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95", - "sellAmount": "3000000000000000", - "buyAmount": "11689102009", - "validTo": 4294967295, - "appData": "0x5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e636", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171cf66a317554", - "app_data_hash": "0x5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e636", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":517,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000005a4a3e9b7c3fb82b66e4f3c295d68bda7764ff95000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002b8b98ab900000000000000000000000000000000000000000000000000000000ffffffff5d157f166954f6168aeaad579a1980912509a3af16577f2bb47406b3ef63e6360000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171cf66a3175540000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbf218ce6f1ba3c450b82ab060f4ac481ba61399e5aca8848fed7713652ac41c3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073684, - "block_timestamp": 1781624676, - "tx_hash": "0x8470fe076cb2466863f68027ca42ed1079015256d95e5ddbf278ce472ae70773", - "log_index": 185, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "sellAmount": "3000000000000000", - "buyAmount": "11461108175", - "validTo": 4294967295, - "appData": "0x7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f287253", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d076a317659", - "app_data_hash": "0x7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f287253", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":547,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002ab22a1cf00000000000000000000000000000000000000000000000000000000ffffffff7dc348e6a9a5ce20f0d3578e926dc1f7245e95749461663dcabbab293f2872530000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d076a3176590000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd000d8e026b97a045f038b0ccb1321203ce8d3fdc9d5d4bb6ac936703672a66ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073684, - "block_timestamp": 1781624676, - "tx_hash": "0x2869062f73730ff857e7cfaa18fe4a810a68be844b5c2215a9d8a1d088ed79da", - "log_index": 186, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xabaab35331caca64c9d1bb37a76e8adaccb0c310", - "sellAmount": "3000000000000000", - "buyAmount": "425970083", - "validTo": 4294967295, - "appData": "0x9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d046a317658", - "app_data_hash": "0x9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":555,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000abaab35331caca64c9d1bb37a76e8adaccb0c310000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001963c9a300000000000000000000000000000000000000000000000000000000ffffffff9ef7b0abe5794687f99254ea6cd6071d258daa2248aec3baa1cd0db60913abc10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d046a3176580000000000000000000000000000000000000000" - } - }, - { - "uid": "0x41e05a801c0e465cb7ffcdbd8d0a34aadc38044342961f344fdd79fc017053b8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073706, - "block_timestamp": 1781624940, - "tx_hash": "0x71161a569717d9210988d71576a178e210a94869bbf783fa1b05c54481c9852f", - "log_index": 96, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "11437554800", - "validTo": 4294967295, - "appData": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d156a317765", - "app_data_hash": "0x15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":516,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002a9bb3c7000000000000000000000000000000000000000000000000000000000ffffffff15e6ad044637c621a9610df83bef56845d38da755a4c4a1fe6a56c4e74b37adf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d156a3177650000000000000000000000000000000000000000" - } - }, - { - "uid": "0x46728c280bfaeaa5f0a65000af2da2ebd603a85bcb9507a1bdd2bc987e0568daba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073706, - "block_timestamp": 1781624940, - "tx_hash": "0xda884f4f063fb6525034e9be66e869f6c3204a3bbd5c61069218b9f060b603f5", - "log_index": 109, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "417610655", - "validTo": 4294967295, - "appData": "0xe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a398", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d146a317768", - "app_data_hash": "0xe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a398", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":534,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000018e43b9f00000000000000000000000000000000000000000000000000000000ffffffffe25d4a89173e7a5a67d22da187bee972261540bbd3320b98e741b1ffa1b2a3980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d146a3177680000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7d517f6befad866f8b47f789b1d89b88c7c500de8727624412c55d55e037cfd6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073716, - "block_timestamp": 1781625060, - "tx_hash": "0xbfac0479a4477bf549c0867ee904cd68ebddb9d3e924ca4b35bfdd9373c902e9", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4277446d349a403bfd42f607505ef460d80b2de7", - "sellAmount": "3000000000000000", - "buyAmount": "9500929904", - "validTo": 4294967295, - "appData": "0x35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f9246", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d1b6a3177df", - "app_data_hash": "0x35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f9246", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":521,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004277446d349a403bfd42f607505ef460d80b2de7000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000002364caf7000000000000000000000000000000000000000000000000000000000ffffffff35b445ce36cc78696cdce35d54ca082162061890b4faefddebe7ef3efa5f92460000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d1b6a3177df0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcd0993d3d31b1d299b5e8a86bbbbd81cfa0d1c3183e1d49acf49b5182da382b6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073738, - "block_timestamp": 1781625324, - "tx_hash": "0xe6d83d4112d9d841cc901676e704ea537d1c82aa288e6b2ba1532dcad5602d5f", - "log_index": 95, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "sellAmount": "3000000000000000", - "buyAmount": "410618017", - "validTo": 4294967295, - "appData": "0xcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d286a3178e0", - "app_data_hash": "0xcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":519,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000187988a100000000000000000000000000000000000000000000000000000000ffffffffcf3114251a1e949e931cc99c5691787388439b9c1a2de56217352420c5283ed80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d286a3178e00000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd41c9e0bca8c1e02c11816f4446a5a54b56f1927fd15180ddd2cbaec9e12a5a9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073738, - "block_timestamp": 1781625324, - "tx_hash": "0x28d7ad9009cf447e9a18050edb2fe6522c922a75d6e921d6bd13b3d1b0538820", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x9132748505f3439f60002e28d56cc74baf7a9114", - "sellAmount": "3000000000000000", - "buyAmount": "9256282275", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d2a6a3178e8", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000009132748505f3439f60002e28d56cc74baf7a9114000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000227b7a8a300000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d2a6a3178e80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x728367f6b832283d0e0ceaaedef9420ae5d7790a6c877e2e0dcc47bd5617e01cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11073789, - "block_timestamp": 1781625936, - "tx_hash": "0x5e5842f238c3b58630e4ab74eca2d1a8882415674e711a700c478bb848512683", - "log_index": 305, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30100000000000000", - "buyAmount": "1201007962241800920", - "validTo": 4294967295, - "appData": "0x6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171d3e6a317b44", - "app_data_hash": "0x6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":94,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006aefca5fbd400000000000000000000000000000000000000000000000000010aad660e1d69ad800000000000000000000000000000000000000000000000000000000ffffffff6a224f81444b2326efff172daa624325f38551f9a42b44d9cfd458ac488658b10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171d3e6a317b440000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa78cabeb3b2758df956e580b224d896ea4a14e332b6643e82f4190d3f2ecbd4bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074351, - "block_timestamp": 1781632692, - "tx_hash": "0x3fa10e4c2a5affc56856c70af4604824a7313541691a0caefe7a095706f917ac", - "log_index": 36, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "sellAmount": "3000000000000000", - "buyAmount": "402068215", - "validTo": 4294967295, - "appData": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e216a3195b0", - "app_data_hash": "0x34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd52", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":577,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017f712f700000000000000000000000000000000000000000000000000000000ffffffff34824764379ccf534c0a14fa2a81f7290f8aeb9960fa72d60f2a6075eabddd520000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e216a3195b00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x211dd498ba00c935fb075c33f3a9429225d12ac9e54d34845d0f24de98bdcc7bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074351, - "block_timestamp": 1781632692, - "tx_hash": "0xd03f52426f6691ca1d86b4ae3177202cae16e43caebf5f1d37c5260e2c976d98", - "log_index": 39, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x85851fcb5d7403d77949432b4cd63790b962e92b", - "sellAmount": "3000000000000000", - "buyAmount": "4689867573", - "validTo": 4294967295, - "appData": "0x031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c0797255", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e236a3195b6", - "app_data_hash": "0x031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c0797255", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":550,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000085851fcb5d7403d77949432b4cd63790b962e92b000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000011789b33500000000000000000000000000000000000000000000000000000000ffffffff031c2b63075bdf33cf10ea93a20c96f964f3ba47df98ca5eb971acf7c07972550000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e236a3195b60000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5f686fb710c1ff7581299a6e3b431d11a23eafe85fa2117019f5cf10be012df5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074387, - "block_timestamp": 1781633124, - "tx_hash": "0x8337252f83605d22fe12006a512b7c741d7b88da2c4178ebeae6990a793945f9", - "log_index": 51, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "sellAmount": "3000000000000000", - "buyAmount": "406673606", - "validTo": 4294967295, - "appData": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e316a31975d", - "app_data_hash": "0xa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":513,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000183d58c600000000000000000000000000000000000000000000000000000000ffffffffa1301781465d1f008067fc72dfcd0b3114fe8b2642a0c92f530eea6e414738a90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e316a31975d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3b7b5aaa7a5d2ac99f87d9d5c1c569136cf88a68bcee8215a4dc065bd29a187cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074387, - "block_timestamp": 1781633124, - "tx_hash": "0x1eb992352b0faee9e356adc922b1c9da194dc542f6fc1449f3483800e1c8b400", - "log_index": 63, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3058b17394d11fd8b847e6522a0e6302cddf1bdf", - "sellAmount": "3000000000000000", - "buyAmount": "4255981134", - "validTo": 4294967295, - "appData": "0x9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e326a319762", - "app_data_hash": "0x9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":559,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003058b17394d11fd8b847e6522a0e6302cddf1bdf000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000fdad1e4e00000000000000000000000000000000000000000000000000000000ffffffff9c6c43fadf31b1986d9f427804c8d1793ab065d718ff44241a0ebd26f7da454e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e326a3197620000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8cb5b94f996cf5f75a36bb40dd21497f592dd7ae2688c265b9cd6a7bb35f1060ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074421, - "block_timestamp": 1781633532, - "tx_hash": "0x3248302fb48b3e5853fbf23fe241beb55de259c6028fb8ba6b9e8c8f4c7fcccb", - "log_index": 48, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "sellAmount": "3000000000000000", - "buyAmount": "4285687523", - "validTo": 4294967295, - "appData": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e376a3198f4", - "app_data_hash": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":512,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ff7266e300000000000000000000000000000000000000000000000000000000ffffffff8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e1840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e376a3198f40000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7545eda04f10f9f6e60e70ee2c695a24b64319b35cccbbe24f2243afaba009a2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074421, - "block_timestamp": 1781633532, - "tx_hash": "0xc0213bc7504493b448bd4102492358c230f9462d9c279eaa6589a72891bc1fe0", - "log_index": 62, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x01accec4273dee321b96274493fbf1d0fc14da07", - "sellAmount": "3000000000000000", - "buyAmount": "394377751", - "validTo": 4294967295, - "appData": "0x7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e6", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e386a3198f9", - "app_data_hash": "0x7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e6", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":570,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000001accec4273dee321b96274493fbf1d0fc14da07000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000001781ba1700000000000000000000000000000000000000000000000000000000ffffffff7dcb23e48c4c53fb3ac1be3337ed9889d7a94debecede637a00437449b99b1e60000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e386a3198f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6c4472c1f1ec896f9670c1f2c5cad4b8db05f0cf94e1fd9c909c2dab1443049bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074463, - "block_timestamp": 1781634048, - "tx_hash": "0xa01715da5ec992eeb156871e38681e99bb9f0f98e59bf0c27669e0f797ae1805", - "log_index": 131, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "sellAmount": "3000000000000000", - "buyAmount": "399420481", - "validTo": 4294967295, - "appData": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e406a319b00", - "app_data_hash": "0x8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e184", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":512,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017ceac4100000000000000000000000000000000000000000000000000000000ffffffff8efbe1cde218af879799a3bf34c165c1e512e8efa730d9865f5d66bae271e1840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e406a319b000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x134a3f327191093700c55bb8fd4224724f13aa82dc4842345a46ead829937206ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074464, - "block_timestamp": 1781634060, - "tx_hash": "0xeacde6ac89c7b5ec41c598e97b521e647ac37769d35bf6f2808118c8ca51169a", - "log_index": 24, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x1cb9d55f7741f6bf142235e81058361026951a47", - "sellAmount": "3000000000000000", - "buyAmount": "4185399405", - "validTo": 4294967295, - "appData": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e446a319b06", - "app_data_hash": "0xebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b00", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":571,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000001cb9d55f7741f6bf142235e81058361026951a47000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000f978206d00000000000000000000000000000000000000000000000000000000ffffffffebfc40e9c9831896d187330116cff2267439b00aefae6a1b6cdc64f016562b000000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e446a319b060000000000000000000000000000000000000000" - } - }, - { - "uid": "0x625b15a3aca46f2a554c5193a562b2b9e9ddb5034797a1d6d57927f97e54caf4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074482, - "block_timestamp": 1781634276, - "tx_hash": "0x553916439aa28c7725dc72a17e18175461c2a493ca9ae5a20a2aaf225ca6d5b1", - "log_index": 111, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x60f0fe4f7c271b5e8a0f211aa7bd98285dca111b", - "sellAmount": "100000000000000000", - "buyAmount": "145438939356", - "validTo": 4294967295, - "appData": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e506a319bd0", - "app_data_hash": "0x64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b665", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000060f0fe4f7c271b5e8a0f211aa7bd98285dca111b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000021dcd618dc00000000000000000000000000000000000000000000000000000000ffffffff64786fd7cb86927db36a84de66ec04845f109e9a59df63c9b99e8e6d5d96b6650000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e506a319bd00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b981bae262938ffc08acc78770763ba5051597e80bb06b9fc049de7ce0c827dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074491, - "block_timestamp": 1781634408, - "tx_hash": "0xc63d88e3044c6684a485a0bf33b667f1695fdfa3666b8a8a8fa243dded2ed2b4", - "log_index": 23, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "sellAmount": "3000000000000000", - "buyAmount": "3781621823", - "validTo": 4294967295, - "appData": "0xe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e5a6a319c60", - "app_data_hash": "0xe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":546,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000e166f83f00000000000000000000000000000000000000000000000000000000ffffffffe75988e9d5e0673d8f27a4e51935bc07909377466fbce3f95e9d6cd4437725cf0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e5a6a319c600000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2316cffb82684d528238d930c87a44191ddaf412204a6d86a538d1558a853e12ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11074491, - "block_timestamp": 1781634408, - "tx_hash": "0xb247df7b9a1a73d30dec3b07496daf6e2c33800cbafbd43600d865d7c56cfb11", - "log_index": 28, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xd9a63dc2dd297af4b071f31ba8e0db0a668956af", - "sellAmount": "3000000000000000", - "buyAmount": "398531375", - "validTo": 4294967295, - "appData": "0x93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb86", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000171e5c6a319c69", - "app_data_hash": "0x93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb86", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":484,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000d9a63dc2dd297af4b071f31ba8e0db0a668956af000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000017c11b2f00000000000000000000000000000000000000000000000000000000ffffffff93d8794612b14738b8235e26b806907f0c3890784e626fcad340d5c92acdfb860000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000171e5c6a319c690000000000000000000000000000000000000000" - } - }, - { - "uid": "0x019e8adfe24e656a3c875647bfce3eb1ea39cb9c7b473655b3c962934f4a05c4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076610, - "block_timestamp": 1781659896, - "tx_hash": "0xca1c1c9b478a5f63d324b34f243fbd1c86decddd704c99694d34a0a221ece0d9", - "log_index": 320, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4075671767417315423", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721106a31ffe5", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000388fb1e0edff605f00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721106a31ffe50000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe0ccf0ed207a9ec7056975d40c6a32bbc0fb41367409174a285f8329f64adb97ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076616, - "block_timestamp": 1781659968, - "tx_hash": "0xd9b9622663d0ed38de4617dbfb2a8010526f819ff0196f54c38df7f907f7ff87", - "log_index": 379, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "13666536297", - "validTo": 4294967295, - "appData": "0x33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721186a32003a", - "app_data_hash": "0x33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":185,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000000000032e96cb6900000000000000000000000000000000000000000000000000000000ffffffff33c67766aa455557799f7599f128b6af57de98de5b41b7bb91662928b414734c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721186a32003a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfcfd0fa60f0adaad95cde4f0f06bde80753af7ae3782b1bdf5d5ca52b79d1ebbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11076620, - "block_timestamp": 1781660016, - "tx_hash": "0x5ca964ec1c27d8eb6fc47e92c0a0b8c9184143def28dc00d50d455eada7de804", - "log_index": 545, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "40264034856", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017211a6a320066", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000095fec6a2800000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017211a6a3200660000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9fabbf086cf89b990f836f12bb3045204480fb15e679e82b3b93529bde3f608cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11077214, - "block_timestamp": 1781667204, - "tx_hash": "0x5d27e4c6b1aaa3b3ab28a398bb99f062cef0a1527f2ac368d49f2396555315ba", - "log_index": 87, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "57760306069", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017213c6a321c74", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000d72c8539500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017213c6a321c740000000000000000000000000000000000000000" - } - }, - { - "uid": "0x84fd93424f3bb11e6fa5c0cfb45dfc171fc2b91888139af480507ec3a22e30c9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11077272, - "block_timestamp": 1781667900, - "tx_hash": "0x0e12337ffc385d2c5ca063e4a562104734b6f2012e3ceb6b624f412cc64725df", - "log_index": 342, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xea1fed52a9b161f9ebfe58892699896cb9d0cd44", - "receiver": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "sellAmount": "30000000000000000", - "buyAmount": "9812058475546634260", - "validTo": 4294967295, - "appData": "0xea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721506a321f3b", - "app_data_hash": "0xea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":96,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000ea1fed52a9b161f9ebfe58892699896cb9d0cd440000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478000000000000000000000000000000000000000000000000006a94d74f430000000000000000000000000000000000000000000000000000882b6f326e51681400000000000000000000000000000000000000000000000000000000ffffffffea1df97da9521d0a0f14fcec6d1d943e3f1115c3fc859ccda948b81d8407d82c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721506a321f3b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa1b4b41b8c1eb2a6ad6e9e92f3ba02b9f82230cebb8847eba84ec7745866af23ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078078, - "block_timestamp": 1781677608, - "tx_hash": "0x2e8952212c643719fba43eacb29c337e4d1b9ce2f5ba8aaf5fb2cdcf2354b587", - "log_index": 74, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "sellAmount": "3000000000000000", - "buyAmount": "1973590925", - "validTo": 4294967295, - "appData": "0x428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721da6a324520", - "app_data_hash": "0x428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":594,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000075a29b8d00000000000000000000000000000000000000000000000000000000ffffffff428c17d596b03fb06511fd6fb1b55cb23ed53df5dddba2f923f1ee7b6fc831fe0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721da6a3245200000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9e4ceb196fa1eda2e7b8e25e352c1362b56db63bf51b302ea0b277d2348f78c1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078078, - "block_timestamp": 1781677608, - "tx_hash": "0x62ba57495464e98e89103793a4a9777810d379f4ecb4065afce66e7227c7e9ad", - "log_index": 91, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xbbc940b8d3dd0977826162a2fccfdc4a227a0a5d", - "sellAmount": "3000000000000000", - "buyAmount": "2880361707", - "validTo": 4294967295, - "appData": "0xbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721dc6a324525", - "app_data_hash": "0xbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":580,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000bbc940b8d3dd0977826162a2fccfdc4a227a0a5d000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000abaed4eb00000000000000000000000000000000000000000000000000000000ffffffffbc67ff39d75c38fe641f5941776987ec1564e3ba04e6266f1cfb6093521247ff0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721dc6a3245250000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd6eaa1a916db337bb31afab19c2e5c0453e184fe3dbc58911aa70f6d9074cb1bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078093, - "block_timestamp": 1781677788, - "tx_hash": "0xb8a71cf1e0cb8ad941b9fad9a68a6e2b91f75cb554475e2e69b8b0d435d09c1e", - "log_index": 204, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "sellAmount": "3000000000000000", - "buyAmount": "1937029268", - "validTo": 4294967295, - "appData": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721de6a3245cd", - "app_data_hash": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":549,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000007374b89400000000000000000000000000000000000000000000000000000000ffffffff6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721de6a3245cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x819ff1ecc26eaffc98c594c1d38562def85683e76880e71f213489fb9098f645ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078093, - "block_timestamp": 1781677788, - "tx_hash": "0xa9f630ad3530584fd6f6343f7d606a9dea6500232b96dbd4ddb1ccb81096da5c", - "log_index": 223, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x286aa21ff4b93e33c13bc84ea6b9de4e16bece96", - "sellAmount": "3000000000000000", - "buyAmount": "2904285905", - "validTo": 4294967295, - "appData": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e06a3245d3", - "app_data_hash": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":532,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000286aa21ff4b93e33c13bc84ea6b9de4e16bece96000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000ad1be2d100000000000000000000000000000000000000000000000000000000ffffffff968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e06a3245d30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa06aafbb034441dd197d8f108b44b36d79f497b48fff636d35c2f6f80c379e13ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078113, - "block_timestamp": 1781678028, - "tx_hash": "0x8ccb0869e7cb4cd6b95658cbb940b7a2de69d8315ccaed608a72aeb7f59de716", - "log_index": 122, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "sellAmount": "3000000000000000", - "buyAmount": "1861609585", - "validTo": 4294967295, - "appData": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e16a3246cd", - "app_data_hash": "0x12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":579,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006ef5e87100000000000000000000000000000000000000000000000000000000ffffffff12042ecdcc200a4f5c5c27fe083d247b7e8fa2d7466fb7efebcb8ca38c4d7c0d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e16a3246cd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd6f7b83bd69ab27363b100cb8aafe9e67dc9a61d10b8f8958920462a6a8d37beba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078114, - "block_timestamp": 1781678040, - "tx_hash": "0xee8091df6a252e533f7cb3925d210d96b3c0a53d36978df1c9bab698cb432bd9", - "log_index": 127, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xb3d192970a8c51730ae5d44f531b9a20b1d728ee", - "sellAmount": "3000000000000000", - "buyAmount": "2846117696", - "validTo": 4294967295, - "appData": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001721e46a3246d1", - "app_data_hash": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":584,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000b3d192970a8c51730ae5d44f531b9a20b1d728ee000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000a9a44f4000000000000000000000000000000000000000000000000000000000ffffffffb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001721e46a3246d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1ac7b66129618244e058e56e4ca1b6a3b01a6d379578de9666bbf2f6b4c511c6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078214, - "block_timestamp": 1781679240, - "tx_hash": "0x76a9ad1b9ef807ef129d98f36cc92c14a72b8455cba25b25ad7db379753f4b2e", - "log_index": 329, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1806672021", - "validTo": 4294967295, - "appData": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722036a324b88", - "app_data_hash": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":576,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006bafa09500000000000000000000000000000000000000000000000000000000ffffffff4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722036a324b880000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9fc72d42b02063d69f315e0c9c91fee8d419c48d9e517000ba2c2fe0bd24984eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078215, - "block_timestamp": 1781679252, - "tx_hash": "0x9e3d8da305b02537732c69307c9c12d9d8283e13e563dfb12cded4732d2886de", - "log_index": 95, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1802015172", - "validTo": 4294967295, - "appData": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722066a324b8c", - "app_data_hash": "0x22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":585,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000006b6891c400000000000000000000000000000000000000000000000000000000ffffffff22458cd0b96ad9471327cfa34908cb1be35684dc051f5fc46ea41c525c09ae1e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722066a324b8c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x642b789047e9b58bd9ad98954b7b61d417f02570133aadc7bfa118078c822006ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078225, - "block_timestamp": 1781679372, - "tx_hash": "0x90c34437e7bf6aa710b0c65f5d84017cd65f88f096e71b9a361d9deb7cf1c987", - "log_index": 286, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8793a879cf1837f4fbcce3b425ae43770675c12a", - "sellAmount": "3000000000000000", - "buyAmount": "1713858257", - "validTo": 4294967295, - "appData": "0x66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017220c6a324c0a", - "app_data_hash": "0x66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":604,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008793a879cf1837f4fbcce3b425ae43770675c12a000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000662766d100000000000000000000000000000000000000000000000000000000ffffffff66f44ac5c45ea652896294c758fe91dae7bf0be80ec9e9f88d94a0f622a803d70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017220c6a324c0a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7cf68a7b25d6919a491646853e179998e367906ba931e575eb9bf6389b812b40ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078246, - "block_timestamp": 1781679624, - "tx_hash": "0x54ca0b8ff9dd19bacf3f1e6fda073b1c08e11469731ec10a37ad04b572cc02b2", - "log_index": 220, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "sellAmount": "3000000000000000", - "buyAmount": "1677560640", - "validTo": 4294967295, - "appData": "0x9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722156a324d07", - "app_data_hash": "0x9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":591,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000063fd8b4000000000000000000000000000000000000000000000000000000000ffffffff9e292b2dda2ec1021300ef3a65fad2a4390949f5c50663601ad1950524a4231e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722156a324d070000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb6e107519acfd3f9d355ad94bc1da521458151b63b736a66200e8423a07d8823ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078248, - "block_timestamp": 1781679648, - "tx_hash": "0x19dfe5925fc9edd56df2cafbbe016d2d08b16e3c94fdbb424f4cd3e086a48bb4", - "log_index": 119, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x9b76327a91233c2bfb0db80ed7c3d1e5d686fd4f", - "sellAmount": "3000000000000000", - "buyAmount": "1665090544", - "validTo": 4294967295, - "appData": "0x89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722176a324d16", - "app_data_hash": "0x89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":582,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000009b76327a91233c2bfb0db80ed7c3d1e5d686fd4f000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000633f43f000000000000000000000000000000000000000000000000000000000ffffffff89c3981b1a4b6fbe1c23150f59402fe5d03c26d463794d5050b35adcd88875fb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722176a324d160000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd3c38d90f5bfda4ecc0706bb3d5ec58bd83dc8c1183e1f8d3b785fc1c4b1f28fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078475, - "block_timestamp": 1781682372, - "tx_hash": "0xc544c004b5e9cc924a8777a036d8180860990259be24a0eefb66e80484200eab", - "log_index": 178, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "sellAmount": "3000000000000000", - "buyAmount": "1629903612", - "validTo": 4294967295, - "appData": "0x69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722726a3257b9", - "app_data_hash": "0x69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":590,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000061265afc00000000000000000000000000000000000000000000000000000000ffffffff69f7347567bdbbf825055baac8c17899e0a446a9c80d8aab01c9a6342ee306e90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722726a3257b90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x22e93a3a93880467b6ba74a041673b43573bbd19febaf9ad26946058b09a7f60ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078475, - "block_timestamp": 1781682372, - "tx_hash": "0x7c9618335193d4141c583516bf1caf3156b9cc39018aee1a3dc029091f74fccb", - "log_index": 205, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x7318c432e852e54f556a3c24d573d877347a91e2", - "sellAmount": "3000000000000000", - "buyAmount": "3224087935", - "validTo": 4294967295, - "appData": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722756a3257bf", - "app_data_hash": "0xb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":584,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000007318c432e852e54f556a3c24d573d877347a91e2000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c02bad7f00000000000000000000000000000000000000000000000000000000ffffffffb22ff493417778724e9ff06a60a79c083ca0f66f940f21b5ef81b678602b5cdb0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722756a3257bf0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5e60eb4ec8c8634eae23207d887a490cd99f7daafac55ef87e677135eee4feffba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078500, - "block_timestamp": 1781682672, - "tx_hash": "0x52763b60a0243c7d57e95fb13fa51e7aeab8df8d75846175d980aa59b67c1c86", - "log_index": 310, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "sellAmount": "3000000000000000", - "buyAmount": "1589860466", - "validTo": 4294967295, - "appData": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722836a3258ef", - "app_data_hash": "0x4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":576,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000005ec3587200000000000000000000000000000000000000000000000000000000ffffffff4471fb789d7020b95b47335493fc5875eaab582010c67ad768ac54aa5335ef3d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722836a3258ef0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xaf138bc274dcd24ebb344cc26bfca6ed4e21e560d4b1d2ef7d3116b8b2dca484ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078501, - "block_timestamp": 1781682684, - "tx_hash": "0xf358d26d04adb90d4337e62a891695b7633e677606af07fbed4b4dbdde546c3a", - "log_index": 74, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x69d8c2cd892c06eb5409a331e3770fcdeca1c643", - "sellAmount": "3000000000000000", - "buyAmount": "3274149785", - "validTo": 4294967295, - "appData": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722866a3258f5", - "app_data_hash": "0x0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":510,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000069d8c2cd892c06eb5409a331e3770fcdeca1c643000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000c3278f9900000000000000000000000000000000000000000000000000000000ffffffff0d0878cfe0cb1d84a860e63bc771ff4fd72ffa61b1f4f11aee18dccff7c9238c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722866a3258f50000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcbb6226fc692ce61536d4a8b476507cc5e599af79e47d5ff1c36f3c949d6d01aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078504, - "block_timestamp": 1781682720, - "tx_hash": "0x2f7a6a91b452e55c85e00f06dd34d9f28ddba5e89f7f006320d222c5c15132e0", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "41246159593", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017228a6a325918", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000099a7672e900000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017228a6a3259180000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7bc92f460730baa66148ad9fc664a83a2643a1a78e1d24ca0fe33e6fcf83f673ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078513, - "block_timestamp": 1781682828, - "tx_hash": "0x2475e04a4c60f874a0487d8ee105019e240928b0446758f40deef2dfded111bb", - "log_index": 111, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "59005507112", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722946a32597e", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000dbd00962800000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722946a32597e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7bf72b311bf26ecccef2e2774197b37e9db11c213e96eaccf9b6027b2fe73436ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078520, - "block_timestamp": 1781682912, - "tx_hash": "0xb0f58210cc6e64b4f8adb77dc71c488514a0b8410e863b0a3d7ff464ad3760e3", - "log_index": 160, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "sellAmount": "3000000000000000", - "buyAmount": "725974732", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017229a6a3259da", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000002b457ecc00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017229a6a3259da0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9dd59d5b617a0f85e92c8e5b5255f8e6286c3894a6eff9d8a2bc5f29cfd2e727ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078520, - "block_timestamp": 1781682912, - "tx_hash": "0x712e4a950e65805e5f850c808e02972fe6e7e298628319e8bc042a7e5563b439", - "log_index": 190, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xa4985ecaeeac7ce1699134866e4a2b50e2f12685", - "sellAmount": "3000000000000000", - "buyAmount": "3083963260", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017229d6a3259de", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000a4985ecaeeac7ce1699134866e4a2b50e2f12685000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b7d18b7c00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017229d6a3259de0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8f8bed5037a46ac5e39d697bba6711a1846ccd99699dead3837c2f81723ffb8eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078539, - "block_timestamp": 1781683140, - "tx_hash": "0x83db9ce3e57ec98fb6470d0cbd89ac9ffa01e546768bbdcc1d11cbc532e49762", - "log_index": 333, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "sellAmount": "3000000000000000", - "buyAmount": "3024882930", - "validTo": 4294967295, - "appData": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722a86a325ab9", - "app_data_hash": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":574,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b44c0cf200000000000000000000000000000000000000000000000000000000ffffffff807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c501740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722a86a325ab90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4e04244d9d111f42f03662ecb92c2e96412b2f1e20dc1c87ff603648dbd0adafba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078539, - "block_timestamp": 1781683140, - "tx_hash": "0xc0168df1c0cd691053c93282a4c78d08d0d3c3c664f23eedeb7ea75dc87ca3df", - "log_index": 334, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xafe4ea682110dff6ee48201de034a8b7fdd169b0", - "sellAmount": "3000000000000000", - "buyAmount": "701122877", - "validTo": 4294967295, - "appData": "0x988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722a66a325abd", - "app_data_hash": "0x988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":581,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000afe4ea682110dff6ee48201de034a8b7fdd169b0000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000029ca493d00000000000000000000000000000000000000000000000000000000ffffffff988d20d00f6b54fde4e733511691a245013e9a2f280d89db797e1f7c1bcc84e90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722a66a325abd0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe16973fad0fdf99f45b98aa36216c7e05429ae0cd004714c578faee349f6e8a1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078557, - "block_timestamp": 1781683356, - "tx_hash": "0x15708d8fab4d1c10d5105554b626543780770aa4abec801a3a7916a4d42be37c", - "log_index": 169, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "sellAmount": "3000000000000000", - "buyAmount": "688532675", - "validTo": 4294967295, - "appData": "0x8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c3304054", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722b26a325b98", - "app_data_hash": "0x8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c3304054", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":578,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000290a2cc300000000000000000000000000000000000000000000000000000000ffffffff8ecf1d25087f7c3e9f3ecf8d6775685423e4d97c011de685ac548625c33040540000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722b26a325b980000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfb362d5897ea57c89a0e5f951eea05d07f79007bb61c866413e50a98b2fdb81cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11078558, - "block_timestamp": 1781683368, - "tx_hash": "0x55e2a87d668cc6fd0b111214045b9179a22e3b750c2c911ee3b8dda0f56ffd13", - "log_index": 149, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x3991dcb23bb280199d6e8a4c9213dbc53ef2eec8", - "sellAmount": "3000000000000000", - "buyAmount": "3007883750", - "validTo": 4294967295, - "appData": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001722b46a325ba2", - "app_data_hash": "0x807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c50174", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":574,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000003991dcb23bb280199d6e8a4c9213dbc53ef2eec8000000000000000000000000000000000000000000000000000aa87bee53800000000000000000000000000000000000000000000000000000000000b348a9e600000000000000000000000000000000000000000000000000000000ffffffff807a98eb13c80d8fb87232d02869d3379317bb3b2cd5705cdce021e772c501740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001722b46a325ba20000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6068b9d9b98f94a5a2bd03cb537a33da151f458bcb52d36145cbb08ccdffb629ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11080029, - "block_timestamp": 1781701044, - "tx_hash": "0xd565905883d55116a8ef30e46991af0d454f3843b1a0fe39d1632db71e2c48a6", - "log_index": 30, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xd4759b15b0ec5eee3a01a0dfd3e8b70504adb868", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xd4759b15b0ec5eee3a01a0dfd3e8b70504adb868", - "sellAmount": "50000000000000000", - "buyAmount": "22121513287251100262", - "validTo": 4294967295, - "appData": "0xf2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001725b06a32a0a1", - "app_data_hash": "0xf2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"staging\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"referrer\":{\"code\":\"MOO-MOO\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000d4759b15b0ec5eee3a01a0dfd3e8b70504adb868" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000d4759b15b0ec5eee3a01a0dfd3e8b70504adb86800000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000132ff672144af826600000000000000000000000000000000000000000000000000000000fffffffff2f62ebcae67d9c6dc72a69a6e5f5cf72f8555bd28bca1235d2da45a79fbd08c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001725b06a32a0a10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x299fe6889920b4869c0fa99684b15a8c8c8bf657c92fe0743b7bec5ed45e956fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11080365, - "block_timestamp": 1781705076, - "tx_hash": "0x800c92d50f62883551f497d89a24c3ade95ede801b70102baf4888f06c94bf5a", - "log_index": 46, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x541e08e9533938e545677443987adecba2f4cf1c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x541e08e9533938e545677443987adecba2f4cf1c", - "sellAmount": "10000000000000000", - "buyAmount": "4279758090521596071", - "validTo": 4294967295, - "appData": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001726506a32b060", - "app_data_hash": "0x62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":187,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000541e08e9533938e545677443987adecba2f4cf1c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000541e08e9533938e545677443987adecba2f4cf1c000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003b64c14ee624d0a700000000000000000000000000000000000000000000000000000000ffffffff62107cf706bdba60dcd54b74b55984b42df43217a7b67e6dc2766c905ac4549f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001726506a32b0600000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbdbb9773fa98d64748954d7da6fed4b17a9fb880f6a430716e949994e8b1e341ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11082360, - "block_timestamp": 1781729064, - "tx_hash": "0x257de5b5711c867fbcd11923669aac4b4f01399e31ce618ddab22ce65d42dd9a", - "log_index": 173, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xc621c5a6c8f2ee120c11f1bd016029adbddf54cf", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xc621c5a6c8f2ee120c11f1bd016029adbddf54cf", - "sellAmount": "8000000000000000", - "buyAmount": "3386366020898494818", - "validTo": 4294967295, - "appData": "0xbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d7", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017288e6a330e27", - "app_data_hash": "0xbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d7", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":221,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000c621c5a6c8f2ee120c11f1bd016029adbddf54cf" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000c621c5a6c8f2ee120c11f1bd016029adbddf54cf000000000000000000000000000000000000000000000000001c6bf5263400000000000000000000000000000000000000000000000000002efec9f44b1b996200000000000000000000000000000000000000000000000000000000ffffffffbae8167f069d0dacca9214d001978e606da297fb428cdd914a35c6a28b6574d70000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017288e6a330e270000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbaedfe1cf121202595305d8153b7851cb21239c4c0af9f0c6cb796599702f742ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083644, - "block_timestamp": 1781744496, - "tx_hash": "0xd1f370c2b0e76bf01e07b59ab6fffe15bb97175a4b2e343126bf54aa58344c3e", - "log_index": 341, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xe8b4765048c65da747f5262527b5864c98496527", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0xe8b4765048c65da747f5262527b5864c98496527", - "sellAmount": "60000000000000000", - "buyAmount": "2430985366936195654", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729416a334a64", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000e8b4765048c65da747f5262527b5864c98496527" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000e8b4765048c65da747f5262527b5864c9849652700000000000000000000000000000000000000000000000000d529ae9e86000000000000000000000000000000000000000000000000000021bc984fb267924600000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729416a334a640000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8194545de1ed8e4bea8e13ec108ac10958e49d138675ae53f19b94e30442f3f9ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083764, - "block_timestamp": 1781745936, - "tx_hash": "0x4a9aec4729167c4eb8d5c125a689978d8af1bbfabbe66ba225d69b917c3b6f96", - "log_index": 567, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "1000000000000000000", - "buyAmount": "59808168179", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729626a335001", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000decd838f300000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729626a3350010000000000000000000000000000000000000000" - } - }, - { - "uid": "0x9c92f5f35cff463b928341d3f96745bfc434e921acd078c32715e99a46c44bfcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083770, - "block_timestamp": 1781746008, - "tx_hash": "0x096319cec0abf649c7d4bc708b4b929f42617a6ea5ce2c87ce7832a03207ccd8", - "log_index": 553, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "1000000000000000000", - "buyAmount": "482693023980", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729646a33504c", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000007062bf08ec00000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729646a33504c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x98906b976da3cee661495836c7d362cbb406c90384fc606ae854192ede25fc5aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083812, - "block_timestamp": 1781746512, - "tx_hash": "0xa3a701f2fa97a3a1ac7f0d1a4b73fc1f8f21eef66def8db7c86572b8952e24e2", - "log_index": 618, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "392870645898924217", - "validTo": 4294967295, - "appData": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017296f6a33523e", - "app_data_hash": "0x1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf22434", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":179,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000573c1c55b7bd0b900000000000000000000000000000000000000000000000000000000ffffffff1b34a38083107c63bff5fc8fb6e02d487b9ef1e82d3f562866b6969f1bf224340000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017296f6a33523e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x627afacc9f40e3a7143c66693a6280e464f04829aacdad154583ac24dc42b7d5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083859, - "block_timestamp": 1781747076, - "tx_hash": "0xbb06194793467873c855276424876939056dd463c7e7f5ea66c303a9a2a3f798", - "log_index": 237, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "600000000000000000", - "buyAmount": "27097688068", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729906a33546a", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000853a0d2313c0000000000000000000000000000000000000000000000000000000000064f25e80400000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729906a33546a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x240bcbdecd532fc60cc12e01d6d72321b5de48823fed5a48dd9708e6286fd6b7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11083866, - "block_timestamp": 1781747160, - "tx_hash": "0x579e3d9fdac624f3716df8ce30714ac606b237370cc939d3b409bbf7a8acdbf8", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "600000000000000000", - "buyAmount": "266488304834628180915", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729916a3354c5", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000853a0d2313c000000000000000000000000000000000000000000000000000e7244a554e0065bb300000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729916a3354c50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2559867e882308cc78ef30ba8e42ffd9dcd59b0b0466fe56baae8d1745169982ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084049, - "block_timestamp": 1781749380, - "tx_hash": "0xf9870f02e64238b8c76b8433a5a2391646c443f29c4049f0822bc518ee39c7af", - "log_index": 455, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xa634030a2603a0d70b7cddf6cc9ad90d93f6db50", - "sellAmount": "100000000000000000", - "buyAmount": "1407457410", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729c06a335d7e", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000a634030a2603a0d70b7cddf6cc9ad90d93f6db50000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000053e4188200000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729c06a335d7e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7ce8d855b0977b0120d6adcbdd368bd25ec2bbd015cbf37081bd579f3617c63cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084079, - "block_timestamp": 1781749740, - "tx_hash": "0x8add8e612d0cc934b909be70c0a585368ba2bb1d32ad9ffcad151dc3e5e7459b", - "log_index": 629, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xa9e354e04c305a5de11e036025dbe63451dae888", - "receiver": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "sellAmount": "10000000000000000", - "buyAmount": "47251907151317040570", - "validTo": 4294967295, - "appData": "0x86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf083861", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729ca6a335ed1", - "app_data_hash": "0x86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf083861", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":217,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000a9e354e04c305a5de11e036025dbe63451dae888000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000028fc07f33e9fdfdba00000000000000000000000000000000000000000000000000000000ffffffff86415e2f21c581ae60cccdebb3967d50809cfcbd7d5cc69cc86eea2bcf0838610000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729ca6a335ed10000000000000000000000000000000000000000" - } - }, - { - "uid": "0xffc3686ea9b81671b021c95efd3883ee45b5591902a26286ffeda26465d53d10ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084150, - "block_timestamp": 1781750592, - "tx_hash": "0xed2779bd0360ff62f903eef90440516e4a6a2d90e39d2c0da81a841e16bfc35f", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xa9e354e04c305a5de11e036025dbe63451dae888", - "receiver": "0xeffd3447e89e0e09ddc1a786c2b5466dd8eec96d", - "sellAmount": "2000000000000000", - "buyAmount": "3665129510555176458", - "validTo": 4294967295, - "appData": "0xc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada51", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001729cc6a33622f", - "app_data_hash": "0xc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada51", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":767,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000a9e354e04c305a5de11e036025dbe63451dae888000000000000000000000000effd3447e89e0e09ddc1a786c2b5466dd8eec96d00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000032dd27df04714e0a00000000000000000000000000000000000000000000000000000000ffffffffc753562ad35bbffa0d998cd8825cbddb03ce77db31dcecc81907d31e6e8ada510000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001729cc6a33622f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7b51bb4aba053b20441a6c0df1c266d0cc6fc16e281d583fc07ba4488ce7ef26ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11084744, - "block_timestamp": 1781757792, - "tx_hash": "0xc4288a16179dcb342c70532983bd0c0a06788dc01d64ef0f31968792a87f506d", - "log_index": 197, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0a003f60bd9eef0590de7f8b1d988e29a5e6acb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0x0a003f60bd9eef0590de7f8b1d988e29a5e6acb7", - "sellAmount": "1000000000000000000", - "buyAmount": "463552362", - "validTo": 4294967295, - "appData": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172a6e6a337e55", - "app_data_hash": "0x24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000a003f60bd9eef0590de7f8b1d988e29a5e6acb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc910000000000000000000000000a003f60bd9eef0590de7f8b1d988e29a5e6acb70000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000001ba13f6a00000000000000000000000000000000000000000000000000000000ffffffff24661e25978ba63789b7fcd0521525a38b8b07a189d7fb522ac31de84bd6c0c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172a6e6a337e550000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8b4e73ec67bd653783118a3cb66da57c4766cf68b2fb4fdd59d90f10e1c183b1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085093, - "block_timestamp": 1781762016, - "tx_hash": "0xb5d57bd43f153038d3263c8acdfe2aef1a8bd764c3becbb4e3810d32a369af00", - "log_index": 267, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "sellAmount": "3000000000000000", - "buyAmount": "188423008", - "validTo": 4294967295, - "appData": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ac76a338ed7", - "app_data_hash": "0x02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f80", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":553,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b3b1b6000000000000000000000000000000000000000000000000000000000ffffffff02751e337221c5b131db083cc5739a22b8b1411e50f5740fd1aba48cda692f800000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ac76a338ed70000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bd2dbf842699a6bbacb51988628dadad29835bfecdb1196013570fef4140c00ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085093, - "block_timestamp": 1781762016, - "tx_hash": "0x264acc600ad37482dc42513e807a1b56c522ed712ed852f4a180815af7c865d9", - "log_index": 321, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xeaea6c3c9219bd5951291f6958f163224df843b1", - "sellAmount": "3000000000000000", - "buyAmount": "2467848255", - "validTo": 4294967295, - "appData": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ac96a338edb", - "app_data_hash": "0xf802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":526,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000eaea6c3c9219bd5951291f6958f163224df843b1000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000009318603f00000000000000000000000000000000000000000000000000000000fffffffff802fa8c5d19aaf443c23b80c912429c18264d72ef309e824bbb187f758e253e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ac96a338edb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd3d9da383cdae81043b8c5c945de8a9e18d1372e2f55539312c8ee6033c70cfdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085123, - "block_timestamp": 1781762376, - "tx_hash": "0xf90eae57885077399b2cf6247fe4fe921f8ed6603e90583496bdcf550c2dc5e3", - "log_index": 132, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "sellAmount": "3000000000000000", - "buyAmount": "185412694", - "validTo": 4294967295, - "appData": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172acb6a33903f", - "app_data_hash": "0x6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":549,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b0d2c5600000000000000000000000000000000000000000000000000000000ffffffff6a67eef03a9bcc727fa1b71890242c08e5baceb53c18e0c766491741458cbc2d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172acb6a33903f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x518e19aab296a2af714964d0c3ded7cd3126799a0b3c91937b05770bfa16cb1dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085123, - "block_timestamp": 1781762376, - "tx_hash": "0xe3fce48fe496b631c910d3f4bfb2da0cc65b6d1d049670465b714d6dfc9bba8f", - "log_index": 144, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8af78ae6c980068a95774467607227d80e3bcd05", - "sellAmount": "3000000000000000", - "buyAmount": "2445460781", - "validTo": 4294967295, - "appData": "0xfea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172acd6a339044", - "app_data_hash": "0xfea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":541,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008af78ae6c980068a95774467607227d80e3bcd05000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000091c2c52d00000000000000000000000000000000000000000000000000000000fffffffffea258abd16c663f1cde91a390227589575d5d15075927d88c4b69a3b7e90e9b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172acd6a3390440000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8bb95de20b2bc4f529fb2fb6e7032aec1fe058433207c270c061c9448e11b5f4ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085229, - "block_timestamp": 1781763648, - "tx_hash": "0x81222420097522888c7b3ca92dc8cd7c4307d5589e43c3dd5c6d28eb9f42ca20", - "log_index": 392, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "sellAmount": "3000000000000000", - "buyAmount": "186824823", - "validTo": 4294967295, - "appData": "0x4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e0", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ad66a339539", - "app_data_hash": "0x4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e0", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":464,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000000b22b87700000000000000000000000000000000000000000000000000000000ffffffff4f3f50db26938436aca012cd2004cf2589cff4407a2c1bd32d4b9a10c359a9e00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ad66a3395390000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f80482c1fcf7cd0f250b12f303207f91c999dc5f0e7a8d3df2b2d3131f876f1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085229, - "block_timestamp": 1781763648, - "tx_hash": "0xb425873743f51b9b99163372affbdb9b0dcf4d03877ac532ca38c60125ad3f37", - "log_index": 402, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8525834218582b2deb9ba8788cfa60cfdbc51392", - "sellAmount": "3000000000000000", - "buyAmount": "2574273094", - "validTo": 4294967295, - "appData": "0x7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ad46a33953f", - "app_data_hash": "0x7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":465,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008525834218582b2deb9ba8788cfa60cfdbc51392000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000099704a4600000000000000000000000000000000000000000000000000000000ffffffff7698380032e1721311a61bfbd177763a063fc5c36876276af90c0cd91a2bc73e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ad46a33953f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6217df15ccc4990a9d22378aa748359fdb6deb87d6098e93a97a67eac03f6d41ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085285, - "block_timestamp": 1781764320, - "tx_hash": "0x8cd8bce925bd86f5b338d9acbce2666c2fb345b6217249424659b6db018b9d89", - "log_index": 179, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "146914557110", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ae66a3397d9", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000002234ca3cb600000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ae66a3397d90000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb8b7945e5e64c71ab7430fa9cd0e8523dfbf7801f991d6366516932dd6bf9a46ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085290, - "block_timestamp": 1781764380, - "tx_hash": "0xba927758ac45e8f8fc7e3de259ab12d7bebdd05b4a1a48a497b570df7c25fb91", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "137230706698", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172ae86a33980e", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001ff396680a00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172ae86a33980e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbeaa3ed381dfd58db4c683a4810f08491a4c553fcba29aafbbf3e072ef15af63ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085296, - "block_timestamp": 1781764452, - "tx_hash": "0x115fb8b7326d1587bfdc19106ed69e388cc2284b32b550ce5836828e4ac33241", - "log_index": 51, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "136092590449", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172aed6a339862", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001fafc0217100000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172aed6a3398620000000000000000000000000000000000000000" - } - }, - { - "uid": "0xac9baa9a79531b642444ec6f2ea410b60d587377abdea089ce98e543c4ef0263ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085311, - "block_timestamp": 1781764632, - "tx_hash": "0x4da8ad0364d3ba06cd3405e467239ec21a01778c9ef3051aec83590ee27a337b", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "135046086001", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172aef6a339906", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001f715fbd7100000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172aef6a3399060000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5471a5aa664706773d9f9d00764b164c4860bdb3ac684b618406bb5c49e1beb1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085316, - "block_timestamp": 1781764692, - "tx_hash": "0x160a9b2ee69a0cc66303e5b261321ca258dd2db5f31f42e3ee47dc5bdcdf76c0", - "log_index": 33, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "134037221750", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172af16a33993d", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000001f353db17600000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172af16a33993d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb9af72ee95b029577b23f968917e328750de30a86635acb312aefce04199b8e0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11085768, - "block_timestamp": 1781770116, - "tx_hash": "0x1d097f240a28101f26aeb1ae231e24ea3a4df662887fe16480315212b24fc330", - "log_index": 410, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30200000000000000", - "buyAmount": "1218876746382639626", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172b876a33ae51", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006b4abd7037800000000000000000000000000000000000000000000000000010ea51f1651f020a00000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172b876a33ae510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x577b183cdac6edd32f292bd214f463f8b01bd8d85ed29b03cde2ef069f6790f7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086236, - "block_timestamp": 1781775732, - "tx_hash": "0x2a880ef51956e54628126f9862de4094ab1b676617119146248ebb06eba1a09e", - "log_index": 96, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "53447319456", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bab6a33c46c", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000c71b55fa000000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bab6a33c46c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xafd52f06e6fd522f768dc3b879c31f7818d8d748e79c5683612e955d104c3266ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086284, - "block_timestamp": 1781776308, - "tx_hash": "0x0d436cdbac81764bf7818e49f0ee8c52d392a35c9f30cd96f66fe41a41133355", - "log_index": 98, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "94689739878", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bb46a33c6a4", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000160bf2c46600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bb46a33c6a40000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4ee0044363f660d01bd2a6e2033620e41c786b67b10f0544f2eb28cd8144208cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086290, - "block_timestamp": 1781776380, - "tx_hash": "0xbf1c0e8393d62e4606ef3fae5a712011f7cb25ba4932545985b9eb8399560afb", - "log_index": 248, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "93567326162", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bb76a33c6eb", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000015c90c17d200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bb76a33c6eb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6442782816eab3c907715d472a2db9f9b7c75ed5236617a4a155fc9e54d9d760ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11086493, - "block_timestamp": 1781778816, - "tx_hash": "0x9dadabf967ade57ced8e5670eac6b5b94df6d69ec881e5557479cdc73362e4c8", - "log_index": 45, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "111006445247", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172bf16a33d06f", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000019d87feebf00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172bf16a33d06f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x31cce049ef32cdc59b48323c67a8faeafcd6e44167080b957f6f660134711111ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087438, - "block_timestamp": 1781790168, - "tx_hash": "0x01ca9d3ed386600c8b3e8afc3a7f54dd144b6f87710be90b370fc31153234ef4", - "log_index": 180, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "22146154752", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172caa6a33fcc5", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000052803810000000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172caa6a33fcc50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x927561c19ae564fca0c7e350a61c7d241a878353f0b3f1e7c3770a844345b868ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087442, - "block_timestamp": 1781790216, - "tx_hash": "0x11364a161b10f3bf41f545d633cbcc9b38602a42ab84f27972509a64412c1a61", - "log_index": 42, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "56578236075", - "validTo": 4294967295, - "appData": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cac6a33fd00", - "app_data_hash": "0x1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":75,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000d2c535eab00000000000000000000000000000000000000000000000000000000ffffffff1a73e3b20393ab2dd0632d510d8c2b4a80cdb64aa7bd37e44ae1052e4205e9a80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cac6a33fd000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x33e40575e17f11d9b80ab80c3c6f231c0bfd60db2e85a1f52c210c1686038944ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087462, - "block_timestamp": 1781790456, - "tx_hash": "0x5e2ba04d6d26e4a2f5eb64e4be6af21474ab01d6331e1917a8eda18f58e5ee13", - "log_index": 42, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "51804002339", - "validTo": 4294967295, - "appData": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cb36a33fde8", - "app_data_hash": "0x4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":77,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000c0fc2582300000000000000000000000000000000000000000000000000000000ffffffff4b019d8b9268374a4bad602e433b7f3df1a24f2ad941eb2e49b4236ec8554fab0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cb36a33fde80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5fe03b4ee871751804eb7b021e66c2d0ddfafb279b2218aa8aac3ce8cb3c96d6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087468, - "block_timestamp": 1781790528, - "tx_hash": "0xecbac027a9eacb1783a0a3a2688aec80c3bc9001bd833313d3d91b64c3259185", - "log_index": 85, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "49586414578", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cb96a33fe35", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000b8b94a3f200000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cb96a33fe350000000000000000000000000000000000000000" - } - }, - { - "uid": "0x5e7f3fe1498999246b82983af707929c8a4b1831c7663a7d31de37b93d9b1487ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087473, - "block_timestamp": 1781790588, - "tx_hash": "0xe567bc67609d07890b11edf98924c78af490bfedc253a4d8ce1df693b625667a", - "log_index": 35, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "94306883092", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172cbc6a33fe69", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000015f520d61400000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172cbc6a33fe690000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0cfa57204e97b9c91f2e685ada7847c21847deacc30519734635816fd471223fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087748, - "block_timestamp": 1781793888, - "tx_hash": "0xb0455b692097fefb5a7850698a6c3ff831517442f77d38d81daf56172301dd29", - "log_index": 137, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "sellAmount": "3000000000000000", - "buyAmount": "1467227059", - "validTo": 4294967295, - "appData": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172d166a340b60", - "app_data_hash": "0x968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd8", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":532,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000000000000057741bb300000000000000000000000000000000000000000000000000000000ffffffff968a7cae388675d1f5f150e0c33f138a376cf2297c0e164ca4c2fe28fd6aafd80000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172d166a340b600000000000000000000000000000000000000000" - } - }, - { - "uid": "0x88e25f261f0c934dfe9fd1190017eeefbdcabcb3bb2f40d05c32fe3b878ab5afba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11087749, - "block_timestamp": 1781793900, - "tx_hash": "0x77502c64b31d237c6a5406208571da2044d7da4480e65c1ccd1c4e463a558c3a", - "log_index": 136, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x91335e2f56cdb6744fff7da70969d90638cce19e", - "sellAmount": "3000000000000000", - "buyAmount": "2272596748", - "validTo": 4294967295, - "appData": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172d186a340b65", - "app_data_hash": "0x387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":518,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000091335e2f56cdb6744fff7da70969d90638cce19e000000000000000000000000000000000000000000000000000aa87bee538000000000000000000000000000000000000000000000000000000000008775130c00000000000000000000000000000000000000000000000000000000ffffffff387164afa7d6ef1febb500d0c66cc903869fe223a99f8259866a9ba2a99857a10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172d186a340b650000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3d47b55bb7ebb4b046b0dcb0c152c4990805062c4768a2bd0907ea7fd3d772e5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089218, - "block_timestamp": 1781811528, - "tx_hash": "0x74613648cfe829e1fab12f23ef21469a0ed230bf36d3d993b155de9b116b0346", - "log_index": 101, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "40031896826542000", - "buyAmount": "17527546292499914395", - "validTo": 4294967295, - "appData": "0x77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e286a345034", - "app_data_hash": "0x77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":81,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7000000000000000000000000000000000000000000000000008e38cc4e07f7b0000000000000000000000000000000000000000000000000f33e5a7cf4ac1e9b00000000000000000000000000000000000000000000000000000000ffffffff77d2f2e3c1b1482fea439e656a5cf63ea32afefbaeaa006280942f0c1eb471cd0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e286a3450340000000000000000000000000000000000000000" - } - }, - { - "uid": "0x91b7bb9802c77fecf69051cb58010bf4eaf3511fdc272d9b89cdf20a701d4afbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089257, - "block_timestamp": 1781811996, - "tx_hash": "0x6a8bf05ac5d9a96c7f94e706d86066a79d831d6af0651d15838971293afe2756", - "log_index": 139, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "1000000000000000", - "buyAmount": "477798941", - "validTo": 4294967295, - "appData": "0xc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b15", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e336a345216", - "app_data_hash": "0xc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b15", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":2011,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb700000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000000000000000000000000000000000001c7aa21d00000000000000000000000000000000000000000000000000000000ffffffffc10c79345e8b27e8021467437ffe359d3d046cc90dfcdb6482e48ef832913b150000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e336a3452160000000000000000000000000000000000000000" - } - }, - { - "uid": "0x479315784e8f9e9e254405234fc9a4d2391b16a7cc7dc0fcf86093419b41c3aaba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089274, - "block_timestamp": 1781812200, - "tx_hash": "0x9a5ca07d7276dd8a764faef10b9ddd60da1d4b95fb7174a76be8cee00a49c6e0", - "log_index": 71, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x145b714ad53921242d5e4c185343772f8a4e4cb7", - "sellAmount": "37446325452113514", - "buyAmount": "29815248673", - "validTo": 4294967295, - "appData": "0xb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e3c6a3452e1", - "app_data_hash": "0xb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":86,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000145b714ad53921242d5e4c185343772f8a4e4cb70000000000000000000000000000000000000000000000000085093c0eb7866a00000000000000000000000000000000000000000000000000000006f120972100000000000000000000000000000000000000000000000000000000ffffffffb58c21b442ec398926797d19ac2ac5e35b7f136d862454b0d4ad564e3efd9ce40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e3c6a3452e10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x006b940d37b891afad2e0bf729b38ed68ae902e67450c171b7fd7901c3922f56ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089296, - "block_timestamp": 1781812464, - "tx_hash": "0x087ad71566fccd7c5b451ad828bbbaeba8e93003c62c6e93bb78fac22c0193fe", - "log_index": 208, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7b8f0b8e09ca522ad3418fb89b9176f1bc74644c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x7b8f0b8e09ca522ad3418fb89b9176f1bc74644c", - "sellAmount": "120000000000000000", - "buyAmount": "52832056816179325249", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e486a3453ea", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007b8f0b8e09ca522ad3418fb89b9176f1bc74644c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000007b8f0b8e09ca522ad3418fb89b9176f1bc74644c00000000000000000000000000000000000000000000000001aa535d3d0c0000000000000000000000000000000000000000000000000002dd312bc2119fa94100000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e486a3453ea0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x104f25a0d633f9f39840723fc7e72a87d327829c9bc541a08ad9c8a62b9ecc9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089394, - "block_timestamp": 1781813640, - "tx_hash": "0x622375d89119df6419324ad4e5603688261fb01a4d47d717d686b6dd426b5731", - "log_index": 367, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "sellAmount": "4714466422212269", - "buyAmount": "193421397728197901", - "validTo": 4294967295, - "appData": "0xb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d", - "feeAmount": "285533577787731", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172e686a345881", - "app_data_hash": "0xb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d", - "app_data_resolved": { - "fullAppData": "{}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a0000000000000000000000000000000000000000000000000010bfc84066c6ad00000000000000000000000000000000000000000000000002af2bbc878c7d0d00000000000000000000000000000000000000000000000000000000ffffffffb48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d000000000000000000000000000000000000000000000000000103b0f779b953f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172e686a3458810000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6d296984c1ce92ad816194112193e44ea322f3a6d671c6fc2d1929806622ccd8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089725, - "block_timestamp": 1781817624, - "tx_hash": "0x82da5ceda6e28337625a991d4fc7db6b82a1695012b58a6b660ec92b8a88b878", - "log_index": 155, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "sellAmount": "2000000000000000", - "buyAmount": "698594959890775127", - "validTo": 4294967295, - "appData": "0xe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": true, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172f6a6a346812", - "app_data_hash": "0xe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":857,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a00000000000000000000000000000000000000000000000000071afd498d000000000000000000000000000000000000000000000000000009b1e86a2a2afc5700000000000000000000000000000000000000000000000000000000ffffffffe46e7d0cc02ede7c7e143b47f589549ad67b271a1809c9cffe7e7dc60329c86d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000015a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172f6a6a3468120000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf5788a8ba6d8a7ff763299311fa02f6e87777e49ec287e2aadc54a8c964deffbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11089920, - "block_timestamp": 1781819964, - "tx_hash": "0xae6ff21563e64f3f2fdbfabf6b362e8bf771f699a195ffe090333264c0db42a4", - "log_index": 84, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x28977f072c3f9e5899708bca9c327369924486dd", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x28977f072c3f9e5899708bca9c327369924486dd", - "sellAmount": "10000000000000000", - "buyAmount": "4312757926303371962", - "validTo": 4294967295, - "appData": "0xcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172f846a347109", - "app_data_hash": "0xcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-sdk-wasm-swap-demo\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.5\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000028977f072c3f9e5899708bca9c327369924486dd" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000028977f072c3f9e5899708bca9c327369924486dd000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003bd9fe7be79012ba00000000000000000000000000000000000000000000000000000000ffffffffcff26c46e7166c1dab98491d174d6d666147e5562b535818262541391bbec62d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172f846a3471090000000000000000000000000000000000000000" - } - }, - { - "uid": "0xdd4a43c4585339ded372309162806dcee34afcb3411c10a2d6538e4967e60503ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11090323, - "block_timestamp": 1781824800, - "tx_hash": "0x82de62b7e601e1fc4cfe66a8bc93e596fd2fc75405d61facfc3c80138d702b6c", - "log_index": 252, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4e16893cd1fff4455c970a788c868671ab93d8b1", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x9d2efa1cda4cc7a3258af126566a5bcd8a24f7cc", - "receiver": "0x4e16893cd1fff4455c970a788c868671ab93d8b1", - "sellAmount": "10000000000000000", - "buyAmount": "710160124267367018", - "validTo": 4294967295, - "appData": "0x76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000172fbc6a348400", - "app_data_hash": "0x76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":208,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004e16893cd1fff4455c970a788c868671ab93d8b1" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000009d2efa1cda4cc7a3258af126566a5bcd8a24f7cc0000000000000000000000004e16893cd1fff4455c970a788c868671ab93d8b1000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000009dafeded49a8a6a00000000000000000000000000000000000000000000000000000000ffffffff76c89e0625cbd9dd0abe9903cf173de8442d3c746a9a0d4201fb4cd30cdfc96d0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000172fbc6a3484000000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3d1098b807f138f04f8dbf3bd7ae5de53da1562345f311ccccb13b0e139c0191ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091082, - "block_timestamp": 1781833920, - "tx_hash": "0x8fef26deeffc007a9bb1da0a4d9e4eb87a83405d053853de59164a6cb4a1a549", - "log_index": 518, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "55055341111", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017301e6a34a7bb", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000cd18dd63700000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017301e6a34a7bb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe11c2022eab06f614f3466aefee62e0935020cacf107f3f2c41d53515708aa1dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091089, - "block_timestamp": 1781834004, - "tx_hash": "0x53f3a5b098c3b9d72e48999adabf9a61f6bfd8863213d8d4424d18b049f5451a", - "log_index": 466, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "4897709189", - "validTo": 4294967295, - "appData": "0x848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730256a34a80c", - "app_data_hash": "0x848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":186,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000123ed1c8500000000000000000000000000000000000000000000000000000000ffffffff848949b0be53fe96ee43b77844377a12e06f2e4a129bcc14c5b4bd46936d05a50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730256a34a80c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1b7ecce8e8da1c4c24ba0064196951a4bc1cf1397900c5edcf2ea37eff266255ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091095, - "block_timestamp": 1781834076, - "tx_hash": "0x80aa3665a242ac419f775d9fb87fffd3cf17bff6c7f5bf8995dd77d778e0762f", - "log_index": 645, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4064924478581430370", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017302a6a34a851", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000038698346c0a2d86200000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017302a6a34a8510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x17413c36f762b4f043539e465918f3acbe8d92503dc32d31e5f654d8add31724ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091102, - "block_timestamp": 1781834160, - "tx_hash": "0x6e9257e80c4897dc98677ba4373be81fa7b78741520d8dc934ae78153888f0d7", - "log_index": 600, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "1300000000000000", - "buyAmount": "414280907641317243", - "validTo": 4294967295, - "appData": "0xf9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730316a34a8a4", - "app_data_hash": "0xf9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1186,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000049e57d635400000000000000000000000000000000000000000000000000005bfd24a612fe77b00000000000000000000000000000000000000000000000000000000fffffffff9bcf3c895ec686159f1a5b3ea570ad7191c4b2ae70454cc2059d888f7f00bba0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730316a34a8a40000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe18203b84cb8368358e8d375c94d8c738b3bf479c042abd881a9a6a8c17dfa44ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091111, - "block_timestamp": 1781834268, - "tx_hash": "0x30ce2bef8e319cfac8b370880f04a189df6887e0cd8cf754b6d4810285f9ea35", - "log_index": 271, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "573833000000000000", - "buyAmount": "252672845129172466337", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001730376a34a910", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000007f6aa12bd65900000000000000000000000000000000000000000000000000db28a45ed479d3aa100000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001730376a34a9100000000000000000000000000000000000000000" - } - }, - { - "uid": "0x362dcbfb47d683caa387338420dc664fd212fd4cad4628e42f1dcd98737f3ffbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11091361, - "block_timestamp": 1781837316, - "tx_hash": "0x90d9f4ca8bd41a037ceef335e168fcb8cfd9805ee6be598c63aad5c4c35f51e3", - "log_index": 573, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xd57af64ed597007990abe48434fcca685ee134ce", - "receiver": "0x2df787aee880af0be17f2932057cca2ad6dd8478", - "sellAmount": "3000000000000000", - "buyAmount": "4461280859271727529", - "validTo": 4294967295, - "appData": "0x81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace8077", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017307d6a34b4fe", - "app_data_hash": "0x81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace8077", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":537,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000d57af64ed597007990abe48434fcca685ee134ce0000000000000000000000002df787aee880af0be17f2932057cca2ad6dd8478000000000000000000000000000000000000000000000000000aa87bee5380000000000000000000000000000000000000000000000000003de9a74dfc2409a900000000000000000000000000000000000000000000000000000000ffffffff81936e388f71f15f0eb6675e4b2a68f5458983eb460c0652cefc81a4dace80770000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017307d6a34b4fe0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x7fe88a513bcb126fb7156d18b31a23bbb03aa33bf5f3876c3405c65828bc9592ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11093487, - "block_timestamp": 1781863032, - "tx_hash": "0x28978a1f5d23a82aec06459b4a989f2aeeff1138c7b53ab0ead5dedef030d677", - "log_index": 218, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x8c94184bc1ebbfe53bcc36d6395899f0a923a31e", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x8c94184bc1ebbfe53bcc36d6395899f0a923a31e", - "sellAmount": "1000000000000000000", - "buyAmount": "388406859321", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001732796a351954", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000008c94184bc1ebbfe53bcc36d6395899f0a923a31e" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000008c94184bc1ebbfe53bcc36d6395899f0a923a31e0000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000005a6eda563900000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001732796a3519540000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0c37aa675a84a8f94dfc6fb8e27dea1f34435102ab1b3fe43640e4858d92adb0ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094315, - "block_timestamp": 1781872980, - "tx_hash": "0x76260763b966d5c4ad530c7c7d32a84f98cac44030407d82146979bf2e2e9a0a", - "log_index": 88, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9fa3c00a92ec5f96b1ad2527ab41b3932efeda58", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x9fa3c00a92ec5f96b1ad2527ab41b3932efeda58", - "sellAmount": "1000000000000000000", - "buyAmount": "438120010025215221933", - "validTo": 4294967295, - "appData": "0xa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733446a354049", - "app_data_hash": "0xa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"referrer\":{\"code\":\"MOOOO\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009fa3c00a92ec5f96b1ad2527ab41b3932efeda58" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000009fa3c00a92ec5f96b1ad2527ab41b3932efeda580000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000017c022f3dbcf8860ad00000000000000000000000000000000000000000000000000000000ffffffffa1ba74eb08d80bec4a9f5a3deac838fd4b0a18384800e94b88a0cde16c2acfd40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733446a3540490000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2170ca89003f76f3439751daf74f58a5f8c61adb900144ab5665fba4659cda55ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094609, - "block_timestamp": 1781876508, - "tx_hash": "0xfc3dc80cd1f541de9e6621b6aac8bb9be27082d27eb4ab29fe92f902e8a0a10e", - "log_index": 37, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0064241fab45a6fbf5e90ff9e4b9650216c48641", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x0064241fab45a6fbf5e90ff9e4b9650216c48641", - "sellAmount": "50000000000000000", - "buyAmount": "21710356739052631925", - "validTo": 4294967295, - "appData": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733946a354de3", - "app_data_hash": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-swap-wasm\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.6\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000064241fab45a6fbf5e90ff9e4b9650216c48641" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000064241fab45a6fbf5e90ff9e4b9650216c4864100000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000012d4aae6d823d777500000000000000000000000000000000000000000000000000000000ffffffff82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733946a354de30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xcc734808b617ea1b56b02b6c1e5af209f821b122fd28bc5b1dc6f047a74d63cbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11094664, - "block_timestamp": 1781877168, - "tx_hash": "0x16c3c2cc8b6fe4da8ec6037fc3d8e59cace07ae059ae6b8d5b59512d6ac73f6d", - "log_index": 352, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x05bb5df1913283db49870acfac065926aac902d2", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x5d25751d6f70e2c6d8f496e65d6ad0941b759560", - "receiver": "0x05bb5df1913283db49870acfac065926aac902d2", - "sellAmount": "10000000000000000", - "buyAmount": "12747565496824406115", - "validTo": 4294967295, - "appData": "0x31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b412", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733a56a35508b", - "app_data_hash": "0x31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b412", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":178,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000005bb5df1913283db49870acfac065926aac902d2" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000005d25751d6f70e2c6d8f496e65d6ad0941b75956000000000000000000000000005bb5df1913283db49870acfac065926aac902d2000000000000000000000000000000000000000000000000002386f26fc10000000000000000000000000000000000000000000000000000b0e87347a53ea06300000000000000000000000000000000000000000000000000000000ffffffff31fd51aeedc825fe5399216b3cf8082a67ef90b4c66a8692fe82a200b399b4120000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733a56a35508b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x30fbd13655ea67870e66306d4064de6534e72b1fcae6eefcc9eab0f5dee4208fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11095162, - "block_timestamp": 1781883144, - "tx_hash": "0xf2b59832f3c0a9c93767a71c68fc0b8107615e3fbf84530c73760774b8dee225", - "log_index": 830, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30300000000000000", - "buyAmount": "1206300178407213600", - "validTo": 4294967295, - "appData": "0xfac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001733df6a3567f9", - "app_data_hash": "0xfac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":95,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006ba5b080b1c00000000000000000000000000000000000000000000000000010bda39efa73ca2000000000000000000000000000000000000000000000000000000000fffffffffac38e759aa01ea7940cb7b564713ca0d7e9459bd3f5d63adc9a77ba2c395d5f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001733df6a3567f90000000000000000000000000000000000000000" - } - }, - { - "uid": "0x35e2b54d4ac995838fdc863b6fcbf69299387bdd26ee20249c8a57daae0151f3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11095647, - "block_timestamp": 1781888964, - "tx_hash": "0x619543e70cf3076ea4fcab18ee658cc7caf8bc8971631bc29bcb71f1793263e0", - "log_index": 257, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x4b32a32399045dfe93cb376b3eaae8d9186a7881", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x4b32a32399045dfe93cb376b3eaae8d9186a7881", - "sellAmount": "200000000000000000", - "buyAmount": "118695850426", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001734806a357ea3", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000004b32a32399045dfe93cb376b3eaae8d9186a7881" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000004b32a32399045dfe93cb376b3eaae8d9186a788100000000000000000000000000000000000000000000000002c68af0bb1400000000000000000000000000000000000000000000000000000000001ba2d2f1ba00000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001734806a357ea30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2300a9f60d9d01267af425333c7864de38c613c779f9124cd533cff210288a05ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11096098, - "block_timestamp": 1781894376, - "tx_hash": "0x7d226e1f18e03e923b27a9f8082f68d5f4970ed848e1d2e72e409b58e3f61434", - "log_index": 40, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf3f128aa2e7904924bcd2220e8573a1fc68bc6a7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xf3f128aa2e7904924bcd2220e8573a1fc68bc6a7", - "sellAmount": "10000000000000000", - "buyAmount": "4492834272429127369", - "validTo": 4294967295, - "appData": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x000000000017359e6a3593df", - "app_data_hash": "0x82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"cow-swap-wasm\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":50},\"utm\":{\"utmCampaign\":\"developer-cohort\",\"utmContent\":\"wasm\",\"utmMedium\":\"cow-rs@0.1.0-alpha.6\",\"utmSource\":\"cow-sdk\",\"utmTerm\":\"rs\"}},\"version\":\"1.15.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f3f128aa2e7904924bcd2220e8573a1fc68bc6a7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f3f128aa2e7904924bcd2220e8573a1fc68bc6a7000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003e59c0f77ad6b6c900000000000000000000000000000000000000000000000000000000ffffffff82275873027496793547b4fac1740dd28c09817b107791e5fdb7686ae00d409b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c000000000017359e6a3593df0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe2e97306cbe93c81d5472f15b42a23ca37c87b7d151aa129763c84ef0aa42f8bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11097901, - "block_timestamp": 1781916048, - "tx_hash": "0x9c3691ed07216339d1e38a6e7c16a5f29e011404a7fed37b8f9455ecd708b990", - "log_index": 363, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xf56d0f35f4b7ab02cac579035220bcce23bf18ed", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbbbe8905aeb65f5114f9c5f52b8a1130db33513b", - "receiver": "0xf56d0f35f4b7ab02cac579035220bcce23bf18ed", - "sellAmount": "30000000000000000", - "buyAmount": "836208744757351966", - "validTo": 4294967295, - "appData": "0x7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738a66a35e876", - "app_data_hash": "0x7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":114,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000f56d0f35f4b7ab02cac579035220bcce23bf18ed" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000bbbe8905aeb65f5114f9c5f52b8a1130db33513b000000000000000000000000f56d0f35f4b7ab02cac579035220bcce23bf18ed000000000000000000000000000000000000000000000000006a94d74f4300000000000000000000000000000000000000000000000000000b9acf6c4556561e00000000000000000000000000000000000000000000000000000000ffffffff7123077d45c4ca51916e2859def84f77446b584315797d07a4cfd8fa2eddf9fa0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738a66a35e8760000000000000000000000000000000000000000" - } - }, - { - "uid": "0x15d6d916a96f41d9d130f4ec3fecc66a67b2953c7242bc85d1f57886856a2f8bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098198, - "block_timestamp": 1781919612, - "tx_hash": "0x0b36fe3f1e6554b48d3073f40cbac0ecd2ba36967597f1ddeedf6f5950845faa", - "log_index": 113, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "65561298781", - "validTo": 4294967295, - "appData": "0xb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738b26a35f66a", - "app_data_hash": "0xb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":70,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000000f43c2075d00000000000000000000000000000000000000000000000000000000ffffffffb52f968c2364b75d91ef44f77cb77b55507b3088daf03b17488d1a01684ea34c0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738b26a35f66a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd0186dc05c85c872eecff1a51e7abd23976b11f0d23ba4f1b79b1c4f4ef03489ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098367, - "block_timestamp": 1781921652, - "tx_hash": "0x6f782626aa8f5987d3c216aa49be32de2a71c33be8ebdec66549a85de693b2bb", - "log_index": 467, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "20000000000000000", - "buyAmount": "786330874799083854", - "validTo": 4294967295, - "appData": "0xd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c720", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738d96a35fe68", - "app_data_hash": "0xd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c720", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":130,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000470de4df8200000000000000000000000000000000000000000000000000000ae99bc3b452514e00000000000000000000000000000000000000000000000000000000ffffffffd27786c2611afd1676af2aa3421b184c2ab99b82d85173b1f4762a20c0a9c7200000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738d96a35fe680000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe188b861712f3b3e0137ed5e16ebdc014655149d315132a013f33c43d7a284d6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098372, - "block_timestamp": 1781921712, - "tx_hash": "0xfe7483e36dd3014739cf47e42f65952a1c1642ae0d953b59fccdbc954868b41f", - "log_index": 456, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "54741962398", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001738db6a35fea7", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e8600000000000000000000000000000000000000000000000000000000000cbee00e9e00000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001738db6a35fea70000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd13cad5b99607561527d31a2a4e792a840fe14802c375bfd39780e30b377e72dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11098766, - "block_timestamp": 1781926452, - "tx_hash": "0xa7bfec99b054d2a10a7c5f7f0d4bc0e3c782d336a5aaaa6a6468c7ec4549a411", - "log_index": 480, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30300000000000000", - "buyAmount": "1204404047601920333", - "validTo": 4294967295, - "appData": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739016a361123", - "app_data_hash": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":93,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006ba5b080b1c00000000000000000000000000000000000000000000000000010b6e7199f5ae94d00000000000000000000000000000000000000000000000000000000ffffffff3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739016a3611230000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbd8cc1614ee8fd124674679ddfe19aba28c193468d16007cacdc9514d7b37cb1ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11099348, - "block_timestamp": 1781933460, - "tx_hash": "0xf378a0e66b05a4de4ce835d29fc353fb77848cf1dca1375633a79784becf0a49", - "log_index": 57, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "17309183751", - "validTo": 4294967295, - "appData": "0x181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f751", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739166a362c85", - "app_data_hash": "0x181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f751", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":79,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000407b52f0700000000000000000000000000000000000000000000000000000000ffffffff181ab88b7ce3da71c65f8efaf01ce6be57a5fe04dfaca19cb20db530d724f7510000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739166a362c850000000000000000000000000000000000000000" - } - }, - { - "uid": "0x77446407735fe5b0ae0bd2d625607228b69457e4a3a0f60fa07db9fb552c14f5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11099353, - "block_timestamp": 1781933520, - "tx_hash": "0xdeda94d0c6fdcd804ffdf34e19feb8c1308863563345493a2cb219eb5052d409", - "log_index": 90, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "40503544582", - "validTo": 4294967295, - "appData": "0x2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739196a362cc2", - "app_data_hash": "0x2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":80,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000096e330b0600000000000000000000000000000000000000000000000000000000ffffffff2a968f27811fefb1db663c47e518110ddf0e85e5febfb36b778d114aa049a7ee0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739196a362cc20000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfec45a4275eecd8750852772f324a97655a43078d739e78b9b56dc7d6fd3be29ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11100012, - "block_timestamp": 1781941428, - "tx_hash": "0x3a96aafe78b6cbb771163812460843dbc6af7d8cb74fc5ecfdb2557e9c562102", - "log_index": 265, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "480000000000000000", - "buyAmount": "404947959169", - "validTo": 4294967295, - "appData": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739266a364ba3", - "app_data_hash": "0xf3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc9", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":56,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c80000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000006a94d74f43000000000000000000000000000000000000000000000000000000000005e48c77d8100000000000000000000000000000000000000000000000000000000fffffffff3be8e032e64b64a521effc0d6b03800f6250e6d42082d26eebd9eaea4b7abc90000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739266a364ba30000000000000000000000000000000000000000" - } - }, - { - "uid": "0xd97a9fa007f04c621c6f05f4f6d5fd1411ec010cbe69c23833a85a80141091dbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11100016, - "block_timestamp": 1781941476, - "tx_hash": "0xd639e3705406b7041a7ff91b3b921f71aa66dfdcd53000a5e49201e8679b2362", - "log_index": 262, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x0bcfa77f1e1042dc67b288ddbfd217428448480c", - "sellAmount": "480000000000000000", - "buyAmount": "54963695446", - "validTo": 4294967295, - "appData": "0x8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e02", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x00000000001739296a364bd8", - "app_data_hash": "0x8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e02", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":55,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d00000000000000000000000000bcfa77f1e1042dc67b288ddbfd217428448480c00000000000000000000000000000000000000000000000006a94d74f43000000000000000000000000000000000000000000000000000000000000ccc176f5600000000000000000000000000000000000000000000000000000000ffffffff8221f9a297f6094090a3c1e3e697a7dbaa686e220f9f031e2d470bab58fc8e020000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c00000000001739296a364bd80000000000000000000000000000000000000000" - } - }, - { - "uid": "0xb1c28fdb942f26df6c51fabbe7fc993010d76a3a664e51f1e9d8f63f42f6944eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11102012, - "block_timestamp": 1781965476, - "tx_hash": "0x33f54e3dd7eac79c47006d737deabbc25e66dacdded0cccb66a792da80b0d765", - "log_index": 320, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "31000000000000000", - "buyAmount": "1234033423748101677", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a036a36a99a", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006e2255f409800000000000000000000000000000000000000000000000000011202adc5776f62d00000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a036a36a99a0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x62ac84898792e53d0442baa16ac0400ad6919e2410849f6c93717e4ff95447cdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11102181, - "block_timestamp": 1781967528, - "tx_hash": "0xd55f94b34a0e12a212afd7cfb35d1fed8cfba0588cea8b754eb8822eecbf2d08", - "log_index": 316, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xff602766ca1e4a861f2fddf3b5c20dc3b8b9131f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x58eb19ef91e8a6327fed391b51ae1887b833cc91", - "receiver": "0xff602766ca1e4a861f2fddf3b5c20dc3b8b9131f", - "sellAmount": "300000000000000000", - "buyAmount": "139111134", - "validTo": 4294967295, - "appData": "0xc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef9383684", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a1a6a36b197", - "app_data_hash": "0xc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef9383684", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":55,\"smartSlippage\":true}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000ff602766ca1e4a861f2fddf3b5c20dc3b8b9131f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000058eb19ef91e8a6327fed391b51ae1887b833cc91000000000000000000000000ff602766ca1e4a861f2fddf3b5c20dc3b8b9131f0000000000000000000000000000000000000000000000000429d069189e000000000000000000000000000000000000000000000000000000000000084aaade00000000000000000000000000000000000000000000000000000000ffffffffc6c7de5fee96b78890f2acef3e54a6e66fd39aabd7365a805332001ef93836840000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a1a6a36b1970000000000000000000000000000000000000000" - } - }, - { - "uid": "0x64fc616d2891e449f20b8581a97eb2f8177a3a643a54ad6e61160865301527fdba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11103512, - "block_timestamp": 1781983536, - "tx_hash": "0x518b57406fea01e928fdf1b04bf4aa3f1185ca16b6610e9cb1cb5eec3f422243", - "log_index": 176, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xa013a0b118eaaf9625db57faee049c993a8946d0", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0xa013a0b118eaaf9625db57faee049c993a8946d0", - "sellAmount": "10000000000000000", - "buyAmount": "4398038534883464380", - "validTo": 4294967295, - "appData": "0xd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b14182", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173a3b6a36f02f", - "app_data_hash": "0xd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b14182", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":201,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000a013a0b118eaaf9625db57faee049c993a8946d0" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000a013a0b118eaaf9625db57faee049c993a8946d0000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000003d08f8bee43554bc00000000000000000000000000000000000000000000000000000000ffffffffd285bda848c630af9355567cea7decda1776d2b18820cb23f9f5f866d1b141820000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173a3b6a36f02f0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x809b0200bf1559986965f26648093bb178a5e8d93a871479e293507967804586ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105542, - "block_timestamp": 1782007956, - "tx_hash": "0x3f0f3159d02feec68e8f364cd52d8f02086994fa4aae0c90cba929ef99481a79", - "log_index": 134, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "95017239796", - "validTo": 4294967295, - "appData": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173aca6a374f8d", - "app_data_hash": "0xbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa7885", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":90,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000161f7804f400000000000000000000000000000000000000000000000000000000ffffffffbfbd142717d5ab94c45b747a0378289a8175fbeafd703d4c5fceec304bfa78850000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173aca6a374f8d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x2efe5755ae5af528a0ea9c6abbb29004b54fc8aba78b211d84c9faa1a47c0255ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105545, - "block_timestamp": 1782008004, - "tx_hash": "0xfa33f1b3a8d45960aa6b9931f27d73a1a0d46cc4ad29d9253a37fc5c7e72af6c", - "log_index": 455, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3981607377106476577", - "validTo": 4294967295, - "appData": "0x9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ace6a374fb6", - "app_data_hash": "0x9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":88,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000374182d063819e2100000000000000000000000000000000000000000000000000000000ffffffff9ea3e68b82abb021c4f385fc144aec6bd527e0d87fecf5a9ce0d83582e2d080f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ace6a374fb60000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa801952a7c389530f0a15d086c1413d7ae4a3a11797bb8b88eec493f21281d75ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105551, - "block_timestamp": 1782008076, - "tx_hash": "0x96c145763af1cd640c045c43c4d583fdde28711910764050bc7f8c253f9b9e92", - "log_index": 534, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "42300751995", - "validTo": 4294967295, - "appData": "0x576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a405395", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ad46a375002", - "app_data_hash": "0x576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a405395", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":115,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e86000000000000000000000000000000000000000000000000000000000009d952407b00000000000000000000000000000000000000000000000000000000ffffffff576f12b7fac72155763d85432f15e2c21f395741a49fa596088c55b31a4053950000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ad46a3750020000000000000000000000000000000000000000" - } - }, - { - "uid": "0xe51fcd2e7df1557304698271509113f9f2a3c9e3a5364cf626fdedb44452ae9eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105566, - "block_timestamp": 1782008256, - "tx_hash": "0x81d5a4301432dccb640a67e20beaeb61970fdaf542d6a4f3d31ff3e660271e9f", - "log_index": 568, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "50000000000000000", - "buyAmount": "44602720021", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173ae46a3750ae", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000000a62877f1500000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173ae46a3750ae0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x87e7ed809508f4a9d5e22a7cd72a87cf2e2f7f6834f598124f27434e5858762dba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105602, - "block_timestamp": 1782008688, - "tx_hash": "0xf48bb0f258acae8a7665bcccb42b045700236e96aee5c37794cd4ac45102314f", - "log_index": 424, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "60000000000000000", - "buyAmount": "36154471328", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b0b6a375263", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000d529ae9e860000000000000000000000000000000000000000000000000000000000086af973a000000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b0b6a3752630000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa249ff22a2aea55379a4ca3bff16b2fc84f8ac783dfb294058d1454dacc25734ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105613, - "block_timestamp": 1782008820, - "tx_hash": "0x5f875b42346bda28ccec431b211695cd2daf9003b6e2f23a596b9650ca4726ac", - "log_index": 45, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "22658593985", - "validTo": 4294967295, - "appData": "0x4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d92", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b226a3752e9", - "app_data_hash": "0x4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d92", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":102,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000000000005468eb4c100000000000000000000000000000000000000000000000000000000ffffffff4c0e83f00de6ed4c930e407faa62dc1c98e5d2ee3dcd0b52a19618b44a240d920000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b226a3752e90000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfbe5e123060e6ec9a05a90ffe41c37681b3ebd3db229b2f86b02498148b39033ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105618, - "block_timestamp": 1782008880, - "tx_hash": "0xf07091a7af051dd539aadcac6ac98bdbb066d9261dd32eca9395c96f1d3c14fb", - "log_index": 19, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "50000000000000000", - "buyAmount": "36753223075", - "validTo": 4294967295, - "appData": "0x1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b2d6a37531c", - "app_data_hash": "0x1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":104,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000000b1a2bc2ec50000000000000000000000000000000000000000000000000000000000088ea9ada300000000000000000000000000000000000000000000000000000000ffffffff1af14db8cf5e96e08af7db03bba51a03c50c655ae45339e2c87fee6263e6c3af0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b2d6a37531c0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x72bf47dc595715fa4afee539469f45dd53b2af9440f7d0f152240f0a6a6b2fe2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11105715, - "block_timestamp": 1782010044, - "tx_hash": "0xaf3cefb5ab0dfe553a9a7986eaf71ca23521968b48197bb5fbaa64756bf10ac0", - "log_index": 497, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "30100000000000000", - "buyAmount": "1185911861215795099", - "validTo": 4294967295, - "appData": "0x4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b406a3757b3", - "app_data_hash": "0x4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":128,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46000000000000000000000000000000000000000000000000006aefca5fbd40000000000000000000000000000000000000000000000000001075348df6b0979b00000000000000000000000000000000000000000000000000000000ffffffff4b4bfe67d3c3f5425674ae5b2eeb22a6e1b69b7a5ce8929ed70abc05576922cc0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b406a3757b30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1293c734a1404206c371788d9049b045f67cd0974763f13dc59741be4475d651ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11106911, - "block_timestamp": 1782024444, - "tx_hash": "0xea7c3e7e82fab71255b05a3fd1ac3b27adea9d354eba1fe313b6f97672a9b1fb", - "log_index": 295, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "130000000000000000", - "buyAmount": "163390285400", - "validTo": 4294967295, - "appData": "0x01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b486a378ff0", - "app_data_hash": "0x01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":60,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000001cdda4faccd0000000000000000000000000000000000000000000000000000000000260ad1e65800000000000000000000000000000000000000000000000000000000ffffffff01c84758e6b385cb30af79f49ecd6aedc9c026f28f39289ae41f5017cbfe80db0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b486a378ff00000000000000000000000000000000000000000" - } - }, - { - "uid": "0xeeb9580b065954ce8dc28fa542f2a032978b89b2405574b34f1c3622c5373d40ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11106932, - "block_timestamp": 1782024696, - "tx_hash": "0xd131443378834e6e9284b47ec6dfa04cabc089a7151c9e339791a8009ad22d2f", - "log_index": 138, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "800000000000000000", - "buyAmount": "2154051549465", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b4b6a3790e3", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da55329700000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000001f5877a391900000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b4b6a3790e30000000000000000000000000000000000000000" - } - }, - { - "uid": "0x42e1019e3235e8a095f147b2e7d2ef12d587879bd57b7bac439ebb3be4861ea2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11107190, - "block_timestamp": 1782027792, - "tx_hash": "0xda3d66cc610d05bd714dabadb49295dce1339580a6868b2f864bb4443ec0de16", - "log_index": 122, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "700000000000000000", - "buyAmount": "454734723217", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173b526a379d02", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000009b6e64a8ec6000000000000000000000000000000000000000000000000000000000069e04d389100000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173b526a379d020000000000000000000000000000000000000000" - } - }, - { - "uid": "0x863285b84360f355796171c54bb778e85c04c53d63e8860cb4ef58609d4fcacbba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11108038, - "block_timestamp": 1782037968, - "tx_hash": "0x7be1597fcb954eb7775365a45b220f82a546c1059a7bd1cc1ada319f9f94ae31", - "log_index": 215, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9c1c3484ebc79a5543a3ad193573eb0250756e55", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xbe72e441bf55620febc26715db68d3494213d8cb", - "receiver": "0x9c1c3484ebc79a5543a3ad193573eb0250756e55", - "sellAmount": "100000000000000000", - "buyAmount": "45796774228361504087", - "validTo": 4294967295, - "appData": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173bb06a37c4c8", - "app_data_hash": "0x4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":64,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009c1c3484ebc79a5543a3ad193573eb0250756e55" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000009c1c3484ebc79a5543a3ad193573eb0250756e55000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000027b8ed384dc40655700000000000000000000000000000000000000000000000000000000ffffffff4b70e9e5757f8022a8dbd2328d93cb8d4b88bd1b713268e56d750e1f944abd7b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173bb06a37c4c80000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3545ede8cbc6a9b3b88d9e6ad9edbd0219d3181a897143706a84bca84e4439b7ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11109421, - "block_timestamp": 1782054600, - "tx_hash": "0x8182fa21698411fdc10314c5b5e832be036ce0dc89c85edeef6e6e4b3d7c2dc8", - "log_index": 435, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "30200000000000000", - "buyAmount": "1201985779697191530", - "validTo": 4294967295, - "appData": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173bf46a3805c0", - "app_data_hash": "0x3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":93,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec000000000000000000000000000000000000000000000000006b4abd7037800000000000000000000000000000000000000000000000000010ae4fb2bfec0a6a00000000000000000000000000000000000000000000000000000000ffffffff3590a66c701fcdb35a11937f48367bbd156122d02b8f42ab2f8ca05e25ebbbde0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173bf46a3805c00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x98d90da1983292de38bab6263ced6dec408e53a1004111416da98405d84aa5caba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112811, - "block_timestamp": 1782095304, - "tx_hash": "0xdb3d5afe4f455e19c7ee88d5de9ca61abc8fbfaddc799eb196f6f0452117101b", - "log_index": 425, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "618752569741", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb06a38a4bb", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000901086f18d00000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb06a38a4bb0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xf60dc92a43e9f591ef82aba723a1ad64351ff2540abe477550e0ef68039272b5ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112823, - "block_timestamp": 1782095448, - "tx_hash": "0x52eba0f7e55d5bc81c57f4567ca36d5595f85dc7e22f40c0b2bcd12d6f72e838", - "log_index": 283, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3613303531689246551", - "validTo": 4294967295, - "appData": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb66a38a549", - "app_data_hash": "0x11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c5", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1000,\"smartSlippage\":false},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000003225086b00007f5700000000000000000000000000000000000000000000000000000000ffffffff11b9642a449e571a61d42c0864697cfde62d3e776108762077ccc50c754023c50000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb66a38a5490000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbad0af58df602423e8deda4247257dd0dfbb5c95290c39b2e1510d93b04da8eeba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112886, - "block_timestamp": 1782096204, - "tx_hash": "0x3280cbaeb75b04c5bc32ea71aaae99fa60314a33473a4a4dae02adb83d5a324e", - "log_index": 464, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "4000000000000000000", - "buyAmount": "342772475155", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cb86a38a848", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000003782dace9d9000000000000000000000000000000000000000000000000000000000004fced4e51300000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cb86a38a8480000000000000000000000000000000000000000" - } - }, - { - "uid": "0xda12aeee5dced139c3bae5ce4dfa895f98bf262b680a3040d3c2b46d91cc0e25ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11112894, - "block_timestamp": 1782096300, - "tx_hash": "0xbb19c81a8086304b41b5ab1e18a185adacfdfe7c3c5c4a96cd837ef45eb6dfcd", - "log_index": 344, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x55a679b99e25de2d9227b5ebd8079cbd872b473f", - "sellAmount": "2000000000000000000", - "buyAmount": "2061946554520", - "validTo": 4294967295, - "appData": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cba6a38a8a5", - "app_data_hash": "0x910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":51,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000055a679b99e25de2d9227b5ebd8079cbd872b473f0000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000000000001e01597889800000000000000000000000000000000000000000000000000000000ffffffff910fb63b23e9a000ec4cb69facdd06fd86f5fc8dc16adff3a8a408875394425f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cba6a38a8a50000000000000000000000000000000000000000" - } - }, - { - "uid": "0x271f933de098a2132649344a0dd67c23f2599ca7a5a7ea4c9a91558817b95301ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11113205, - "block_timestamp": 1782100044, - "tx_hash": "0x066f0b3dc1775240b7766368d8a4ec601ba80128bd8341292f8005e0ddc66955", - "log_index": 247, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x6ec4672a460b414e661b3baf798071655edd1b46", - "sellAmount": "51000000000000000", - "buyAmount": "1999916059146688219", - "validTo": 4294967295, - "appData": "0xf048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173cbc6a38b741", - "app_data_hash": "0xf048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":141,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000006ec4672a460b414e661b3baf798071655edd1b46" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000006ec4672a460b414e661b3baf798071655edd1b4600000000000000000000000000000000000000000000000000b5303ad38b80000000000000000000000000000000000000000000000000001bc1210f4e0996db00000000000000000000000000000000000000000000000000000000fffffffff048416956033a926809e59ffb0674331774ef5c4d8de7586ea9df8605a76e7e0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173cbc6a38b7410000000000000000000000000000000000000000" - } - }, - { - "uid": "0x354f7970df826a66f6ea2df641c0a1abb0e71c265de80dd10ccbbc51c25c237bba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11114331, - "block_timestamp": 1782113580, - "tx_hash": "0xee29cd369c7d8d9147e8f3c171208d937cbae1cffbcc0d0057f02b9b894dfff0", - "log_index": 443, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "873998305205", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d196a38ec1d", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000cb7e5bc7b500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d196a38ec1d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x52d511d99409694546c306c9df74bb59cfc0419e2460a683ee5f8e1a351f1eacba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11114353, - "block_timestamp": 1782113844, - "tx_hash": "0xccba15ac5455e9f947705f376fe08240a1d05e89a6e95c76331f460a1b3b76cc", - "log_index": 346, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "4013221168023401979", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d286a38ed22", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000037b1d363ad1cf5fb00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d286a38ed220000000000000000000000000000000000000000" - } - }, - { - "uid": "0x39ba5342dedbd786d6d5238f993ae62c1d7498c946377818274386e5c0a52a1fba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115217, - "block_timestamp": 1782124248, - "tx_hash": "0x8f47a18653c4473a1f85671b8872883b1f293d59b95dd7d12418711a1f3ad4df", - "log_index": 222, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "500000000000000000", - "buyAmount": "1519255794265", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d666a3915d1", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000161bab3b25900000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d666a3915d10000000000000000000000000000000000000000" - } - }, - { - "uid": "0x8f627309435bf115950a7dcd162c6ee385f636c5ef3ad474d487ca5e7dc0ce78ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115224, - "block_timestamp": 1782124332, - "tx_hash": "0xfed0b5ac295a5439ae0e09b4ffecd94ab8d0477eb9c47df8e81f6f33c6eec0bf", - "log_index": 97, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "500000000000000000", - "buyAmount": "5754867610445", - "validTo": 4294967295, - "appData": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d6b6a391625", - "app_data_hash": "0xacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce519", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":52,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b700000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000000000053be8d6f34d00000000000000000000000000000000000000000000000000000000ffffffffacc3a3b809bc86e2113604110946b738e436ab96974ac7f81da2ca7f283ce5190000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d6b6a3916250000000000000000000000000000000000000000" - } - }, - { - "uid": "0x4d0b40ff1543576448ce4e7d462ad9323c2e3485834488834159ab6f4bb5aa17ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115229, - "block_timestamp": 1782124392, - "tx_hash": "0x86be0587493da971fd9ea7083401fb6e55ed655a5bf6fec707f6c078eaa24831", - "log_index": 36, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "955998719306", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d6e6a391659", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c800000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000de95f6cd4a00000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d6e6a3916590000000000000000000000000000000000000000" - } - }, - { - "uid": "0xdc658bc7e66649c2e40973fe0c1e694c26e9c4134a6fed714cff56a6d01beae6ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115320, - "block_timestamp": 1782125508, - "tx_hash": "0xaaee2a3d3db76e20a5b1d5b76b77354894898f64550fae8620eb1856f3636e20", - "log_index": 209, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "100000000000000000", - "buyAmount": "1040023733157", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d746a391aac", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000f2263ec3a500000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d746a391aac0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3ad5be4803641967af1c256ba8b40da541ba700cff392f651e241114a1bdf71cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115412, - "block_timestamp": 1782126624, - "tx_hash": "0xe9a56c5842ac6c29ed3b78917ef2591788623db90714cb62ecb4d54e6ab4fd46", - "log_index": 158, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "10000000000000000", - "buyAmount": "176226363118", - "validTo": 4294967295, - "appData": "0x147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a74", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7a6a391f0d", - "app_data_hash": "0x147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a74", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":173,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000002907e8e6ee00000000000000000000000000000000000000000000000000000000ffffffff147eea762f679a0c14fe063e84a5727c48d889a8609139270bce02d3bdfc0a740000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7a6a391f0d0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa412cc0c38e2fa9c2d8dccd81b33cac5c930d504b4f500f1fb28a9db953160f8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115417, - "block_timestamp": 1782126684, - "tx_hash": "0xcb8693cfcad3c53ba712ce9d1eb4d89abb88b2cc1de5f61b02197c20c38a60c7", - "log_index": 136, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "1000000000000000", - "buyAmount": "14620194769", - "validTo": 4294967295, - "appData": "0x029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7c6a391f51", - "app_data_hash": "0x029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1826,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000003676e77d100000000000000000000000000000000000000000000000000000000ffffffff029c6f6e7b2d7e35c3524639a75e7c8a97cfcb490cae24b80d1469d4ab89c22f0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7c6a391f510000000000000000000000000000000000000000" - } - }, - { - "uid": "0x6cce9b6251c620ed8a28f41a6f21e0b76098872f8407cb44cefd7d73749d2e5eba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115424, - "block_timestamp": 1782126768, - "tx_hash": "0xbf006e4c4549a2906eb4778aa2cac778c079b26e7c73b0cedd8c59bdb516c238", - "log_index": 162, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "2000000000000000", - "buyAmount": "36719411249", - "validTo": 4294967295, - "appData": "0x479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d7d6a391f98", - "app_data_hash": "0x479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":775,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000071afd498d0000000000000000000000000000000000000000000000000000000000088ca5c03100000000000000000000000000000000000000000000000000000000ffffffff479c8fc6ba3983d9a2fb3cbcaef70bb71e9d9802965b64327de2c835ac62677b0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d7d6a391f980000000000000000000000000000000000000000" - } - }, - { - "uid": "0x45902f9e5da88a7587b059006d050a6fca1f9679b170a08638fb58983ae43b78ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115429, - "block_timestamp": 1782126828, - "tx_hash": "0xab777e0ce49605885eaf3d907ba86b4a0853757ae0461155267a27f6955f9df7", - "log_index": 107, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xbf966e6d886a1f7910d3bc9777709cc37a400b63", - "sellAmount": "1200000000000000", - "buyAmount": "17647826638", - "validTo": 4294967295, - "appData": "0x8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d816a391fde", - "app_data_hash": "0x8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":1341,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b63" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000bf966e6d886a1f7910d3bc9777709cc37a400b6300000000000000000000000000000000000000000000000000044364c5bb0000000000000000000000000000000000000000000000000000000000041be476ce00000000000000000000000000000000000000000000000000000000ffffffff8f929d3fda5f29a4014f053e473b9dda0cbe68b663b1f17c0aef07f2c852a6f10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d816a391fde0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xfac5eea4988f14149b3fda6316aa95d97e0bb0124b6851fec162e565dfbcbd1aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115499, - "block_timestamp": 1782127668, - "tx_hash": "0xf5898a5673e51dcee6994c7d2f1270e33950d1a175bc591de2a532c1d7148266", - "log_index": 34, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "702648658085", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d876a392325", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000a3991fa8a500000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d876a3923250000000000000000000000000000000000000000" - } - }, - { - "uid": "0x3f581643d744c168d68d0bde1794bc8a608ff9c94adc2aa2348a811fe2be80b8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115786, - "block_timestamp": 1782131112, - "tx_hash": "0x327a0c74827a827994113aa3fd8916a835eb5a346cf62e62f0a207f8cfe6514c", - "log_index": 75, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "100000000000000000", - "buyAmount": "961886171940", - "validTo": 4294967295, - "appData": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173d9e6a3930a0", - "app_data_hash": "0xc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a609093", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":62,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000dff4e2332400000000000000000000000000000000000000000000000000000000ffffffffc250f4f0b5d3affc0ccfa105845af208ae18102d1a67def0bf8e7b732a6090930000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173d9e6a3930a00000000000000000000000000000000000000000" - } - }, - { - "uid": "0x303a3415b13ab81f070eae814b64f2b88e607217566d51e8262c16c0e7f03a13ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115796, - "block_timestamp": 1782131232, - "tx_hash": "0xec1e95e6b9e24596d40b5951377eb11259e9e29e343e108c54f154995adf9889", - "log_index": 38, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x39c9c049ae092c1d3b35b6e827b2c7c716d6a2b7", - "sellAmount": "100000000000000000", - "buyAmount": "162165232975", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173da26a393118", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x00000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d000000000000000000000000039c9c049ae092c1d3b35b6e827b2c7c716d6a2b7000000000000000000000000000000000000000000000000016345785d8a000000000000000000000000000000000000000000000000000000000025c1cd154f00000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173da26a3931180000000000000000000000000000000000000000" - } - }, - { - "uid": "0xc6bf93cb67fe38c1fe0ffdc948dd727d615390a5e00985d72c07fa533276bb2cba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11115816, - "block_timestamp": 1782131472, - "tx_hash": "0x3309a0d31bee2a4cb357f4a0e6507d884bccc784570eec993604c5118ef6c04e", - "log_index": 151, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0xdcb35931a1ffffdc9c6d913932662f8da5532970", - "sellAmount": "50000000000000000", - "buyAmount": "306331005501", - "validTo": 4294967295, - "appData": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173db56a393204", - "app_data_hash": "0x4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":76,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da5532970" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000dcb35931a1ffffdc9c6d913932662f8da553297000000000000000000000000000000000000000000000000000b1a2bc2ec500000000000000000000000000000000000000000000000000000000004752c0323d00000000000000000000000000000000000000000000000000000000ffffffff4b2392b557c47988d4d1dcef8abd59cb90704caa3b6d8f7e2d968a1ddc5bfa3a0000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173db56a3932040000000000000000000000000000000000000000" - } - }, - { - "uid": "0xc70930beb50bf3ff6fbc1d6b7a147e0fb3df858fba02c914ce5716bf84bb9a41ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116414, - "block_timestamp": 1782138732, - "tx_hash": "0x22cd17ae6859c428952059348eae8de83aef966c537b5cd471a03f5112e6ea53", - "log_index": 377, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x9b0199711d109b6226db314bde7116e64e0688ec", - "sellAmount": "51000000000000000", - "buyAmount": "2038406969490573943", - "validTo": 4294967295, - "appData": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e126a394e60", - "app_data_hash": "0x8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d4", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":74,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x0000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000009b0199711d109b6226db314bde7116e64e0688ec00000000000000000000000000000000000000000000000000b5303ad38b80000000000000000000000000000000000000000000000000001c49e056bc2a8a7700000000000000000000000000000000000000000000000000000000ffffffff8fef0ada2c9b3928d6bc5f50b22089377e6cd10aa61f80349b44f64014c0c3d40000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e126a394e600000000000000000000000000000000000000000" - } - }, - { - "uid": "0xbdc6f0ae1177bf5a22f04b4346914dd434630a24589439e87e9c1da0840f51d8ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116631, - "block_timestamp": 1782141408, - "tx_hash": "0x1aa598a55578584c81af0e4ec6e975b0540d7d6ebd6561f6a990fa25d24cb518", - "log_index": 267, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "62579374031", - "validTo": 4294967295, - "appData": "0x516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b98", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e176a3958cf", - "app_data_hash": "0x516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b98", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":181,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000e920577cf00000000000000000000000000000000000000000000000000000000ffffffff516298ffeffb098bc0e75025ebfcafb11271e5c5a66f9fd72206ba32d8762b980000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e176a3958cf0000000000000000000000000000000000000000" - } - }, - { - "uid": "0x0bab3b087f2ebff9d9bec12303e00106b11ce672254cc262b9753d4f7ccccb2aba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116635, - "block_timestamp": 1782141456, - "tx_hash": "0xe3c1e24dc3218cada45564eb58284c1d938c562051eb882f5ea37bba5b946960", - "log_index": 230, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "486830996934", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e196a395909", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000aa8e23fb1079ea71e0a56f48a2aa51851d8433d0000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000000000007159637dc600000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e196a3959090000000000000000000000000000000000000000" - } - }, - { - "uid": "0x1916db8fb427ff2009035b790de6921d565e55c0e5e414031fad2fa1a0910cfcba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116641, - "block_timestamp": 1782141528, - "tx_hash": "0x894f89569972d545e85d11122f6a1e2d3af186b00229a2962bb5829f8c363e91", - "log_index": 287, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "10000000000000000", - "buyAmount": "60405431878", - "validTo": 4294967295, - "appData": "0x1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e28", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e1c6a39594b", - "app_data_hash": "0x1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e28", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":176,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000e1071be4600000000000000000000000000000000000000000000000000000000ffffffff1098c837e9e8f1cc5f74dacbc62ad06e7be2403abccf4d780f98235fd0ec0e280000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e1c6a39594b0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xda1c4056133c58c935a2d5e5db1262227cd85daef18c079b2402d795611922e3ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116645, - "block_timestamp": 1782141576, - "tx_hash": "0xc16b9a2606101784d96a5d6458581c8cec7b3a69af3144e75b2fcf9ff1c8329b", - "log_index": 265, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x0625afb445c3b6b7b929342a04a22599fd5dbb59", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "100000000000000000", - "buyAmount": "3976944831987662352", - "validTo": 4294967295, - "appData": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e1e6a39597e", - "app_data_hash": "0x58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf2358", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":63,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b000000000000000000000000000000000000000000000000016345785d8a00000000000000000000000000000000000000000000000000003730f24101f33e1000000000000000000000000000000000000000000000000000000000ffffffff58713f7e8a99c1b3870881a41d6a2d3da37e973970f3b2f2e5d75375b5cf23580000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e1e6a39597e0000000000000000000000000000000000000000" - } - }, - { - "uid": "0xa2d2d863a63539bc183e9f426bc022ee3ee4747b044ede08b8f7d76b575bc0c2ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff", - "block_number": 11116660, - "block_timestamp": 1782141756, - "tx_hash": "0x8ce7088ff1f1675c877e9bb298d05d3ebc22986856adc7bc5ce61d35f329df37", - "log_index": 314, - "contract": "0xba3cb449bd2b4adddbc894d8697f5170800eadec", - "sender": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "gpv2_order": { - "sellToken": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - "buyToken": "0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8", - "receiver": "0x536a844ef215dd8a13a06023f24a568e4ee3cb6b", - "sellAmount": "20000000000000000", - "buyAmount": "124555889355", - "validTo": 4294967295, - "appData": "0xcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d1", - "feeAmount": "0", - "kind": "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775", - "partiallyFillable": false, - "sellTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9", - "buyTokenBalance": "0x5a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc9" - }, - "signature": { - "scheme": 0, - "payload": "0xba3cb449bd2b4adddbc894d8697f5170800eadec" - }, - "extra_data": "0x0000000000173e2a6a395a31", - "app_data_hash": "0xcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d1", - "app_data_resolved": { - "fullAppData": "{\"appCode\":\"Overlayer\",\"metadata\":{\"orderClass\":{\"orderClass\":\"market\"},\"quote\":{\"slippageBips\":125,\"smartSlippage\":true},\"widget\":{\"appCode\":\"CoW Swap\",\"environment\":\"production\"}},\"version\":\"1.14.0\"}" - }, - "raw_log": { - "topics": [ - "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9", - "0x000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b" - ], - "data": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000094a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8000000000000000000000000536a844ef215dd8a13a06023f24a568e4ee3cb6b00000000000000000000000000000000000000000000000000470de4df8200000000000000000000000000000000000000000000000000000000001d001c0acb00000000000000000000000000000000000000000000000000000000ffffffffcedc1e3d136dc75c2bd17739a62a8c708d560809968f74232c1b08b0af6e39d10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001c00000000000000000000000000000000000000000000000000000000000000240000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000014ba3cb449bd2b4adddbc894d8697f5170800eadec000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0000000000173e2a6a395a310000000000000000000000000000000000000000" - } - } - ], - "twap_conditionals": [ - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11072304, - "block_timestamp": 1781608080, - "tx_hash": "0x4c6b13ffa0765d774f17c263dc179d71b7622021183b351fc40a18885d792ea3", - "log_index": 530, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed01d69c7", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000022ef08fc97d78971500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000013c680000000000000000000000000000000000000000000000000000000000000000053b2ce06c595d1a56022c064798b5b00cce5f1160f8b816bfcf15cee4de22f5c" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed01d69c700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b200000000000000000000000000000000000000000000000000022ef08fc97d78971500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000013c680000000000000000000000000000000000000000000000000000000000000000053b2ce06c595d1a56022c064798b5b00cce5f1160f8b816bfcf15cee4de22f5c" - } - }, - { - "owner": "0xf568a3a2dffd73c000e8e475b2d335a4a3818eba", - "block_number": 11072962, - "block_timestamp": 1781615976, - "tx_hash": "0x839f49ef34313dba42de16de12ca830f61996607c449b02761b8d494b4c67f0d", - "log_index": 239, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed096275b", - "static_input": "0x0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba00000000000000000000000000000000000000000000000199650db3ca0600000000000000000000000000000000000000000000000000023b9992b2b9d55da10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000d3a8afc48166e63025b0693a4d107e03065bfce724eb84cd1f434f26fcd07d8e" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed096275b000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb59000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000f568a3a2dffd73c000e8e475b2d335a4a3818eba00000000000000000000000000000000000000000000000199650db3ca0600000000000000000000000000000000000000000000000000023b9992b2b9d55da10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000d3a8afc48166e63025b0693a4d107e03065bfce724eb84cd1f434f26fcd07d8e" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073264, - "block_timestamp": 1781619600, - "tx_hash": "0x7e46f954c1bbec02bafd775a5069afdbf81b938caf47daa4b06127774541a513", - "log_index": 10, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0cd09dd", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001635ed1d997914c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0cd09dd00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001635ed1d997914c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073279, - "block_timestamp": 1781619780, - "tx_hash": "0x5dfc327e29429f03434ab982c6f70f1e343240804ebcb858c752e85e4445af11", - "log_index": 93, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d0315f", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001632f69d8357707000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d0315f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000001632f69d8357707000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073294, - "block_timestamp": 1781619960, - "tx_hash": "0x5aa4df996b0384f8e2d3fbde2821c2bb8c3a9388f057e2d296035ca7e7df34b0", - "log_index": 46, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d2c99c", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000163137c204f340e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d2c99c00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000163137c204f340e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073321, - "block_timestamp": 1781620284, - "tx_hash": "0x4794b4e2cca405505cfdf6c8f4469de8b7454a13a3389c3f54b965e1cf167d81", - "log_index": 233, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0d7e042", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000008b20032293f1f1dc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0d7e04200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec80000000000000000000000000000000000000000000000000008b20032293f1f1dc0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073356, - "block_timestamp": 1781620704, - "tx_hash": "0x87c715de54e2c36ab1aae02c90388b1166f6334f1734d9ebc6b54aeb0ce7c7af", - "log_index": 117, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0de3dd9", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000003278bee9bb1fb2a2c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0de3dd900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a82500000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000003278bee9bb1fb2a2c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073405, - "block_timestamp": 1781621292, - "tx_hash": "0xa410cfc7d8d8287786e43f5172a1871c7d061683a74b7b85176cd2ec9b23cb44", - "log_index": 126, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0e73bc3", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e80000000000000000000000000000000000000000000000000000bae23fea904871820000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0e73bc300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e80000000000000000000000000000000000000000000000000000bae23fea904871820000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073446, - "block_timestamp": 1781621784, - "tx_hash": "0x9568af5e403693595d71d787c9938d3f04d4eb9af4660c2abee0b1dcb24f4ad1", - "log_index": 43, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0eeb253", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000075ee57b4682dc5a90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0eeb25300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000075ee57b4682dc5a90000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073496, - "block_timestamp": 1781622420, - "tx_hash": "0x32b5fd5f1aa575ae51789d02eb5461a55708b802b0968d3c9673cc41864c7f16", - "log_index": 34, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0f866e9", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007923c0f707757f330000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0f866e900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007923c0f707757f330000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073515, - "block_timestamp": 1781622648, - "tx_hash": "0xeff06f6b268f1d5133afe919678737c691d84f7041ce272e133df7ac3b97adc1", - "log_index": 13, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0fb9e69", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007a0a26e0be40e1040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0fb9e6900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000007a0a26e0be40e1040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073530, - "block_timestamp": 1781622828, - "tx_hash": "0x1da00e00571a814a84a49a7676297e4ba036dd2f9652350350ca615de6eadffb", - "log_index": 40, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed0fe9490", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079f8685b73abff790000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed0fe949000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079f8685b73abff790000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073544, - "block_timestamp": 1781622996, - "tx_hash": "0x924e691724e69b550f096b4fa030d5611f65d0fa0329bc1cb558f4b28e959569", - "log_index": 33, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed1012832", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079ffee55bcfdc4650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed101283200000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e8000000000000000000000000000000000000000000000000000079ffee55bcfdc4650000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073549, - "block_timestamp": 1781623056, - "tx_hash": "0x7f91012a0a6a3d4b4bca7d1b28b112a7dda601e338f41cd16f00fe0b8bc840d4", - "log_index": 86, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed1022269", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000046b1b5e06d6b76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed102226900000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a8250000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000046b1b5e06d6b76000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073775, - "block_timestamp": 1781625768, - "tx_hash": "0xbb04bcc55edac657ff1009e4e887c042f7d43f9f7b3c504c01804d881ced7152", - "log_index": 89, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed12b6d41", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000931649e68a4c81500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed12b6d4100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000931649e68a4c81500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073936, - "block_timestamp": 1781627712, - "tx_hash": "0x88ea9a74af5789332c1995d4bf472dbb302f8107271546f31626eedb343a4f7e", - "log_index": 69, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed13f711f", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000092ea31632e2c6f4e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed13f711f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000092ea31632e2c6f4e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073944, - "block_timestamp": 1781627808, - "tx_hash": "0x400289179852b4c22161c739ef37a997d3020a2a06024d60e81ad60aef999e42", - "log_index": 115, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14a9bec", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000930edb1bccc8bd560000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14a9bec00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b00000000000000000000000000000000000000000000000000000930edb1bccc8bd560000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073956, - "block_timestamp": 1781627952, - "tx_hash": "0xc4f76e3538b294832843d14fe34dbc93f54af84bee6a4dab5357c196237b18dd", - "log_index": 21, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14cc8bd", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000093641cdd84083dd30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14cc8bd00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b0000000000000000000000000000000000000000000000000000093641cdd84083dd30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073964, - "block_timestamp": 1781628048, - "tx_hash": "0xcf31b328f91dd76c05a810cbc9542ecf72ed20a1ec7e26399e088f9643056265", - "log_index": 22, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14e20e1", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000009347806a5ef9104e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14e20e100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000009347806a5ef9104e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000a30f4ffade0aeafdc36c619371c69593c49b05bd681b1f9a7641fa8c9e94a28d" - } - }, - { - "owner": "0x0882a97c3fc692319e75a905a3b59e563188a825", - "block_number": 11073970, - "block_timestamp": 1781628120, - "tx_hash": "0x35a62afad925d64ff440721c9a417301129e429f43b36514363fd93b0e293a76", - "log_index": 7, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed14f52ab", - "static_input": "0x000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000000054f79dd2e6289c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed14f52ab00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000b4f1737af37711e9a5890d9510c9bb60e170cb0d000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000882a97c3fc692319e75a905a3b59e563188a825000000000000000000000000000000000000000000000000a688906bd8b000000000000000000000000000000000000000000000000000000054f79dd2e6289c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000708000000000000000000000000000000000000000000000000000000000000000097d10c43cf244b44b6d46175d34a51815a8f55279e1c7f0d4fd381ac0afc6036" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11080666, - "block_timestamp": 1781708688, - "tx_hash": "0xc6bc316c3d858ed95725eafa2022e8475d69f26a50e3c9c58ab306354ce20bdc", - "log_index": 423, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019ed61c531a", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000c1db7d271eff8ea9d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000029400000000000000000000000000000000000000000000000000000000000000009464d1c818e1d41b4b7ee6591d5adb5099b91c6bb2a7930eb3876c22b45bccc6" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019ed61c531a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000c1db7d271eff8ea9d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000029400000000000000000000000000000000000000000000000000000000000000009464d1c818e1d41b4b7ee6591d5adb5099b91c6bb2a7930eb3876c22b45bccc6" - } - }, - { - "owner": "0x7bf140727d27ea64b607e042f1225680b40eca6a", - "block_number": 11089362, - "block_timestamp": 1781813256, - "tx_hash": "0xa3d8a36f8a7dd8b097635ac59249b908d3f634bf5ede87c9336619e319e4d02d", - "log_index": 380, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x000000000000000000000000000000000000000000000000000000006670f000", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000014995a1118caf95833e923faf8dd155721cd53c200000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000025800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000007bf140727d27ea64b607e042f1225680b40eca6a" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a5000000000000000000000000000000000000000000000000000000006670f00000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000014995a1118caf95833e923faf8dd155721cd53c200000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000025800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" - } - }, - { - "owner": "0x14995a1118caf95833e923faf8dd155721cd53c2", - "block_number": 11089503, - "block_timestamp": 1781814948, - "tx_hash": "0x04dd5835e26d316ca8ede3c2336d830e8db2dadc002c065acdce9b703343ea3e", - "log_index": 220, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019edc721bf0", - "static_input": "0x000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2000000000000000000000000000000000000000000000000005406d7f3adf39b00000000000000000000000000000000000000000000000081addcecc64f158c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000089e1620e951d921cb524162fa1b553254f048059c313748f3ab15496bad98b6" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019edc721bf000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000014995a1118caf95833e923faf8dd155721cd53c2000000000000000000000000000000000000000000000000005406d7f3adf39b00000000000000000000000000000000000000000000000081addcecc64f158c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000089e1620e951d921cb524162fa1b553254f048059c313748f3ab15496bad98b6" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11093450, - "block_timestamp": 1781862588, - "tx_hash": "0xb2c8fbca82119ae1bff00087f298542f3b9f335e6b0efaddc8c1095f3ab4594c", - "log_index": 575, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019edf48bc65", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000c08de6fcb28b80000000000000000000000000000000000000000000000000000fbc448a07d1bfd2f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000eb4a5218a649e020f7a923ae88e634b00ea8907f3370917eaed68d517d7e5785" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019edf48bc6500000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000c08de6fcb28b80000000000000000000000000000000000000000000000000000fbc448a07d1bfd2f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000eb4a5218a649e020f7a923ae88e634b00ea8907f3370917eaed68d517d7e5785" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11116259, - "block_timestamp": 1782136860, - "tx_hash": "0x74a1b1eceb78c771b8086c82fc811651f32689debe1a573dfc94dc8e3681e3be", - "log_index": 121, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019eefa18f6f", - "static_input": "0x000000000000000000000000d3f3d46febcd4cdaa2b83799b7a5cdcb69d135de0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531000000000000000000000000000000000000000000000000e4fbc69449f200000000000000000000000000000000000000000000000000014f44069598038f7700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012c0000000000000000000000000000000000000000000000000000000000000000a139c89ba0059666dc63693ea74049365e130326583f4f512184425ce92f1ad0" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019eefa18f6f00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000d3f3d46febcd4cdaa2b83799b7a5cdcb69d135de0000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb590000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531000000000000000000000000000000000000000000000000e4fbc69449f200000000000000000000000000000000000000000000000000014f44069598038f7700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000012c0000000000000000000000000000000000000000000000000000000000000000a139c89ba0059666dc63693ea74049365e130326583f4f512184425ce92f1ad0" - } - }, - { - "owner": "0x8fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531", - "block_number": 11116489, - "block_timestamp": 1782139632, - "tx_hash": "0x612fda6a65556fbb90531f771c461bb490bc67a8f2c871e6bb5017d1d7eed7d9", - "log_index": 405, - "params": { - "handler": "0x6cf1e9ca41f7611def408122793c358a3d11e5a5", - "salt": "0x0000000000000000000000000000000000000000000000000000019eefcbfc83", - "static_input": "0x000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000906a6d3d85e8a0000000000000000000000000000000000000000000000000000047df71ed148c4be00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000014a0000000000000000000000000000000000000000000000000000000000000000941043d9f9386d7c812dce62122f511201b5efd90e555a05afd4fc1816cd756b" - }, - "raw_log": { - "topics": [ - "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361", - "0x0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e531" - ], - "data": "0x00000000000000000000000000000000000000000000000000000000000000200000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000019eefcbfc8300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000008fab71c0d4272698a3b2d1f3ed5fc3c1b9b3e53100000000000000000000000000000000000000000000000906a6d3d85e8a0000000000000000000000000000000000000000000000000000047df71ed148c4be00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000014a0000000000000000000000000000000000000000000000000000000000000000941043d9f9386d7c812dce62122f511201b5efd90e555a05afd4fc1816cd756b" - } - } - ] -} \ No newline at end of file diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 0ad9d4aa..f4193c65 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -16,8 +16,8 @@ //! //! Not a faithful orderbook simulator - the load test cares about //! shepherd's throughput when the orderbook responds quickly, not -//! about the orderbook's own behaviour. For real-orderbook fidelity -//! see the backtest against live `/api/v1/quote`. +//! about the orderbook's own behaviour. Real-orderbook fidelity comes +//! from running against the live testnet orderbook. #![cfg_attr(not(test), warn(unused_crate_dependencies))] From d1b0d660e346a5d8bb030a951117f3f9a32935e2 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 11:19:07 +0000 Subject: [PATCH 079/139] cow: retire the legacy cow-api host cone (#471) Delete shepherd-cow-host, the shepherd:cow legacy worlds (cow-api, cow-ext, shepherd; cow-events stays as the event-ABI package of record), the shepherd-sdk cow surface, and the shepherd-sdk-test mocks. The shepherd binary composes the core lattice with the videre platform alone. The keeper sweep moves to composable-cow behind the sweep slice; stop-loss migrates onto #[videre_sdk::keeper] and pool submit through the typed CowClient, keyed on the venue-and-body intent-id. twap gates topic-0 on the sol decoder pinned against cow-events; the WIT layering gate moves to cow-venue. ADR-0005/0006 marked superseded. --- Cargo.lock | 120 +--- Cargo.toml | 20 +- README.md | 7 +- crates/composable-cow/Cargo.toml | 17 +- crates/composable-cow/src/lib.rs | 7 +- .../run.rs => composable-cow/src/sweep.rs} | 10 +- .../run.rs => composable-cow/tests/sweep.rs} | 411 +++++------ crates/cow-venue/data/classification.toml | 2 +- crates/cow-venue/tests/wit_layering.rs | 25 + crates/nexum-sdk-test/src/lib.rs | 4 +- crates/nexum-sdk/src/proptests.rs | 3 +- crates/nexum-sdk/src/wit_bindgen_macro.rs | 3 +- crates/shepherd-cow-host/Cargo.toml | 52 -- crates/shepherd-cow-host/src/config.rs | 59 -- crates/shepherd-cow-host/src/config/tests.rs | 45 -- crates/shepherd-cow-host/src/cow.rs | 49 -- crates/shepherd-cow-host/src/cow_orderbook.rs | 204 ------ .../src/cow_orderbook/tests.rs | 362 ---------- crates/shepherd-cow-host/src/ext_cow.rs | 319 --------- crates/shepherd-cow-host/src/lib.rs | 20 - crates/shepherd-cow-host/tests/cow_boot.rs | 207 ------ crates/shepherd-sdk-test/Cargo.toml | 24 - crates/shepherd-sdk-test/src/lib.rs | 669 ------------------ crates/shepherd-sdk-test/tests/mock_venue.rs | 374 ---------- crates/shepherd-sdk/Cargo.toml | 45 -- crates/shepherd-sdk/README.md | 82 --- crates/shepherd-sdk/src/cow/error.rs | 305 -------- crates/shepherd-sdk/src/cow/events.rs | 111 --- crates/shepherd-sdk/src/cow/mod.rs | 83 --- crates/shepherd-sdk/src/cow/transport.rs | 226 ------ crates/shepherd-sdk/src/lib.rs | 85 --- crates/shepherd-sdk/src/prelude.rs | 31 - crates/shepherd-sdk/src/proptests.rs | 39 - crates/shepherd-sdk/src/wit_bindgen_macro.rs | 79 --- crates/shepherd/Cargo.toml | 1 - crates/shepherd/src/main.rs | 50 +- .../0005-cow-api-via-cached-orderbookapi.md | 8 +- .../adr/0006-cow-twap-ethflow-host-helpers.md | 8 +- engine.load.toml | 11 +- engine.m3.toml | 15 +- extensions.toml | 4 - justfile | 2 +- modules/examples/stop-loss/Cargo.toml | 9 +- modules/examples/stop-loss/module.toml | 29 +- modules/examples/stop-loss/src/lib.rs | 60 +- modules/examples/stop-loss/src/strategy.rs | 422 +++++------ modules/twap-monitor/Cargo.toml | 6 +- modules/twap-monitor/module.toml | 6 +- modules/twap-monitor/src/strategy.rs | 61 +- scripts/e2e-report-gen.sh | 4 +- tools/orderbook-mock/src/main.rs | 6 +- wit/shepherd-cow/cow-api.wit | 62 -- wit/shepherd-cow/cow-ext.wit | 9 - wit/shepherd-cow/shepherd.wit | 7 - 54 files changed, 620 insertions(+), 4259 deletions(-) rename crates/{shepherd-sdk/src/cow/run.rs => composable-cow/src/sweep.rs} (97%) rename crates/{shepherd-sdk/tests/run.rs => composable-cow/tests/sweep.rs} (58%) create mode 100644 crates/cow-venue/tests/wit_layering.rs delete mode 100644 crates/shepherd-cow-host/Cargo.toml delete mode 100644 crates/shepherd-cow-host/src/config.rs delete mode 100644 crates/shepherd-cow-host/src/config/tests.rs delete mode 100644 crates/shepherd-cow-host/src/cow.rs delete mode 100644 crates/shepherd-cow-host/src/cow_orderbook.rs delete mode 100644 crates/shepherd-cow-host/src/cow_orderbook/tests.rs delete mode 100644 crates/shepherd-cow-host/src/ext_cow.rs delete mode 100644 crates/shepherd-cow-host/src/lib.rs delete mode 100644 crates/shepherd-cow-host/tests/cow_boot.rs delete mode 100644 crates/shepherd-sdk-test/Cargo.toml delete mode 100644 crates/shepherd-sdk-test/src/lib.rs delete mode 100644 crates/shepherd-sdk-test/tests/mock_venue.rs delete mode 100644 crates/shepherd-sdk/Cargo.toml delete mode 100644 crates/shepherd-sdk/README.md delete mode 100644 crates/shepherd-sdk/src/cow/error.rs delete mode 100644 crates/shepherd-sdk/src/cow/events.rs delete mode 100644 crates/shepherd-sdk/src/cow/mod.rs delete mode 100644 crates/shepherd-sdk/src/cow/transport.rs delete mode 100644 crates/shepherd-sdk/src/lib.rs delete mode 100644 crates/shepherd-sdk/src/prelude.rs delete mode 100644 crates/shepherd-sdk/src/proptests.rs delete mode 100644 crates/shepherd-sdk/src/wit_bindgen_macro.rs delete mode 100644 wit/shepherd-cow/cow-api.wit delete mode 100644 wit/shepherd-cow/cow-ext.wit delete mode 100644 wit/shepherd-cow/shepherd.wit diff --git a/Cargo.lock b/Cargo.lock index 45d44d3e..533912ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -281,7 +281,7 @@ dependencies = [ "lru", "parking_lot", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "thiserror 2.0.18", @@ -350,7 +350,7 @@ dependencies = [ "alloy-transport-ws", "futures", "pin-project", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -540,7 +540,7 @@ dependencies = [ "alloy-json-rpc", "alloy-transport", "itertools 0.14.0", - "reqwest 0.13.4", + "reqwest", "serde_json", "tower", "tracing", @@ -1491,9 +1491,13 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "borsh", + "cow-venue", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", + "tracing", + "videre-sdk", ] [[package]] @@ -1621,14 +1625,11 @@ dependencies = [ "cowprotocol-primitives", "cowprotocol-signing", "js-sys", - "reqwest 0.12.28", "serde", "serde_json", "serde_with", "thiserror 2.0.18", "url", - "wasm-bindgen", - "wasm-bindgen-futures", ] [[package]] @@ -2888,7 +2889,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", ] [[package]] @@ -3860,7 +3860,7 @@ dependencies = [ "axum", "clap", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "tokio", @@ -4538,44 +4538,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.8", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -5184,69 +5146,10 @@ dependencies = [ "anyhow", "nexum-launch", "nexum-runtime", - "shepherd-cow-host", "tokio", "videre-host", ] -[[package]] -name = "shepherd-cow-host" -version = "0.2.0" -dependencies = [ - "alloy-chains", - "alloy-primitives", - "anyhow", - "cowprotocol", - "http", - "metrics", - "nexum-runtime", - "reqwest 0.13.4", - "serde", - "serde_json", - "strum", - "tempfile", - "thiserror 2.0.18", - "tokio", - "toml 1.1.2+spec-1.1.0", - "tracing", - "url", - "wasmtime", - "wiremock", -] - -[[package]] -name = "shepherd-sdk" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "composable-cow", - "cow-venue", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "proptest", - "serde_json", - "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", - "tracing", - "videre-sdk", -] - -[[package]] -name = "shepherd-sdk-test" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "composable-cow", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "serde_json", - "shepherd-sdk", -] - [[package]] name = "shlex" version = "2.0.1" @@ -5361,13 +5264,12 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", - "serde_json", - "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] @@ -5917,7 +5819,7 @@ dependencies = [ "nexum-sdk", "nexum-sdk-test", "serde_json", - "shepherd-sdk", + "toml 1.1.2+spec-1.1.0", "tracing", "videre-sdk", "wit-bindgen 0.59.0", diff --git a/Cargo.toml b/Cargo.toml index 421a2529..37235e3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,6 @@ members = [ "crates/nexum-world", "crates/no-std-probe", "crates/shepherd", - "crates/shepherd-cow-host", - "crates/shepherd-sdk", - "crates/shepherd-sdk-test", "crates/videre-host", "crates/videre-macros", "crates/videre-sdk", @@ -123,19 +120,12 @@ alloy-transport-ws = { version = "2.1", default-features = false } # the engine can key maps and signatures on `Chain` instead of a bare u64. alloy-chains = { version = "0.2", default-features = false, features = ["std", "serde"] } -# CoW Protocol bindings. Pinned to one version across the workspace -# (was `1.0.0-alpha` in engine vs `1.0.0-alpha.3` in SDK before -# hoisting). The engine takes `http-client` for `OrderBookApi`; -# guest-side consumers (SDK, strategies) express their own -# `default-features = false` builds for the `cdylib` wasm target. -cowprotocol = { version = "0.2.0", default-features = false, features = ["http-client"] } - -# HTTP transport for `cow_api::request` REST passthrough and the -# orderbook-mock test surface. +# HTTP transport for the SDK helpers and the orderbook-mock test +# surface. reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } -# Typed HTTP method/request/response for the CoW passthrough and the -# engine's wasi:http gate. Single `http` version in the graph via reqwest, -# so `reqwest::Method` is `http::Method`. +# Typed HTTP method/request/response for the engine's wasi:http gate. +# Single `http` version in the graph via reqwest, so `reqwest::Method` +# is `http::Method`. http = "1" # Body trait + combinators (already in the graph via wasmtime-wasi-http) # for the engine's response-body cap on the wasi:http gate. diff --git a/README.md b/README.md index 8c94e3ac..21aa9c19 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,10 @@ Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs | `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | | `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | | `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | -| `crates/shepherd/` | The `shepherd` binary - the cow composition root wiring the cow-api extension. | +| `crates/shepherd/` | The `shepherd` binary - the cow composition root registering the videre venue platform. | | `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | -| `crates/shepherd-sdk/` | CoW-domain guest SDK - the cow-api trait and CoW Protocol helpers on top of `nexum-sdk`. | | `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | -| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - CoW Protocol extensions on top of `nexum:host`. | +| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - the CoW event ABIs of record. | | `modules/` | Guest modules - TWAP and EthFlow watch-towers, examples, and test fixtures. | | `docs/` | Architecture and design notes. Start with [`docs/00-overview.md`](docs/00-overview.md). | @@ -84,7 +83,7 @@ name = "twap-monitor" version = "0.1.0" [capabilities] -required = ["chain", "local-store", "cow-api"] +required = ["chain", "local-store", "client"] optional = ["http"] [[subscription]] diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml index f12a385e..e785b2cb 100644 --- a/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "ComposableCoW keeper machinery: the conditional-order body and the structured poll Verdict, with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter." +description = "ComposableCoW keeper machinery: the conditional-order body, the structured poll Verdict with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter, and the sweep composition over the venue client." [lib] # Plain library, keeper-side only. The CoW venue crate is orderbook-only @@ -21,6 +21,21 @@ alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } nexum-sdk = { path = "../nexum-sdk" } +# `sweep` slice: the keeper run over the typed CoW client on the +# `videre:venue/client` seam. +cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } +videre-sdk = { path = "../videre-sdk", optional = true } +tracing = { workspace = true, optional = true } + +[features] +# The poll-loop composition conditional-commitment keepers share: +# gate/journal discipline, pool submission, and retry dispatch. +sweep = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] [dev-dependencies] proptest.workspace = true +nexum-sdk-test = { path = "../nexum-sdk-test" } + +[[test]] +name = "sweep" +required-features = ["sweep"] diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs index 505fa115..d4dc3680 100644 --- a/crates/composable-cow/src/lib.rs +++ b/crates/composable-cow/src/lib.rs @@ -3,13 +3,18 @@ //! ComposableCoW keeper machinery, kept out of the CoW venue: the //! conditional-order body ([`ComposableBody`]) and the structured poll //! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined -//! behind [`LegacyRevertAdapter`]. +//! behind [`LegacyRevertAdapter`]. The `sweep` slice adds the shared +//! poll-loop composition ([`run`]) over the typed CoW venue client. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] pub mod body; pub mod poll; +#[cfg(feature = "sweep")] +pub mod sweep; pub use body::ComposableBody; pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; +#[cfg(feature = "sweep")] +pub use sweep::run; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/composable-cow/src/sweep.rs similarity index 97% rename from crates/shepherd-sdk/src/cow/run.rs rename to crates/composable-cow/src/sweep.rs index 0a19bc8e..d3fa5dd7 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/composable-cow/src/sweep.rs @@ -1,4 +1,4 @@ -//! Keeper run: the poll-loop composition conditional- +//! Keeper sweep: the poll-loop composition conditional- //! commitment modules share. //! //! [`run`] walks the keeper watch set, polls each gate-ready @@ -19,7 +19,8 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes, hex}; -use composable_cow::Verdict; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, intent_id}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ @@ -28,10 +29,7 @@ use nexum_sdk::keeper::{ use videre_sdk::keeper::retry_action; use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; -use super::{ - CowClient, CowIntent, CowIntentBody, SignedOrder, gpv2_to_order_data, intent_id, - order_data_to_body, -}; +use crate::Verdict; /// Poll every gate-ready watch once at `tick` and run each outcome's /// effect. One source poll per ready watch; a `Post` outcome makes at diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/composable-cow/tests/sweep.rs similarity index 58% rename from crates/shepherd-sdk/tests/run.rs rename to crates/composable-cow/tests/sweep.rs index f4e32081..7d400f4c 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/composable-cow/tests/sweep.rs @@ -1,29 +1,76 @@ -//! Keeper-run acceptance tests against the composed -//! `shepherd_sdk_test::MockHost`. These live as an integration test -//! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` -//! externally, and the external and unit-test copies of the traits -//! are distinct types. +//! Sweep acceptance tests: `run` over the generic store mocks with a +//! scripted venue transport on the `videre:venue/client` seam. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; -use composable_cow::Verdict; +use composable_cow::{Verdict, run}; +use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; -use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; -use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{ - CowApiError, CowApiTransport, CowClient, CowIntent, CowIntentBody, CowVenue, OrderRejection, - SignedOrder, gpv2_to_order_data, order_data_to_body, run, +use nexum_sdk_test::{MockHost, capture_tracing}; +use videre_sdk::client::sealed::SealedTransport; +use videre_sdk::keeper::submission_key; +use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, UnsignedTx, Venue as _, VenueFault, + VenueId, VenueTransport, }; -use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; -/// The typed client every test drives `run` with: the transitional -/// cow-api bridge over the composed mock host. -fn venue(host: &MockHost) -> CowClient> { - CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) +/// Scripted venue transport: one submit outcome per queued entry, +/// every submit recorded. Quote, status, and cancel are off the sweep +/// path. +#[derive(Default)] +struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, +} + +impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } +} + +impl SealedTransport for &MockVenue {} + +impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit(&self, venue: &VenueId, body: Vec) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } +} + +fn client(venue: &MockVenue) -> CowClient<&MockVenue> { + CowClient::with_transport(venue) } /// Closure-backed source so each test scripts its own outcome and @@ -66,17 +113,6 @@ fn sample_tick() -> Tick { } } -/// `validTo` a given number of seconds from now. The `OrderCreation` -/// constructor's client-side max-horizon policy reads the wall clock -/// (not the block clock), so test orders must expire relative to it. -fn valid_to_in(seconds: u64) -> u32 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_secs(); - u32::try_from(now + seconds).expect("test validTo fits u32") -} - fn submittable_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), @@ -84,7 +120,7 @@ fn submittable_order() -> GPv2OrderData { receiver: Address::ZERO, sellAmount: U256::from(1_000_000_u64), buyAmount: U256::from(999_u64), - validTo: valid_to_in(3_600), + validTo: u32::MAX, appData: cowprotocol::EMPTY_APP_DATA_HASH, feeAmount: U256::ZERO, kind: OrderKind::SELL, @@ -108,18 +144,28 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -/// The intent-id the keeper journals for `order`: the venue-and-body -/// key over the same signed body `run` derives pre-submit. -fn intent_id(order: &GPv2OrderData) -> String { +/// The encoded intent body the sweep submits for `order`. +fn intent_bytes(order: &GPv2OrderData) -> Vec { let order_data = gpv2_to_order_data(order).expect("known markers"); - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_data_to_body(&order_data), owner: sample_owner().into_array(), signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - }))) + })) + .to_bytes() .expect("body encodes") } +/// The intent-id the sweep journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + submission_key(&CowVenue::ID, &intent_bytes(order)) +} + +fn accepted() -> Result { + Ok(SubmitOutcome::Accepted(vec![0xAA])) +} + // ---- lifecycle outcomes ---- #[test] @@ -127,28 +173,30 @@ fn try_next_block_leaves_the_store_untouched() { let host = MockHost::new(); seed_watch(&host); let before = host.store.snapshot(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) .unwrap(); assert_eq!(host.store.snapshot(), before); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] -fn try_on_block_sets_the_block_gate() { +fn wait_block_sets_the_block_gate() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::WaitBlock { wait_until: 2_000, reason: [0; 4], @@ -164,14 +212,15 @@ fn try_on_block_sets_the_block_gate() { } #[test] -fn try_at_epoch_sets_the_epoch_gate() { +fn wait_timestamp_sets_the_epoch_gate() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::WaitTimestamp { wait_until: 1_800_000_000, reason: [0; 4], @@ -192,10 +241,11 @@ fn invalid_removes_the_watch_and_its_gates() { let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); Gates::new(&host).set_next_block(watch, 1).unwrap(); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) @@ -214,10 +264,11 @@ fn gated_watch_is_not_polled() { .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) .unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -234,10 +285,11 @@ fn malformed_watch_rows_are_skipped() { let host = MockHost::new(); host.store.set("watch:no-separator", b"junk").unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); Verdict::TryNextBlock { reason: [0; 4] } @@ -256,22 +308,26 @@ fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xserveruid".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + assert_eq!(venue.submit_count(), 1); assert!( Journal::submitted(&host) .contains(&intent_id(&order)) .unwrap(), "submitted:{{intent_id}} marker must be recorded", ); - assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); + + // The next tick short-circuits on the journal: no second submit. + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); } #[test] @@ -279,24 +335,29 @@ fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xfeedface".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xFE, 0xED, 0xFA, 0xCE]))); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); - assert!( - !snapshot.contains_key("submitted:0xfeedface"), + assert_eq!( + snapshot + .keys() + .filter(|k| k.starts_with("submitted:")) + .count(), + 1, "marker must key on the pre-submit intent-id, not the server receipt", ); } #[test] -fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { +fn ready_skips_the_venue_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -304,10 +365,11 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(|_, _, _, _| { polls.set(polls.get() + 1); ready_outcome(&order) @@ -318,7 +380,7 @@ fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { assert_eq!(polls.get(), 1, "the source is still consulted"); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, "the journal guard must short-circuit before any network work", ); @@ -330,68 +392,60 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { let key = seed_watch(&host); let mut order = submittable_order(); order.kind = B256::repeat_byte(0x42); + let venue = MockVenue::default(); run( &host, - &venue(&host), + &client(&venue), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) .unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert!(host.store.snapshot().contains_key(&key)); } -/// A Ready order whose `validTo` exceeds the constructor's client-side -/// one-year horizon can never be submitted: the rejection is -/// deterministic for the polled payload, so the watch must drop -/// through the ledger instead of re-polling and warn-looping on every -/// block forever. (Watch-tower net effect: submit, orderbook rejects -/// with `ExcessiveValidTo`, classifier drops.) +/// A sweep cannot sign: a `requires-signing` outcome is surfaced, not +/// journalled, so the next tick re-poses the same ask. #[test] -fn ready_beyond_the_valid_to_horizon_drops_the_watch() { +fn requires_signing_is_surfaced_and_not_journalled() { let host = MockHost::new(); let key = seed_watch(&host); - let mut order = submittable_order(); - order.validTo = valid_to_in(2 * 365 * 24 * 3_600); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); let source = src(move |_, _, _, _| ready_outcome(&order)); - let (result, logs) = capture_tracing(|| run(&host, &venue(&host), &source, &sample_tick())); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); result.unwrap(); - assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); + assert_eq!(venue.submit_count(), 1); let snapshot = host.store.snapshot(); - assert!( - !snapshot.contains_key(&key), - "an unsubmittable order must not survive to warn-loop forever", - ); + assert!(snapshot.contains_key(&key), "the watch survives"); assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); - assert!(logs.any(|e| e.message.contains("submit dropped watch"))); + assert!(logs.any(|e| e.message.contains("requires signing"))); } // ---- submission failure dispatch ---- -fn rejection(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "test".into(), - data: None, - }) -} - #[test] -fn transient_rejection_keeps_the_watch_ungated() { +fn transient_fault_keeps_the_watch_ungated() { let host = MockHost::new(); let key = seed_watch(&host); let watch_key = WatchRef::parse(&key).unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("InsufficientFee"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); run( &host, - &venue(&host), + &client(&venue), &src(move |_, _, _, _| ready_outcome(&order)), &sample_tick(), ) @@ -405,88 +459,91 @@ fn transient_rejection_keeps_the_watch_ungated() { } #[test] -fn permanent_rejection_drops_the_watch_through_the_ledger() { +fn denied_fault_drops_the_watch_through_the_ledger() { let host = MockHost::new(); let key = seed_watch(&host); Gates::new(&host) .set_next_block(WatchRef::parse(&key).unwrap(), 1) .unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("InvalidSignature"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); - run( - &host, - &venue(&host), - &src(move |_, _, _, _| ready_outcome(&order)), - &sample_tick(), - ) - .unwrap(); + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &sample_tick())); + result.unwrap(); assert!( host.store.is_empty(), - "a permanent rejection must drop the watch and its gates", + "a permanent refusal must drop the watch and its gates", ); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); } -/// The orderbook already holds the order: the receipt is recorded, the -/// watch survives, and the next tick short-circuits on the journal -/// instead of re-posting. +/// A rate-limit fault with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] -fn duplicated_order_records_the_receipt_and_keeps_the_watch() { +fn rate_limited_submit_backs_off_through_the_epoch_gate() { let host = MockHost::new(); let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); let order = submittable_order(); - host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - let source = { - let order = order.clone(); - src(move |_, _, _, _| ready_outcome(&order)) - }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); + let tick = sample_tick(); + run( + &host, + &client(&venue), + &src(move |_, _, _, _| ready_outcome(&order)), + &tick, + ) + .unwrap(); - assert!(host.store.snapshot().contains_key(&key)); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap(), - "already-submitted must journal the intent-id", + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", ); - - // The next tick must not touch the orderbook again. - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } /// Restart regression: a keeper that posted, journalled, and then /// restarted over the same persistent local store must not post the -/// same order again - one orderbook POST across both lives. +/// same order again - one venue submit across both lives. #[test] fn restart_with_a_journalled_intent_does_not_repost() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok("0xserveruid".to_string())); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); // A restarted keeper: fresh instance, the local store carried over. let restarted = MockHost::new(); for (key, value) in host.store.snapshot() { restarted.store.set(&key, &value).unwrap(); } - restarted.cow_api.respond(Ok("0xserveruid".to_string())); + let venue_after = MockVenue::default(); + venue_after.enqueue_submit(accepted()); - run(&restarted, &venue(&restarted), &source, &sample_tick()).unwrap(); + run(&restarted, &client(&venue_after), &source, &sample_tick()).unwrap(); assert_eq!( - host.cow_api.call_count() + restarted.cow_api.call_count(), + venue.submit_count() + venue_after.submit_count(), 1, - "resubmit after restart must make no second orderbook POST", + "resubmit after restart must make no second venue submit", ); assert!( Journal::submitted(&restarted) @@ -495,124 +552,34 @@ fn restart_with_a_journalled_intent_does_not_repost() { ); } -/// A rate-limit fault with server guidance backs the watch off on the -/// epoch clock - `RetryAction::Backoff` reached through the ledger. -#[test] -fn rate_limited_submit_backs_off_through_the_epoch_gate() { - let host = MockHost::new(); - let key = seed_watch(&host); - let watch = WatchRef::parse(&key).unwrap(); - let order = submittable_order(); - host.cow_api - .respond(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - })))); - - let tick = sample_tick(); - run( - &host, - &venue(&host), - &src(move |_, _, _, _| ready_outcome(&order)), - &tick, - ) - .unwrap(); - - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key), "backoff must keep the watch"); - assert_eq!( - snapshot.get(&watch.next_epoch_key()).unwrap(), - &(tick.epoch_s + 3).to_le_bytes().to_vec(), - "2500ms rounds up to a 3s backoff from the tick clock", - ); - assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); -} - // ---- the generic seam ---- /// The seam proof: a `Post` verdict reaches the venue transport as the -/// encoded `CowIntentBody` under the CoW venue id, the journal keys on -/// the generic submission key, and the legacy cow-api seam is never -/// touched. +/// encoded `CowIntentBody` under the CoW venue id, and the journal +/// keys on the generic submission key. #[test] fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { - use std::cell::RefCell; - - use videre_sdk::keeper::submission_key; - use videre_sdk::{ - IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, - VenueTransport, - }; - - /// Records the venue and wire bytes of every submit. - struct SpyTransport { - calls: RefCell)>>, - } - - impl videre_sdk::client::sealed::SealedTransport for &SpyTransport {} - - impl VenueTransport for &SpyTransport { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - unreachable!("quote not exercised") - } - - async fn submit( - &self, - venue: &VenueId, - body: Vec, - ) -> Result { - self.calls.borrow_mut().push((venue.to_string(), body)); - Ok(SubmitOutcome::Accepted(vec![0xAA])) - } - - async fn status( - &self, - _venue: &VenueId, - _receipt: &[u8], - ) -> Result { - unreachable!("status not exercised") - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - unreachable!("cancel not exercised") - } - } - let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - let spy = SpyTransport { - calls: RefCell::new(Vec::new()), - }; - let client = CowClient::with_transport(&spy); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); let source = { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &client, &source, &sample_tick()).unwrap(); + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); - let order_data = gpv2_to_order_data(&order).expect("known markers"); - let expected = CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: order_data_to_body(&order_data), - owner: sample_owner().into_array(), - signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - })) - .to_bytes() - .expect("body encodes"); - - let calls = spy.calls.borrow(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, CowVenue::ID.as_str()); - assert_eq!(calls[0].1, expected, "the wire carries the intent body"); + let expected = intent_bytes(&order); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!(submits[0].0, CowVenue::ID.as_str()); + assert_eq!(submits[0].1, expected, "the wire carries the intent body"); assert!( Journal::submitted(&host) .contains(&submission_key(&CowVenue::ID, &expected)) .unwrap(), "the journal keys on the generic submission key", ); - assert_eq!( - host.cow_api.call_count(), - 0, - "the legacy cow-api seam is never touched", - ); } diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index f7910590..f2cbb40b 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -32,7 +32,7 @@ # permanent rather than retried every block forever. # # Relationship to `cowprotocol::ApiError::retry_hint()`. The upstream -# `cowprotocol` crate (a shepherd-sdk dependency) also classifies +# `cowprotocol` crate also classifies # orderbook `errorType`s, via `RetryHint`. Ratified: this table, not # `RetryHint`, is shepherd's classification source of truth. It is # shepherd's own, more conservative retry policy, kept as data of record diff --git a/crates/cow-venue/tests/wit_layering.rs b/crates/cow-venue/tests/wit_layering.rs new file mode 100644 index 00000000..4ccf59ed --- /dev/null +++ b/crates/cow-venue/tests/wit_layering.rs @@ -0,0 +1,25 @@ +//! Layering gate: no generic WIT package references `shepherd:cow`. +//! The bundle-layer package carries only the event ABIs; the generic +//! host and videre packages must never name it. + +use std::path::Path; + +#[test] +fn generic_wit_packages_never_reference_shepherd_cow() { + let wit_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { + let pkg = pkg.expect("wit dir entry").path(); + if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { + continue; + } + for file in std::fs::read_dir(&pkg).expect("wit package dir") { + let path = file.expect("wit package entry").path(); + let text = std::fs::read_to_string(&path).expect("read wit file"); + assert!( + !text.contains("shepherd:cow"), + "{} references shepherd:cow", + path.display(), + ); + } + } +} diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index ceee3ec0..1da359e2 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -54,8 +54,8 @@ //! bridges with a trivial converter on its own crate boundary - see the //! tutorial for the exact shape. //! -//! Domain SDK test crates compose these mocks with their own (the CoW -//! `shepherd-sdk-test` embeds them next to its `MockCowApi`). +//! Domain test crates compose these mocks with their own scripted +//! venue transports on the `videre:venue/client` seam. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/nexum-sdk/src/proptests.rs b/crates/nexum-sdk/src/proptests.rs index 0f3e24b9..2a4709e7 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/crates/nexum-sdk/src/proptests.rs @@ -11,7 +11,8 @@ //! `balance-tracker`'s persistence path). //! //! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `shepherd-sdk`. +//! `gpv2_to_order_data` marker guard) live in `composable-cow` and +//! `cow-venue`. #![cfg(test)] diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 586df413..98059b8b 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -20,8 +20,7 @@ //! not import is a compile error. //! //! A domain SDK layers its own interfaces on top by invoking this -//! macro and adding trait impls for the same `WitBindgenHost` (the -//! CoW SDK's `bind_cow_host_via_wit_bindgen!` does exactly that). +//! macro and adding trait impls for the same `WitBindgenHost`. //! //! Usage in a module's `lib.rs`: //! diff --git a/crates/shepherd-cow-host/Cargo.toml b/crates/shepherd-cow-host/Cargo.toml deleted file mode 100644 index 47ba5e1e..00000000 --- a/crates/shepherd-cow-host/Cargo.toml +++ /dev/null @@ -1,52 +0,0 @@ -[package] -name = "shepherd-cow-host" -version = "0.2.0" -edition.workspace = true -license.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[lib] -path = "src/lib.rs" - -[dependencies] -# The host runtime this extension plugs into: HostState, the RuntimeTypes -# lattice, the extension seam, the capability namespace, and the shared -# `nexum:host/types` bindgen module the cow world binds through `with`. -nexum-runtime = { path = "../nexum-runtime" } - -# `cow-api` backend. cowprotocol pulls `OrderBookApi`, `OrderCreation`, -# `OrderUid`, the orderbook base URL table per `Chain`, and the typed -# error surface the host re-projects into the cow-api-error variant. -cowprotocol.workspace = true -# REST passthrough for `cow_api::request`. -reqwest.workspace = true -# Typed HTTP method for the CoW passthrough. -http.workspace = true -# Typed EIP-155 chain ids for the orderbook pool keys. -alloy-chains.workspace = true -# `hex::encode_prefixed` for the returned OrderUid. -alloy-primitives.workspace = true - -# WASM Component Model linker the extension hook writes into. -wasmtime.workspace = true - -# `strum::IntoStaticStr` on the error enum for metric labels. -strum.workspace = true -thiserror.workspace = true -anyhow.workspace = true -tracing.workspace = true -metrics.workspace = true -serde_json = { workspace = true, features = ["std"] } -url.workspace = true -# `[extensions.cow]` config table: serde derive for `CowConfig`, toml -# for the opaque `toml::Value` handed over by the engine config. -serde.workspace = true -toml.workspace = true - -[dev-dependencies] -tokio.workspace = true -wiremock.workspace = true -tempfile.workspace = true diff --git a/crates/shepherd-cow-host/src/config.rs b/crates/shepherd-cow-host/src/config.rs deleted file mode 100644 index f0612b25..00000000 --- a/crates/shepherd-cow-host/src/config.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! The `[extensions.cow]` config table, owned by this extension. -//! -//! `engine.toml` stays domain-free: the engine hands every -//! `[extensions.]` table to the composition root verbatim, and -//! this module parses the `cow` one. - -use std::collections::HashMap; - -use alloy_chains::Chain; -use nexum_runtime::engine_config::EngineConfig; -use serde::Deserialize; -use strum::IntoStaticStr; -use thiserror::Error; - -/// The `[extensions.cow]` table from `engine.toml`. -/// -/// ```toml -/// [extensions.cow.orderbook_urls] -/// 11155111 = "http://localhost:9999" -/// ``` -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CowConfig { - /// Per-chain orderbook base URL overrides keyed by EIP-155 chain - /// id (numeric or named, as with `[chains.]`). Chains without - /// an entry use the canonical `cowprotocol::Chain` URL. - #[serde(default)] - pub orderbook_urls: HashMap, -} - -/// Boot-time errors from parsing the cow extension's config. -/// -/// `IntoStaticStr` exposes the snake_case variant name for -/// structured-log `error_kind` fields, matching the other host-side -/// error enums. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowConfigError { - /// The `[extensions.cow]` table failed to deserialize. - #[error("parse [extensions.cow]: {0}")] - Section(#[from] toml::de::Error), -} - -impl TryFrom<&EngineConfig> for CowConfig { - type Error = CowConfigError; - - /// Parse the `[extensions.cow]` table. An absent table yields an - /// empty override set, so every chain uses its canonical URL. - fn try_from(cfg: &EngineConfig) -> Result { - match cfg.extensions.get("cow") { - Some(section) => Ok(section.clone().try_into()?), - None => Ok(Self::default()), - } - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/config/tests.rs b/crates/shepherd-cow-host/src/config/tests.rs deleted file mode 100644 index b91b8c77..00000000 --- a/crates/shepherd-cow-host/src/config/tests.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::*; - -fn engine_cfg(toml_src: &str) -> EngineConfig { - toml::from_str(toml_src).expect("engine config parses") -} - -fn mainnet() -> Chain { - Chain::from_id(1) -} - -#[test] -fn extension_table_resolves() { - let cfg = engine_cfg( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:8888" -"#, - ); - let cow = CowConfig::try_from(&cfg).expect("extension table parses"); - assert_eq!( - cow.orderbook_urls.get(&mainnet()).map(String::as_str), - Some("http://localhost:8888"), - ); -} - -#[test] -fn absent_config_yields_no_overrides() { - let cow = CowConfig::try_from(&EngineConfig::default()).expect("empty config parses"); - assert!(cow.orderbook_urls.is_empty()); -} - -#[test] -fn misspelled_extension_key_errors() { - // deny_unknown_fields turns a typo inside the new table into a - // boot-time error instead of a silent fall-through to the live - // orderbook. - let cfg = engine_cfg( - r#" -[extensions.cow] -orderbook_url = "http://localhost:9999" -"#, - ); - let err = CowConfig::try_from(&cfg).expect_err("unknown key in [extensions.cow] rejected"); - assert!(matches!(err, CowConfigError::Section(_))); -} diff --git a/crates/shepherd-cow-host/src/cow.rs b/crates/shepherd-cow-host/src/cow.rs deleted file mode 100644 index d1f0041e..00000000 --- a/crates/shepherd-cow-host/src/cow.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! CoW orderbook seam: REST passthrough plus typed order submission, -//! mirroring the inherent `OrderBookPool` API. - -use std::future::Future; - -use alloy_chains::Chain; -use cowprotocol::OrderUid; - -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -/// Async CoW orderbook backend. `get` (concrete client lookup) is -/// deliberately not part of the seam; it leaks `OrderBookApi`. -pub trait CowApi { - /// REST passthrough against the chain's orderbook base URL. - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send; - - /// Typed submission of a JSON-encoded `OrderCreation`. - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send; -} - -impl CowApi for OrderBookPool { - fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> impl Future> + Send { - OrderBookPool::request(self, chain, method, path, body) - } - - fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> impl Future> + Send { - OrderBookPool::submit_order_json(self, chain, body) - } -} diff --git a/crates/shepherd-cow-host/src/cow_orderbook.rs b/crates/shepherd-cow-host/src/cow_orderbook.rs deleted file mode 100644 index 61202dc0..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! `shepherd:cow/cow-api` backend. -//! -//! Two responsibilities: -//! -//! 1. `request` - generic REST passthrough. Module gives the HTTP -//! method, path (relative to the chain's orderbook base URL), and -//! optional JSON body. We dispatch via `reqwest`, return the -//! response body verbatim. -//! 2. `submit_order` - typed submission. Module gives a JSON-encoded -//! `cowprotocol::OrderCreation`; we parse, dispatch via -//! `cowprotocol::OrderBookApi::post_order`, return the assigned -//! `OrderUid` as a `0x`-prefixed hex string. -//! -//! Per-chain `OrderBookApi` instances are constructed once at engine -//! boot from the discriminated chain set in `cowprotocol::Chain`. -//! Chains the SDK does not know about return `Unsupported` at the -//! host call boundary. - -use std::collections::HashMap; -use std::time::Duration; - -use alloy_chains::Chain; -use cowprotocol::{Chain as CowChain, OrderBookApi, OrderCreation, OrderUid}; -use nexum_runtime::engine_config::EngineConfig; -use strum::IntoStaticStr; -use thiserror::Error; - -use crate::config::{CowConfig, CowConfigError}; - -/// Process-wide pool of `OrderBookApi` clients keyed by chain. -#[derive(Debug, Clone)] -pub struct OrderBookPool { - clients: HashMap, - http: reqwest::Client, -} - -/// Canonical CoW Protocol chain set the engine ships clients for. -/// -/// Both `Default::default()` and `OrderBookPool::from_config` walk -/// this single source of truth so a new chain joining CoW protocol -/// only needs a one-line addition here instead of two parallel -/// arrays. -const DEFAULT_CHAINS: &[CowChain] = &[ - CowChain::Mainnet, - CowChain::Gnosis, - CowChain::Sepolia, - CowChain::ArbitrumOne, - CowChain::Base, -]; - -impl Default for OrderBookPool { - /// Build a pool covering every `cowprotocol::Chain` variant. Each entry - /// uses the canonical `api.cow.fi/{slug}/api/v1` base URL from the SDK. - /// Override individual entries via `OrderBookApi::new_with_base_url` for - /// barn or staging targets. - fn default() -> Self { - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("reqwest client builder"); - let clients = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - Self { clients, http } - } -} - -impl OrderBookPool { - /// Build a pool from engine config, honouring the - /// `[extensions.cow.orderbook_urls]` overrides. Chains without an - /// override fall back to the canonical `cowprotocol::Chain` URLs - /// (same as [`OrderBookPool::default`]). - /// - /// Used by the load test to point all submissions at - /// `tools/orderbook-mock`, and by staging/barn deployments that - /// run against a non-production orderbook. - pub fn from_config(cfg: &EngineConfig) -> Result { - let cow_cfg = CowConfig::try_from(cfg)?; - let http = reqwest::Client::new(); - let mut clients: HashMap = DEFAULT_CHAINS - .iter() - .map(|c| (Chain::from_id(c.id()), OrderBookApi::new(*c))) - .collect(); - // Sort by numeric id so override logs are deterministic - // (`Chain` is not `Ord`). - let mut entries: Vec<_> = cow_cfg.orderbook_urls.iter().collect(); - entries.sort_by_key(|(c, _)| c.id()); - for (chain, url) in entries { - let chain_id = chain.id(); - match url.parse::() { - Ok(parsed) => { - tracing::info!(chain_id, url, "cow-api: orderbook URL override"); - clients.insert(*chain, OrderBookApi::new_with_base_url(parsed)); - } - Err(e) => { - tracing::warn!(chain_id, url, error = %e, "cow-api: bad orderbook_url, falling back to canonical"); - } - } - } - Ok(Self { clients, http }) - } - - /// Look up the client for a chain. - pub fn get(&self, chain: Chain) -> Result<&OrderBookApi, CowApiError> { - self.clients - .get(&chain) - .ok_or(CowApiError::UnknownChain(chain)) - } - - /// REST passthrough. The base URL is whichever URL the pool's - /// `OrderBookApi` client carries - overrides set via - /// `OrderBookApi::new_with_base_url` (staging, wiremock) flow - /// through here too, which keeps the passthrough and the typed - /// `submit_order_json` path aimed at the same orderbook. - pub async fn request( - &self, - chain: Chain, - method: http::Method, - path: &str, - body: Option<&str>, - ) -> Result { - use http::Method; - let api = self.get(chain)?; - let base = api.base_url().clone(); - // `path` may or may not lead with a slash; `Url::join` handles - // both, but we strip a single leading `/` so consumers can - // write either `/orders/...` or `orders/...` interchangeably. - let trimmed = path.strip_prefix('/').unwrap_or(path); - let url = base - .join(trimmed) - .map_err(|e| CowApiError::BadPath(format!("{path:?}: {e}")))?; - - if ![Method::GET, Method::POST, Method::PUT, Method::DELETE].contains(&method) { - return Err(CowApiError::BadMethod(method)); - } - // `reqwest::Method` is `http::Method`, so the typed method flows - // straight through. - let request = self.http.request(method, url); - let request = if let Some(body) = body { - request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.to_owned()) - } else { - request - }; - - let response = request.send().await.map_err(CowApiError::Network)?; - let status = response.status().as_u16(); - let text = response.text().await.map_err(CowApiError::Network)?; - // Non-2xx responses are surfaced as HttpError so the guest can - // distinguish 404 (not found) from 200 (success) via the - // cow-api-error http case status. - // The full response body is preserved in the error for structured - // decoding (e.g. `{"errorType": "...", "description": "..."}`). - if status >= 400 { - return Err(CowApiError::HttpError { status, body: text }); - } - Ok(text) - } - - /// Typed submission. `body` is the JSON encoding of - /// `cowprotocol::OrderCreation`. The chain's orderbook validates - /// `from`, the EIP-712 hash, and (if `Eip1271`) the contract - /// signature; we return whatever UID it assigns. - pub async fn submit_order_json( - &self, - chain: Chain, - body: &[u8], - ) -> Result { - let creation: OrderCreation = serde_json::from_slice(body).map_err(CowApiError::Decode)?; - let api = self.get(chain)?; - let uid = api.post_order(&creation).await?; - Ok(uid) - } -} - -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` (`"unknown_chain"`, `"bad_method"`, ...) so the -/// `shepherd_cow_api_*` metric labels and structured-log fields stay -/// in sync with the Rust source of truth instead of growing a -/// `match err { ... => "decode" ... }` ladder per call site. -#[derive(Debug, Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - #[error("unknown chain {0} (no cowprotocol::Chain variant)")] - UnknownChain(Chain), - #[error("bad HTTP method `{0}` (expected GET/POST/PUT/DELETE)")] - BadMethod(http::Method), - #[error("invalid path: {0}")] - BadPath(String), - #[error("HTTP {status}")] - HttpError { status: u16, body: String }, - #[error("network: {0}")] - Network(#[from] reqwest::Error), - #[error("decode OrderCreation JSON: {0}")] - Decode(#[from] serde_json::Error), - #[error("orderbook: {0}")] - Orderbook(#[from] cowprotocol::Error), -} - -#[cfg(test)] -mod tests; diff --git a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs deleted file mode 100644 index f4155888..00000000 --- a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs +++ /dev/null @@ -1,362 +0,0 @@ -use super::*; -use http::Method; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -/// The canonical CoW mainnet chain, as the pool keys it (alloy `Chain` -/// derived from the cowprotocol id). -fn mainnet() -> Chain { - Chain::from_id(CowChain::Mainnet.id()) -} - -#[test] -fn pool_indexes_default_chains() { - let pool = OrderBookPool::default(); - assert!(pool.get(Chain::from_id(1)).is_ok(), "mainnet present"); - assert!(pool.get(Chain::from_id(100)).is_ok(), "gnosis present"); - assert!( - pool.get(Chain::from_id(11_155_111)).is_ok(), - "sepolia present" - ); - assert!(pool.get(Chain::from_id(42_161)).is_ok(), "arbitrum present"); - assert!(pool.get(Chain::from_id(8_453)).is_ok(), "base present"); -} - -#[test] -fn from_config_applies_extension_override() { - let cfg: EngineConfig = toml::from_str( - r#" -[extensions.cow.orderbook_urls] -1 = "http://localhost:9999" -"#, - ) - .expect("engine config parses"); - let pool = OrderBookPool::from_config(&cfg).expect("pool builds"); - let api = pool.get(mainnet()).expect("mainnet client"); - assert!( - api.base_url().as_str().starts_with("http://localhost:9999"), - "extension override applied, got {}", - api.base_url(), - ); -} - -#[test] -fn unknown_chain_surfaces_typed_error() { - let pool = OrderBookPool::default(); - assert!(matches!( - pool.get(Chain::from_id(99_999)), - Err(CowApiError::UnknownChain(c)) if c == Chain::from_id(99_999) - )); -} - -/// Build a pool whose Mainnet entry points at `mock.uri()`. -/// `OrderBookApi::new_with_base_url` ships in cowprotocol; we -/// rely on it so wiremock-driven tests can exercise the full -/// request path without re-implementing the HTTP client. -fn pool_with_mainnet_at(mock: &MockServer) -> OrderBookPool { - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url(mock.uri().parse().expect("mock uri parses")), - ); - OrderBookPool { - clients, - http: reqwest::Client::new(), - } -} - -#[tokio::test] -async fn request_passes_get_path_through() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/version")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"version":"x.y.z"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .expect("request succeeds"); - assert_eq!(body, r#"{"version":"x.y.z"}"#); -} - -#[tokio::test] -async fn request_relative_path_works() { - // Module passes a path without a leading slash. The - // passthrough should still resolve against the orderbook - // base URL. - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/native_price/0xabc")) - .respond_with(ResponseTemplate::new(200).set_body_string("1.23")) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request(mainnet(), Method::GET, "api/v1/native_price/0xabc", None) - .await - .expect("relative path resolves"); - assert_eq!(body, "1.23"); -} - -#[tokio::test] -async fn request_rejects_unknown_method() { - let pool = OrderBookPool::default(); - let err = pool - .request(mainnet(), Method::PATCH, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::BadMethod(_))); -} - -#[tokio::test] -async fn request_post_with_body_is_forwarded() { - let mock = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/api/v1/quote")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"quote":"ok"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let body = pool - .request( - mainnet(), - Method::POST, - "/api/v1/quote", - Some(r#"{"sellToken":"0x01"}"#), - ) - .await - .expect("post with body succeeds"); - assert_eq!(body, r#"{"quote":"ok"}"#); -} - -#[tokio::test] -async fn request_4xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - let error_body = r#"{"errorType":"InsufficientFee","description":"fee too low"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(error_body)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request( - mainnet(), - Method::POST, - "/api/v1/orders", - Some(r#"{"test":true}"#), - ) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 400); - assert_eq!(body, error_body); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn request_rejects_unknown_chain() { - let pool = OrderBookPool::default(); - let err = pool - .request(Chain::from_id(99_999), Method::GET, "/x", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::UnknownChain(c) if c == Chain::from_id(99_999))); -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_envelope() { - // The orderbook rejects with a typed envelope. The pool must - // surface `cowprotocol::Error::OrderbookApi { status, api }` - // so the WIT adapter can forward `api` to the cow-api-error - // rejected case. The string - // `DuplicatedOrder` is what the live - // Sepolia orderbook returns for an already-submitted order; - // it parses as `ApiError` even though the retriable-error - // classifier does not recognise the spelling. - let mock = MockServer::start().await; - let envelope = r#"{"errorType":"DuplicatedOrder","description":"order already exists"}"#; - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(400).set_body_string(envelope)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .submit_order_json(mainnet(), sample_order_json().as_bytes()) - .await - .expect_err("orderbook 400 surfaces as error"); - - match err { - CowApiError::Orderbook(cowprotocol::Error::OrderbookApi { status, api }) => { - assert_eq!(status, 400); - assert_eq!(api.error_type, "DuplicatedOrder"); - assert_eq!(api.description, "order already exists"); - } - other => panic!("expected OrderbookApi envelope, got {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_propagates_orderbook_response() { - let mock = MockServer::start().await; - let body_json = sample_order_json(); - // cowprotocol POST /api/v1/orders returns the order UID - // (56-byte hex) as a JSON string body. - let returned_uid = format!("\"0x{}\"", "ab".repeat(56)); - Mock::given(method("POST")) - .and(path("/api/v1/orders")) - .respond_with(ResponseTemplate::new(201).set_body_string(returned_uid.clone())) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let uid = pool - .submit_order_json(mainnet(), body_json.as_bytes()) - .await - .expect("submit succeeds"); - assert_eq!(uid.as_slice().len(), 56); - assert_eq!(uid.as_slice(), &[0xab; 56]); -} - -/// A minimal but accepted-by-cowprotocol OrderCreation JSON. We -/// generate it inside the test so the JSON shape stays in lockstep -/// with the published `cowprotocol` version. -fn sample_order_json() -> String { - use alloy_primitives::{Address, U256}; - use cowprotocol::OrderCreation; - use cowprotocol::app_data::{EMPTY_APP_DATA_HASH, EMPTY_APP_DATA_JSON}; - use cowprotocol::order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}; - use cowprotocol::signature::Signature; - use cowprotocol::signing_scheme::SigningScheme; - - let order_data = OrderData { - sell_token: Address::from([0x01; 20]), - buy_token: Address::from([0x02; 20]), - receiver: None, - sell_amount: U256::from(100u64), - buy_amount: U256::from(99u64), - valid_to: u32::MAX, - app_data: EMPTY_APP_DATA_HASH, - fee_amount: U256::ZERO, - kind: OrderKind::Sell, - partially_fillable: false, - sell_token_balance: SellTokenSource::Erc20, - buy_token_balance: BuyTokenDestination::Erc20, - }; - let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::new( - &order_data, - signature, - Address::from([0x03; 20]), - EMPTY_APP_DATA_JSON.to_owned(), - None, - ) - .expect("valid OrderCreation"); - serde_json::to_string(&creation).expect("serialise OrderCreation") -} - -#[tokio::test] -async fn request_rejects_malformed_path() { - // `Url::join` is very lenient for valid UTF-8 inputs. The - // `BadPath` variant fires only when `Url::join` returns a parse - // error, which is hard to provoke. Using a bare scheme-like - // string (`"://not-a-path"`) is NOT rejected because after - // stripping the leading `/` it is treated as a relative path - // component. Instead, feed a string that *will* reach the - // network but is handled by wiremock with a 404, confirming the - // passthrough returns Ok even for nonsensical paths. - let mock = MockServer::start().await; - let pool = pool_with_mainnet_at(&mock); - // wiremock returns 404 for any un-mocked route, now surfaced - // as HttpError (not Ok) since we distinguish HTTP status codes. - let err = pool - .request(mainnet(), Method::GET, "://not-a-path", None) - .await - .unwrap_err(); - assert!( - matches!(err, CowApiError::HttpError { status: 404, .. }), - "Url::join treats this as a relative path; wiremock 404 surfaces as HttpError" - ); -} - -#[tokio::test] -async fn request_network_error_on_dead_server() { - // Build the pool against a port that no one is listening on. - // We use port 1 (TCP echo / privileged) which is never bound - // by user-space processes, guaranteeing a connection-refused. - let mut clients = std::collections::HashMap::new(); - clients.insert( - mainnet(), - OrderBookApi::new_with_base_url("http://127.0.0.1:1/".parse().expect("valid url")), - ); - let pool = OrderBookPool { - clients, - http: reqwest::Client::new(), - }; - let err = pool - .request(mainnet(), Method::GET, "/api/v1/version", None) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Network(_))); -} - -#[tokio::test] -async fn request_5xx_response_surfaces_http_error_with_body() { - let mock = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v1/health")) - .respond_with(ResponseTemplate::new(500).set_body_string(r#"{"error":"internal"}"#)) - .expect(1) - .mount(&mock) - .await; - - let pool = pool_with_mainnet_at(&mock); - let err = pool - .request(mainnet(), Method::GET, "/api/v1/health", None) - .await - .unwrap_err(); - match err { - CowApiError::HttpError { status, body } => { - assert_eq!(status, 500); - assert_eq!(body, r#"{"error":"internal"}"#); - } - other => panic!("expected HttpError, got: {other:?}"), - } -} - -#[tokio::test] -async fn submit_order_rejects_invalid_json() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), b"not json") - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} - -#[tokio::test] -async fn submit_order_rejects_wrong_schema() { - let pool = OrderBookPool::default(); - let err = pool - .submit_order_json(mainnet(), br#"{"valid":"json"}"#) - .await - .unwrap_err(); - assert!(matches!(err, CowApiError::Decode(_))); -} diff --git a/crates/shepherd-cow-host/src/ext_cow.rs b/crates/shepherd-cow-host/src/ext_cow.rs deleted file mode 100644 index b7c93271..00000000 --- a/crates/shepherd-cow-host/src/ext_cow.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! The cow-api extension: `shepherd:cow/cow-api` wired through the -//! extension seam rather than hard-linked into the core host. -//! -//! Shape: a local `bindgen!` for the extension world, a `Host` impl for -//! the foreign `HostState` reached through [`ExtState`], a payload -//! trait ([`CowBackend`]) the lattice `Ext` member satisfies, and an -//! [`Extension`] impl carrying the linker hook and capability namespace. -//! -//! The bindgen shares `nexum:host/types` with the core bindings via -//! `with`, so the `fault` the extension's `cow-api-error` embeds is the -//! same type the core host constructs. - -use std::marker::PhantomData; -use std::sync::Arc; -use std::time::Instant; - -use alloy_chains::Chain; -use nexum_runtime::bindings::nexum::host::types::Fault; -use nexum_runtime::host::component::{BuilderContext, ComponentBuilder, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::state::{ExtState, HostState}; -use nexum_runtime::manifest::NamespaceCaps; -use wasmtime::component::{HasSelf, Linker}; - -use crate::cow::CowApi; -use crate::cow_orderbook::{CowApiError, OrderBookPool}; - -mod bindings { - wasmtime::component::bindgen!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/cow-ext", - imports: { default: async }, - with: { "nexum:host/types": nexum_runtime::bindings::nexum::host::types }, - }); -} - -use bindings::shepherd::cow::cow_api::{ - CowApiError as WitCowApiError, HttpFailure, OrderRejection, -}; - -/// Capability namespace this extension owns. Merged into capability -/// enforcement so a module importing `shepherd:cow/cow-api` validates. -pub const COW_CAPABILITIES: NamespaceCaps = NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], -}; - -/// Extension payload providing a cow-api backend. The lattice `Ext` member -/// implements this so the `Host` impl can extract the backend generically. -pub trait CowBackend { - /// The cow orderbook backend type. - type Cow: CowApi; - /// Borrow the cow backend. - fn cow(&self) -> &Self::Cow; -} - -/// The cow-api payload the reference engine ships in its `Ext` slot. -#[derive(Clone)] -pub struct ReferenceExt { - /// `cow-api` backend - per-chain `OrderBookApi` clients + reqwest. - pub cow: OrderBookPool, -} - -impl CowBackend for ReferenceExt { - type Cow = OrderBookPool; - fn cow(&self) -> &OrderBookPool { - &self.cow - } -} - -/// Builds the reference `Ext` payload: the cow orderbook pool from -/// `[extensions.cow]`. Lives here because the cow cone (and so the -/// [`OrderBookPool`] it opens) belongs to this extension crate, not the -/// core runtime. -pub struct ReferenceExtBuilder; - -impl ComponentBuilder for ReferenceExtBuilder { - type Output = ReferenceExt; - - async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { - let cow = OrderBookPool::from_config(ctx.config)?; - Ok(ReferenceExt { cow }) - } -} - -/// The cow-api extension over a lattice whose `Ext` payload carries a cow -/// backend. -struct CowExtension(PhantomData T>); - -impl Extension for CowExtension -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - fn namespace(&self) -> &'static str { - "cow" - } - - fn capabilities(&self) -> NamespaceCaps { - COW_CAPABILITIES - } - - fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { - // Link only the cow-api interface. The whole-world - // `CowExt::add_to_linker` would also re-add the shared - // `nexum:host/types` instance, which the core event-module - // linker already provides, tripping a "defined twice" error. - bindings::shepherd::cow::cow_api::add_to_linker::, HasSelf>>( - linker, - |s| s, - )?; - Ok(()) - } -} - -/// Build the cow extension for a lattice whose `Ext` payload carries a cow -/// backend. Wired at the composition root into `build_linker` and -/// capability enforcement. -pub fn extension() -> Arc> -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - Arc::new(CowExtension(PhantomData)) -} - -/// Project the backend [`CowApiError`] into the WIT `cow-api-error`. -/// -/// Local-shape failures (unknown chain, bad method/path, decode) become -/// a shared [`Fault`]; a transport-layer HTTP failure becomes an -/// [`Http`](bindings::shepherd::cow::cow_api::CowApiError::Http) case; an -/// orderbook rejection envelope is parsed once here into a -/// [`Rejected`](bindings::shepherd::cow::cow_api::CowApiError::Rejected) -/// case so the guest never re-decodes the failure body. -fn cow_error_to_wit(err: CowApiError) -> WitCowApiError { - match err { - CowApiError::UnknownChain(chain) => WitCowApiError::Fault(Fault::Unsupported(format!( - "chain {chain} not in cowprotocol" - ))), - CowApiError::BadMethod(m) => { - WitCowApiError::Fault(Fault::InvalidInput(format!("unsupported HTTP method: {m}"))) - } - CowApiError::BadPath(msg) => WitCowApiError::Fault(Fault::InvalidInput(msg)), - CowApiError::HttpError { status, body } => WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }), - CowApiError::Network(e) => WitCowApiError::Fault(Fault::Unavailable(e.to_string())), - CowApiError::Decode(e) => WitCowApiError::Fault(Fault::InvalidInput(format!( - "invalid OrderCreation JSON: {e}" - ))), - CowApiError::Orderbook(e) => orderbook_error_to_wit(e), - } -} - -/// Map a `cowprotocol::Error` to WIT form. -/// -/// An `OrderbookApi` reply is parsed once into a typed -/// [`OrderRejection`] carrying the orderbook's `errorType` / -/// `description` plus its optional structured `data` payload, -/// re-encoded as a JSON string. A non-2xx reply with an unparseable -/// body becomes an [`HttpFailure`]. Everything else is a host-side -/// [`Fault::Internal`]. -fn orderbook_error_to_wit(err: cowprotocol::Error) -> WitCowApiError { - match err { - cowprotocol::Error::OrderbookApi { status, api } => { - WitCowApiError::Rejected(OrderRejection { - status, - error_type: api.error_type, - description: api.description, - data: api.data.map(|d| d.to_string()), - }) - } - cowprotocol::Error::UnexpectedStatus { status, body } => { - WitCowApiError::Http(HttpFailure { - status, - body: Some(body), - }) - } - other => WitCowApiError::Fault(Fault::Internal(other.to_string())), - } -} - -impl bindings::shepherd::cow::cow_api::Host for HostState -where - T: RuntimeTypes, - T::Ext: CowBackend, -{ - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, %method, %path, "cow-api::request"); - // The guest hands us a free-form method string; normalise to - // uppercase so `get` and `GET` both resolve, then type it. The - // allowlist itself lives behind the seam. - let method = match http::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) { - Ok(m) => m, - Err(_) => { - return Err(WitCowApiError::Fault(Fault::InvalidInput(format!( - "unsupported HTTP method: {method}" - )))); - } - }; - let result = self - .ext() - .cow() - .request(chain, method, &path, body.as_deref()) - .await - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::request done"); - result - } - - async fn submit_order( - &mut self, - chain_id: u64, - order_data: Vec, - ) -> Result { - let start = Instant::now(); - let chain = Chain::from_id(chain_id); - tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); - let result = self - .ext() - .cow() - .submit_order_json(chain, &order_data) - .await - .map(|uid| alloy_primitives::hex::encode_prefixed(uid.as_slice())) - .map_err(cow_error_to_wit); - tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done"); - let outcome = if result.is_ok() { "ok" } else { "err" }; - metrics::counter!( - "shepherd_cow_api_submit_total", - "chain_id" => chain_id.to_string(), - "outcome" => outcome, - ) - .increment(1); - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use cowprotocol::error::ApiError; - - #[test] - fn orderbook_api_error_becomes_typed_rejection() { - // The orderbook rejects with a typed envelope. The mapping - // parses it once, host-side, into an `order-rejection` so the - // guest dispatches on `error-type` without a second decode. - let api = ApiError { - error_type: "DuplicatedOrder".to_owned(), - description: "order already exists".to_owned(), - data: Some(serde_json::json!({"min_fee": "1234"})), - }; - let err = cowprotocol::Error::OrderbookApi { status: 400, api }; - - let WitCowApiError::Rejected(rejection) = orderbook_error_to_wit(err) else { - panic!("orderbook envelope must project to a typed rejection"); - }; - assert_eq!(rejection.status, 400); - assert_eq!(rejection.error_type, "DuplicatedOrder"); - assert_eq!(rejection.description, "order already exists"); - // The envelope's structured payload survives as a JSON string. - assert_eq!(rejection.data.as_deref(), Some(r#"{"min_fee":"1234"}"#)); - } - - #[test] - fn unexpected_status_becomes_http_failure() { - // A non-2xx reply with an unparseable body carries no typed - // rejection; it surfaces as a raw http-failure with the body - // preserved for diagnostics. - let err = cowprotocol::Error::UnexpectedStatus { - status: 502, - body: "upstream".to_owned(), - }; - - let WitCowApiError::Http(http) = orderbook_error_to_wit(err) else { - panic!("unexpected-status must project to an http-failure"); - }; - assert_eq!(http.status, 502); - assert_eq!(http.body.as_deref(), Some("upstream")); - } - - #[test] - fn backend_http_error_projects_to_http_failure() { - // The passthrough backend surfaces a non-2xx as `HttpError`; - // it must reach the guest as an http-failure so a 404 is - // matchable on `status`. - let err = CowApiError::HttpError { - status: 404, - body: "not found".to_owned(), - }; - - let WitCowApiError::Http(http) = cow_error_to_wit(err) else { - panic!("backend HttpError must project to an http-failure"); - }; - assert_eq!(http.status, 404); - assert_eq!(http.body.as_deref(), Some("not found")); - } - - #[test] - fn unknown_chain_projects_to_unsupported_fault() { - let err = CowApiError::UnknownChain(Chain::from_id(9999)); - assert!(matches!( - cow_error_to_wit(err), - WitCowApiError::Fault(Fault::Unsupported(_)), - )); - } -} diff --git a/crates/shepherd-cow-host/src/lib.rs b/crates/shepherd-cow-host/src/lib.rs deleted file mode 100644 index 2980ec7e..00000000 --- a/crates/shepherd-cow-host/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! The cow-api host extension: `shepherd:cow/cow-api` wired into the nexum -//! runtime through the linker extension seam. -//! -//! The core runtime knows nothing of CoW Protocol; this crate owns the -//! `cowprotocol` dependency, the `shepherd:cow/cow-ext` bindgen, the -//! orderbook backend, and the [`Extension`](nexum_runtime::host::extension::Extension) -//! value the composition root assembles into the linker and capability -//! registry. It depends on the runtime (for `HostState`, the extension -//! seam, and the shared `nexum:host/types` bindgen); the runtime never -//! depends on it, so the CoW cone stays out of the bare engine. - -pub mod config; -mod cow; -pub mod cow_orderbook; -pub mod ext_cow; - -pub use config::{CowConfig, CowConfigError}; -pub use cow::CowApi; -pub use cow_orderbook::{CowApiError, OrderBookPool}; -pub use ext_cow::{CowBackend, ReferenceExt, ReferenceExtBuilder, extension}; diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs deleted file mode 100644 index b195b537..00000000 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Boot-order coverage for the cow-api extension: a module that imports -//! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root, and fails to boot without it. -//! -//! These exercise the real wit-bindgen + supervisor path against pre-built -//! wasm artefacts and skip gracefully when the artefact is absent. - -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use nexum_runtime::bindings::nexum; -use nexum_runtime::engine_config::{EngineConfig, ModuleLimits}; -use nexum_runtime::host::component::{Components, RuntimeTypes}; -use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use nexum_runtime::host::state::HostState; -use nexum_runtime::supervisor::{Supervisor, build_linker}; -use shepherd_cow_host::{OrderBookPool, ReferenceExt, extension}; -use wasmtime::component::Linker; - -const SEPOLIA: u64 = 11_155_111; - -/// Reference-shaped lattice: the core backends plus the cow-api payload in -/// the extension slot, matching what the CLI composition root assembles. -#[derive(Debug, Clone, Copy, Default)] -struct CowTestTypes; - -impl nexum_runtime::sealed::SealedRuntimeTypes for CowTestTypes {} - -impl RuntimeTypes for CowTestTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -fn cow_extensions() -> Vec>> { - vec![extension::()] -} - -fn make_wasmtime_engine() -> wasmtime::Engine { - let mut config = wasmtime::Config::new(); - config.wasm_component_model(true); - config.consume_fuel(true); - wasmtime::Engine::new(&config).expect("wasmtime engine") -} - -fn make_linker(engine: &wasmtime::Engine) -> Linker> { - build_linker::(engine, &cow_extensions()).expect("build_linker") -} - -/// A chainless provider pool: no `[chains]` entries, so every -/// `chain::request` surfaces `UnknownChain`. Enough to prove boot and -/// dispatch without a live RPC endpoint. -async fn chainless_pool() -> ProviderPool { - ProviderPool::from_config(&EngineConfig::default()) - .await - .expect("chainless provider pool") -} - -async fn test_components(store: LocalStore) -> Components { - Components { - chain: chainless_pool().await, - store, - ext: ReferenceExt { - cow: OrderBookPool::default(), - }, - logs: nexum_runtime::host::logs::LogPipeline::in_memory(ModuleLimits::default().logs()), - } -} - -fn temp_local_store() -> (tempfile::TempDir, LocalStore) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("ls.redb"); - let store = LocalStore::open(path).expect("local store"); - (dir, store) -} - -/// Path to a module's `.wasm` artefact under the workspace target dir. -/// `CARGO_MANIFEST_DIR` is `crates/shepherd-cow-host`; two parents up is -/// the workspace root, mirroring the runtime's own helper. -fn module_wasm(module_name: &str) -> PathBuf { - let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) -} - -fn module_wasm_or_skip(module_name: &str) -> Option { - let p = module_wasm(module_name); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", - p.display() - ); - None - } -} - -fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) -} - -fn synthetic_sepolia_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: SEPOLIA, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - } -} - -async fn boot_production_module( - engine: &wasmtime::Engine, - linker: &Linker>, - local_store: &LocalStore, - wasm: &Path, - manifest: &Path, -) -> Supervisor { - let components = test_components(local_store.clone()).await; - let limits = ModuleLimits::default(); - Supervisor::boot_single( - engine, - linker, - wasm, - Some(manifest), - &components, - &limits, - &cow_extensions(), - None, - ) - .await - .expect("boot_single") -} - -/// stop-loss imports `shepherd:cow/cow-api`; it boots with the cow -/// extension and a block dispatch reaches it. -#[tokio::test] -async fn e2e_stop_loss_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("stop-loss") else { - return; - }; - let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (stop-loss) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot in the tests above; -/// drop the pairing and boot fails. -#[tokio::test] -async fn stop_loss_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("stop-loss") else { - return; - }; - let manifest = production_module_toml("modules/examples/stop-loss/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store).await; - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: ethflow-watcher declares - // the cow-api capability, which a core-only registry does not - // recognise (registering it is exactly what the cow extension does). - // Rules out an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml deleted file mode 100644 index ea2a4a47..00000000 --- a/crates/shepherd-sdk-test/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "shepherd-sdk-test" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "In-memory CoW host mock for Shepherd module unit tests. Implements shepherd_sdk::cow::CowApiHost and composes the nexum-sdk-test mocks." - -[lib] -# Plain library, host-only - module Cargo.toml lists this under -# [dev-dependencies] so it never ships in the wasm bundle. - -[dependencies] -nexum-sdk = { path = "../nexum-sdk" } -nexum-sdk-test = { path = "../nexum-sdk-test" } -shepherd-sdk = { path = "../shepherd-sdk" } -serde_json = { workspace = true, features = ["std"] } - -[dev-dependencies] -# Order construction for the MockVenue acceptance tests that drive -# the keeper run end to end. -alloy-primitives.workspace = true -composable-cow = { path = "../composable-cow" } -cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs deleted file mode 100644 index b0a8dd22..00000000 --- a/crates/shepherd-sdk-test/src/lib.rs +++ /dev/null @@ -1,669 +0,0 @@ -//! # shepherd-sdk-test -//! -//! In-memory implementation of the CoW-domain -//! [`shepherd_sdk::cow::CowApiHost`] trait, plus a [`MockHost`] that -//! composes it with the generic `nexum-sdk-test` mocks so a CoW module -//! can write integration tests for its strategy logic without -//! `wit-bindgen`, `wasmtime`, or a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate and test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use shepherd_sdk::cow::CowApiHost as _; -//! use shepherd_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.cow_api.respond(Ok("0xuid".into())); -//! -//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); -//! assert_eq!(host.cow_api.call_count(), 1); -//! ``` -//! -//! Per-call venue scripting - outcome queues, status sequences, fault -//! injection - goes through [`MockVenue`] on the same seam: -//! -//! ```rust -//! use nexum_sdk::host::Fault; -//! use shepherd_sdk::cow::{CowApiError, CowApiHost as _}; -//! use shepherd_sdk_test::MockHost; -//! -//! let host = MockHost::with_venue(); -//! host.cow_api -//! .enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); -//! host.cow_api.enqueue_submit(Ok("0xuid".into())); -//! -//! assert!(host.submit_order(1, b"{}").is_err()); -//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); -//! ``` -//! -//! Modules that never touch the orderbook use `nexum-sdk-test`'s -//! `MockHost` directly instead. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] - -use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; - -use nexum_sdk::Level; -use nexum_sdk::host::{ - ChainError, ChainHost, Fault, IdentityHost, LocalStoreHost, LoggingHost, Message, - MessagingHost, RemoteStoreHost, -}; -use nexum_sdk::prelude::{Address, B256, Signature}; -use nexum_sdk_test::{ - MockChain, MockIdentity, MockLocalStore, MockLogging, MockMessaging, MockRemoteStore, -}; -use shepherd_sdk::cow::{CowApiError, CowApiHost}; - -/// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus a venue mock on the `shepherd:cow/cow-api` seam - -/// [`MockCowApi`] by default, [`MockVenue`] via -/// [`with_venue`](MockHost::with_venue). Each field exposes the -/// per-trait mock so tests can program responses and assert on calls. -#[derive(Default)] -pub struct MockHost { - /// `nexum:host/chain` mock. - pub chain: MockChain, - /// `nexum:host/identity` mock. - pub identity: MockIdentity, - /// `nexum:host/local-store` mock. - pub store: MockLocalStore, - /// `nexum:host/remote-store` mock. - pub remote_store: MockRemoteStore, - /// `nexum:host/messaging` mock. - pub messaging: MockMessaging, - /// `shepherd:cow/cow-api` mock. - pub cow_api: V, - /// `nexum:host/logging` mock. - pub logging: MockLogging, -} - -impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. - pub fn new() -> Self { - Self::default() - } -} - -impl MockHost { - /// Fresh empty host with [`MockVenue`] on the cow-api seam. - pub fn with_venue() -> Self { - Self::default() - } -} - -impl ChainHost for MockHost { - fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { - self.chain.request(chain_id, method, params) - } -} - -impl LocalStoreHost for MockHost { - fn get(&self, key: &str) -> Result>, Fault> { - self.store.get(key) - } - fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { - self.store.set(key, value) - } - fn delete(&self, key: &str) -> Result<(), Fault> { - self.store.delete(key) - } - fn list_keys(&self, prefix: &str) -> Result, Fault> { - self.store.list_keys(prefix) - } - fn contains(&self, key: &str) -> Result { - self.store.contains(key) - } - fn len(&self, key: &str) -> Result, Fault> { - // Qualified: the mock's inherent `len` counts rows. - LocalStoreHost::len(&self.store, key) - } - fn count(&self, prefix: &str) -> Result { - self.store.count(prefix) - } -} - -impl IdentityHost for MockHost { - fn accounts(&self) -> Result, Fault> { - self.identity.accounts() - } - fn sign(&self, account: Address, message: &[u8]) -> Result { - self.identity.sign(account, message) - } - fn sign_typed_data(&self, account: Address, typed_data: &str) -> Result { - self.identity.sign_typed_data(account, typed_data) - } -} - -impl RemoteStoreHost for MockHost { - fn upload(&self, data: &[u8]) -> Result { - self.remote_store.upload(data) - } - fn download(&self, reference: B256) -> Result, Fault> { - self.remote_store.download(reference) - } - fn read_feed(&self, owner: Address, topic: B256) -> Result>, Fault> { - self.remote_store.read_feed(owner, topic) - } - fn write_feed(&self, topic: B256, data: &[u8]) -> Result { - self.remote_store.write_feed(topic, data) - } -} - -impl MessagingHost for MockHost { - fn publish(&self, content_topic: &str, payload: &[u8]) -> Result<(), Fault> { - self.messaging.publish(content_topic, payload) - } - fn query( - &self, - content_topic: &str, - start_time: Option, - end_time: Option, - limit: Option, - ) -> Result, Fault> { - self.messaging - .query(content_topic, start_time, end_time, limit) - } -} - -impl CowApiHost for MockHost { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.cow_api.submit_order(chain_id, body) - } - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.cow_api.cow_api_request(chain_id, method, path, body) - } -} - -impl LoggingHost for MockHost { - fn log(&self, level: Level, message: &str) { - self.logging.log(level, message); - } -} - -// ---------------------------------------------------------------- cow-api - -/// In-memory [`CowApiHost`] that captures every submission and returns -/// a programmable response. -#[derive(Default)] -pub struct MockCowApi { - response: RefCell>>, - calls: RefCell>, - /// `cow_api_request` mock state. Keyed by `(method, path)` so - /// tests can program different responses for `GET - /// /api/v1/app_data/0x...` vs other endpoints. Falls back to the - /// unkeyed `request_response` if no key matches. - request_responses: - RefCell>>, - request_response: RefCell>>, - request_calls: RefCell>, -} - -/// One recorded [`MockCowApi::submit_order`] invocation. -#[derive(Clone, Debug)] -pub struct SubmitCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// Raw `OrderCreation` JSON body. - pub body: Vec, -} - -/// One recorded [`MockCowApi::cow_api_request`] invocation. -#[derive(Clone, Debug)] -pub struct RequestCall { - /// Chain the guest targeted. - pub chain_id: u64, - /// HTTP-style verb. - pub method: String, - /// Absolute orderbook path, e.g. `/api/v1/app_data/0xabcd...`. - pub path: String, - /// Optional JSON body (for POST/PUT). - pub body: Option, -} - -impl MockCowApi { - /// Program the response the mock returns on every subsequent - /// `submit_order` call. Defaults to an `Unsupported` fault if - /// unset. - pub fn respond(&self, result: Result) { - *self.response.borrow_mut() = Some(result); - } - - /// All submissions, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last submission, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Convenience: parse the most recent body as JSON. - pub fn last_body_as_json(&self) -> Option { - self.last_call() - .and_then(|c| serde_json::from_slice(&c.body).ok()) - } - - /// Count of submissions. - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } -} - -impl MockCowApi { - /// Program a response for a specific `(method, path)` pair. - /// Highest priority - used when both this and `respond_to_request` - /// are set. - pub fn respond_to_request_for( - &self, - method: impl Into, - path: impl Into, - result: Result, - ) { - self.request_responses - .borrow_mut() - .insert((method.into(), path.into()), result); - } - - /// Program the catch-all response for `cow_api_request` calls - /// that don't match a specific `(method, path)` key. Defaults - /// to an `Unsupported` fault. - pub fn respond_to_request(&self, result: Result) { - *self.request_response.borrow_mut() = Some(result); - } - - /// All `cow_api_request` invocations, in arrival order. - pub fn request_calls(&self) -> Vec { - self.request_calls.borrow().clone() - } -} - -impl CowApiHost for MockCowApi { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.calls.borrow_mut().push(SubmitCall { - chain_id, - body: body.to_vec(), - }); - self.response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no response configured".to_string(), - ))) - }) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.request_calls.borrow_mut().push(RequestCall { - chain_id, - method: method.to_string(), - path: path.to_string(), - body: body.map(str::to_string), - }); - if let Some(r) = self - .request_responses - .borrow() - .get(&(method.to_string(), path.to_string())) - .cloned() - { - return r; - } - self.request_response.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockCowApi: no cow_api_request response configured".to_string(), - ))) - }) - } -} - -// ---------------------------------------------------------------- venue - -/// Scripted in-memory venue on the [`CowApiHost`] seam: programmable -/// per-call behaviour, unlike [`MockCowApi`]'s single replayed -/// response. Compose it with the generic mocks via -/// [`MockHost::with_venue`]. -/// -/// The two queue disciplines differ deliberately. Submissions are -/// discrete effects, so the submit queue strictly drains - one outcome -/// per call, then the configured fallback (default: an `Unsupported` -/// fault), so a test that scripts N outcomes catches an unexpected -/// N+1th submit. Responses are observations, so a `(method, path)` -/// sequence advances per call and its final entry replays forever - a -/// terminal order status persists no matter how often it is re-polled. -/// An injected fault overrides both (without consuming the queues) -/// until cleared, modelling a venue outage. -#[derive(Default)] -pub struct MockVenue { - submit_queue: RefCell>, - submit_fallback: RefCell>, - response_sequences: RefCell>>, - response_fallback: RefCell>, - fault: RefCell>, - calls: RefCell>, - request_calls: RefCell>, -} - -/// One scripted venue reply: the body / UID on success, a typed -/// [`CowApiError`] otherwise. -type VenueOutcome = Result; - -impl MockVenue { - /// Append one `submit_order` outcome to the queue; each call - /// consumes one, in order. - pub fn enqueue_submit(&self, outcome: Result) { - self.submit_queue.borrow_mut().push_back(outcome); - } - - /// Steady-state `submit_order` response once the queue is drained. - /// Unset, a drained queue yields an `Unsupported` fault. - pub fn set_submit_fallback(&self, outcome: Result) { - *self.submit_fallback.borrow_mut() = Some(outcome); - } - - /// Append one outcome to the `(method, path)` response sequence. - /// Each matching `cow_api_request` call advances the sequence; the - /// final entry sticks. - pub fn enqueue_response( - &self, - method: impl Into, - path: impl Into, - outcome: Result, - ) { - self.response_sequences - .borrow_mut() - .entry((method.into(), path.into())) - .or_default() - .push_back(outcome); - } - - /// Append one status-probe outcome for the order, keyed on the - /// orderbook's `GET /api/v1/orders/{uid}` route. - pub fn enqueue_order_status(&self, uid: &str, outcome: Result) { - self.enqueue_response("GET", format!("/api/v1/orders/{uid}"), outcome); - } - - /// Catch-all `cow_api_request` response for calls with no - /// programmed sequence. Unset, those yield an `Unsupported` fault. - pub fn set_response_fallback(&self, outcome: Result) { - *self.response_fallback.borrow_mut() = Some(outcome); - } - - /// Fail every venue call with `err` until - /// [`clear_fault`](Self::clear_fault) - a scripted outage. Queued - /// outcomes are not consumed while the fault is active. - pub fn inject_fault(&self, err: CowApiError) { - *self.fault.borrow_mut() = Some(err); - } - - /// Lift an injected fault; queued outcomes resume where they left - /// off. - pub fn clear_fault(&self) { - *self.fault.borrow_mut() = None; - } - - /// All submissions, in arrival order. - pub fn calls(&self) -> Vec { - self.calls.borrow().clone() - } - - /// Last submission, if any. - pub fn last_call(&self) -> Option { - self.calls.borrow().last().cloned() - } - - /// Convenience: parse the most recent submission body as JSON. - pub fn last_body_as_json(&self) -> Option { - self.last_call() - .and_then(|c| serde_json::from_slice(&c.body).ok()) - } - - /// Count of submissions (failed and injected-fault calls included). - pub fn call_count(&self) -> usize { - self.calls.borrow().len() - } - - /// All `cow_api_request` invocations, in arrival order. - pub fn request_calls(&self) -> Vec { - self.request_calls.borrow().clone() - } - - /// Scripted submit outcomes not yet consumed - assert `0` to prove - /// a scenario played out in full. - pub fn pending_submits(&self) -> usize { - self.submit_queue.borrow().len() - } -} - -impl CowApiHost for MockVenue { - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { - self.calls.borrow_mut().push(SubmitCall { - chain_id, - body: body.to_vec(), - }); - if let Some(err) = self.fault.borrow().as_ref() { - return Err(err.clone()); - } - if let Some(outcome) = self.submit_queue.borrow_mut().pop_front() { - return outcome; - } - self.submit_fallback.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockVenue: submit queue exhausted and no fallback configured".to_string(), - ))) - }) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result { - self.request_calls.borrow_mut().push(RequestCall { - chain_id, - method: method.to_string(), - path: path.to_string(), - body: body.map(str::to_string), - }); - if let Some(err) = self.fault.borrow().as_ref() { - return Err(err.clone()); - } - if let Some(sequence) = self - .response_sequences - .borrow_mut() - .get_mut(&(method.to_string(), path.to_string())) - { - // Advance until one entry remains, then replay it: the - // sequence's final state persists. - if sequence.len() > 1 { - return sequence.pop_front().expect("length checked above"); - } - if let Some(last) = sequence.front() { - return last.clone(); - } - } - self.response_fallback.borrow().clone().unwrap_or_else(|| { - Err(CowApiError::Fault(Fault::Unsupported( - "MockVenue: no response programmed for this request".to_string(), - ))) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cow_api_captures_body_and_returns_uid() { - let api = MockCowApi::default(); - api.respond(Ok("0xdeadbeef".into())); - let uid = api.submit_order(1, b"{\"x\":1}").unwrap(); - assert_eq!(uid, "0xdeadbeef"); - let last = api.last_call().unwrap(); - assert_eq!(last.chain_id, 1); - assert_eq!(last.body, b"{\"x\":1}"); - assert_eq!(api.last_body_as_json().unwrap()["x"], 1); - } - - #[test] - fn cow_api_default_response_is_unsupported() { - let api = MockCowApi::default(); - let err = api.submit_order(1, b"{}").unwrap_err(); - assert!( - matches!(err, CowApiError::Fault(Fault::Unsupported(_))), - "got {err:?}", - ); - } - - // ---- MockVenue ---- - - #[test] - fn venue_submit_queue_drains_in_order_then_falls_back() { - let venue = MockVenue::default(); - venue.enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); - venue.enqueue_submit(Ok("0xuid".into())); - - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Timeout)), - )); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!(venue.pending_submits(), 0); - - // A drained queue is unsupported by default: an unscripted - // extra submit fails loudly. - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Unsupported(_))), - )); - - venue.set_submit_fallback(Ok("0xsteady".into())); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xsteady"); - assert_eq!(venue.call_count(), 4, "every call is recorded"); - } - - #[test] - fn venue_records_submissions_like_the_single_shot_mock() { - let venue = MockVenue::default(); - venue.enqueue_submit(Ok("0xuid".into())); - venue.submit_order(7, b"{\"x\":1}").unwrap(); - - let last = venue.last_call().unwrap(); - assert_eq!(last.chain_id, 7); - assert_eq!(last.body, b"{\"x\":1}"); - assert_eq!(venue.last_body_as_json().unwrap()["x"], 1); - } - - #[test] - fn venue_fault_injection_overrides_queues_until_cleared() { - let venue = MockVenue::default(); - venue.enqueue_submit(Ok("0xuid".into())); - venue.enqueue_response("GET", "/api/v1/orders/0x1", Ok("{}".into())); - venue.inject_fault(CowApiError::Fault(Fault::Unavailable("down".into()))); - - assert!(matches!( - venue.submit_order(1, b"{}"), - Err(CowApiError::Fault(Fault::Unavailable(_))), - )); - assert!( - venue - .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) - .is_err() - ); - - // The outage consumed nothing: outcomes resume on recovery. - venue.clear_fault(); - assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!( - venue - .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) - .unwrap(), - "{}", - ); - assert_eq!(venue.call_count(), 2); - assert_eq!(venue.request_calls().len(), 2); - } - - #[test] - fn venue_response_sequence_advances_and_final_entry_sticks() { - let venue = MockVenue::default(); - for body in ["\"open\"", "\"open\"", "\"fulfilled\""] { - venue.enqueue_order_status("0xuid", Ok(body.into())); - } - let probe = || { - venue - .cow_api_request(1, "GET", "/api/v1/orders/0xuid", None) - .unwrap() - }; - assert_eq!(probe(), "\"open\""); - assert_eq!(probe(), "\"open\""); - assert_eq!(probe(), "\"fulfilled\""); - // The terminal entry replays for any later re-poll. - assert_eq!(probe(), "\"fulfilled\""); - } - - #[test] - fn venue_unscripted_request_uses_the_fallback_then_defaults() { - let venue = MockVenue::default(); - assert!(matches!( - venue.cow_api_request(1, "GET", "/api/v1/anything", None), - Err(CowApiError::Fault(Fault::Unsupported(_))), - )); - venue.set_response_fallback(Ok("catch-all".into())); - assert_eq!( - venue - .cow_api_request(1, "GET", "/api/v1/anything", None) - .unwrap(), - "catch-all", - ); - } - - #[test] - fn mock_host_with_venue_dispatches_through_cow_host_bound() { - let host = MockHost::with_venue(); - host.cow_api.enqueue_submit(Ok("0xuid".into())); - - let _: &dyn shepherd_sdk::cow::CowHost = &host; - assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); - assert_eq!(host.cow_api.call_count(), 1); - } - - #[test] - fn mock_host_dispatches_through_cow_host_bound() { - let host = MockHost::new(); - host.chain - .respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); - host.cow_api.respond(Ok("0xuid".into())); - - // Through the `CowHost` bound. - let _: &dyn shepherd_sdk::cow::CowHost = &host; - host.set("key", b"val").unwrap(); - assert_eq!(host.get("key").unwrap().as_deref(), Some(&b"val"[..])); - assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); - assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); - host.log(Level::INFO, "happy path"); - - assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); - assert_eq!(host.logging.lines().len(), 1); - assert_eq!(host.store.len(), 1); - } -} diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs deleted file mode 100644 index ec371ca2..00000000 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! MockVenue acceptance tests: the scripted venue driving the keeper -//! run (multi-tick retry, backoff, and outage scenarios the -//! single-replayed-response mock cannot express) and module-shaped -//! strategy code polling the venue directly. - -use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; -use composable_cow::Verdict; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; -use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; -use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{ - CowApiError, CowApiTransport, CowClient, CowHost, CowIntent, CowIntentBody, OrderRejection, - SignedOrder, gpv2_to_order_data, order_data_to_body, order_uid_hex, run, -}; -use shepherd_sdk_test::{MockHost, MockVenue}; - -const SEPOLIA: u64 = 11_155_111; - -type VenueHost = MockHost; - -/// The typed client every test drives `run` with: the transitional -/// cow-api bridge over the scripted venue host. -fn venue(host: &VenueHost) -> CowClient> { - CowClient::with_transport(CowApiTransport::new(host, SEPOLIA)) -} - -/// Closure-backed source so each test scripts its own outcome. -struct FnSource(F); - -impl ConditionalSource for FnSource -where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, -{ - type Outcome = Verdict; - - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { - (self.0)(host, watch, params, tick) - } -} - -/// Pin the closure to the higher-ranked source signature at the -/// construction site so inference never guesses a too-narrow lifetime. -fn src(f: F) -> FnSource -where - F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, -{ - FnSource(f) -} - -fn sample_owner() -> Address { - address!("00112233445566778899aabbccddeeff00112233") -} - -fn sample_tick() -> Tick { - Tick { - chain_id: SEPOLIA, - block: 1_000, - epoch_s: 1_700_000_000, - } -} - -/// `validTo` a given number of seconds from now. The `OrderCreation` -/// constructor's client-side max-horizon policy reads the wall clock -/// (not the block clock), so test orders must expire relative to it. -fn valid_to_in(seconds: u64) -> u32 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_secs(); - u32::try_from(now + seconds).expect("test validTo fits u32") -} - -fn submittable_order() -> GPv2OrderData { - GPv2OrderData { - sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), - buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), - receiver: Address::ZERO, - sellAmount: U256::from(1_000_000_u64), - buyAmount: U256::from(999_u64), - validTo: valid_to_in(3_600), - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - } -} - -fn ready_outcome(order: &GPv2OrderData) -> Verdict { - Verdict::Post { - order: Box::new(order.clone()), - signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), - next_poll_timestamp: 0, - } -} - -fn ready_source( - order: &GPv2OrderData, -) -> FnSource, &[u8], &Tick) -> Verdict> { - let order = order.clone(); - src(move |_, _, _, _| ready_outcome(&order)) -} - -fn seed_watch(host: &VenueHost) -> String { - WatchSet::new(host) - .put( - &sample_owner(), - &keccak256(b"conditional order params"), - b"params", - ) - .unwrap() -} - -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") -} - -/// The intent-id the keeper journals for `order`: the venue-and-body -/// key over the same signed body `run` derives pre-submit. -fn intent_id(order: &GPv2OrderData) -> String { - let order_data = gpv2_to_order_data(order).expect("known markers"); - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: order_data_to_body(&order_data), - owner: sample_owner().into_array(), - signature: hex!("c0ffeec0ffeec0ffee").to_vec(), - }))) - .expect("body encodes") -} - -fn rejection(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "test".into(), - data: None, - }) -} - -// ---- keeper use ---- - -/// A transient rejection on the first tick keeps the watch alive; the -/// next tick's scripted success is journalled. Per-call scripting is -/// the point: one venue plays a different outcome on each tick. -#[test] -fn keeper_retries_a_transient_rejection_then_submits() { - let host = MockHost::with_venue(); - let key = seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(rejection("InsufficientFee"))); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - - let source = ready_source(&order); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(host.store.snapshot().contains_key(&key), "watch survives"); - assert!( - !Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); - - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); - assert_eq!( - host.cow_api.pending_submits(), - 0, - "scenario played out in full" - ); -} - -/// A rate-limit with server guidance gates the watch on the epoch -/// clock; the venue is only reached again once the gate clears, and -/// the queued success then lands. -#[test] -fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { - let host = MockHost::with_venue(); - seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - })))); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - - let t0 = sample_tick(); - let source = ready_source(&order); - run(&host, &venue(&host), &source, &t0).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - - // 2500ms rounds up to a 3s epoch gate: a tick inside it never - // reaches the venue. - let gated = Tick { - epoch_s: t0.epoch_s + 2, - ..t0 - }; - run(&host, &venue(&host), &source, &gated).unwrap(); - assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); - - let clear = Tick { - epoch_s: t0.epoch_s + 3, - ..t0 - }; - run(&host, &venue(&host), &source, &clear).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); -} - -/// A venue outage is transient: the watch stays, nothing is gated, and -/// the first tick after recovery submits the queued outcome. -#[test] -fn keeper_survives_a_venue_outage_and_submits_on_recovery() { - let host = MockHost::with_venue(); - let key = seed_watch(&host); - let watch_key = WatchRef::parse(&key).unwrap(); - let order = submittable_order(); - host.cow_api - .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); - - let source = ready_source(&order); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&key)); - assert!(!snapshot.contains_key(&watch_key.next_block_key())); - assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); - assert_eq!(host.cow_api.call_count(), 1); - - host.cow_api.clear_fault(); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - run(&host, &venue(&host), &source, &sample_tick()).unwrap(); - assert_eq!(host.cow_api.call_count(), 2); - assert!( - Journal::submitted(&host) - .contains(&intent_id(&order)) - .unwrap() - ); -} - -/// A scripted permanent rejection drops the watch through the ledger. -#[test] -fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { - let host = MockHost::with_venue(); - seed_watch(&host); - let order = submittable_order(); - host.cow_api - .enqueue_submit(Err(rejection("InvalidSignature"))); - - run(&host, &venue(&host), &ready_source(&order), &sample_tick()).unwrap(); - - assert!(host.store.is_empty(), "watch and gates must go"); - assert_eq!(host.cow_api.call_count(), 1); -} - -/// Keeper rows written through the composed host stay invisible to a -/// sibling store namespace, and a decoy watch planted there never -/// reaches the sweep - the store-fidelity seam under the venue tests. -#[test] -fn keeper_sweep_ignores_sibling_namespace_watches() { - let host = MockHost::with_venue(); - seed_watch(&host); - let sibling = host.store.namespaced("other-module"); - assert!(sibling.is_empty(), "keeper rows must not leak across"); - sibling - .set( - "watch:0x00112233445566778899aabbccddeeff00112233:0xdead", - b"decoy", - ) - .unwrap(); - - let order = submittable_order(); - host.cow_api.enqueue_submit(Ok(client_uid(&order))); - let polls = std::cell::Cell::new(0_u32); - run( - &host, - &venue(&host), - &src(|_, _, _, _| { - polls.set(polls.get() + 1); - ready_outcome(&order) - }), - &sample_tick(), - ) - .unwrap(); - - assert_eq!(polls.get(), 1, "only this module's watch is swept"); - assert_eq!(host.cow_api.call_count(), 1); -} - -// ---- module use ---- - -/// Module-shaped fill tracker: probe the orderbook status route and -/// journal an `observed:` receipt once the order reports fulfilled. -/// Generic over [`CowHost`] exactly like production strategy code. -fn record_fill(host: &H, chain_id: u64, uid: &str) -> Result { - let path = format!("/api/v1/orders/{uid}"); - let Ok(body) = host.cow_api_request(chain_id, "GET", &path, None) else { - return Ok(false); - }; - let fulfilled = serde_json::from_str::(&body) - .ok() - .and_then(|v| v.get("status").and_then(|s| s.as_str().map(str::to_owned))) - .is_some_and(|status| status == "fulfilled"); - if fulfilled { - Journal::observed(host).record(uid)?; - } - Ok(fulfilled) -} - -/// The status sequence advances one entry per module poll and its -/// terminal entry persists across any number of re-polls. -#[test] -fn module_tracks_a_fill_through_a_status_sequence() { - let host = MockHost::with_venue(); - for body in [ - r#"{"status":"open"}"#, - r#"{"status":"open"}"#, - r#"{"status":"fulfilled"}"#, - ] { - host.cow_api.enqueue_order_status("0xuid", Ok(body.into())); - } - - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - // Terminal status sticks: an over-eager re-poll sees it again. - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - assert!(Journal::observed(&host).contains("0xuid").unwrap()); - assert_eq!(host.cow_api.request_calls().len(), 4); - assert_eq!(host.cow_api.request_calls()[0].path, "/api/v1/orders/0xuid"); -} - -/// An outage mid-sequence surfaces to the module as a failed probe and -/// consumes nothing: the sequence resumes where it left off. -#[test] -fn module_probe_rides_out_an_injected_outage() { - let host = MockHost::with_venue(); - host.cow_api - .enqueue_order_status("0xuid", Ok(r#"{"status":"open"}"#.into())); - host.cow_api - .enqueue_order_status("0xuid", Ok(r#"{"status":"fulfilled"}"#.into())); - - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - host.cow_api - .inject_fault(CowApiError::Fault(Fault::Timeout)); - assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); - - host.cow_api.clear_fault(); - assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); - assert!(Journal::observed(&host).contains("0xuid").unwrap()); -} - -/// The free `watch_key` helper produces exactly the key -/// `WatchSet::put` writes through the venue host, so a test can seed -/// or assert rows without a host turbofish. -#[test] -fn watch_key_helper_unifies_with_the_venue_host() { - let host = MockHost::with_venue(); - let hash: B256 = keccak256(b"conditional order params"); - let written = WatchSet::new(&host) - .put(&sample_owner(), &hash, b"params") - .unwrap(); - assert_eq!(watch_key(&sample_owner(), &hash), written); -} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml deleted file mode 100644 index a2c7f216..00000000 --- a/crates/shepherd-sdk/Cargo.toml +++ /dev/null @@ -1,45 +0,0 @@ -[package] -name = "shepherd-sdk" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, order bridging, and prelude on top of cowprotocol types." - -[lib] -# Plain library - modules link this and emit their own cdylib for the -# WASM Component. Building shepherd-sdk on the host target is also -# supported so the helpers are unit-testable without a wasm toolchain. - -[dependencies] -# Re-export shim: the venue-neutral intent body types now live in the -# cow-venue default slice; this crate re-exports them under `cow` until -# the module ports move off the legacy path. The `client` slice carries -# the table-driven retry classification the cow error surface delegates -# to; the `assembly` slice carries the chain-edge order projections the -# keeper run still drives. -cow-venue = { path = "../cow-venue", features = ["client", "assembly"] } -# The structured poll seam the keeper run dispatches on. -composable-cow = { path = "../composable-cow" } -nexum-sdk = { path = "../nexum-sdk" } -# The typed client seam the keeper run submits through; also the -# `VenueTransport` contract the legacy cow-api bridge implements. -videre-sdk = { path = "../videre-sdk" } -cowprotocol = { version = "0.2.0", default-features = false } -alloy-primitives.workspace = true -serde_json.workspace = true -strum.workspace = true -thiserror.workspace = true -tracing.workspace = true - -[dev-dependencies] -# `capture_tracing` observes the keeper run's diagnostics in the -# acceptance tests. -nexum-sdk-test = { path = "../nexum-sdk-test" } -alloy-sol-types.workspace = true -proptest.workspace = true -# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, -# and the keeper run acceptance-tests against the composed MockHost -# as an integration test - the mock crate links shepherd-sdk -# externally, so the unit-test copy of the traits would not unify. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md deleted file mode 100644 index 13a9ae0c..00000000 --- a/crates/shepherd-sdk/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# shepherd-sdk - -CoW-domain guest SDK for [Shepherd](https://github.com/nullislabs/shepherd) modules. - -`shepherd-sdk` layers the CoW Protocol surface on top of the generic -`nexum-sdk`: the module keeps its own `wit_bindgen::generate!` call -(which emits the world-specific `Guest` trait and host-import shims -into the module's own crate), pulls the host trait seam and generic -helpers from `nexum-sdk`, and pulls the CoW types and helpers from -here. Nothing is re-exported between the two crates; modules import -each directly. - -## Quick tour - -```rust -use nexum_sdk::prelude::*; -use shepherd_sdk::prelude::*; -use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; -``` - -| Module | What it provides | -|---|---| -| `prelude` | One-liner `use ::*` for cowprotocol order / signing / orderbook surface (alloy primitives come from `nexum_sdk::prelude`). | -| `cow` | `CowApiHost` trait for `shepherd:cow/cow-api` + the `CowHost` bound over the core `nexum_sdk::host::Host`. | -| `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | -| `cow::composable` | `sol! IConditionalOrder` errors + `Verdict` + `LegacyRevertAdapter`. | -| `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | -| `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | - -## Testing modules host-free - -Add the companion `shepherd-sdk-test` crate as a dev-dep and write -your strategy function against `&impl shepherd_sdk::cow::CowHost` -(or `&impl nexum_sdk::host::Host` if it never touches the -orderbook). Tests against `MockHost` then run without `wit-bindgen` -or `wasmtime`: - -```rust,ignore -let host = shepherd_sdk_test::MockHost::new(); -host.cow_api.respond(Ok("0xuid".into())); -submit_watch(&host, 1).unwrap(); -assert_eq!(host.cow_api.call_count(), 1); -``` - -## Why no `wit_bindgen::generate!` in the SDK - -The macro emits types into the calling crate (the module's cdylib). -Re-exporting wit-bindgen output from a library would duplicate -symbols and break the component-export contract. Helpers in this -SDK take primitive arguments (`&[u8]`, `&str`, `Option<&str>`) so -the SDK stays world-neutral; modules unpack their wit-bindgen -`Fault` / `Log` into primitives at the call site. Trade-off -documented in ADR-0006 and ADR-0007 in `docs/adr/`. - -## Layout - -``` -crates/shepherd-sdk/ -├── src/ -│ ├── lib.rs crate root + intra-doc links -│ ├── prelude.rs cowprotocol bulk re-exports -│ ├── cow/ -│ │ ├── mod.rs CowApiHost + CowHost -│ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + Verdict + LegacyRevertAdapter -│ │ └── error.rs RetryAction + classify_api_error -│ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! -└── README.md you are here - -(The generic surface - host trait seam, chain / config / address -helpers, http, tracing - lives in the sibling `nexum-sdk` crate.) -``` - -## Generating docs locally - -```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open -``` - -The CI gate `cargo doc -p shepherd-sdk --no-deps` runs under those -flags, so all public items carry doc comments and intra-doc links -resolve. diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs deleted file mode 100644 index a5e0de03..00000000 --- a/crates/shepherd-sdk/src/cow/error.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Typed `shepherd:cow/cow-api` error surface and orderbook rejection -//! classification. -//! -//! [`CowApiError`] mirrors the WIT `cow-api-error` variant: a shared -//! host [`Fault`], a raw [`HttpFailure`], or a typed [`OrderRejection`] -//! the host parsed once from the orderbook's `{errorType, description}` -//! envelope. The guest dispatches on the variant directly, so no -//! second JSON decode of a failure body happens strategy-side. -//! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into the -//! keeper [`RetryAction`] the retry ledger dispatches on; -//! [`classify_submit_error`] widens the table to the whole -//! [`CowApiError`] surface. - -use nexum_sdk::host::{Fault, HostFault}; -use strum::IntoStaticStr; - -pub use nexum_sdk::keeper::RetryAction; - -/// A non-2xx orderbook reply with no typed rejection envelope. `body` -/// is the raw response text, foreign orderbook JSON kept verbatim: a -/// caller matches on `status` and reads `body` only for diagnostics. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HttpFailure { - /// HTTP status code. - pub status: u16, - /// Raw response body, when the host captured one. - pub body: Option, -} - -/// A typed orderbook rejection of a submitted order, parsed once -/// host-side from the `{errorType, description, data}` envelope. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OrderRejection { - /// HTTP status returned with the rejection. - pub status: u16, - /// Machine-readable `errorType` (e.g. `"InsufficientFee"`). - pub error_type: String, - /// Human-readable description. - pub description: String, - /// The envelope's optional structured payload (e.g. a minimum-fee - /// quote), serialised to a JSON string by the host via - /// `serde_json::Value::to_string`. - pub data: Option, -} - -/// Mirror of `shepherd:cow/cow-api.cow-api-error`. The domain-side -/// counterpart the [`bind_cow_host_via_wit_bindgen`](crate::bind_cow_host_via_wit_bindgen) -/// macro converts the per-cdylib wit-bindgen error into, so strategy -/// logic dispatches on one host-neutral type. -/// -/// `IntoStaticStr` exposes the variant name as a snake_case `&'static -/// str`; [`HostFault::label`] refines the [`Fault`] case to the -/// embedded fault's own label so metric and log labels stay granular. -#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum CowApiError { - /// A shared host fault (unsupported, timeout, transport down, ...). - #[error(transparent)] - Fault(Fault), - /// A raw non-2xx HTTP reply without a typed rejection envelope. - #[error("orderbook http {}", .0.status)] - Http(HttpFailure), - /// A typed orderbook rejection of a submitted order. - #[error("orderbook rejected ({} {}): {}", .0.status, .0.error_type, .0.description)] - Rejected(OrderRejection), -} - -impl nexum_sdk::host::sealed::SealedHostFault for CowApiError {} - -impl HostFault for CowApiError { - fn fault(&self) -> Option<&Fault> { - match self { - CowApiError::Fault(f) => Some(f), - _ => None, - } - } - - fn label(&self) -> &'static str { - match self { - CowApiError::Fault(f) => f.label(), - other => other.into(), - } - } -} - -/// Classify a decoded orderbook [`OrderRejection`] into the keeper -/// [`RetryAction`] via the shipped CoW classification table -/// ([`cow_venue::classify`]): the `errorType` drives the action - -/// transient types retry next block, throttle types back off, permanent -/// types drop. The one invariant the table enforces: an `errorType` -/// absent from the data is permanent, never retried every block forever. -/// -/// Non-`Rejected` failures carry no `error_type`; classify those with -/// [`classify_submit_error`]. -/// -/// # Example -/// -/// ``` -/// use shepherd_sdk::cow::{classify_api_error, OrderRejection, RetryAction}; -/// -/// // Transient: orderbook rejects with InsufficientFee -> retry next block. -/// let transient = OrderRejection { -/// status: 400, -/// error_type: "InsufficientFee".to_string(), -/// description: "fee too low".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&transient), RetryAction::TryNextBlock); -/// -/// // Permanent: InvalidSignature -> drop the watch / placement. -/// let permanent = OrderRejection { -/// status: 400, -/// error_type: "InvalidSignature".to_string(), -/// description: "bad sig".to_string(), -/// data: None, -/// }; -/// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); -/// ``` -pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - cow_venue::classify(&rejection.error_type) -} - -/// Whether the rejection says the orderbook already holds this exact -/// order, per the classification table's `already-submitted` flag -/// (`DuplicatedOrder`, plus the `DuplicateOrder` spelling older -/// deployments emit). Already-submitted is success wearing an error -/// status - dropping the watch on it would kill every future tranche of -/// a TWAP - so the caller records the `submitted:` receipt and keeps the -/// watch. -pub fn is_already_submitted(rejection: &OrderRejection) -> bool { - cow_venue::is_already_submitted(&rejection.error_type) -} - -/// Classify a whole [`CowApiError`] from a submission into the keeper -/// [`RetryAction`]. -/// -/// A typed rejection dispatches through [`classify_api_error`]; a -/// rate-limit fault with server guidance becomes `Backoff` (hint -/// rounded up to whole seconds, minimum one). Everything else -/// (transport faults, raw HTTP errors, unguided rate limits) is -/// transient -> `TryNextBlock`, so a flaky orderbook never poisons a -/// still-valid order. -pub fn classify_submit_error(err: &CowApiError) -> RetryAction { - match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - CowApiError::Fault(Fault::RateLimited(limit)) => match limit.retry_after_ms { - Some(ms) => RetryAction::Backoff { - seconds: ms.div_ceil(1000).max(1), - }, - None => RetryAction::TryNextBlock, - }, - _ => RetryAction::TryNextBlock, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use nexum_sdk::host::RateLimit; - - fn rejection(error_type: &str) -> OrderRejection { - OrderRejection { - status: 400, - error_type: error_type.to_string(), - description: "test".to_string(), - data: None, - } - } - - #[test] - fn retriable_kind_yields_try_next_block() { - assert_eq!( - classify_api_error(&rejection("InsufficientFee")), - RetryAction::TryNextBlock, - ); - } - - /// A throttle errorType backs off rather than retrying next block, - /// so the table reaches every retry arm - the `Backoff` producer the - /// hand-coded classifier lacked. - #[test] - fn throttle_kind_yields_backoff() { - assert_eq!( - classify_api_error(&rejection("TooManyLimitOrders")), - RetryAction::Backoff { seconds: 30 }, - ); - } - - #[test] - fn permanent_kinds_yield_drop() { - for kind in [ - "InvalidSignature", - "WrongOwner", - "UnsupportedToken", - "InvalidAppData", - "InvalidEip1271Signature", - ] { - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::Drop, - "{kind}", - ); - } - } - - #[test] - fn unknown_kind_yields_drop() { - assert_eq!( - classify_api_error(&rejection("NewlyMintedErrorType")), - RetryAction::Drop, - ); - } - - /// Both spellings pin: the orderbook emits `DuplicatedOrder`, the - /// older `DuplicateOrder` form must classify identically. Neither - /// may drop the watch - that would kill every future tranche. - #[test] - fn duplicated_order_is_already_submitted_and_never_drops() { - for kind in ["DuplicatedOrder", "DuplicateOrder"] { - assert!(is_already_submitted(&rejection(kind)), "{kind}"); - assert_eq!( - classify_api_error(&rejection(kind)), - RetryAction::TryNextBlock, - "{kind}", - ); - } - assert!(!is_already_submitted(&rejection("InsufficientFee"))); - assert!(!is_already_submitted(&rejection("InvalidSignature"))); - } - - #[test] - fn submit_error_rejection_routes_through_the_table() { - assert_eq!( - classify_submit_error(&CowApiError::Rejected(rejection("InvalidSignature"))), - RetryAction::Drop, - ); - assert_eq!( - classify_submit_error(&CowApiError::Rejected(rejection("InsufficientFee"))), - RetryAction::TryNextBlock, - ); - } - - #[test] - fn submit_error_rate_limit_hint_becomes_backoff_in_whole_seconds() { - let limited = |ms| CowApiError::Fault(Fault::RateLimited(RateLimit { retry_after_ms: ms })); - assert_eq!( - classify_submit_error(&limited(Some(2_500))), - RetryAction::Backoff { seconds: 3 }, - ); - // Sub-second hints round up to a full second, never to zero. - assert_eq!( - classify_submit_error(&limited(Some(1))), - RetryAction::Backoff { seconds: 1 }, - ); - // No guidance -> plain next-block retry. - assert_eq!( - classify_submit_error(&limited(None)), - RetryAction::TryNextBlock - ); - } - - #[test] - fn submit_error_transient_shapes_stay_try_next_block() { - assert_eq!( - classify_submit_error(&CowApiError::Fault(Fault::Timeout)), - RetryAction::TryNextBlock, - ); - assert_eq!( - classify_submit_error(&CowApiError::Http(HttpFailure { - status: 502, - body: None, - })), - RetryAction::TryNextBlock, - ); - } - - #[test] - fn fault_case_recovers_embedded_fault_and_label() { - let err = CowApiError::Fault(Fault::Timeout); - assert_eq!(err.fault(), Some(&Fault::Timeout)); - // Fault case refines the label to the embedded fault's own. - assert_eq!(err.label(), "timeout"); - - let rl = CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(250), - })); - assert_eq!(rl.label(), "rate_limited"); - } - - #[test] - fn non_fault_cases_expose_variant_label_and_no_fault() { - let http = CowApiError::Http(HttpFailure { - status: 404, - body: None, - }); - assert_eq!(http.fault(), None); - assert_eq!(http.label(), "http"); - - let rejected = CowApiError::Rejected(rejection("InvalidSignature")); - assert_eq!(rejected.fault(), None); - assert_eq!(rejected.label(), "rejected"); - } -} diff --git a/crates/shepherd-sdk/src/cow/events.rs b/crates/shepherd-sdk/src/cow/events.rs deleted file mode 100644 index 20acdd42..00000000 --- a/crates/shepherd-sdk/src/cow/events.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! CoW on-chain event ABIs, mirroring `shepherd:cow/cow-events`. -//! -//! `wit/shepherd-cow/cow-events.wit` is the package of record; the -//! constants here are parity-tested against it, the `cowprotocol` -//! `sol!` types, and each keeper's `module.toml`. - -use alloy_primitives::{B256, b256}; - -/// One on-chain event surface: the canonical Solidity signature and -/// its keccak256 topic-0. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct EventAbi { - /// Canonical Solidity event signature. - pub signature: &'static str, - /// keccak256 of [`Self::signature`]: the log's topic-0. - pub topic0: B256, -} - -/// `ComposableCoW.ConditionalOrderCreated`. -pub const CONDITIONAL_ORDER_CREATED: EventAbi = EventAbi { - signature: "ConditionalOrderCreated(address,(address,bytes32,bytes))", - topic0: b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), -}; - -/// `CoWSwapOnchainOrders.OrderPlacement` (EthFlow). -pub const ORDER_PLACEMENT: EventAbi = EventAbi { - signature: "OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,\ - uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)", - topic0: b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), -}; - -/// Every event surface the keepers decode. -pub const ALL: &[EventAbi] = &[CONDITIONAL_ORDER_CREATED, ORDER_PLACEMENT]; - -#[cfg(test)] -mod tests { - use alloy_primitives::keccak256; - use alloy_sol_types::SolEvent; - use cowprotocol::{CoWSwapOnchainOrders, ComposableCoW}; - - use super::*; - - #[test] - fn topic0_is_keccak_of_signature() { - for abi in ALL { - assert_eq!(abi.topic0, keccak256(abi.signature), "{}", abi.signature); - } - } - - #[test] - fn matches_the_sol_decoder_types() { - assert_eq!( - ComposableCoW::ConditionalOrderCreated::SIGNATURE, - CONDITIONAL_ORDER_CREATED.signature, - ); - assert_eq!( - ComposableCoW::ConditionalOrderCreated::SIGNATURE_HASH, - CONDITIONAL_ORDER_CREATED.topic0, - ); - assert_eq!( - CoWSwapOnchainOrders::OrderPlacement::SIGNATURE, - ORDER_PLACEMENT.signature, - ); - assert_eq!( - CoWSwapOnchainOrders::OrderPlacement::SIGNATURE_HASH, - ORDER_PLACEMENT.topic0, - ); - } - - #[test] - fn wit_package_of_record_pins_every_surface() { - let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); - let flat: String = wit - .lines() - .map(|l| l.trim().trim_start_matches("/// ")) - .collect(); - for abi in ALL { - assert!( - flat.contains(abi.signature), - "cow-events.wit must pin the signature {}", - abi.signature, - ); - assert!( - flat.contains(&format!("{:#x}", abi.topic0)), - "cow-events.wit must pin the topic-0 {:#x}", - abi.topic0, - ); - } - } - - /// Layering gate: no generic WIT package references `shepherd:cow`. - #[test] - fn generic_wit_packages_never_reference_shepherd_cow() { - let wit_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); - for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { - let pkg = pkg.expect("wit dir entry").path(); - if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { - continue; - } - for file in std::fs::read_dir(&pkg).expect("wit package dir") { - let path = file.expect("wit package entry").path(); - let text = std::fs::read_to_string(&path).expect("read wit file"); - assert!( - !text.contains("shepherd:cow"), - "{} references shepherd:cow", - path.display(), - ); - } - } - } -} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs deleted file mode 100644 index 8909134c..00000000 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! CoW Protocol bridging. -//! -//! ABI decoding helpers, the orderbook error surface, and [`run()`] - -//! the poll/submit composition over the keeper stores, submitting -//! through the typed [`CowClient`] on the `videre:venue/client` seam. -//! The chain-edge order projections live in the `cow-venue` `assembly` -//! slice (the venue adapter owns them) and are re-exported here. -//! -//! The poll seam is the structured -//! [`Verdict`](composable_cow::Verdict), carried by the -//! `composable-cow` keeper crate together with the quarantined revert -//! decoding; only orderbook concerns live here. -//! -//! The codec submodules stay purely host-neutral: helpers take -//! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can -//! be unit-tested without wit-bindgen scaffolding and re-used -//! unchanged by TWAP, EthFlow, and future strategy modules. The -//! keeper run is generic over the host traits and the venue transport -//! alone; [`CowApiTransport`] carries it over the legacy -//! `shepherd:cow/cow-api` import until the module worlds flip. - -pub mod error; -pub mod events; -pub mod run; -pub mod transport; - -/// Chain-edge order assembly, re-exported from the `cow-venue` -/// `assembly` slice the venue adapter owns. -pub use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; -pub use error::{ - CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, - classify_submit_error, is_already_submitted, -}; -pub use run::run; -pub use transport::CowApiTransport; - -/// The venue-neutral intent body types and their borsh `IntentBody` -/// codec, re-exported from the `cow-venue` default slice, plus the -/// typed CoW venue client. The shim keeps this path stable while the -/// module ports move off the legacy surface. -pub use cow_venue::{ - BuyToken, BuyTokenDestination, CowClient, CowIntent, CowIntentBody, CowVenue, OrderBody, - OrderBuilder, OrderKind, OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, -}; - -use nexum_sdk::host::Host; - -/// `shepherd:cow/cow-api` - the legacy orderbook submission path, -/// retiring. The keeper [`run()`] submits through the typed -/// [`CowClient`]; [`CowApiTransport`] bridges it onto this seam until -/// the module worlds flip to `videre:venue/client`. -pub trait CowApiHost { - /// Submit an `OrderCreation` JSON body. The host returns the - /// canonical order UID on success. A rejection surfaces as a typed - /// [`CowApiError::Rejected`]; classify it with - /// [`classify_api_error`]. - fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; - - /// REST-style request against the CoW Protocol orderbook for the - /// given chain. The host routes to the correct base URL - /// (`https://api.cow.fi//api/v1/...`). Returns the raw - /// response body. Strategies that need a typed surface should - /// wrap this in an SDK helper. - /// - /// `method` is `"GET" | "POST" | "PUT" | "DELETE"`. - /// `path` is the absolute orderbook path beginning with `/api/v1`. - /// `body` is an optional JSON request body (only used for POST/PUT). - /// - /// A non-2xx reply surfaces as [`CowApiError::Http`]; callers - /// distinguish "orderbook does not know this resource" from a - /// genuine upstream failure by matching `http.status == 404`. - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: Option<&str>, - ) -> Result; -} - -/// Host bound for strategies that reach the CoW Protocol orderbook. -pub trait CowHost: Host + CowApiHost {} -impl CowHost for T {} diff --git a/crates/shepherd-sdk/src/cow/transport.rs b/crates/shepherd-sdk/src/cow/transport.rs deleted file mode 100644 index ece99f70..00000000 --- a/crates/shepherd-sdk/src/cow/transport.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Transitional venue transport over the legacy `shepherd:cow/cow-api` -//! seam. -//! -//! [`CowApiTransport`] implements the videre [`VenueTransport`] -//! contract by assembling the orderbook `OrderCreation` from the -//! decoded [`CowIntentBody`] and driving -//! [`CowApiHost::submit_order`], so the keeper [`run`](super::run()) -//! submits through the typed [`CowClient`](super::CowClient) while -//! module worlds still import the legacy host extension. Deleted when -//! the worlds flip to `videre:venue/client`. - -use alloy_primitives::{Address, hex}; -use cow_venue::assembly; -use cow_venue::body::{CowIntent, CowIntentBody}; -use cowprotocol::Chain; -use nexum_sdk::host::Fault; -use nexum_sdk::keeper::RetryAction; -use videre_sdk::client::sealed::SealedTransport; -use videre_sdk::{ - IntentBody as _, IntentStatus, Quotation, SubmitOutcome, VenueFault, VenueId, VenueTransport, -}; - -use super::{CowApiError, CowApiHost, classify_api_error, is_already_submitted}; - -/// The `videre:venue/client` verbs carried over the legacy -/// `shepherd:cow/cow-api` import: submit only, pre-bound to one chain's -/// orderbook. Quote, status, and cancel have no legacy submission-path -/// counterpart and refuse as `unsupported`. -pub struct CowApiTransport<'h, H> { - host: &'h H, - chain_id: u64, -} - -impl<'h, H: CowApiHost> CowApiTransport<'h, H> { - /// Bind the legacy seam to one chain's orderbook. - #[must_use] - pub const fn new(host: &'h H, chain_id: u64) -> Self { - Self { host, chain_id } - } -} - -impl SealedTransport for CowApiTransport<'_, H> {} - -impl VenueTransport for CowApiTransport<'_, H> { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - Err(VenueFault::Unsupported) - } - - async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { - let CowIntentBody::V1(intent) = - CowIntentBody::from_bytes(&body).map_err(|e| VenueFault::InvalidBody(e.to_string()))?; - // The legacy seam posts EIP-1271 only; the pre-sign flow needs - // the adapter. - let CowIntent::Signed(signed) = intent else { - return Err(VenueFault::Unsupported); - }; - let order = assembly::body_to_order_data(&signed.order); - let owner = Address::from(signed.owner); - let creation = assembly::build_order_creation(&order, &signed.signature, owner) - .map_err(|e| VenueFault::InvalidBody(e.to_string()))?; - let json = serde_json::to_vec(&creation) - .map_err(|e| VenueFault::Unavailable(format!("order encode failed: {e}")))?; - match self.host.submit_order(self.chain_id, &json) { - Ok(uid) => Ok(SubmitOutcome::Accepted(receipt_bytes(&uid))), - // Already-held is success wearing an error status; the - // receipt is the client-derived UID (empty on a chain the - // SDK cannot derive for). - Err(CowApiError::Rejected(r)) if is_already_submitted(&r) => { - let receipt = Chain::try_from(self.chain_id) - .map(|chain| { - assembly::order_uid(chain, &order, owner) - .as_slice() - .to_vec() - }) - .unwrap_or_default(); - Ok(SubmitOutcome::Accepted(receipt)) - } - Err(err) => Err(venue_fault(&err)), - } - } - - async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { - Err(VenueFault::Unsupported) - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - Err(VenueFault::Unsupported) - } -} - -/// The server UID at its wire spelling; a non-hex receipt rides through -/// as raw bytes rather than failing an accepted submit. -fn receipt_bytes(uid: &str) -> Vec { - hex::decode(uid).unwrap_or_else(|_| uid.as_bytes().to_vec()) -} - -/// Project a legacy submission failure onto the venue fault the typed -/// client reports, mirroring the adapter: throttles keep their hint, -/// host and server failures stay retryable, and only a structured -/// rejection, folded through the shipped classification table, carries -/// a permanent venue verdict. -fn venue_fault(err: &CowApiError) -> VenueFault { - match err { - CowApiError::Fault(Fault::RateLimited(limit)) => VenueFault::RateLimited { - retry_after_ms: limit.retry_after_ms, - }, - CowApiError::Fault(Fault::Timeout) => VenueFault::Timeout, - // Any other host fault is infrastructure, not a venue verdict: - // it stays retryable so an unprovisioned capability or unknown - // chain never drops a still-valid order. - CowApiError::Fault(fault) => VenueFault::Unavailable(fault.to_string()), - CowApiError::Http(http) if http.status == 429 => VenueFault::RateLimited { - retry_after_ms: None, - }, - CowApiError::Http(http) => { - VenueFault::Unavailable(format!("orderbook http {}", http.status)) - } - CowApiError::Rejected(rejection) => { - let detail = format!("{}: {}", rejection.error_type, rejection.description); - match classify_api_error(rejection) { - RetryAction::TryNextBlock => VenueFault::Unavailable(detail), - RetryAction::Backoff { seconds } => VenueFault::RateLimited { - retry_after_ms: Some(seconds.saturating_mul(1000)), - }, - _ => VenueFault::Denied(detail), - } - } - } -} - -#[cfg(test)] -mod tests { - use nexum_sdk::host::RateLimit; - use videre_sdk::keeper::retry_action; - - use super::super::{HttpFailure, OrderRejection}; - use super::*; - - fn rejected(error_type: &str) -> CowApiError { - CowApiError::Rejected(OrderRejection { - status: 400, - error_type: error_type.into(), - description: "d".into(), - data: None, - }) - } - - #[test] - fn legacy_failures_project_onto_the_venue_fault_by_shape() { - assert!(matches!( - venue_fault(&rejected("InsufficientFee")), - VenueFault::Unavailable(detail) if detail.contains("InsufficientFee") - )); - assert!(matches!( - venue_fault(&rejected("TooManyLimitOrders")), - VenueFault::RateLimited { - retry_after_ms: Some(30_000) - } - )); - assert!(matches!( - venue_fault(&rejected("InvalidSignature")), - VenueFault::Denied(detail) if detail.contains("InvalidSignature") - )); - assert!(matches!( - venue_fault(&CowApiError::Fault(Fault::RateLimited(RateLimit { - retry_after_ms: Some(2_500), - }))), - VenueFault::RateLimited { - retry_after_ms: Some(2_500) - } - )); - assert!(matches!( - venue_fault(&CowApiError::Fault(Fault::Timeout)), - VenueFault::Timeout - )); - assert!(matches!( - venue_fault(&CowApiError::Http(HttpFailure { - status: 429, - body: None, - })), - VenueFault::RateLimited { - retry_after_ms: None - } - )); - assert!(matches!( - venue_fault(&CowApiError::Http(HttpFailure { - status: 502, - body: None, - })), - VenueFault::Unavailable(_) - )); - } - - #[test] - fn host_faults_stay_retryable_and_never_drop_the_watch() { - for fault in [ - Fault::Unsupported("cow-api not provisioned".into()), - Fault::Denied("allowlist".into()), - Fault::Unavailable("rpc down".into()), - Fault::Internal("host bug".into()), - Fault::InvalidInput("mangled".into()), - ] { - let projected = venue_fault(&CowApiError::Fault(fault)); - assert!(matches!(projected, VenueFault::Unavailable(_))); - assert_eq!(retry_action(&projected), RetryAction::TryNextBlock); - } - assert_eq!( - retry_action(&venue_fault(&CowApiError::Fault(Fault::Timeout))), - RetryAction::TryNextBlock - ); - assert_eq!( - retry_action(&venue_fault(&CowApiError::Fault(Fault::RateLimited( - RateLimit { - retry_after_ms: Some(2_500), - } - )))), - RetryAction::Backoff { seconds: 3 } - ); - } - - #[test] - fn server_uid_decodes_to_wire_bytes_with_a_raw_fallback() { - assert_eq!(receipt_bytes("0xc0ffee"), vec![0xC0, 0xFF, 0xEE]); - assert_eq!(receipt_bytes("not-hex"), b"not-hex".to_vec()); - } -} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs deleted file mode 100644 index 2201ad35..00000000 --- a/crates/shepherd-sdk/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! # shepherd-sdk -//! -//! CoW-domain SDK for Shepherd modules, layered on the generic -//! [`nexum_sdk`]. Everything host-neutral (the host trait seam, config -//! and address parsing, `eth_call` plumbing, HTTP, the tracing facade) -//! lives in `nexum-sdk`; this crate carries only the CoW Protocol -//! surface. Modules import both crates directly. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use shepherd_sdk::prelude::*` imports cowprotocol's -//! order / signing surface ([`OrderCreation`], -//! [`OrderData`], [`OrderUid`], [`OrderKind`], [`Signature`], -//! [`Chain`], [`GPv2OrderData`], [`EMPTY_APP_DATA_JSON`]). -//! -//! - [`cow`] - [`run`], the poll -> outcome -> gate/journal/submit -//! composition over the keeper stores, dispatching the structured -//! [`Verdict`] from the `composable-cow` keeper crate and submitting -//! through the typed [`CowClient`] on the `videre:venue/client` -//! seam; `GPv2OrderData` -> `OrderData` bridging -//! ([`gpv2_to_order_data`]) and the classifiers mapping submit -//! failures into the keeper [`RetryAction`]. The legacy -//! [`CowApiHost`] trait for `shepherd:cow/cow-api` (and the -//! [`CowHost`] bound over the core [`Host`]) stays for the read -//! paths and the transitional [`CowApiTransport`] bridge. -//! -//! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - -//! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: -//! the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. -//! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. Trade-off -//! documented in ADR-0006 / ADR-0007 - the SDK stays on the guest -//! side, neutral to which world the module exports. -//! -//! [`OrderCreation`]: cowprotocol::OrderCreation -//! [`OrderData`]: cowprotocol::OrderData -//! [`OrderUid`]: cowprotocol::OrderUid -//! [`OrderKind`]: cowprotocol::OrderKind -//! [`Signature`]: cowprotocol::Signature -//! [`Chain`]: cowprotocol::Chain -//! [`GPv2OrderData`]: cowprotocol::GPv2OrderData -//! [`EMPTY_APP_DATA_JSON`]: cowprotocol::EMPTY_APP_DATA_JSON -//! [`CowApiHost`]: cow::CowApiHost -//! [`CowHost`]: cow::CowHost -//! [`CowClient`]: cow::CowClient -//! [`CowApiTransport`]: cow::CowApiTransport -//! [`Host`]: nexum_sdk::host::Host -//! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`Verdict`]: composable_cow::Verdict -//! [`RetryAction`]: cow::RetryAction -//! [`run`]: cow::run() - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] - -pub mod cow; -pub mod prelude; -pub mod wit_bindgen_macro; - -#[cfg(test)] -mod proptests; - -#[cfg(test)] -mod tests { - //! Locks the prelude's surface - the build itself proves the - //! re-exports compile against both `wasm32-wasip2` and the - //! host target. - - use crate::prelude::*; - - #[test] - fn prelude_re_exports_resolve() { - let _kind: OrderKind = OrderKind::Sell; - let _chain: Chain = Chain::Sepolia; - assert_eq!(EMPTY_APP_DATA_JSON, "{}"); - } -} diff --git a/crates/shepherd-sdk/src/prelude.rs b/crates/shepherd-sdk/src/prelude.rs deleted file mode 100644 index 6adbc23b..00000000 --- a/crates/shepherd-sdk/src/prelude.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Bulk-imports the CoW Protocol primitives every Shepherd module uses -//! on every other line. `use shepherd_sdk::prelude::*` covers -//! cowprotocol's order and signing surface; the alloy address / hash / -//! numeric types come from `nexum_sdk::prelude::*` alongside it. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, …) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). The prelude -//! covers only the host-neutral protocol layer that the SDK helpers -//! consume by value. - -pub use cowprotocol::{ - BuyTokenDestination, - // App-data + chain + domain identity. - Chain, - DomainSeparator, - EMPTY_APP_DATA_HASH, - EMPTY_APP_DATA_JSON, - // Settlement primitives carried in event payloads and order bodies. - GPv2OrderData, - // Orderbook submission body + the parts every assembly path touches. - OrderCreation, - OrderData, - OrderKind, - // Order identity. - OrderUid, - SellTokenSource, - // Signing. - Signature, - SigningScheme, -}; diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs deleted file mode 100644 index c300c65f..00000000 --- a/crates/shepherd-sdk/src/proptests.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Property-based regression tests for the CoW-domain codec surface. -//! Lives behind `#[cfg(test)]` so neither the wasm32-wasip2 builds nor -//! downstream consumers pay the proptest dep cost. -//! -//! Covered here: -//! -//! - `gpv2_to_order_data` marker mapping (no-panic guard). -//! -//! The generic properties (`eth_call` round-trip, `scale_decimal`) -//! live in `nexum-sdk`; the revert-decode guard lives in -//! `composable-cow`. - -#![cfg(test)] - -use proptest::prelude::*; - -proptest! { - /// `gpv2_to_order_data` is exhaustive over the marker enum; - /// fuzzing the inputs as raw u8 (not the typed enum) is the only - /// way to exercise the fallback path. Strategy: feed any 4 marker - /// bytes (kind + sellTokenSource + buyTokenDestination + - /// partiallyFillable) and assert either `Some` (recognised) or - /// `None` (unknown marker), never a panic. - #[test] - fn gpv2_marker_dispatch_never_panics( - kind in any::(), - sell in any::(), - buy in any::(), - fillable in any::(), - ) { - let _ = (kind, sell, buy, fillable); - // We do not call `gpv2_to_order_data` here because building - // a `GPv2OrderData` requires a full alloy-sol-encoded struct - // and the generators for that are extensive. The property - // test for the marker dispatch lives in `cow_venue::assembly` - // example-based; this proptest stands in as a no-panic - // guard for the inputs the strategy ABI can produce. - } -} diff --git a/crates/shepherd-sdk/src/wit_bindgen_macro.rs b/crates/shepherd-sdk/src/wit_bindgen_macro.rs deleted file mode 100644 index ff4b7e5d..00000000 --- a/crates/shepherd-sdk/src/wit_bindgen_macro.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter for -//! CoW modules: the generic adapter plus the `CowApiHost` impl. -//! -//! Layers on `nexum_sdk::bind_host_via_wit_bindgen!`, which emits the -//! core adapter (`WitBindgenHost`, the six core host trait impls, the -//! fault and level `From` impls, and the tracing wiring). This macro -//! invokes it and adds the [`CowApiHost`](crate::cow::CowApiHost) -//! impl over the `shepherd:cow/cow-api` import shims. -//! -//! The macro assumes the module compiles against `shepherd:cow/shepherd` -//! with `wit_bindgen::generate!({ ..., generate_all })`, so both the -//! `nexum::host::*` and `shepherd::cow::cow_api` output paths are in -//! scope at the call site, and that the module crate also depends on -//! `nexum-sdk` (the expansion names `::nexum_sdk` directly). -//! -//! Usage in a module's `lib.rs`: -//! -//! ```ignore -//! wit_bindgen::generate!({ /* ... */ }); -//! shepherd_sdk::bind_cow_host_via_wit_bindgen!(); -//! // Everything the generic macro emits is in scope, plus the -//! // `CowApiHost` impl for `WitBindgenHost`. -//! ``` - -/// Generate the generic `WitBindgenHost` adapter plus the `CowApiHost` -/// impl. See module docs. -#[macro_export] -macro_rules! bind_cow_host_via_wit_bindgen { - () => { - ::nexum_sdk::bind_host_via_wit_bindgen!(); - - /// Lift the per-cdylib wit-bindgen `cow-api-error` into the - /// SDK's [`CowApiError`]( - /// $crate::cow::CowApiError), projecting each case onto the - /// host-neutral mirror. - fn convert_cow_err(e: shepherd::cow::cow_api::CowApiError) -> $crate::cow::CowApiError { - match e { - shepherd::cow::cow_api::CowApiError::Fault(f) => { - $crate::cow::CowApiError::Fault(::core::convert::Into::into(f)) - } - shepherd::cow::cow_api::CowApiError::Http(h) => { - $crate::cow::CowApiError::Http($crate::cow::HttpFailure { - status: h.status, - body: h.body, - }) - } - shepherd::cow::cow_api::CowApiError::Rejected(r) => { - $crate::cow::CowApiError::Rejected($crate::cow::OrderRejection { - status: r.status, - error_type: r.error_type, - description: r.description, - data: r.data, - }) - } - } - } - - impl $crate::cow::CowApiHost for WitBindgenHost { - fn submit_order( - &self, - chain_id: u64, - body: &[u8], - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::submit_order(chain_id, body).map_err(convert_cow_err) - } - - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - body: ::core::option::Option<&str>, - ) -> ::core::result::Result<::std::string::String, $crate::cow::CowApiError> { - shepherd::cow::cow_api::request(chain_id, method, path, body) - .map_err(convert_cow_err) - } - } - }; -} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml index 2b5ed588..1de15410 100644 --- a/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -15,7 +15,6 @@ path = "src/main.rs" [dependencies] nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } -shepherd-cow-host = { path = "../shepherd-cow-host" } videre-host = { path = "../videre-host" } anyhow.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index f7c629cd..b35ad0ee 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,7 +1,8 @@ -//! The `shepherd` binary: the cow composition root. Binds the reference -//! lattice with the cow-api extension payload in the `Ext` slot, registers -//! the videre venue platform, and hands it all to the generic launcher; -//! the engine itself stays venue- and cow-free. +//! The `shepherd` binary: the cow composition root. Boots the +//! reference backends, registers the videre venue platform, and hands +//! it all to the generic launcher; CoW enters only as the bundled +//! `cow-venue` adapter component, and the engine itself stays venue- +//! and cow-free. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -10,56 +11,35 @@ use std::sync::Arc; use nexum_runtime::addons::{AddOns, PrometheusAddOn}; use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ - ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, + ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, }; use nexum_runtime::host::extension::Extension; -use nexum_runtime::host::local_store_redb::LocalStore; -use nexum_runtime::host::provider_pool::ProviderPool; -use nexum_runtime::preset::Runtime; -use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; +use nexum_runtime::preset::{CoreRuntime, Runtime}; -/// The reference lattice: the core backends with the cow-api payload in -/// the `Ext` slot. -#[derive(Debug, Clone, Copy, Default)] -struct ReferenceTypes; - -impl nexum_runtime::sealed::SealedRuntimeTypes for ReferenceTypes {} - -impl RuntimeTypes for ReferenceTypes { - type Chain = ProviderPool; - type Store = LocalStore; - type Ext = ReferenceExt; -} - -/// The cow preset: reference backends, the videre venue platform, the -/// cow-api extension, and the Prometheus add-on. +/// The cow preset: the reference core backends with the videre venue +/// platform and the Prometheus add-on. #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; impl nexum_runtime::sealed::SealedRuntime for ShepherdRuntime {} impl Runtime for ShepherdRuntime { - type Types = ReferenceTypes; + type Types = CoreRuntime; type ChainBuilder = ProviderPoolBuilder; type StoreBuilder = LocalStoreBuilder; - type ExtBuilder = ReferenceExtBuilder; + type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components( - self, - ) -> ComponentsBuilder { - ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ReferenceExtBuilder) + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } fn add_ons(&self) -> AddOns { vec![Box::new(PrometheusAddOn)] } - fn extensions(&self, config: &EngineConfig) -> Vec>> { - vec![ - Arc::new(videre_host::platform(config)), - extension::(), - ] + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![Arc::new(videre_host::platform(config))] } } diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 37493689..6586e26a 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,10 +1,16 @@ --- -status: proposed +status: superseded implemented-in: nullislabs/shepherd#8 --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` +> **Superseded by the videre venue-adapter architecture.** The +> `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend +> are retired: orderbook submission and status ride the `cow-venue` +> adapter component over `wasi:http`, driven through the +> `videre:venue/client` pool seam. + ## Context `shepherd:cow/cow-api` exposes two operations: a generic REST passthrough (`request`) and a typed order submission (`submit-order`). Either could be implemented with raw `reqwest` against `api.cow.fi/{slug}/api/v1`, but the published `cowprotocol` crate already ships an `OrderBookApi` client that knows the chain-specific base URL, the canonical paths, and the `post_order` codec. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index a76a9faa..43600c13 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -1,9 +1,15 @@ --- -status: proposed +status: superseded --- # TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +> **Superseded by the videre venue-adapter architecture.** The +> strategies-as-guest-modules line holds, but the protocol seam it +> assigned to `shepherd:cow/cow-api` is retired: modules submit typed +> intent bodies through the `videre:venue/client` pool seam and the +> `cow-venue` adapter owns the orderbook edge. + ## Context TWAP (over ComposableCoW) and EthFlow are the two CoW workflows the M2 grant ships modules for. The natural-seeming approach is to add `shepherd:cow/twap` and `shepherd:cow/ethflow` WIT interfaces that the host implements on top of `cowprotocol` crate primitives, so modules would call `twap.poll-and-submit(...)` and `ethflow.submit-from-log(...)` as host functions. This ADR rejects that direction. diff --git a/engine.load.toml b/engine.load.toml index 44924b49..4f8072b2 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -7,8 +7,8 @@ # # Differences vs engine.e2e.toml: # - chain points at the local Anvil fork on ws://localhost:8545 -# - [extensions.cow.orderbook_urls] points at tools/orderbook-mock -# (no live cow.fi) +# - the cow adapter's load-variant manifest points the orderbook at +# tools/orderbook-mock (no live cow.fi) # - state_dir is per-run (./data/load) so successive runs do not # inherit local-store rows from each other # - log level is debug for the supervisor-dispatch surface so the @@ -33,18 +33,13 @@ bind_addr = "127.0.0.1:9100" [chains.11155111] rpc_url = "ws://localhost:8545" -# Point the cow-api extension at tools/orderbook-mock. -[extensions.cow.orderbook_urls] -11155111 = "http://localhost:9999" - [[modules]] path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Load-variant manifest: Sepolia chain id with the -# orderbook re-pointed at tools/orderbook-mock, matching -# [extensions.cow.orderbook_urls] above. +# orderbook re-pointed at tools/orderbook-mock. [[adapters]] path = "./target/wasm32-wasip2/release/cow_venue.wasm" manifest = "./crates/cow-venue/module.load.toml" diff --git a/engine.m3.toml b/engine.m3.toml index d5fb7e69..fc21fefe 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -3,7 +3,7 @@ # Boots the 3 M3 example modules (price-alert + balance-tracker + # stop-loss) against Sepolia. The 3 modules exercise the full SDK # helper surface (chain::request via Chainlink read, local-store -# diffing, cow-api submit with PreSign). +# diffing, pool submit through the cow adapter with PreSign). # # Usage: # just run-m3 @@ -11,7 +11,8 @@ # cargo build -p price-alert --target wasm32-wasip2 --release # cargo build -p balance-tracker --target wasm32-wasip2 --release # cargo build -p stop-loss --target wasm32-wasip2 --release -# cargo run -p nexum-cli -- --engine-config engine.m3.toml +# cargo build -p cow-venue --features adapter --target wasm32-wasip2 --release +# cargo run -p shepherd -- --engine-config engine.m3.toml [engine] # Separate from data/m2 and the M1 example state. @@ -34,3 +35,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter stop-loss submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain the oracle is read on. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/extensions.toml b/extensions.toml index 6a280f02..e836e923 100644 --- a/extensions.toml +++ b/extensions.toml @@ -7,7 +7,3 @@ [extensions.client] import = "videre:venue/client@0.1.0" packages = ["videre-value-flow", "videre-types", "videre-venue"] - -[extensions.cow-api] -import = "shepherd:cow/cow-api@0.1.0" -packages = ["shepherd-cow"] diff --git a/justfile b/justfile index 1d22c5c2..019fb0a6 100644 --- a/justfile +++ b/justfile @@ -57,7 +57,7 @@ build-m3: # (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m3: build-m3 build-engine +run-m3: build-m3 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs # Build the http-probe example module (wasi:http fetch + allowlist diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml index 0dab8781..f0b92be4 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -4,22 +4,23 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a pre-signed CoW order when price drops below a configured trigger, dedups via submitted:{uid}." +description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a CoW order intent through the venue registry when price drops below a configured trigger, dedups via the venue-and-body intent-id." [lib] crate-type = ["cdylib"] [dependencies] +cow-venue = { path = "../../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } +videre-sdk = { path = "../../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } +# The chain-edge projections back the pinned-UID regression test. +cow-venue = { path = "../../../crates/cow-venue", features = ["client", "assembly"] } nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } # Only used by tests in `strategy.rs` to encode a synthetic oracle # return body; the production code uses `nexum_sdk::chain::chainlink`. diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml index 58065270..b8387417 100644 --- a/modules/examples/stop-loss/module.toml +++ b/modules/examples/stop-loss/module.toml @@ -1,7 +1,7 @@ # stop-loss example module: watches a Chainlink oracle and submits a -# CoW order when the price drops below the configured trigger. -# Demonstrates eth_call + OrderCreation + cow-api submit + local-store -# dedup, the full M3 SDK surface. +# CoW order intent through the venue registry when the price drops below the +# configured trigger. Demonstrates eth_call + the typed venue client + +# local-store dedup. [module] name = "stop-loss" @@ -9,10 +9,16 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -required = ["logging", "chain", "local-store", "cow-api"] +# - logging -> structured runtime logs +# - chain -> eth_call into the Chainlink aggregator +# - local-store -> submitted: / dropped: dedup markers +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "chain", "local-store", "client"] optional = [] [capabilities.http] +# All outbound HTTP is the cow adapter's; the module makes no direct +# `http` calls. allow = [] # --- subscriptions ---------------------------------------------------- @@ -21,6 +27,11 @@ allow = [] kind = "block" chain_id = 11155111 # Sepolia +# The one body-schema version this module encodes; install refuses the +# module unless every installed venue adapter decodes it. +[venue] +body_version = 1 + # --- config ----------------------------------------------------------- [config] @@ -35,14 +46,14 @@ decimals = "8" # on the first block. trigger_price = "2000.00" # Order parameters. The owner pre-signs via GPv2Signing.setPreSignature -# (on-chain, outside this module); the module submits the body with -# Signature::PreSign on trigger. +# (on-chain, outside this module); the cow adapter posts the unsigned +# body pre-sign on trigger. # # E2E run pinning: test EOA on Sepolia with 0.05 ETH # balance. Without a pre-sign + a WETH wrap the orderbook will reject -# with TransferSimulationFailed which the SDK classifies as -# TryNextBlock — that itself is a valid terminal marker (`backoff:` -# write to local-store) and proves the full submit path E2E. +# with TransferSimulationFailed, which classifies as retry-next-block; +# that itself is a valid terminal marker and proves the full submit +# path E2E. owner = "0x7bF140727D27ea64b607E042f1225680B40ECa6A" # WETH9 Sepolia (`wss://sepolia.etherscan.io/token/0xfff9976782d46cc05630d1f6ebab18b2324d6b14`). sell_token = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs index 5e203753..fa226ed9 100644 --- a/modules/examples/stop-loss/src/lib.rs +++ b/modules/examples/stop-loss/src/lib.rs @@ -1,51 +1,43 @@ //! # stop-loss (example Shepherd module) //! //! Watches a Chainlink price oracle on every block. When the price -//! drops at or below `trigger_price`, the module submits a pre-signed -//! CoW order using the parameters from `module.toml::[config]` and -//! persists `submitted:{uid}` to dedup re-poll attempts. The owner is -//! expected to have called `GPv2Signing.setPreSignature` on-chain -//! ahead of the trigger so the orderbook accepts the submission. +//! drops at or below `trigger_price`, the module submits a CoW order +//! intent through the venue registry using the parameters from +//! `module.toml::[config]` and persists a `submitted:` marker to dedup +//! re-poll attempts. The cow adapter posts the unsigned order +//! pre-sign; the owner is expected to call +//! `GPv2Signing.setPreSignature` on-chain ahead of the trigger so the +//! orderbook activates the submission. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. -//! -//! Same recipe as `price-alert` - the wit-bindgen adapter -//! is intentionally mechanical and is a candidate for a future -//! declarative macro in the SDK. - +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. + +// wit_bindgen::generate! expands to host-import shims whose arity +// matches the WIT signatures, which can exceed clippy's +// too-many-arguments threshold. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../../wit/nexum-host", - "../../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; -use nexum::host::types; - -// `WitBindgenHost` and the fault and level `From` impls are generated -// below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); +use cow_venue::CowClient; static SETTINGS: OnceLock = OnceLock::new(); struct StopLoss; -impl Guest for StopLoss { +#[videre_sdk::keeper] +impl StopLoss { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config)?; @@ -60,15 +52,11 @@ impl Guest for StopLoss { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: nexum::host::types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg)?; - } + strategy::on_block(&WitBindgenHost, &CowClient::new(), block.chain_id, cfg)?; Ok(()) } } - -export!(StopLoss); diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index 8c97f74f..7891f40d 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -1,20 +1,19 @@ //! Pure stop-loss strategy logic. Reads an oracle, optionally submits -//! a pre-signed CoW order, dedups via local-store. Every interaction -//! with the world flows through the [`CowHost`] trait so the tests can -//! drive it against `shepherd_sdk_test::MockHost`. +//! a CoW order intent through the typed venue client, dedups via +//! local-store. Every interaction with the world flows through the +//! `nexum_sdk::host` trait seams and the videre [`VenueTransport`] +//! under the typed [`CowClient`], so tests drive it against +//! `nexum_sdk_test::MockHost` and a scripted transport. use alloy_primitives::I256; +use cow_venue::{BuyToken, CowClient, CowIntent, CowIntentBody, OrderBody, SellToken, intent_id}; use nexum_sdk::chain::chainlink::read_latest_answer; use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::Fault; -use nexum_sdk::prelude::{Address, Bytes, U256}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, is_already_submitted, -}; -use shepherd_sdk::prelude::{ - BuyTokenDestination, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderKind, - OrderUid, SellTokenSource, Signature, -}; +use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost, LoggingHost}; +use nexum_sdk::keeper::RetryAction; +use nexum_sdk::prelude::{Address, U256, hex}; +use videre_sdk::keeper::retry_action; +use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; /// Resolved configuration parsed from `module.toml::[config]`. #[derive(Clone, Debug)] @@ -23,7 +22,8 @@ pub struct Settings { pub oracle_address: Address, /// Trigger price scaled to the oracle's native units. pub trigger_price_scaled: I256, - /// Order owner (= EIP-712 signer / PreSign caller). + /// Order owner (= the `setPreSignature` caller and buy-token + /// receiver). pub owner: Address, /// Sell side of the order. pub sell_token: Address, @@ -40,10 +40,19 @@ pub struct Settings { /// React to a new block. /// /// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure). Only host-store errors bubble -/// up via `?` so the supervisor can surface persistence issues - all -/// other faults log and let the next block re-poll. -pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Result<(), Fault> { +/// (oracle RPC error, decode failure, venue refusal). Only host-store +/// errors bubble up via `?` so the supervisor can surface persistence +/// issues - all other faults log and let the next block re-poll. +pub fn on_block( + host: &H, + venue: &CowClient, + chain_id: u64, + settings: &Settings, +) -> Result<(), Fault> +where + H: ChainHost + LoggingHost + LocalStoreHost, + T: VenueTransport, +{ let price = match read_latest_answer(host, chain_id, settings.oracle_address, "stop-loss") { Some(p) => p, None => return Ok(()), // logged inside read_latest_answer @@ -58,140 +67,98 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res return Ok(()); } - // Compute UID up-front so we can dedup before paying for the - // serialise + submit round trip. - let (creation, uid) = match build_creation(chain_id, settings) { - Ok(x) => x, + // Derive the venue-and-body intent-id up-front so the dedup guard + // runs before any network work. + let intent = build_intent(settings); + let id = match intent_id(&intent) { + Ok(id) => id, Err(e) => { - tracing::warn!(error = %e, "stop-loss skipped (build)"); + tracing::error!(error = %e, "intent body encode failed"); return Ok(()); } }; - let uid_hex = format!("{uid}"); - let dedup_key = format!("submitted:{uid_hex}"); + let dedup_key = format!("submitted:{id}"); if host.get(&dedup_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss already submitted, idle"); + tracing::info!(intent = %id, "stop-loss already submitted, idle"); return Ok(()); } - let dropped_key = format!("dropped:{uid_hex}"); + let dropped_key = format!("dropped:{id}"); if host.get(&dropped_key)?.is_some() { - tracing::info!(uid = %uid_hex, "stop-loss previously dropped, idle"); + tracing::info!(intent = %id, "stop-loss previously dropped, idle"); return Ok(()); } - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!(error = %e, "OrderCreation JSON encode failed"); - return Ok(()); - } + let Some(outcome) = rt::complete(venue.submit(&intent)) else { + // Guest transports never suspend; retry on the next block. + tracing::error!("stop-loss submit future suspended; retrying next block"); + return Ok(()); }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - if server_uid != uid_hex { - tracing::warn!( - local = %uid_hex, - server = %server_uid, - "stop-loss uid drift", - ); - } - host.set(&format!("submitted:{server_uid}"), b"")?; + match outcome { + Ok(SubmitOutcome::Accepted(receipt)) => { + host.set(&dedup_key, b"")?; tracing::warn!( price = %price, trigger = %settings.trigger_price_scaled, - uid = %server_uid, + receipt = %hex::encode_prefixed(&receipt), "stop-loss TRIGGERED", ); } - Err(err) => { - // Success wearing an error status: the orderbook already - // holds this exact order. Record the receipt under the - // key the dedup guard reads, so the next block idles - // instead of re-posting until `validTo`. - if let CowApiError::Rejected(rejection) = &err - && is_already_submitted(rejection) - { - host.set(&dedup_key, b"")?; - tracing::info!( - uid = %uid_hex, - "stop-loss already on the orderbook; receipt recorded", - ); - return Ok(()); + Ok(SubmitOutcome::RequiresSigning(_)) => { + // The orderbook holds the order as signature-pending; the + // owner activates it with the on-chain `setPreSignature` + // call made ahead of the trigger. Journalled so the next + // block idles instead of re-posting. + host.set(&dedup_key, b"")?; + tracing::warn!( + price = %price, + trigger = %settings.trigger_price_scaled, + "stop-loss TRIGGERED (pre-sign pending on-chain activation)", + ); + } + Err(ClientError::Body(e)) => { + tracing::error!(error = %e, "intent body encode failed"); + } + Err(ClientError::Venue(fault)) => match retry_action(&fault) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + tracing::warn!(error = %fault, "stop-loss retry on next block"); } - // Only a typed orderbook rejection classifies; transport - // faults and raw HTTP errors are transient (retry next - // block) rather than a terminal drop. - let action = match &err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { - tracing::warn!(error = %err, "stop-loss retry on next block"); - } - RetryAction::Drop => { - host.set(&dropped_key, b"")?; - tracing::warn!(uid = %uid_hex, error = %err, "stop-loss dropped"); - } - // `RetryAction` is `#[non_exhaustive]`; treat unknown - // future variants like `TryNextBlock` rather than - // silently dropping the watch on an SDK bump. - _ => { - tracing::warn!( - error = %err, - "stop-loss unknown retry-action - retry on next block", - ); - } + RetryAction::Drop => { + host.set(&dropped_key, b"")?; + tracing::warn!(intent = %id, error = %fault, "stop-loss dropped"); } - } + // `RetryAction` is `#[non_exhaustive]`; treat unknown + // future variants like `TryNextBlock` rather than + // silently dropping the order on an SDK bump. + _ => { + tracing::warn!( + error = %fault, + "stop-loss unknown retry-action - retry on next block", + ); + } + }, + // `ClientError` is non-exhaustive; retry on the next block. + Err(e) => tracing::error!(error = %e, "stop-loss submit failed"), } Ok(()) } -// `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (review consolidation): the same flow + `Option` return shape now serves -// price-alert + stop-loss from the SDK, with `domain: &str` carrying the -// module label into the Warn log. - -/// Assemble the `OrderCreation` body + canonical UID from settings. -/// Uses `Signature::PreSign` so the module ships zero ECDSA - the -/// owner is expected to have called `GPv2Signing.setPreSignature` -/// on-chain ahead of the trigger. -fn build_creation(chain_id: u64, settings: &Settings) -> Result<(OrderCreation, OrderUid), Fault> { - let chain = Chain::try_from(chain_id).map_err(|_| { - Fault::Unsupported(format!("chain {chain_id} not supported by cowprotocol")) - })?; - let domain = chain.settlement_domain(); - let gpv2 = GPv2OrderData { - sellToken: settings.sell_token, - buyToken: settings.buy_token, - receiver: settings.owner, - sellAmount: settings.sell_amount, - buyAmount: settings.buy_amount, - validTo: settings.valid_to, - appData: cowprotocol::EMPTY_APP_DATA_HASH, - feeAmount: U256::ZERO, - kind: OrderKind::SELL, - partiallyFillable: false, - sellTokenBalance: SellTokenSource::ERC20, - buyTokenBalance: BuyTokenDestination::ERC20, - }; - let order_data = gpv2_to_order_data(&gpv2).ok_or_else(|| { - Fault::InvalidInput("GPv2OrderData carried an unknown enum marker".into()) - })?; - let uid = order_data.uid(&domain, settings.owner); - let creation = OrderCreation::new( - &order_data, - Signature::PreSign, - settings.owner, - EMPTY_APP_DATA_JSON.to_string(), - None, +/// Assemble the order intent from settings: an unsigned order the cow +/// adapter posts pre-sign. The owner receives the buy token and the +/// app-data hash pins the canonical empty document. +fn build_intent(settings: &Settings) -> CowIntentBody { + let order = OrderBody::sell( + SellToken(settings.sell_token.into_array()), + settings.sell_amount.to_be_bytes(), ) - .map_err(|e| Fault::InvalidInput(format!("cowprotocol rejected the body: {e}")))?; - // Silence the unused `Bytes` import on builds where `Signature:: - // PreSign` is the only signature variant we construct. - let _: Option = None; - Ok((creation, uid)) + .for_at_least( + BuyToken(settings.buy_token.into_array()), + settings.buy_amount.to_be_bytes(), + ) + .valid_to(settings.valid_to) + .receiver(settings.owner.into_array()) + .app_data(cowprotocol::EMPTY_APP_DATA_HASH.0) + .build(); + CowIntentBody::V1(CowIntent::Order(order)) } /// Parse `module.toml::[config]` into a typed [`Settings`]. @@ -266,19 +233,78 @@ fn config_err(e: ConfigError) -> Fault { #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::hex; use alloy_sol_types::SolCall; use nexum_sdk::Level; use nexum_sdk::chain::chainlink::AggregatorV3; use nexum_sdk::chain::eth_call_params; - use nexum_sdk::host::{ChainError, Fault}; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; - use shepherd_sdk_test::MockHost; + use nexum_sdk::host::ChainError; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{IntentStatus, Quotation, UnsignedTx, VenueFault, VenueId}; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted venue transport: one submit outcome per queued entry, + /// every submit recorded. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + fn client(venue: &MockVenue) -> CowClient<&MockVenue> { + CowClient::with_transport(venue) + } + fn settings_below(trigger_scaled: i128) -> Settings { Settings { oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" @@ -320,12 +346,11 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } - fn programmed_uid(settings: &Settings) -> String { - let (_creation, uid) = build_creation(SEPOLIA, settings).unwrap(); - format!("{uid}") + fn programmed_id(settings: &Settings) -> String { + intent_id(&build_intent(settings)).unwrap() } - /// Regression test pinning the OrderUid produced by the + /// Regression test pinning the orderbook UID derived from the /// E2E run's `modules/examples/stop-loss/module.toml` config so an /// operator can `setPreSignature(uid, true)` ahead of the run /// without re-deriving the UID from the EIP-712 / domain- @@ -353,8 +378,17 @@ mod tests { buy_amount: U256::from(20_000_000_000_000_000_000_u128), valid_to: u32::MAX, }; + let CowIntentBody::V1(CowIntent::Order(body)) = build_intent(&settings) else { + panic!("stop-loss emits an unsigned order intent"); + }; + let order = cow_venue::assembly::body_to_order_data(&body); + let uid = cow_venue::assembly::order_uid( + cowprotocol::Chain::try_from(SEPOLIA).unwrap(), + &order, + settings.owner, + ); assert_eq!( - programmed_uid(&settings), + format!("{uid}"), "0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff", ); } @@ -362,6 +396,7 @@ mod tests { #[test] fn idle_when_price_above_trigger() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(/*trigger*/ 250_000_000_000); program_oracle( &host, @@ -369,9 +404,9 @@ mod tests { Ok(oracle_response_json(300_000_000_000)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert_eq!( host.chain.call_count(), @@ -383,27 +418,28 @@ mod tests { #[test] fn triggers_and_submits_once_then_dedups() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, s.oracle_address, Ok(oracle_response_json(200_000_000_000)), ); - let uid = programmed_uid(&s); - host.cow_api.respond(Ok(uid.clone())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xAA; 56]))); // First block: submits. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); // Second block at the same price: dedup'd, no new submit. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); assert_eq!( host.chain.call_count(), 2, @@ -411,52 +447,43 @@ mod tests { ); } + /// The adapter posts the unsigned order pre-sign and asks for the + /// on-chain activation: the intent is journalled so the next block + /// idles instead of re-posting. #[test] - fn permanent_submit_error_marks_dropped() { + fn requires_signing_outcome_records_the_marker_and_idles() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, s.oracle_address, Ok(oracle_response_json(200_000_000_000)), ); + venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { + chain: SEPOLIA, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0x22], + }))); + + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - // Orderbook returns InvalidSignature - permanent per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); - - on_block(&host, SEPOLIA, &s).unwrap(); - let uid = programmed_uid(&s); + let id = programmed_id(&s); assert!( host.store .snapshot() - .contains_key(&format!("dropped:{uid}")) - ); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("submitted:{uid}")) + .contains_key(&format!("submitted:{id}")) ); - // Second block: dropped marker idles the loop. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); // no resubmit + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); } - /// A duplicate rejection is success wearing an error status: the - /// orderbook already holds the order (e.g. the local marker was - /// lost), so the receipt must be recorded and the next block must - /// idle instead of re-posting until `validTo`. #[test] - fn duplicate_rejection_records_receipt_and_idles() { + fn permanent_submit_error_marks_dropped() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -464,39 +491,29 @@ mod tests { Ok(oracle_response_json(200_000_000_000)), ); - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "DuplicatedOrder".into(), - description: "order already exists".into(), - data: None, - }))); - - on_block(&host, SEPOLIA, &s).unwrap(); + // A structured permanent refusal - `Denied` classifies as + // `Drop` in the videre retry table. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); - let uid = programmed_uid(&s); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{uid}")), - "the receipt must land under the key the dedup guard reads", - ); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + let id = programmed_id(&s); + assert!(host.store.snapshot().contains_key(&format!("dropped:{id}"))); assert!( !host .store .snapshot() - .contains_key(&format!("dropped:{uid}")), - "already-submitted must never mark the order dropped", + .contains_key(&format!("submitted:{id}")) ); - // Second block: the receipt idles the loop, no re-POST. - on_block(&host, SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 1); + // Second block: dropped marker idles the loop. + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); + assert_eq!(venue.submit_count(), 1); // no resubmit } #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -504,26 +521,21 @@ mod tests { Ok(oracle_response_json(200_000_000_000)), ); - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); - let (result, logs) = capture_tracing(|| on_block(&host, SEPOLIA, &s)); + let (result, logs) = capture_tracing(|| on_block(&host, &client(&venue), SEPOLIA, &s)); result.unwrap(); // No persistence flag - next block will retry. assert_eq!(host.store.len(), 0); - assert_eq!(host.cow_api.call_count(), 1, "the submit was attempted"); + assert_eq!(venue.submit_count(), 1, "the submit was attempted"); logs.expect_one(|e| e.level == Level::WARN && e.message.contains("retry on next block")); } #[test] fn oracle_rpc_error_is_warn_and_continue() { let host = MockHost::new(); + let venue = MockVenue::default(); let s = settings_below(250_000_000_000); program_oracle( &host, @@ -531,9 +543,9 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - on_block(&host, SEPOLIA, &s).unwrap(); + on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); assert_eq!(host.store.len(), 0); assert!(host.logging.contains("oracle eth_call failed")); } diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index acacdadb..53e29409 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,10 +9,9 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -composable-cow = { path = "../../crates/composable-cow" } +composable-cow = { path = "../../crates/composable-cow", features = ["sweep"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../crates/shepherd-sdk" } videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } @@ -23,4 +22,7 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Parses the shipped `module.toml` so the subscription parity test reads the +# real `event_signature` value rather than scanning the file text. +toml.workspace = true nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index d7f45ca2..b40033b2 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -27,9 +27,9 @@ allow = [] # ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = # keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), -# pinned in wit/shepherd-cow/cow-events.wit and parity-tested in -# strategy.rs. Both `address` and `event_signature` are pinned so the -# supervisor does not deliver unrelated logs to the module. +# parity-tested against the sol! decoder in strategy.rs. Both `address` +# and `event_signature` are pinned so the supervisor does not deliver +# unrelated logs to the module. [[subscription]] kind = "chain-log" chain_id = 11155111 diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 15affe60..ccd199b5 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -13,12 +13,12 @@ //! 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 through the pool, and retry dispatch live in -//! the shared composition (`shepherd_sdk::cow::run`). +//! journal, submission through the venue registry, and retry dispatch live in +//! the shared composition (`composable_cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; -use composable_cow::{LegacyRevertAdapter, Verdict}; +use composable_cow::{LegacyRevertAdapter, Verdict, run}; use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, @@ -27,7 +27,6 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{events, run}; use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. @@ -76,7 +75,7 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fa /// Poll entry: run the keeper over every gate-ready watch through the /// shared composition, submitting through the typed client onto the -/// pool. The block timestamp arrives in milliseconds; the tick carries +/// venue seam. The block timestamp arrives in milliseconds; the tick carries /// Unix seconds. pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> where @@ -93,10 +92,10 @@ where // ---- indexing path ---- -/// Topic-0 resolves from the `shepherd:cow/cow-events` package of -/// record before the ABI decode. +/// Topic-0 gates before the ABI decode; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { - if log.topics().first() != Some(&events::CONDITIONAL_ORDER_CREATED.topic0) { + if log.topics().first() != Some(&ConditionalOrderCreated::SIGNATURE_HASH) { return None; } let decoded = ConditionalOrderCreated::decode_log(&log.inner).ok()?; @@ -681,7 +680,7 @@ mod tests { assert_eq!( venue.submit_count(), 0, - "the pool must NOT be touched when submitted:{{intent_id}} already exists", + "the venue must NOT be touched when submitted:{{intent_id}} already exists", ); } @@ -718,7 +717,7 @@ mod tests { "exactly one eth_call to poll Ready" ); let submits = venue.submits(); - assert_eq!(submits.len(), 1, "exactly one pool submit"); + assert_eq!(submits.len(), 1, "exactly one venue submit"); let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") else { @@ -785,7 +784,7 @@ mod tests { }); } - /// The venue's throttle hint survives the pool seam: a rate-limited + /// The venue's throttle hint survives the venue seam: a rate-limited /// refusal backs the watch off on the epoch clock instead of /// hot-looping the submit every block. #[test] @@ -928,29 +927,29 @@ mod tests { }); } - /// Guard: the `sol!` decoder's topic-0 matches the - /// `shepherd:cow/cow-events` package of record. A typo or ABI - /// drift would silently miss every registration event. + /// The supervisor builds its log filter from this manifest's + /// `event_signature` (`nexum-runtime::supervisor` -> + /// `build_alloy_filter`), so a drift from the decoder topic-0 + /// subscribes to one topic and decodes another, and the module + /// silently sees nothing. Reads the chain-log subscription's parsed + /// value, so a stale hash in a comment cannot satisfy it. #[test] - fn topic0_matches_conditional_order_created_canonical_signature() { + fn manifest_topic0_matches_conditional_order_created_signature_hash() { + let manifest: toml::Value = + toml::from_str(include_str!("../module.toml")).expect("module.toml parses"); + let pinned = manifest["subscription"] + .as_array() + .expect("module.toml declares subscriptions") + .iter() + .find(|sub| sub.get("kind").and_then(toml::Value::as_str) == Some("chain-log")) + .and_then(|sub| sub.get("event_signature")) + .and_then(toml::Value::as_str) + .expect("the chain-log subscription pins an event_signature"); + let pinned: alloy_primitives::B256 = pinned.parse().expect("event_signature is a b256"); assert_eq!( + pinned, ConditionalOrderCreated::SIGNATURE_HASH, - events::CONDITIONAL_ORDER_CREATED.topic0, - "sol! topic-0 must match the shepherd:cow/cow-events pin", - ); - } - - /// Stronger guard than the constant check above: read the shipped - /// `module.toml` and assert its pinned `event_signature` actually - /// equals the package-of-record topic-0 - catches a manifest/code - /// drift the decoder assertion cannot see. - #[test] - fn manifest_topic0_matches_conditional_order_created_signature_hash() { - let manifest = include_str!("../module.toml"); - let expected = format!("{:#x}", events::CONDITIONAL_ORDER_CREATED.topic0); - assert!( - manifest.contains(&expected), - "module.toml event_signature must equal the shepherd:cow/cow-events pin ({expected})", + "module.toml event_signature drifted from the decoder topic-0", ); } } diff --git a/scripts/e2e-report-gen.sh b/scripts/e2e-report-gen.sh index 219a1a98..29f156bf 100755 --- a/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -155,7 +155,9 @@ shepherd_keys = [ "shepherd_module_restarts_total", "shepherd_module_poisoned", "shepherd_chain_request_total", - "shepherd_cow_api_submit_total", + "shepherd_adapter_errors_total", + "shepherd_adapter_restarts_total", + "shepherd_adapter_poisoned", "shepherd_stream_reconnects_total", ] diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index f4193c65..8e7450d5 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,7 +1,7 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the one endpoint shepherd's `cow-api` host backend hits on -//! every order submission: +//! Serves the one endpoint the cow venue adapter hits on every order +//! submission: //! //! - `POST /api/v1/orders` - accepts any body, returns a synthetic //! 56-byte OrderUid as a JSON-encoded hex string. Counts a request @@ -147,7 +147,7 @@ async fn post_orders(State(state): State>, body: String) -> impl I state.counters.submits_err.fetch_add(1, Ordering::Relaxed); // Alternate transient + permanent so the load test exercises // both `TryNextBlock` and `Drop` paths through - // `shepherd_sdk::cow::classify_api_error`. + // `cow_venue::classification::classify`. let n = state.counters.submits_err.load(Ordering::Relaxed); let api = if n.is_multiple_of(2) { ApiError { diff --git a/wit/shepherd-cow/cow-api.wit b/wit/shepherd-cow/cow-api.wit deleted file mode 100644 index 0dbaa940..00000000 --- a/wit/shepherd-cow/cow-api.wit +++ /dev/null @@ -1,62 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Legacy host-extension surface: retiring. Submission moves to the -/// generic videre pool seam; deleted at the fork-gated poll wire-swap. -interface cow-api { - use nexum:host/types@0.1.0.{chain-id, fault}; - - /// A non-2xx reply from the orderbook that carries no typed - /// rejection envelope. `body` is the raw response text, foreign - /// orderbook JSON kept as a string deliberately: the host does not - /// parse it, so a caller matches on `status` (e.g. 404 for a - /// resource the orderbook has not indexed yet) and reads `body` - /// only for diagnostics. - record http-failure { - status: u16, - body: option, - } - - /// A typed orderbook rejection of a submitted order. Parsed once, - /// host-side, from the orderbook's `{errorType, description, data}` - /// envelope so the guest dispatches on `error-type` without a - /// second JSON decode. `data` is the envelope's optional structured - /// payload (e.g. a minimum-fee quote), re-encoded as a JSON string. - record order-rejection { - status: u16, - error-type: string, - description: string, - data: option, - } - - /// A cow-api call failure: a shared host `fault`, a raw HTTP - /// failure, or a typed order rejection. - variant cow-api-error { - fault(fault), - http(http-failure), - rejected(order-rejection), - } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain - /// (e.g. https://api.cow.fi/mainnet for chain 1). - /// - /// method: "GET" | "POST" | "PUT" | "DELETE" - /// path: relative API path, e.g. "/api/v1/orders" - /// body: optional JSON request body - /// - /// Returns the response body as a JSON string. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit an order to the CoW Protocol. - /// - /// `order-data`: the serialised order payload. - /// Returns the order UID on success. - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} diff --git a/wit/shepherd-cow/cow-ext.wit b/wit/shepherd-cow/cow-ext.wit deleted file mode 100644 index 2a81b5f7..00000000 --- a/wit/shepherd-cow/cow-ext.wit +++ /dev/null @@ -1,9 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Extension world: the cow-api interface alone, wired into a module -/// linker by the cow extension. Kept separate from `shepherd` so the -/// extension contributes only its own import, never the core interfaces. -/// Retiring with `cow-api`. -world cow-ext { - import cow-api; -} diff --git a/wit/shepherd-cow/shepherd.wit b/wit/shepherd-cow/shepherd.wit deleted file mode 100644 index 729bcd0f..00000000 --- a/wit/shepherd-cow/shepherd.wit +++ /dev/null @@ -1,7 +0,0 @@ -package shepherd:cow@0.1.0; - -/// Shepherd module — event-driven Nexum module with CoW Protocol extensions. -world shepherd { - include nexum:host/event-module@0.1.0; - import cow-api; -} From 5270aa2f02bf023aeeaa4a59e4e222a30f3c71bb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 11:32:25 +0000 Subject: [PATCH 080/139] twap-monitor: index ComposableCoW v2 ConditionalOrderRemoved (#472) twap: index the v2 ConditionalOrderRemoved event and drop the watch Subscribe the twap-monitor to the ComposableCoW v2 ConditionalOrderRemoved topic-0 and pin it in the shepherd:cow/cow-events package of record. The created and removed subscription streams merge in arrival order, not chain order, so each watch row carries the create log's (block, log-index) stamp and a removal drops the watch (with its gates) only when it postdates the stamp: a stale removal for a re-registered order is ignored, and an unprovable ordering keeps the watch for the poll path's self-healing drop. --- modules/twap-monitor/module.toml | 14 +- modules/twap-monitor/src/lib.rs | 7 +- modules/twap-monitor/src/strategy.rs | 428 +++++++++++++++++++++++---- wit/shepherd-cow/cow-events.wit | 4 + 4 files changed, 399 insertions(+), 54 deletions(-) diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index b40033b2..575806f6 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones to the CoW venue through the pool. +# submit ready ones to the CoW venue through the venue registry. [module] name = "twap-monitor" @@ -36,6 +36,18 @@ chain_id = 11155111 address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" +# ComposableCoW v2 ConditionalOrderRemoved emissions. topic-0 = +# keccak256("ConditionalOrderRemoved(address,bytes32)"), parity-tested +# against the sol! decoder in strategy.rs. +# This stream and the created stream merge in arrival order, not +# chain order, so a removal drops the watch (with its gates) only +# when it postdates the watch's indexed create. +[[subscription]] +kind = "chain-log" +chain_id = 11155111 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9" + # New-block ticks drive the TWAP poll loop (`getTradeableOrderWithSignature`). [[subscription]] kind = "block" diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index aa6495db..71bf20a5 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,8 +1,9 @@ //! # twap-monitor (Shepherd keeper module) //! -//! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to the -//! CoW venue through the pool as they go live. +//! Indexes `ComposableCoW.ConditionalOrderCreated` and v2 +//! `ConditionalOrderRemoved` logs and polls each watched conditional +//! order on every block, submitting tranches to the CoW venue through +//! the venue registry as they go live. //! //! ## Module layout //! diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index ccd199b5..92c5970c 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -16,17 +16,19 @@ //! journal, submission through the venue registry, and retry dispatch live in //! the shared composition (`composable_cow::run`). -use alloy_primitives::{Address, Bytes, keccak256}; +use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use composable_cow::{LegacyRevertAdapter, Verdict, run}; use cow_venue::CowClient; use cowprotocol::{ - COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, + COMPOSABLE_COW, + ComposableCoW::{ConditionalOrderCreated, ConditionalOrderRemoved}, + ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet, watch_key}; use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. @@ -62,12 +64,19 @@ mod abi { } } -/// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` -/// chain-log in a dispatch batch and persist its watch. +/// Indexer entry: decode every ComposableCoW registration and removal +/// chain-log in a dispatch batch. A `ConditionalOrderCreated` persists +/// its watch stamped with the log's chain position; a v2 +/// `ConditionalOrderRemoved` drops the watch (with its gates) only +/// when it postdates that stamp. The two subscription streams merge in +/// arrival order, not chain order, so the stamp is what keeps a stale +/// removal from dropping a re-registered watch. pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { - persist_watch(host, owner, ¶ms)?; + persist_watch(host, owner, ¶ms, LogPosition::of(log))?; + } else if let Some((owner, hash)) = decode_conditional_order_removed(log) { + remove_watch(host, owner, &hash, LogPosition::of(log))?; } } Ok(()) @@ -92,6 +101,60 @@ where // ---- indexing path ---- +/// Chain position of a mined log, ordered as the chain orders logs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct LogPosition { + block: u64, + index: u64, +} + +impl LogPosition { + /// `None` for a pending log. + fn of(log: &Log) -> Option { + Some(Self { + block: log.block_number?, + index: log.log_index?, + }) + } +} + +/// Header tag + `(block, log-index)` little-endian words. +const ROW_HEADER_LEN: usize = 17; + +/// Watch row payload: a position header ahead of the ABI-encoded +/// `ConditionalOrderParams`. The header pins where the indexed +/// `ConditionalOrderCreated` sits on chain. +fn encode_row(indexed_at: Option, params: &[u8]) -> Vec { + let mut row = Vec::with_capacity(ROW_HEADER_LEN + params.len()); + match indexed_at { + Some(at) => { + row.push(1); + row.extend_from_slice(&at.block.to_le_bytes()); + row.extend_from_slice(&at.index.to_le_bytes()); + } + None => row.extend_from_slice(&[0; ROW_HEADER_LEN]), + } + row.extend_from_slice(params); + row +} + +/// Split a watch row into its position stamp and params bytes; `None` +/// on a malformed row. +fn decode_row(row: &[u8]) -> Option<(Option, &[u8])> { + let (header, params) = row.split_first_chunk::()?; + let [tag, position @ ..] = header; + let (block, index) = position.split_first_chunk::<8>()?; + let indexed_at = match tag { + 0 => None, + 1 => Some(LogPosition { + block: u64::from_le_bytes(*block), + index: u64::from_le_bytes(index.try_into().ok()?), + }), + _ => return None, + }; + Some((indexed_at, params)) +} + /// Topic-0 gates before the ABI decode; the pin is parity-tested /// against the `shepherd:cow/cow-events` package of record. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { @@ -102,32 +165,89 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// The watch set overwrites in place, so re-indexing the same log -/// (re-org replay, overlapping subscription windows) produces no -/// observable side effect. +/// The watch set overwrites in place and the stamp keeps the latest +/// indexed position, so re-indexing (re-org replay, overlapping +/// subscription windows, cursor rewind) never ages the row. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, + indexed_at: Option, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; + let hash = keccak256(&encoded); + let watches = WatchSet::new(host); + let prior = WatchRef::parse(&watch_key(&owner, &hash)) + .map(|watch| watches.get(watch)) + .transpose()? + .flatten() + .and_then(|row| decode_row(&row).and_then(|(at, _)| at)); + let row = encode_row(prior.max(indexed_at), &encoded); + let key = watches.put(&owner, &hash, &row)?; tracing::info!("indexed {key}"); Ok(()) } +/// Topic-0 gates before the ABI decode; the pin is parity-tested +/// against the `shepherd:cow/cow-events` package of record. +fn decode_conditional_order_removed(log: &Log) -> Option<(Address, B256)> { + if log.topics().first() != Some(&ConditionalOrderRemoved::SIGNATURE_HASH) { + return None; + } + let decoded = ConditionalOrderRemoved::decode_log(&log.inner).ok()?; + Some((decoded.data.owner, decoded.data.singleOrderHash)) +} + +/// `singleOrderHash` is `keccak256(abi.encode(params))`, exactly the +/// hash the watch key carries. The removal lands only when it provably +/// postdates the watch's stamp: `remove(hash)` + `create(same params)` +/// in one call re-registers the same hash, and the earlier remove can +/// arrive after the later create, so an unprovable ordering keeps the +/// watch (a wrongly-kept watch self-heals through the poll's drop +/// path; a wrongly-dropped one is never polled again). A removal for +/// an order this keeper never indexed (or already dropped) replays +/// cleanly as a no-op. +fn remove_watch( + host: &H, + owner: Address, + hash: &B256, + removed_at: Option, +) -> Result<(), Fault> { + let key = watch_key(&owner, hash); + let Some(watch) = WatchRef::parse(&key) else { + return Ok(()); + }; + let watches = WatchSet::new(host); + let Some(row) = watches.get(watch)? else { + return Ok(()); + }; + let indexed_at = decode_row(&row).and_then(|(at, _)| at); + match (indexed_at, removed_at) { + (Some(indexed_at), Some(removed_at)) if indexed_at < removed_at => { + watches.remove(watch)?; + tracing::info!("removed {key}"); + } + _ => tracing::info!("kept {key}: removal does not postdate its create"), + } + Ok(()) +} + // ---- poll path ---- -/// TWAP conditional source: decode the stored `ConditionalOrderParams` -/// and evaluate `getTradeableOrderWithSignature` on chain. A row this -/// source cannot decode polls again next block rather than tearing -/// down the sweep. +/// TWAP conditional source: decode the stored row's +/// `ConditionalOrderParams` and evaluate +/// `getTradeableOrderWithSignature` on chain. A row this source cannot +/// decode polls again next block rather than tearing down the sweep. struct TwapSource; impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { + let Some((_, params)) = decode_row(params) else { + tracing::warn!("watch {} carried an unparseable row; skipping", watch.key()); + return Verdict::TryNextBlock { reason: [0; 4] }; + }; let Ok(params) = ConditionalOrderParams::abi_decode(params) else { tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); return Verdict::TryNextBlock { reason: [0; 4] }; @@ -234,9 +354,6 @@ fn outcome_label(o: &Verdict) -> &'static str { // Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. -#[cfg(test)] -use nexum_sdk::keeper::watch_key; - #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { let watch = WatchRef::parse(key)?; @@ -405,7 +522,7 @@ mod tests { fn decodes_well_formed_log() { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + let log = make_log(owner, ¶ms, at(1, 0)); let (decoded_owner, decoded_params) = decode_conditional_order_created(&log).expect("decode succeeds"); @@ -470,26 +587,48 @@ mod tests { // ---- MockHost + MockVenue dispatch tests ---- - /// Build the alloy log the indexer expects from a well-formed - /// `ConditionalOrderCreated`, assembled through the same WIT-edge - /// path the bind macro uses at runtime. - fn make_log(owner: Address, params: &ConditionalOrderParams) -> Log { + /// Chain position shorthand for the log builders. + fn at(block: u64, index: u64) -> LogPosition { + LogPosition { block, index } + } + + /// Assemble a mined ComposableCoW log at `position` through the + /// same WIT-edge path the bind macro uses at runtime. + fn make_event_log(owner: Address, topic0: B256, data: &[u8], position: LogPosition) -> Log { let mut owner_topic = vec![0u8; 12]; owner_topic.extend_from_slice(owner.as_slice()); - let topics = vec![ - ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), - owner_topic, - ]; - let data = params.abi_encode(); + let topics = vec![topic0.to_vec(), owner_topic]; nexum_sdk::events::ChainLogParts { address: COMPOSABLE_COW.as_slice(), topics: &topics, - data: &data, + data, + block_number: Some(position.block), + log_index: Some(position.index), ..Default::default() } .into() } + /// A well-formed `ConditionalOrderCreated` mined at `position`. + fn make_log(owner: Address, params: &ConditionalOrderParams, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderCreated::SIGNATURE_HASH, + ¶ms.abi_encode(), + position, + ) + } + + /// A well-formed v2 `ConditionalOrderRemoved` mined at `position`. + fn make_removed_log(owner: Address, hash: B256, position: LogPosition) -> Log { + make_event_log( + owner, + ConditionalOrderRemoved::SIGNATURE_HASH, + &hash.abi_encode(), + position, + ) + } + /// Build the `params_json` `poll_one` passes to `host.request`. fn programmed_eth_call_params(owner: Address, params: &ConditionalOrderParams) -> String { let call = abi::getTradeableOrderWithSignatureCall { @@ -513,11 +652,13 @@ mod tests { } /// Pre-seed a `watch:` row identical to what the indexer would - /// write. + /// write for a create mined at block 1, index 0. fn seed_watch(host: &MockHost, owner: Address, params: &ConditionalOrderParams) -> String { let encoded = params.abi_encode(); let key = watch_key(&owner, &keccak256(&encoded)); - host.store.set(&key, &encoded).unwrap(); + host.store + .set(&key, &encode_row(Some(at(1, 0)), &encoded)) + .unwrap(); key } @@ -534,13 +675,17 @@ mod tests { let host = MockHost::new(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - let log = make_log(owner, ¶ms); + let log = make_log(owner, ¶ms, at(42, 9)); on_chain_logs(&host, &[log]).unwrap(); let expected_key = watch_key(&owner, &keccak256(params.abi_encode())); assert_eq!(host.store.len(), 1); - assert!(host.store.snapshot().contains_key(&expected_key)); + let store = host.store.snapshot(); + let row = store.get(&expected_key).expect("watch row present"); + let (indexed_at, stored) = decode_row(row).expect("row decodes"); + assert_eq!(indexed_at, Some(at(42, 9)), "row carries the log position"); + assert_eq!(stored, params.abi_encode(), "row carries the params"); } #[test] @@ -552,13 +697,188 @@ mod tests { let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); // Re-deliver the same log. - on_chain_logs(&host, &[make_log(owner, ¶ms)]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(42, 9))]).unwrap(); assert_eq!(host.store.len(), 1, "redelivery must not duplicate watches"); } + #[test] + fn decodes_well_formed_removed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = b256!("0303030303030303030303030303030303030303030303030303030303030303"); + let log = make_removed_log(owner, hash, at(1, 0)); + + let (decoded_owner, decoded_hash) = + decode_conditional_order_removed(&log).expect("decode succeeds"); + assert_eq!(decoded_owner, owner); + assert_eq!(decoded_hash, hash); + // The two decoders never cross-match: topic-0 keeps them apart. + assert!(decode_conditional_order_created(&log).is_none()); + assert!( + decode_conditional_order_removed(&make_log(owner, &sample_params(), at(1, 0))) + .is_none() + ); + } + + #[test] + fn watch_row_codec_round_trips() { + for indexed_at in [None, Some(at(3, 7))] { + let row = encode_row(indexed_at, b"payload"); + let (decoded_at, params) = decode_row(&row).expect("row decodes"); + assert_eq!(decoded_at, indexed_at); + assert_eq!(params, b"payload"); + } + assert!(decode_row(&[]).is_none(), "short row is malformed"); + let mut bad_tag = encode_row(None, b"payload"); + bad_tag[0] = 2; + assert!(decode_row(&bad_tag).is_none(), "unknown tag is malformed"); + } + + #[test] + fn removal_drops_watch_and_gates_and_spares_the_rest() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let key = seed_watch(&host, owner, ¶ms); + let (owner_hex, hash_hex) = parse_watch_key(&key).unwrap(); + host.store + .set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &500u64.to_le_bytes(), + ) + .unwrap(); + host.store + .set( + &format!("next_epoch:{owner_hex}:{hash_hex}"), + &1_700_000_000u64.to_le_bytes(), + ) + .unwrap(); + // A sibling watch under a different hash must survive. + let mut other = sample_params(); + other.salt = b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let other_key = seed_watch(&host, owner, &other); + + let hash = keccak256(params.abi_encode()); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + let store = host.store.snapshot(); + assert!(!store.contains_key(&key), "removal must drop the watch"); + assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); + assert!(!store.contains_key(&format!("next_epoch:{owner_hex}:{hash_hex}"))); + assert!(store.contains_key(&other_key), "sibling watch survives"); + } + + #[test] + fn removal_of_an_unindexed_watch_is_a_no_op() { + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = keccak256(sample_params().abi_encode()); + + on_chain_logs(&host, &[make_removed_log(owner, hash, at(2, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0); + } + + #[test] + fn later_removal_in_its_own_dispatch_drops_the_watch() { + // The runtime dispatches each log singly; a create and its + // genuine removal always arrive as two separate calls. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(9, 0))]).unwrap(); + + assert_eq!(host.store.len(), 0, "a postdating removal lands"); + } + + #[test] + fn same_block_later_removal_drops_the_watch() { + // A create and its removal can share a block, so ordering rests + // on the log index alone. This is the later-index half of the + // boundary; the stale test below pins the earlier-index half. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 6))]).unwrap(); + + assert_eq!( + host.store.len(), + 0, + "a same-block removal at a later log index lands", + ); + } + + #[test] + fn stale_removal_arriving_after_a_re_registered_create_is_ignored() { + // `remove(hash)` + `create(same params)` in one call + // re-registers the same hash at a later log index. The two + // subscription streams merge in arrival order, so the earlier + // remove can arrive after the later create, each as its own + // single-log dispatch. The live watch must survive. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "a stale removal must not drop the re-registered watch", + ); + } + + #[test] + fn redelivered_older_create_keeps_the_later_stamp() { + // A cursor rewind can redeliver an old create after a newer + // registration of the same params; the stamp must not age, or + // the stale removal between them would land. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + on_chain_logs(&host, &[make_log(owner, ¶ms, at(2, 0))]).unwrap(); + on_chain_logs(&host, &[make_removed_log(owner, hash, at(7, 4))]).unwrap(); + + assert!( + host.store.snapshot().contains_key(&key), + "the stamp keeps the latest indexed position", + ); + } + + #[test] + fn removal_without_a_mined_position_keeps_the_watch() { + // A removal whose position cannot be proven later than the + // create is ignored; the poll path's drop verdict is the + // self-healing teardown for a truly removed order. + let host = MockHost::new(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); + let hash = keccak256(params.abi_encode()); + let key = watch_key(&owner, &hash); + + on_chain_logs(&host, &[make_log(owner, ¶ms, at(7, 5))]).unwrap(); + let mut pending = make_removed_log(owner, hash, at(9, 0)); + pending.block_number = None; + pending.log_index = None; + on_chain_logs(&host, &[pending]).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + } + #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); @@ -927,29 +1247,37 @@ mod tests { }); } - /// The supervisor builds its log filter from this manifest's - /// `event_signature` (`nexum-runtime::supervisor` -> - /// `build_alloy_filter`), so a drift from the decoder topic-0 - /// subscribes to one topic and decodes another, and the module - /// silently sees nothing. Reads the chain-log subscription's parsed - /// value, so a stale hash in a comment cannot satisfy it. + /// The supervisor builds its log filters from this manifest's + /// chain-log `event_signature` pins + /// (`nexum-runtime::supervisor` -> `build_alloy_filter`), so a + /// drift from a decoder topic-0 subscribes to one topic and decodes + /// another, and the module silently sees nothing. Compares the two + /// sets rather than testing membership, so a missing pin and a pin + /// the decoders cannot handle both fail too. #[test] - fn manifest_topic0_matches_conditional_order_created_signature_hash() { + fn manifest_topics_match_the_decoder_signature_hashes() { let manifest: toml::Value = toml::from_str(include_str!("../module.toml")).expect("module.toml parses"); - let pinned = manifest["subscription"] + let pinned: std::collections::BTreeSet = manifest["subscription"] .as_array() .expect("module.toml declares subscriptions") .iter() - .find(|sub| sub.get("kind").and_then(toml::Value::as_str) == Some("chain-log")) - .and_then(|sub| sub.get("event_signature")) - .and_then(toml::Value::as_str) - .expect("the chain-log subscription pins an event_signature"); - let pinned: alloy_primitives::B256 = pinned.parse().expect("event_signature is a b256"); - assert_eq!( - pinned, + .filter(|sub| sub.get("kind").and_then(toml::Value::as_str) == Some("chain-log")) + .map(|sub| { + sub.get("event_signature") + .and_then(toml::Value::as_str) + .expect("every chain-log subscription pins an event_signature") + .parse() + .expect("event_signature is a b256") + }) + .collect(); + let decoded = std::collections::BTreeSet::from([ ConditionalOrderCreated::SIGNATURE_HASH, - "module.toml event_signature drifted from the decoder topic-0", + ConditionalOrderRemoved::SIGNATURE_HASH, + ]); + assert_eq!( + pinned, decoded, + "module.toml chain-log topics and the sol! decoder topic-0s have diverged", ); } } diff --git a/wit/shepherd-cow/cow-events.wit b/wit/shepherd-cow/cow-events.wit index b5f54bc1..3b5505c1 100644 --- a/wit/shepherd-cow/cow-events.wit +++ b/wit/shepherd-cow/cow-events.wit @@ -12,6 +12,10 @@ interface cow-events { /// signature: ConditionalOrderCreated(address,(address,bytes32,bytes)) /// topic0: 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361 conditional-order-created, + /// ComposableCoW v2 single-order removal. + /// signature: ConditionalOrderRemoved(address,bytes32) + /// topic0: 0x67e0f2b23e842ce65d7edff49765689c9c0931f911fc5971d09bb598cc1af4a9 + conditional-order-removed, /// CoWSwapOnchainOrders (EthFlow) placement. /// signature: OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes) /// topic0: 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9 From 9cac7c373068728c19afabd56da23a9ff47bf370 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 11:52:06 +0000 Subject: [PATCH 081/139] twap: grant one next-block retry before dropping on InvalidEip1271Signature (#473) A first-time user's Safe wiring and ComposableCoW create() land in one block, so the orderbook rejects the first submission against its own head and the keeper dropped a valid watch. The classification table gains a drop-on-repeat action, the retry ledger records the refused block and gates one next-block retry, and a denied refusal re-enters the table by its errorType prefix so the grace survives the coarse venue-error collapse. A repeat on a later block still drops. --- Cargo.lock | 1 + crates/composable-cow/src/sweep.rs | 23 ++- crates/composable-cow/tests/sweep.rs | 150 ++++++++++++++++++++ crates/cow-venue/build.rs | 1 + crates/cow-venue/data/classification.toml | 23 ++- crates/cow-venue/src/adapter.rs | 12 ++ crates/cow-venue/src/classification.rs | 47 +++++- crates/cow-venue/src/classification_data.rs | 5 +- crates/cow-venue/src/lib.rs | 2 +- crates/nexum-sdk/src/keeper.rs | 95 +++++++++---- crates/nexum-sdk/tests/keeper.rs | 122 +++++++++++++++- crates/videre-sdk/Cargo.toml | 2 + crates/videre-sdk/src/keeper.rs | 63 +++++++- 13 files changed, 492 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 533912ed..1b2b7ece 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5983,6 +5983,7 @@ dependencies = [ "nexum-sdk-test", "strum", "thiserror 2.0.18", + "tracing", "videre-macros", "videre-status-body", "wit-bindgen 0.59.0", diff --git a/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs index d3fa5dd7..768d51c4 100644 --- a/crates/composable-cow/src/sweep.rs +++ b/crates/composable-cow/src/sweep.rs @@ -13,21 +13,24 @@ //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they fold into a //! [`RetryAction`] through the videre -//! [`retry_action`] table, the ledger applies the effect, and the +//! [`retry_action`] table, a `denied` refusal re-entering the CoW +//! classification through its errorType prefix +//! ([`classify_denied`]) so a one-shot row survives the coarse +//! collapse, the ledger applies the effect, and the //! sweep moves on. Diagnostics go through the guest `tracing` facade - //! the same channel strategy code logs on - so module tests observe //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes, hex}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; -use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, intent_id}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, classify_denied, intent_id}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; use videre_sdk::keeper::retry_action; -use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; +use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport, rt}; use crate::Verdict; @@ -141,6 +144,12 @@ where }; match outcome { Ok(SubmitOutcome::Accepted(receipt)) => { + // An acceptance ends any refusal episode: clear the + // first-refusal marker so a later independent refusal + // earns a fresh one-block grace. + if let Err(fault) = Retrier::new(host).clear_refusal(watch) { + tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); + } // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the @@ -162,13 +171,17 @@ where tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { - let action = retry_action(&fault); - Retrier::new(host).apply(watch, action, tick.epoch_s)?; + let action = match &fault { + VenueFault::Denied(detail) => classify_denied(detail), + other => retry_action(other), + }; + Retrier::new(host).apply(watch, action, tick)?; match action { RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), RetryAction::Backoff { seconds } => { tracing::warn!("submit backoff {seconds}s: {fault}"); } + RetryAction::DropOnRepeat => tracing::warn!("submit drop-on-repeat: {fault}"), RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), // `RetryAction` is non-exhaustive; the ledger already // ran the effect, so the log needs only the name. diff --git a/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/sweep.rs index 7d400f4c..567a1384 100644 --- a/crates/composable-cow/tests/sweep.rs +++ b/crates/composable-cow/tests/sweep.rs @@ -512,6 +512,156 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } +/// The adapter's projection of the same-block wiring+create race: a +/// first-time user's Safe wiring and `create()` land in one block, so +/// the orderbook rejects the first submission against its own head. +fn eip1271_rejection() -> Result { + Err(VenueFault::Denied( + "InvalidEip1271Signature: signature for computed order hash 0x7ee5 is not valid".into(), + )) +} + +/// Same-block wiring+create race: the first rejection gates the watch +/// to the next block instead of dropping it, re-polls within the block +/// stay gated, and the retried submission one block later lands. +#[test] +fn first_eip1271_rejection_retries_on_the_next_block() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + let tick = sample_tick(); + let (result, logs) = capture_tracing(|| run(&host, &client(&venue), &source, &tick)); + result.unwrap(); + + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "first rejection keeps the watch" + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(tick.block + 1).to_le_bytes().to_vec(), + "the watch gates to the next block", + ); + assert!(logs.any(|e| e.message.contains("drop-on-repeat"))); + + // Sub-block re-polls stay gated: the race is not hammered. + run(&host, &client(&venue), &source, &tick).unwrap(); + assert_eq!(venue.submit_count(), 1); + + // One block later the wiring is visible and the retry lands. + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + +/// A rejection that repeats on a later block is a genuinely broken +/// signature: the watch and every derived key go. +#[test] +fn repeated_eip1271_rejection_on_a_later_block_drops_the_watch() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(eip1271_rejection()); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let tick = sample_tick(); + run(&host, &client(&venue), &source, &tick).unwrap(); + + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + + assert_eq!(venue.submit_count(), 2); + assert!( + host.store.is_empty(), + "a repeated rejection must drop the watch, its gates, and the marker", + ); +} + +/// An accepted submission ends the refusal episode: a later tranche's +/// own first rejection earns a fresh one-block grace instead of an +/// immediate drop on the stale marker from an earlier tranche. +#[test] +fn acceptance_resets_the_one_block_grace_for_later_tranches() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tranche_one = submittable_order(); + let mut tranche_two = submittable_order(); + tranche_two.buyAmount = U256::from(1_001_u64); + + let venue = MockVenue::default(); + venue.enqueue_submit(eip1271_rejection()); + venue.enqueue_submit(accepted()); + venue.enqueue_submit(eip1271_rejection()); + + let tick = sample_tick(); + let boundary = tick.block + 5; + let source = src(move |_, _, _, t: &Tick| { + if t.block < boundary { + ready_outcome(&tranche_one) + } else { + ready_outcome(&tranche_two) + } + }); + + // Tranche one: refused at the tick block, accepted one block later. + run(&host, &client(&venue), &source, &tick).unwrap(); + let next = Tick { + block: tick.block + 1, + ..tick + }; + run(&host, &client(&venue), &source, &next).unwrap(); + assert_eq!(venue.submit_count(), 2); + assert!( + !host.store.snapshot().contains_key(&watch.refused_key()), + "acceptance must clear the first-refusal marker", + ); + + // Tranche two: its own first rejection at a later block keeps the + // watch and gates it to the next block. + let later = Tick { + block: boundary, + ..tick + }; + run(&host, &client(&venue), &source, &later).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&key), + "a fresh refusal after an acceptance must keep the watch", + ); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &later.block.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &(later.block + 1).to_le_bytes().to_vec(), + ); +} + /// Restart regression: a keeper that posted, journalled, and then /// restarted over the same persistent local store must not post the /// same order again - one venue submit across both lives. diff --git a/crates/cow-venue/build.rs b/crates/cow-venue/build.rs index 10122c97..f619f788 100644 --- a/crates/cow-venue/build.rs +++ b/crates/cow-venue/build.rs @@ -28,6 +28,7 @@ fn main() { let action = match e.action { Action::TryNextBlock => "GenAction::TryNextBlock", Action::Backoff => "GenAction::Backoff", + Action::DropOnRepeat => "GenAction::DropOnRepeat", Action::Drop => "GenAction::Drop", }; out.push_str(&format!( diff --git a/crates/cow-venue/data/classification.toml b/crates/cow-venue/data/classification.toml index f2cbb40b..2d236248 100644 --- a/crates/cow-venue/data/classification.toml +++ b/crates/cow-venue/data/classification.toml @@ -18,6 +18,12 @@ # watch for `backoff-seconds` before the next # attempt. `backoff-seconds` is required and # must be at least 1. +# action = "drop-on-repeat" Permanent unless first-seen: the first +# rejection retries on the next block (a +# same-block state race can make a valid +# order verify late); a repeat on a later +# block removes the watch. +# # action = "drop" Permanent: no retry can succeed. Remove the # watch and its gates. # @@ -41,7 +47,7 @@ # (a permanent-looking contract rejection is dropped rather than # retried, and the limit-order backoff is shorter) are exactly: # -# InvalidEip1271Signature drop upstream: retry next block +# InvalidEip1271Signature drop-on-repeat upstream: retry next block # InsufficientBalance drop upstream: backoff 10 min # InsufficientAllowance drop upstream: backoff 10 min # InvalidAppData drop upstream: backoff 60 s @@ -85,6 +91,17 @@ error-type = "DuplicateOrder" action = "try-next-block" already-submitted = true +# --- One-block grace: retry once, then drop -------------------------- + +# A first-time user's Safe wiring and conditional-order registration +# can land in the same block as the indexed event; an orderbook node +# verifying against its own head then rejects a signature that is valid +# one block later. The first rejection retries next block; a repeat on +# a later block is a genuinely broken signature and drops the watch. +[[entry]] +error-type = "InvalidEip1271Signature" +action = "drop-on-repeat" + # --- Permanent: drop the watch --------------------------------------- # # Listed for documentation; each is also the default for any unlisted @@ -95,10 +112,6 @@ already-submitted = true error-type = "InvalidSignature" action = "drop" -[[entry]] -error-type = "InvalidEip1271Signature" -action = "drop" - [[entry]] error-type = "WrongOwner" action = "drop" diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 333475e7..693154ee 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -420,6 +420,9 @@ fn classified(api: &ApiError) -> VenueError { RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { retry_after_ms: Some(seconds.saturating_mul(1000)), }), + // The one-shot grace is client-side; the wire stays `denied`, + // and the errorType prefix in the detail carries it across. + RetryAction::DropOnRepeat => VenueError::Denied(detail), RetryAction::Drop => VenueError::Denied(detail), _ => VenueError::Denied(detail), } @@ -758,6 +761,15 @@ mod tests { Err(VenueError::Denied(detail)) if detail.contains("InvalidSignature") )); + // A drop-on-repeat row stays `denied` on the wire; the + // errorType prefix carries the one-shot grace to the client. + reject(&fetch, "InvalidEip1271Signature"); + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::Denied(detail)) + if detail.starts_with("InvalidEip1271Signature:") + )); + reject(&fetch, "TooManyLimitOrders"); assert!(matches!( submit_with(&fetch, &config, &signed_bytes()), diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index aabda91f..a3c6861b 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -32,6 +32,7 @@ pub const CLASSIFICATION_TOML: &str = include_str!("../data/classification.toml" enum GenAction { TryNextBlock, Backoff, + DropOnRepeat, Drop, } @@ -53,6 +54,7 @@ impl GeneratedRow { GenAction::Backoff => RetryAction::Backoff { seconds: self.backoff_seconds.max(1), }, + GenAction::DropOnRepeat => RetryAction::DropOnRepeat, GenAction::Drop => RetryAction::Drop, } } @@ -116,6 +118,19 @@ pub fn is_already_submitted(error_type: &str) -> bool { table().is_already_submitted(error_type) } +/// Retry action for a coarse `denied` refusal. The adapter spells a +/// classified rejection as `{errorType}: {description}`; the prefix +/// re-enters the table so a [`RetryAction::DropOnRepeat`] row survives +/// the collapse to the wire `venue-error`. Every other denial is +/// permanent. +pub fn classify_denied(detail: &str) -> RetryAction { + let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix); + match classify(error_type) { + RetryAction::DropOnRepeat => RetryAction::DropOnRepeat, + _ => RetryAction::Drop, + } +} + #[cfg(test)] mod tests { use super::*; @@ -141,6 +156,7 @@ mod tests { Action::Backoff => RetryAction::Backoff { seconds: entry.backoff_seconds.max(1), }, + Action::DropOnRepeat => RetryAction::DropOnRepeat, Action::Drop => RetryAction::Drop, }; assert_eq!( @@ -168,6 +184,10 @@ mod tests { classify("TooManyLimitOrders"), RetryAction::Backoff { seconds: 30 }, ); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); assert_eq!(classify("InvalidSignature"), RetryAction::Drop); assert!(is_already_submitted("DuplicatedOrder")); assert!(is_already_submitted("DuplicateOrder")); @@ -181,7 +201,7 @@ mod tests { assert!(!is_already_submitted("NewlyMintedErrorType")); } - /// All three retry arms are reachable from the table alone. + /// Every retry arm is reachable from the table alone. #[test] fn table_reaches_every_arm() { assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); @@ -189,9 +209,34 @@ mod tests { classify("TooManyLimitOrders"), RetryAction::Backoff { .. } )); + assert_eq!( + classify("InvalidEip1271Signature"), + RetryAction::DropOnRepeat, + ); assert_eq!(classify("InvalidSignature"), RetryAction::Drop); } + /// A denied detail re-enters the table by its `errorType` prefix: + /// only a drop-on-repeat row escapes the permanent default, and a + /// transient row cannot be smuggled through a denial. + #[test] + fn denied_detail_refines_by_error_type_prefix() { + assert_eq!( + classify_denied("InvalidEip1271Signature: signature is not valid"), + RetryAction::DropOnRepeat, + ); + assert_eq!( + classify_denied("InvalidSignature: bad sig"), + RetryAction::Drop, + ); + assert_eq!( + classify_denied("InsufficientFee: too low"), + RetryAction::Drop, + ); + assert_eq!(classify_denied("policy refusal"), RetryAction::Drop); + assert_eq!(classify_denied(""), RetryAction::Drop); + } + #[test] fn duplicate_type_is_rejected() { let toml = r#" diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index 49162e0b..bb96cc3f 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -10,7 +10,7 @@ use serde::Deserialize; -/// One of the three retry actions an `errorType` maps to on the wire. +/// One of the retry actions an `errorType` maps to on the wire. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Action { @@ -18,6 +18,9 @@ pub enum Action { TryNextBlock, /// Throttle: gate the watch for `backoff_seconds` before retrying. Backoff, + /// Permanent unless first-seen: retry on the next block once, then + /// drop on a repeat at a later block. + DropOnRepeat, /// Permanent: remove the watch and its gates. Drop, } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 4d31bbe2..990c4363 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -81,6 +81,6 @@ pub use order::{ pub use adapter::CowAdapter; #[cfg(feature = "client")] -pub use classification::{ClassificationTable, classify, is_already_submitted}; +pub use classification::{ClassificationTable, classify, classify_denied, is_already_submitted}; #[cfg(feature = "client")] pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 637e7985..726bdae4 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -26,10 +26,10 @@ //! - [`Retrier`] - runs a [`RetryAction`]'s effect through the //! stores after a failed keeper run attempt. //! -//! [`WatchRef`] ties the first two together: gate keys are derived -//! from the exact hex substrings of the stored watch key, and -//! [`WatchSet::remove`] drops a watch together with all of its gate -//! keys so no failure path can orphan a gate. +//! [`WatchRef`] ties the first two together: gate and marker keys are +//! derived from the exact hex substrings of the stored watch key, and +//! [`WatchSet::remove`] drops a watch together with all of its derived +//! keys so no failure path can orphan one. //! //! ``` //! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; @@ -99,6 +99,10 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// the observe-and-verify path (e.g. ethflow) recorded an existing /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Prefix of the first-refusal marker paired with a watch: the block a +/// [`RetryAction::DropOnRepeat`] refusal was first seen at, u64 +/// little-endian. +pub const REFUSED_PREFIX: &str = "refused:"; /// Canonical watch key for an owner / commitment-hash pair (lowercase /// `0x`-prefixed hex on both halves). Free-standing because the key @@ -159,6 +163,11 @@ impl<'k> WatchRef<'k> { pub fn next_epoch_key(&self) -> String { format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) } + + /// The `refused:` first-refusal marker key paired with this watch. + pub fn refused_key(&self) -> String { + format!("{REFUSED_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } } /// Watch-set registry: one row per conditional commitment, keyed @@ -199,11 +208,13 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { self.host.list_keys(WATCH_PREFIX) } - /// Drop the watch together with both of its gate keys. Gates go - /// first: a fault part-way leaves the watch row behind so a retry - /// re-drops it, and a gate key can never outlive its watch. + /// Drop the watch together with its gate and refusal keys. The + /// derived keys go first: a fault part-way leaves the watch row + /// behind so a retry re-drops it, and a derived key can never + /// outlive its watch. pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { Gates::new(self.host).clear(watch)?; + self.host.delete(&watch.refused_key())?; self.host.delete(&watch.key()) } } @@ -238,12 +249,12 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { /// inclusive at its threshold. #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { - if let Some(next) = self.read_u64(&watch.next_block_key())? + if let Some(next) = read_u64(self.host, &watch.next_block_key())? && block < next { return Ok(false); } - if let Some(next) = self.read_u64(&watch.next_epoch_key())? + if let Some(next) = read_u64(self.host, &watch.next_epoch_key())? && epoch_s < next { return Ok(false); @@ -256,21 +267,22 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { self.host.delete(&watch.next_block_key())?; self.host.delete(&watch.next_epoch_key()) } +} - fn read_u64(&self, key: &str) -> Result, Fault> { - // Absent key: silently no gate. Present but wrong length: the - // value is corrupt, so warn before falling open to no gate - - // fail-open is deliberate (a corrupt value can only make the - // watch poll sooner), but it must not pass unobserved. - let Some(b) = self.host.get(key)? else { - return Ok(None); - }; - match <[u8; 8]>::try_from(b.as_slice()) { - Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), - Err(_) => { - tracing::warn!(%key, len = b.len(), "gate value corrupt; treating as absent"); - Ok(None) - } +/// Little-endian u64 row read shared by the gate and refusal-marker +/// keys. Absent key: silently `None`. Present but wrong length: the +/// value is corrupt, so warn before falling open to `None` - fail-open +/// is deliberate (a corrupt value can only make the watch act sooner, +/// never wedge it), but it must not pass unobserved. +fn read_u64(host: &H, key: &str) -> Result, Fault> { + let Some(b) = host.get(key)? else { + return Ok(None); + }; + match <[u8; 8]>::try_from(b.as_slice()) { + Ok(bytes) => Ok(Some(u64::from_le_bytes(bytes))), + Err(_) => { + tracing::warn!(%key, len = b.len(), "stored value corrupt; treating as absent"); + Ok(None) } } } @@ -373,14 +385,20 @@ pub enum RetryAction { /// Seconds to wait before retrying. seconds: u64, }, + /// Grant one next-block retry: the first application records the + /// block and gates the watch to the next one; a repeat at a later + /// block removes the watch. + DropOnRepeat, /// Remove the watch and its gates; no retry can succeed. Drop, } /// Retry ledger: runs a [`RetryAction`]'s effect through the keeper /// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; -/// `Drop` delegates to [`WatchSet::remove`], so gates go first and no -/// failure path can orphan one. +/// `DropOnRepeat` keeps a `refused:` block marker so only a repeat on +/// a later block removes the watch; `Drop` delegates to +/// [`WatchSet::remove`], so derived keys go first and no failure path +/// can orphan one. pub struct Retrier<'h, H> { host: &'h H, } @@ -391,20 +409,39 @@ impl<'h, H: LocalStoreHost> Retrier<'h, H> { Self { host } } - /// Apply `action` to the watch, with `now_epoch_s` as the backoff - /// origin. + /// Apply `action` to the watch at `tick`: the backoff origin and + /// the repeat-detection block both read from it. pub fn apply( &self, watch: WatchRef<'_>, action: RetryAction, - now_epoch_s: u64, + tick: &Tick, ) -> Result<(), Fault> { match action { RetryAction::TryNextBlock => Ok(()), RetryAction::Backoff { seconds } => { - Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + Gates::new(self.host).set_next_epoch(watch, tick.epoch_s.saturating_add(seconds)) + } + RetryAction::DropOnRepeat => { + let key = watch.refused_key(); + match read_u64(self.host, &key)? { + Some(block) if tick.block > block => WatchSet::new(self.host).remove(watch), + Some(_) => Ok(()), + None => { + self.host.set(&key, &tick.block.to_le_bytes())?; + Gates::new(self.host).set_next_block(watch, tick.block.saturating_add(1)) + } + } } RetryAction::Drop => WatchSet::new(self.host).remove(watch), } } + + /// Clear the watch's first-refusal marker: an accepted submit ends + /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] + /// refusal earns a fresh one-block grace. No-op when no marker is + /// set. + pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.refused_key()) + } } diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index e271fa41..f73c9adf 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -8,8 +8,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, - Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, REFUSED_PREFIX, + Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; use nexum_sdk_test::MockHost; @@ -362,6 +362,14 @@ fn seeded_watch(host: &MockHost) -> String { .unwrap() } +fn tick_at(block: u64, epoch_s: u64) -> Tick { + Tick { + chain_id: 1, + block, + epoch_s, + } +} + #[test] fn ledger_try_next_block_leaves_the_store_untouched() { let host = MockHost::new(); @@ -372,7 +380,7 @@ fn ledger_try_next_block_leaves_the_store_untouched() { .apply( WatchRef::parse(&key).unwrap(), RetryAction::TryNextBlock, - 1_000, + &tick_at(100, 1_000), ) .unwrap(); @@ -387,7 +395,11 @@ fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { let ledger = Retrier::new(&host); ledger - .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .apply( + watch, + RetryAction::Backoff { seconds: 30 }, + &tick_at(100, 1_000), + ) .unwrap(); let gates = Gates::new(&host); @@ -410,7 +422,11 @@ fn ledger_backoff_saturates_on_the_epoch_clock() { let watch = WatchRef::parse(&key).unwrap(); Retrier::new(&host) - .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .apply( + watch, + RetryAction::Backoff { seconds: u64::MAX }, + &tick_at(100, 1_000), + ) .unwrap(); assert_eq!( @@ -427,17 +443,109 @@ fn ledger_drop_removes_the_watch_and_its_gates() { Gates::new(&host).set_next_block(watch, 500).unwrap(); Retrier::new(&host) - .apply(watch, RetryAction::Drop, 1_000) + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) .unwrap(); assert!(host.store.is_empty(), "watch and gates must go"); } +#[test] +fn ledger_drop_on_repeat_grants_one_next_block_retry() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + // First refusal: the block is recorded and the watch gates to the + // next block instead of dropping. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "first refusal keeps the watch"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &100_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &101_u64.to_le_bytes().to_vec(), + ); + + // A repeat at the same block leaves the store untouched. + let before = host.store.snapshot(); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + assert_eq!(host.store.snapshot(), before); + + // A repeat on a later block removes the watch and every derived key. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(101, 1_012)) + .unwrap(); + assert!(host.store.is_empty(), "watch, gates, and marker must go"); +} + +#[test] +fn ledger_clear_refusal_resets_the_one_block_grace() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger.clear_refusal(watch).unwrap(); + assert!(!host.store.snapshot().contains_key(&watch.refused_key())); + + // A refusal at a later block after the clear is a fresh first + // refusal: the watch survives and the marker records the new block. + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(105, 1_060)) + .unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "the watch must survive"); + assert_eq!( + snapshot.get(&watch.refused_key()).unwrap(), + &105_u64.to_le_bytes().to_vec(), + ); + assert_eq!( + snapshot.get(&watch.next_block_key()).unwrap(), + &106_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_refused_marker() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + let ledger = Retrier::new(&host); + ledger + .apply(watch, RetryAction::DropOnRepeat, &tick_at(100, 1_000)) + .unwrap(); + ledger + .apply(watch, RetryAction::Drop, &tick_at(100, 1_000)) + .unwrap(); + + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with(REFUSED_PREFIX)), + "drop must not orphan the refusal marker", + ); +} + #[test] fn retry_action_labels_are_stable_snake_case() { - let cases: [(RetryAction, &str); 3] = [ + let cases: [(RetryAction, &str); 4] = [ (RetryAction::TryNextBlock, "try_next_block"), (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::DropOnRepeat, "drop_on_repeat"), (RetryAction::Drop, "drop"), ]; for (action, label) in cases { diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 5ccad1f2..0c87c63b 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -36,6 +36,8 @@ videre-status-body = { path = "../videre-status-body" } http.workspace = true strum.workspace = true thiserror.workspace = true +# Best-effort fault logs on the sweep's non-critical store cleanups. +tracing.workspace = true wit-bindgen.workspace = true [dev-dependencies] diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 8ca91a3e..732a271a 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -65,10 +65,14 @@ impl Keeper { /// run every other outcome and every venue refusal through the /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on - /// acceptance, so an accepted body never reaches the venue twice; - /// a `requires-signing` answer journals nothing and is surfaced - /// afresh each sweep. Store faults abort the sweep; venue refusals - /// never do - they fold into per-watch retry actions. + /// acceptance - the watch's first-refusal marker is then cleared + /// best-effort - so a journalled acceptance is never resubmitted. + /// The record is not atomic with the submit: an acceptance whose + /// journal write faults can still resubmit. A `requires-signing` + /// answer journals nothing and is surfaced afresh each sweep. + /// Store faults abort the sweep, bar the post-acceptance marker + /// clear; venue refusals never do - they fold into per-watch + /// retry actions. pub async fn sweep(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, @@ -105,6 +109,15 @@ impl Keeper { match self.venues.submit(&self.venue, body).await { Ok(SubmitOutcome::Accepted(_)) => { journal.record(&key)?; + // The acceptance is journalled; the marker + // clear is cleanup and must not abort the + // sweep. + if let Err(fault) = retrier.clear_refusal(watch) { + tracing::error!( + %fault, + "refusal-marker clear failed after journalled acceptance", + ); + } report.submitted += 1; continue; } @@ -123,7 +136,7 @@ impl Keeper { RetryAction::Drop => report.dropped += 1, _ => report.retried += 1, } - retrier.apply(watch, action, tick.epoch_s)?; + retrier.apply(watch, action, tick)?; } Ok(report) } @@ -191,6 +204,7 @@ pub fn retry_action(fault: &VenueFault) -> RetryAction { mod tests { use std::cell::RefCell; + use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::MockLocalStore; @@ -310,6 +324,45 @@ mod tests { assert_eq!(venue.submitted.borrow().len(), 1); } + #[test] + fn accepted_body_clears_the_refusal_marker() { + let host = MockLocalStore::default(); + let key = put_watch(&host); + let watch = WatchRef::parse(&key).expect("well-formed key"); + host.set(&watch.refused_key(), &50_u64.to_le_bytes()) + .expect("marker writes"); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("sweep runs"); + assert_eq!(report.submitted, 1); + assert!( + host.get(&watch.refused_key()) + .expect("marker reads") + .is_none(), + "acceptance must clear the first-refusal marker", + ); + } + + #[test] + fn refusal_marker_clear_fault_does_not_abort_a_journalled_acceptance() { + let host = MockLocalStore::default(); + put_watch(&host); + host.fail_on("refused:", Fault::Unavailable("store down".into())); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); + + let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) + .expect("marker-clear fault must not abort the sweep"); + assert_eq!(report.submitted, 1); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + Journal::submitted(&host) + .contains(&key) + .expect("journal reads"), + "acceptance must be journalled before the marker clear", + ); + } + #[test] fn a_changed_body_submits_afresh() { let host = MockLocalStore::default(); From aec462a102ac5ce63e50d3b7c0a577d51fd34479 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 12:12:23 +0000 Subject: [PATCH 082/139] docs: rewrite platform docs as the shipped-venue source of truth (#474) docs: rewrite the platform docs as the shipped-venue source of truth Doc 05 documents both personas as shipped: the videre-sdk surface, the venue and keeper macros, the typed client, and an author-a-venue walkthrough on the echo pair, with the CoW crates as the production instance. Doc 08 names venue adapters as the domain-extension mechanism, replaces the Layer-3 world-include pattern with the videre:venue contract and the registry route, marks shepherd:cow as the retired legacy read path (cow-events stays as the event-ABI package of record), and squares the WIT layout, server table, and SDK layering with the tree. One inbound sdk.md anchor follows the doc 05 heading. --- docs/05-sdk-design.md | 543 +++++++++++++++++++---------- docs/08-platform-generalisation.md | 141 ++++---- docs/sdk.md | 85 ++--- 3 files changed, 461 insertions(+), 308 deletions(-) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index eff5eed4..085bbf97 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,107 +1,101 @@ -# SDK Design: The Two-Persona SDK Plan - -This document describes the guest-side SDK crates and the plan that -shapes them: a **module-author persona** and a **venue-adapter -persona**, each with its own crate pair and attribute macro. The -module-author persona is shipped and is what this document mostly -describes; the venue-adapter persona is design intent tracked by a -separate epic and is called out explicitly as such wherever it -appears below. - -For the architectural decision behind the host-trait seam that the -module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). -For the rustdoc-level API reference (the source of truth once you are -writing module code), see [`sdk.md`](sdk.md) and the rustdoc under -`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and -`crates/nexum-module-macros/`. +# SDK Design: The Two-Persona SDK -## The two personas +This document describes the guest-side SDK crates. There are two +personas, and both are shipped: the **module author**, served by +`nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which +covers both sides of a venue: the adapter author who speaks one venue's +protocol, and the keeper author who drives venues through the typed +client. + +For the architectural decision behind the host-trait seam both personas +build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the +rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc +under `crates/nexum-sdk/` and `crates/videre-sdk/`. -The runtime has two kinds of guest authors, and they need different -things from the SDK: +## The two personas 1. **Module author.** Writes an automation module against - `nexum:host/event-module` (or the CoW-extended `shepherd:cow/shepherd` - world): react to blocks, chain logs, ticks, or messages; read and - write local state; submit orders. This persona is served today by - `nexum-sdk` (+ the `#[nexum::module]` macro, spelled - `#[nexum_sdk::module]` in code) and, for CoW-specific modules, - `shepherd-sdk` on top. - -2. **Venue adapter author.** Writes an adapter that exposes a trading - venue (CoW Protocol, a DEX, a lending market, ...) to modules - through a common intent surface, so a module author does not need - to know the venue's wire format. This persona is planned but not - yet shipped: the crate (`videre-sdk`), the per-venue crates - (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the - `#[nexum::venue]` macro, and the `videre-test` conformance kit - are all tracked by the SDK-surfaces epic and have no code in the - tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) - below for the shape of the plan. - -Each persona has its own proc-macro crate (`nexum-module-macros` for -modules, `videre-macros` for venue adapters) and both share the + `nexum:host/event-module`: react to blocks, chain logs, ticks, or + messages; read and write local state. Served by `nexum-sdk` plus the + `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). + +2. **Venue author.** Writes the component that exposes a trading venue + (CoW Protocol, a DEX, a lending market, ...) to modules through the + `videre:venue` intent surface, so a module author never sees the + venue's wire format. Served by `videre-sdk`: the `VenueAdapter` + trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the + `videre-test` conformance kit. + +A keeper - a module that drives venues - sits between the two: it is a +module by world, but it authors with `#[videre_sdk::keeper]` and calls +venues through the typed `VenueClient`. The domain itself (CoW, a DEX) +lives in the venue adapter, never in the host: see +[doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). + +Each persona has its own proc-macro crate (`nexum-module-macros`, +`videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so strategy logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. -## Module-author persona (shipped): `nexum-sdk` + `shepherd-sdk` - -### Crate structure +## Crate layout ``` -nexum-sdk/ -├── Cargo.toml +nexum-sdk/ # universal module SDK (host-neutral, domain-free) └── src/ - ├── lib.rs # crate docs, `pub use nexum_module_macros::module` - ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) + ├── lib.rs # crate docs, `pub use nexum_module_macros::module` + ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError - ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters - ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier - ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader + ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters + ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier + ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader ├── events.rs # native alloy Log assembly from the wire ChainLog record ├── config.rs # (key, value) config-table lookups, decimal scaling ├── address.rs # EVM address parsing with typed errors ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) - ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam - └── proptests.rs # cfg(test) property tests (not part of the public surface) + └── tracing.rs # guest tracing facade + panic hook over a LogSink seam -nexum-module-macros/ -├── Cargo.toml # proc-macro = true -└── src/ - └── lib.rs # #[module] attribute macro +nexum-module-macros/ # #[module] attribute macro (proc-macro) -shepherd-sdk/ -├── Cargo.toml +videre-sdk/ # venue SDK: both venue sides └── src/ - ├── lib.rs # crate docs; no re-export of nexum-sdk - ├── prelude.rs # cowprotocol order/signing/orderbook re-exports - ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost - ├── cow/ # CowApiHost trait, gpv2_to_order_data, Verdict, LegacyRevertAdapter, - │ # RetryAction classifiers, run() (poll -> gate/journal/submit) - └── proptests.rs # cfg(test) property tests (not part of the public surface) + ├── lib.rs # crate docs, macro re-exports (venue, keeper, IntentBody) + ├── adapter.rs # VenueAdapter trait (init + the five intent functions) + ├── body.rs # IntentBody trait + BodyError (versioned borsh codec) + ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues + ├── keeper.rs # Keeper::sweep - the generic sweep assembler; Sweep, SweepReport + ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch + ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError + ├── rt.rs # completes async keeper handlers on the sync guest boundary + └── bindings.rs # the shared import-only bindgen the macros remap onto + +videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) + +videre-test/ # venue conformance kit +└── src/ # CodecVectors, HeaderGoldens, MockTransport, reference venue + +cow-venue/ # the CoW venue, as feature slices +composable-cow/ # ComposableCoW keeper machinery (body, poll seam, sweep) ``` `nexum-sdk` is host-neutral and domain-free: any module targeting the runtime pulls helpers and canonical primitive types from it regardless -of which world it exports. `shepherd-sdk` depends on `nexum-sdk` and -layers the CoW Protocol domain on top; modules that touch the -orderbook import both crates directly (nothing is re-exported between -them). `shepherd-sdk` has not been retired - the clean break described -in the SDK epic (folding its CoW surface into a future `cow-venue` -crate) is deferred to a follow-on train, sequenced after the -venue-adapter persona lands. +of which world it exports. `videre-sdk` layers the venue platform's +guest surface on top. Domain crates (`cow-venue`, `composable-cow`) +depend on the SDKs; nothing is re-exported between layers. Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over -`ChainHost` / `LocalStoreHost` / `LoggingHost`) and `shepherd-sdk-test` -(composes those mocks with `MockCowApi`). See -[Testing](#testing-nexum-sdk-test-and-shepherd-sdk-test) below. +`ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and +keepers, and `videre-test` for adapters. See +[Testing](#testing-nexum-sdk-test-and-videre-test) below. + +## Module-author persona: `nexum-sdk` ### The host-trait seam -Neither crate calls `wit_bindgen`-generated functions directly. -Instead `nexum-sdk::host` exposes small traits that mirror the WIT +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. +Instead `nexum_sdk::host` exposes small traits that mirror the WIT interfaces: ```rust @@ -121,39 +115,33 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`, -`cow_api_request`), and -its own supertrait `CowHost: Host + CowApiHost`. Strategy code takes -`&impl Host` (or a narrower `` bound -when it only needs part of the surface) so tests inject -`nexum_sdk_test::MockHost` while the compiled module injects the -wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) -for the full rationale (four traits over one fat trait, the -`strategy.rs` / `lib.rs` split, and the world-neutral `HostError` -predecessor that per-interface typed errors later replaced - see -[ADR-0011](adr/0011-per-interface-typed-errors.md)). +Strategy code takes `&impl Host` (or a narrower +`` bound when it only needs part of the +surface) so tests inject `nexum_sdk_test::MockHost` while the compiled +module injects the wit-bindgen-backed adapter. See +[ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and +[ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error +model. ### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` -Every module still keeps its own `wit_bindgen::generate!` call (the -macro emits types into the calling crate; re-exporting wit-bindgen -output from a library crate would duplicate symbols and break the +Every module keeps its own `wit_bindgen::generate!` call (the macro +emits types into the calling crate; re-exporting wit-bindgen output +from a library crate would duplicate symbols and break the component-export contract). What the SDK removes is the ~80 lines of -mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` -declarative macro emits a `WitBindgenHost` struct, the `ChainHost` / -`LocalStoreHost` / `LoggingHost` impls over the generated import +mechanical glue that used to sit next to it: the +`nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a +`WitBindgenHost` struct, the trait impls over the generated import shims, the `Fault` / `ChainError` converters in both directions, a -`Level` <-> wit-bindgen `logging::Level` converter, a -`From for nexum_sdk::events::Log` impl, and an -`install_tracing()` helper that routes `tracing::info!(...)` through -the bound host logging call. The adapter is capability-selected: the -zero-argument form emits the full set for blanket-world modules, and -the `caps: [chain, logging]` form (what `#[nexum_sdk::module]` -generates from the manifest) emits only the pieces whose imports the -module's world carries. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` -layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. - -### The `#[nexum::module]` macro +`Level` converter, a `From for nexum_sdk::events::Log` impl, +and an `install_tracing()` helper that routes `tracing::info!(...)` +through the bound host logging call. The adapter is +capability-selected: the zero-argument form emits the full set for +blanket-world modules, and the `caps: [chain, logging]` form (what +`#[nexum_sdk::module]` generates from the manifest) emits only the +pieces whose imports the module's world carries. + +### The `#[nexum_sdk::module]` macro `nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose @@ -190,37 +178,130 @@ impl HttpProbe { } ``` -Two things worth being precise about, since they differ from earlier -drafts of this plan: - -- **One macro, not two.** There is no separate `#[shepherd::module]`. - The macro reads the crate's `module.toml` and generates against a - per-module world whose imports are exactly the - `[capabilities].required`/`optional` declarations (a chain + - local-store module simply has no `cow-api` or `identity` bindings to - call). This retires the import-elision dependency ADR-0009 flagged - for macro-built modules: their imports equal their declarations by - construction, and the runtime's capability check is a backstop - rather than a consumer of toolchain dead-import elision. Declaring - `cow-api` (or `pool`) pulls that import into the world, and the - module layers its own domain adapter (a `CowApiHost` impl over the - generated shims) on top of the emitted core one. +Two things worth being precise about: + +- **The world is derived from the manifest.** The macro generates + against a per-module world whose imports are exactly the + `[capabilities].required`/`optional` declarations: a chain + + local-store module simply has no `identity` bindings to call. Its + imports equal its declarations by construction, and the runtime's + capability check is a backstop rather than a consumer of toolchain + dead-import elision. - **Handlers are synchronous.** `init` and the named handlers are - plain `fn`, called directly with no `block_on` wrapper. There is no - `async fn` handler support and no injected `&RootProvider` - modules + plain `fn`, called directly with no `block_on` wrapper. Modules call `host.request(chain_id, method, params_json)` (or the `chain::eth_call_params` / `parse_eth_call_result` helpers) directly against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). + (Keeper handlers are the exception: see below.) + +The `Guest`/`export!` shape the macro emits follows the `strategy.rs` +(pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus +the macro attribute) split from ADR-0009. The keeper primitives in +`nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, +`ConditionalSource`, `Retrier` - give conditional-commitment modules +a shared set of `LocalStoreHost` conventions instead of hand-rolled +key schemes; `videre_sdk::keeper` assembles them into the generic +sweep. + +## Venue persona: `videre-sdk` + +### The venue contract + +An adapter targets the `videre:venue/venue-adapter` world: it exports +the `adapter` interface (`body-versions`, `derive-header`, `quote`, +`submit`, `status`, `cancel`) plus `init`, and imports scoped +transport only - `chain`, `messaging`, and allowlisted `wasi:http`. +No local-store, remote-store, identity, or logging import: an adapter +structurally cannot touch host key material or persistent state. +Keepers reach venues through the host-implemented +`videre:venue/client` interface, which mirrors `adapter` with a venue +selector per call. The types (`intent-header`, `quotation`, +`submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in +`videre:types`, over the `videre:value-flow` asset vocabulary. + +### Bodies: the `IntentBody` derive + +A venue's intent body is opaque on the wire; typing is a guest-side +agreement between keeper and adapter, spelled as an outer per-venue +version enum under `#[derive(videre_sdk::IntentBody)]`: -The `Guest`/`export!` shape the macro emits still follows the -`strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` -(handlers plus the macro attribute) split from ADR-0009. The keeper -helpers in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, -`ConditionalSource`, `Retrier` - give conditional-commitment -modules (watchers that poll a set of pending commitments) a shared set -of `LocalStoreHost` conventions instead of hand-rolled key schemes. +```rust +#[derive(videre_sdk::IntentBody)] +enum EchoBody { + V1(u64), +} +``` + +The wire form is the borsh enum layout: a one-byte version tag (the +variant's declaration index) then the borsh payload - so the tag order +is the schema: append new versions, never reorder. Decoding an unknown +tag fails typedly as `BodyError::UnknownVersion` rather than as a +stringly decode error. The versions an adapter decodes are declared in +its manifest `[venue] body_versions` and asserted at install against +its `body-versions` export; a keeper declares the single +`[venue] body_version` it encodes and install refuses it unless every +installed adapter decodes that version. + +### `#[videre_sdk::venue]` + +The single blessed venue authoring path. Apply it to the adapter's +`impl VenueAdapter for MyVenue` block: the macro reads the crate's +`module.toml`, asserts its `[module] kind` is `venue-adapter`, +synthesizes a per-component world exporting the `videre:venue/adapter` +face and importing exactly the manifest's declared scoped transport, +then emits the `wit_bindgen::generate!` call, the untouched trait +impl, and the export glue. An undeclared capability's bindings do not +exist (using one is a compile error), and a capability outside the +venue-permitted set (`chain`, `messaging`, `http`) is rejected at +expansion. The generated world remaps the shared interfaces onto +`videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly +and shares type identity with the conformance kit and the client core. + +### The typed client and `#[videre_sdk::keeper]` + +The wire carries opaque bodies and a stringly venue selector; typing +is recovered in `videre_sdk::client`. A keeper names a venue once, as +a `Venue` marker carrying its `VenueId` and body schema: + +```rust +struct CowVenue; +impl Venue for CowVenue { + const ID: VenueId = VenueId::from_static("cow"); + type Body = CowIntentBody; +} +``` -### Testing: `nexum-sdk-test` and `shepherd-sdk-test` +`VenueClient` then drives the venue with typed bodies - `quote` +returns a `Quoted` typestate whose `submit` sends exactly the priced +bytes - encoding through `IntentBody` before the byte-level, +native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the +module's own `videre:venue/client` import; tests implement +`VenueTransport` in memory. + +`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it +to an `impl` block whose associated functions are the event handlers +(`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, +`on_intent_status`). It requires the `client` capability (the +`videre:venue/client` import is what makes a keeper a keeper), wires +that import onto the SDK's shared shims, and lets handlers be `async` +so they can await the typed client directly; `videre_sdk::rt` +completes the futures on the synchronous guest boundary. A +`From` impl onto the wire fault is emitted, so `?` +applies to client calls inside handlers. + +`videre_sdk::keeper::Keeper::sweep` assembles the world-neutral +`nexum_sdk::keeper` stores - `WatchSet` to `Gates` to +`ConditionalSource::poll` to `Retrier` to `Journal` - over the +`VenueTransport` seam, so a conditional-commitment keeper writes one +`poll` producing the shared `Sweep` outcome and inherits the whole +gate/journal/retry pass. + +### Testing: `nexum-sdk-test` and `videre-test` + +Keeper strategy logic tests exactly as module logic does: against the +host traits with `nexum_sdk_test::MockHost`, plus an in-memory +`VenueTransport` for the client seam. Tests run as plain native Rust - +no `wasm32-wasip2` target, no wasmtime instance, no network. ```rust use nexum_sdk::host::*; @@ -233,79 +314,159 @@ assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); assert_eq!(host.chain.calls().len(), 1); ``` -`MockHost` composes one mock per trait (`chain`, `store`, `logging` -in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on the -`shepherd:cow/cow-api` seam, backed by `MockCowApi` by default or the -per-call-scriptable `MockVenue` via `MockHost::with_venue()`), each -recording calls and letting tests program responses. Tests run as -plain native Rust against the traits - no `wasm32-wasip2` target, no -wasmtime instance, no network round-trip. This is the whole SDK-side -testing story today. (The runtime crate separately ships a -feature-gated component-level harness - `nexum-runtime`'s -`test_utils::TestRuntime`, behind the `test-utils` feature - that -loads a compiled `.wasm` plus manifest under real wasmtime and -dispatches events to it; that is runtime-internal tooling, not part -of the module-author SDK contract.) - -## Venue-adapter persona (planned) - -The venue-adapter persona is the other half of the two-persona plan -and is **not shipped**. It is tracked by a set of open issues under -the SDK-surfaces epic and depends on a venue-adapter WIT world that -does not exist yet either. Nothing below this heading describes code -in this repository; it is recorded here so the module-author persona -above is read in the context of where the SDK is going, not as a -competing vision. - -The planned shape: - -- **`videre-sdk`** - a new crate carrying the guest-side - `VenueAdapter` trait over the (also planned) adapter-world bindgen, - a `borsh`-backed `IntentBody` derive that enforces a per-venue - version enum (an adapter rejects an intent body tagged with an - unknown version rather than misinterpreting it), and typed wrappers - over the scoped transport imports (`http`, `messaging`, `chain`) an - adapter is granted. -- **Per-venue crates** - e.g. a `cow-venue` crate that would carry - CoW Protocol's intent-body codec and become the eventual home for - the CoW helpers `shepherd-sdk::cow` carries today, once the clean - break happens. -- **`#[nexum::venue]`** - a second attribute macro, in `videre-macros`, - parallel to `#[nexum::module]`: it would emit the per-cdylib export - glue for an adapter and a per-component world matching the - manifest's declared capabilities (retiring the import-elision - dependency for the venue side from day one, rather than as - follow-on work). -- **`videre-test`** - a conformance kit: published codec - round-trip vectors (so a non-Rust adapter author can prove - byte-exact `IntentBody` encoding without linking Rust), header- - derivation golden fixtures, and a `MockTransport` for adapter unit - tests. - -Once this lands, `shepherd-sdk`'s CoW surface is expected to move into -the `cow-venue` crate as a single clean-break migration - the same -no-deprecation-window reasoning [ADR-0011](adr/0011-per-interface-typed-errors.md) -gives for pre-1.0 wire breaks applies here - and this document should -be revisited to describe the venue-adapter persona as shipped rather -than planned. +Adapters are held to the `videre-test` conformance kit instead: + +- **`CodecVectors`** - the venue's `IntentBody` wire bytes as a JSON + file (bytes as lowercase hex). A Rust adapter checks its derived + enum with `assert_conforms`; a non-Rust author reads the same file + and proves byte-exactness without linking Rust. +- **`HeaderGoldens`** - published bodies paired with the header a + conforming `derive-header` projects from them. +- **`MockTransport`** - the three transports an adapter is granted + (chain, messaging, outbound HTTP) as programmable in-memory mocks + behind the SDK's own seams. + +(The runtime crate separately ships a feature-gated component-level +harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a +compiled `.wasm` plus manifest under real wasmtime; that is +runtime-internal tooling, not part of either SDK contract.) + +## Walkthrough: authoring a venue on videre + +The shipped reference pair is `modules/examples/echo-venue` (adapter) +and `modules/examples/echo-keeper` (driver); the production instance +of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. + +1. **Declare the manifest.** A venue adapter is a component with a + `module.toml` whose kind names it: + + ```toml + [module] + name = "echo-venue" # the venue id the registry installs it under + version = "0.1.0" + kind = "venue-adapter" + component = "sha256:..." + + [capabilities] + required = ["chain"] # scoped transport only: chain, messaging, http + + [capabilities.http] + allow = [] + + [venue] + body_versions = [1] # must equal the body-versions export; install asserts it + ``` + +2. **Implement `VenueAdapter`.** One impl block under the macro; the + five intent functions plus `init` and `body_versions`: + + ```rust + use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; + + struct EchoVenue; + + #[videre_sdk::venue] + impl VenueAdapter for EchoVenue { + fn init(_config: Config) -> Result<(), Fault> { Ok(()) } + fn body_versions() -> Vec { vec![1] } + fn derive_header(body: Vec) -> Result { /* pure, no I/O */ } + fn quote(body: Vec) -> Result { /* ... */ } + fn submit(body: Vec) -> Result { /* ... */ } + fn status(receipt: Vec) -> Result { /* ... */ } + fn cancel(receipt: Vec) -> Result<(), VenueError> { /* ... */ } + } + ``` + + `derive_header` is the policy seam: it projects the guard-facing + `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) + from a body, pure and I/O-free, and the host's egress guard runs on + it before every submit. + +3. **Publish the fixtures.** Ship the venue's codec vectors and header + goldens, and hold the adapter to them with `videre-test` in the + crate's tests. Non-Rust keeper authors read the same files. + +4. **Build and install.** Build the cdylib for `wasm32-wasip2` + (`cargo build --target wasm32-wasip2 --release -p echo-venue`) and + install it via the engine config: + + ```toml + [[adapters]] + path = "target/wasm32-wasip2/release/echo_venue.wasm" + manifest = "modules/examples/echo-venue/module.toml" + http_allow = [] # the operator's outbound-HTTP grant + ``` + + Install boots the component, checks the `body-versions` handshake + against the manifest, and registers the venue under its manifest + name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). + +5. **Drive it from a keeper.** A keeper declares the `client` + capability plus its `[venue] body_version`, names the venue as a + `Venue` marker, and speaks types end to end: + + ```rust + #[videre_sdk::keeper] + impl EchoKeeper { + async fn on_block(block: types::Block) -> Result<(), Fault> { + let venue = VenueClient::::new(); + let quoted = venue.quote(&EchoBody::V1(block.number)).await?; + if let SubmitOutcome::Accepted(receipt) = quoted.submit().await? { + let _status = venue.status(&receipt).await?; + } + Ok(()) + } + } + ``` + + An accepted submit is watched implicitly: the registry polls the + adapter's `status` and fans transitions back as `intent-status` + events, which the keeper subscribes to in its manifest and handles + in `on_intent_status`. + +## The CoW venue + +CoW ships as the production instance of the persona, in two crates so +the venue stays orderbook-only: + +- **`cow-venue`** - feature slices. `body` (default, `no_std`): the + order intent body types and codec, light enough for any keeper or + adapter to carry. `client`: the typed `CowClient` bound to the CoW + venue, the deterministic `intent_id` journal key, and the + table-driven retry classification generated from the shipped + `data/classification.toml`. `assembly`: the chain-edge order + projections and orderbook submission bodies. `adapter`: the venue + adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, + manifest at `crates/cow-venue/module.toml`). +- **`composable-cow`** - the ComposableCoW keeper machinery, kept out + of the venue: the conditional-order `ComposableBody`, the structured + poll seam (`Verdict`, with the deployed 1.x reverting wire + quarantined behind `LegacyRevertAdapter`, per + [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the + `sweep` slice composing the poll loop over the typed `CowClient`. + +The shipped CoW keepers - `modules/twap-monitor`, +`modules/ethflow-watcher`, `modules/examples/stop-loss` - are ordinary +`#[videre_sdk::keeper]` modules on this surface. ## Non-Rust module and adapter authors For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is relevant - they generate bindings directly from the WIT package for -their target world with their language's `wit-bindgen`. The WIT is -the universal contract; both Rust SDKs are an ergonomics layer on top -of it, not a requirement. +their target world with their language's `wit-bindgen`, and prove body +conformance against the venue's published `videre-test` fixture files. +The WIT is the universal contract; both Rust SDKs are an ergonomics +layer on top of it, not a requirement. ## Where to go next - [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry - point for module authors. + point. - [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam decision this document builds on. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed - error model (`Fault`, `ChainError`, `CowApiError`) the host traits - return. + error model the host traits return. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough - design and why module authors call `host.request` directly rather - than through an injected provider. + design and why module authors call `host.request` directly. +- [doc 08](08-platform-generalisation.md) - the layered WIT and why + venue adapters are the domain-extension mechanism. diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index 110bf3c0..fc9023d9 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -39,22 +39,23 @@ These six primitives are orthogonal: Together they cover the full spectrum: persistent truth (chain), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (messaging), and diagnostics (logging). -The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) One additional **additive** capability - `http` (allowlisted) - is declared via the manifest's `[capabilities]` section but is not part of the six-primitive core; it is serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one. +The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) Two additional **additive** capabilities are declared via the manifest's `[capabilities]` section but are not part of the six-primitive core: `http` (allowlisted), serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one, and `client`, the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). ## Architectural Principle: Layered WIT Worlds -The current `shepherd` world conflates universal blockchain runtime capabilities with CoW Protocol domain-specific interfaces. To enable reuse across platforms and domains, the WIT is split into layers: +Universal runtime capabilities and domain-specific surfaces live in separate layers: ```mermaid graph TD - subgraph L3["Layer 3: Application-Specific Worlds"] - COW["shepherd:cow - cow + order (CoW Protocol automation)"] - DEFI["myapp:defi - vault + strategy (DeFi yield app)"] - GAME["game:engine - physics + assets (on-chain game)"] + subgraph L3["Layer 3: Domain Venues (adapter components)"] + COW["cow-venue - CoW Protocol orderbook"] + DEX["dex-venue - a DEX (hypothetical)"] + LEND["lend-venue - a lending market (hypothetical)"] end subgraph L2["Layer 2: Capability Extensions (optional, composable)"] - UI["ui - user interface bridge (interactive modules)"] + VC["videre:venue/client - typed intent access to installed venues"] + UI["ui - user interface bridge (planned)"] end subgraph L1["Layer 1: Universal Runtime Interfaces"] @@ -67,11 +68,11 @@ graph TD EXP["Exports: init(config) + on-event(event)"] end - L3 -->|"builds on via WIT include"| L2 - L2 -->|"builds on via WIT include"| L1 + L3 -->|"exports videre:venue/adapter, routed by the host through"| L2 + L2 -->|"adds imports to"| L1 ``` -Each layer builds on the one below via WIT `include`. A module compiled against Layer 1 alone runs on any conforming host. A module compiled against Layer 3 (e.g. `shepherd:cow`) requires a host that implements Layers 1 + the CoW extension. +Layer 1 is the world every module compiles against. A Layer 2 capability adds an import to a module's manifest-derived world: a keeper module declares `client` and gains `videre:venue/client`. Layer 3 is not a world at all: a domain enters the system as a **venue adapter component** installed into the host's venue registry, and modules reach it through the Layer 2 client interface. No module compiles against a domain world - see [Layer 3](#layer-3-domain-extensions-venue-adapters). ## Layer 1: Universal Interfaces @@ -576,54 +577,48 @@ price-dashboard/ The host loads `index.html` into a WebView and injects the bridge JavaScript that connects DOM events to `on-interact` and `ui::render` calls to DOM updates. -## Layer 3: Domain Extensions +## Layer 3: Domain Extensions (Venue Adapters) -Domain-specific interfaces extend the universal layer for particular use cases. The pattern: +A domain (CoW Protocol, a DEX, a lending market) extends the platform as a **venue adapter**: a component authored with `#[videre_sdk::venue]` that exports the `videre:venue/adapter` interface and imports scoped transport only (`chain`, `messaging`, allowlisted `wasi:http`). This is the domain-extension mechanism - the domain's wire protocol, body codec, and error projection live inside the adapter component, and nothing domain-specific enters the host or any module world. ```wit -package shepherd:cow@0.1.0; - -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - record http-failure { status: u16, body: option } - record order-rejection { status: u16, error-type: string, description: string, data: option } - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - submit-order: func(chain-id: chain-id, order-data: list) - -> result; +package videre:venue@0.1.0; + +/// Worker (keeper) face. The host holds the venue registry; the keeper +/// names a venue by string. +interface client { + use videre:types/types.{quotation, receipt, intent-status, submit-outcome, venue-error}; + + quote: func(venue: string, body: list) -> result; + submit: func(venue: string, body: list) -> result; + observe: func(venue: string, receipt: receipt) -> result<_, venue-error>; + status: func(venue: string, receipt: receipt) -> result; + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; } -world shepherd { - include nexum:host/event-module; - import cow-api; +/// Provider (venue) face. Mirrors `client` without the venue selector: +/// one installed adapter answers for exactly one venue. +interface adapter { + use videre:types/types.{intent-header, quotation, receipt, intent-status, submit-outcome, venue-error}; + + body-versions: func() -> list; + derive-header: func(body: list) -> result; + quote: func(body: list) -> result; + submit: func(body: list) -> result; + status: func(receipt: receipt) -> result; + cancel: func(receipt: receipt) -> result<_, venue-error>; } ``` -Other domains follow the same pattern: +The two faces meet in the host. The venue platform (`crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this whole path - typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). -```wit -// Hypothetical DeFi yield module -package defi:yield@0.1.0; +A new domain therefore adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter - the engine itself stays venue- and cow-free. -interface vault { /* ... */ } -interface strategy { /* ... */ } +### The legacy read path: `shepherd:cow` -world yield-module { - include nexum:host/event-module; - import vault; - import strategy; -} -``` +The retired predecessor to venue adapters was a Layer-3 *world* extension: the `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world including `event-module` and importing it, and a host-side extension cone implementing the interface over a cached orderbook client. That model put the domain in the module's own world and a backend in every host that ran it; each new domain would have needed its own world, host cone, and SDK glue. -The `include` mechanism ensures that any domain-specific module inherits the full universal interface set. A `shepherd` module can call `chain::request`, `identity::sign`, `local-store::get`, `remote-store::upload`, `messaging::publish`, and `logging::log` - plus the CoW-specific `cow-api::request` and `cow-api::submit-order`. +The path is deleted: the `cow-api` interface, the `shepherd`/`cow-ext` worlds, and the host cone are gone, and orderbook I/O lives in the `cow-venue` adapter behind `videre:venue/client`. The `shepherd:cow` package remains only as `cow-events`, the package of record for the CoW on-chain event ABIs (signatures and topic-0 hashes) that keeper manifests and decoders are parity-tested against. [ADR-0005](adr/0005-cow-api-via-cached-orderbookapi.md) and [ADR-0006](adr/0006-cow-twap-ethflow-host-helpers.md) are superseded accordingly. ## Complete WIT Package Layout @@ -637,23 +632,29 @@ wit/ │ ├── remote-store.wit # remote-store interface (Swarm) │ ├── messaging.wit # messaging interface (Waku) │ ├── logging.wit # logging interface -│ ├── ui.wit # ui interface + host-capabilities (planned hosts only) │ ├── event-module.wit # event-module world (6 imports) -│ ├── query-module.wit # experimental: query-module world (no host impl in 0.2) -│ └── app-module.wit # app-module world (includes ui) - design only +│ └── query-module.wit # experimental: query-module world (no host impl in 0.2) +│ +├── videre-value-flow/ +│ └── types.wit # asset + asset-amount vocabulary +│ +├── videre-types/ +│ └── types.wit # intent-header, quotation, receipt, submit-outcome, intent-status, venue-error +│ +├── videre-venue/ +│ └── venue.wit # client + adapter interfaces, venue-adapter world │ └── shepherd-cow/ - ├── cow-api.wit # merged cow-api interface (request + submit-order) - └── shepherd.wit # shepherd world (includes event-module + cow-api) + └── cow-events.wit # CoW event-ABI package of record (legacy package name) ``` -The `nexum-host` package is domain-agnostic and reusable. The `shepherd-cow` package is the CoW Protocol extension. New domains add new packages without touching the universal layer. +The `nexum-host` package is domain-agnostic and reusable. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages: the universal and venue layers are closed. (The `ui` interface and `app-module` world are design-only and ship no WIT yet.) ## Platform Targets ### Server Runtime (Reference Implementation - Nexum) -This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum distribution with CoW Protocol support. +This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum composition root that registers the videre venue platform and bundles the `cow-venue` adapter. | Interface | Implementation | |-----------|---------------| @@ -663,7 +664,7 @@ This is the current design (docs 01-07), adapted for the layered WIT. Shepherd i | `remote-store` | Bee API (`http://localhost:1633`) - operator runs a Bee node | | `messaging` | Waku node (nwaku) via JSON-RPC or REST API | | `logging` | `tracing` crate -> JSON structured logs | -| `cow-api` | reqwest HTTP client -> CoW Protocol API (REST passthrough + typed `submit-order`) | +| `videre:venue/client` | videre-host `VenueRegistry` -> installed venue adapter components (CoW via the bundled `cow-venue` adapter over allowlisted `wasi:http`) | | Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | | WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | @@ -867,7 +868,7 @@ The super app adds a capability-grant layer on top of the WIT world. When a modu ✓ remote-store - read/write to Swarm network ✓ messaging - send/receive messages (topics: /nexum/1/twap-*) ✗ ui - (not requested - event-driven module) - ✓ cow-api - interact with CoW Protocol API and submit orders + ✓ client - submit intents to installed venues (cow) [Allow] [Deny] ``` @@ -970,26 +971,15 @@ The content hash is the trust anchor. The transport is interchangeable. ## SDK Layering -The SDK is designed to mirror the WIT layering. **The two-crate split is shipped: `nexum-sdk` carries the universal surface (host-trait seam, bind macro, chain / config / address helpers, `http::fetch`, tracing facade) and `shepherd-sdk` layers the CoW domain on top, with no re-export between them.** The host-trait seam is from [ADR-0009](adr/0009-host-trait-surface.md). The diagram below describes the 0.3+ target for the typed-client layer: +The SDK mirrors the architecture, one crate per layer, with no re-export between them. See [doc 05](05-sdk-design.md) for the full treatment. -```mermaid -graph TD - subgraph ShepherdSDK["shepherd-sdk (Domain-specific: CoW Protocol)"] - COW_ITEMS["Cow client,\n#[shepherd::module] macro\n(imports cow-api)"] - end - - subgraph NexumSDK["nexum-sdk (Universal: any blockchain app)"] - NEXUM_ITEMS["HostTransport, provider(),\nTypedState, RemoteStore,\nMessaging, Signer,\nlogging macros,\nFault / HostFault / ChainError,\n#[nexum::module] macro\n(imports chain + identity\n+ local-store\n+ remote-store + messaging\n+ logging)"] - end - - ShepherdSDK -->|"extends"| NexumSDK -``` +- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, the `#[nexum_sdk::module]` attribute macro, chain / config / address helpers, the `http` fetch seam over wasi:http, the keeper store primitives, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore`, `Messaging`, and `Signer` typed wrappers as future direction. Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. +- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic sweep assembler and the `videre-test` conformance kit alongside. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`Verdict`, `RetryAction`, `gpv2_to_order_data`, `LegacyRevertAdapter`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **Per-venue crates (shipped for CoW)** - each domain ships as crates on the venue layer, not as an SDK layer: `cow-venue` (body codec, typed client, adapter component) and `composable-cow` (conditional-order keeper machinery). A new domain adds its own. -A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. +A generic automation module depends only on `nexum-sdk`; a keeper adds `videre-sdk` and the venue's body crate; a venue adapter depends on `videre-sdk` and its own crate. For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. @@ -1014,10 +1004,11 @@ For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnece | `event-module` world (0.2, shipping) | Event-driven modules - server today, mobile/background planned | | `query-module` world (0.2 experimental) | Request/response modules - WIT published, no host impl in 0.2 | | `app-module` world | Interactive modules - design only; planned hosts | -| `shepherd:cow` WIT package | CoW Protocol domain extension | -| `shepherd` world | CoW automation modules (includes event-module + cow-api) | -| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / HostFault / ChainError, bind macro, chain / config / address helpers, guest `http` helper, tracing facade. HostTransport, TypedState, RemoteStore, Messaging, Signer remain future direction | -| `shepherd-sdk` crate (shipped) | CoW-domain Rust SDK: cow-api trait + CoW helpers on top of `nexum-sdk`, no re-export between the layers. | +| `videre:types` / `videre:value-flow` / `videre:venue` WIT packages | Venue-neutral intent contract: types, asset vocabulary, client + adapter faces, venue-adapter world | +| `shepherd:cow` WIT package | CoW event-ABI package of record (`cow-events` only; the legacy `cow-api` read path and `shepherd` world are retired) | +| Venue adapter components | The domain-extension mechanism: `#[videre_sdk::venue]` components installed into the videre platform (`cow-venue` shipped) | +| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / ChainError, bind macro, module macro, chain / config / address helpers, keeper store primitives, guest `http` helper, tracing facade | +| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, sweep assembler; `videre-test` conformance kit alongside | | Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | | Host Adapter | Platform-specific implementation of universal interfaces | diff --git a/docs/sdk.md b/docs/sdk.md index 4d48a7fa..1e93cd36 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,19 +1,20 @@ -# The module SDK: nexum-sdk + shepherd-sdk +# The module SDK: nexum-sdk + videre-sdk `nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the `#[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). +`videre-sdk` layers the venue surface on top: the typed venue client, +the intent-body codec, the `VenueAdapter` seam and the keeper sweep. +Modules that talk to a venue 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/`, +site under `target/doc/nexum_sdk/` and `target/doc/videre_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-module-macros -p videre-macros --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p nexum-sdk -p videre-sdk -p nexum-module-macros -p videre-macros --no-deps --open ``` ## Authoring a module @@ -27,7 +28,7 @@ The macro generates the rest of the per-cdylib glue: the `wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` adapter, a `Guest` impl whose `on_event` dispatches to whichever handlers are present (absent handlers no-op), and `export!`. See -[doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked +[doc 05](05-sdk-design.md#the-nexum_sdkmodule-macro) for a worked example and the `nexum-module-macros` rustdoc for the fine print. ## Supported host capabilities @@ -36,9 +37,9 @@ The SDK is host-neutral - it does not call wit-bindgen-generated functions directly. Instead, it exposes traits that mirror the on-the-wire host interfaces, and modules adapt their wit-bindgen imports to the traits at the cdylib boundary (the -`bind_host_via_wit_bindgen!` / `bind_cow_host_via_wit_bindgen!` -macros generate that adapter). The traits in -[`nexum_sdk::host`][host-doc] and [`shepherd_sdk::cow`][cow-doc] are: +`bind_host_via_wit_bindgen!` macro generates that adapter). The traits +in [`nexum_sdk::host`][host-doc] and the venue seam in +[`videre_sdk::client`][client-doc] are: | Trait | Mirrors | What it does | |---|---|---| @@ -46,36 +47,35 @@ macros generate that adapter). The traits in | `LocalStoreHost` | `nexum:host/local-store@0.1.0` | Per-module key-value store | | `LoggingHost` | `nexum:host/logging@0.1.0` | Structured log lines tagged by module | | `Host` | supertrait | Bundles the three core traits; blanket impl | -| `CowApiHost` (shepherd-sdk) | `shepherd:cow/cow-api@0.1.0` | Orderbook submission (`POST /api/v1/orders`) | -| `CowHost` (shepherd-sdk) | supertrait | `Host` + `CowApiHost` for orderbook strategies | +| `VenueTransport` (videre-sdk) | `videre:venue/client@0.1.0` | Quote, submit, observe, status and cancel against an installed venue adapter | A module declaring `[capabilities].required = ["chain", "local-store", -"cow-api", "logging"]` in its `module.toml` matches the host trait -seam one-for-one. +"client", "logging"]` in its `module.toml` matches the host trait seam +one-for-one. [host-doc]: ../target/doc/nexum_sdk/host/index.html -[cow-doc]: ../target/doc/shepherd_sdk/cow/index.html +[client-doc]: ../target/doc/videre_sdk/client/index.html ## Modules - [`nexum_sdk::prelude`](../target/doc/nexum_sdk/prelude/index.html) - and [`shepherd_sdk::prelude`](../target/doc/shepherd_sdk/prelude/index.html) - - bulk re-exports. The nexum prelude covers the alloy primitives - (`Address`, `B256`, `Bytes`, `U256`, `keccak256`); the shepherd - prelude covers cowprotocol's order / signing / orderbook surface. - -- [`cow`](../target/doc/shepherd_sdk/cow/index.html) - CoW Protocol - bridging: - - `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::Verdict` + `cow::composable::LegacyRevertAdapter` - - typed dispatch over the five `IConditionalOrder` custom errors - (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, - `PollTryAtEpoch`, `PollNever`). - - `cow::error::RetryAction` + `cow::error::classify_api_error` - - map `cow_api::submit_order` failures into `TryNextBlock` / - `Backoff(s)` / `Drop`. + - bulk re-exports covering the alloy primitives (`Address`, `B256`, + `Bytes`, `U256`, `keccak256`). `videre-sdk` has no prelude; it + re-exports its surface from the crate root. + +- [`client`](../target/doc/videre_sdk/client/index.html) - the typed + venue seam (in `videre-sdk`): a `Venue` marker drives `VenueClient`, + which encodes through `IntentBody` before the byte-level + `VenueTransport` seam. `keeper::retry_action` folds a `VenueFault` + into a `RetryAction`. + +- CoW-specific pieces live in their own L3 crates rather than the SDK: + `cow_venue::assembly::gpv2_to_order_data` converts the on-chain + `GPv2OrderData` into the typed `OrderData` the orderbook signs + against, and `composable_cow::{Verdict, LegacyRevertAdapter}` give + typed dispatch over the five `IConditionalOrder` custom errors + (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, + `PollTryAtEpoch`, `PollNever`). - [`chain`](../target/doc/nexum_sdk/chain/index.html) - `eth_call` JSON plumbing (in `nexum-sdk`): @@ -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::LegacyRevertAdapter(s)` - the `chain-error` - rpc revert bytes -> typed `Verdict` - lives in `shepherd-sdk`.) + (The CoW-specific `LegacyRevertAdapter` - the `chain-error` rpc + revert bytes to a typed `Verdict` - lives in `composable-cow`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait seam plus the SDK's host-neutral `Fault` vocabulary (same cases @@ -108,20 +108,21 @@ seam one-for-one. failures. See `modules/examples/http-probe` for a complete module. -## Companions: nexum-sdk-test and shepherd-sdk-test +## Companions: nexum-sdk-test and videre-test -Add `nexum-sdk-test` as a dev-dep on the module crate to write -strategy tests against in-memory mocks; CoW modules use -`shepherd-sdk-test`, whose `MockHost` composes the generic mocks with -`MockCowApi`. See the crate docs +Add `nexum-sdk-test` as a dev-dep on the module crate to write strategy +tests against in-memory mocks; its `MockHost` covers the chain, local +store and logging seams. `videre-test` is the venue-side kit: codec +vectors and header goldens for conformance runs, plus transport mocks +such as `MockFetch`. See the crate docs ([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), -[shepherd-sdk-test](../crates/shepherd-sdk-test/src/lib.rs)) for the -usage pattern. +[videre-test](../crates/videre-test/src/lib.rs)) for the usage +pattern. ## Versioning The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` -and `crates/shepherd-sdk/` in the shepherd monorepo. They are not yet +and `crates/videre-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. The `cowprotocol` crate is published to crates.io; the workspace From 149ee80014e0cbb8f857b08c22563cadabf13caa Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 14:42:53 +0000 Subject: [PATCH 083/139] nexum-runtime: reject colliding extension claims at boot (#529) feat(nexum-runtime): reject colliding extension claims at boot One boot-time uniqueness pass fails fast on a service namespace, subscription kind, or manifest section two wired extensions both claim, each of which silently dedupes downstream. --- crates/nexum-runtime/src/supervisor.rs | 32 ++++++++++ crates/nexum-runtime/src/supervisor/tests.rs | 62 ++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index fd369eeb..ebddddd8 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -365,6 +365,36 @@ fn enforce_extension_sections( Ok(()) } +/// Refuse a string two wired extensions both claim, fail-fast at boot, in +/// any of three classes: service namespace, subscription kind, manifest +/// section. Each class dedupes silently downstream, so an unchecked +/// collision routes to whichever extension the map or `.any()` scan hits +/// first. +fn enforce_extension_uniqueness( + extensions: &[Arc>], +) -> Result<()> { + let mut namespaces = BTreeSet::new(); + let mut kinds = BTreeSet::new(); + let mut sections = BTreeSet::new(); + for ext in extensions { + let namespace = ext.namespace(); + if !namespaces.insert(namespace) { + return Err(anyhow!("extension namespace {namespace} is claimed twice")); + } + for kind in ext.subscriptions() { + if !kinds.insert(*kind) { + return Err(anyhow!("subscription kind {kind} is claimed twice")); + } + } + for section in ext.manifest_sections() { + if !sections.insert(*section) { + return Err(anyhow!("manifest section [{section}] is claimed twice")); + } + } + } + Ok(()) +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -395,6 +425,7 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { + enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; // Provider kinds the boot loop resolves manifest kinds against. @@ -489,6 +520,7 @@ impl Supervisor { extensions: &[Arc>], clocks: Option, ) -> Result { + enforce_extension_uniqueness(extensions)?; let registry = capability_registry(extensions); let services = HostServices::from_extensions(extensions)?; let entry = ModuleEntry { diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 2617869f..947a17fc 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -63,6 +63,68 @@ fn extension_sections_must_be_claimed() { assert!(err.to_string().contains("keeper"), "{err}"); } +/// Two extensions colliding on a subscription kind or a manifest section +/// are refused at boot; a non-colliding set passes the uniqueness pass. +#[test] +fn extension_claims_must_be_unique() { + struct Claiming { + namespace: &'static str, + subscriptions: &'static [&'static str], + sections: &'static [&'static str], + } + impl Extension for Claiming { + fn namespace(&self) -> &'static str { + self.namespace + } + fn capabilities(&self) -> crate::manifest::NamespaceCaps { + crate::manifest::NamespaceCaps { + prefix: "acme:ext/", + ifaces: &[], + } + } + fn link(&self, _linker: &mut Linker>) -> anyhow::Result<()> { + Ok(()) + } + fn subscriptions(&self) -> &'static [&'static str] { + self.subscriptions + } + fn manifest_sections(&self) -> &'static [&'static str] { + self.sections + } + } + fn ext( + namespace: &'static str, + subscriptions: &'static [&'static str], + sections: &'static [&'static str], + ) -> Arc> { + Arc::new(Claiming { + namespace, + subscriptions, + sections, + }) + } + + enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["fills"], &["pool"]), + ]) + .expect("non-colliding set boots"); + + let err = enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["orders"], &["pool"]), + ]) + .expect_err("duplicate subscription kind"); + assert!(err.to_string().contains("orders"), "{err}"); + + let err = enforce_extension_uniqueness(&[ + ext("a", &["orders"], &["venue"]), + ext("b", &["fills"], &["venue"]), + ]) + .expect_err("duplicate manifest section"); + assert!(err.to_string().contains("[venue]"), "{err}"); +} + #[tokio::test] async fn empty_supervisor_returns_no_subscriptions() { let engine = make_wasmtime_engine(); From 0014460f56b6efc3b95ccc40c9eb44e8ac1e5455 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 14:43:44 +0000 Subject: [PATCH 084/139] runtime: name all four components builder params in CoreRuntime (#532) fix(nexum-runtime): spell out CoreRuntime::components logs builder param --- crates/nexum-runtime/src/preset.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 56fe1629..5a0b811c 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -91,7 +91,9 @@ impl Runtime for CoreRuntime { type ExtBuilder = (); type LogsBuilder = LogPipelineBuilder; - fn components(self) -> ComponentsBuilder { + fn components( + self, + ) -> ComponentsBuilder { ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) } From a5573a3f038127678efdf6c51cdea929e09b38f0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 23:18:14 +0000 Subject: [PATCH 085/139] videre-host: document handshake admission boundaries (#533) docs(videre-host): note admission-check boundaries Document the opt-in precondition on admit_worker/admit_provider (a manifest omitting [venue] is admitted unconditionally) and the post-instantiation, pre-init ordering of the registry export-divergence check. --- crates/videre-host/src/handshake.rs | 6 ++++++ crates/videre-host/src/registry.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs index a1aa23d7..2544bd49 100644 --- a/crates/videre-host/src/handshake.rs +++ b/crates/videre-host/src/handshake.rs @@ -44,6 +44,9 @@ fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyh /// Admit one provider: a `[venue]` section, when present, must be the /// adapter shape with a non-empty version set. +/// +/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an +/// intentional silent opt-out for non-venue keepers. pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { let Some(value) = sections.get(SECTION) else { return Ok(()); @@ -91,6 +94,9 @@ pub(crate) fn verify_exported_versions( /// `[venue] body_versions` set contains that version. Every installed /// venue is a legal runtime submit target, so one non-decoding adapter /// refuses the keeper. +/// +/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an +/// intentional silent opt-out for non-venue keepers. pub(crate) fn admit_worker( worker: &str, sections: &ExtensionSections, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index 88a44392..d00f5b14 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -746,6 +746,11 @@ impl ProviderKind for VenueAdapterKind { .await .map_err(anyhow::Error::from) .context("read adapter body-versions")?; + // Post-instantiation, pre-init: an export cannot be called before + // instantiating, so unlike the pre-compile manifest-section + // predicates in `supervisor.rs` a buggy or malicious adapter fully + // instantiates, running any instantiation side effects, before this + // divergence check catches the mismatch. crate::handshake::verify_exported_versions(venue_id.as_str(), &declared, exported)?; match bindings .call_init(&mut store, &config) From ee7848e25b25babdaa801e0202e5f91daae72c29 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 23:19:09 +0000 Subject: [PATCH 086/139] test: pin unknown-venue path when registry service absent (#531) * test(videre-host): pin unknown-venue on a registry-less client boot * docs(videre-host): correct the registry-less wrapper variance note --- crates/videre-host/tests/platform.rs | 132 ++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index ff3506f2..55053ff6 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -15,9 +15,9 @@ use nexum_runtime::engine_config::{ AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, }; use nexum_runtime::host::component::ChainMethod; -use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent, ProviderManifest}; use nexum_runtime::host::state::HostState; -use nexum_runtime::manifest::CapabilityRegistry; +use nexum_runtime::manifest::{CapabilityRegistry, ExtensionSections, NamespaceCaps}; use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; use videre_host::bindings::{ @@ -1081,3 +1081,131 @@ async fn e2e_crash_looping_adapter_is_poisoned() { Err(VenueError::Unavailable(_)) )); } + +// ── service-missing unknown-venue ───────────────────────────────────── + +/// The videre platform with its registry service withheld: forwards +/// every face `boot_single` consults to [`Videre`] save `service`, which +/// returns `None`, so `HostServices::from_extensions` seeds no venue +/// registry. The provider and event faces default (unused under +/// `boot_single`). +struct ClientWithoutRegistry(Videre); + +impl Extension for ClientWithoutRegistry { + fn namespace(&self) -> &'static str { + Extension::::namespace(&self.0) + } + + fn capabilities(&self) -> NamespaceCaps { + Extension::::capabilities(&self.0) + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + Extension::::link(&self.0, linker) + } + + fn manifest_sections(&self) -> &'static [&'static str] { + Extension::::manifest_sections(&self.0) + } + + fn subscriptions(&self) -> &'static [&'static str] { + Extension::::subscriptions(&self.0) + } + + fn admit_worker( + &self, + worker: &str, + sections: &ExtensionSections, + providers: &[ProviderManifest], + ) -> anyhow::Result<()> { + Extension::::admit_worker(&self.0, worker, sections, providers) + } +} + +/// The service-lookup miss, end to end: a keeper importing +/// `videre:venue/client` boots through `boot_single` with no registry +/// service seeded, so `client.rs` resolves every venue call to +/// `unknown-venue` before any adapter is consulted. Distinct from the +/// adapter-map miss, where the registry is present and the venue id is +/// merely unlisted. +#[tokio::test] +async fn client_without_registry_service_resolves_every_venue_to_unknown() { + let Some(wasm) = module_wasm_or_skip("echo-client") else { + return; + }; + + // A [venue]-free manifest: no adapter boots under `boot_single`, so a + // keeper declaring `[venue] body_version` would be refused before it + // could reach the client face at all. + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("echo-client.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "echo-client" + +[capabilities] +required = ["client", "logging"] + +[[subscription]] +kind = "block" +chain_id = 1 +"#, + ) + .expect("write manifest"); + + let engine = make_wasmtime_engine(); + let extensions: Vec>> = vec![Arc::new(ClientWithoutRegistry( + platform(&EngineConfig::default()), + ))]; + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + // The precondition of the client.rs unknown-venue branch: the booted + // service map holds no venue registry. + assert!( + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .is_none(), + "boot_single must seed no registry service", + ); + + // One chain-1 block drives the keeper's quote then submit; with no + // registry both resolve to unknown-venue, which the keeper absorbs. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + assert_eq!(supervisor.alive_count(), 1, "the keeper stays alive"); + + 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("quote at echo-venue was refused")), + "quote resolved to unknown-venue; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submit to echo-venue was refused")), + "submit resolved to unknown-venue; records were: {messages:?}", + ); +} From 06f820b03ed8406e781d643545da7fd1e3ae43b3 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 23:20:11 +0000 Subject: [PATCH 087/139] runtime: add PresetBuilder::with_components escape hatch (#530) * feat(nexum-runtime): give PresetBuilder an escape hatch to the component seams Add PresetBuilder::with_components, mapping the preset's ComponentsBuilder to the builders to open so an embedder overrides one seam (chain, store, ext, or logs) while the preset's extensions and add-ons carry through. The new PresetComponentsBuilder stage gathers those before consuming the preset and launches otherwise as PresetBuilder::launch. * test(nexum-runtime): load the real manifest in the preset with_components e2e The manifest path derived from wasm.ancestors().nth(3) resolved to /target, so the explicit manifest pointed at a nonexistent file and the loader silently fell back to an anonymous module. Derive the repo root once and reuse it for both the wasm fixture and the manifest. --- crates/nexum-runtime/src/builder.rs | 189 +++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index fda2b575..0d551d00 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -27,7 +27,7 @@ use nexum_tasks::{DrainOutcome, TaskExit, TaskHandle, TaskManager, TaskSet}; use tracing::{error, info, warn}; use wasmtime::Engine; -use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOn}; +use crate::addons::{AddOnHandle, AddOns, AddOnsContext, RuntimeAddOn}; use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, @@ -434,6 +434,34 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } + /// Override the preset's component builders before launch. `map` receives + /// the preset's [`ComponentsBuilder`] and returns the builders to open, so + /// an embedder swaps one seam (e.g. logs or store) while keeping the rest; + /// the preset's extensions and add-ons carry through unchanged. The preset + /// path's mirror of [`TypedBuilder::with_components`]. + pub fn with_components( + self, + map: impl FnOnce( + ComponentsBuilder, + ) -> ComponentsBuilder, + ) -> PresetComponentsBuilder<'a, R::Types, C, S, E, L> { + // Gather the preset's extensions and add-ons before `components` + // consumes the preset by value. + let mut extensions = self.preset.extensions(self.config); + extensions.extend(self.extensions); + let add_ons = self.preset.add_ons(); + let components = map(self.preset.components()); + PresetComponentsBuilder { + config: self.config, + extensions, + add_ons, + wasm: self.wasm, + manifest: self.manifest, + clocks: self.clocks, + components, + } + } + /// Open the preset's backends and launch. Builds the [`Components`] bundle /// from the preset's component builders, gathers the preset's extensions /// (appended ones after), installs the preset's add-ons, then drives @@ -475,6 +503,59 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } } +/// A preset with its component builders overridden through +/// [`PresetBuilder::with_components`]: the preset's extensions and add-ons are +/// already gathered, leaving only [`launch`](Self::launch). +pub struct PresetComponentsBuilder<'a, T: RuntimeTypes, C, S, E, L> { + config: &'a EngineConfig, + extensions: Vec>>, + add_ons: AddOns, + wasm: Option, + manifest: Option, + clocks: Option, + components: ComponentsBuilder, +} + +impl PresetComponentsBuilder<'_, T, C, S, E, L> +where + T: RuntimeTypes, + C: ComponentBuilder, + S: ComponentBuilder, + E: ComponentBuilder, + L: ComponentBuilder, +{ + /// Open the overridden backends and launch, otherwise as + /// [`PresetBuilder::launch`]. + pub async fn launch(self) -> anyhow::Result { + let tasks = TaskManager::new(); + let executor = tasks.executor(); + let data_dir = self.config.engine.state_dir.clone(); + let build_ctx = BuilderContext { + config: self.config, + data_dir: &data_dir, + executor: &executor, + }; + // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is + // consumed by the launch call, so both must stay in scope for that call. + let add_on_refs: Vec<&dyn RuntimeAddOn> = self.add_ons.iter().map(|a| &**a).collect(); + let components = self.components.build::(&build_ctx).await?; + + let runtime = AssembledRuntime { + components, + extensions: self.extensions, + add_ons: &add_on_refs, + wasm: self.wasm.as_deref(), + manifest: self.manifest.as_deref(), + clocks: self.clocks, + }; + let ctx = LaunchContext { + tasks, + config: self.config, + }; + runtime.launch(ctx).await + } +} + /// The lattice is bound; extensions and an optional module-source override /// may be added before the component builders. pub struct TypedBuilder<'a, T: RuntimeTypes> { @@ -793,6 +874,112 @@ mod tests { ); } + /// A core-lattice preset with no add-ons, so the `with_components` tests + /// avoid the process-global Prometheus recorder the `CoreRuntime` preset + /// installs (only one such install succeeds per process). + struct NoAddOnCore; + + impl crate::sealed::SealedRuntime for NoAddOnCore {} + + impl RuntimePreset for NoAddOnCore { + type Types = CoreRuntime; + type ChainBuilder = ProviderPoolBuilder; + type StoreBuilder = LocalStoreBuilder; + type ExtBuilder = (); + type LogsBuilder = LogPipelineBuilder; + + fn components(self) -> ComponentsBuilder { + ComponentsBuilder::new(ProviderPoolBuilder, LocalStoreBuilder, ()) + } + + fn add_ons(&self) -> AddOns { + Vec::new() + } + } + + /// Counts builds, so a test observes an overridden seam reaching the + /// launch's component build. + struct CountingLogsBuilder(Arc); + + impl ComponentBuilder for CountingLogsBuilder { + type Output = LogPipeline; + async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(LogPipeline::in_memory(ctx.config.limits.logs())) + } + } + + /// `with_components` overrides a seam on the preset path: the substituted + /// logs builder drives the launch's component build (once), which then + /// bails on the empty module set. Locks the preset escape hatch. + #[tokio::test] + async fn preset_with_components_overrides_a_seam() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let built = Arc::new(AtomicUsize::new(0)); + let seen = built.clone(); + let err = match RuntimeBuilder::new(&config) + .with_runtime(NoAddOnCore) + .with_components(move |c| c.with_logs(CountingLogsBuilder(seen))) + .launch() + .await + { + Ok(_) => panic!("default config declares no modules; launch must bail"), + Err(err) => err, + }; + assert!(err.to_string().contains("no modules to run"), "{err}"); + assert_eq!( + built.load(Ordering::SeqCst), + 1, + "overridden logs builder ran once", + ); + } + + /// Full preset-path launch with the logs seam overridden through + /// `with_components`: the run reads the substituted pipeline and the + /// trigger-to-wait handshake stops it. Skips when the module fixture is + /// not built (`just build-module`). + #[tokio::test] + async fn e2e_preset_with_components_launches_through_overridden_logs() { + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("repo root"); + let wasm = repo_root.join("target/wasm32-wasip2/release/example.wasm"); + if !wasm.exists() { + eprintln!( + "SKIP: {} not found - run `just build-module` to enable E2E tests", + wasm.display() + ); + return; + } + let manifest = repo_root.join("modules/example/module.toml"); + + let dir = tempfile::tempdir().expect("tempdir"); + let mut config = EngineConfig::default(); + config.engine.state_dir = dir.path().join("state"); + + let custom = LogPipeline::in_memory(config.limits.logs()); + let mut handle = RuntimeBuilder::new(&config) + .with_runtime(NoAddOnCore) + .with_module_source(Some(wasm), Some(manifest)) + .with_components(|c| c.with_logs(Prebuilt(custom.clone()))) + .launch() + .await + .expect("launch through the overridden logs seam"); + + assert!( + Arc::ptr_eq(&handle.logs().router(), &custom.router()), + "run reads the overridden pipeline", + ); + + handle.shutdown(); + handle.wait().await.expect("clean shutdown"); + } + /// when every configured module fails `init`, launch must /// abort with an operator-facing error instead of idling behind an /// empty event loop. From 7cff3d4453674346332cf681046746a37e18032e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Thu, 23 Jul 2026 23:22:22 +0000 Subject: [PATCH 088/139] host: tighten venue-registry install seam and mutex discipline (#534) * docs(videre-host): note install mutex discipline * refactor(videre-host): gate install to pub(crate) Install is boot-time only; drop it off the shared public handle so a post-boot clone cannot register with no compiler signal. Out-of-crate tests reach a mock adapter through the test-utils install_for_test seam. --- Cargo.lock | 1 + crates/videre-host/Cargo.toml | 11 +++++++++++ crates/videre-host/src/registry.rs | 18 +++++++++++++++++- crates/videre-host/tests/platform.rs | 2 +- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b2b7ece..6733e983 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5959,6 +5959,7 @@ dependencies = [ "tokio", "toml 1.1.2+spec-1.1.0", "tracing", + "videre-host", "videre-status-body", "wasmtime", ] diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml index a8c6dd11..e45d28d3 100644 --- a/crates/videre-host/Cargo.toml +++ b/crates/videre-host/Cargo.toml @@ -26,7 +26,18 @@ tokio.workspace = true toml.workspace = true tracing.workspace = true +[features] +# Test-only helpers: the direct `VenueRegistry::install_for_test` seam, so an +# out-of-crate test installs a mock adapter without the provider boot path. +# Off by default; the self dev-dependency below turns it on for this crate's +# own tests. +test-utils = [] + [dev-dependencies] +# Self dev-dependency enabling `test-utils` for this crate's own test targets, +# so `cargo test -p videre-host` sees `install_for_test` without every +# invocation passing `--features test-utils`. +videre-host = { path = ".", features = ["test-utils"] } nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } nexum-tasks = { path = "../nexum-tasks" } tempfile.workspace = true diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index d00f5b14..c33a70db 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -347,12 +347,17 @@ impl VenueRegistry { /// adapters answering the same venue would silently shadow one another, /// which is a config error worth failing boot over. A dead incumbent is /// replaced: that is the sweep restarting a trapped adapter. - pub fn install( + /// + /// Crate-internal: adapters install at provider boot, never post-boot + /// through a shared handle clone. + pub(crate) fn install( &self, venue: VenueId, liveness: Liveness, invoker: impl VenueInvoker + 'static, ) -> Result<(), DuplicateVenue> { + // Takes the adapter-map mutex only for the synchronous insert; never + // held across an await. let mut adapters = self.inner.adapters.lock().expect("adapter map poisoned"); if adapters.get(&venue).is_some_and(|v| v.liveness.is_alive()) { return Err(DuplicateVenue { venue }); @@ -367,6 +372,17 @@ impl VenueRegistry { Ok(()) } + /// Test-only direct install, bypassing the provider boot path. + #[cfg(feature = "test-utils")] + pub fn install_for_test( + &self, + venue: VenueId, + liveness: Liveness, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + self.install(venue, liveness, invoker) + } + /// Resolve a venue id to its installed adapter slot. An uninstalled /// venue is `unknown-venue`; an installed but dead one is `unavailable` /// pending the supervisor's restart sweep, without touching its diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 55053ff6..3011ee60 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -281,7 +281,7 @@ impl VenueInvoker for ScriptedAdapter { fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { let registry = VenueRegistryBuilder::new(Default::default()).build(); registry - .install( + .install_for_test( VenueId::from("cow"), nexum_runtime::host::actor::Liveness::default(), adapter, From e0144d93c5f602ce153e56f2eb721cc13eecb231 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 02:27:28 +0000 Subject: [PATCH 089/139] modules: delete stop-loss example subsumed by composablecow monitor (#570) examples: remove stop-loss module --- .github/workflows/ci.yml | 4 +- Cargo.lock | 15 - Cargo.toml | 1 - Dockerfile | 6 +- crates/nexum-runtime/src/supervisor/tests.rs | 2 +- crates/nexum-sdk/src/chain/chainlink.rs | 2 +- crates/nexum-sdk/src/config.rs | 3 +- docs/00-overview.md | 2 +- docs/05-sdk-design.md | 4 +- docs/deployment/docker.md | 2 +- docs/production.md | 5 +- docs/testing-runtime-harness.md | 2 +- engine.docker.toml | 6 +- engine.e2e.toml | 9 +- engine.m3.toml | 15 +- engine.soak.docker.toml | 4 - engine.soak.toml | 6 +- justfile | 15 +- modules/examples/stop-loss/Cargo.toml | 27 - modules/examples/stop-loss/module.toml | 72 --- modules/examples/stop-loss/src/lib.rs | 62 -- modules/examples/stop-loss/src/strategy.rs | 613 ------------------- scripts/e2e-report-gen.sh | 6 +- scripts/e2e-run.sh | 5 +- scripts/lib.sh | 3 +- 25 files changed, 35 insertions(+), 856 deletions(-) delete mode 100644 modules/examples/stop-loss/Cargo.toml delete mode 100644 modules/examples/stop-loss/module.toml delete mode 100644 modules/examples/stop-loss/src/lib.rs delete mode 100644 modules/examples/stop-loss/src/strategy.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0f3b9f..deb34074 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: - uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 with: tool: nextest - # Build all 18 guest wasms ONCE (17 modules + the cow adapter, + # Build all 17 guest wasms ONCE (16 modules + the cow adapter, # release/wasm32-wasip2): the single # source of truth for guest buildability and the artifacts the integration # tests load. Replaces the deleted 9-way build-module matrix, which recompiled @@ -88,7 +88,7 @@ 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 echo-venue \ + -p balance-tracker -p http-probe -p echo-venue \ -p echo-client -p echo-keeper -p clock-reader -p flaky-bomb -p flaky-venue \ -p fuel-bomb -p memory-bomb -p panic-bomb -p slow-host # Separate invocation on purpose: unifying `cow-venue/adapter` diff --git a/Cargo.lock b/Cargo.lock index 6733e983..9a1416af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5258,21 +5258,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "stop-loss" -version = "0.1.0" -dependencies = [ - "alloy-primitives", - "alloy-sol-types", - "cow-venue", - "cowprotocol", - "nexum-sdk", - "nexum-sdk-test", - "tracing", - "videre-sdk", - "wit-bindgen 0.59.0", -] - [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index 37235e3f..27461972 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,6 @@ members = [ "modules/examples/echo-venue", "modules/examples/http-probe", "modules/examples/price-alert", - "modules/examples/stop-loss", "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", "modules/fixtures/flaky-venue", diff --git a/Dockerfile b/Dockerfile index fa19d90f..8cc4bf90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,7 +69,7 @@ COPY --from=planner /src/recipe.json recipe.json RUN cargo chef cook --release -p shepherd --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p twap-monitor -p ethflow-watcher -p price-alert \ - -p balance-tracker -p stop-loss --recipe-path recipe.json \ + -p balance-tracker --recipe-path recipe.json \ && cargo chef cook --release --target wasm32-wasip2 \ -p cow-venue --features cow-venue/adapter --recipe-path recipe.json @@ -82,14 +82,13 @@ COPY . . # is used verbatim so builds are reproducible. RUN cargo build -p shepherd --release --locked -# Five production modules plus the bundled cow venue adapter. The wasm +# Four production modules plus the bundled cow venue adapter. The wasm # artefacts land under # `target/wasm32-wasip2/release/.wasm`. RUN cargo build -p twap-monitor --target wasm32-wasip2 --release --locked \ && cargo build -p ethflow-watcher --target wasm32-wasip2 --release --locked \ && cargo build -p price-alert --target wasm32-wasip2 --release --locked \ && cargo build -p balance-tracker --target wasm32-wasip2 --release --locked \ - && cargo build -p stop-loss --target wasm32-wasip2 --release --locked \ && cargo build -p cow-venue --target wasm32-wasip2 --release --locked --features adapter # ----------------------------------------------------------------- runtime @@ -126,7 +125,6 @@ COPY --from=build /src/modules/twap-monitor/module.toml /opt/shepherd/manifes COPY --from=build /src/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml -COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml # The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 947a17fc..829eae09 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -153,7 +153,7 @@ fn progress_marker_key_uses_numeric_chain_id() { /// corresponding select arm is never selected. /// /// Surfaced when wiring up `engine.m3.toml` for the M3 testnet runbook: -/// the 3 M3 example modules (price-alert, balance-tracker, stop-loss) +/// the M3 example modules (price-alert, balance-tracker) /// all subscribe to blocks only, no logs. The engine bailed within /// ~50 ms of `supervisor ready` until this fix landed. #[tokio::test] diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index 57c49b70..74e04051 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -4,7 +4,7 @@ //! latestRoundData.answer` flow against a Chainlink AggregatorV3 //! oracle. Returns `Some(answer)` on success or `None` on any host / //! decode failure (logging the failure at Warn). Used by oracle-driven -//! example modules (price-alert, stop-loss) so they consume the SDK +//! example modules (price-alert) so they consume the SDK //! instead of redefining the `AggregatorV3` ABI + read loop locally. //! //! The shape is deliberately `Option` rather than diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index fdfcfc9c..f1526293 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -6,8 +6,7 @@ //! repeatedly: required-key lookup, optional-key lookup, and decimal //! parsing for thresholds / amounts. Hoisting these here keeps the //! example modules consuming the SDK rather than re-implementing the -//! same loops around it (each copy in price-alert + stop-loss had -//! started to drift in error wording). +//! same loops around it. use alloy_primitives::{I256, U256}; use thiserror::Error; diff --git a/docs/00-overview.md b/docs/00-overview.md index 3e8427e9..c5d0a6e3 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -363,7 +363,7 @@ shepherd/ ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ ├── ethflow-watcher/ Ethflow order monitoring module -│ └── examples/ price-alert, balance-tracker, stop-loss, http-probe reference modules +│ └── examples/ price-alert, balance-tracker, http-probe reference modules ├── wit/ │ ├── nexum-host/ Universal WIT package (chain, identity, local-store, remote-store, messaging, logging) │ └── shepherd-cow/ CoW Protocol WIT package (cow-api, shepherd) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 085bbf97..f77078e4 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -446,8 +446,8 @@ the venue stays orderbook-only: `sweep` slice composing the poll loop over the typed `CowClient`. The shipped CoW keepers - `modules/twap-monitor`, -`modules/ethflow-watcher`, `modules/examples/stop-loss` - are ordinary -`#[videre_sdk::keeper]` modules on this surface. +`modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` +modules on this surface. ## Non-Rust module and adapter authors diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index a01c4a4e..e6d1220d 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -101,7 +101,7 @@ manifest = "/opt/shepherd/manifests/twap-monitor.toml" [[modules]] path = "/opt/shepherd/modules/ethflow_watcher.wasm" manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -# Add price-alert / balance-tracker / stop-loss the same way. +# Add price-alert / balance-tracker the same way. ``` If you want compose to use this file instead of the bundled diff --git a/docs/production.md b/docs/production.md index 9d65236e..96f57755 100644 --- a/docs/production.md +++ b/docs/production.md @@ -219,8 +219,7 @@ The local-store is a single redb file at `last_dispatched_block:{chain_id}` keys; losing it on a production module forces a from-scratch resync (twap-monitor re-discovers `watch:` from the next `ConditionalOrderCreated` -log; stop-loss re-issues a `submitted:` write if the trigger -fires again). +log). ### 4.1 Cold backup (recommended for first deploy + before upgrades) @@ -461,7 +460,7 @@ defaults. | Class | Modules typical | Fuel/event | Memory cap | Notes | |---|---|---|---|---| | **Light indexer** | price-alert, balance-tracker | 200M | 16 MiB | Block-tick poll + 1-2 RPC reads. Defaults are 5× headroom. | -| **TWAP-style polling** | twap-monitor, stop-loss | 1B (default) | 64 MiB (default) | Per-block `getTradeableOrderWithSignature` calls per registered order; long ABI decode + signature work. Defaults sized for this case. | +| **TWAP-style polling** | twap-monitor | 1B (default) | 64 MiB (default) | Per-block `getTradeableOrderWithSignature` calls per registered order; long ABI decode + signature work. Defaults sized for this case. | | **Multi-chain swarm** | 5+ modules × 2+ chains | 2B | 128 MiB | More headroom for parallel dispatch overhead; modules don't share state, but the per-store wasmtime overhead is per-(module, chain). | A module that consistently traps `OutOfFuel` is a bug, not a diff --git a/docs/testing-runtime-harness.md b/docs/testing-runtime-harness.md index 1e2e4323..b8539b14 100644 --- a/docs/testing-runtime-harness.md +++ b/docs/testing-runtime-harness.md @@ -14,7 +14,7 @@ before writing a runtime test. `nexum-sdk-test::MockHost` (CoW modules: `shepherd-sdk-test::MockHost`). No wasmtime, no component boundary, no engine crate at all. This is already the dominant pattern across every shipped module (twap-monitor, - ethflow-watcher, stop-loss, price-alert, balance-tracker) - see + ethflow-watcher, price-alert, balance-tracker) - see [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-shepherd-sdk-test). **New module-logic tests belong here.** - **The engine harness (this page) is reserved for engine, host, and diff --git a/engine.docker.toml b/engine.docker.toml index 80331746..674e2863 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -55,7 +55,7 @@ rpc_url = "${BASE_RPC_URL}" # ---- modules ---- # -# The image bakes all five production modules at the paths below. +# The image bakes all four production modules at the paths below. # Comment out any you don't intend to run on this deployment. [[modules]] @@ -74,10 +74,6 @@ manifest = "/opt/shepherd/manifests/price-alert.toml" path = "/opt/shepherd/modules/balance_tracker.wasm" manifest = "/opt/shepherd/manifests/balance-tracker.toml" -[[modules]] -path = "/opt/shepherd/modules/stop_loss.wasm" -manifest = "/opt/shepherd/manifests/stop-loss.toml" - # ---- adapters ---- # # The bundled cow venue adapter: the venue registry resolves the `cow` diff --git a/engine.e2e.toml b/engine.e2e.toml index 31960778..3b664336 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -1,16 +1,15 @@ # E2E testnet integration config for nexum. # -# Boots all 5 production + example modules on Sepolia simultaneously +# Boots all 4 production + example modules on Sepolia simultaneously # for the 4-6 h E2E run: # # - twap-monitor (modules/twap-monitor) # - ethflow-watcher (modules/ethflow-watcher) # - price-alert (modules/examples/price-alert) # - balance-tracker (modules/examples/balance-tracker) -# - stop-loss (modules/examples/stop-loss) # # This is the integration step between the M3 single-chain runbook -# (`engine.m3.toml`, 3 modules) and the 7-day soak +# (`engine.m3.toml`, 2 modules) and the 7-day soak # (Sepolia + Arb Sepolia, all modules, no human-in-the-loop). The # E2E run validates correctness in a real-chain dispatch context; # the soak validates stability afterwards. @@ -62,10 +61,6 @@ manifest = "modules/examples/price-alert/module.toml" path = "target/wasm32-wasip2/release/balance_tracker.wasm" manifest = "modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" - # --- adapters --------------------------------------------------------- # The cow venue adapter twap-monitor submits through (`just diff --git a/engine.m3.toml b/engine.m3.toml index fc21fefe..8889dd4b 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -1,16 +1,15 @@ # M3 smoke / validation config for nexum. # -# Boots the 3 M3 example modules (price-alert + balance-tracker + -# stop-loss) against Sepolia. The 3 modules exercise the full SDK -# helper surface (chain::request via Chainlink read, local-store -# diffing, pool submit through the cow adapter with PreSign). +# Boots the 2 M3 example modules (price-alert + balance-tracker) +# against Sepolia. The modules exercise the full SDK helper surface +# (chain::request via Chainlink read, local-store diffing, pool submit +# through the cow adapter with PreSign). # # Usage: # just run-m3 # # or: # cargo build -p price-alert --target wasm32-wasip2 --release # cargo build -p balance-tracker --target wasm32-wasip2 --release -# cargo build -p stop-loss --target wasm32-wasip2 --release # cargo build -p cow-venue --features adapter --target wasm32-wasip2 --release # cargo run -p shepherd -- --engine-config engine.m3.toml @@ -32,13 +31,9 @@ manifest = "modules/examples/price-alert/module.toml" path = "target/wasm32-wasip2/release/balance_tracker.wasm" manifest = "modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" - # --- adapters --------------------------------------------------------- -# The cow venue adapter stop-loss submits through (`just +# The cow venue adapter the modules submit through (`just # build-cow-venue`). Sepolia manifest: the adapter's orderbook must # match the chain the oracle is read on. [[adapters]] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 7efd05cc..c3e93f39 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -43,10 +43,6 @@ manifest = "/opt/shepherd/manifests/price-alert.toml" path = "/opt/shepherd/modules/balance_tracker.wasm" manifest = "/opt/shepherd/manifests/balance-tracker.toml" -[[modules]] -path = "/opt/shepherd/modules/stop_loss.wasm" -manifest = "/opt/shepherd/manifests/stop-loss.toml" - # --- adapters ----------------------------------------------------------- # The cow venue adapter twap-monitor submits through. Sepolia diff --git a/engine.soak.toml b/engine.soak.toml index ba3e78d7..567be71b 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -8,7 +8,7 @@ # cargo build --release -p nexum-cli # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ -# -p balance-tracker -p stop-loss +# -p balance-tracker # cargo build --target wasm32-wasip2 --release \ # -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml @@ -65,10 +65,6 @@ manifest = "modules/examples/price-alert/module.toml" path = "target/wasm32-wasip2/release/balance_tracker.wasm" manifest = "modules/examples/balance-tracker/module.toml" -[[modules]] -path = "target/wasm32-wasip2/release/stop_loss.wasm" -manifest = "modules/examples/stop-loss/module.toml" - # --- adapters --------------------------------------------------------- # The cow venue adapter twap-monitor submits through (`just diff --git a/justfile b/justfile index 019fb0a6..3d957bf4 100644 --- a/justfile +++ b/justfile @@ -46,15 +46,14 @@ build-m2: run-m2: build-m2 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs -# Build the M3 example modules (price-alert + balance-tracker + stop-loss) -# for wasm32-wasip2. +# Build the M3 example modules (price-alert + balance-tracker) for +# wasm32-wasip2. build-m3: cargo build -p price-alert --target wasm32-wasip2 --release cargo build -p balance-tracker --target wasm32-wasip2 --release - cargo build -p stop-loss --target wasm32-wasip2 --release # Run nexum wired for the M3 smoke / validation scenario -# (Sepolia, 3 example modules). See `docs/operations/m3-testnet-runbook.md`. +# (Sepolia, 2 example modules). See `docs/operations/m3-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. run-m3: build-m3 build-cow-venue build-engine @@ -65,11 +64,11 @@ run-m3: build-m3 build-cow-venue build-engine build-http-probe: cargo build -p http-probe --target wasm32-wasip2 --release -# Build all 5 modules required by the E2E run (twap-monitor + -# ethflow-watcher + price-alert + balance-tracker + stop-loss). +# Build all 4 modules required by the E2E run (twap-monitor + +# ethflow-watcher + price-alert + balance-tracker). build-e2e: build-m2 build-m3 -# Run the 4-6 h E2E integration scenario on Sepolia. All 5 modules +# Run the 4-6 h E2E integration scenario on Sepolia. All 4 modules # dispatched simultaneously against a live RPC; metrics scraped at # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers @@ -108,7 +107,7 @@ 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 echo-venue \ + -p balance-tracker -p http-probe -p echo-venue \ -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ -p memory-bomb -p panic-bomb -p slow-host cargo test --workspace --all-features --no-fail-fast diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml deleted file mode 100644 index f0b92be4..00000000 --- a/modules/examples/stop-loss/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "stop-loss" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Shepherd example module: stop-loss order submitter. Watches a Chainlink oracle, submits a CoW order intent through the venue registry when price drops below a configured trigger, dedups via the venue-and-body intent-id." - -[lib] -crate-type = ["cdylib"] - -[dependencies] -cow-venue = { path = "../../../crates/cow-venue", features = ["client"] } -nexum-sdk = { path = "../../../crates/nexum-sdk" } -videre-sdk = { path = "../../../crates/videre-sdk" } -cowprotocol = { version = "0.2.0", default-features = false } -alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } -tracing = { version = "0.1", default-features = false } -wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } - -[dev-dependencies] -# The chain-edge projections back the pinned-UID regression test. -cow-venue = { path = "../../../crates/cow-venue", features = ["client", "assembly"] } -nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `strategy.rs` to encode a synthetic oracle -# return body; the production code uses `nexum_sdk::chain::chainlink`. -alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } diff --git a/modules/examples/stop-loss/module.toml b/modules/examples/stop-loss/module.toml deleted file mode 100644 index b8387417..00000000 --- a/modules/examples/stop-loss/module.toml +++ /dev/null @@ -1,72 +0,0 @@ -# stop-loss example module: watches a Chainlink oracle and submits a -# CoW order intent through the venue registry when the price drops below the -# configured trigger. Demonstrates eth_call + the typed venue client + -# local-store dedup. - -[module] -name = "stop-loss" -version = "0.1.0" -component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" - -[capabilities] -# - logging -> structured runtime logs -# - chain -> eth_call into the Chainlink aggregator -# - local-store -> submitted: / dropped: dedup markers -# - client -> videre:venue/client submit path to the cow adapter -required = ["logging", "chain", "local-store", "client"] -optional = [] - -[capabilities.http] -# All outbound HTTP is the cow adapter's; the module makes no direct -# `http` calls. -allow = [] - -# --- subscriptions ---------------------------------------------------- - -[[subscription]] -kind = "block" -chain_id = 11155111 # Sepolia - -# The one body-schema version this module encodes; install refuses the -# module unless every installed venue adapter decodes it. -[venue] -body_version = 1 - -# --- config ----------------------------------------------------------- - -[config] -# Chainlink AggregatorV3Interface address (ETH/USD on Sepolia). -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" -# Oracle's decimals (Chainlink USD pairs are 8). -decimals = "8" -# Trigger price in the oracle's native decimal units. The Sepolia -# Chainlink ETH/USD feed reports a mocked value around $1681 at the -# time of the E2E run (2026-06-18). Setting the trigger -# *above* the live price + direction=below ensures the strategy fires -# on the first block. -trigger_price = "2000.00" -# Order parameters. The owner pre-signs via GPv2Signing.setPreSignature -# (on-chain, outside this module); the cow adapter posts the unsigned -# body pre-sign on trigger. -# -# E2E run pinning: test EOA on Sepolia with 0.05 ETH -# balance. Without a pre-sign + a WETH wrap the orderbook will reject -# with TransferSimulationFailed, which classifies as retry-next-block; -# that itself is a valid terminal marker and proves the full submit -# path E2E. -owner = "0x7bF140727D27ea64b607E042f1225680B40ECa6A" -# WETH9 Sepolia (`wss://sepolia.etherscan.io/token/0xfff9976782d46cc05630d1f6ebab18b2324d6b14`). -sell_token = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" -# COW token Sepolia (verified on-chain: name="CoW Protocol Token", -# symbol="COW", decimals=18). -buy_token = "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59" -# 0.005 WETH (small enough to fit in the 0.01 WETH wrap budget the -# E2E runbook recommends; large enough that the orderbook's min- -# quote endpoint actually returns a price). -sell_amount_wei = "5000000000000000" -# 20 COW (conservative; current quote on cow.fi/sepolia at the time -# of the E2E run is ~30 COW per 0.005 WETH so a 20 COW buy_amount -# leaves room for slippage without making the order too generous). -buy_amount_wei = "20000000000000000000" -# uint32::MAX = order never expires. -valid_to_seconds = "4294967295" diff --git a/modules/examples/stop-loss/src/lib.rs b/modules/examples/stop-loss/src/lib.rs deleted file mode 100644 index fa226ed9..00000000 --- a/modules/examples/stop-loss/src/lib.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! # stop-loss (example Shepherd module) -//! -//! Watches a Chainlink price oracle on every block. When the price -//! drops at or below `trigger_price`, the module submits a CoW order -//! intent through the venue registry using the parameters from -//! `module.toml::[config]` and persists a `submitted:` marker to dedup -//! re-poll attempts. The cow adapter posts the unsigned order -//! pre-sign; the owner is expected to call -//! `GPv2Signing.setPreSignature` on-chain ahead of the trigger so the -//! orderbook activates the submission. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and unit tests against the -//! `nexum_sdk::host` trait seams and the videre `VenueTransport` -//! seam. It does not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the -//! macro derives the component world from `module.toml`, emits the -//! `WitBindgenHost` adapter, and dispatches each event variant to -//! `strategy` with the typed [`CowClient`] over the module's own -//! `videre:venue/client` import. - -// wit_bindgen::generate! expands to host-import shims whose arity -// matches the WIT signatures, which can exceed clippy's -// too-many-arguments threshold. -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![allow(clippy::too_many_arguments)] - -mod strategy; - -use std::sync::OnceLock; - -use cow_venue::CowClient; - -static SETTINGS: OnceLock = OnceLock::new(); - -struct StopLoss; - -#[videre_sdk::keeper] -impl StopLoss { - fn init(config: Vec<(String, String)>) -> Result<(), Fault> { - install_tracing(); - let cfg = strategy::parse_config(&config)?; - tracing::info!( - "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", - cfg.owner, - cfg.trigger_price_scaled, - cfg.sell_token, - cfg.buy_token, - ); - let _ = SETTINGS.set(cfg); - Ok(()) - } - - fn on_block(block: nexum::host::types::Block) -> Result<(), Fault> { - let Some(cfg) = SETTINGS.get() else { - return Ok(()); - }; - strategy::on_block(&WitBindgenHost, &CowClient::new(), block.chain_id, cfg)?; - Ok(()) - } -} diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs deleted file mode 100644 index 7891f40d..00000000 --- a/modules/examples/stop-loss/src/strategy.rs +++ /dev/null @@ -1,613 +0,0 @@ -//! Pure stop-loss strategy logic. Reads an oracle, optionally submits -//! a CoW order intent through the typed venue client, dedups via -//! local-store. Every interaction with the world flows through the -//! `nexum_sdk::host` trait seams and the videre [`VenueTransport`] -//! under the typed [`CowClient`], so tests drive it against -//! `nexum_sdk_test::MockHost` and a scripted transport. - -use alloy_primitives::I256; -use cow_venue::{BuyToken, CowClient, CowIntent, CowIntentBody, OrderBody, SellToken, intent_id}; -use nexum_sdk::chain::chainlink::read_latest_answer; -use nexum_sdk::config::{self, ConfigError}; -use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost, LoggingHost}; -use nexum_sdk::keeper::RetryAction; -use nexum_sdk::prelude::{Address, U256, hex}; -use videre_sdk::keeper::retry_action; -use videre_sdk::{ClientError, SubmitOutcome, VenueTransport, rt}; - -/// Resolved configuration parsed from `module.toml::[config]`. -#[derive(Clone, Debug)] -pub struct Settings { - /// Chainlink AggregatorV3Interface address. - pub oracle_address: Address, - /// Trigger price scaled to the oracle's native units. - pub trigger_price_scaled: I256, - /// Order owner (= the `setPreSignature` caller and buy-token - /// receiver). - pub owner: Address, - /// Sell side of the order. - pub sell_token: Address, - /// Buy side of the order. - pub buy_token: Address, - /// Sell amount in atomic units of `sell_token`. - pub sell_amount: U256, - /// Buy amount in atomic units of `buy_token`. - pub buy_amount: U256, - /// Order expiry (Unix seconds). - pub valid_to: u32, -} - -/// React to a new block. -/// -/// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure, venue refusal). Only host-store -/// errors bubble up via `?` so the supervisor can surface persistence -/// issues - all other faults log and let the next block re-poll. -pub fn on_block( - host: &H, - venue: &CowClient, - chain_id: u64, - settings: &Settings, -) -> Result<(), Fault> -where - H: ChainHost + LoggingHost + LocalStoreHost, - T: VenueTransport, -{ - let price = match read_latest_answer(host, chain_id, settings.oracle_address, "stop-loss") { - Some(p) => p, - None => return Ok(()), // logged inside read_latest_answer - }; - - if price > settings.trigger_price_scaled { - tracing::info!( - price = %price, - trigger = %settings.trigger_price_scaled, - "stop-loss idle", - ); - return Ok(()); - } - - // Derive the venue-and-body intent-id up-front so the dedup guard - // runs before any network work. - let intent = build_intent(settings); - let id = match intent_id(&intent) { - Ok(id) => id, - Err(e) => { - tracing::error!(error = %e, "intent body encode failed"); - return Ok(()); - } - }; - let dedup_key = format!("submitted:{id}"); - if host.get(&dedup_key)?.is_some() { - tracing::info!(intent = %id, "stop-loss already submitted, idle"); - return Ok(()); - } - let dropped_key = format!("dropped:{id}"); - if host.get(&dropped_key)?.is_some() { - tracing::info!(intent = %id, "stop-loss previously dropped, idle"); - return Ok(()); - } - - let Some(outcome) = rt::complete(venue.submit(&intent)) else { - // Guest transports never suspend; retry on the next block. - tracing::error!("stop-loss submit future suspended; retrying next block"); - return Ok(()); - }; - match outcome { - Ok(SubmitOutcome::Accepted(receipt)) => { - host.set(&dedup_key, b"")?; - tracing::warn!( - price = %price, - trigger = %settings.trigger_price_scaled, - receipt = %hex::encode_prefixed(&receipt), - "stop-loss TRIGGERED", - ); - } - Ok(SubmitOutcome::RequiresSigning(_)) => { - // The orderbook holds the order as signature-pending; the - // owner activates it with the on-chain `setPreSignature` - // call made ahead of the trigger. Journalled so the next - // block idles instead of re-posting. - host.set(&dedup_key, b"")?; - tracing::warn!( - price = %price, - trigger = %settings.trigger_price_scaled, - "stop-loss TRIGGERED (pre-sign pending on-chain activation)", - ); - } - Err(ClientError::Body(e)) => { - tracing::error!(error = %e, "intent body encode failed"); - } - Err(ClientError::Venue(fault)) => match retry_action(&fault) { - RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { - tracing::warn!(error = %fault, "stop-loss retry on next block"); - } - RetryAction::Drop => { - host.set(&dropped_key, b"")?; - tracing::warn!(intent = %id, error = %fault, "stop-loss dropped"); - } - // `RetryAction` is `#[non_exhaustive]`; treat unknown - // future variants like `TryNextBlock` rather than - // silently dropping the order on an SDK bump. - _ => { - tracing::warn!( - error = %fault, - "stop-loss unknown retry-action - retry on next block", - ); - } - }, - // `ClientError` is non-exhaustive; retry on the next block. - Err(e) => tracing::error!(error = %e, "stop-loss submit failed"), - } - Ok(()) -} - -/// Assemble the order intent from settings: an unsigned order the cow -/// adapter posts pre-sign. The owner receives the buy token and the -/// app-data hash pins the canonical empty document. -fn build_intent(settings: &Settings) -> CowIntentBody { - let order = OrderBody::sell( - SellToken(settings.sell_token.into_array()), - settings.sell_amount.to_be_bytes(), - ) - .for_at_least( - BuyToken(settings.buy_token.into_array()), - settings.buy_amount.to_be_bytes(), - ) - .valid_to(settings.valid_to) - .receiver(settings.owner.into_array()) - .app_data(cowprotocol::EMPTY_APP_DATA_HASH.0) - .build(); - CowIntentBody::V1(CowIntent::Order(order)) -} - -/// Parse `module.toml::[config]` into a typed [`Settings`]. -pub fn parse_config(entries: &[(String, String)]) -> Result { - let oracle_address = config::get_required(entries, "oracle_address") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("oracle_address: {e}")))?; - let decimals = config::get_required(entries, "decimals") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("decimals: {e}")))?; - if decimals > 38 { - return Err(invalid(format!( - "decimals={decimals} exceeds the I256 power-of-ten budget" - ))); - } - let trigger_price_scaled = config::scale_decimal( - config::get_required(entries, "trigger_price").map_err(config_err)?, - decimals, - "trigger_price", - ) - .map_err(config_err)?; - let owner = config::get_required(entries, "owner") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("owner: {e}")))?; - let sell_token = config::get_required(entries, "sell_token") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("sell_token: {e}")))?; - let buy_token = config::get_required(entries, "buy_token") - .map_err(config_err)? - .parse::
() - .map_err(|e| invalid(format!("buy_token: {e}")))?; - let sell_amount = config::get_required(entries, "sell_amount_wei") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("sell_amount_wei: {e}")))?; - let buy_amount = config::get_required(entries, "buy_amount_wei") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("buy_amount_wei: {e}")))?; - let valid_to = config::get_required(entries, "valid_to_seconds") - .map_err(config_err)? - .parse::() - .map_err(|e| invalid(format!("valid_to_seconds: {e}")))?; - Ok(Settings { - oracle_address, - trigger_price_scaled, - owner, - sell_token, - buy_token, - sell_amount, - buy_amount, - valid_to, - }) -} - -/// Lift a free-text invalid-config detail into a [`Fault::InvalidInput`]. -/// Used when the SDK helper does not own the error (e.g. an -/// `Address::from_str` failure or a `U256::from_str` overflow). -fn invalid(message: impl Into) -> Fault { - Fault::InvalidInput(message.into()) -} - -/// Project a `nexum_sdk::config::ConfigError` into a -/// [`Fault::InvalidInput`] via `Display`. -fn config_err(e: ConfigError) -> Fault { - invalid(e.to_string()) -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::VecDeque; - - use alloy_primitives::hex; - use alloy_sol_types::SolCall; - use nexum_sdk::Level; - use nexum_sdk::chain::chainlink::AggregatorV3; - use nexum_sdk::chain::eth_call_params; - use nexum_sdk::host::ChainError; - use nexum_sdk_test::{MockHost, capture_tracing}; - use videre_sdk::client::sealed::SealedTransport; - use videre_sdk::{IntentStatus, Quotation, UnsignedTx, VenueFault, VenueId}; - - use super::*; - - const SEPOLIA: u64 = 11_155_111; - - /// Scripted venue transport: one submit outcome per queued entry, - /// every submit recorded. - #[derive(Default)] - struct MockVenue { - outcomes: RefCell>>, - submits: RefCell)>>, - } - - impl MockVenue { - fn enqueue_submit(&self, outcome: Result) { - self.outcomes.borrow_mut().push_back(outcome); - } - - fn submit_count(&self) -> usize { - self.submits.borrow().len() - } - } - - impl SealedTransport for &MockVenue {} - - impl VenueTransport for &MockVenue { - async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { - unreachable!("quote not exercised") - } - - async fn submit( - &self, - venue: &VenueId, - body: Vec, - ) -> Result { - self.submits.borrow_mut().push((venue.to_string(), body)); - self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { - Err(VenueFault::Unavailable( - "MockVenue: unscripted submit".into(), - )) - }) - } - - async fn status( - &self, - _venue: &VenueId, - _receipt: &[u8], - ) -> Result { - unreachable!("status not exercised") - } - - async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { - unreachable!("cancel not exercised") - } - } - - fn client(venue: &MockVenue) -> CowClient<&MockVenue> { - CowClient::with_transport(venue) - } - - fn settings_below(trigger_scaled: i128) -> Settings { - Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" - .parse() - .unwrap(), - trigger_price_scaled: I256::try_from(trigger_scaled).unwrap(), - owner: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" - .parse() - .unwrap(), - sell_token: "0x6810e776880C02933D47DB1b9fc05908e5386b96" - .parse() - .unwrap(), - buy_token: "0xfff9976782d46cc05630d1f6ebab18b2324d6b14" - .parse() - .unwrap(), - sell_amount: U256::from(1_000_000_000_000_000_000_u128), - buy_amount: U256::from(300_000_000_000_000_000_u128), - valid_to: u32::MAX, - } - } - - fn oracle_response_json(answer_scaled: i128) -> String { - use alloy_primitives::aliases::U80; - let returns = AggregatorV3::latestRoundDataReturn { - roundId: U80::ZERO, - answer: I256::try_from(answer_scaled).unwrap(), - startedAt: U256::ZERO, - updatedAt: U256::ZERO, - answeredInRound: U80::ZERO, - }; - let encoded = AggregatorV3::latestRoundDataCall::abi_encode_returns(&returns); - let hex_body = hex::encode_prefixed(encoded); - format!("\"{hex_body}\"") - } - - fn program_oracle(host: &MockHost, oracle: Address, response: Result) { - let call_data = AggregatorV3::latestRoundDataCall {}.abi_encode(); - let params = eth_call_params(&oracle, &call_data); - host.chain.respond_to("eth_call", ¶ms, response); - } - - fn programmed_id(settings: &Settings) -> String { - intent_id(&build_intent(settings)).unwrap() - } - - /// Regression test pinning the orderbook UID derived from the - /// E2E run's `modules/examples/stop-loss/module.toml` config so an - /// operator can `setPreSignature(uid, true)` ahead of the run - /// without re-deriving the UID from the EIP-712 / domain- - /// separator dance. If this assertion ever flips, either: - /// (a) the module.toml has drifted from the pinned settings, or - /// (b) the EIP-712 type-hash / domain-separator changed, - /// and the runbook's `setPreSignature` step needs the new UID. - #[test] - fn e2e_settings_yield_expected_uid() { - let settings = Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306" - .parse() - .unwrap(), - trigger_price_scaled: I256::try_from(200_000_000_000_i128).unwrap(), - owner: "0x7bF140727D27ea64b607E042f1225680B40ECa6A" - .parse() - .unwrap(), - sell_token: "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" - .parse() - .unwrap(), - buy_token: "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59" - .parse() - .unwrap(), - sell_amount: U256::from(5_000_000_000_000_000_u128), - buy_amount: U256::from(20_000_000_000_000_000_000_u128), - valid_to: u32::MAX, - }; - let CowIntentBody::V1(CowIntent::Order(body)) = build_intent(&settings) else { - panic!("stop-loss emits an unsigned order intent"); - }; - let order = cow_venue::assembly::body_to_order_data(&body); - let uid = cow_venue::assembly::order_uid( - cowprotocol::Chain::try_from(SEPOLIA).unwrap(), - &order, - settings.owner, - ); - assert_eq!( - format!("{uid}"), - "0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff", - ); - } - - #[test] - fn idle_when_price_above_trigger() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(/*trigger*/ 250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Ok(oracle_response_json(300_000_000_000)), - ); - - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - - assert_eq!(venue.submit_count(), 0); - assert_eq!(host.store.len(), 0); - assert_eq!( - host.chain.call_count(), - 1, - "oracle consulted: idle because above trigger, not because unread" - ); - } - - #[test] - fn triggers_and_submits_once_then_dedups() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Ok(oracle_response_json(200_000_000_000)), - ); - venue.enqueue_submit(Ok(SubmitOutcome::Accepted(vec![0xAA; 56]))); - - // First block: submits. - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(venue.submit_count(), 1); - let id = programmed_id(&s); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{id}")) - ); - - // Second block at the same price: dedup'd, no new submit. - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(venue.submit_count(), 1); - assert_eq!( - host.chain.call_count(), - 2, - "oracle still polled each block; dedup is at the submit stage" - ); - } - - /// The adapter posts the unsigned order pre-sign and asks for the - /// on-chain activation: the intent is journalled so the next block - /// idles instead of re-posting. - #[test] - fn requires_signing_outcome_records_the_marker_and_idles() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Ok(oracle_response_json(200_000_000_000)), - ); - venue.enqueue_submit(Ok(SubmitOutcome::RequiresSigning(UnsignedTx { - chain: SEPOLIA, - to: vec![0x11; 20], - value: Vec::new(), - data: vec![0x22], - }))); - - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - - let id = programmed_id(&s); - assert!( - host.store - .snapshot() - .contains_key(&format!("submitted:{id}")) - ); - - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(venue.submit_count(), 1); - } - - #[test] - fn permanent_submit_error_marks_dropped() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Ok(oracle_response_json(200_000_000_000)), - ); - - // A structured permanent refusal - `Denied` classifies as - // `Drop` in the videre retry table. - venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); - - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - let id = programmed_id(&s); - assert!(host.store.snapshot().contains_key(&format!("dropped:{id}"))); - assert!( - !host - .store - .snapshot() - .contains_key(&format!("submitted:{id}")) - ); - - // Second block: dropped marker idles the loop. - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - assert_eq!(venue.submit_count(), 1); // no resubmit - } - - #[test] - fn transient_submit_error_leaves_state_unchanged() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Ok(oracle_response_json(200_000_000_000)), - ); - - venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); - - let (result, logs) = capture_tracing(|| on_block(&host, &client(&venue), SEPOLIA, &s)); - result.unwrap(); - - // No persistence flag - next block will retry. - assert_eq!(host.store.len(), 0); - assert_eq!(venue.submit_count(), 1, "the submit was attempted"); - logs.expect_one(|e| e.level == Level::WARN && e.message.contains("retry on next block")); - } - - #[test] - fn oracle_rpc_error_is_warn_and_continue() { - let host = MockHost::new(); - let venue = MockVenue::default(); - let s = settings_below(250_000_000_000); - program_oracle( - &host, - s.oracle_address, - Err(ChainError::Fault(Fault::Timeout)), - ); - - on_block(&host, &client(&venue), SEPOLIA, &s).unwrap(); - - assert_eq!(venue.submit_count(), 0); - assert_eq!(host.store.len(), 0); - assert!(host.logging.contains("oracle eth_call failed")); - } - - #[test] - fn parse_config_round_trips_settings() { - let entries = vec![ - ( - "oracle_address".into(), - "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), - ), - ("decimals".into(), "8".into()), - ("trigger_price".into(), "2500.00".into()), - ( - "owner".into(), - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".into(), - ), - ( - "sell_token".into(), - "0x6810e776880C02933D47DB1b9fc05908e5386b96".into(), - ), - ( - "buy_token".into(), - "0xfff9976782d46cc05630d1f6ebab18b2324d6b14".into(), - ), - ("sell_amount_wei".into(), "1000000000000000000".into()), - ("buy_amount_wei".into(), "300000000000000000".into()), - ("valid_to_seconds".into(), "4294967295".into()), - ]; - let s = parse_config(&entries).unwrap(); - assert_eq!(s.valid_to, u32::MAX); - assert_eq!( - s.trigger_price_scaled, - I256::try_from(250_000_000_000_i64).unwrap() - ); - } - - #[test] - fn parse_config_rejects_missing_owner() { - let entries = vec![ - ( - "oracle_address".into(), - "0x694AA1769357215DE4FAC081bf1f309aDC325306".into(), - ), - ("decimals".into(), "8".into()), - ("trigger_price".into(), "1.0".into()), - ( - "sell_token".into(), - "0x6810e776880C02933D47DB1b9fc05908e5386b96".into(), - ), - ( - "buy_token".into(), - "0xfff9976782d46cc05630d1f6ebab18b2324d6b14".into(), - ), - ("sell_amount_wei".into(), "1".into()), - ("buy_amount_wei".into(), "1".into()), - ("valid_to_seconds".into(), "1".into()), - ]; - let err = parse_config(&entries).unwrap_err(); - let Fault::InvalidInput(message) = err else { - panic!("expected invalid-input fault, got {err:?}"); - }; - assert!(message.contains("owner")); - } -} diff --git a/scripts/e2e-report-gen.sh b/scripts/e2e-report-gen.sh index 29f156bf..3d9161d9 100755 --- a/scripts/e2e-report-gen.sh +++ b/scripts/e2e-report-gen.sh @@ -40,7 +40,7 @@ LOG, M_START, M_END, START_ISO, END_ISO, TEMPLATE, OUT, STATE = sys.argv[1:9] # ── Parse engine log ───────────────────────────────────────────────── blocks = [] # list of dispatched block_numbers (per module, but we just want range) -markers = {m: [] for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker","stop-loss")} +markers = {m: [] for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker")} errors = [] trapped = [] poisoned = [] @@ -56,8 +56,6 @@ MARKER_PATTERNS = { # balance-tracker logs each per-block diff as # "0x changed +N wei (prior=..., current=...)". "balance-tracker": ["changed +", "changed -"], - "stop-loss": ["TRIGGERED", "retry on next block", "stop-loss submitted", - "stop-loss dropped", "already submitted", "submitted:"], } def event_field(ev, key, default=None): @@ -234,7 +232,7 @@ lines.append("## 4. Per-module terminal-state markers") lines.append("") lines.append("| Module | First marker | Sample line |") lines.append("|---|---|---|") -for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker","stop-loss"): +for m in ("twap-monitor","ethflow-watcher","price-alert","balance-tracker"): if markers[m]: first = markers[m][0] # Truncate the marker line for the table diff --git a/scripts/e2e-run.sh b/scripts/e2e-run.sh index 8ec2f4e1..b3ec7aac 100755 --- a/scripts/e2e-run.sh +++ b/scripts/e2e-run.sh @@ -6,7 +6,7 @@ # operator's RPC URL (with key) substituted in. Local file is # gitignored. # 3. Cleans data/e2e for a fresh local-store. -# 4. Builds all 5 modules + the engine. +# 4. Builds all 4 modules + the engine. # 5. Launches shepherd via nohup, redirecting stdout/stderr to # docs/operations/e2e-reports/engine-.log. JSON logs # (no --pretty-logs) so e2e-report-gen.sh can mine them with jq. @@ -44,14 +44,13 @@ render_engine_config log "cleaning local-store at $REPO_ROOT/data/e2e" rm -rf "$REPO_ROOT/data/e2e" -log "building 5 modules + engine (this can take a minute on first run)" +log "building 4 modules + engine (this can take a minute on first run)" ( cd "$REPO_ROOT" cargo build -p twap-monitor --target wasm32-wasip2 --release >/dev/null cargo build -p ethflow-watcher --target wasm32-wasip2 --release >/dev/null cargo build -p price-alert --target wasm32-wasip2 --release >/dev/null cargo build -p balance-tracker --target wasm32-wasip2 --release >/dev/null - cargo build -p stop-loss --target wasm32-wasip2 --release >/dev/null cargo build -p shepherd --release >/dev/null ) diff --git a/scripts/lib.sh b/scripts/lib.sh index 229a83df..0e773ecb 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -12,8 +12,7 @@ STATE_FILE="$SCRIPT_DIR/.state" REPORTS_DIR="$REPO_ROOT/docs/operations/e2e-reports" # Pinned identities — match docs/operations/e2e-prep.md -# section 0. If you change one, change them in lock-step and re-run -# `cargo test -p stop-loss --lib e2e_settings_yield_expected_uid`. +# section 0. If you change one, change them in lock-step. TEST_EOA="0x7bF140727D27ea64b607E042f1225680B40ECa6A" TEST_SAFE="0x14995a1118Caf95833e923faf8Dd155721cd53c2" COMPOSABLE_COW="0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" From bd7fbf7c40587acd8a99f8d0ae49949d21fb6c25 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 02:28:34 +0000 Subject: [PATCH 090/139] test: round-trip the no_std IntentBody derive probe (#571) --- crates/no-std-probe/src/lib.rs | 37 ++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs index fd455704..89603613 100644 --- a/crates/no-std-probe/src/lib.rs +++ b/crates/no-std-probe/src/lib.rs @@ -1,5 +1,10 @@ -//! Compile-only `#![no_std]` probe: `#[derive(IntentBody)]` must expand -//! without the consumer's std prelude or an `extern crate alloc`. +//! `no_std` derive-hygiene probe for `#[derive(IntentBody)]`. The claim +//! is scoped to the derive, not the whole SDK: the expansion names only +//! `::core` and the SDK's `__private` re-exports (borsh, `alloc`), so it +//! compiles without the consumer's std prelude or an `extern crate +//! alloc`. The crate is `#![no_std]`; the `tests` module (std-exempt) +//! exercises an actual encode/decode round-trip, so the generated codec +//! is verified correct, not merely expanded. #![no_std] #![warn(missing_docs)] @@ -12,3 +17,31 @@ pub enum ProbeBody { /// First published version. V1(u8), } + +#[cfg(test)] +mod tests { + use super::*; + use videre_sdk::BodyError; + + #[test] + fn round_trip() { + let body = ProbeBody::V1(7); + let bytes = body.to_bytes().expect("encode"); + // One-byte version tag (0) then the borsh u8 payload. + assert_eq!(bytes, [0u8, 7u8]); + assert_eq!(ProbeBody::from_bytes(&bytes).expect("decode"), body); + } + + #[test] + fn unknown_version() { + assert!(matches!( + ProbeBody::from_bytes(&[9, 7]), + Err(BodyError::UnknownVersion { version: 9 }), + )); + } + + #[test] + fn empty() { + assert!(matches!(ProbeBody::from_bytes(&[]), Err(BodyError::Empty))); + } +} From 51f1ef957cab8183a6709df96a03940aeb8201a8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 06:58:36 +0000 Subject: [PATCH 091/139] videre-sdk: name the one-poll driver for the seam it asserts (#576) --- crates/composable-cow/src/sweep.rs | 16 +++++++---- crates/cow-venue/src/client.rs | 8 ++++-- crates/videre-macros/src/keeper.rs | 8 +++--- crates/videre-macros/src/lib.rs | 2 +- crates/videre-sdk/src/client.rs | 34 ++++++++++++++++++++++ crates/videre-sdk/src/keeper.rs | 5 +++- crates/videre-sdk/src/lib.rs | 8 +++--- crates/videre-sdk/src/rt.rs | 38 ------------------------- crates/videre-sdk/tests/adapter.rs | 5 +++- docs/05-sdk-design.md | 5 ++-- modules/ethflow-watcher/src/strategy.rs | 8 ++++-- 11 files changed, 74 insertions(+), 63 deletions(-) delete mode 100644 crates/videre-sdk/src/rt.rs diff --git a/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs index 768d51c4..01e0a702 100644 --- a/crates/composable-cow/src/sweep.rs +++ b/crates/composable-cow/src/sweep.rs @@ -29,8 +29,11 @@ use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, }; +use std::task::Poll; + +use videre_sdk::client::poll_once; use videre_sdk::keeper::retry_action; -use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport, rt}; +use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport}; use crate::Verdict; @@ -135,12 +138,13 @@ where return Ok(()); } - let Some(outcome) = rt::complete(venue.submit(&intent)) else { + let Poll::Ready(outcome) = poll_once(venue.submit(&intent)) else { // Guest transports never suspend; a pending future means a - // foreign transport misbehaved. The watch stays for the next - // tick. - tracing::error!("{label} submit future suspended; retrying next tick"); - return Ok(()); + // foreign transport misbehaved. Route through the retrier for + // symmetry with the venue-refusal arm; a next-block retry keeps + // the watch for the next tick. + tracing::error!("{label} submit future suspended; retrying next block"); + return Retrier::new(host).apply(watch, RetryAction::TryNextBlock, tick); }; match outcome { Ok(SubmitOutcome::Accepted(receipt)) => { diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 9dd86963..9a5ecfaf 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -147,9 +147,11 @@ mod tests { let client = CowClient::with_transport(spy.clone()); assert_eq!(client.venue(), CowVenue::ID); - videre_sdk::rt::complete(client.submit(&body)) - .expect("guest futures complete in one poll") - .expect("submit succeeds"); + let std::task::Poll::Ready(result) = videre_sdk::client::poll_once(client.submit(&body)) + else { + panic!("guest futures complete in one poll"); + }; + result.expect("submit succeeds"); let calls = spy.submitted.borrow(); assert_eq!(calls.len(), 1); diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs index 9af9c569..465f701d 100644 --- a/crates/videre-macros/src/keeper.rs +++ b/crates/videre-macros/src/keeper.rs @@ -4,7 +4,7 @@ //! with the keeper deltas: the `client` capability is required, the //! videre interfaces remap onto the SDK bindings (one shim set, one //! type identity for the typed client), async handlers complete via -//! `videre_sdk::rt::complete`, and `ClientError` folds into the wire +//! `videre_sdk::client::poll_once`, and `ClientError` folds into the wire //! fault so `?` works in handlers. use proc_macro2::TokenStream; @@ -115,9 +115,9 @@ pub(crate) fn expand(input: &ItemImpl) -> syn::Result { // typed internal fault, never a hang. let drive = |call: TokenStream| { quote! { - match ::videre_sdk::rt::complete(#call) { - ::core::option::Option::Some(result) => result, - ::core::option::Option::None => ::core::result::Result::Err( + match ::videre_sdk::client::poll_once(#call) { + ::core::task::Poll::Ready(result) => result, + ::core::task::Poll::Pending => ::core::result::Result::Err( nexum::host::types::Fault::Internal( ::std::string::String::from(#SUSPENDED), ), diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index a9cb5677..bbcaccb3 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -180,7 +180,7 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { /// whose associated functions are the event handlers (`init`, /// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, /// `on_intent_status`); handlers may be `async` and are completed on -/// the synchronous guest boundary (`videre_sdk::rt::complete`), so a +/// the synchronous guest boundary (`videre_sdk::client::poll_once`), so a /// handler can await the typed `VenueClient` directly. /// /// The macro reads the crate's `module.toml`, requires the `client` diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index 63ff375d..4a4e9443 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -15,6 +15,8 @@ use std::borrow::Cow; use std::fmt; use std::future::Future; use std::marker::PhantomData; +use std::pin::pin; +use std::task::{Context, Poll, Waker}; use strum::IntoStaticStr; @@ -133,6 +135,17 @@ pub trait VenueTransport: sealed::SealedTransport { ) -> impl Future>; } +/// Poll a future once and return its state. `videre:venue/client@0.1.0` +/// declares plain funcs, so a [`VenueTransport`] over the host import +/// resolves on the first poll. [`Poll::Pending`] means a foreign +/// [`VenueTransport`] impl suspended, which the keeper macro folds to +/// `Fault::Internal`. +pub fn poll_once(future: F) -> Poll { + let mut future = pin!(future); + let mut cx = Context::from_waker(Waker::noop()); + future.as_mut().poll(&mut cx) +} + /// The module's `videre:venue/client` import behind the /// [`VenueTransport`] seam: the transport every guest-side /// [`VenueClient`] defaults to. @@ -308,3 +321,24 @@ pub enum ClientError { #[error(transparent)] Venue(#[from] VenueFault), } + +#[cfg(test)] +mod tests { + use std::task::Poll; + + use super::poll_once; + + #[test] + fn ready_chain_completes_in_one_poll() { + async fn two() -> u8 { + let one = async { 1u8 }.await; + one + async { 1u8 }.await + } + assert_eq!(poll_once(two()), Poll::Ready(2)); + } + + #[test] + fn suspending_future_reports_pending() { + assert_eq!(poll_once(std::future::pending::<()>()), Poll::Pending); + } +} diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 732a271a..6e64fdcd 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -215,7 +215,10 @@ mod tests { /// Drive a sweep on the test's synchronous boundary. fn run(future: F) -> F::Output { - crate::rt::complete(future).expect("sweep futures complete in one poll") + match crate::client::poll_once(future) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("sweep futures complete in one poll"), + } } /// Answers every poll with one programmed outcome. diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 007eb2e2..3594f2b5 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -25,8 +25,9 @@ //! own `videre:venue/client` import). Lives here (not in the //! strategy SDK) so the codec and the client that speaks it version //! together. `#[videre_sdk::keeper]` on a handler impl wires the -//! import and drives async handlers; [`rt`] completes their futures -//! on the synchronous guest boundary. +//! import and drives async handlers; +//! [`poll_once`](client::poll_once) completes their futures on the +//! synchronous guest boundary. //! //! - [`keeper`](mod@keeper) - the generic sweep assembler: //! [`Keeper::sweep`] runs the world-neutral `nexum_sdk::keeper` @@ -80,7 +81,6 @@ pub mod client; pub mod event; pub mod faults; pub mod keeper; -pub mod rt; pub mod transport; pub use adapter::VenueAdapter; @@ -96,7 +96,7 @@ pub use videre_macros::IntentBody; /// (asserting the `client` capability), remaps the videre interfaces /// onto the SDK bindings so the module drives a [`VenueClient`] with /// shared type identity, dispatches events to the handlers (async ones -/// completed through [`rt::complete`]), and folds [`ClientError`] into +/// completed through [`client::poll_once`]), and folds [`ClientError`] into /// the wire fault so `?` works in handlers. See /// [`videre_macros::keeper`]. pub use videre_macros::keeper; diff --git a/crates/videre-sdk/src/rt.rs b/crates/videre-sdk/src/rt.rs deleted file mode 100644 index 6a62f4f3..00000000 --- a/crates/videre-sdk/src/rt.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Futures on the synchronous guest boundary. - -use std::future::Future; -use std::pin::pin; -use std::task::{Context, Poll, Waker}; - -/// Complete a future in one poll. Guest host imports are synchronous, -/// so every await in a keeper future resolves immediately and one poll -/// runs it to completion. `None` reports a future that suspended, -/// which nothing built over the host imports does; the keeper macro's -/// emitted glue folds it to a typed fault. -pub fn complete(future: F) -> Option { - let mut future = pin!(future); - let mut cx = Context::from_waker(Waker::noop()); - match future.as_mut().poll(&mut cx) { - Poll::Ready(output) => Some(output), - Poll::Pending => None, - } -} - -#[cfg(test)] -mod tests { - use super::complete; - - #[test] - fn ready_chain_completes_in_one_poll() { - async fn two() -> u8 { - let one = async { 1u8 }.await; - one + async { 1u8 }.await - } - assert_eq!(complete(two()), Some(2)); - } - - #[test] - fn suspending_future_reports_none() { - assert_eq!(complete(std::future::pending::<()>()), None); - } -} diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index 0c490331..d43cd252 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -15,7 +15,10 @@ use videre_sdk::{ /// Drive a client future on the test's synchronous boundary. fn run(future: F) -> F::Output { - videre_sdk::rt::complete(future).expect("client futures complete in one poll") + match videre_sdk::client::poll_once(future) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("client futures complete in one poll"), + } } /// First published body version: a fixed-price quote. diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index f77078e4..1512ee47 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -284,8 +284,9 @@ to an `impl` block whose associated functions are the event handlers `on_intent_status`). It requires the `client` capability (the `videre:venue/client` import is what makes a keeper a keeper), wires that import onto the SDK's shared shims, and lets handlers be `async` -so they can await the typed client directly; `videre_sdk::rt` -completes the futures on the synchronous guest boundary. A +so they can await the typed client directly; +`videre_sdk::client::poll_once` completes the futures on the +synchronous guest boundary. A `From` impl onto the wire fault is emitted, so `?` applies to client calls inside handlers. diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 69456554..de3d3238 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -211,7 +211,7 @@ mod tests { use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk_test::{MockHost, capture_tracing}; use videre_sdk::client::VenueId; - use videre_sdk::rt::complete; + use videre_sdk::client::poll_once; use videre_sdk::status_body::IntentStatus as Lifecycle; use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; @@ -309,8 +309,10 @@ mod tests { logs: &[Log], ) -> Result<(), Fault> { let client = CowClient::with_transport(spy.clone()); - complete(on_chain_logs(host, &client, chain_id, logs)) - .expect("guest futures complete in one poll") + match poll_once(on_chain_logs(host, &client, chain_id, logs)) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("guest futures complete in one poll"), + } } fn open_status() -> Vec { From fbc29bca33ec0ac5f3981e12fdf6e4bc71437d6f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 06:59:49 +0000 Subject: [PATCH 092/139] videre: bind venue id to the adapter manifest name (#577) --- crates/cow-venue/src/client.rs | 11 +- crates/nexum-world/src/lib.rs | 15 +++ crates/videre-macros/src/lib.rs | 24 +++-- crates/videre-macros/src/venue_marker.rs | 127 +++++++++++++++++++++++ 4 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 crates/videre-macros/src/venue_marker.rs diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 9a5ecfaf..aa035281 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -10,7 +10,7 @@ use alloc::string::String; -use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::client::{HostVenues, Venue, VenueClient}; use videre_sdk::keeper::submission_key; use videre_sdk::{BodyError, IntentBody as _}; @@ -22,10 +22,9 @@ use crate::body::CowIntentBody; #[derive(Clone, Copy, Debug)] pub struct CowVenue; -impl Venue for CowVenue { - const ID: VenueId = VenueId::from_static("cow"); - type Body = CowIntentBody; -} +// The id is held to `module.toml`'s `[module] name` at expansion. +#[videre_sdk::venue(id = "cow", body = CowIntentBody)] +impl Venue for CowVenue {} /// A typed client pre-bound to the CoW venue: callers cannot mis-route /// or submit a foreign body. @@ -49,7 +48,7 @@ mod tests { use std::cell::RefCell; use std::rc::Rc; - use videre_sdk::client::VenueTransport; + use videre_sdk::client::{VenueId, VenueTransport}; use videre_sdk::{IntentStatus, Quotation, SubmitOutcome, VenueFault}; use super::*; diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 4a6aae1e..12269b92 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -316,6 +316,21 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { Ok(names) } +/// Extract the declared `[module] name` from the manifest text, the id +/// the module registers under. Absent or non-string is an error. +pub fn manifest_name(text: &str) -> Result { + let value: toml::Table = text + .parse() + .map_err(|e| format!("module.toml is not valid TOML: {e}"))?; + value + .get("module") + .and_then(|module| module.get("name")) + .ok_or_else(|| "[module].name is missing".to_string())? + .as_str() + .map(str::to_owned) + .ok_or_else(|| "[module].name must be a string".to_string()) +} + /// Extract the declared `[module] kind` from the manifest text, `None` /// when absent (the runtime defaults an absent kind to the worker). pub fn manifest_kind(text: &str) -> Result, String> { diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index bbcaccb3..4c21451a 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -22,6 +22,7 @@ mod intent_body; mod keeper; +mod venue_marker; mod world; use proc_macro::TokenStream; @@ -79,19 +80,26 @@ const VENUE_KIND: &str = "venue-adapter"; /// codegen resolves `Guest`, `exports`, and `export!` there), and the /// consuming crate must declare `wit-bindgen` and `videre-sdk` as /// direct dependencies. +/// +/// # Client marker +/// +/// Given arguments (`#[videre_sdk::venue(id = "cow", body = CowBody)]`) +/// the attribute instead fills a client-side `impl Venue for Marker {}`: +/// it emits the `const ID`/`type Body` from the args and, at expansion, +/// asserts the id equals the crate manifest's `[module] name`. No +/// component world is generated, so a keeper linking the client slice +/// never pulls adapter bindgen. This form expands on host and wasm alike +/// and is opt-in: hand-written `Venue` impls keep compiling. #[proc_macro_attribute] pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(item as ItemImpl); + if !attr.is_empty() { - return syn::Error::new( - proc_macro2::Span::call_site(), - "#[videre_sdk::venue] takes no arguments", - ) - .to_compile_error() - .into(); + return venue_marker::expand(attr.into(), &input) + .unwrap_or_else(syn::Error::into_compile_error) + .into(); } - let input = syn::parse_macro_input!(item as ItemImpl); - let Some((None, trait_path, _)) = &input.trait_ else { return syn::Error::new_spanned( &input.self_ty, diff --git a/crates/videre-macros/src/venue_marker.rs b/crates/videre-macros/src/venue_marker.rs new file mode 100644 index 00000000..315f9d7f --- /dev/null +++ b/crates/videre-macros/src/venue_marker.rs @@ -0,0 +1,127 @@ +//! The client-side `#[videre_sdk::venue(id = "...", body = Type)]` path: +//! fills a `Venue` marker impl and checks the id against `module.toml` +//! at expansion. No component world, so a keeper linking the client +//! slice never pulls adapter bindgen. + +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{ItemImpl, LitStr, Token, Type}; + +/// `id = "cow", body = CowIntentBody`: the venue id and the body schema +/// the marker binds. +struct Args { + id: LitStr, + body: Type, +} + +impl Parse for Args { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut id = None; + let mut body = None; + while !input.is_empty() { + let key: syn::Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "id" => id = Some(input.parse()?), + "body" => body = Some(input.parse()?), + other => { + return Err(syn::Error::new( + key.span(), + format!("unknown argument `{other}`, expected `id` or `body`"), + )); + } + } + if input.peek(Token![,]) { + input.parse::()?; + } + } + Ok(Self { + id: id.ok_or_else(|| syn::Error::new(Span::call_site(), "missing `id = \"...\"`"))?, + body: body + .ok_or_else(|| syn::Error::new(Span::call_site(), "missing `body = Type`"))?, + }) + } +} + +/// Expand `#[videre_sdk::venue(id, body)]` on an `impl Venue for Marker +/// {}` block: inject `const ID`/`type Body` from the args and assert the +/// id equals the crate manifest's `[module] name`. +pub fn expand(attr: TokenStream, input: &ItemImpl) -> Result { + let args: Args = syn::parse2(attr)?; + + let Some((None, trait_path, _)) = &input.trait_ else { + return Err(syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue(id = ..)] must be applied to an `impl Venue for ...` block", + )); + }; + if trait_path + .segments + .last() + .is_none_or(|segment| segment.ident != "Venue") + { + return Err(syn::Error::new_spanned( + trait_path, + "#[videre_sdk::venue(id = ..)] must be applied to an impl of `videre_sdk::client::Venue`", + )); + } + if !input.items.is_empty() { + return Err(syn::Error::new_spanned( + &input.self_ty, + "#[videre_sdk::venue(id = ..)] fills the impl body; leave it empty", + )); + } + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[videre_sdk::venue(id = ..)] must be applied to a non-generic impl", + )); + } + + let self_ty = &input.self_ty; + let id = &args.id; + let body = &args.body; + + // Read the crate manifest at expansion and hold the id to its + // registered name, alloy-`sol!`-style. Any mismatch is a + // compile_error, so the marker and the adapter it types cannot drift. + let manifest_path = manifest_id_check(id)?; + + Ok(quote! { + // Rebuild anchor: an edited `[module] name` re-runs the check. + const _: &[u8] = ::core::include_bytes!(#manifest_path); + + impl #trait_path for #self_ty { + const ID: ::videre_sdk::client::VenueId = + ::videre_sdk::client::VenueId::from_static(#id); + type Body = #body; + } + }) +} + +/// Assert `id` equals the crate manifest's `[module] name`, returning the +/// manifest path for the rebuild anchor. +fn manifest_id_check(id: &LitStr) -> Result { + let err = |msg: String| syn::Error::new(id.span(), msg); + let manifest_path = nexum_world::manifest_dir() + .map_err(&err)? + .join("module.toml"); + let text = std::fs::read_to_string(&manifest_path).map_err(|e| { + err(format!( + "could not read {} ({e}); #[videre_sdk::venue(id = ..)] holds the id to the \ + manifest's [module] name, so the manifest must sit next to Cargo.toml", + manifest_path.display() + )) + })?; + let name = nexum_world::manifest_name(&text) + .map_err(|e| err(format!("{}: {e}", manifest_path.display())))?; + if name != id.value() { + return Err(err(format!( + "{}: venue id {:?} disagrees with [module] name {name:?}", + manifest_path.display(), + id.value(), + ))); + } + Ok(manifest_path.to_string_lossy().into_owned()) +} From 97fc2e09889019c739959a6dba9bf727fb23bf16 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 08:09:15 +0000 Subject: [PATCH 093/139] keeper: rename sweep to run, ConditionalSource to Poller (#579) --- crates/composable-cow/Cargo.toml | 10 +- crates/composable-cow/src/lib.rs | 12 +- .../composable-cow/src/{sweep.rs => run.rs} | 18 +- .../composable-cow/tests/{sweep.rs => run.rs} | 14 +- crates/cow-venue/src/client.rs | 6 +- crates/nexum-sdk/src/keeper.rs | 8 +- crates/nexum-sdk/src/lib.rs | 4 +- crates/nexum-sdk/tests/keeper.rs | 8 +- crates/videre-host/tests/platform.rs | 2 +- crates/videre-sdk/Cargo.toml | 6 +- crates/videre-sdk/src/keeper.rs | 159 +++++++++--------- crates/videre-sdk/src/lib.rs | 10 +- docs/00-overview.md | 4 +- docs/05-sdk-design.md | 18 +- docs/08-platform-generalisation.md | 4 +- docs/sdk.md | 2 +- modules/twap-monitor/Cargo.toml | 2 +- modules/twap-monitor/src/strategy.rs | 8 +- 18 files changed, 146 insertions(+), 149 deletions(-) rename crates/composable-cow/src/{sweep.rs => run.rs} (93%) rename crates/composable-cow/tests/{sweep.rs => run.rs} (98%) diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml index e785b2cb..6d7c29e5 100644 --- a/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "ComposableCoW keeper machinery: the conditional-order body, the structured poll Verdict with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter, and the sweep composition over the venue client." +description = "ComposableCoW keeper machinery: the conditional-order body, the structured poll Verdict with the deployed 1.x revert decoding quarantined behind LegacyRevertAdapter, and the run composition over the venue client." [lib] # Plain library, keeper-side only. The CoW venue crate is orderbook-only @@ -21,7 +21,7 @@ alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } nexum-sdk = { path = "../nexum-sdk" } -# `sweep` slice: the keeper run over the typed CoW client on the +# `run` slice: the keeper run over the typed CoW client on the # `videre:venue/client` seam. cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } videre-sdk = { path = "../videre-sdk", optional = true } @@ -30,12 +30,12 @@ tracing = { workspace = true, optional = true } [features] # The poll-loop composition conditional-commitment keepers share: # gate/journal discipline, pool submission, and retry dispatch. -sweep = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] +run = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] [dev-dependencies] proptest.workspace = true nexum-sdk-test = { path = "../nexum-sdk-test" } [[test]] -name = "sweep" -required-features = ["sweep"] +name = "run" +required-features = ["run"] diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs index d4dc3680..d0eb35bb 100644 --- a/crates/composable-cow/src/lib.rs +++ b/crates/composable-cow/src/lib.rs @@ -3,18 +3,18 @@ //! ComposableCoW keeper machinery, kept out of the CoW venue: the //! conditional-order body ([`ComposableBody`]) and the structured poll //! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined -//! behind [`LegacyRevertAdapter`]. The `sweep` slice adds the shared -//! poll-loop composition ([`run`]) over the typed CoW venue client. +//! behind [`LegacyRevertAdapter`]. The `run` slice adds the shared +//! poll-loop composition ([`run`](run::run)) over the typed CoW venue client. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] pub mod body; pub mod poll; -#[cfg(feature = "sweep")] -pub mod sweep; +#[cfg(feature = "run")] +pub mod run; pub use body::ComposableBody; pub use poll::{IConditionalOrder, LegacyRevertAdapter, Verdict}; -#[cfg(feature = "sweep")] -pub use sweep::run; +#[cfg(feature = "run")] +pub use run::run; diff --git a/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/run.rs similarity index 93% rename from crates/composable-cow/src/sweep.rs rename to crates/composable-cow/src/run.rs index 01e0a702..a3ee56f2 100644 --- a/crates/composable-cow/src/sweep.rs +++ b/crates/composable-cow/src/run.rs @@ -1,8 +1,8 @@ -//! Keeper sweep: 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 -//! watch through a [`ConditionalSource`], and runs the +//! watch through a [`Poller`], and runs the //! [`Verdict`]'s effect: lifecycle outcomes update the gate and //! watch stores, `Post` drives one submission through the typed //! [`CowClient`] onto the `videre:venue/client` seam with the @@ -10,14 +10,14 @@ //! venue-and-body [`intent_id`] - and the keeper [`Retrier`] //! as the failure dispatch. //! -//! Store faults abort the sweep (the next tick replays it); +//! Store faults abort the run (the next tick replays it); //! submission failures never do - they fold into a //! [`RetryAction`] through the videre //! [`retry_action`] table, a `denied` refusal re-entering the CoW //! classification through its errorType prefix //! ([`classify_denied`]) so a one-shot row survives the coarse //! collapse, the ledger applies the effect, and the -//! sweep moves on. Diagnostics go through the guest `tracing` facade - +//! run 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. @@ -26,9 +26,7 @@ use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, classify_denied, intent_id}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, -}; +use nexum_sdk::keeper::{Gates, Journal, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet}; use std::task::Poll; use videre_sdk::client::poll_once; @@ -43,7 +41,7 @@ use crate::Verdict; pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where H: LocalStoreHost, - S: ConditionalSource, + S: Poller, T: VenueTransport, { let watches = WatchSet::new(host); @@ -155,7 +153,7 @@ where tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); } // The submit already succeeded; a journal-store fault here - // must not abort the sweep or unwind the accepted order. + // must not abort the run 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(&intent_id) { @@ -167,7 +165,7 @@ where ); } Ok(SubmitOutcome::RequiresSigning(_)) => { - // A sweep cannot sign; nothing is journalled, so the next + // A run cannot sign; nothing is journalled, so the next // tick surfaces the same ask afresh. tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); } diff --git a/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/run.rs similarity index 98% rename from crates/composable-cow/tests/sweep.rs rename to crates/composable-cow/tests/run.rs index 567a1384..a466e14b 100644 --- a/crates/composable-cow/tests/sweep.rs +++ b/crates/composable-cow/tests/run.rs @@ -1,4 +1,4 @@ -//! Sweep acceptance tests: `run` over the generic store mocks with a +//! Run acceptance tests: `run` over the generic store mocks with a //! scripted venue transport on the `videre:venue/client` seam. use std::cell::{Cell, RefCell}; @@ -10,7 +10,7 @@ use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::LocalStoreHost as _; -use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{Gates, Journal, Poller, Tick, WatchRef, WatchSet}; use nexum_sdk_test::{MockHost, capture_tracing}; use videre_sdk::client::sealed::SealedTransport; use videre_sdk::keeper::submission_key; @@ -22,7 +22,7 @@ use videre_sdk::{ const SEPOLIA: u64 = 11_155_111; /// Scripted venue transport: one submit outcome per queued entry, -/// every submit recorded. Quote, status, and cancel are off the sweep +/// every submit recorded. Quote, status, and cancel are off the run /// path. #[derive(Default)] struct MockVenue { @@ -77,7 +77,7 @@ fn client(venue: &MockVenue) -> CowClient<&MockVenue> { /// observes its own poll calls. struct FnSource(F); -impl ConditionalSource for FnSource +impl Poller for FnSource where F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, { @@ -144,7 +144,7 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -/// The encoded intent body the sweep submits for `order`. +/// The encoded intent body the run submits for `order`. fn intent_bytes(order: &GPv2OrderData) -> Vec { let order_data = gpv2_to_order_data(order).expect("known markers"); CowIntentBody::V1(CowIntent::Signed(SignedOrder { @@ -156,7 +156,7 @@ fn intent_bytes(order: &GPv2OrderData) -> Vec { .expect("body encodes") } -/// The intent-id the sweep journals for `order`: the venue-and-body +/// The intent-id the run journals for `order`: the venue-and-body /// key over the same signed body `run` derives pre-submit. fn intent_id(order: &GPv2OrderData) -> String { submission_key(&CowVenue::ID, &intent_bytes(order)) @@ -406,7 +406,7 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); } -/// A sweep cannot sign: a `requires-signing` outcome is surfaced, not +/// A run cannot sign: a `requires-signing` outcome is surfaced, not /// journalled, so the next tick re-poses the same ask. #[test] fn requires_signing_is_surfaced_and_not_journalled() { diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index aa035281..e61e5b65 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -30,10 +30,10 @@ impl Venue for CowVenue {} /// or submit a foreign body. pub type CowClient = VenueClient; -/// Deterministic intent-id for `body`: the sweep's +/// Deterministic intent-id for `body`: the run's /// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any /// network work, so a keeper journals the same key whether it submits -/// through the sweep or directly. +/// through the run or directly. /// /// The key covers the encoded body, so a signed payload /// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its @@ -121,7 +121,7 @@ mod tests { assert_eq!( id, submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), - "the id must be exactly the key the generic sweep journals", + "the id must be exactly the key the generic run journals", ); assert!(id.starts_with("cow:0x")); diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 726bdae4..115b9725 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -20,7 +20,7 @@ //! //! Two pieces drive the stores from the poll loop: //! -//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! - [`Poller`] - 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 @@ -348,8 +348,8 @@ pub struct Tick { /// 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 { +/// not tear down the caller's run: `poll` is infallible by contract. +pub trait Poller { /// What one poll produces. type Outcome; @@ -362,7 +362,7 @@ pub trait ConditionalSource { /// (for example `"twap"`). Diagnostic only - no behaviour keys /// off it. fn label(&self) -> &'static str { - "conditional" + "poller" } } diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 201aa1fd..9961cc24 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -37,7 +37,7 @@ //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! ([`Journal`]); plus the [`Poller`] poll seam and the //! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! //! - [`chain`] - typed chain access: alloy [`Chain`], @@ -94,7 +94,7 @@ //! [`WatchSet`]: keeper::WatchSet //! [`Gates`]: keeper::Gates //! [`Journal`]: keeper::Journal -//! [`ConditionalSource`]: keeper::ConditionalSource +//! [`Poller`]: keeper::Poller //! [`Retrier`]: keeper::Retrier //! [`RetryAction`]: keeper::RetryAction //! [`Chain`]: alloy_chains::Chain diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index f73c9adf..c2e2ea9f 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -8,8 +8,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, REFUSED_PREFIX, - Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, + Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, REFUSED_PREFIX, Retrier, + RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; use nexum_sdk_test::MockHost; @@ -559,9 +559,9 @@ fn retry_action_labels_are_stable_snake_case() { /// keeper passes the stored params verbatim and the tick it judged /// the gates by. #[test] -fn conditional_source_sees_params_and_tick_verbatim() { +fn poller_sees_params_and_tick_verbatim() { struct EchoSource; - impl ConditionalSource for EchoSource { + impl Poller for EchoSource { type Outcome = (usize, u64, u64, u64, String); fn poll( &self, diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 3011ee60..2dc39e33 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -818,7 +818,7 @@ async fn e2e_twap_monitor_boots_against_the_cow_adapter() { assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); // twap-monitor subscribes to Sepolia blocks (poll path); with no - // watches indexed the sweep is empty and the keeper stays alive. + // watches indexed the run is empty and the keeper stays alive. assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); assert_eq!(supervisor.alive_count(), 1); } diff --git a/crates/videre-sdk/Cargo.toml b/crates/videre-sdk/Cargo.toml index 0c87c63b..acc8a57e 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/crates/videre-sdk/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Guest-side videre SDK: the VenueAdapter trait mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, the generic keeper sweep assembler, and typed wrappers over the scoped transport imports." +description = "Guest-side videre SDK: the VenueAdapter trait mirroring the venue-adapter world, the borsh-versioned IntentBody codec, the typed venue client over the native-AFIT transport seam, the generic keeper run assembler, and typed wrappers over the scoped transport imports." [lib] # Plain library - adapters link this and emit their own cdylib for the @@ -36,10 +36,10 @@ videre-status-body = { path = "../videre-status-body" } http.workspace = true strum.workspace = true thiserror.workspace = true -# Best-effort fault logs on the sweep's non-critical store cleanups. +# Best-effort fault logs on the run's non-critical store cleanups. tracing.workspace = true wit-bindgen.workspace = true [dev-dependencies] -# In-memory `LocalStoreHost` behind the keeper sweep tests. +# In-memory `LocalStoreHost` behind the keeper run tests. nexum-sdk-test = { path = "../nexum-sdk-test" } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 6e64fdcd..945b0697 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -1,27 +1,25 @@ -//! The generic keeper sweep: one pass assembling the world-neutral -//! stores - [`WatchSet`] to [`Gates`] to [`ConditionalSource::poll`] to +//! The generic keeper run: one pass assembling the world-neutral +//! stores - [`WatchSet`] to [`Gates`] to [`Poller::poll`] to //! [`Retrier`] to [`Journal`] - and routing submissions through the //! [`VenueTransport`] seam. //! -//! [`Sweep`] is the shared poll outcome: the concrete -//! [`ConditionalSource::Outcome`] a keeper's sources produce so -//! [`Keeper::sweep`] can act on every one of them. The world-neutral +//! [`Outcome`] is the shared poll outcome: the concrete +//! [`Poller::Outcome`] a keeper's pollers produce so +//! [`Keeper::run`] can act on every one of them. The world-neutral //! primitives stay in `nexum_sdk::keeper`; this module only assembles //! them. use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ - ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, -}; +use nexum_sdk::keeper::{Gates, Journal, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{hex, keccak256}; use crate::client::{VenueId, VenueTransport}; use crate::{SubmitOutcome, UnsignedTx, VenueFault}; -/// What one poll asks the sweep to do with its watch. +/// What one poll asks the run to do with its watch. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] -pub enum Sweep { +pub enum Outcome { /// Submit these encoded intent-body bytes to the bound venue. Submit(Vec), /// Nothing to do yet; the next tick re-polls. @@ -35,7 +33,7 @@ pub enum Sweep { Drop, } -/// A keeper: one conditional source bound to one venue, swept over the +/// A keeper: one poller bound to one venue, run over the /// keeper stores. pub struct Keeper { source: S, @@ -60,8 +58,8 @@ impl Keeper { } impl Keeper { - /// Sweep the watch set once at `tick`: poll every ready watch, - /// submit [`Sweep::Submit`] bodies through the venue seam, and + /// Run the watch set once at `tick`: poll every ready watch, + /// submit [`Outcome::Submit`] bodies through the venue seam, and /// run every other outcome and every venue refusal through the /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on @@ -69,20 +67,20 @@ impl Keeper { /// best-effort - so a journalled acceptance is never resubmitted. /// The record is not atomic with the submit: an acceptance whose /// journal write faults can still resubmit. A `requires-signing` - /// answer journals nothing and is surfaced afresh each sweep. - /// Store faults abort the sweep, bar the post-acceptance marker + /// answer journals nothing and is surfaced afresh each run. + /// Store faults abort the run, bar the post-acceptance marker /// clear; venue refusals never do - they fold into per-watch /// retry actions. - pub async fn sweep(&self, host: &H, tick: &Tick) -> Result + pub async fn run(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, - S: ConditionalSource, + S: Poller, { let watches = WatchSet::new(host); let gates = Gates::new(host); let retrier = Retrier::new(host); let journal = Journal::submitted(host); - let mut report = SweepReport::default(); + let mut report = RunReport::default(); for key in watches.list()? { let Some(watch) = WatchRef::parse(&key) else { @@ -100,7 +98,7 @@ impl Keeper { report.polled += 1; let action = match self.source.poll(host, watch, ¶ms, tick) { - Sweep::Submit(body) => { + Outcome::Submit(body) => { let key = submission_key(&self.venue, &body); if journal.contains(&key)? { report.duplicates += 1; @@ -111,7 +109,7 @@ impl Keeper { journal.record(&key)?; // The acceptance is journalled; the marker // clear is cleanup and must not abort the - // sweep. + // run. if let Err(fault) = retrier.clear_refusal(watch) { tracing::error!( %fault, @@ -128,9 +126,9 @@ impl Keeper { Err(fault) => retry_action(&fault), } } - Sweep::WaitBlock => RetryAction::TryNextBlock, - Sweep::Backoff { seconds } => RetryAction::Backoff { seconds }, - Sweep::Drop => RetryAction::Drop, + Outcome::WaitBlock => RetryAction::TryNextBlock, + Outcome::Backoff { seconds } => RetryAction::Backoff { seconds }, + Outcome::Drop => RetryAction::Drop, }; match action { RetryAction::Drop => report.dropped += 1, @@ -142,27 +140,27 @@ impl Keeper { } } -/// One sweep's tally, by watch disposition. +/// One run's tally, by watch disposition. #[derive(Clone, Debug, Default, PartialEq)] #[non_exhaustive] -pub struct SweepReport { +pub struct RunReport { /// Watches polled. pub polled: usize, /// Watches skipped by an unexpired gate. pub gated: usize, /// Watches skipped unread: a malformed key, or a row that vanished - /// mid-sweep. + /// mid-run. pub skipped: usize, /// Bodies the venue accepted, submission key newly journalled. pub submitted: usize, - /// Bodies whose key an earlier sweep had journalled, skipped + /// Bodies whose key an earlier run had journalled, skipped /// without a venue call. pub duplicates: usize, /// Watches left in place for a later tick. pub retried: usize, /// Watches dropped. pub dropped: usize, - /// Transactions the venue answered `requires-signing`; a sweep + /// Transactions the venue answered `requires-signing`; a run /// cannot sign, so the caller owns them. pub unsigned: Vec, } @@ -170,8 +168,8 @@ pub struct SweepReport { /// Deterministic pre-submit journal key: the venue id and the /// keccak-256 of the body. The hash is a fixed-length suffix, so the /// key is unambiguous whatever the venue id contains. Public so a -/// keeper journalling outside [`Keeper::sweep`] writes the key the -/// sweep checks. +/// keeper journalling outside [`Keeper::run`] writes the key the +/// run checks. pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) } @@ -179,7 +177,7 @@ pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { /// Fold a venue refusal into the retry action the ledger runs: the /// throttle hint becomes an epoch gate, transient failures retry next /// block, and refusals no retry can cure drop the watch. Public so a -/// keeper sweeping outside [`Keeper::sweep`] folds refusals the same +/// keeper running outside [`Keeper::run`] folds refusals the same /// way. pub fn retry_action(fault: &VenueFault) -> RetryAction { match fault { @@ -209,37 +207,37 @@ mod tests { use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::MockLocalStore; - use super::{Keeper, Sweep, SweepReport}; + use super::{Keeper, Outcome, RunReport}; use crate::client::{VenueId, VenueTransport}; use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; - /// Drive a sweep on the test's synchronous boundary. + /// Drive a run on the test's synchronous boundary. fn run(future: F) -> F::Output { match crate::client::poll_once(future) { std::task::Poll::Ready(output) => output, - std::task::Poll::Pending => panic!("sweep futures complete in one poll"), + std::task::Poll::Pending => panic!("run futures complete in one poll"), } } /// Answers every poll with one programmed outcome. - struct StubSource(Sweep); + struct StubSource(Outcome); - impl nexum_sdk::keeper::ConditionalSource for StubSource { - type Outcome = Sweep; + impl nexum_sdk::keeper::Poller for StubSource { + type Outcome = Outcome; - fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Outcome { self.0.clone() } } /// Pops one programmed outcome per poll, from the back. - struct SeqSource(RefCell>); + struct SeqSource(RefCell>); - impl nexum_sdk::keeper::ConditionalSource for SeqSource { - type Outcome = Sweep; + impl nexum_sdk::keeper::Poller for SeqSource { + type Outcome = Outcome; - fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Sweep { - self.0.borrow_mut().pop().unwrap_or(Sweep::WaitBlock) + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Outcome { + self.0.borrow_mut().pop().unwrap_or(Outcome::WaitBlock) } } @@ -299,7 +297,7 @@ mod tests { .expect("mock store accepts the watch") } - fn keeper(outcome: Sweep, venue: &StubVenue) -> Keeper { + fn keeper(outcome: Outcome, venue: &StubVenue) -> Keeper { Keeper::new(StubSource(outcome), venue, "stub") } @@ -308,9 +306,9 @@ mod tests { let host = MockLocalStore::default(); put_watch(&host); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xA5, 0x5A]))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.polled, 1); assert_eq!(report.submitted, 1); assert_eq!(venue.submitted.borrow().as_slice(), [b"body".to_vec()]); @@ -320,8 +318,8 @@ mod tests { assert!(journal.contains(&key).expect("journal reads")); assert_eq!(WatchSet::new(&host).list().expect("list reads").len(), 1); - // A later sweep re-polls the watch but never re-posts the body. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + // A later run re-polls the watch but never re-posts the body. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.submitted, 0); assert_eq!(report.duplicates, 1); assert_eq!(venue.submitted.borrow().len(), 1); @@ -336,8 +334,8 @@ mod tests { .expect("marker writes"); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); assert_eq!(report.submitted, 1); assert!( host.get(&watch.refused_key()) @@ -354,8 +352,8 @@ mod tests { host.fail_on("refused:", Fault::Unavailable("store down".into())); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("marker-clear fault must not abort the sweep"); + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("marker-clear fault must not abort the run"); assert_eq!(report.submitted, 1); let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); assert!( @@ -373,20 +371,20 @@ mod tests { let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); // Polls pop from the back: `one` first, then `two`. let source = SeqSource(RefCell::new(vec![ - Sweep::Submit(b"two".to_vec()), - Sweep::Submit(b"one".to_vec()), + Outcome::Submit(b"two".to_vec()), + Outcome::Submit(b"one".to_vec()), ])); let keeper = Keeper::new(source, &venue, "stub"); assert_eq!( - run(keeper.sweep(&host, &TICK)) - .expect("sweep runs") + run(keeper.run(&host, &TICK)) + .expect("keeper runs") .submitted, 1 ); assert_eq!( - run(keeper.sweep(&host, &TICK)) - .expect("sweep runs") + run(keeper.run(&host, &TICK)) + .expect("keeper runs") .submitted, 1 ); @@ -407,15 +405,15 @@ mod tests { data: vec![0xFE], }; let venue = StubVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.unsigned, vec![tx.clone()]); assert_eq!(report.submitted, 0); - // Nothing accepted, nothing journalled: the next sweep + // Nothing accepted, nothing journalled: the next run // surfaces the same transaction again. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.unsigned, vec![tx]); } @@ -429,8 +427,8 @@ mod tests { .expect("gate writes"); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); assert_eq!(report.gated, 1); assert_eq!(report.polled, 0); assert!(venue.submitted.borrow().is_empty()); @@ -442,7 +440,7 @@ mod tests { put_watch(&host); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = run(keeper(Sweep::Drop, &venue).sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper(Outcome::Drop, &venue).run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.dropped, 1); assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); } @@ -452,13 +450,13 @@ mod tests { let host = MockLocalStore::default(); put_watch(&host); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let keeper = keeper(Sweep::Backoff { seconds: 30 }, &venue); + let keeper = keeper(Outcome::Backoff { seconds: 30 }, &venue); - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.retried, 1); // Still inside the backoff window: gated, not polled. - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.gated, 1); // At the threshold the gate opens again. @@ -466,7 +464,7 @@ mod tests { epoch_s: TICK.epoch_s + 30, ..TICK }; - let report = run(keeper.sweep(&host, &later)).expect("sweep runs"); + let report = run(keeper.run(&host, &later)).expect("keeper runs"); assert_eq!(report.polled, 1); } @@ -477,9 +475,9 @@ mod tests { let venue = StubVenue::new(Err(VenueFault::RateLimited { retry_after_ms: Some(2_500), })); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.retried, 1); // 2500 ms rounds up to a 3 s epoch gate. @@ -488,7 +486,7 @@ mod tests { ..TICK }; assert_eq!( - run(keeper.sweep(&host, &at_2s)).expect("sweep runs").gated, + run(keeper.run(&host, &at_2s)).expect("keeper runs").gated, 1 ); let at_3s = Tick { @@ -496,7 +494,7 @@ mod tests { ..TICK }; assert_eq!( - run(keeper.sweep(&host, &at_3s)).expect("sweep runs").polled, + run(keeper.run(&host, &at_3s)).expect("keeper runs").polled, 1 ); } @@ -507,8 +505,8 @@ mod tests { put_watch(&host); let venue = StubVenue::new(Err(VenueFault::Denied("blocked".into()))); - let report = run(keeper(Sweep::Submit(b"body".to_vec()), &venue).sweep(&host, &TICK)) - .expect("sweep runs"); + let report = run(keeper(Outcome::Submit(b"body".to_vec()), &venue).run(&host, &TICK)) + .expect("keeper runs"); assert_eq!(report.dropped, 1); assert!(WatchSet::new(&host).list().expect("list reads").is_empty()); } @@ -518,12 +516,12 @@ mod tests { let host = MockLocalStore::default(); put_watch(&host); let venue = StubVenue::new(Err(VenueFault::Unavailable("down".into()))); - let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + let keeper = keeper(Outcome::Submit(b"body".to_vec()), &venue); - let report = run(keeper.sweep(&host, &TICK)).expect("sweep runs"); + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); assert_eq!(report.retried, 1); assert_eq!( - run(keeper.sweep(&host, &TICK)).expect("sweep runs").polled, + run(keeper.run(&host, &TICK)).expect("keeper runs").polled, 1 ); } @@ -533,7 +531,8 @@ mod tests { let host = MockLocalStore::default(); let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![1]))); - let report = run(keeper(Sweep::WaitBlock, &venue).sweep(&host, &TICK)).expect("sweep runs"); - assert_eq!(report, SweepReport::default()); + let report = + run(keeper(Outcome::WaitBlock, &venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report, RunReport::default()); } } diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 3594f2b5..5bdec44d 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -29,11 +29,11 @@ //! [`poll_once`](client::poll_once) completes their futures on the //! synchronous guest boundary. //! -//! - [`keeper`](mod@keeper) - the generic sweep assembler: -//! [`Keeper::sweep`] runs the world-neutral `nexum_sdk::keeper` +//! - [`keeper`](mod@keeper) - the generic run assembler: +//! [`Keeper::run`] runs the world-neutral `nexum_sdk::keeper` //! stores over a -//! [`ConditionalSource`](nexum_sdk::keeper::ConditionalSource) -//! producing the shared [`Sweep`] outcome, submitting through the +//! [`Poller`](nexum_sdk::keeper::Poller) +//! producing the shared [`Outcome`] outcome, submitting through the //! [`VenueTransport`] seam. //! //! - [`transport`] - typed wrappers over the world's scoped imports: @@ -87,7 +87,7 @@ pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; pub use faults::VenueFault; -pub use keeper::{Keeper, Sweep, SweepReport, retry_action}; +pub use keeper::{Keeper, Outcome, RunReport, retry_action}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; diff --git a/docs/00-overview.md b/docs/00-overview.md index c5d0a6e3..768448e1 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -270,13 +270,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 | +| | `keeper::{WatchSet, Gates, Journal, Retrier, Poller}` - 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`, `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 | +| | `cow::run` - the shared poll-loop composition: run the keeper watch set, poll a `Poller`, 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/05-sdk-design.md b/docs/05-sdk-design.md index 1512ee47..af944182 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -48,7 +48,7 @@ nexum-sdk/ # universal module SDK (host-neutral, domain-free ├── 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 + ├── keeper.rs # WatchSet, Gates, Journal, Poller, 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 @@ -64,7 +64,7 @@ videre-sdk/ # venue SDK: both venue sides ├── adapter.rs # VenueAdapter trait (init + the five intent functions) ├── body.rs # IntentBody trait + BodyError (versioned borsh codec) ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues - ├── keeper.rs # Keeper::sweep - the generic sweep assembler; Sweep, SweepReport + ├── keeper.rs # Keeper::run - the generic run assembler; Outcome, RunReport ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError ├── rt.rs # completes async keeper handlers on the sync guest boundary @@ -76,7 +76,7 @@ videre-test/ # venue conformance kit └── src/ # CodecVectors, HeaderGoldens, MockTransport, reference venue cow-venue/ # the CoW venue, as feature slices -composable-cow/ # ComposableCoW keeper machinery (body, poll seam, sweep) +composable-cow/ # ComposableCoW keeper machinery (body, poll seam, run) ``` `nexum-sdk` is host-neutral and domain-free: any module targeting the @@ -198,10 +198,10 @@ The `Guest`/`export!` shape the macro emits follows the `strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, -`ConditionalSource`, `Retrier` - give conditional-commitment modules +`Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic -sweep. +run. ## Venue persona: `videre-sdk` @@ -290,11 +290,11 @@ synchronous guest boundary. A `From` impl onto the wire fault is emitted, so `?` applies to client calls inside handlers. -`videre_sdk::keeper::Keeper::sweep` assembles the world-neutral +`videre_sdk::keeper::Keeper::run` assembles the world-neutral `nexum_sdk::keeper` stores - `WatchSet` to `Gates` to -`ConditionalSource::poll` to `Retrier` to `Journal` - over the +`Poller::poll` to `Retrier` to `Journal` - over the `VenueTransport` seam, so a conditional-commitment keeper writes one -`poll` producing the shared `Sweep` outcome and inherits the whole +`poll` producing the shared `Outcome` outcome and inherits the whole gate/journal/retry pass. ### Testing: `nexum-sdk-test` and `videre-test` @@ -444,7 +444,7 @@ the venue stays orderbook-only: poll seam (`Verdict`, with the deployed 1.x reverting wire quarantined behind `LegacyRevertAdapter`, per [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the - `sweep` slice composing the poll loop over the typed `CowClient`. + `run` slice composing the poll loop over the typed `CowClient`. The shipped CoW keepers - `modules/twap-monitor`, `modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index fc9023d9..fab96f38 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -975,7 +975,7 @@ The SDK mirrors the architecture, one crate per layer, with no re-export between - **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, the `#[nexum_sdk::module]` attribute macro, chain / config / address helpers, the `http` fetch seam over wasi:http, the keeper store primitives, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore`, `Messaging`, and `Signer` typed wrappers as future direction. Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic sweep assembler and the `videre-test` conformance kit alongside. +- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic run assembler and the `videre-test` conformance kit alongside. - **Per-venue crates (shipped for CoW)** - each domain ships as crates on the venue layer, not as an SDK layer: `cow-venue` (body codec, typed client, adapter component) and `composable-cow` (conditional-order keeper machinery). A new domain adds its own. @@ -1008,7 +1008,7 @@ For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnece | `shepherd:cow` WIT package | CoW event-ABI package of record (`cow-events` only; the legacy `cow-api` read path and `shepherd` world are retired) | | Venue adapter components | The domain-extension mechanism: `#[videre_sdk::venue]` components installed into the videre platform (`cow-venue` shipped) | | `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / ChainError, bind macro, module macro, chain / config / address helpers, keeper store primitives, guest `http` helper, tracing facade | -| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, sweep assembler; `videre-test` conformance kit alongside | +| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, run assembler; `videre-test` conformance kit alongside | | Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | | Host Adapter | Platform-specific implementation of universal interfaces | diff --git a/docs/sdk.md b/docs/sdk.md index 1e93cd36..a465dff3 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -5,7 +5,7 @@ primitives, ABI helpers, an effect-trait seam for testing, the `#[nexum_sdk::module]` attribute macro and per-module adapter macro, and a `prelude` that keeps boilerplate out of module crates. `videre-sdk` layers the venue surface on top: the typed venue client, -the intent-body codec, the `VenueAdapter` seam and the keeper sweep. +the intent-body codec, the `VenueAdapter` seam and the keeper run. Modules that talk to a venue depend on both crates and import each directly (nothing is re-exported between them). diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 53e29409..a9a91c9d 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -9,7 +9,7 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -composable-cow = { path = "../../crates/composable-cow", features = ["sweep"] } +composable-cow = { path = "../../crates/composable-cow", features = ["run"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } videre-sdk = { path = "../../crates/videre-sdk" } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 92c5970c..c027be23 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -12,7 +12,7 @@ //! //! The module owns decode and evaluate only: log decoding into the //! keeper watch set, and the `getTradeableOrderWithSignature` poll -//! behind [`ConditionalSource`]. Gate discipline, the `submitted:` +//! behind [`Poller`]. Gate discipline, the `submitted:` //! journal, submission through the venue registry, and retry dispatch live in //! the shared composition (`composable_cow::run`). @@ -28,7 +28,7 @@ use cowprotocol::{ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; -use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet, watch_key}; +use nexum_sdk::keeper::{Poller, Tick, WatchRef, WatchSet, watch_key}; use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. @@ -237,10 +237,10 @@ fn remove_watch( /// TWAP conditional source: decode the stored row's /// `ConditionalOrderParams` and evaluate /// `getTradeableOrderWithSignature` on chain. A row this source cannot -/// decode polls again next block rather than tearing down the sweep. +/// decode polls again next block rather than tearing down the run. struct TwapSource; -impl ConditionalSource for TwapSource { +impl Poller for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { From 37fbf0b2d3a6e424d72d32809fa511d607454212 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 11:51:44 +0000 Subject: [PATCH 094/139] cow-venue: split refusal projection by read vs submit path (#580) --- crates/cow-venue/src/adapter.rs | 39 +++++++++++++++++---------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 693154ee..297aa54d 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -210,7 +210,7 @@ pub(crate) fn status_with( return Err(VenueError::Unavailable("order not found".to_owned())); } if !response.status().is_success() { - return Err(refusal(&response).into_error()); + return Err(refusal_for_read(&response)); } /// The one server field the lifecycle projection reads. #[derive(Deserialize)] @@ -249,7 +249,7 @@ pub(crate) fn quote_with( Some(request), )?; if !response.status().is_success() { - return Err(refusal(&response).into_error()); + return Err(refusal_for_read(&response)); } let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; @@ -321,7 +321,7 @@ fn post_order( .map_err(|e| VenueError::Unavailable(format!("uid decode failed: {e}")))?; return Ok(Posted::Accepted(uid)); } - match refusal(&response) { + match refusal_for_submit(&response) { Refusal::AlreadyHeld => Ok(Posted::AlreadyHeld), Refusal::Error(err) => Err(err), } @@ -352,7 +352,9 @@ fn call( Ok(fetch.fetch(request)?) } -/// A non-2xx orderbook reply, projected for the wire. +/// A non-2xx orderbook reply on the submit path, where already-held is +/// a success shape. Reads take [`refusal_for_read`] instead, so a read +/// caller cannot hold an unresolved already-held. enum Refusal { /// The structured already-held rejection: success wearing an error /// status. @@ -361,21 +363,10 @@ enum Refusal { Error(VenueError), } -impl Refusal { - /// Collapse for call sites where already-held is not a success - /// shape (reads); the orderbook only emits it on submission. - fn into_error(self) -> VenueError { - match self { - Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), - Refusal::Error(err) => err, - } - } -} - -/// Project a non-2xx reply: throttles first, server failures stay -/// retryable, and only a structured 4xx envelope reaches the +/// Project a non-2xx submit reply: throttles first, server failures +/// stay retryable, and only a structured 4xx envelope reaches the /// classification table. -fn refusal(response: &http::Response>) -> Refusal { +fn refusal_for_submit(response: &http::Response>) -> Refusal { let status = response.status(); if status == http::StatusCode::TOO_MANY_REQUESTS { return Refusal::Error(VenueError::RateLimited(RateLimit { @@ -396,6 +387,16 @@ fn refusal(response: &http::Response>) -> Refusal { } } +/// Project a non-2xx read reply: already-held has no success meaning on +/// a read (the orderbook only emits it on submission), so it collapses +/// to an error rather than a success shape. +fn refusal_for_read(response: &http::Response>) -> VenueError { + match refusal_for_submit(response) { + Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), + Refusal::Error(err) => err, + } +} + /// `Retry-After` in milliseconds, when the reply carries the /// delta-seconds form. fn retry_after_ms(response: &http::Response>) -> Option { @@ -839,7 +840,7 @@ mod tests { .header(http::header::RETRY_AFTER, "7") .body(Vec::new()) .expect("test response builds"); - let Refusal::Error(VenueError::RateLimited(rl)) = refusal(&response) else { + let Refusal::Error(VenueError::RateLimited(rl)) = refusal_for_submit(&response) else { panic!("429 must rate-limit"); }; assert_eq!(rl.retry_after_ms, Some(7_000)); From 2a1052fc51e8136b90cc9cde39c243fd7832e03b Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 11:54:25 +0000 Subject: [PATCH 095/139] cow-venue: replace OrderBuilder typestate with constructor args (#581) --- crates/cow-venue/src/adapter.rs | 14 ++- crates/cow-venue/src/body.rs | 16 +-- crates/cow-venue/src/client.rs | 28 ++++-- crates/cow-venue/src/order.rs | 169 ++++++++++++++------------------ 4 files changed, 113 insertions(+), 114 deletions(-) diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 297aa54d..a6272fb9 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -558,11 +558,15 @@ mod tests { } fn order_body() -> OrderBody { - OrderBody::sell(SellToken([0x11; 20]), amount(42)) - .for_at_least(BuyToken([0x22; 20]), amount(41)) - .valid_to(1_700_000_000) - .app_data([0x44; 32]) - .build() + OrderBody::sell( + SellToken([0x11; 20]), + amount(42), + BuyToken([0x22; 20]), + amount(41), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .build() } fn amount(value: u8) -> [u8; 32] { diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 6bb39165..1442089e 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -41,12 +41,16 @@ mod tests { use crate::order::{BuyToken, SellToken}; fn order_body() -> OrderBody { - OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) - .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) - .valid_to(1_700_000_000) - .app_data([0x44; 32]) - .partially_fillable() - .build() + OrderBody::sell( + SellToken([0x11; 20]), + [0x01; 32], + BuyToken([0x22; 20]), + [0x02; 32], + 1_700_000_000, + ) + .app_data([0x44; 32]) + .partially_fillable() + .build() } /// The codec conformance set: the v1 intent as a round-trip vector diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index e61e5b65..83f56095 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -99,12 +99,16 @@ mod tests { use crate::body::CowIntent; use crate::order::{BuyToken, OrderBody, SellToken}; CowIntentBody::V1(CowIntent::Order( - OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) - .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) - .valid_to(1_700_000_000) - .app_data([0x44; 32]) - .partially_fillable() - .build(), + OrderBody::sell( + SellToken([0x11; 20]), + [0x01; 32], + BuyToken([0x22; 20]), + [0x02; 32], + 1_700_000_000, + ) + .app_data([0x44; 32]) + .partially_fillable() + .build(), )) } @@ -126,10 +130,14 @@ mod tests { assert!(id.starts_with("cow:0x")); let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) - .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) - .valid_to(1_700_000_000) - .build(), + order: OrderBody::sell( + SellToken([0x11; 20]), + [0x01; 32], + BuyToken([0x22; 20]), + [0x02; 32], + 1_700_000_000, + ) + .build(), owner: [0x55; 20], signature: vec![0xC0], })); diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index 8bb1d725..dbc0a67a 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -11,7 +11,6 @@ use alloc::vec::Vec; use core::fmt; -use core::marker::PhantomData; use borsh::{BorshDeserialize, BorshSerialize}; @@ -106,46 +105,65 @@ pub struct OrderBody { } impl OrderBody { - /// Start a sell order: `amount` of `token` is the fixed side. + /// A sell order: `sell_amount` of `sell` is the fixed side, at least + /// `buy_amount` of `buy` in return, expiring at `valid_to`. #[must_use] - pub const fn sell(token: SellToken, amount: U256) -> OrderBuilder { - OrderBuilder::start(OrderKind::Sell, token.0, amount, [0; 20], [0; 32]) - } - - /// Start a buy order: `amount` of `token` is the fixed side. + pub const fn sell( + sell: SellToken, + sell_amount: U256, + buy: BuyToken, + buy_amount: U256, + valid_to: u32, + ) -> OrderBuilder { + OrderBuilder::new( + OrderKind::Sell, + sell.0, + sell_amount, + buy.0, + buy_amount, + valid_to, + ) + } + + /// A buy order: `buy_amount` of `buy` is the fixed side, spending at + /// most `sell_amount` of `sell`, expiring at `valid_to`. #[must_use] - pub const fn buy(token: BuyToken, amount: U256) -> OrderBuilder { - OrderBuilder::start(OrderKind::Buy, [0; 20], [0; 32], token.0, amount) + pub const fn buy( + buy: BuyToken, + buy_amount: U256, + sell: SellToken, + sell_amount: U256, + valid_to: u32, + ) -> OrderBuilder { + OrderBuilder::new( + OrderKind::Buy, + sell.0, + sell_amount, + buy.0, + buy_amount, + valid_to, + ) } } -/// Builder state: the buy-side limit is unset. -pub enum NeedsBuy {} -/// Builder state: the sell-side limit is unset. -pub enum NeedsSell {} -/// Builder state: the expiry is unset. -pub enum NeedsValidTo {} -/// Builder state: every required field is set. -pub enum Ready {} - -/// Typestate builder for [`OrderBody`]: [`OrderBody::sell`] or -/// [`OrderBody::buy`] fixes the kind and its side, the counter-side -/// limit and the expiry are compile-time required, and the optionals -/// default (self-receive, zero `app_data` and `fee_amount`, -/// fill-or-kill, ERC-20 balances). +/// Builder for [`OrderBody`]: the required fields (both sides and the +/// expiry) are constructor args, so completeness is compile-time +/// guaranteed without state markers; the optionals default (self-receive, +/// zero `app_data` and `fee_amount`, fill-or-kill, ERC-20 balances). +/// Use [`OrderBody::sell`] or [`OrderBody::buy`] to fix the kind. #[derive(Clone, Debug)] -pub struct OrderBuilder { +pub struct OrderBuilder { body: OrderBody, - state: PhantomData, } -impl OrderBuilder { - const fn start( +impl OrderBuilder { + const fn new( kind: OrderKind, sell_token: Address, sell_amount: U256, buy_token: Address, buy_amount: U256, + valid_to: u32, ) -> Self { Self { body: OrderBody { @@ -154,7 +172,7 @@ impl OrderBuilder { receiver: None, sell_amount, buy_amount, - valid_to: 0, + valid_to, app_data: [0; 32], fee_amount: [0; 32], kind, @@ -162,56 +180,9 @@ impl OrderBuilder { sell_token_balance: SellTokenSource::Erc20, buy_token_balance: BuyTokenDestination::Erc20, }, - state: PhantomData, } } - const fn into_state(self) -> OrderBuilder { - OrderBuilder { - body: self.body, - state: PhantomData, - } - } -} - -impl OrderBuilder { - /// Demand at least `amount` of `token` in return. - #[must_use] - pub const fn for_at_least( - mut self, - token: BuyToken, - amount: U256, - ) -> OrderBuilder { - self.body.buy_token = token.0; - self.body.buy_amount = amount; - self.into_state() - } -} - -impl OrderBuilder { - /// Spend at most `amount` of `token`. - #[must_use] - pub const fn for_at_most( - mut self, - token: SellToken, - amount: U256, - ) -> OrderBuilder { - self.body.sell_token = token.0; - self.body.sell_amount = amount; - self.into_state() - } -} - -impl OrderBuilder { - /// Expire at `valid_to` (Unix seconds). - #[must_use] - pub const fn valid_to(mut self, valid_to: u32) -> OrderBuilder { - self.body.valid_to = valid_to; - self.into_state() - } -} - -impl OrderBuilder { /// Deliver the buy token to `receiver` instead of the owner. #[must_use] pub const fn receiver(mut self, receiver: Address) -> Self { @@ -352,25 +323,33 @@ mod tests { #[test] fn sell_builder_matches_the_literal() { - let built = OrderBody::sell(SellToken([0x11; 20]), sample().sell_amount) - .for_at_least(BuyToken([0x22; 20]), [0xff; 32]) - .valid_to(0xffff_ffff) - .receiver([0x33; 20]) - .app_data([0x44; 32]) - .build(); + let built = OrderBody::sell( + SellToken([0x11; 20]), + sample().sell_amount, + BuyToken([0x22; 20]), + [0xff; 32], + 0xffff_ffff, + ) + .receiver([0x33; 20]) + .app_data([0x44; 32]) + .build(); assert_eq!(built, sample()); } #[test] fn buy_builder_fixes_the_buy_side() { - let built = OrderBody::buy(BuyToken([0x22; 20]), [0xff; 32]) - .for_at_most(SellToken([0x11; 20]), [0x01; 32]) - .valid_to(100) - .partially_fillable() - .sell_token_balance(SellTokenSource::External) - .buy_token_balance(BuyTokenDestination::Internal) - .fee_amount([0x05; 32]) - .build(); + let built = OrderBody::buy( + BuyToken([0x22; 20]), + [0xff; 32], + SellToken([0x11; 20]), + [0x01; 32], + 100, + ) + .partially_fillable() + .sell_token_balance(SellTokenSource::External) + .buy_token_balance(BuyTokenDestination::Internal) + .fee_amount([0x05; 32]) + .build(); assert_eq!(built.kind, OrderKind::Buy); assert_eq!(built.sell_token, [0x11; 20]); assert_eq!(built.buy_token, [0x22; 20]); @@ -386,10 +365,14 @@ mod tests { #[test] fn builder_defaults_are_the_wire_defaults() { - let built = OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) - .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) - .valid_to(1) - .build(); + let built = OrderBody::sell( + SellToken([0x11; 20]), + [0x01; 32], + BuyToken([0x22; 20]), + [0x02; 32], + 1, + ) + .build(); assert_eq!(built.receiver, None); assert_eq!(built.app_data, [0; 32]); assert_eq!(built.fee_amount, [0; 32]); From 7b8b0713c0e75490e176701b26a00855da5158dd Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 11:55:02 +0000 Subject: [PATCH 096/139] twap-monitor: rename strategy module to keeper (#582) --- modules/twap-monitor/module.toml | 4 ++-- modules/twap-monitor/src/{strategy.rs => keeper.rs} | 2 +- modules/twap-monitor/src/lib.rs | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) rename modules/twap-monitor/src/{strategy.rs => keeper.rs} (99%) diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index 575806f6..e53a15e4 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -27,7 +27,7 @@ allow = [] # ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = # keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"), -# parity-tested against the sol! decoder in strategy.rs. Both `address` +# parity-tested against the sol! decoder in keeper.rs. Both `address` # and `event_signature` are pinned so the supervisor does not deliver # unrelated logs to the module. [[subscription]] @@ -38,7 +38,7 @@ event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2c # ComposableCoW v2 ConditionalOrderRemoved emissions. topic-0 = # keccak256("ConditionalOrderRemoved(address,bytes32)"), parity-tested -# against the sol! decoder in strategy.rs. +# against the sol! decoder in keeper.rs. # This stream and the created stream merge in arrival order, not # chain order, so a removal drops the watch (with its gates) only # when it postdates the watch's indexed create. diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/keeper.rs similarity index 99% rename from modules/twap-monitor/src/strategy.rs rename to modules/twap-monitor/src/keeper.rs index c027be23..ec2ed215 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -1,4 +1,4 @@ -//! Pure strategy logic for the twap-monitor module. +//! Pure logic for the twap-monitor keeper module. //! //! Every interaction with the world flows through a trait seam: the //! `nexum_sdk::host` traits for chain and store access and the videre diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 71bf20a5..2360715e 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -7,13 +7,13 @@ //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against the +//! - `keeper.rs` holds the pure logic and unit tests against the //! `nexum_sdk::host` trait seams and the videre `VenueTransport` //! seam. It does not know `wit-bindgen` exists. //! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the //! macro derives the component world from `module.toml`, emits the //! `WitBindgenHost` adapter, and dispatches each event variant to -//! `strategy` with the typed [`CowClient`] over the module's own +//! `keeper` with the typed [`CowClient`] over the module's own //! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity @@ -22,7 +22,7 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -mod strategy; +mod keeper; use cow_venue::CowClient; use nexum::host::types; @@ -39,17 +39,17 @@ impl TwapMonitor { fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs)?; + keeper::on_chain_logs(&WitBindgenHost, &logs)?; Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { - let info = strategy::BlockInfo { + let info = keeper::BlockInfo { chain_id: block.chain_id, number: block.number, timestamp: block.timestamp, }; - strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; + keeper::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } From c9b4d5dcd5186a9f26df69e043b9b595dc3bc1b8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 11:56:27 +0000 Subject: [PATCH 097/139] keeper: add reserve/commit/mark journal primitives (#583) keeper: add reserve/commit/mark journal primitives (dormant) --- crates/nexum-sdk/src/keeper.rs | 125 +++++++++++++++++++++++- crates/nexum-sdk/tests/keeper.rs | 160 ++++++++++++++++++++++++++++++- 2 files changed, 280 insertions(+), 5 deletions(-) diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 115b9725..7457acf3 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -287,9 +287,51 @@ fn read_u64(host: &H, key: &str) -> Result, Fault } } -/// Receipt-keyed idempotency journal: presence markers under a fixed -/// prefix. The marker value is empty - presence of the key is the -/// receipt - so re-recording is idempotent by construction. +/// RESERVED value tag: the marker owes a durable effect, its body and +/// `next_eligible` follow. +const RESERVED_TAG: u8 = 0x01; +/// COMMITTED value tag: the durable effect landed; nothing is owed. +const COMMITTED_TAG: u8 = 0x02; + +/// Presence class of a durable-effect marker under the tag-first value +/// scheme. RESERVED and COMMITTED can never collide by presence: the +/// leading tag byte tells them apart, and a corrupt tag falls to +/// [`Reserved`](Mark::Reserved) so a reconcile resubmits, never skips. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Mark { + /// A submit is in flight or owed; the effect is not yet durable. + Reserved, + /// The upstream effect is durable; no resubmit is owed. + Committed, +} + +/// A live reservation enumerated from the journal: the receipt key +/// (prefix-stripped), the earliest epoch-seconds it may retry at, and +/// the stored body. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Reservation { + /// Receipt key as passed to [`Journal::reserve`], prefix stripped. + pub key: String, + /// Earliest Unix-seconds a retry is eligible; `0` when unset. + pub next_eligible: u64, + /// Opaque reservation body stored alongside the marker. + pub body: Vec, +} + +/// Encode a RESERVED value: `[0x01] ++ be64(next_eligible) ++ body`. +fn reserved_value(next_eligible: u64, body: &[u8]) -> Vec { + let mut v = Vec::with_capacity(9 + body.len()); + v.push(RESERVED_TAG); + v.extend_from_slice(&next_eligible.to_be_bytes()); + v.extend_from_slice(body); + v +} + +/// Receipt-keyed idempotency journal under a fixed prefix. The observed +/// path ([`record`](Self::record) / [`contains`](Self::contains)) stores +/// an empty presence marker, so re-recording is idempotent. The +/// durable-effect path ([`reserve`](Self::reserve) / [`commit`](Self::commit)) +/// tags the value so [`mark`](Self::mark) tells RESERVED from COMMITTED. pub struct Journal<'h, H> { host: &'h H, prefix: &'static str, @@ -320,12 +362,89 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { } /// Whether the receipt is already journalled. + /// + /// Presence-only: reports the key exists, not whether it is RESERVED + /// or COMMITTED. MUST NOT guard a reserve/commit journal; use + /// [`mark`](Self::mark) there, else a corrupt marker is guard-skipped + /// yet never reconciled. pub fn contains(&self, receipt: &str) -> Result { Ok(self .host .get(&format!("{}{receipt}", self.prefix))? .is_some()) } + + /// Reserve `key`: write a RESERVED marker with `next_eligible` 0. + pub fn reserve(&self, key: &str, body: &[u8]) -> Result<(), Fault> { + self.host + .set(&format!("{}{key}", self.prefix), &reserved_value(0, body)) + } + + /// Re-park a reservation: rewrite its RESERVED marker with + /// `next_eligible = until`, body unchanged. + pub fn park(&self, key: &str, body: &[u8], until: u64) -> Result<(), Fault> { + self.host.set( + &format!("{}{key}", self.prefix), + &reserved_value(until, body), + ) + } + + /// Commit `key`: overwrite with the COMMITTED marker. Idempotent. + pub fn commit(&self, key: &str) -> Result<(), Fault> { + self.host + .set(&format!("{}{key}", self.prefix), &[COMMITTED_TAG]) + } + + /// Release `key`: delete the marker. No-op when absent. + pub fn release(&self, key: &str) -> Result<(), Fault> { + self.host.delete(&format!("{}{key}", self.prefix)) + } + + /// Classify `key`'s marker. Absent: `None`. Legacy empty or a + /// leading `0x02`: [`Committed`](Mark::Committed). Any other first + /// byte (`0x01`, unknown, corrupt): [`Reserved`](Mark::Reserved), so + /// a corrupt tag reconciles rather than skips. + pub fn mark(&self, key: &str) -> Result, Fault> { + let Some(v) = self.host.get(&format!("{}{key}", self.prefix))? else { + return Ok(None); + }; + Ok(Some(match v.first() { + None | Some(&COMMITTED_TAG) => Mark::Committed, + Some(_) => Mark::Reserved, + })) + } + + /// Enumerate every live reservation: each key whose value is + /// non-empty and not COMMITTED. Parse is fail-safe and agrees with + /// [`mark`](Self::mark) - a well-formed `0x01` marker yields its + /// `next_eligible` and body, any other non-committed value yields + /// `next_eligible` 0 and the bytes after the tag. Feeds the sweep + /// reconcile and operator enumerate. + pub fn pending(&self) -> Result, Fault> { + let mut out = Vec::new(); + for full in self.host.list_keys(self.prefix)? { + let Some(v) = self.host.get(&full)? else { + continue; + }; + if v.first().is_none_or(|&b| b == COMMITTED_TAG) { + continue; + } + let key = full.strip_prefix(self.prefix).unwrap_or(&full).to_owned(); + let (next_eligible, body) = if v[0] == RESERVED_TAG && v.len() >= 9 { + let mut be = [0u8; 8]; + be.copy_from_slice(&v[1..9]); + (u64::from_be_bytes(be), v[9..].to_vec()) + } else { + (0, v.get(1..).unwrap_or(&[]).to_vec()) + }; + out.push(Reservation { + key, + next_eligible, + body, + }); + } + Ok(out) + } } /// One poll dispatch's world view: chain, block height, and the block diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index c2e2ea9f..1d7f33ee 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -8,8 +8,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, REFUSED_PREFIX, Retrier, - RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, + Gates, Journal, Mark, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, REFUSED_PREFIX, + Reservation, Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; use nexum_sdk_test::MockHost; @@ -354,6 +354,162 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(!snapshot.contains_key("observed:0xuid")); } +// ---- durable-effect journal ---- + +#[test] +fn mark_distinguishes_reserved_committed_absent_and_legacy() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + // Absent. + assert_eq!(journal.mark("absent").unwrap(), None); + + // Reserved. + journal.reserve("res", b"body").unwrap(); + assert_eq!(journal.mark("res").unwrap(), Some(Mark::Reserved)); + + // Committed. + journal.commit("com").unwrap(); + assert_eq!(journal.mark("com").unwrap(), Some(Mark::Committed)); + + // Legacy empty presence marker reads as Committed. + host.store.set("submitted:legacy", b"").unwrap(); + assert_eq!(journal.mark("legacy").unwrap(), Some(Mark::Committed)); + + // A corrupt single-byte tag reads as Reserved. + host.store.set("submitted:corrupt", b"\x7f").unwrap(); + assert_eq!(journal.mark("corrupt").unwrap(), Some(Mark::Reserved)); +} + +#[test] +fn reserve_then_commit_marks_committed() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.commit("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); +} + +#[test] +fn reserve_then_release_marks_absent() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.release("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), None); + assert!(host.store.is_empty()); +} + +#[test] +fn mark_and_pending_agree_on_every_non_committed_value() { + // The invariant: every value mark() calls Reserved, pending() must + // enumerate - including a corrupt single-byte tag with empty body. + // A guard-skipped-yet-never-reconciled marker is a dropped effect. + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + journal.reserve("well_formed", b"body").unwrap(); + host.store.set("submitted:corrupt_tag", b"\x7f").unwrap(); + host.store.set("submitted:short_01", b"\x01").unwrap(); + + let listed: std::collections::BTreeSet = journal + .pending() + .unwrap() + .into_iter() + .map(|r| r.key) + .collect(); + + for key in ["well_formed", "corrupt_tag", "short_01"] { + assert_eq!( + journal.mark(key).unwrap(), + Some(Mark::Reserved), + "{key} must mark Reserved", + ); + assert!(listed.contains(key), "{key} must be enumerated"); + } + + // The corrupt tag enumerates with a zero next_eligible and empty body. + let corrupt = journal + .pending() + .unwrap() + .into_iter() + .find(|r| r.key == "corrupt_tag") + .unwrap(); + assert_eq!(corrupt.next_eligible, 0); + assert!(corrupt.body.is_empty()); +} + +#[test] +fn pending_ignores_committed_and_legacy_and_returns_bodies() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + journal.park("a", b"aa", 1_700_000_000).unwrap(); + journal.reserve("b", b"bb").unwrap(); + journal.commit("c").unwrap(); + host.store.set("submitted:d", b"").unwrap(); + + let mut listed = journal.pending().unwrap(); + listed.sort_by(|x, y| x.key.cmp(&y.key)); + + assert_eq!( + listed, + vec![ + Reservation { + key: "a".into(), + next_eligible: 1_700_000_000, + body: b"aa".to_vec(), + }, + Reservation { + key: "b".into(), + next_eligible: 0, + body: b"bb".to_vec(), + }, + ], + ); +} + +#[test] +fn park_updates_next_eligible_without_touching_body() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.park("uid", b"body", 1_700_000_000).unwrap(); + + let listed = journal.pending().unwrap(); + assert_eq!( + listed, + vec![Reservation { + key: "uid".into(), + next_eligible: 1_700_000_000, + body: b"body".to_vec(), + }], + ); +} + +#[test] +fn double_commit_is_idempotent() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + journal.commit("uid").unwrap(); + journal.commit("uid").unwrap(); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); + assert_eq!(host.store.len(), 1); + assert_eq!( + host.store.snapshot().get("submitted:uid").unwrap(), + &vec![0x02], + ); +} + +#[test] +fn release_of_absent_is_a_no_op() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.release("nope").unwrap(); + assert!(host.store.is_empty()); +} + // ---- retry ledger ---- fn seeded_watch(host: &MockHost) -> String { From 909d3a2c5db2577dfd204c305f8004a7cae95afe Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 11:59:14 +0000 Subject: [PATCH 098/139] venue: reconcile-contract compliance suite (#584) --- crates/cow-venue/src/adapter.rs | 68 +++++++++++++++++- crates/videre-sdk/src/client.rs | 42 ++++++++++- crates/videre-sdk/src/lib.rs | 4 +- crates/videre-test/src/lib.rs | 2 + crates/videre-test/src/reconcile.rs | 108 ++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 crates/videre-test/src/reconcile.rs diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index a6272fb9..416d6ae3 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -45,6 +45,12 @@ use crate::order::OrderUid; /// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. pub struct CowAdapter; +// The reconcile floor: an already-held re-POST folds to the same accept +// outcome (`submit_with`, both auth paths) and status GETs the +// body-derived uid, so the adapter honours the contract. +impl videre_sdk::client::sealed::SealedReconcile for CowAdapter {} +impl videre_sdk::VenueReconcile for CowAdapter {} + /// Default per-request timeout bound: the SDK's per-phase default. const DEFAULT_TIMEOUT: Duration = videre_sdk::transport::http::DEFAULT_TIMEOUT; @@ -524,10 +530,11 @@ mod export { #[cfg(test)] mod tests { - use videre_sdk::IntentBody as _; use videre_sdk::transport::BoundedFetch; use videre_sdk::transport::http::FetchError; + use videre_sdk::{IntentBody as _, VenueFault}; use videre_test::MockFetch; + use videre_test::reconcile::ReconcileFixture; use super::*; use crate::body::{CowIntent, CowIntentBody}; @@ -950,4 +957,63 @@ mod tests { )); assert_eq!(fetch.request_count(), 0); } + + // ── reconcile contract ─────────────────────────────────────────── + + /// The shared compliance fixture: one owner-configured config drives + /// both auth paths (a signed order is authorised by its own owner, so + /// the config owner only enables the pre-sign path). + struct CowReconcile; + + impl CowReconcile { + fn cfg() -> AdapterConfig { + with_owner(owner()) + } + + fn uid() -> cowprotocol::OrderUid { + expected_uid(&Self::cfg()) + } + } + + impl ReconcileFixture for CowReconcile { + fn signed_body() -> Vec { + signed_bytes() + } + + fn presign_body() -> Vec { + order_bytes() + } + + fn receipt() -> Vec { + Self::uid().as_slice().to_vec() + } + + fn program_accept(fetch: &MockFetch) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 201, + format!("\"{}\"", Self::uid()), + ); + } + + fn program_already_held(fetch: &MockFetch) { + reject(fetch, "DuplicatedOrder"); + } + + fn program_absent(fetch: &MockFetch) { + let uid = OrderUid::try_from(Self::receipt().as_slice()).expect("uid is 56 bytes"); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + } + + fn submit(fetch: &MockFetch, body: &[u8]) -> Result { + submit_with(fetch, &Self::cfg(), body).map_err(VenueFault::from) + } + + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result { + status_with(fetch, &Self::cfg(), receipt).map_err(VenueFault::from) + } + } + + videre_test::venue_reconcile_compliance!(CowReconcile); } diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index 4a4e9443..08a535b0 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -77,11 +77,13 @@ pub trait Venue { type Body: IntentBody; } -/// Sealing marker for [`VenueTransport`]: a transport opts in by also -/// implementing it. +/// Sealing markers: a transport opts into [`VenueTransport`], and an +/// adapter into [`VenueReconcile`], by also implementing the respective +/// marker. #[doc(hidden)] pub mod sealed { pub trait SealedTransport {} + pub trait SealedReconcile {} } /// The byte-level seam under the typed client: `videre:venue/client` @@ -135,6 +137,42 @@ pub trait VenueTransport: sealed::SealedTransport { ) -> impl Future>; } +/// The reconcile contract a venue adapter honours so a keeper can +/// recover a stranded reservation without double-placing. A marker: it +/// adds no methods, it names the guarantees the adapter's +/// [`submit`](crate::VenueAdapter::submit) and +/// [`status`](crate::VenueAdapter::status) already give. Exactly-once +/// across the external POST is unreachable from the host alone (the call +/// is not inside the reserve transaction), so the floor is venue-side and +/// per-adapter. +/// +/// An adapter opts in by implementing it (and the sealing marker), and +/// proves it with `videre_test::venue_reconcile_compliance!`. +/// +/// # Contract +/// +/// 1. Mandatory re-POST idempotency. A [`submit`](crate::VenueAdapter::submit) +/// of a body the venue already holds resolves to the SAME outcome as +/// the first submit: a signed order folds to +/// [`SubmitOutcome::Accepted`] with the same receipt, a pre-sign order +/// to the same [`SubmitOutcome::RequiresSigning`] call. A held body +/// NEVER surfaces as a terminal [`VenueFault`]: it folds to the accept +/// outcome, never to a classified `denied`. This floor is what makes a +/// reconcile resubmit safe. +/// 2. Optional status fast-path. An adapter MAY derive a receipt from the +/// body, so a reconcile can [`status`](crate::VenueAdapter::status) it +/// first and commit without a redundant POST. +/// [`observe`](VenueTransport::observe) (defaulting `unsupported`) is +/// not the reconcile primitive; `submit` is. +/// +/// # Validity +/// +/// Reconcile trusts venue-side order validity (its `validTo` and limit +/// price); it does not re-poll the watch source. The contract therefore +/// requires adapters to carry self-describing order validity. (Maintainer +/// decision, 2026-07-24.) +pub trait VenueReconcile: crate::VenueAdapter + sealed::SealedReconcile {} + /// Poll a future once and return its state. `videre:venue/client@0.1.0` /// declares plain funcs, so a [`VenueTransport`] over the host import /// resolves on the first poll. [`Poll::Pending`] means a foreign diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 5bdec44d..cb77ce80 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -85,7 +85,9 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; +pub use client::{ + ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueReconcile, VenueTransport, +}; pub use faults::VenueFault; pub use keeper::{Keeper, Outcome, RunReport, retry_action}; /// Derive [`IntentBody`] on the outer per-venue version enum. See diff --git a/crates/videre-test/src/lib.rs b/crates/videre-test/src/lib.rs index 2485b000..dd907bd6 100644 --- a/crates/videre-test/src/lib.rs +++ b/crates/videre-test/src/lib.rs @@ -64,6 +64,7 @@ pub mod codec; pub mod fixture; pub mod header; +pub mod reconcile; pub mod reference; pub mod report; pub mod transport; @@ -74,6 +75,7 @@ pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, }; +pub use reconcile::ReconcileFixture; pub use report::{ConformanceReport, Violation}; pub use transport::{ ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, diff --git a/crates/videre-test/src/reconcile.rs b/crates/videre-test/src/reconcile.rs new file mode 100644 index 00000000..20d49e8a --- /dev/null +++ b/crates/videre-test/src/reconcile.rs @@ -0,0 +1,108 @@ +//! The reconcile-contract compliance suite: an adapter proves it honours +//! [`VenueReconcile`](videre_sdk::VenueReconcile) by implementing +//! [`ReconcileFixture`] over its own mock transport and invoking +//! [`venue_reconcile_compliance!`](crate::venue_reconcile_compliance). + +use videre_sdk::{IntentStatus, SubmitOutcome, VenueFault}; + +use crate::MockFetch; + +/// The per-adapter fixtures the compliance suite drives. Implement it on +/// a unit type in the adapter's tests: the `program_*` hooks arm the +/// adapter's own [`MockFetch`], and `submit`/`status` run its paths, +/// lifting the adapter error into [`VenueFault`]. +pub trait ReconcileFixture { + /// A signed body the venue accepts and derives a receipt for. + fn signed_body() -> Vec; + /// A pre-sign body the venue accepts. + fn presign_body() -> Vec; + /// The body-derived receipt a held body resolves to. + fn receipt() -> Vec; + /// Arm the mock to accept a fresh submission. + fn program_accept(fetch: &MockFetch); + /// Arm the mock to reject a submission as already held. + fn program_already_held(fetch: &MockFetch); + /// Arm the mock to answer a status read as not-found. + fn program_absent(fetch: &MockFetch); + /// Submit a body through the adapter over `fetch`. + fn submit(fetch: &MockFetch, body: &[u8]) -> Result; + /// Read a receipt's status through the adapter over `fetch`. + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result; +} + +/// A fresh submit and a re-POST of a held body resolve identically: +/// mandatory re-POST idempotency (contract point 1). +pub fn assert_re_post_idempotent(body: &[u8]) { + let fresh = MockFetch::default(); + F::program_accept(&fresh); + let first = F::submit(&fresh, body).expect("a fresh submit is accepted"); + + let held = MockFetch::default(); + F::program_already_held(&held); + let again = F::submit(&held, body).expect("a held body folds to accepted, never a fault"); + + assert!( + first == again, + "a re-POST of a held body must resolve to the same outcome", + ); +} + +/// A held body never surfaces as a terminal fault, across both auth +/// paths (contract point 1's floor). +pub fn assert_held_never_faults() { + for body in [F::signed_body(), F::presign_body()] { + let held = MockFetch::default(); + F::program_already_held(&held); + assert!( + F::submit(&held, &body).is_ok(), + "a held body must fold to accepted, never a terminal fault", + ); + } +} + +/// An absent status read stays retryable (`unavailable`), never terminal, +/// so a lagging read path does not strand a reconcile. +pub fn assert_status_absent_is_retryable() { + let fetch = MockFetch::default(); + F::program_absent(&fetch); + assert!( + matches!( + F::status(&fetch, &F::receipt()), + Err(VenueFault::Unavailable(_)), + ), + "an absent status read must stay retryable", + ); +} + +/// Instantiate the [`VenueReconcile`](videre_sdk::VenueReconcile) +/// compliance suite for a [`ReconcileFixture`]: re-POST idempotency on +/// both auth paths, a held body never faulting, and an absent status read +/// staying retryable. +#[macro_export] +macro_rules! venue_reconcile_compliance { + ($fixture:ty) => { + #[test] + fn reconcile_signed_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::signed_body(), + ); + } + + #[test] + fn reconcile_presign_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::presign_body(), + ); + } + + #[test] + fn reconcile_held_body_never_faults() { + $crate::reconcile::assert_held_never_faults::<$fixture>(); + } + + #[test] + fn reconcile_status_absent_is_retryable() { + $crate::reconcile::assert_status_absent_is_retryable::<$fixture>(); + } + }; +} From 605361a609e18134bb04eaf94f60228f6f25fae7 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 12:45:47 +0000 Subject: [PATCH 099/139] keeper: sweep-driven reconcile for stranded submit reservations (#586) --- crates/videre-sdk/src/keeper.rs | 614 +++++++++++++++++++++++++++++--- crates/videre-sdk/src/lib.rs | 4 +- 2 files changed, 577 insertions(+), 41 deletions(-) diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 945b0697..084d2959 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -10,7 +10,9 @@ //! them. use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::keeper::{Gates, Journal, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ + Gates, Journal, Mark, Poller, Reservation, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; use nexum_sdk::prelude::{hex, keccak256}; use crate::client::{VenueId, VenueTransport}; @@ -33,24 +35,40 @@ pub enum Outcome { Drop, } +/// Default `[limits.reconcile]` `max_per_tick`: stranded reservations +/// the top-of-sweep reconcile pass resolves per run. +pub const DEFAULT_RECONCILE_BUDGET: usize = 16; + /// A keeper: one poller bound to one venue, run over the /// keeper stores. pub struct Keeper { source: S, venues: P, venue: VenueId, + max_per_tick: usize, } impl Keeper { - /// Bind a source to the venue id its submissions route to. + /// Bind a source to the venue id its submissions route to. The + /// reconcile budget defaults to [`DEFAULT_RECONCILE_BUDGET`]. pub fn new(source: S, venues: P, venue: impl Into) -> Self { Self { source, venues, venue: venue.into(), + max_per_tick: DEFAULT_RECONCILE_BUDGET, } } + /// Override the per-tick reconcile budget (`[limits.reconcile]` + /// `max_per_tick`). The fresh-watch loop is never budget-bounded; + /// only the reconcile pass is. + #[must_use] + pub fn with_reconcile_budget(mut self, max_per_tick: usize) -> Self { + self.max_per_tick = max_per_tick; + self + } + /// The venue every submission routes to. pub fn venue(&self) -> &VenueId { &self.venue @@ -58,19 +76,21 @@ impl Keeper { } impl Keeper { - /// Run the watch set once at `tick`: poll every ready watch, - /// submit [`Outcome::Submit`] bodies through the venue seam, and - /// run every other outcome and every venue refusal through the - /// [`Retrier`]. The [`submission_key`] is checked against the - /// `submitted:` [`Journal`] before every submit and recorded on - /// acceptance - the watch's first-refusal marker is then cleared - /// best-effort - so a journalled acceptance is never resubmitted. - /// The record is not atomic with the submit: an acceptance whose - /// journal write faults can still resubmit. A `requires-signing` - /// answer journals nothing and is surfaced afresh each run. - /// Store faults abort the run, bar the post-acceptance marker - /// clear; venue refusals never do - they fold into per-watch - /// retry actions. + /// Run one sweep at `tick`. First the [`reconcile`] pass resolves + /// every stranded reservation on the `submitted:` reserve/commit + /// [`Journal`], budget-bounded by the reconcile budget; then every + /// gate-ready watch is polled, and an [`Outcome::Submit`] body is + /// reserved on its [`submission_key`] before the venue await and + /// committed on acceptance. A `RESERVED` marker seen by the submit + /// arm is owned by this tick's reconcile pass and never re-POSTed; a + /// `COMMITTED` marker is an idempotent skip. `release` runs only on a + /// known synchronous non-accept (`requires-signing` or a venue + /// refusal): no crash, trap, `OutOfFuel`, or deadline-cancel during + /// the await reaches a release, so the marker persists for the next + /// tick's reconcile pass. This is the reconcile-not-release contract. + /// Store faults abort the run, bar the best-effort commit and marker + /// clear; venue refusals never do - they fold into per-watch retry + /// actions. pub async fn run(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, @@ -82,6 +102,14 @@ impl Keeper { let journal = Journal::submitted(host); let mut report = RunReport::default(); + let rec = reconcile(&self.venue, &self.venues, &journal, tick, self.max_per_tick).await?; + report.reconciled_committed = rec.committed; + report.reconciled_released = rec.released; + report.reconciled_pending = rec.pending; + report.reconciled_gated = rec.gated; + report.reconciled_unsigned = rec.unsigned.len(); + report.unsigned.extend(rec.unsigned); + for key in watches.list()? { let Some(watch) = WatchRef::parse(&key) else { report.skipped += 1; @@ -100,30 +128,51 @@ impl Keeper { let action = match self.source.poll(host, watch, ¶ms, tick) { Outcome::Submit(body) => { let key = submission_key(&self.venue, &body); - if journal.contains(&key)? { - report.duplicates += 1; - continue; - } - match self.venues.submit(&self.venue, body).await { - Ok(SubmitOutcome::Accepted(_)) => { - journal.record(&key)?; - // The acceptance is journalled; the marker - // clear is cleanup and must not abort the - // run. - if let Err(fault) = retrier.clear_refusal(watch) { - tracing::error!( - %fault, - "refusal-marker clear failed after journalled acceptance", - ); - } - report.submitted += 1; + match journal.mark(&key)? { + // Durable already: idempotent skip, no venue call. + Some(Mark::Committed) => { + report.duplicates += 1; continue; } - Ok(SubmitOutcome::RequiresSigning(tx)) => { - report.unsigned.push(tx); + // This tick's reconcile pass owns the reservation; + // never a second POST here. + Some(Mark::Reserved) => { + report.retried += 1; continue; } - Err(fault) => retry_action(&fault), + None => { + // Reserve the real body before the await: a + // crash, trap, or deadline-cancel now strands a + // RESERVED marker the next tick's reconcile + // pass resolves, never a silent drop. + journal.reserve(&key, &body)?; + match self.venues.submit(&self.venue, body).await { + Ok(SubmitOutcome::Accepted(_)) => { + // Best-effort: a commit fault just + // reconciles next tick. + let _ = journal.commit(&key); + if let Err(fault) = retrier.clear_refusal(watch) { + tracing::error!( + %fault, + "refusal-marker clear failed after commit", + ); + } + report.submitted += 1; + continue; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; + report.unsigned.push(tx); + continue; + } + // A known synchronous non-accept: release + // the reserve, then fold the refusal. + Err(fault) => { + journal.release(&key)?; + retry_action(&fault) + } + } + } } } Outcome::WaitBlock => RetryAction::TryNextBlock, @@ -140,6 +189,82 @@ impl Keeper { } } +/// Top-of-sweep reconcile pass over the reserve journal: before the +/// fresh-watch loop, resolve each stranded reservation against the +/// venue. A `RESERVED` marker is an unknown submit outcome - an +/// accepted-but-uncommitted write, or a crash, trap, or deadline-cancel +/// mid-submit - and is NEVER skipped. Each reservation from +/// [`Journal::pending`], budget-bounded by `max_per_tick`: +/// +/// - `next_eligible > tick.epoch_s`: still backing off, left untouched. +/// - accepted: committed. +/// - `requires-signing`: released, the tx surfaced to the caller. +/// - terminal refusal (a [`RetryAction::Drop`] fault): released. +/// - transient refusal: left `RESERVED` for the next tick; a rate-limit +/// hint re-parks `next_eligible`. +/// +/// No `release` runs on a post-await success or unknown path. Shared so +/// an L3 keeper reuses the exact resolution. +pub async fn reconcile( + venue: &VenueId, + venues: &P, + journal: &Journal<'_, H>, + tick: &Tick, + max_per_tick: usize, +) -> Result +where + H: LocalStoreHost, + P: VenueTransport, +{ + let mut report = ReconcileReport::default(); + let mut spent = 0usize; + for Reservation { + key, + next_eligible, + body, + } in journal.pending()? + { + if next_eligible > tick.epoch_s { + report.gated += 1; + continue; + } + if spent >= max_per_tick { + break; + } + spent += 1; + match venues.submit(venue, body.clone()).await { + Ok(SubmitOutcome::Accepted(_)) => { + journal.commit(&key)?; + report.committed += 1; + } + Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; + report.unsigned.push(tx); + } + Err(fault) if is_terminal(&fault) => { + journal.release(&key)?; + report.released += 1; + } + Err(VenueFault::RateLimited { + retry_after_ms: Some(ms), + }) => { + journal.park(&key, &body, tick.epoch_s.saturating_add(ms.div_ceil(1000)))?; + report.pending += 1; + } + // Transient (timeout, unavailable, throttle without a hint): + // leave the marker for the next tick. + Err(_) => report.pending += 1, + } + } + Ok(report) +} + +/// A venue refusal no resubmit can cure: its [`retry_action`] drops the +/// watch, so the reservation is safe to release. +fn is_terminal(fault: &VenueFault) -> bool { + matches!(retry_action(fault), RetryAction::Drop) +} + /// One run's tally, by watch disposition. #[derive(Clone, Debug, Default, PartialEq)] #[non_exhaustive] @@ -156,12 +281,43 @@ pub struct RunReport { /// Bodies whose key an earlier run had journalled, skipped /// without a venue call. pub duplicates: usize, - /// Watches left in place for a later tick. + /// Watches left in place for a later tick, plus submit arms whose + /// marker the reconcile pass already owns this tick. pub retried: usize, /// Watches dropped. pub dropped: usize, + /// Stranded reservations the reconcile pass committed this tick. + pub reconciled_committed: usize, + /// Reservations the reconcile pass released on a terminal refusal. + pub reconciled_released: usize, + /// Reservations the reconcile pass left `RESERVED` for a later tick. + pub reconciled_pending: usize, + /// Reservations still inside their backoff window this tick. + pub reconciled_gated: usize, + /// Reservations the reconcile pass answered `requires-signing`; the + /// txs ride [`unsigned`](Self::unsigned). + pub reconciled_unsigned: usize, /// Transactions the venue answered `requires-signing`; a run - /// cannot sign, so the caller owns them. + /// cannot sign, so the caller owns them. Carries both fresh-watch + /// and reconcile-pass answers. + pub unsigned: Vec, +} + +/// One reconcile pass's tally, by reservation disposition. +#[derive(Clone, Debug, Default, PartialEq)] +#[non_exhaustive] +pub struct ReconcileReport { + /// Stranded reservations the venue re-accepted, now committed. + pub committed: usize, + /// Reservations released on a terminal venue refusal. + pub released: usize, + /// Reservations left `RESERVED` for a later tick on a transient + /// fault. + pub pending: usize, + /// Reservations still inside their backoff window, untouched. + pub gated: usize, + /// Transactions the venue answered `requires-signing`; released and + /// handed to the caller. pub unsigned: Vec, } @@ -200,14 +356,15 @@ pub fn retry_action(fault: &VenueFault) -> RetryAction { #[cfg(test)] mod tests { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; + use std::collections::HashSet; use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; + use nexum_sdk::keeper::{Gates, Journal, Mark, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::MockLocalStore; - use super::{Keeper, Outcome, RunReport}; + use super::{Keeper, Outcome, RunReport, submission_key}; use crate::client::{VenueId, VenueTransport}; use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; @@ -535,4 +692,381 @@ mod tests { run(keeper(Outcome::WaitBlock, &venue).run(&host, &TICK)).expect("keeper runs"); assert_eq!(report, RunReport::default()); } + + // ---- #573 reserve/commit + top-of-sweep reconcile ---- + + const STUB: &str = "stub"; + + /// The submit key the run reserves for `body` at the stub venue. + fn stub_key(body: &[u8]) -> String { + submission_key(&VenueId::from_static(STUB), body) + } + + /// Seed a stranded `RESERVED` marker, as a prior tick's reserve + /// whose submit outcome never landed. + fn seed_reserved(host: &MockLocalStore, body: &[u8]) { + Journal::submitted(host) + .reserve(&stub_key(body), body) + .expect("reserve writes"); + } + + /// Venue that counts POSTs and models re-POST idempotency: a body it + /// already holds re-accepts (never a fresh refusal), so a reconcile + /// resubmit is safe. A body it does not hold gets the programmed + /// `outcome`; an accepted outcome joins the held set. + struct CountingVenue { + outcome: RefCell>, + posts: RefCell>>, + held: RefCell>>, + } + + impl CountingVenue { + fn new(outcome: Result) -> Self { + Self { + outcome: RefCell::new(outcome), + posts: RefCell::new(Vec::new()), + held: RefCell::new(HashSet::new()), + } + } + + fn accepting() -> Self { + Self::new(Ok(SubmitOutcome::Accepted(vec![0xAB]))) + } + + fn post_count(&self) -> usize { + self.posts.borrow().len() + } + + fn held_count(&self) -> usize { + self.held.borrow().len() + } + + /// Pre-seed a held body: a POST the venue received before the + /// caller lost its outcome to a deadline-cancel. + fn preload(&self, body: &[u8]) { + self.held.borrow_mut().insert(body.to_vec()); + } + } + + impl crate::client::sealed::SealedTransport for &CountingVenue {} + + impl VenueTransport for &CountingVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + _venue: &VenueId, + body: Vec, + ) -> Result { + self.posts.borrow_mut().push(body.clone()); + if self.held.borrow().contains(&body) { + return Ok(SubmitOutcome::Accepted(vec![0xAB])); + } + let outcome = self.outcome.borrow().clone(); + if let Ok(SubmitOutcome::Accepted(_)) = &outcome { + self.held.borrow_mut().insert(body); + } + outcome + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Wraps a store, faulting the first `COMMITTED` write to + /// `submitted:` once, then delegating. Models an accepted submit + /// whose commit write faults: the `RESERVED` marker persists and no + /// release runs. + struct FlakyCommit { + inner: MockLocalStore, + arm: Cell, + } + + impl FlakyCommit { + fn new() -> Self { + Self { + inner: MockLocalStore::default(), + arm: Cell::new(true), + } + } + } + + impl nexum_sdk::host::LocalStoreHost for FlakyCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.inner.get(key) + } + + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + // 0x02 is the journal COMMITTED tag. + if self.arm.get() && key.starts_with("submitted:") && value.first() == Some(&0x02) { + self.arm.set(false); + return Err(Fault::Unavailable("commit write faulted".into())); + } + self.inner.set(key, value) + } + + fn delete(&self, key: &str) -> Result<(), Fault> { + self.inner.delete(key) + } + + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.inner.list_keys(prefix) + } + + fn contains(&self, key: &str) -> Result { + self.inner.contains(key) + } + + fn len(&self, key: &str) -> Result, Fault> { + nexum_sdk::host::LocalStoreHost::len(&self.inner, key) + } + + fn count(&self, prefix: &str) -> Result { + self.inner.count(prefix) + } + } + + /// A keeper whose source never submits: exercises the reconcile pass + /// alone. + fn idle_keeper(venue: &CountingVenue) -> Keeper { + Keeper::new(StubSource(Outcome::WaitBlock), venue, STUB) + } + + fn mark(host: &impl nexum_sdk::host::LocalStoreHost, body: &[u8]) -> Option { + Journal::submitted(host) + .mark(&stub_key(body)) + .expect("mark reads") + } + + #[test] + fn reserve_commit_happy_path_then_idempotent_skip() { + let host = MockLocalStore::default(); + put_watch(&host); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"body".to_vec())), &venue, STUB); + + // Tick A: None -> reserve -> Accepted -> commit. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.submitted, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(mark(&host, b"body"), Some(Mark::Committed)); + + // Tick B: the COMMITTED marker is an idempotent skip, zero calls. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.duplicates, 1); + assert_eq!(report.submitted, 0); + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn w1_reserved_but_venue_never_saw_post_reconciles() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + + // Tick B: the reconcile pass resubmits the stranded reservation. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1, "exactly one held order"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn w2_accepted_but_commit_faults_reconciles_without_double_holding() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), &venue, STUB); + + // Tick A: venue accepts (POST #1) but the commit write faults; the + // RESERVED marker persists, no release runs. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.submitted, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // Tick B: reconcile resubmits (POST #2), the venue dedups, commit + // lands - two POSTs but one held order. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn w3_cancelled_during_submit_reconciles_both_ways() { + // Sub-case (a): the venue DID receive it before the cancel. + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + venue.preload(b"order"); + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + + // Sub-case (b), the venue did NOT receive it, is W1 above: a fresh + // Accepted then commit, one held order. + } + + #[test] + fn reconcile_requires_signing_releases_and_never_re_enumerates() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = CountingVenue::new(Ok(SubmitOutcome::RequiresSigning(tx.clone()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_unsigned, 1); + assert_eq!(report.unsigned, vec![tx]); + assert_eq!(mark(&host, b"order"), None); + + // The reservation is gone; later ticks do not re-enumerate it. + for _ in 0..3 { + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_unsigned, 0); + assert!(Journal::submitted(&host).pending().unwrap().is_empty()); + } + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn reconcile_rate_limit_parks_then_reposts_after_the_window() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_000), + })); + + // T: parked with next_eligible = T + 2, one POST. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(venue.post_count(), 1); + + // T + 1: inside the window, gated, no POST. + let at_1 = Tick { + epoch_s: TICK.epoch_s + 1, + ..TICK + }; + let report = run(idle_keeper(&venue).run(&host, &at_1)).expect("keeper runs"); + assert_eq!(report.reconciled_gated, 1); + assert_eq!(venue.post_count(), 1); + + // T + 2: the window elapsed, exactly one more POST. + let at_2 = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + run(idle_keeper(&venue).run(&host, &at_2)).expect("keeper runs"); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn reconcile_terminal_refusal_releases_and_stays_gone() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_released, 1); + assert_eq!(mark(&host, b"order"), None); + assert_eq!(venue.post_count(), 1); + + // No later reconcile resurrects it. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_released, 0); + assert_eq!(venue.post_count(), 1); + } + + #[test] + fn reconcile_transient_refusal_keeps_the_marker_reserved() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::new(Err(VenueFault::Unavailable("down".into()))); + + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_eq!(venue.post_count(), 1); + + // Still RESERVED next tick, reconciled again. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_pending, 1); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn reconcile_budget_bounds_the_pass_but_not_the_fresh_watch() { + let host = MockLocalStore::default(); + for body in [b"o1".as_slice(), b"o2", b"o3"] { + seed_reserved(&host, body); + } + put_watch(&host); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"fresh".to_vec())), &venue, STUB) + .with_reconcile_budget(2); + + // At most two orphans reconciled, yet the fresh watch still + // submits its own order this tick. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 2); + assert_eq!(report.submitted, 1); + assert_eq!(Journal::submitted(&host).pending().unwrap().len(), 1); + + // The remaining orphan reconciles on a later tick. + let report = run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.reconciled_committed, 1); + assert!(Journal::submitted(&host).pending().unwrap().is_empty()); + } + + #[test] + fn anti_572_reserved_marker_drives_a_reconcile_post_not_a_duplicate_skip() { + let host = MockLocalStore::default(); + seed_reserved(&host, b"order"); + let venue = CountingVenue::accepting(); + + // The #572 design would skip a RESERVED marker as a duplicate and + // drop it forever; the reconcile pass MUST resubmit. + let report = run(idle_keeper(&venue).run(&host, &TICK)).expect("keeper runs"); + assert_eq!(report.duplicates, 0, "a RESERVED marker is not a duplicate"); + assert!(venue.post_count() >= 1, "the reconcile pass must POST"); + assert_eq!(report.reconciled_committed, 1); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn anti_572_crash_between_submit_and_commit_leaves_reserved_not_none() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let venue = CountingVenue::accepting(); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), &venue, STUB); + + // The faulted commit is a proxy for a crash between submit and + // commit: the marker MUST be RESERVED, never released to None. + run(keeper.run(&host, &TICK)).expect("keeper runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_ne!(mark(&host, b"order"), None); + } } diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index cb77ce80..d38b895b 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -89,7 +89,9 @@ pub use client::{ ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueReconcile, VenueTransport, }; pub use faults::VenueFault; -pub use keeper::{Keeper, Outcome, RunReport, retry_action}; +pub use keeper::{ + DEFAULT_RECONCILE_BUDGET, Keeper, Outcome, ReconcileReport, RunReport, reconcile, retry_action, +}; /// Derive [`IntentBody`] on the outer per-venue version enum. See /// [`videre_macros::IntentBody`]. pub use videre_macros::IntentBody; From 357766c6985cc47129aa82f678c2bff3a1403baa Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 13:54:10 +0000 Subject: [PATCH 100/139] keeper: reserve/commit journal with top-of-sweep reconcile (#587) --- crates/composable-cow/src/run.rs | 134 +++++++++---- crates/composable-cow/tests/run.rs | 294 ++++++++++++++++++++++++++++- crates/videre-sdk/src/client.rs | 7 + 3 files changed, 391 insertions(+), 44 deletions(-) diff --git a/crates/composable-cow/src/run.rs b/crates/composable-cow/src/run.rs index a3ee56f2..ac90f64a 100644 --- a/crates/composable-cow/src/run.rs +++ b/crates/composable-cow/src/run.rs @@ -1,14 +1,17 @@ //! Keeper run: the poll-loop composition conditional- //! commitment modules share. //! -//! [`run`] walks the keeper watch set, polls each gate-ready -//! watch through a [`Poller`], and runs the +//! [`run`] first drives the shared +//! [`reconcile`](videre_sdk::reconcile) pass over the `submitted:` +//! reserve/commit journal, then walks the keeper watch set, polls each +//! gate-ready watch through a [`Poller`], and runs the //! [`Verdict`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Post` drives one submission through the typed -//! [`CowClient`] onto the `videre:venue/client` seam with the -//! `submitted:` journal as the idempotency guard - keyed on the -//! venue-and-body [`intent_id`] - and the keeper [`Retrier`] -//! as the failure dispatch. +//! watch stores, `Post` reserves the encoded body on the venue-and-body +//! submission key and drives one submission through the typed +//! [`CowClient`] onto the `videre:venue/client` seam, committing on +//! acceptance, with the keeper [`Retrier`] as the failure dispatch. A +//! reservation whose submit outcome is lost is resubmitted by the next +//! tick's reconcile pass, never dropped. //! //! Store faults abort the run (the next tick replays it); //! submission failures never do - they fold into a @@ -23,27 +26,55 @@ use alloy_primitives::{Address, Bytes, hex}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; -use cow_venue::{CowClient, CowIntent, CowIntentBody, SignedOrder, classify_denied, intent_id}; +use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder, classify_denied}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; -use nexum_sdk::keeper::{Gates, Journal, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ + Gates, Journal, Mark, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; use std::task::Poll; use videre_sdk::client::poll_once; -use videre_sdk::keeper::retry_action; -use videre_sdk::{ClientError, SubmitOutcome, VenueFault, VenueTransport}; +use videre_sdk::keeper::{retry_action, submission_key}; +use videre_sdk::{ + ClientError, IntentBody as _, SubmitOutcome, Venue as _, VenueFault, VenueTransport, +}; use crate::Verdict; /// Poll every gate-ready watch once at `tick` and run each outcome's -/// effect. One source poll per ready watch; a `Post` outcome makes at -/// most one venue submit through `venue`. +/// effect. The top-of-sweep [`reconcile`](videre_sdk::reconcile) pass +/// resolves stranded reservations first, then one source poll per ready +/// watch; a `Post` outcome makes at most one venue submit through +/// `venue`. pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where H: LocalStoreHost, S: Poller, T: VenueTransport, { + // Resolve any stranded reservation before polling fresh watches, so a + // submit whose outcome was lost is resubmitted, never dropped (#572). + // The helper is async and the guest boundary synchronous, so drive it + // with `poll_once`. + let journal = Journal::submitted(host); + match poll_once(videre_sdk::reconcile( + &CowVenue::ID, + venue.transport(), + &journal, + tick, + videre_sdk::DEFAULT_RECONCILE_BUDGET, + )) { + Poll::Ready(res) => { + res?; + } + Poll::Pending => { + // A misbehaving guest transport suspended; leave the RESERVED + // markers for the next tick rather than dropping them. + tracing::error!("cow reconcile suspended; skipping this tick"); + } + } + let watches = WatchSet::new(host); let gates = Gates::new(host); for key in watches.list()? { @@ -79,14 +110,16 @@ where Ok(()) } -/// Submit one freshly-polled `Ready` order through the typed client, -/// guarding on the `submitted:` journal and dispatching any venue +/// Submit one freshly-polled `Ready` order through the typed client on +/// the `submitted:` reserve/commit journal, dispatching any venue /// refusal through the retry ledger. /// -/// The journal keys on the deterministic venue-and-body [`intent_id`], -/// derived before any network work from the same body bytes the venue -/// submit carries, so the guard is independent of where assembly -/// happens. The venue's receipt rides the log only. +/// The journal keys on the deterministic venue-and-body submission key. +/// A `COMMITTED` marker is an idempotent skip; a `RESERVED` marker is +/// owned by this tick's reconcile pass and never re-submitted here. A +/// fresh order reserves its encoded body before the submit and commits +/// on acceptance; release runs only on a known synchronous non-accept, +/// never on a pending or accepted path. fn submit_ready( host: &H, venue: &CowClient, @@ -123,56 +156,76 @@ where owner: owner.into_array(), signature: signature.to_vec(), })); - let intent_id = match intent_id(&intent) { - Ok(id) => id, + // Reserve the exact wire bytes the venue submit and the reconcile + // resubmit both carry, so the id and the reservation agree. + let encoded = match intent.to_bytes() { + Ok(bytes) => bytes, Err(err) => { tracing::error!("intent body encode failed: {err}"); return Ok(()); } }; + let intent_id = submission_key(&CowVenue::ID, &encoded); let journal = Journal::submitted(host); - if journal.contains(&intent_id)? { - tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); - return Ok(()); + match journal.mark(&intent_id)? { + Some(Mark::Committed) => { + tracing::info!("{label} {intent_id} already committed; skipping re-submit"); + return Ok(()); + } + Some(Mark::Reserved) => { + // Owned by this tick's reconcile pass; never a second submit. + tracing::info!("{label} {intent_id} reserved; reconcile owns it"); + return Ok(()); + } + None => {} } + // Reserve the real body before any network work: a crash or lost + // outcome now strands a RESERVED marker the next tick's reconcile + // resolves, never a silent drop (#572). + journal.reserve(&intent_id, &encoded)?; let Poll::Ready(outcome) = poll_once(venue.submit(&intent)) else { // Guest transports never suspend; a pending future means a - // foreign transport misbehaved. Route through the retrier for - // symmetry with the venue-refusal arm; a next-block retry keeps - // the watch for the next tick. + // foreign transport misbehaved. Leave the marker RESERVED for the + // next tick's reconcile and retry the watch next block; never + // release on a pending path. tracing::error!("{label} submit future suspended; retrying next block"); return Retrier::new(host).apply(watch, RetryAction::TryNextBlock, tick); }; match outcome { Ok(SubmitOutcome::Accepted(receipt)) => { + // The submit landed; commit the reservation best-effort. A + // commit fault leaves the marker RESERVED for reconcile, never + // released or aborted. + if let Err(fault) = journal.commit(&intent_id) { + tracing::error!("submitted {intent_id} but commit write failed: {fault}"); + } // An acceptance ends any refusal episode: clear the - // first-refusal marker so a later independent refusal - // earns a fresh one-block grace. + // first-refusal marker so a later independent refusal earns a + // fresh one-block grace. if let Err(fault) = Retrier::new(host).clear_refusal(watch) { tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); } - // The submit already succeeded; a journal-store fault here - // must not abort the run 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(&intent_id) { - tracing::error!("submitted {intent_id} but journal write failed: {fault}"); - } tracing::info!( "submitted {intent_id} (receipt {})", hex::encode_prefixed(&receipt), ); } Ok(SubmitOutcome::RequiresSigning(_)) => { - // A run cannot sign; nothing is journalled, so the next - // tick surfaces the same ask afresh. + // A known non-accept: a run cannot sign, so release the reserve + // and re-pose the ask next tick. + journal.release(&intent_id)?; tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); } Err(ClientError::Body(err)) => { + // A known non-accept before any order is placed: release. + journal.release(&intent_id)?; tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { + // A known venue refusal: release the reserve, then fold it + // through the ledger. + journal.release(&intent_id)?; let action = match &fault { VenueFault::Denied(detail) => classify_denied(detail), other => retry_action(other), @@ -193,8 +246,9 @@ where } } } - // `ClientError` is non-exhaustive; a future case leaves the - // watch for the next tick. + // `ClientError` is non-exhaustive; an unknown outcome is neither a + // known accept nor a known refusal, so leave the marker RESERVED + // for reconcile rather than releasing. Err(err) => tracing::error!("submit failed: {err}"), } Ok(()) diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index a466e14b..e3bf7ba1 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -2,16 +2,16 @@ //! scripted venue transport on the `videre:venue/client` seam. use std::cell::{Cell, RefCell}; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; use composable_cow::{Verdict, run}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; -use nexum_sdk::host::LocalStoreHost as _; -use nexum_sdk::keeper::{Gates, Journal, Poller, Tick, WatchRef, WatchSet}; -use nexum_sdk_test::{MockHost, capture_tracing}; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::keeper::{Gates, Journal, Mark, Poller, Tick, WatchRef, WatchSet}; +use nexum_sdk_test::{MockHost, MockLocalStore, capture_tracing}; use videre_sdk::client::sealed::SealedTransport; use videre_sdk::keeper::submission_key; use videre_sdk::{ @@ -733,3 +733,289 @@ fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { "the journal keys on the generic submission key", ); } + +// ---- #573 reserve/commit + top-of-sweep reconcile ---- + +/// Venue that models the CoW re-POST floor: a body it already holds +/// re-accepts (the `DuplicatedOrder` -> AlreadyHeld -> Accepted fold), +/// so a reconcile resubmit is always safe. A fresh body gets the +/// programmed outcome; an accepted body joins the held set. Every POST +/// is recorded. +struct HoldingVenue { + outcome: RefCell>, + posts: RefCell>>, + held: RefCell>>, +} + +impl HoldingVenue { + fn new(outcome: Result) -> Self { + Self { + outcome: RefCell::new(outcome), + posts: RefCell::new(Vec::new()), + held: RefCell::new(HashSet::new()), + } + } + + fn accepting() -> Self { + Self::new(Ok(SubmitOutcome::Accepted(vec![0xAB]))) + } + + fn posts(&self) -> Vec> { + self.posts.borrow().clone() + } + + fn post_count(&self) -> usize { + self.posts.borrow().len() + } + + fn held_count(&self) -> usize { + self.held.borrow().len() + } + + /// Pre-seed a held body: a POST the venue received before the caller + /// lost its outcome. + fn preload(&self, body: &[u8]) { + self.held.borrow_mut().insert(body.to_vec()); + } +} + +impl SealedTransport for &HoldingVenue {} + +impl VenueTransport for &HoldingVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit(&self, _venue: &VenueId, body: Vec) -> Result { + self.posts.borrow_mut().push(body.clone()); + if self.held.borrow().contains(&body) { + return Ok(SubmitOutcome::Accepted(vec![0xAB])); + } + let outcome = self.outcome.borrow().clone(); + if let Ok(SubmitOutcome::Accepted(_)) = &outcome { + self.held.borrow_mut().insert(body); + } + outcome + } + + async fn status(&self, _venue: &VenueId, _receipt: &[u8]) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } +} + +fn holding_client(venue: &HoldingVenue) -> CowClient<&HoldingVenue> { + CowClient::with_transport(venue) +} + +/// Wraps a store, faulting the first `COMMITTED` write to `submitted:` +/// once, then delegating. Models an accepted submit whose commit write +/// faults: the `RESERVED` marker persists and no release runs. +struct FlakyCommit { + inner: MockLocalStore, + arm: Cell, +} + +impl FlakyCommit { + fn new() -> Self { + Self { + inner: MockLocalStore::default(), + arm: Cell::new(true), + } + } +} + +impl LocalStoreHost for FlakyCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.inner.get(key) + } + + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + // 0x02 is the journal COMMITTED tag. + if self.arm.get() && key.starts_with("submitted:") && value.first() == Some(&0x02) { + self.arm.set(false); + return Err(Fault::Unavailable("commit write faulted".into())); + } + self.inner.set(key, value) + } + + fn delete(&self, key: &str) -> Result<(), Fault> { + self.inner.delete(key) + } + + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.inner.list_keys(prefix) + } + + fn contains(&self, key: &str) -> Result { + self.inner.contains(key) + } + + fn len(&self, key: &str) -> Result, Fault> { + LocalStoreHost::len(&self.inner, key) + } + + fn count(&self, prefix: &str) -> Result { + self.inner.count(prefix) + } +} + +/// A source that never submits: exercises the reconcile pass alone. +struct Idle; + +impl Poller for Idle { + type Outcome = Verdict; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Verdict { + Verdict::TryNextBlock { reason: [0; 4] } + } +} + +/// A source that posts one fixed order on every poll. +struct PostOnce(GPv2OrderData); + +impl Poller for PostOnce { + type Outcome = Verdict; + + fn poll(&self, _host: &H, _watch: WatchRef<'_>, _params: &[u8], _tick: &Tick) -> Verdict { + ready_outcome(&self.0) + } +} + +/// Seed a stranded `RESERVED` marker on the encoded body, as a prior +/// tick's reserve whose submit outcome never landed. +fn seed_reserved(host: &impl LocalStoreHost, order: &GPv2OrderData) { + Journal::submitted(host) + .reserve(&intent_id(order), &intent_bytes(order)) + .unwrap(); +} + +fn cow_mark(host: &impl LocalStoreHost, order: &GPv2OrderData) -> Option { + Journal::submitted(host).mark(&intent_id(order)).unwrap() +} + +/// W1: reserved, but the venue never saw the POST. The next tick's +/// reconcile resubmits to exactly one held order. +#[test] +fn w1_reserved_but_venue_never_saw_the_post_reconciles() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!( + venue.post_count(), + 1, + "reconcile resubmits the stranded body" + ); + assert_eq!(venue.held_count(), 1, "exactly one held order"); + assert_eq!( + venue.posts()[0], + intent_bytes(&order), + "the reserved body round-trips", + ); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); +} + +/// W2: accepted, then the commit faults, leaving the marker RESERVED. +/// The next tick's reconcile resubmits, the venue dedups +/// (AlreadyHeld -> Accepted), the commit lands: two POSTs, one held. +#[test] +fn w2_accepted_then_commit_faults_reconciles_without_double_holding() { + let host = FlakyCommit::new(); + WatchSet::new(&host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let order = submittable_order(); + let venue = HoldingVenue::accepting(); + + // Tick A: reserve, venue accepts (POST #1), the commit write faults; + // the RESERVED marker persists, no release runs. + run( + &host, + &holding_client(&venue), + &PostOnce(order.clone()), + &sample_tick(), + ) + .unwrap(); + assert_eq!(venue.post_count(), 1); + assert_eq!( + cow_mark(&host, &order), + Some(Mark::Reserved), + "a commit fault leaves the marker RESERVED", + ); + + // Tick B: reconcile re-POSTs (POST #2), the venue dedups, the commit + // lands. The fresh loop then sees COMMITTED and never re-posts. + run( + &host, + &holding_client(&venue), + &PostOnce(order.clone()), + &sample_tick(), + ) + .unwrap(); + assert_eq!( + venue.post_count(), + 2, + "reconcile re-POSTs the reserved body" + ); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); +} + +/// W3: the run was abandoned after the venue received the POST. The next +/// tick's reconcile re-POSTs, the AlreadyHeld backstop accepts, one held. +#[test] +fn w3_abandoned_after_the_post_reconciles_to_one_held() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + venue.preload(&intent_bytes(&order)); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1, "the already-held order stays single"); + assert_eq!(cow_mark(&host, &order), Some(Mark::Committed)); + // The venue-never-saw-it sub-case is W1 above. +} + +/// Anti-#572: a RESERVED marker MUST drive a reconcile POST routed +/// THROUGH `venue.submit`, where the AlreadyHeld -> Accepted backstop +/// catches the duplicate. A pre-venue skip (the #572 shape) would defeat +/// the backstop and drop the order. +#[test] +fn anti_572_reserved_marker_drives_a_reconcile_post_through_the_venue() { + let host = MockLocalStore::default(); + let order = submittable_order(); + seed_reserved(&host, &order); + let venue = HoldingVenue::accepting(); + // The venue already holds it: the reconcile POST hits the + // AlreadyHeld -> Accepted path, not a fresh accept. + venue.preload(&intent_bytes(&order)); + + run(&host, &holding_client(&venue), &Idle, &sample_tick()).unwrap(); + + assert_eq!( + venue.post_count(), + 1, + "the reserved marker POSTs, never a silent skip", + ); + assert_eq!( + venue.posts()[0], + intent_bytes(&order), + "the exact reserved bytes re-POST", + ); + assert_eq!(venue.held_count(), 1, "no duplicate order"); + assert_eq!( + cow_mark(&host, &order), + Some(Mark::Committed), + "the backstop-accepted resubmit commits", + ); +} diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index 08a535b0..f3384d44 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -257,6 +257,13 @@ impl VenueClient { V::ID } + /// The bound transport, so a keeper reconcile pass resubmits reserved + /// bodies through the same seam this client submits on. + #[must_use] + pub fn transport(&self) -> &T { + &self.transport + } + /// Encode the typed body and price it at the bound venue. The /// returned [`Quoted`] carries the encoded bytes, so `submit` sends /// exactly the body the venue priced. From 2aa8380a0c2adc205446b286343340fcf20f91f9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 23:00:10 +0000 Subject: [PATCH 101/139] cow-venue: use alloy primitives instead of byte-array aliases (#588) The order body's Address/U256 type aliases over raw byte arrays shadowed the alloy names; the body fields, SellToken/BuyToken, and the builder constructor now carry alloy_primitives::Address/U256 directly, and the assembly projections drop the to_be_bytes/from_be_bytes hops that existed only to cross the old alias boundary. Borsh impls for the alloy types come from alloy-primitives' own borsh feature (FixedBytes/Address in-crate, U256 through its ruint passthrough), so the U256 wire form moves from 32-byte big-endian to ruint's little-endian; the published codec vectors and header goldens are regenerated, and the conformance suite gains regeneration-parity tests plus an ignored regenerate_published_fixtures writer. The value-flow header and quotation projections now go through a new AssetAmount::erc20(Address, U256) constructor on videre-sdk, which owns the minimal big-endian uint encoding next to the wire type it serves; the adapter no longer hand-rolls to_be_bytes_trimmed_vec, so its call sites read in plain alloy types. value_flow becomes a real module re-exporting the bindings so the public path is unchanged. The alias removal also settles the two hygiene points the issue names: alloy-primitives is regated from the assembly slice to the body slice (optional only so the empty --no-default-features build stays dependency-free), and the no_std attribute is dropped since every target compiles the crate with full std over wasm32-wasip2. Closes #546 --- crates/composable-cow/src/run.rs | 2 +- crates/composable-cow/tests/run.rs | 2 +- crates/cow-venue/Cargo.toml | 10 +- crates/cow-venue/src/adapter.rs | 67 +++------ crates/cow-venue/src/assembly.rs | 40 +++--- crates/cow-venue/src/body.rs | 13 +- crates/cow-venue/src/client.rs | 23 +-- crates/cow-venue/src/lib.rs | 14 +- crates/cow-venue/src/order.rs | 83 +++++------ crates/cow-venue/tests/conformance.rs | 135 +++++++++++++++++- .../tests/vectors/cow-header-goldens.json | 4 +- .../tests/vectors/cow-intent-body.json | 10 +- crates/videre-sdk/src/lib.rs | 2 +- crates/videre-sdk/src/value_flow.rs | 19 +++ modules/twap-monitor/src/keeper.rs | 2 +- 15 files changed, 262 insertions(+), 164 deletions(-) create mode 100644 crates/videre-sdk/src/value_flow.rs diff --git a/crates/composable-cow/src/run.rs b/crates/composable-cow/src/run.rs index ac90f64a..97342acd 100644 --- a/crates/composable-cow/src/run.rs +++ b/crates/composable-cow/src/run.rs @@ -153,7 +153,7 @@ where let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_data_to_body(&order_data), - owner: owner.into_array(), + owner, signature: signature.to_vec(), })); // Reserve the exact wire bytes the venue submit and the reconcile diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index e3bf7ba1..b4b7e411 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -149,7 +149,7 @@ fn intent_bytes(order: &GPv2OrderData) -> Vec { let order_data = gpv2_to_order_data(order).expect("known markers"); CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_data_to_body(&order_data), - owner: sample_owner().into_array(), + owner: sample_owner(), signature: hex!("c0ffeec0ffeec0ffee").to_vec(), })) .to_bytes() diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index d718ad35..650cee31 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -33,7 +33,10 @@ nexum-sdk = { path = "../nexum-sdk", optional = true } # submission bodies. Express-declared (not workspace-inherited) so the # guest build never inherits the native `http-client` feature. cowprotocol = { version = "0.2.0", default-features = false, optional = true } -alloy-primitives = { workspace = true, optional = true } +# `body` slice: the order body fields are alloy `Address`/`U256`, and +# `borsh` supplies their borsh impls (ruint for `U256`). Optional only +# so the empty `--no-default-features` build stays dependency-free. +alloy-primitives = { workspace = true, features = ["borsh"], optional = true } alloy-sol-types = { workspace = true, optional = true } # `adapter` slice: the orderbook REST speaker over the scoped # wasi:http transport. @@ -51,6 +54,7 @@ toml = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +alloy-primitives = { workspace = true } serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } @@ -66,12 +70,12 @@ cowprotocol = { version = "0.2.0", default-features = false } # `--no-default-features` drops everything so downstream can depend on a # single slice without pulling the codec or the keeper transitively. default = ["body"] -body = ["dep:borsh", "dep:videre-sdk"] +body = ["dep:borsh", "dep:videre-sdk", "dep:alloy-primitives"] client = ["body", "dep:nexum-sdk"] # Chain-edge order assembly, shared by the adapter's submit and the # keeper's legacy submit path. Carries no component glue, so a keeper # module can link it without exporting the adapter face. -assembly = ["body", "dep:cowprotocol", "dep:alloy-primitives", "dep:alloy-sol-types"] +assembly = ["body", "dep:cowprotocol", "dep:alloy-sol-types"] # The venue-adapter component slice: the `#[videre_sdk::venue]` export # and the orderbook transport. Only the cdylib wasm build enables it. adapter = [ diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 416d6ae3..298d437b 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -21,7 +21,7 @@ use core::time::Duration; use std::sync::{PoisonError, RwLock}; -use alloy_primitives::{Address, U256}; +use alloy_primitives::Address; use cowprotocol::{ ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, }; @@ -29,7 +29,7 @@ use nexum_sdk::keeper::RetryAction; use serde::Deserialize; use url::Url; use videre_sdk::transport::http::Fetch; -use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; +use videre_sdk::value_flow::AssetAmount; use videre_sdk::{ AuthScheme, IntentBody as _, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, UnsignedTx, VenueError, @@ -149,8 +149,8 @@ pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result (&signed.order, AuthScheme::Eip1271), }; Ok(IntentHeader { - gives: erc20(order.sell_token, minimal_be(&order.sell_amount)), - wants: erc20(order.buy_token, minimal_be(&order.buy_amount)), + gives: AssetAmount::erc20(order.sell_token, order.sell_amount), + wants: AssetAmount::erc20(order.buy_token, order.buy_amount), settlement: Settlement { chain }, authorisation, }) @@ -170,7 +170,7 @@ pub(crate) fn submit_with( match decode(body)? { CowIntent::Signed(signed) => { let order = assembly::body_to_order_data(&signed.order); - let owner = Address::from(signed.owner); + let owner = signed.owner; let creation = assembly::build_order_creation(&order, &signed.signature, owner) .map_err(|e| VenueError::InvalidBody(e.to_string()))?; let uid = match post_order(fetch, config, &creation)? { @@ -243,7 +243,7 @@ pub(crate) fn quote_with( let intent = decode(body)?; let (wire, from) = match &intent { CowIntent::Order(order) => (order, config.owner.ok_or(VenueError::Unsupported)?), - CowIntent::Signed(signed) => (&signed.order, Address::from(signed.owner)), + CowIntent::Signed(signed) => (&signed.order, signed.owner), }; let order = assembly::body_to_order_data(wire); let request = serde_json::to_vec("e_request(&order, from)) @@ -260,18 +260,9 @@ pub(crate) fn quote_with( let quoted: cowprotocol::OrderQuoteResponse = serde_json::from_slice(response.body()) .map_err(|e| VenueError::Unavailable(format!("quote decode failed: {e}")))?; Ok(Quotation { - gives: erc20( - order.sell_token.into_array(), - minimal_be_u256(quoted.quote.sell_amount), - ), - wants: erc20( - order.buy_token.into_array(), - minimal_be_u256(quoted.quote.buy_amount), - ), - fee: erc20( - order.sell_token.into_array(), - minimal_be_u256(quoted.quote.fee_amount), - ), + gives: AssetAmount::erc20(order.sell_token, quoted.quote.sell_amount), + wants: AssetAmount::erc20(order.buy_token, quoted.quote.buy_amount), + fee: AssetAmount::erc20(order.sell_token, quoted.quote.fee_amount), valid_until_ms: u64::from(quoted.quote.valid_to).saturating_mul(1000), }) } @@ -435,28 +426,6 @@ fn classified(api: &ApiError) -> VenueError { } } -// ── value projections ──────────────────────────────────────────────── - -/// Big-endian bytes with leading zeros trimmed: the minimal `uint` -/// spelling, where an empty list is zero. -fn minimal_be(bytes: &[u8; 32]) -> Vec { - let first = bytes.iter().position(|byte| *byte != 0); - first.map_or(Vec::new(), |index| bytes[index..].to_vec()) -} - -fn minimal_be_u256(value: U256) -> Vec { - minimal_be(&value.to_be_bytes::<32>()) -} - -fn erc20(token: [u8; 20], amount: Vec) -> AssetAmount { - AssetAmount { - asset: Asset::Erc20(Erc20 { - token: token.to_vec(), - }), - amount, - } -} - // The component-ABI export glue only exists on the wasm build; the // native build keeps the same trait impl (for conformance suites) // without export symbols no native linker accepts. @@ -530,8 +499,10 @@ mod export { #[cfg(test)] mod tests { + use alloy_primitives::U256; use videre_sdk::transport::BoundedFetch; use videre_sdk::transport::http::FetchError; + use videre_sdk::value_flow::{Asset, Erc20}; use videre_sdk::{IntentBody as _, VenueFault}; use videre_test::MockFetch; use videre_test::reconcile::ReconcileFixture; @@ -566,26 +537,20 @@ mod tests { fn order_body() -> OrderBody { OrderBody::sell( - SellToken([0x11; 20]), - amount(42), - BuyToken([0x22; 20]), - amount(41), + SellToken(Address::repeat_byte(0x11)), + U256::from(42u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(41u64), 1_700_000_000, ) .app_data([0x44; 32]) .build() } - fn amount(value: u8) -> [u8; 32] { - let mut bytes = [0u8; 32]; - bytes[31] = value; - bytes - } - fn signed_bytes() -> Vec { CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_body(), - owner: owner().into_array(), + owner: owner(), signature: vec![0xC0, 0xFF, 0xEE], })) .to_bytes() diff --git a/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs index 6e04db1c..7bbf213b 100644 --- a/crates/cow-venue/src/assembly.rs +++ b/crates/cow-venue/src/assembly.rs @@ -8,10 +8,6 @@ //! the adapter, not the keeper, owns every projection across that //! edge. -use alloc::format; -use alloc::string::String; -use alloc::vec::Vec; - use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolCall; use cowprotocol::{ @@ -113,14 +109,14 @@ pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol #[must_use] pub fn order_data_to_body(order: &OrderData) -> OrderBody { OrderBody { - sell_token: order.sell_token.into_array(), - buy_token: order.buy_token.into_array(), - receiver: order.receiver.map(Address::into_array), - sell_amount: order.sell_amount.to_be_bytes(), - buy_amount: order.buy_amount.to_be_bytes(), + sell_token: order.sell_token, + buy_token: order.buy_token, + receiver: order.receiver, + sell_amount: order.sell_amount, + buy_amount: order.buy_amount, valid_to: order.valid_to, app_data: order.app_data.0, - fee_amount: order.fee_amount.to_be_bytes(), + fee_amount: order.fee_amount, kind: match order.kind { OrderKind::Sell => crate::order::OrderKind::Sell, OrderKind::Buy => crate::order::OrderKind::Buy, @@ -143,14 +139,14 @@ pub fn order_data_to_body(order: &OrderData) -> OrderBody { #[must_use] pub fn body_to_order_data(body: &OrderBody) -> OrderData { OrderData { - sell_token: Address::from(body.sell_token), - buy_token: Address::from(body.buy_token), - receiver: body.receiver.map(Address::from), - sell_amount: alloy_primitives::U256::from_be_bytes(body.sell_amount), - buy_amount: alloy_primitives::U256::from_be_bytes(body.buy_amount), + sell_token: body.sell_token, + buy_token: body.buy_token, + receiver: body.receiver, + sell_amount: body.sell_amount, + buy_amount: body.buy_amount, valid_to: body.valid_to, app_data: body.app_data.into(), - fee_amount: alloy_primitives::U256::from_be_bytes(body.fee_amount), + fee_amount: body.fee_amount, kind: match body.kind { crate::order::OrderKind::Sell => OrderKind::Sell, crate::order::OrderKind::Buy => OrderKind::Buy, @@ -283,14 +279,14 @@ mod tests { let g = submittable_gpv2(); let order = gpv2_to_order_data(&g).expect("known markers"); let body = order_data_to_body(&order); - assert_eq!(body.sell_token, g.sellToken.into_array()); - assert_eq!(body.buy_token, g.buyToken.into_array()); - assert_eq!(body.receiver, Some(g.receiver.into_array())); - assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); - assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.sell_token, g.sellToken); + assert_eq!(body.buy_token, g.buyToken); + assert_eq!(body.receiver, Some(g.receiver)); + assert_eq!(body.sell_amount, g.sellAmount); + assert_eq!(body.buy_amount, g.buyAmount); assert_eq!(body.valid_to, g.validTo); assert_eq!(body.app_data, g.appData.0); - assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + assert_eq!(body.fee_amount, g.feeAmount); assert_eq!(body.kind, crate::order::OrderKind::Sell); assert!(!body.partially_fillable); assert_eq!( diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 1442089e..4d0f450f 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -35,17 +35,18 @@ pub enum CowIntentBody { #[cfg(test)] mod tests { - use super::*; + use alloy_primitives::{Address, U256}; use videre_test::{CodecVectors, Expectation}; + use super::*; use crate::order::{BuyToken, SellToken}; fn order_body() -> OrderBody { OrderBody::sell( - SellToken([0x11; 20]), - [0x01; 32], - BuyToken([0x22; 20]), - [0x02; 32], + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), 1_700_000_000, ) .app_data([0x44; 32]) @@ -68,7 +69,7 @@ mod tests { "v1-signed", &CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_body(), - owner: [0x55; 20], + owner: Address::repeat_byte(0x55), signature: vec![0xC0, 0xFF, 0xEE], })), ) diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 83f56095..92696153 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,8 +8,6 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. -use alloc::string::String; - use videre_sdk::client::{HostVenues, Venue, VenueClient}; use videre_sdk::keeper::submission_key; use videre_sdk::{BodyError, IntentBody as _}; @@ -96,14 +94,16 @@ mod tests { } fn sample_body() -> CowIntentBody { + use alloy_primitives::{Address, U256}; + use crate::body::CowIntent; use crate::order::{BuyToken, OrderBody, SellToken}; CowIntentBody::V1(CowIntent::Order( OrderBody::sell( - SellToken([0x11; 20]), - [0x01; 32], - BuyToken([0x22; 20]), - [0x02; 32], + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), 1_700_000_000, ) .app_data([0x44; 32]) @@ -114,6 +114,7 @@ mod tests { #[test] fn intent_id_is_deterministic_and_body_scoped() { + use alloy_primitives::{Address, U256}; use videre_sdk::IntentBody; use crate::body::CowIntent; @@ -131,14 +132,14 @@ mod tests { let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: OrderBody::sell( - SellToken([0x11; 20]), - [0x01; 32], - BuyToken([0x22; 20]), - [0x02; 32], + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), 1_700_000_000, ) .build(), - owner: [0x55; 20], + owner: Address::repeat_byte(0x55), signature: vec![0xC0], })); assert_ne!(id, intent_id(&other).expect("body encodes")); diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index 990c4363..e7852a36 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -7,12 +7,10 @@ //! own crate and never here. //! //! The body slice is dependency-light on purpose. It links only the -//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive) -//! and borsh, so a venue adapter component or a strategy module can carry -//! the body types and codec without dragging in the host-side CoW -//! machinery. The crate is `#![no_std]` (tests and the `adapter` slice -//! aside): the derive's generated code reaches `alloc` through the -//! venue SDK re-export, never `::std`. +//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive), +//! borsh, and the alloy primitives the body fields carry, so a venue +//! adapter component or a strategy module can carry the body types and +//! codec without dragging in the host-side CoW machinery. //! //! With `--no-default-features` the slice drops out entirely and the //! crate compiles empty, so a consumer can depend on a single slice @@ -34,16 +32,12 @@ //! never linked by a keeper module (linking it would export the //! adapter face). -#![cfg_attr(not(any(test, feature = "adapter")), no_std)] #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] // wit_bindgen::generate! expands to host-import shims whose arity can // exceed clippy's too-many-arguments threshold. #![cfg_attr(feature = "adapter", allow(clippy::too_many_arguments))] -#[cfg(feature = "body")] -extern crate alloc; - #[cfg(feature = "body")] pub mod body; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index dbc0a67a..2b22a0d4 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -1,25 +1,18 @@ //! 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 alloc::vec::Vec; +//! On the wire a CoW order is the 12-field `GPv2Order` tuple. This body +//! type is the same shape over the alloy primitives (`Address`/`U256`, +//! small enums for the balance and kind markers) so it borsh-encodes +//! and links without the on-chain stack. The one non-obvious invariant: +//! 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 core::fmt; +use alloy_primitives::{Address, U256}; 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]; - /// The token an order sells, typed so a builder call cannot swap /// sides with the buy token. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -174,7 +167,7 @@ impl OrderBuilder { buy_amount, valid_to, app_data: [0; 32], - fee_amount: [0; 32], + fee_amount: U256::ZERO, kind, partially_fillable: false, sell_token_balance: SellTokenSource::Erc20, @@ -302,18 +295,14 @@ mod tests { 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], + sell_token: Address::repeat_byte(0x11), + buy_token: Address::repeat_byte(0x22), + receiver: Some(Address::repeat_byte(0x33)), + sell_amount: U256::from(0x2a_u64), + buy_amount: U256::MAX, valid_to: 0xffff_ffff, app_data: [0x44; 32], - fee_amount: [0u8; 32], + fee_amount: U256::ZERO, kind: OrderKind::Sell, partially_fillable: false, sell_token_balance: SellTokenSource::Erc20, @@ -324,13 +313,13 @@ mod tests { #[test] fn sell_builder_matches_the_literal() { let built = OrderBody::sell( - SellToken([0x11; 20]), + SellToken(Address::repeat_byte(0x11)), sample().sell_amount, - BuyToken([0x22; 20]), - [0xff; 32], + BuyToken(Address::repeat_byte(0x22)), + U256::MAX, 0xffff_ffff, ) - .receiver([0x33; 20]) + .receiver(Address::repeat_byte(0x33)) .app_data([0x44; 32]) .build(); assert_eq!(built, sample()); @@ -339,43 +328,43 @@ mod tests { #[test] fn buy_builder_fixes_the_buy_side() { let built = OrderBody::buy( - BuyToken([0x22; 20]), - [0xff; 32], - SellToken([0x11; 20]), - [0x01; 32], + BuyToken(Address::repeat_byte(0x22)), + U256::MAX, + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), 100, ) .partially_fillable() .sell_token_balance(SellTokenSource::External) .buy_token_balance(BuyTokenDestination::Internal) - .fee_amount([0x05; 32]) + .fee_amount(U256::from(5u64)) .build(); assert_eq!(built.kind, OrderKind::Buy); - assert_eq!(built.sell_token, [0x11; 20]); - assert_eq!(built.buy_token, [0x22; 20]); - assert_eq!(built.sell_amount, [0x01; 32]); - assert_eq!(built.buy_amount, [0xff; 32]); + assert_eq!(built.sell_token, Address::repeat_byte(0x11)); + assert_eq!(built.buy_token, Address::repeat_byte(0x22)); + assert_eq!(built.sell_amount, U256::from(1u64)); + assert_eq!(built.buy_amount, U256::MAX); assert_eq!(built.valid_to, 100); assert!(built.partially_fillable); assert_eq!(built.sell_token_balance, SellTokenSource::External); assert_eq!(built.buy_token_balance, BuyTokenDestination::Internal); - assert_eq!(built.fee_amount, [0x05; 32]); + assert_eq!(built.fee_amount, U256::from(5u64)); assert_eq!(built.receiver, None); } #[test] fn builder_defaults_are_the_wire_defaults() { let built = OrderBody::sell( - SellToken([0x11; 20]), - [0x01; 32], - BuyToken([0x22; 20]), - [0x02; 32], + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), 1, ) .build(); assert_eq!(built.receiver, None); assert_eq!(built.app_data, [0; 32]); - assert_eq!(built.fee_amount, [0; 32]); + assert_eq!(built.fee_amount, U256::ZERO); assert!(!built.partially_fillable); assert_eq!(built.sell_token_balance, SellTokenSource::Erc20); assert_eq!(built.buy_token_balance, BuyTokenDestination::Erc20); @@ -421,7 +410,7 @@ mod tests { fn signed_order_borsh_round_trips() { let signed = SignedOrder { order: sample(), - owner: [0x55; 20], + owner: Address::repeat_byte(0x55), signature: vec![0xC0, 0xFF, 0xEE], }; let bytes = borsh::to_vec(&signed).expect("encode"); diff --git a/crates/cow-venue/tests/conformance.rs b/crates/cow-venue/tests/conformance.rs index 4c176300..24efdd12 100644 --- a/crates/cow-venue/tests/conformance.rs +++ b/crates/cow-venue/tests/conformance.rs @@ -3,9 +3,12 @@ //! the adapter's own derivation. The files are the contract a non-Rust //! adapter author reads. -use cow_venue::{CowAdapter, CowIntentBody}; -use videre_sdk::VenueAdapter; -use videre_test::{CodecVectors, HeaderGoldens}; +use alloy_primitives::{Address, U256}; +use cow_venue::{ + BuyToken, CowAdapter, CowIntent, CowIntentBody, OrderBody, SellToken, SignedOrder, +}; +use videre_sdk::{IntentBody as _, VenueAdapter}; +use videre_test::{CodecVectors, Expectation, HeaderGoldens}; fn fixture(name: &str) -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -13,6 +16,95 @@ fn fixture(name: &str) -> std::path::PathBuf { .join(name) } +/// The one order every published fixture carries. +fn order_body() -> OrderBody { + OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(42u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(41u64), + 1_700_000_000, + ) + .app_data([0x44; 32]) + .build() +} + +fn signed_order() -> SignedOrder { + SignedOrder { + order: order_body(), + owner: Address::repeat_byte(0x55), + signature: vec![0xC0, 0xFF, 0xEE], + } +} + +fn encoded(intent: CowIntent) -> Vec { + CowIntentBody::V1(intent).to_bytes().expect("body encodes") +} + +/// Rebuild the published codec vectors from the shipped codec. +fn build_codec_vectors() -> CodecVectors { + let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); + vectors + .push_round_trip( + "v1-order", + &CowIntentBody::V1(CowIntent::Order(order_body())), + ) + .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(signed_order())), + ) + .expect("signed order encodes"); + let mut unknown = encoded(CowIntent::Order(order_body())); + unknown[0] = 9; + vectors.push_failure( + "unknown-version", + unknown, + Expectation::UnknownVersion { version: 9 }, + ); + vectors.push_failure("empty", Vec::new(), Expectation::Empty); + let mut truncated = encoded(CowIntent::Order(order_body())); + truncated.truncate(truncated.len() - 1); + vectors.push_failure( + "truncated-payload", + truncated, + Expectation::Malformed { version: 0 }, + ); + let mut trailing = encoded(CowIntent::Order(order_body())); + trailing.push(0); + vectors.push_failure( + "trailing-bytes", + trailing, + Expectation::Malformed { version: 0 }, + ); + vectors +} + +/// Rebuild the published header goldens through the adapter's own +/// mainnet-configured derivation. +fn build_header_goldens() -> HeaderGoldens { + CowAdapter::init(vec![("chain".to_owned(), "1".to_owned())]).expect("config parses"); + let mut goldens = HeaderGoldens::new("cow"); + goldens + .record( + "v1-order-presign", + encoded(CowIntent::Order(order_body())), + CowAdapter::derive_header, + ) + .expect("header derives") + .notes = Some("unsigned order: authorised by host-held keys (pre-sign)".to_owned()); + goldens + .record( + "v1-signed", + encoded(CowIntent::Signed(signed_order())), + CowAdapter::derive_header, + ) + .expect("header derives") + .notes = Some("owner-signed order: EIP-1271".to_owned()); + goldens +} + #[test] fn codec_conforms_to_the_published_vectors() { let vectors = CodecVectors::load(fixture("cow-intent-body.json")).expect("vectors parse"); @@ -27,3 +119,40 @@ fn derive_header_conforms_to_the_published_goldens() { let goldens = HeaderGoldens::load(fixture("cow-header-goldens.json")).expect("goldens parse"); goldens.assert_conforms(CowAdapter::derive_header); } + +#[test] +fn published_vectors_match_regeneration() { + assert_eq!( + CodecVectors::load(fixture("cow-intent-body.json")) + .expect("vectors parse") + .to_json(), + build_codec_vectors().to_json(), + "cow-intent-body.json has drifted; run the ignored \ + regenerate_published_fixtures test and commit the result", + ); +} + +#[test] +fn published_goldens_match_regeneration() { + assert_eq!( + HeaderGoldens::load(fixture("cow-header-goldens.json")) + .expect("goldens parse") + .to_json(), + build_header_goldens().to_json(), + "cow-header-goldens.json has drifted; run the ignored \ + regenerate_published_fixtures test and commit the result", + ); +} + +/// Rewrite the published files after a deliberate wire change, then +/// commit the diff. +#[test] +#[ignore = "writes the published fixture files in place"] +fn regenerate_published_fixtures() { + build_codec_vectors() + .write(fixture("cow-intent-body.json")) + .unwrap(); + build_header_goldens() + .write(fixture("cow-header-goldens.json")) + .unwrap(); +} diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/crates/cow-venue/tests/vectors/cow-header-goldens.json index d482f6a2..08555774 100644 --- a/crates/cow-venue/tests/vectors/cow-header-goldens.json +++ b/crates/cow-venue/tests/vectors/cow-header-goldens.json @@ -4,7 +4,7 @@ "goldens": [ { "name": "v1-order-presign", - "body": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "body": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", "header": { "gives": { "asset": { @@ -31,7 +31,7 @@ }, { "name": "v1-signed", - "body": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "body": "000111111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", "header": { "gives": { "asset": { diff --git a/crates/cow-venue/tests/vectors/cow-intent-body.json b/crates/cow-venue/tests/vectors/cow-intent-body.json index 651231e7..13b58d34 100644 --- a/crates/cow-venue/tests/vectors/cow-intent-body.json +++ b/crates/cow-venue/tests/vectors/cow-intent-body.json @@ -4,17 +4,17 @@ "vectors": [ { "name": "v1-order", - "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", "expect": "round-trip" }, { "name": "v1-signed", - "bytes": "00011111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", + "bytes": "000111111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000555555555555555555555555555555555555555503000000c0ffee", "expect": "round-trip" }, { "name": "unknown-version", - "bytes": "09001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", + "bytes": "090011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f153654444444444444444444444444444444444444444444444444444444444444444000000000000000000000000000000000000000000000000000000000000000000000000", "expect": { "unknown-version": { "version": 9 @@ -28,7 +28,7 @@ }, { "name": "truncated-payload", - "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f1536544444444444444444444444444444444444444444444444444444444444444440000000000000000000000000000000000000000000000000000000000000000000000", "expect": { "malformed": { "version": 0 @@ -37,7 +37,7 @@ }, { "name": "trailing-bytes", - "bytes": "00001111111111111111111111111111111111111111222222222222222222222222222222222222222200000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000002900f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", + "bytes": "000011111111111111111111111111111111111111112222222222222222222222222222222222222222002a00000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000000000000000000000000f15365444444444444444444444444444444444444444444444444444444444444444400000000000000000000000000000000000000000000000000000000000000000000000000", "expect": { "malformed": { "version": 0 diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index d38b895b..d32e97f3 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -119,7 +119,7 @@ pub use bindings::videre::types::types::{ UnsignedTx, VenueError, }; /// The value-flow vocabulary intent headers are expressed in. -pub use bindings::videre::value_flow::types as value_flow; +pub mod value_flow; /// The venue status-body codec: decode an `intent-status` event's /// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). pub use videre_status_body as status_body; diff --git a/crates/videre-sdk/src/value_flow.rs b/crates/videre-sdk/src/value_flow.rs new file mode 100644 index 00000000..d72a5e76 --- /dev/null +++ b/crates/videre-sdk/src/value_flow.rs @@ -0,0 +1,19 @@ +//! The value-flow wire types, re-exported from +//! [`bindings`](crate::bindings) with constructors that own the `uint` +//! encoding so callers never hand-roll it. + +pub use crate::bindings::videre::value_flow::types::*; + +impl AssetAmount { + /// An ERC-20 amount. Encodes `amount` as the value-flow `uint`: + /// minimal big-endian, where zero is the empty list. + #[must_use] + pub fn erc20(token: nexum_sdk::prelude::Address, amount: nexum_sdk::prelude::U256) -> Self { + Self { + asset: Asset::Erc20(Erc20 { + token: token.as_slice().to_vec(), + }), + amount: amount.to_be_bytes_trimmed_vec(), + } + } +} diff --git a/modules/twap-monitor/src/keeper.rs b/modules/twap-monitor/src/keeper.rs index ec2ed215..e7719e8a 100644 --- a/modules/twap-monitor/src/keeper.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -371,7 +371,7 @@ fn signed_intent_body( let order_data = gpv2_to_order_data(order)?; Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { order: order_data_to_body(&order_data), - owner: owner.into_array(), + owner, signature: signature.to_vec(), }))) } From 5f3a3027984872f34031664e153516245b06b22f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 23:02:55 +0000 Subject: [PATCH 102/139] cow: reconcile the accepted receipt uid against the local derivation (#589) An accepted submit reply's UID is now compared against assembly::order_uid over the submitted order; a disagreement returns VenueError::InvalidBody instead of trusting the mismatched receipt. The already-held path documents that its UID is locally derived and unverified, since that reply carries no UID by design. Closes #559 --- crates/cow-venue/src/adapter.rs | 45 ++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 298d437b..1a7ed309 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -160,8 +160,11 @@ pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result uid, + Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, + // Locally derived and unverified: no UID in the reply. Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), }; Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec())) @@ -187,7 +191,8 @@ pub(crate) fn submit_with( let creation = assembly::build_presign_creation(&order, owner) .map_err(|e| VenueError::InvalidBody(e.to_string()))?; let uid = match post_order(fetch, config, &creation)? { - Posted::Accepted(uid) => uid, + Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, + // Locally derived and unverified: no UID in the reply. Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), }; Ok(SubmitOutcome::RequiresSigning(UnsignedTx { @@ -200,6 +205,24 @@ pub(crate) fn submit_with( } } +/// Refuse an accepted receipt whose UID disagrees with the local +/// derivation: the drift means the stored order is not the order the +/// caller submitted. +fn reconciled_uid( + server: cowprotocol::OrderUid, + config: &AdapterConfig, + order: &OrderData, + owner: Address, +) -> Result { + let derived = assembly::order_uid(config.chain, order, owner); + if server != derived { + return Err(VenueError::InvalidBody(format!( + "orderbook uid {server} disagrees with derived uid {derived}" + ))); + } + Ok(server) +} + /// Poll one receipt's orderbook lifecycle state. pub(crate) fn status_with( fetch: &impl Fetch, @@ -695,6 +718,20 @@ mod tests { assert_eq!(receipt, expected_uid(&config).as_slice()); } + #[test] + fn an_accepted_uid_disagreeing_with_the_derivation_is_refused() { + let config = config(); + let mut drifted = expected_uid(&config); + drifted.0[0] ^= 0x01; + let fetch = MockFetch::default(); + fetch.respond_to(http::Method::POST, ORDERS, 201, format!("\"{drifted}\"")); + + assert!(matches!( + submit_with(&fetch, &config, &signed_bytes()), + Err(VenueError::InvalidBody(detail)) if detail.contains("disagrees") + )); + } + #[test] fn unsigned_submit_requires_signing_the_presign_call() { let config = with_owner(owner()); From ed2b1f96634235b62b26830219391552d638dcee Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 23:04:22 +0000 Subject: [PATCH 103/139] venue: restore the dropped empty-receipt guard on status and cancel (#590) The videre rename dropped the empty-receipt rejection on the status and cancel paths, so any receipt including empty succeeded unconditionally. Restore the guard at the receipt seam: the venue export shim rejects an empty receipt before adapter dispatch and VenueClient::status/cancel reject it before the wire, both through a shared guard_receipt helper. The original check used a dedicated invalid-receipt variant; that would extend the frozen videre:types@0.1.0 venue-error, so the guard reuses the existing invalid-body case with an empty-receipt message. A unit test and a before-the-transport client test restore the dropped coverage. --- crates/videre-sdk/src/adapter.rs | 33 ++++++++++++++++++++++++++++-- crates/videre-sdk/src/client.rs | 6 +++++- crates/videre-sdk/tests/adapter.rs | 15 ++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/videre-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs index 0e6f3edb..f48067fe 100644 --- a/crates/videre-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -10,6 +10,16 @@ use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; +/// Reject an empty receipt as `invalid-body` before it reaches an +/// adapter. Called by the export shim ahead of `status` and `cancel`. +#[doc(hidden)] +pub fn guard_receipt(receipt: &[u8]) -> Result<(), VenueError> { + if receipt.is_empty() { + return Err(VenueError::InvalidBody("empty receipt".into())); + } + Ok(()) +} + /// One venue's protocol speaker: the guest-side face of the /// `venue-adapter` world. Implement it on a unit struct and apply /// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and @@ -45,12 +55,15 @@ pub trait VenueAdapter { /// 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. + /// Report where a previously submitted intent is in its life. The + /// export shim rejects an empty receipt as `invalid-body` before + /// dispatch. 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. + /// no longer win the race. The export shim rejects an empty receipt + /// as `invalid-body` before dispatch. fn cancel(receipt: Vec) -> Result<(), VenueError>; } @@ -101,12 +114,14 @@ macro_rules! __export_venue_adapter { fn status( receipt: ::std::vec::Vec, ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { + $crate::adapter::guard_receipt(&receipt)?; <$adapter as $crate::VenueAdapter>::status(receipt) } fn cancel( receipt: ::std::vec::Vec, ) -> ::core::result::Result<(), $crate::VenueError> { + $crate::adapter::guard_receipt(&receipt)?; <$adapter as $crate::VenueAdapter>::cancel(receipt) } } @@ -114,3 +129,17 @@ macro_rules! __export_venue_adapter { export!(__VidereVenueAdapterExport); }; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_receipt_is_rejected_as_invalid_body() { + match guard_receipt(&[]).unwrap_err() { + VenueError::InvalidBody(detail) => assert_eq!(detail, "empty receipt"), + other => panic!("expected invalid-body, got {other:?}"), + } + guard_receipt(&[1]).unwrap(); + } +} diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index f3384d44..bbc8cc0c 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -290,12 +290,16 @@ impl VenueClient { } /// Report where a previously submitted intent is in its life. + /// Rejects an empty receipt as `invalid-body` before the wire. pub async fn status(&self, receipt: &[u8]) -> Result { + crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; Ok(self.transport.status(&V::ID, receipt).await?) } - /// Ask the bound venue to withdraw an intent. + /// Ask the bound venue to withdraw an intent. Rejects an empty + /// receipt as `invalid-body` before the wire. pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { + crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; Ok(self.transport.cancel(&V::ID, receipt).await?) } } diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index d43cd252..fd324f0d 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -307,6 +307,21 @@ fn quote_typestate_prices_then_submits_the_quoted_body() { assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == RECEIPT.to_vec())); } +#[test] +fn empty_receipt_is_rejected_before_the_transport() { + // The unbound venue would report unknown-venue, so invalid-body + // proves the guard fires before the transport is consulted. + let client = VenueClient::::with_transport(InProcessClient); + assert!(matches!( + run(client.status(&[])).unwrap_err(), + ClientError::Venue(VenueFault::InvalidBody(detail)) if detail == "empty receipt" + )); + assert!(matches!( + run(client.cancel(&[])).unwrap_err(), + ClientError::Venue(VenueFault::InvalidBody(detail)) if detail == "empty receipt" + )); +} + #[test] fn unbound_venue_is_unknown_at_the_client() { let client = VenueClient::::with_transport(InProcessClient); From ba51441641896fafb427d93b6562c10adbb68234 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 01:17:45 +0000 Subject: [PATCH 104/139] cow-venue: add valid_to and valid_for order expiry setters (#592) cow-venue: add valid_to and valid_for order expiry setters (#585) The builder gains an absolute valid_to setter and a relative valid_for(now, duration) that computes valid_to = now.saturating_add(duration), so a caller writing "expire in N seconds" does not hand-roll the arithmetic. Duration is u32 seconds to match the wire validTo and the dispatch tick epoch_s; a wasm guest has no ambient clock, so valid_for takes now explicitly. The twap-monitor valid_to_in helper now routes through valid_for instead of adding by hand. --- crates/cow-venue/src/order.rs | 60 ++++++++++++++++++++++++++++++ modules/twap-monitor/src/keeper.rs | 11 +++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index 2b22a0d4..69e75c17 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -176,6 +176,24 @@ impl OrderBuilder { } } + /// Set the absolute `validTo` unix-seconds expiry, overriding the + /// constructor argument. + #[must_use] + pub const fn valid_to(mut self, secs: u32) -> Self { + self.body.valid_to = secs; + self + } + + /// Expire `duration` seconds after `now`, saturating at `u32::MAX`. + /// `now` is the tick's block timestamp, not a wall clock: `valid_to` + /// is hashed into the submission dedup key, so a wall clock would + /// break reconcile-replay idempotency. + #[must_use] + pub const fn valid_for(mut self, now: u32, duration: u32) -> Self { + self.body.valid_to = now.saturating_add(duration); + self + } + /// Deliver the buy token to `receiver` instead of the owner. #[must_use] pub const fn receiver(mut self, receiver: Address) -> Self { @@ -371,6 +389,48 @@ mod tests { assert_eq!(built.kind, OrderKind::Sell); } + #[test] + fn valid_to_setter_overrides_the_constructor_argument() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_to(0x1234_5678) + .build(); + assert_eq!(built.valid_to, 0x1234_5678); + } + + #[test] + fn valid_for_adds_the_duration_to_now() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_for(1_700_000_000, 3_600) + .build(); + assert_eq!(built.valid_to, 1_700_003_600); + } + + #[test] + fn valid_for_saturates_on_overflow() { + let built = OrderBody::sell( + SellToken(Address::repeat_byte(0x11)), + U256::from(1u64), + BuyToken(Address::repeat_byte(0x22)), + U256::from(2u64), + 1, + ) + .valid_for(u32::MAX - 10, 3_600) + .build(); + assert_eq!(built.valid_to, u32::MAX); + } + #[test] fn order_body_borsh_round_trips() { let body = sample(); diff --git a/modules/twap-monitor/src/keeper.rs b/modules/twap-monitor/src/keeper.rs index e7719e8a..6de62336 100644 --- a/modules/twap-monitor/src/keeper.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -463,15 +463,16 @@ mod tests { on_block(host, &CowClient::with_transport(venue), block) } - /// `validTo` a given number of seconds from now. The constructor's - /// client-side max-horizon policy reads the wall clock (not the - /// block clock), so test orders must expire relative to it. - fn valid_to_in(seconds: u64) -> u32 { + /// `validTo` a given number of seconds from the wall clock, the + /// same saturating `now + seconds` the builder's `valid_for` applies. + fn valid_to_in(seconds: u32) -> 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") + u32::try_from(now) + .expect("wall clock fits u32") + .saturating_add(seconds) } fn sample_params() -> ConditionalOrderParams { From 6a5c3343fa3440744f3d3dca006bb34a1dcbc2e7 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 01:19:20 +0000 Subject: [PATCH 105/139] cow: model Verdict::Post next-poll timestamp as Option (#593) cow: model Verdict::Post next-poll timestamp as Option Closes #567 --- crates/composable-cow/src/poll.rs | 6 +++--- crates/composable-cow/tests/run.rs | 2 +- modules/twap-monitor/src/keeper.rs | 7 +++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/composable-cow/src/poll.rs b/crates/composable-cow/src/poll.rs index 590549cc..f68eb5c7 100644 --- a/crates/composable-cow/src/poll.rs +++ b/crates/composable-cow/src/poll.rs @@ -71,9 +71,9 @@ pub enum Verdict { /// 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, + /// the next poll at. `None` when synthetic - the legacy adapter + /// has no such hint. + next_poll_timestamp: Option, }, /// Retry once the wall clock (Unix seconds, UTC) reaches /// `wait_until`. diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index b4b7e411..7466a7cd 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -134,7 +134,7 @@ fn ready_outcome(order: &GPv2OrderData) -> Verdict { Verdict::Post { order: Box::new(order.clone()), signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), - next_poll_timestamp: 0, + next_poll_timestamp: None, } } diff --git a/modules/twap-monitor/src/keeper.rs b/modules/twap-monitor/src/keeper.rs index 6de62336..bd79b94f 100644 --- a/modules/twap-monitor/src/keeper.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -327,14 +327,13 @@ fn poll_one( /// `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`). +/// carries no next-poll hint, so `next_poll_timestamp` is `None`. fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; Some(Verdict::Post { order: Box::new(order), signature, - next_poll_timestamp: 0, + next_poll_timestamp: None, }) } @@ -570,7 +569,7 @@ mod tests { 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"); + assert_eq!(next_poll_timestamp, None, "legacy path carries no hint"); } other => panic!("expected Post, got {other:?}"), } From fe741ae816e12c9788b7c01899b01f579bacb7ab Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 01:21:02 +0000 Subject: [PATCH 106/139] cow-venue: type the retry classifier boundary with OrderbookApiErrorType (#594) Closes #496 --- crates/cow-venue/Cargo.toml | 12 ++-- crates/cow-venue/src/adapter.rs | 13 ++++- crates/cow-venue/src/classification.rs | 80 ++++++++++++++++---------- 3 files changed, 66 insertions(+), 39 deletions(-) diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 650cee31..9d635463 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -30,8 +30,10 @@ videre-sdk = { path = "../videre-sdk", optional = true } # reach a guest that links this slice. nexum-sdk = { path = "../nexum-sdk", optional = true } # `assembly` slice: the chain-edge order projections and orderbook -# submission bodies. Express-declared (not workspace-inherited) so the -# guest build never inherits the native `http-client` feature. +# submission bodies. The `client` slice also enables it for the typed +# `OrderbookApiErrorType` classifier boundary. Express-declared (not +# workspace-inherited) so the guest build never inherits the native +# `http-client` feature. cowprotocol = { version = "0.2.0", default-features = false, optional = true } # `body` slice: the order body fields are alloy `Address`/`U256`, and # `borsh` supplies their borsh impls (ruint for `U256`). Optional only @@ -60,8 +62,8 @@ toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. videre-test = { path = "../videre-test" } -# Parity tests only: the upstream errorType enum and `retry_hint()` the -# shipped table is reconciled against. Never a runtime dependency. +# Parity tests: the upstream `retry_hint()` the shipped table is +# reconciled against. cowprotocol = { version = "0.2.0", default-features = false } [features] @@ -71,7 +73,7 @@ cowprotocol = { version = "0.2.0", default-features = false } # single slice without pulling the codec or the keeper transitively. default = ["body"] body = ["dep:borsh", "dep:videre-sdk", "dep:alloy-primitives"] -client = ["body", "dep:nexum-sdk"] +client = ["body", "dep:nexum-sdk", "dep:cowprotocol"] # Chain-edge order assembly, shared by the adapter's submit and the # keeper's legacy submit path. Carries no component glue, so a keeper # module can link it without exporting the adapter face. diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 1a7ed309..8f30e462 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -23,7 +23,8 @@ use std::sync::{PoisonError, RwLock}; use alloy_primitives::Address; use cowprotocol::{ - ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, QuoteAppData, QuoteRequest, + ApiError, Chain, OrderCreation, OrderData, OrderKind, OrderStatus, OrderbookApiErrorType, + QuoteAppData, QuoteRequest, }; use nexum_sdk::keeper::RetryAction; use serde::Deserialize; @@ -399,7 +400,7 @@ fn refusal_for_submit(response: &http::Response>) -> Refusal { ))); } match serde_json::from_slice::(response.body()) { - Ok(api) if classification::is_already_submitted(&api.error_type) => Refusal::AlreadyHeld, + Ok(api) if classification::is_already_submitted(api.error_kind()) => Refusal::AlreadyHeld, Ok(api) => Refusal::Error(classified(&api)), Err(_) => Refusal::Error(VenueError::Unavailable(format!( "orderbook status {status}" @@ -436,7 +437,13 @@ fn retry_after_ms(response: &http::Response>) -> Option { /// `rate-limited`, permanent rows (and any future action) are `denied`. fn classified(api: &ApiError) -> VenueError { let detail = format!("{}: {}", api.error_type, api.description); - match classification::classify(&api.error_type) { + let action = match api.error_kind() { + // A wire `errorType` the upstream enum does not know is by + // definition unlisted, hence permanent. + OrderbookApiErrorType::Unknown(_) => RetryAction::Drop, + kind => classification::classify(kind), + }; + match action { RetryAction::TryNextBlock => VenueError::Unavailable(detail), RetryAction::Backoff { seconds } => VenueError::RateLimited(RateLimit { retry_after_ms: Some(seconds.saturating_mul(1000)), diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index a3c6861b..ab2f4f23 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -9,10 +9,12 @@ //! and asserts the generated table agrees. //! //! The one non-obvious invariant: an `errorType` absent from the table -//! 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. +//! (including [`OrderbookApiErrorType::Unknown`]) 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. +use cowprotocol::OrderbookApiErrorType; use nexum_sdk::keeper::RetryAction; /// The shipped classification data, embedded verbatim so a parity test @@ -71,13 +73,20 @@ pub struct ClassificationTable { } impl ClassificationTable { - fn row(&self, error_type: &str) -> Option<&GeneratedRow> { - self.rows.iter().find(|r| r.error_type == error_type) + /// [`OrderbookApiErrorType::Unknown`] is by definition unlisted: + /// the parity tests pin every row to a known variant's exact wire + /// spelling, so only known variants can match a row. + fn row(&self, error_type: &OrderbookApiErrorType) -> Option<&GeneratedRow> { + match error_type { + OrderbookApiErrorType::Unknown(_) => None, + known => self.rows.iter().find(|r| r.error_type == known.as_str()), + } } - /// The retry action for an orderbook `errorType`. Unlisted types are - /// permanent: [`RetryAction::Drop`]. - pub fn classify(&self, error_type: &str) -> RetryAction { + /// The retry action for an orderbook `errorType`. Unlisted types + /// (including [`OrderbookApiErrorType::Unknown`]) are permanent: + /// [`RetryAction::Drop`]. + pub fn classify(&self, error_type: &OrderbookApiErrorType) -> RetryAction { self.row(error_type) .map_or(RetryAction::Drop, GeneratedRow::retry_action) } @@ -85,7 +94,7 @@ impl ClassificationTable { /// 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 { + pub fn is_already_submitted(&self, error_type: &OrderbookApiErrorType) -> bool { self.row(error_type).is_some_and(|r| r.already_submitted) } @@ -108,14 +117,16 @@ pub fn table() -> ClassificationTable { } /// 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) +/// the shipped table. Unlisted types (including +/// [`OrderbookApiErrorType::Unknown`]) are permanent +/// ([`RetryAction::Drop`]). +pub fn classify(error_type: OrderbookApiErrorType) -> 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) +pub fn is_already_submitted(error_type: OrderbookApiErrorType) -> bool { + table().is_already_submitted(&error_type) } /// Retry action for a coarse `denied` refusal. The adapter spells a @@ -125,7 +136,7 @@ pub fn is_already_submitted(error_type: &str) -> bool { /// permanent. pub fn classify_denied(detail: &str) -> RetryAction { let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix); - match classify(error_type) { + match classify(OrderbookApiErrorType::from(error_type)) { RetryAction::DropOnRepeat => RetryAction::DropOnRepeat, _ => RetryAction::Drop, } @@ -136,6 +147,11 @@ mod tests { use super::*; use crate::classification_data::{Action, ClassificationError, parse_and_validate}; + /// Wire spelling to typed kind, as the adapter's `error_kind()` does. + fn kind(error_type: &str) -> OrderbookApiErrorType { + OrderbookApiErrorType::from(error_type) + } + /// The generated table is non-empty. #[test] fn shipped_data_parses() { @@ -160,13 +176,13 @@ mod tests { Action::Drop => RetryAction::Drop, }; assert_eq!( - classify(&entry.error_type), + classify(kind(&entry.error_type)), expected, "classify {}", entry.error_type, ); assert_eq!( - is_already_submitted(&entry.error_type), + is_already_submitted(kind(&entry.error_type)), entry.already_submitted, "already-submitted {}", entry.error_type, @@ -179,41 +195,43 @@ mod tests { /// producer the hand-coded classifier lacked. #[test] fn known_rows_classify_as_documented() { - assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock); assert_eq!( - classify("TooManyLimitOrders"), + classify(kind("TooManyLimitOrders")), RetryAction::Backoff { seconds: 30 }, ); assert_eq!( - classify("InvalidEip1271Signature"), + classify(kind("InvalidEip1271Signature")), RetryAction::DropOnRepeat, ); - assert_eq!(classify("InvalidSignature"), RetryAction::Drop); - assert!(is_already_submitted("DuplicatedOrder")); - assert!(is_already_submitted("DuplicateOrder")); + assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop); + assert!(is_already_submitted(kind("DuplicatedOrder"))); + assert!(is_already_submitted(kind("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")); + let unknown = kind("NewlyMintedErrorType"); + assert!(matches!(unknown, OrderbookApiErrorType::Unknown(_))); + assert_eq!(classify(unknown.clone()), RetryAction::Drop); + assert!(!is_already_submitted(unknown)); } /// Every retry arm is reachable from the table alone. #[test] fn table_reaches_every_arm() { - assert_eq!(classify("InsufficientFee"), RetryAction::TryNextBlock); + assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock); assert!(matches!( - classify("TooManyLimitOrders"), + classify(kind("TooManyLimitOrders")), RetryAction::Backoff { .. } )); assert_eq!( - classify("InvalidEip1271Signature"), + classify(kind("InvalidEip1271Signature")), RetryAction::DropOnRepeat, ); - assert_eq!(classify("InvalidSignature"), RetryAction::Drop); + assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop); } /// A denied detail re-enters the table by its `errorType` prefix: @@ -329,8 +347,8 @@ mod tests { _ => None, }; let shepherd = ( - classify(&entry.error_type), - is_already_submitted(&entry.error_type), + classify(kind(&entry.error_type)), + is_already_submitted(kind(&entry.error_type)), ); if upstream != Some(shepherd) { divergent.push(&entry.error_type); From fcc75287d323c960dd2d922f6027bf187d89d0b0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 01:21:42 +0000 Subject: [PATCH 107/139] tooling: build the cow-venue adapter wasm inside just ci (#596) The module-wasm build emits a featureless cow_venue.wasm through twap-monitor's cow-venue dependency, so the videre-host platform e2e tests loaded a stub component with no adapter exports and failed non-deterministically on a clean checkout, while GitHub CI passed by building the adapter after the modules. Rebuild the adapter component inside the ci recipe, after the module build and before the tests, matching CI's order. Closes #595 --- justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/justfile b/justfile index 3d957bf4..47d2d3d0 100644 --- a/justfile +++ b/justfile @@ -110,4 +110,8 @@ ci: -p balance-tracker -p http-probe -p echo-venue \ -p echo-client -p clock-reader -p flaky-bomb -p flaky-venue -p fuel-bomb \ -p memory-bomb -p panic-bomb -p slow-host + # The module build above emits a featureless cow_venue.wasm through + # twap-monitor's cow-venue dep; rebuild the adapter component over it + # (as CI does) so the platform e2e tests load the adapter, not the stub. + cargo build --release --target wasm32-wasip2 -p cow-venue --features adapter cargo test --workspace --all-features --no-fail-fast From 36b73b266c045bfb966e3cc7508e91fa864bc5aa Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 03:40:42 +0000 Subject: [PATCH 108/139] ci: back sccache with the github actions cache, drop cloudflare r2 (#607) The R2 (S3) backend fails CI two ways: the free R2 allocation is exhausted so writes error, and secrets do not flow to fork pull_request runs, so R2_ACCOUNT_ID interpolates empty and the S3 backend fails sccache server startup before anything compiles. Switch the backend to the GitHub Actions cache (SCCACHE_GHA_ENABLED), which is free, needs no secrets and is available on fork PRs, so both failure modes go away. Drop SCCACHE_BUCKET, SCCACHE_ENDPOINT, SCCACHE_REGION, SCCACHE_S3_KEY_PREFIX and the AWS_* R2 credentials; keep RUSTC_WRAPPER=sccache and CARGO_INCREMENTAL=0. The per-repo cache evicts LRU on its own and a miss is a cold compile, never a failure. Supersedes #475, the fail-open workaround. --- .github/actions/rust-setup/action.yml | 4 ++-- .github/workflows/ci.yml | 22 ++++++---------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/actions/rust-setup/action.yml b/.github/actions/rust-setup/action.yml index b5a5c12b..83437070 100644 --- a/.github/actions/rust-setup/action.yml +++ b/.github/actions/rust-setup/action.yml @@ -1,5 +1,5 @@ name: rust-setup -description: Pinned Rust 1.94 + sccache (R2 backend, configured at the workflow env level) + registry-only Swatinem cache shared across jobs. +description: Pinned Rust 1.94 + sccache (GitHub Actions cache backend, SCCACHE_GHA_ENABLED) + registry-only Swatinem cache shared across jobs. inputs: targets: @@ -11,7 +11,7 @@ inputs: save-if: description: > May this run WRITE the registry cache? Defaults to always: Swatinem now caches - only the small ~/.cargo registry (target/ moved to sccache/R2), and the GHA + only the small ~/.cargo registry (target/ moved to sccache over the GitHub Actions cache), and the GHA cache is branch-scoped, so a PR saving its own registry warms its re-runs without touching the develop baseline. Set false to make a job restore-only. default: "true" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deb34074..25f28ace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,24 +19,14 @@ env: # jobs: any per-job RUSTFLAGS delta re-fingerprints the whole graph and every # sccache object key, defeating reuse. RUSTFLAGS: "-D warnings" - # sccache caches individual rustc invocations across jobs AND runs, including the - # workspace crates (which Swatinem drops). Requires incremental off. Backed by a - # Cloudflare R2 bucket (S3 API), so the cache is shared across jobs, runs, the - # Dockerfile build, and the local host sccache — one silo, zero egress cost. - # Set at the workflow env level (not the composite) because composite actions - # cannot read the `secrets` context. Keys are content-addressed on the full - # compiler input, and the sole build.rs (cow-venue) + the macro crates are - # deterministic, so PR builds writing to the shared bucket write correct objects - # under correct keys (no poisoning); bound storage with an R2 lifecycle-expiry - # rule on the bucket (sccache does not evict cloud backends itself). + # sccache caches individual rustc invocations across jobs and runs, including the + # workspace crates (which Swatinem drops). Requires incremental off. Backed by the + # GitHub Actions cache (SCCACHE_GHA_ENABLED): free, no secrets, and available on + # fork pull_request runs, so forks build cached too. The per-repo cache evicts LRU + # on its own; a miss degrades to a cold compile, never a build failure. RUSTC_WRAPPER: sccache CARGO_INCREMENTAL: "0" - SCCACHE_BUCKET: shepherd-sccache - SCCACHE_ENDPOINT: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com - SCCACHE_REGION: auto - SCCACHE_S3_KEY_PREFIX: ci/rust-1.94 - AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + SCCACHE_GHA_ENABLED: "true" jobs: fmt: From 74c3d8ef9265946514960f1bb57e8c0f7eed0540 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:05:00 +0000 Subject: [PATCH 109/139] host: atomic apply batch verb for local-store (#611) Add an additive apply verb to nexum:host/local-store: a batch of set/delete ops applied in one fsync-durable redb transaction, all or nothing. Later ops on a key supersede earlier ones, the quota is projected once as the net whole-batch footprint, and an over-quota batch is rejected before commit so nothing lands. Per-batch caps (1024 ops, 4 MiB of set-value bytes) are enforced upfront with dedicated StorageError variants surfaced as invalid-input. The StateHandle seam and the in-memory mock carry the same contract; existing verbs, nexum:host@0.1.0, and the keeper Journal are untouched. Part of #609. --- .../nexum-runtime/src/host/component/state.rs | 10 +- crates/nexum-runtime/src/host/error.rs | 6 +- .../src/host/impls/local_store.rs | 15 ++ .../src/host/local_store_redb.rs | 131 ++++++++++++++++++ .../src/host/local_store_redb/tests.rs | 128 +++++++++++++++++ crates/nexum-runtime/src/test_utils/store.rs | 65 ++++++++- wit/nexum-host/local-store.wit | 18 +++ 7 files changed, 370 insertions(+), 3 deletions(-) diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index 635ca0b1..edcb1098 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -5,7 +5,7 @@ // local_store_redb.rs. #![allow(clippy::result_large_err)] -use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError}; +use crate::host::local_store_redb::{LocalStore, ModuleStore, StorageError, WriteOp}; /// Process-wide state store that vends per-module handles. pub trait StateStore { @@ -44,6 +44,10 @@ pub trait StateHandle { fn count(&self, prefix: &str) -> Result { Ok(self.list_keys(prefix)?.len() as u64) } + /// Apply `ops` as one atomic batch: every op lands or none does. + /// Quota is charged on the net whole-batch footprint; the backend + /// caps op count and total value bytes per batch. + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError>; } impl StateStore for LocalStore { @@ -86,4 +90,8 @@ impl StateHandle for ModuleStore { fn count(&self, prefix: &str) -> Result { ModuleStore::count(self, prefix) } + + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + ModuleStore::apply(self, ops) + } } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index e31f803c..bb67b619 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -137,12 +137,16 @@ fn transport_fault(source: &alloy_transport::TransportError) -> Fault { } /// The `local-store` interface is the failure domain, so the fault omits -/// the redundant subsystem tag. A quota breach is a policy `denied`; +/// the redundant subsystem tag. A quota breach is a policy `denied`, an +/// apply batch past a per-batch cap is the caller's `invalid-input`; /// anything else is an `internal` backend failure. impl From for Fault { fn from(err: StorageError) -> Self { match err { StorageError::QuotaExceeded { .. } => Fault::Denied(err.to_string()), + StorageError::ApplyOpsExceeded { .. } | StorageError::ApplyBytesExceeded { .. } => { + Fault::InvalidInput(err.to_string()) + } _ => Fault::Internal(err.to_string()), } } diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/crates/nexum-runtime/src/host/impls/local_store.rs index e5abc7c2..e78bdbb4 100644 --- a/crates/nexum-runtime/src/host/impls/local_store.rs +++ b/crates/nexum-runtime/src/host/impls/local_store.rs @@ -1,8 +1,10 @@ //! `nexum:host/local-store`: redb backend with host-side namespacing. use crate::bindings::nexum; +use crate::bindings::nexum::host::local_store::{KeyValue, WriteOp}; use crate::bindings::nexum::host::types::Fault; use crate::host::component::{RuntimeTypes, StateHandle}; +use crate::host::local_store_redb; use crate::host::state::HostState; impl nexum::host::local_store::Host for HostState { @@ -33,4 +35,17 @@ impl nexum::host::local_store::Host for HostState { async fn count(&mut self, prefix: String) -> Result { self.store.count(&prefix).map_err(Fault::from) } + + async fn apply(&mut self, ops: Vec) -> Result<(), Fault> { + let ops: Vec = ops + .into_iter() + .map(|op| match op { + WriteOp::Set(KeyValue { key, value }) => { + local_store_redb::WriteOp::Set { key, value } + } + WriteOp::Delete(key) => local_store_redb::WriteOp::Delete { key }, + }) + .collect(); + self.store.apply(&ops).map_err(Fault::from) + } } diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 8e2d95e4..cc3929fa 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -24,6 +24,29 @@ const PREFIX_LEN: usize = 32; /// + value so the quota bounds on-disk bytes, not logical payload. const ENTRY_OVERHEAD: u64 = 32; +/// Cap on ops per [`ModuleStore::apply`] batch. +pub const MAX_APPLY_OPS: usize = 1024; + +/// Cap on total set-value bytes per [`ModuleStore::apply`] batch. +pub const MAX_APPLY_VALUE_BYTES: u64 = 4 * 1024 * 1024; + +/// One write in a [`ModuleStore::apply`] batch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOp { + /// Insert or overwrite `key` with `value`. + Set { + /// Module-visible key. + key: String, + /// Value bytes. + value: Vec, + }, + /// Delete `key`; a missing key is a no-op. + Delete { + /// Module-visible key. + key: String, + }, +} + /// Process-wide handle to the local-store redb database. Cheap to /// clone. Use [`LocalStore::module`] to obtain a [`ModuleStore`] /// handle with a pre-computed namespace prefix. @@ -195,6 +218,100 @@ impl ModuleStore { Ok(()) } + /// Apply `ops` in one write transaction: every op lands or none does. + /// Later ops on a key supersede earlier ones. Under a quota, the whole + /// batch's net footprint is projected and checked once; an over-quota + /// batch is rejected before commit so nothing lands. Batches past + /// [`MAX_APPLY_OPS`] or [`MAX_APPLY_VALUE_BYTES`] are rejected upfront. + /// The commit is fsync-durable. + pub fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + if ops.len() > MAX_APPLY_OPS { + return Err(StorageError::ApplyOpsExceeded { + ops: ops.len(), + cap: MAX_APPLY_OPS, + }); + } + let value_bytes: u64 = ops + .iter() + .map(|op| match op { + WriteOp::Set { value, .. } => value.len() as u64, + WriteOp::Delete { .. } => 0, + }) + .sum(); + if value_bytes > MAX_APPLY_VALUE_BYTES { + return Err(StorageError::ApplyBytesExceeded { + bytes: value_bytes, + cap: MAX_APPLY_VALUE_BYTES, + }); + } + let txn = self.db.begin_write().map_err(StorageError::Txn)?; + let mut counters = self.counters.lock().unwrap_or_else(|e| e.into_inner()); + let track = self.quota_bytes.is_some() || counters.contains_key(&self.prefix); + let mut projected = 0u64; + { + let mut table = txn.open_table(TABLE).map_err(StorageError::Table)?; + if track { + // Net whole-batch footprint: each touched key's on-disk cost + // is released once and its post-batch cost charged once. + let mut finals: HashMap<&str, Option> = HashMap::new(); + for op in ops { + match op { + WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), + WriteOp::Delete { key } => finals.insert(key, None), + }; + } + let used = match counters.get(&self.prefix) { + Some(&u) => u, + None => self.used_bytes(&table)?, + }; + let mut released = 0u64; + let mut charged = 0u64; + for (key, value_len) in &finals { + let full = self.build_key(key); + released += table + .get(full.as_slice()) + .map_err(StorageError::Storage)? + .map(|v| self.entry_cost(key.len(), v.value().len())) + .unwrap_or(0); + charged += value_len + .map(|len| self.entry_cost(key.len(), len)) + .unwrap_or(0); + } + projected = used.saturating_sub(released) + charged; + if let Some(quota) = self.quota_bytes + && projected > quota + { + // Returning aborts the write transaction: nothing lands. + return Err(StorageError::QuotaExceeded { + needed: projected, + quota, + }); + } + } + for op in ops { + match op { + WriteOp::Set { key, value } => { + let full = self.build_key(key); + table + .insert(full.as_slice(), value.as_slice()) + .map_err(StorageError::Storage)?; + } + WriteOp::Delete { key } => { + let full = self.build_key(key); + table + .remove(full.as_slice()) + .map_err(StorageError::Storage)?; + } + } + } + } + txn.commit().map_err(StorageError::Commit)?; + if track { + counters.insert(self.prefix.clone(), projected); + } + Ok(()) + } + /// On-disk footprint charged for one entry: prefix + key + value + a /// fixed per-entry overhead. fn entry_cost(&self, key_len: usize, value_len: usize) -> u64 { @@ -306,6 +423,20 @@ pub enum StorageError { /// The module's byte quota. quota: u64, }, + #[error("apply batch has {ops} ops but the cap is {cap}")] + ApplyOpsExceeded { + /// Ops in the rejected batch. + ops: usize, + /// Per-batch op cap. + cap: usize, + }, + #[error("apply batch carries {bytes} value B but the cap is {cap} B")] + ApplyBytesExceeded { + /// Total set-value bytes in the rejected batch. + bytes: u64, + /// Per-batch value-byte cap. + cap: u64, + }, } #[cfg(test)] diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 3e1fd159..886228fb 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -248,6 +248,134 @@ fn quota_counts_across_short_lived_handles_of_one_namespace() { assert!(matches!(err, StorageError::QuotaExceeded { .. })); } +// --------------------------------------------------------------------------- +// Atomic apply batches (#609). +// --------------------------------------------------------------------------- + +fn set_op(key: &str, value: &[u8]) -> WriteOp { + WriteOp::Set { + key: key.into(), + value: value.to_vec(), + } +} + +fn delete_op(key: &str) -> WriteOp { + WriteOp::Delete { key: key.into() } +} + +#[test] +fn apply_mixed_batch_commits_atomically() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + ms.set("stale", b"old").unwrap(); + ms.set("keep", b"as-is").unwrap(); + ms.apply(&[ + set_op("fresh", b"new"), + set_op("stale", b"overwritten"), + delete_op("keep"), + delete_op("missing"), + ]) + .unwrap(); + assert_eq!(ms.get("fresh").unwrap().as_deref(), Some(&b"new"[..])); + assert_eq!( + ms.get("stale").unwrap().as_deref(), + Some(&b"overwritten"[..]) + ); + assert!(ms.get("keep").unwrap().is_none()); + assert!(ms.get("missing").unwrap().is_none()); +} + +#[test] +fn apply_over_quota_batch_lands_nothing() { + let (_dir, store) = fresh(); + // Room for the seeded entry plus one small one, but not the batch's two. + let quota = cost("seed", b"v") + cost("a", b"1"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.set("seed", b"v").unwrap(); + let err = ms + .apply(&[set_op("a", b"1"), set_op("b", b"2")]) + .unwrap_err(); + match err { + StorageError::QuotaExceeded { needed, quota: q } => { + assert_eq!(needed, quota + cost("b", b"2")); + assert_eq!(q, quota); + } + other => panic!("expected QuotaExceeded, got {other:?}"), + } + // All-or-nothing: even the op that fit on its own must not have landed. + assert!(ms.get("a").unwrap().is_none()); + assert!(ms.get("b").unwrap().is_none()); + assert_eq!(ms.get("seed").unwrap().as_deref(), Some(&b"v"[..])); +} + +#[test] +fn apply_over_op_count_batch_rejected_untouched() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + let ops: Vec = (0..=MAX_APPLY_OPS) + .map(|i| set_op(&format!("k{i}"), b"v")) + .collect(); + let err = ms.apply(&ops).unwrap_err(); + match err { + StorageError::ApplyOpsExceeded { ops: n, cap } => { + assert_eq!(n, MAX_APPLY_OPS + 1); + assert_eq!(cap, MAX_APPLY_OPS); + } + other => panic!("expected ApplyOpsExceeded, got {other:?}"), + } + assert!(ms.get("k0").unwrap().is_none()); + assert_eq!(ms.count("").unwrap(), 0); +} + +#[test] +fn apply_over_value_bytes_batch_rejected_untouched() { + let (_dir, store) = fresh(); + let ms = store.module("m").unwrap(); + let big = vec![0u8; MAX_APPLY_VALUE_BYTES as usize]; + let err = ms + .apply(&[set_op("a", &big), set_op("b", b"1")]) + .unwrap_err(); + match err { + StorageError::ApplyBytesExceeded { bytes, cap } => { + assert_eq!(bytes, MAX_APPLY_VALUE_BYTES + 1); + assert_eq!(cap, MAX_APPLY_VALUE_BYTES); + } + other => panic!("expected ApplyBytesExceeded, got {other:?}"), + } + assert!(ms.get("a").unwrap().is_none()); + assert!(ms.get("b").unwrap().is_none()); +} + +#[test] +fn apply_quota_charges_net_batch_footprint() { + let (_dir, store) = fresh(); + // Quota holds exactly one entry: the set alone would bust it, but the + // batch's delete releases the seeded bytes first, so the net fits. + let quota = cost("old", b"12345"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.set("old", b"12345").unwrap(); + assert!(ms.set("new", b"12345").is_err()); + ms.apply(&[delete_op("old"), set_op("new", b"12345")]) + .unwrap(); + assert!(ms.get("old").unwrap().is_none()); + assert_eq!(ms.get("new").unwrap().as_deref(), Some(&b"12345"[..])); + // The counter carried the net footprint: a refill of the freed slot fits. + ms.apply(&[delete_op("new"), set_op("old", b"12345")]) + .unwrap(); +} + +#[test] +fn apply_quota_projects_the_last_op_per_key() { + let (_dir, store) = fresh(); + // The oversized first write on "k" is superseded within the batch; only + // the final small value is charged, so the batch fits a tight quota. + let quota = cost("k", b"ok"); + let ms = store.module("m").unwrap().with_quota(quota); + ms.apply(&[set_op("k", &vec![0u8; 1024]), set_op("k", b"ok")]) + .unwrap(); + assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"ok"[..])); +} + // --------------------------------------------------------------------------- // Concurrent access tests: real parallelism via the blocking pool. // --------------------------------------------------------------------------- diff --git a/crates/nexum-runtime/src/test_utils/store.rs b/crates/nexum-runtime/src/test_utils/store.rs index 7d8be449..edd3aa0b 100644 --- a/crates/nexum-runtime/src/test_utils/store.rs +++ b/crates/nexum-runtime/src/test_utils/store.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use crate::host::component::{StateHandle, StateStore}; -use crate::host::local_store_redb::StorageError; +use crate::host::local_store_redb::{MAX_APPLY_OPS, MAX_APPLY_VALUE_BYTES, StorageError, WriteOp}; type Namespaces = HashMap>>; @@ -115,4 +115,67 @@ impl StateHandle for MockStateHandle { keys.sort(); Ok(keys) } + + fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { + if ops.len() > MAX_APPLY_OPS { + return Err(StorageError::ApplyOpsExceeded { + ops: ops.len(), + cap: MAX_APPLY_OPS, + }); + } + let value_bytes: u64 = ops + .iter() + .map(|op| match op { + WriteOp::Set { value, .. } => value.len() as u64, + WriteOp::Delete { .. } => 0, + }) + .sum(); + if value_bytes > MAX_APPLY_VALUE_BYTES { + return Err(StorageError::ApplyBytesExceeded { + bytes: value_bytes, + cap: MAX_APPLY_VALUE_BYTES, + }); + } + let mut map = self.lock(); + let ns = map.entry(self.namespace.clone()).or_default(); + // Net whole-batch projection, checked once before any mutation so + // an over-quota batch lands nothing (the map mirrors one txn). + if let Some(quota) = self.quota_bytes { + let mut finals: HashMap<&str, Option> = HashMap::new(); + for op in ops { + match op { + WriteOp::Set { key, value } => finals.insert(key, Some(value.len())), + WriteOp::Delete { key } => finals.insert(key, None), + }; + } + let used: u64 = ns.iter().map(|(k, v)| (k.len() + v.len()) as u64).sum(); + let mut released = 0u64; + let mut charged = 0u64; + for (key, value_len) in &finals { + released += ns + .get(*key) + .map(|v| (key.len() + v.len()) as u64) + .unwrap_or(0); + charged += value_len.map(|len| (key.len() + len) as u64).unwrap_or(0); + } + let projected = used.saturating_sub(released) + charged; + if projected > quota { + return Err(StorageError::QuotaExceeded { + needed: projected, + quota, + }); + } + } + for op in ops { + match op { + WriteOp::Set { key, value } => { + ns.insert(key.clone(), value.clone()); + } + WriteOp::Delete { key } => { + ns.remove(key); + } + } + } + Ok(()) + } } diff --git a/wit/nexum-host/local-store.wit b/wit/nexum-host/local-store.wit index 8521b37a..814ef724 100644 --- a/wit/nexum-host/local-store.wit +++ b/wit/nexum-host/local-store.wit @@ -26,4 +26,22 @@ interface local-store { /// Number of keys matching a prefix, without materialising the key /// list. On some backends this may be a scan. count: func(prefix: string) -> result; + + /// One pair in an apply batch. + record key-value { + key: string, + value: list, + } + + /// One write in an apply batch. + variant write-op { + set(key-value), + delete(string), + } + + /// Apply a batch of writes atomically: every op lands or none + /// does. Later ops on a key supersede earlier ones. Quota is + /// charged on the net whole-batch footprint; the host caps the op + /// count and total value bytes per batch. + apply: func(ops: list) -> result<_, fault>; } From 2e5aef55f02ca77e385543d552d6846b3d512cb8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:06:20 +0000 Subject: [PATCH 110/139] venue: type the venue-error surface with invalid-receipt and receipt-mismatch (#597) The venue-error WIT variant gains two additive, venue-neutral cases for the representable adapter-detected conditions that #589 and #590 had shoved into invalid-body strings: invalid-receipt for an empty or structurally-invalid receipt, and receipt-mismatch for a venue-returned identifier that disagrees with the locally derived one. Both are threaded through the bindgen VenueError, the owned VenueFault mirror and the faults.rs conversions, and the two adapter call sites move off invalid-body onto them so a caller can branch without parsing a message. invalid-body and denied stay as the escape hatch for custom or unrepresentable errors. The change is a purely additive edit to videre:types with no version bump, kept extensible per the pre-cleave freeze policy, so it lands cheaply in the monorepo before the carve. Closes #591 --- crates/cow-venue/src/adapter.rs | 11 ++++------- crates/videre-host/src/bindings.rs | 2 ++ crates/videre-sdk/src/adapter.rs | 14 +++++++------- crates/videre-sdk/src/client.rs | 4 ++-- crates/videre-sdk/src/faults.rs | 17 +++++++++++++++-- crates/videre-sdk/src/keeper.rs | 4 +++- crates/videre-sdk/tests/adapter.rs | 6 +++--- wit/videre-types/types.wit | 4 ++++ 8 files changed, 40 insertions(+), 22 deletions(-) diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 8f30e462..018f7717 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -217,9 +217,7 @@ fn reconciled_uid( ) -> Result { let derived = assembly::order_uid(config.chain, order, owner); if server != derived { - return Err(VenueError::InvalidBody(format!( - "orderbook uid {server} disagrees with derived uid {derived}" - ))); + return Err(VenueError::ReceiptMismatch); } Ok(server) } @@ -230,8 +228,7 @@ pub(crate) fn status_with( config: &AdapterConfig, receipt: &[u8], ) -> Result { - let uid = OrderUid::try_from(receipt) - .map_err(|_| VenueError::InvalidBody("receipt is not a 56-byte order uid".to_owned()))?; + let uid = OrderUid::try_from(receipt).map_err(|_| VenueError::InvalidReceipt)?; let url = join(config, &format!("api/v1/orders/{uid}"))?; let response = call(fetch, http::Method::GET, url, None)?; if response.status() == http::StatusCode::NOT_FOUND { @@ -735,7 +732,7 @@ mod tests { assert!(matches!( submit_with(&fetch, &config, &signed_bytes()), - Err(VenueError::InvalidBody(detail)) if detail.contains("disagrees") + Err(VenueError::ReceiptMismatch) )); } @@ -903,7 +900,7 @@ mod tests { let fetch = MockFetch::default(); assert!(matches!( status_with(&fetch, &config, &[0xAB; 3]), - Err(VenueError::InvalidBody(_)) + Err(VenueError::InvalidReceipt) )); let uid = OrderUid([0xAB; 56]); diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index 06e2b887..3eea4883 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -93,6 +93,8 @@ pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> }, VenueError::Unavailable(detail) => Cow::Owned(format!("unavailable: {detail}")), VenueError::Timeout => Cow::Borrowed("timeout"), + VenueError::InvalidReceipt => Cow::Borrowed("invalid receipt"), + VenueError::ReceiptMismatch => Cow::Borrowed("receipt mismatch"), } } diff --git a/crates/videre-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs index f48067fe..28d4451b 100644 --- a/crates/videre-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -10,12 +10,12 @@ use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; -/// Reject an empty receipt as `invalid-body` before it reaches an +/// Reject an empty receipt as `invalid-receipt` before it reaches an /// adapter. Called by the export shim ahead of `status` and `cancel`. #[doc(hidden)] pub fn guard_receipt(receipt: &[u8]) -> Result<(), VenueError> { if receipt.is_empty() { - return Err(VenueError::InvalidBody("empty receipt".into())); + return Err(VenueError::InvalidReceipt); } Ok(()) } @@ -56,14 +56,14 @@ pub trait VenueAdapter { fn submit(body: Vec) -> Result; /// Report where a previously submitted intent is in its life. The - /// export shim rejects an empty receipt as `invalid-body` before + /// export shim rejects an empty receipt as `invalid-receipt` before /// dispatch. 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. The export shim rejects an empty receipt - /// as `invalid-body` before dispatch. + /// as `invalid-receipt` before dispatch. fn cancel(receipt: Vec) -> Result<(), VenueError>; } @@ -135,10 +135,10 @@ mod tests { use super::*; #[test] - fn empty_receipt_is_rejected_as_invalid_body() { + fn empty_receipt_is_rejected_as_invalid_receipt() { match guard_receipt(&[]).unwrap_err() { - VenueError::InvalidBody(detail) => assert_eq!(detail, "empty receipt"), - other => panic!("expected invalid-body, got {other:?}"), + VenueError::InvalidReceipt => {} + other => panic!("expected invalid-receipt, got {other:?}"), } guard_receipt(&[1]).unwrap(); } diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index bbc8cc0c..cc87a19b 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -290,14 +290,14 @@ impl VenueClient { } /// Report where a previously submitted intent is in its life. - /// Rejects an empty receipt as `invalid-body` before the wire. + /// Rejects an empty receipt as `invalid-receipt` before the wire. pub async fn status(&self, receipt: &[u8]) -> Result { crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; Ok(self.transport.status(&V::ID, receipt).await?) } /// Ask the bound venue to withdraw an intent. Rejects an empty - /// receipt as `invalid-body` before the wire. + /// receipt as `invalid-receipt` before the wire. pub async fn cancel(&self, receipt: &[u8]) -> Result<(), ClientError> { crate::adapter::guard_receipt(receipt).map_err(VenueFault::from)?; Ok(self.transport.cancel(&V::ID, receipt).await?) diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index ac57203f..a4516a55 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -51,6 +51,13 @@ pub enum VenueFault { /// The call timed out. #[error("timeout")] Timeout, + /// The receipt is empty or structurally invalid. + #[error("invalid receipt")] + InvalidReceipt, + /// The venue-returned identifier disagrees with the locally derived + /// one. + #[error("receipt mismatch")] + ReceiptMismatch, } /// Lift the wire error into the owned mirror. Exhaustive: the wire enum @@ -67,6 +74,8 @@ impl From for VenueFault { }, VenueError::Unavailable(s) => Self::Unavailable(s), VenueError::Timeout => Self::Timeout, + VenueError::InvalidReceipt => Self::InvalidReceipt, + VenueError::ReceiptMismatch => Self::ReceiptMismatch, } } } @@ -132,8 +141,10 @@ impl From for VenueError { } /// Fold a typed client failure into the SDK-neutral fault a keeper -/// handler returns: an encode failure and a misnamed venue are the -/// caller's `invalid-input`; venue refusals map structurally. +/// handler returns: an encode failure, a misnamed venue, and an invalid +/// receipt are the caller's `invalid-input`; a receipt mismatch is +/// `internal` (a venue integrity failure, not the caller's); other venue +/// refusals map structurally. impl From for host::Fault { fn from(err: ClientError) -> Self { match err { @@ -148,6 +159,8 @@ impl From for host::Fault { } VenueFault::Unavailable(s) => host::Fault::Unavailable(s), VenueFault::Timeout => host::Fault::Timeout, + VenueFault::InvalidReceipt => host::Fault::InvalidInput(fault.to_string()), + VenueFault::ReceiptMismatch => host::Fault::Internal(fault.to_string()), }, } } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 084d2959..b066e0fc 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -350,7 +350,9 @@ pub fn retry_action(fault: &VenueFault) -> RetryAction { VenueFault::UnknownVenue | VenueFault::InvalidBody(_) | VenueFault::Unsupported - | VenueFault::Denied(_) => RetryAction::Drop, + | VenueFault::Denied(_) + | VenueFault::InvalidReceipt + | VenueFault::ReceiptMismatch => RetryAction::Drop, } } diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index fd324f0d..f50b108f 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -309,16 +309,16 @@ fn quote_typestate_prices_then_submits_the_quoted_body() { #[test] fn empty_receipt_is_rejected_before_the_transport() { - // The unbound venue would report unknown-venue, so invalid-body + // The unbound venue would report unknown-venue, so invalid-receipt // proves the guard fires before the transport is consulted. let client = VenueClient::::with_transport(InProcessClient); assert!(matches!( run(client.status(&[])).unwrap_err(), - ClientError::Venue(VenueFault::InvalidBody(detail)) if detail == "empty receipt" + ClientError::Venue(VenueFault::InvalidReceipt) )); assert!(matches!( run(client.cancel(&[])).unwrap_err(), - ClientError::Venue(VenueFault::InvalidBody(detail)) if detail == "empty receipt" + ClientError::Venue(VenueFault::InvalidReceipt) )); } diff --git a/wit/videre-types/types.wit b/wit/videre-types/types.wit index e8b69c81..d77d5f61 100644 --- a/wit/videre-types/types.wit +++ b/wit/videre-types/types.wit @@ -67,6 +67,10 @@ interface types { rate-limited(rate-limit), unavailable(string), timeout, + /// An empty or structurally invalid receipt. + invalid-receipt, + /// The venue-returned identifier disagrees with the locally derived one. + receipt-mismatch, } record rate-limit { From 7095a5b22fab24dd3b43d26afd0bb46e22e70f30 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:07:13 +0000 Subject: [PATCH 111/139] docs: trim adrs to decision plus status (#599) Cut the narrative, considered-option essays, and version-scheduling prose from the eleven ADRs down to context, decision, and load-bearing consequences per the #598 house standard. Update stale vocabulary to the shipped shape: nexum-runtime/nexum-cli crate names, nexum-sdk and nexum-sdk-test, the composable-cow crate path, and the current nullislabs/cow-rs patch channel. Mark 0005 and 0006 superseded by the videre venue-adapter architecture, note 0009's error envelope superseded by 0011, and make 0013's status honest: the Verdict seam has shipped but LegacyRevertAdapter is the live poll path until the fork deploys. Part of #598. --- ...01-engine-toml-separate-from-nexum-toml.md | 32 ++---- .../0002-provider-pool-transport-by-scheme.md | 31 ++---- docs/adr/0003-local-store-namespacing.md | 43 ++------ .../0004-patch-cowprotocol-to-bleu-cow-rs.md | 39 ++------ .../0005-cow-api-via-cached-orderbookapi.md | 33 +------ .../adr/0006-cow-twap-ethflow-host-helpers.md | 50 ++-------- .../0007-upstream-protocol-logic-to-cow-rs.md | 43 ++------ .../0008-factory-subscriptions-in-manifest.md | 52 +++------- docs/adr/0009-host-trait-surface.md | 98 ++----------------- docs/adr/0011-per-interface-typed-errors.md | 20 ++-- .../0013-composable-cow-structured-poll.md | 43 +++----- 11 files changed, 99 insertions(+), 385 deletions(-) diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 5dee12b9..11f584b1 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -1,38 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Operator config (`engine.toml`) is separate from module manifest (`module.toml`) ## Context -The engine needs two distinct kinds of configuration: what the **operator** decides at deployment time (which chains to connect to, where the local-store database lives, which modules to boot) and what the **module developer** declares at build time (required and optional capabilities, HTTP allowlist, module-specific config keys). These have different reviewers, different threat models, and change on different cadences. - -The filenames need to signal who owns each file directly. An operator opening a config file should know without prior context whether the file is their concern or the module developer's. A name like `nexum.toml` requires the reader to know that "nexum" refers to the runtime that hosts the module, which is one indirection too many; `module.toml` reads as "the module's manifest" with no prior context. +The runtime carries two kinds of configuration with different owners, reviewers, and change cadences: what the operator decides at deployment time (chains, local-store location, which modules to boot) and what the module developer declares at build time (required and optional capabilities, HTTP allowlist, module config keys). A module's capability declaration is a property of the build, so it belongs in the published bundle, not in the operator's local file. ## Decision -Two distinct files, distinct schemas, distinct loaders: - -- **`engine.toml`** - operator-owned, lives next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (state_dir, log_level), `[chains.]` (rpc_url), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. -- **`module.toml`** - module-developer-owned, ships in the module's bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. +Two files, two schemas, two loaders: -The engine config carries the path to each module's manifest; the two never collapse into one file. The names `engine.toml` and `module.toml` map directly onto the two distinct roles, so a reader reaching either file knows whose concerns it covers. +- **`engine.toml`** operator-owned, next to the engine binary or pointed to by `--engine-config`. Defines `[engine]` (`state_dir`, `log_level`), `[chains.]` (`rpc_url`), and `[[modules]]` (path, manifest). Loaded by `engine_config::EngineConfig::load`. +- **`module.toml`** module-developer-owned, ships in the module bundle alongside its `.wasm` component. Defines `[module]`, `[capabilities]` (required, optional, http allowlist), `[config]`. Loaded by `manifest::load`. -## Considered options - -- **Single `shepherd.toml` with `[engine]`, `[chains]`, `[[modules]]` *and* nested `[modules..capabilities]` per module.** Rejected: conflates operator and developer concerns. A module's capability declaration is a property of the build, not the deployment - it belongs in the artifact, not in the operator's local file. Auditing a module's capabilities also becomes a per-deployment exercise instead of a property visible in the published bundle. -- **Keep the `nexum.toml` filename for the module manifest.** Rejected: the name does not signal who owns the file (engine vs module). `module.toml` reads as "the module's manifest" without prior context. -- **`module.toml` inside the engine config (module entries embed it inline).** Rejected for the same reason as the single-file proposal; also bloats `engine.toml`. -- **Drop `engine.toml` entirely; pass everything as CLI flags or env vars.** Rejected: per-chain RPC URLs and module lists are awkward as flags, and `RUST_LOG` already covers the only thing that env vars naturally express. +The engine config carries each module's manifest path; the two files never collapse into one. ## Consequences -- A deployment needs both files. A missing `engine.toml` falls back to "no chains, default state_dir" - the example logging module still runs; cow-api / chain backends report `unsupported`. -- A missing `module.toml` triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest()` (defined in `crates/nexum-engine/src/manifest.rs`) and treats every linked capability as required. This fallback is scheduled for removal in 0.3. -- Module-bundle redistribution carries `module.toml` with the artifact; engines do not need to ship templates. -- Future content-addressed module distribution (0.3) embeds `module.toml` in the bundle hash; `engine.toml` references modules by content address rather than filesystem path. The split survives that migration unchanged. -- Implementation impact: `crates/nexum-engine/src/manifest.rs` and `engine_config.rs` need to update the filename lookup from `nexum.toml` to `module.toml`. The 0.1-compat fallback in `manifest::fallback_manifest()` should accept both names during the transition; after 0.3 only `module.toml` is recognised. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- A deployment needs both files. A missing `engine.toml` falls back to no chains and the default `state_dir` (`./data`); the example logging module still runs, chain-backed capabilities report `unsupported`. +- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required. +- Module-bundle redistribution carries `module.toml` with the artifact; engines ship no templates. diff --git a/docs/adr/0002-provider-pool-transport-by-scheme.md b/docs/adr/0002-provider-pool-transport-by-scheme.md index 0cecbb5c..1e0e924b 100644 --- a/docs/adr/0002-provider-pool-transport-by-scheme.md +++ b/docs/adr/0002-provider-pool-transport-by-scheme.md @@ -1,39 +1,28 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8, nullislabs/shepherd#9 +status: accepted --- # Per-chain alloy provider transport selected by URL scheme ## Context -`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport (`eth_subscribe` is push-only over a long-lived connection); request/response works on either HTTP or WebSocket. The operator configures one RPC endpoint per chain in `engine.toml`; the engine has to decide which alloy transport to use. +`nexum:host/chain` covers both generic JSON-RPC dispatch (`request`) and event subscriptions (`subscribe-blocks`, `subscribe-logs`). Subscriptions require a duplex transport; request/response works on either HTTP or WebSocket. The operator configures one `rpc_url` per chain in `engine.toml`, and the runtime picks the alloy transport from it. ## Decision -The `ProviderPool::from_config` constructor reads each chain's `rpc_url` and switches by URL scheme prefix: +`ProviderPool::from_config` switches on the URL scheme: -- `ws://` or `wss://` → `ProviderBuilder::new().connect_ws(WsConnect::new(url))`. Pubsub transport. Subscriptions and request/response both work. **This is the recommended configuration for any chain a module subscribes to.** -- `http://` or `https://` → `ProviderBuilder::new().connect_http(parsed)`. HTTP transport. Request/response only; `subscribe-blocks` and `subscribe-logs` surface as `fault.unsupported` to the guest. +- `ws://` / `wss://` connect via `connect_ws`. Pubsub transport: subscriptions and request/response both work. Recommended for any chain a module subscribes to. +- `http://` / `https://` connect via `connect_http`. Request/response only; `subscribe-blocks` and `subscribe-logs` return `fault.unsupported` to the guest. -Both transports erase to `DynProvider` so the rest of the engine is transport-agnostic. - -Alloy is capable of emulating `eth_subscribe` on HTTP via polling, but this is intentionally **not** enabled. The engine takes an opinionated stance favouring WebSockets for subscriptions; operators who want push-based events configure WSS endpoints. HTTP-only chains are supported for `request` traffic but not for subscriptions. +Both erase to `DynProvider`, so the rest of the runtime is transport-agnostic. Alloy can emulate `eth_subscribe` on HTTP by polling; this is deliberately not enabled. ## Non-goals -- **RPC failover, load balancing, and retry policies are explicitly out of scope for the engine.** This logic lives in upstream crates (alloy ships tower-style middleware for timeout / retry / rate-limit / fallback endpoint). The engine does not roll its own. Operators wanting failover configure it via alloy provider builders before passing them through, or rely on the provider's own fallback (Alchemy, Infura, etc. handle it server-side). -- Re-routing requests across chains, rebalancing across pools within a chain, and similar provider-management concerns are likewise alloy's responsibility. - -## Considered options - -- **Force WSS everywhere.** Rejected: many providers (Alchemy, Infura, self-hosted RPC) expose HTTP-only on free tiers, and modules that only need `request` (no subscriptions) shouldn't be blocked by a WSS requirement. -- **Explicit `transport = "ws" | "http"` field per chain in `engine.toml`.** Rejected for 0.2: redundant with the URL scheme, and operators already distinguish `wss://` from `https://` endpoints when copying them from their RPC provider's dashboard. Revisit if we add IPC (`/path/to/geth.ipc`) - scheme alone won't carry that. -- **Open both an HTTP and a WSS connection per chain.** Rejected: doubles connection count for the common case where one endpoint serves both, and forces operators to provide two URLs even when their provider returns identical data on both. +RPC failover, load balancing, and retry policy are out of scope. Alloy ships tower-style middleware for timeout, retry, rate-limit, and fallback endpoints; operators configure it on the provider builder, or rely on their provider's server-side fallback. ## Consequences -- Operators that need subscriptions must supply WSS URLs; HTTP-only chains downgrade to request-only mode at the host call boundary. -- Connection failures at boot are fatal (the engine refuses to start with a broken chain). This is intentional - silent fall-back to a half-functioning state masks misconfiguration that a module then rediscovers at first event. -- Adding IPC support is additive: extend the scheme match with `/` / `file://` and call `connect_ipc`. -- The `DynProvider` erasure costs a virtual dispatch per call - a measurable concern at scale, deferred to M4 if profiling shows it. +- Operators needing subscriptions supply WSS URLs; HTTP-only chains downgrade to request-only at the host call boundary. +- Connection failure at boot is fatal: the runtime refuses to start with a broken chain rather than masking misconfiguration a module rediscovers at first event. +- Adding IPC is additive: extend the scheme match with `file://` and call `connect_ipc`. diff --git a/docs/adr/0003-local-store-namespacing.md b/docs/adr/0003-local-store-namespacing.md index a7a5ac12..8bfe1f87 100644 --- a/docs/adr/0003-local-store-namespacing.md +++ b/docs/adr/0003-local-store-namespacing.md @@ -1,49 +1,24 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#8 +status: accepted --- # Per-module namespacing in `local-store` via 32-byte deterministic hash prefix ## Context -`nexum:host/local-store` is a key-value store shared across all modules the engine runs. Two modules using the same key string (e.g. `"last-block"`) must see disjoint values; one module must never read or overwrite another's data. The engine knows each module's identity at instantiation time, so namespacing is a host-side concern. - -Two properties matter for the namespace prefix: - -1. **Deterministic and unspoofable.** An arbitrary `module_name` string read out of `module.toml` lets a malicious or careless operator give two modules the same name and have one read the other's state. A fixed-size hash derived from the module's canonical identity is harder to collide and removes the operator-supplied-text attack surface. -2. **Composes with ENS-based module discovery** (per `docs/03-module-discovery.md`): when a module is identified by an ENS name (e.g. `twap-monitor.shepherd.eth`), the ENS namehash is a natural prefix. ENS TXT records pinning the `.wasm` content hash provide a separate verification path against the loaded bundle. +`nexum:host/local-store` is a key-value store shared across every module the runtime runs. Two modules using the same key string must see disjoint values, and one module must never read or overwrite another's data. The runtime knows each module's identity at instantiation, so namespacing is a host-side concern. The prefix must be deterministic and unspoofable: an operator-supplied `module_name` string would let two modules collide by name, so the prefix derives from the module's canonical identity as a fixed-size hash. ## Decision -Single redb database file at `EngineConfig.engine.state_dir`, single shared table `nexum:local-store`. Every key handed to redb is composed host-side as: - -``` -[32-byte namespace prefix][raw key bytes] -``` - -The 32-byte prefix is computed deterministically from the module's canonical identity: - -- **ENS-identified modules** (M3+, per `docs/03`): prefix is `ens_namehash(name)` (EIP-137), e.g. `namehash("twap-monitor.shepherd.eth")`. -- **Locally-loaded modules** (current 0.2 scope, no ENS): prefix is `keccak256(module_name)` where `module_name` comes from `module.toml`'s `[module].name` field. - -Both produce a 32-byte digest with the same domain, so a module loaded locally during development and later published under an ENS name can keep its existing state by registering an alias (`alias = keccak256(name)`) the engine recognises during the migration window. The exact alias mechanism is out of scope for this ADR. - -Modules see plain key strings on both the read and write paths; the prefix is invisible to the WIT-facing API. +Single redb database file at `EngineConfig.engine.state_dir`, single shared table. Every key handed to redb is composed host-side as `[32-byte namespace prefix][raw key bytes]`. -## Considered options +The prefix is `keccak256(module_name)`, where `module_name` is `module.toml`'s `[module].name`. keccak256 shares the domain of the ENS namehash, so a module loaded locally and later published under an ENS name (see `docs/03-module-discovery.md`) can keep its state via an alias registered during migration; the alias mechanism is out of scope here. -- **Separator string** (`{module}:{key}`). Rejected: any module name containing `:` collides with another module's `:`-bearing key. A fixed-size hash is unambiguous regardless of payload bytes. -- **`[len:u8][module_name][key]` length-prefixed string.** Rejected: spoofable (the name is operator-supplied text), and does not align with the ENS-based discovery path that 0.3 will introduce. The 32-byte hash is deterministic and namespace-uniform. -- **One redb database file per module.** Rejected: multiplies open file handles linearly in modules, blocks any future cross-module atomic operations (not currently planned but cheap to keep on the table), and complicates backup tooling (N files vs 1). -- **One redb *table* per module within a single file.** Rejected: redb `TableDefinition` lifetimes are `'static`, so table names must be known at compile time. Dynamic table opening per module would force string-leak workarounds and exposes the same name-collision question as separator-based keys. -- **Engine-allocated incrementing module id.** Rejected: stable across reboots only if the engine persists the allocation table, which adds a chicken-and-egg dependency on the local-store itself. Determinism from the name avoids the dependency entirely. +Modules see plain key strings on both paths; the prefix is invisible to the WIT API. ## Consequences -- The prefix is fixed-size (32 bytes) and independent of module name length. Range scans over a single module's keys are O(log n + module-key-count) - fine for our workload. -- Migrations changing the prefix derivation (e.g., switching the local-mode hash function or the ENS resolver) would orphan every existing module's persisted state. The derivation must stay stable through 0.x; ENS-mode introduction in 0.3 happens additively via the alias mechanism, not by changing existing prefixes. -- A module's `list-keys` iterates over the namespace range (32-byte prefix scan); the host strips the prefix before returning to the guest. -- Module data versioning (schema migrations across module versions) is the module's responsibility. The local-store does not version values; modules MAY embed a `schema_version` byte in their stored payloads and migrate on `init` when the read value's version differs from the current code's expectation. -- ENS-based discovery (per docs/03) integrates without a prefix-format change: when a module is loaded by ENS name, the prefix is `namehash(name)`. The corresponding `.wasm` content hash is verified via ENS TXT records before loading, separately from the local-store prefix derivation. -- Spoofing protection: an operator cannot make module A read module B's state by renaming, because the prefix is the hash of the canonical name. Renaming a module to match another's name produces a name conflict the engine refuses at boot, rather than silent state takeover. +- The prefix is fixed-size and independent of key length. A module's `list-keys` iterates the 32-byte prefix range; the host strips the prefix before returning to the guest. +- Changing the prefix derivation would orphan every module's persisted state, so the derivation stays stable through 0.x; ENS-mode namespacing is introduced additively via the alias mechanism, not by changing existing prefixes. +- The store does not version values. Modules that need schema migration embed their own version marker in stored payloads and migrate on `init`. +- An operator cannot make module A read module B's state by renaming: matching names produces a boot-time conflict, not a silent state takeover. 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 c78d14c6..2b587b29 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -1,44 +1,21 @@ --- -status: proposed -implemented-in: nullislabs/shepherd#10 +status: accepted --- -# Patch `cowprotocol` crate to the head of upstream PR #5 +# Patch the `cowprotocol` crate to a maintained fork ## Context -`cowprotocol` v1.0.0-alpha.3 (the version on crates.io) was cut from an early snapshot of `cowdao-grants/cow-rs` PR #5 at commit `1742ffa`. That PR is still open and is the canonical upstream channel for landing additions to the Rust SDK. Its head branch is `bleu/cow-rs:main`, currently at commit `c012404`, carrying 18 follow-up commits the engine materially depends on: - -- `composable::Proof` byte-width fix (consumed by the TWAP poll path). -- `OrderCreation` zero-`from` fast-fail (closes a MEDIUM severity finding in PR #5). -- `order_book` / `composable` submodule splits (cleaner imports on the engine side). - -ADR-0007 commits us to landing three protocol-level primitives into PR #5 directly (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, and `wasm32` feature-gating) by pushing additional commits to its head branch. Each commit advances both PR #5 and the patch rev consumed here. - -There is no published `alpha.4` and no scheduled date for one; the engine cannot wait. +The workspace needs `cowprotocol` changes ahead of any published release: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and a WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) carries neither. ## Decision -Add a workspace-level `[patch.crates-io]` redirecting `cowprotocol` to `https://github.com/bleu/cow-rs` at commit `c012404`. Every crate that declares `cowprotocol = "1.0.0-alpha.3"` (engine, modules, future SDK) silently picks up the patched build with no `Cargo.toml` change at the dependent site. - -This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream PR #5. Pushing to it updates PR #5; the patch rev advances by bumping a single workspace line. +A single workspace-level `[patch.crates-io]` redirects `cowprotocol` to `https://github.com/nullislabs/cow-rs` at a pinned rev. Every crate declaring `cowprotocol` picks up the patched build with no change at the dependent site. Bumping the fork is a one-line rev edit. -## Considered options - -- **Vendor the missing types locally.** Rejected: re-implementing `composable::Proof`, `OrderCreation`, etc. in the engine repo is the AI-duplication anti-pattern that the cow-rs SDK already solves. Reuse over reimplement applies. -- **Pin every dependent to `cow-rs` git directly.** Works but every new workspace member has to remember the git source. `[patch.crates-io]` centralises the override. -- **Open a separate PR per primitive against `cowdao-grants/cow-rs`.** Rejected: fragments the change across multiple PRs when one already exists at the appropriate granularity. Stacking commits on PR #5 keeps the change coherent and lets the cumulative diff be tracked in one place. -- **Wait for `alpha.4` to publish.** No ETA; the TWAP/EthFlow milestone cannot land without `composable::Proof` correct. +Rather than vendor the missing types locally (reuse over reimplement) or pin each dependent to a git source, the `[patch.crates-io]` override centralises the redirect. ## Consequences -- `cargo update` will re-resolve to the same `rev`; the lock pins it. -- 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. +- `cargo update` re-resolves to the same rev; the lock pins it. +- Drop the patch once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the root `Cargo.toml` states the current drop condition. +- Modules built standalone against crates.io see the unpatched release and may hit the bugs the patch closes. diff --git a/docs/adr/0005-cow-api-via-cached-orderbookapi.md b/docs/adr/0005-cow-api-via-cached-orderbookapi.md index 6586e26a..0c5a570e 100644 --- a/docs/adr/0005-cow-api-via-cached-orderbookapi.md +++ b/docs/adr/0005-cow-api-via-cached-orderbookapi.md @@ -1,40 +1,15 @@ --- status: superseded -implemented-in: nullislabs/shepherd#8 --- # `cow-api` host backend routes both `request` and `submit-order` through `cowprotocol::OrderBookApi` -> **Superseded by the videre venue-adapter architecture.** The -> `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend -> are retired: orderbook submission and status ride the `cow-venue` -> adapter component over `wasi:http`, driven through the -> `videre:venue/client` pool seam. +> **Superseded by the videre venue-adapter architecture.** The `shepherd:cow/cow-api` host extension and its `OrderBookApi` backend are retired: orderbook submission and status ride the `cow-venue` adapter component over `wasi:http`, driven through the `videre:venue/client` pool seam. ## Context -`shepherd:cow/cow-api` exposes two operations: a generic REST passthrough (`request`) and a typed order submission (`submit-order`). Either could be implemented with raw `reqwest` against `api.cow.fi/{slug}/api/v1`, but the published `cowprotocol` crate already ships an `OrderBookApi` client that knows the chain-specific base URL, the canonical paths, and the `post_order` codec. +`shepherd:cow/cow-api` exposed a generic REST passthrough (`request`) and a typed order submission (`submit-order`). The `cowprotocol` crate already shipped an `OrderBookApi` client that knew the chain base URL, canonical paths, and `post_order` codec. -## Decision +## Decision (retired) -At engine boot, construct one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant (currently Mainnet, Gnosis, Sepolia, ArbitrumOne, Base) into a `BTreeMap` keyed by EVM chain id. "Cached" here means built once during boot and reused for the engine's lifetime; clients are not lazy-constructed on each call nor LRU-evicted. The pool implements `Default` so callers instantiate it as `OrderBookPool::default()`; the trait impl populates the map with one entry per `cowprotocol::Chain` variant. - -Both `cow-api` operations consult this pool: - -- `request` resolves the chain's `OrderBookApi`, reads `api.base_url()` for the prefix, joins the module-supplied path, and dispatches via a shared `reqwest::Client`. -- `submit-order` deserialises the JSON `OrderCreation` and calls `OrderBookApi::post_order` directly. The crate handles signing-scheme encoding, error mapping, and `OrderUid` extraction. - -Chains not in `cowprotocol::Chain` return `cow-api-error` carrying `fault.unsupported` at the host call boundary. - -## Considered options - -- **Raw `reqwest` for both.** Rejected: forces us to maintain the chain → base-URL table (drifts whenever cowprotocol adds a chain) and reimplement `post_order`'s body codec and error mapping, the exact duplication the cow-rs SDK already eliminates. -- **`OrderBookApi` for `submit-order`, raw `reqwest` for `request`.** Tempting (request is opaque to the crate) but means two separate chain-resolution paths, two HTTP clients, and a second place to keep the chain set in sync. -- **Build `OrderBookApi` lazily on first call per chain.** Rejected: hides config errors at runtime. Up-front boot construction surfaces unknown chains immediately and amortises away the per-call cost. - -## Consequences - -- Operator-supplied custom orderbook URLs (barn, staging, forked deployments) are out of scope for the default constructor and require a follow-on `OrderBookApi::with_base_url(chain_id, base_url)` constructor in the cow-rs crate (ADR-0007 item 2, not vendored locally). -- Adding a chain means a `cowprotocol::Chain` variant lands in cow-rs first; the engine inherits it on the next patched rev bump. -- The shared `reqwest::Client` enables connection pooling across both `request` and `submit-order` paths. -- Guest-side TWAP and EthFlow modules (ADR-0006) submit orders through this `cow-api` interface; no specialised host helpers wrap it. +At boot, build one `cowprotocol::OrderBookApi` per `cowprotocol::Chain` variant into a `BTreeMap` keyed by chain id, reused for the runtime's lifetime. `request` resolved the chain client and joined the module-supplied path; `submit-order` deserialized the JSON `OrderCreation` and called `OrderBookApi::post_order`. Chains outside `cowprotocol::Chain` returned `fault.unsupported`. diff --git a/docs/adr/0006-cow-twap-ethflow-host-helpers.md b/docs/adr/0006-cow-twap-ethflow-host-helpers.md index 43600c13..d59df80f 100644 --- a/docs/adr/0006-cow-twap-ethflow-host-helpers.md +++ b/docs/adr/0006-cow-twap-ethflow-host-helpers.md @@ -2,56 +2,20 @@ status: superseded --- -# TWAP and EthFlow run as guest modules using low-level host primitives (no specialised `shepherd:cow` interfaces) +# TWAP and EthFlow run as guest modules using low-level host primitives -> **Superseded by the videre venue-adapter architecture.** The -> strategies-as-guest-modules line holds, but the protocol seam it -> assigned to `shepherd:cow/cow-api` is retired: modules submit typed -> intent bodies through the `videre:venue/client` pool seam and the -> `cow-venue` adapter owns the orderbook edge. +> **Superseded by the videre venue-adapter architecture.** The strategies-as-guest-modules line holds, but the protocol seam it assigned to `shepherd:cow/cow-api` is retired: modules submit typed intent bodies through the `videre:venue/client` pool seam, and the `cow-venue` adapter owns the orderbook edge. ## Context -TWAP (over ComposableCoW) and EthFlow are the two CoW workflows the M2 grant ships modules for. The natural-seeming approach is to add `shepherd:cow/twap` and `shepherd:cow/ethflow` WIT interfaces that the host implements on top of `cowprotocol` crate primitives, so modules would call `twap.poll-and-submit(...)` and `ethflow.submit-from-log(...)` as host functions. This ADR rejects that direction. - -The dividing line is protocol vs implementation. CoW Protocol primitives - order types, signing schemes, the orderbook REST surface - are protocol concerns and belong in shared layers (`cowprotocol` crate, `shepherd:cow/cow-api` interface). TWAP is one of many strategies built _on top of_ those primitives; ComposableCoW is the contract surface a TWAP module observes, but the act of polling, deciding when to submit, and reacting to orderbook errors is application logic. Putting that application logic in the host or in `cowprotocol` couples every consumer to one implementation and one error-handling policy. - -Embedding a concrete TWAP implementation in an SDK is an architectural smell the grant explicitly seeks to alleviate. The grant seeks to enable Shepherd as the runtime where many independent strategy implementations coexist, each compiled to its own WASM module. A specialised `twap` interface in the host would defeat that goal: every Shepherd deployment would have to use the same polling implementation, the same error-mapping, the same retry hints, with no room for different strategies to differ on those choices. +TWAP (over ComposableCoW) and EthFlow are strategies built on top of CoW Protocol primitives, not protocol concerns themselves. The dividing line is protocol vs implementation: order types, signing schemes, and the orderbook surface belong in shared layers; polling, submit timing, and error reactions are application logic. Putting that logic in the host or in `cowprotocol` would force every deployment onto one implementation and one error-handling policy. ## Decision -The `shepherd:cow` WIT package contains only the existing `cow-api` interface (REST passthrough + `submit-order`), which is protocol-level. No `twap` interface, no `ethflow` interface, no host-side helpers specific to either workflow. - -TWAP and EthFlow modules implement their logic in Rust guest code using: - -- **`nexum:host/chain`** - `request` (for `eth_call`, `eth_getLogs`, etc.), `subscribe-blocks`, `subscribe-logs`. -- **`nexum:host/local-store`** - for watch lists, cursors, and backoff state. -- **`nexum:host/logging`** - for structured logs. -- **`shepherd:cow/cow-api`** - `submit-order` for orderbook submission. -- **`cowprotocol` crate** (consumed directly by the module, gated on the wasm32 feature work in ADR-0007) - for protocol types: `Order`, `OrderCreation`, `OrderUid`, signing schemes, `OrderPostError`, etc. -- **`alloy_sol_types`** (or equivalent) - for ABI-aware decoding of `ConditionalOrderCreated`, `OrderPlacement`, `getTradeableOrderWithSignature` return values, and similar Solidity-typed payloads. - -Concretely, a TWAP module's `on_event(block)` handler iterates the local-store watch set, makes an `eth_call` to `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])` via `chain.request`, decodes the return (or revert reason) with `alloy_sol_types`, constructs an `OrderCreation` with `cowprotocol` types, and submits via `cow-api/submit-order`. Orderbook errors are interpreted via `OrderPostError::retry_hint()` (ADR-0007). Backoff state is persisted to `local-store`. All of this lives in module Rust source, not in the engine. - -An EthFlow module's `on_event(log)` handler decodes the `OrderPlacement` event with `alloy_sol_types`, constructs the `OrderCreation` (with the EIP-1271 signing scheme pointing at the `CoWSwapEthFlow` contract), and submits the same way. Module-side, no host helper required. - -## Considered options - -- **Specialised `shepherd:cow/twap` and `shepherd:cow/ethflow` interfaces** with rich `PollOutcome` variants and per-event host helpers, backed by `composable::poll_and_build_order` and `eth_flow::decode_placement` primitives in the `cowprotocol` crate. Rejected: this puts a single concrete TWAP / EthFlow implementation behind a WIT boundary, forcing every Shepherd deployment to use the same polling policy, the same error-mapping, the same retry hints. It also blurs the protocol-vs-implementation boundary the grant is meant to clarify. Multiple TWAP implementations (different polling cadences, different error tolerances, different cancel-on-loss thresholds) must be able to coexist as separate modules without changing the host or the SDK. -- **Move TWAP / EthFlow primitives into `cowprotocol` crate but skip the WIT interfaces**, leaving modules to call `composable::poll_and_build_order` from guest code. Rejected for the same reason: `cowprotocol` is the protocol SDK, not the strategy SDK. Putting TWAP logic there embeds an implementation in the shared layer, which is the smell the grant seeks to fix. -- **Ship a thin `shepherd-sdk` helper crate** that wraps the low-level primitive calls (eth_call, decode, submit) into a convenient `Twap::poll(...)` interface for guest modules. **Acceptable for M3** because the helper would live in guest-callable code, not behind a WIT boundary - a module that wants different polling policy just doesn't use the SDK helper. The host stays neutral. -- **EthFlow as pure passive observer (no submission)**. Rejected on closer read of `cowprotocol/services/crates/autopilot/src/database/onchain_order_events/ethflow_events.rs`: the canonical CoW flow expects the event to be relayed into the orderbook, which is what autopilot currently does internally. Shepherd's `ethflow-watcher` externalises that role, so the module does submit; just from guest code, not via a specialised host interface. -- **TWAP merkle-proof / `setRoot` support in v1.** Deferred. The 0.2 module only handles `ComposableCoW.create()` (empty proof, single conditional order). `setRoot` polling requires off-chain proof derivation; when a real module needs it, it will be implemented in guest code using the same low-level primitives, possibly with an SDK helper to encapsulate the proof bookkeeping. +The host stays protocol-neutral: no `twap` or `ethflow` host interface. TWAP and EthFlow modules implement their logic in guest Rust over the universal host primitives (`chain`, `local-store`, `logging`) plus the venue submit seam, using `cowprotocol` crate types for `Order` / `OrderCreation` / `OrderUid` / signing schemes and `alloy_sol_types` for ABI decoding. Different polling strategies coexist as separate modules chosen via `engine.toml`'s `[[modules]]`. ## Consequences -- `shepherd:cow@0.1.0` keeps `cow-api` as its only interface. No new WIT files in this ADR. -- `KNOWN_CAPABILITIES` in `crates/nexum-engine/src/manifest.rs` does **not** gain `"twap"` or `"ethflow"` entries. Modules declare the universal capabilities they actually use: `chain`, `local-store`, `logging`, `cow-api`. -- Modules ship larger (~150 LOC each estimated, up from the ~30 LOC the host-helper design implied), because event decoding, eth_call orchestration, OrderCreation construction, and error-hint interpretation now live in guest code. This is the explicit trade-off: more code per module, less coupling, more freedom for different strategies to coexist. -- Different TWAP polling strategies can coexist as different modules. Operators choose which to load via `engine.toml`'s `[[modules]]` array. -- The watch-tower TypeScript implementation remains the closest reference for what a TWAP module's logic looks like, but it is reference material, not a template the Rust module mirrors verbatim. A newer ComposableCoW iteration in development may simplify the polling surface significantly; the relevant decisions live in the module, not the host. -- `OrderPostError` rich variants + `retry_hint()` (ADR-0007 item 1, formerly item 3) become the primary protocol-level contract between the orderbook and any module submitting orders. Modules `match` on the typed error and apply the `RetryHint` (try-next-block / backoff-seconds / drop). This logic is generic across TWAP, EthFlow, stop-loss, and any future strategy. -- The M3 SDK (`shepherd-sdk` crate) is the natural home for ergonomic guest-side helpers: `WatchSet`, `PollLoop`, `BackoffLedger`, decode-and-submit utilities. The SDK is opt-in for module authors and lives entirely on the guest side; the host remains protocol-neutral. -- The architecture and sequence diagrams in `docs/diagrams/` that depict `twap.poll-and-submit` and `ethflow.submit-from-log` host calls reflect the rejected design and must be updated to show modules calling low-level primitives directly. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor._ +- `KNOWN_CAPABILITIES` gains no `twap` or `ethflow` entry; modules declare only the universal capabilities they use. +- Modules ship larger because event decoding, `eth_call` orchestration, order construction, and error-hint handling live in guest code. This is the explicit trade-off: more code per module, less coupling. +- `OrderPostError::retry_hint` (ADR-0007) is the orderbook-submit contract shared across strategies. The poll mechanism itself is superseded by [ADR-0013](0013-composable-cow-structured-poll.md). diff --git a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md index 9ff06fa3..f750feac 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -1,48 +1,21 @@ --- -status: proposed +status: accepted --- -# Push CoW Protocol primitives to `cow-rs` first, adopt in `nexum-engine` second +# Push CoW Protocol primitives to `cow-rs` first, adopt in the runtime second ## Context -Implementing ADR-0005 (cow-api backend) and supporting guest-side TWAP / EthFlow modules per ADR-0006 surfaces a recurring question: when the engine or its modules need a piece of CoW Protocol logic that the `cowprotocol` Rust SDK does not yet expose (rich orderbook error variants, custom orderbook URLs, wasm32 compatibility), do we write that logic locally and tidy it up upstream later, or do we add it to the open upstream PR first and only land the engine wiring afterwards? - -The failure mode is well-known: duplicating work that an existing crate could do is the AI-coding anti-pattern most likely to land in a contribution. The same risk applies to any engine-side reimplementation of protocol logic. - -The line between **protocol primitives** (which belong in `cowprotocol`) and **strategy implementations** (which belong in guest modules, per ADR-0006) is the operating principle. This ADR covers only the protocol-primitive additions; TWAP polling and EthFlow event decoding stay in guest modules and are explicitly **not** primitives we push to `cowprotocol`. +When the runtime or its modules need CoW Protocol logic the `cowprotocol` crate does not yet expose, the choice is to write it locally and tidy up upstream later, or add it upstream first and land the wiring afterwards. Duplicating logic an existing crate could own is the anti-pattern to avoid. Protocol primitives (order types, signing schemes, orderbook errors) belong in `cowprotocol`; strategy implementations (TWAP polling, EthFlow decoding) stay in guest modules per ADR-0006. ## Decision -Protocol-level CoW logic - anything that an indexer, a bot, or a non-`nexum` Rust consumer of CoW Protocol would also need to interact with the protocol - lands as additional commits on `cowdao-grants/cow-rs` PR #5 first (head branch `bleu/cow-rs:main`), and is consumed by `nexum-engine` and by guest modules via the `[patch.crates-io]` rev bump (ADR-0004). The engine and the modules never write throwaway local copies of the same logic with the intent to "port later". - -The concrete set of primitives this ADR commits to upstream, in priority order: - -1. **`cowprotocol::OrderPostError` rich variants + `retry_hint(&self) -> RetryHint`** - typed orderbook submission errors (`QuoteNotFound`, `InvalidQuote`, `InsufficientAllowance`, `InsufficientBalance`, `TooManyLimitOrders`, `InvalidAppData`, `AppDataFromMismatch`, `SellAmountOverflow`, `ZeroAmount`, `TransferSimulationFailed`, `ExcessiveValidTo`, …) with a `retry_hint()` helper classifying each into `TryNextBlock`, `BackoffSeconds(u64)`, or `Drop`. Mirrors watch-tower's `API_ERRORS_TRY_NEXT_BLOCK` / `API_ERRORS_BACKOFF` / `API_ERRORS_DROP` tables. Without this, every Rust consumer of CoW reinvents the same mapping, and modules spam the orderbook with permanently-broken orders. **Critical-path, not optional.** - -2. **`cowprotocol::OrderBookApi::with_base_url(chain_id, base_url)`** - custom-URL constructor for barn / staging / forked deployments. Unblocks per-chain orderbook URL overrides in `engine.toml` (ADR-0005). +Protocol-level CoW logic, anything a non-`nexum` Rust consumer of the protocol would also need, lands in `cowprotocol` first and is consumed via the `[patch.crates-io]` rev bump (ADR-0004). The runtime never writes throwaway local copies with intent to port later. -3. **`cowprotocol` `wasm32` compatibility** - feature-gate the `reqwest` dependency so guest modules can use the pure types (`Order`, `OrderCreation`, `OrderUid`, signing schemes, error variants) without dragging in an HTTP client. **Critical for ADR-0006**: modules implement TWAP and EthFlow logic in guest code and need `cowprotocol` types compiled to wasm32. Without this, guest modules fall back to duplicating type definitions. - -Lower-priority follow-ons (`OrderUid::from_slice`, retry middleware on `OrderBookApi`, `OrderCreation::from_gpv2`) are good-to-have but are not blocking for the M2 host or module scope. - -## Considered options - -- **Implement locally, refactor upstream later.** Faster short term but predictably leaves an indeterminate amount of duplicated logic in the engine, contradicts the conventions established on cow-rs PR #5, and grows technical debt every time cow-rs evolves the underlying types. Rejected. -- **Push TWAP / ComposableCoW primitives** (`composable::poll_and_build_order`) into `cowprotocol`. Rejected: TWAP is a concrete strategy on top of the protocol, not part of the protocol. Putting it in the SDK forces every consumer to use one polling implementation and one error-mapping policy. Per ADR-0006, TWAP polling lives in guest module code, not in shared layers. -- **Push EthFlow log-decoding primitives** (`eth_flow::decode_placement`) into `cowprotocol`. **Rejected for the same reason**: EthFlow event decoding is an implementation detail of how a particular module relays orders into the orderbook. The protocol layer defines the order types and the orderbook submission endpoint; the act of decoding an on-chain event into an `OrderCreation` is module-side logic. Modules decode `OrderPlacement` directly with `alloy_sol_types` and construct the `OrderCreation` with the EIP-1271 signing scheme. -- **Wait for cow-rs upstream maintainers to add these on their own.** No evidence anyone else is doing this work; the grant timeline does not permit waiting. -- **Vendor a fork of cow-rs inside `nullislabs/shepherd`.** Worst of all worlds: blocks neither the engine nor cow-rs from drifting, and forces every other CoW consumer to re-derive the same primitives. -- **Host-side `AppDataResolver` (LRU cache + GET against `/api/v1/app_data/{hash}`).** Rejected after verifying watch-tower's behavior: it never fetches app-data. The trader uploads the JSON to the orderbook via `PUT /api/v1/app_data/{hash}` separately; the relayer module just submits and reacts to `INVALID_APP_DATA` (backoff 1 min) / `APPDATA_FROM_MISMATCH` (drop) via the error map in item 1 above. +The primitives this covers: `OrderPostError` rich variants plus `retry_hint`, classifying each submission error into try-next-block / backoff / drop; and `wasm32` compatibility (feature-gating `reqwest`) so guest modules use the pure types compiled to wasm without an HTTP client. ## Consequences -- Every M2 engine or module issue that consumes one of the three primitives above is blocked on the corresponding commit landing in PR #5's head branch. Items 1, 2, 3 can be authored as independent commits and pushed in parallel rather than serially. -- `[patch.crates-io]` rev in the workspace `Cargo.toml` (ADR-0004) is bumped after each push to PR #5; the bump is the engine's signal that a new primitive is consumable. -- Commits added to PR #5 follow its established conventions: alloy reuse over local reimplementation, GPL-3.0, edition 2024, terse rustdoc. -- The engine repo stays small: `nexum-engine` contains WIT, host wiring, supervisor, redb store, alloy provider pool, and `engine.toml` schema, with nothing about CoW Protocol semantics. -- Guest modules consume `cowprotocol` types directly (gated on the wasm32 feature in item 3). The `shepherd-sdk` crate in M3 may add ergonomic wrappers on top, but those live on the guest side, not behind a WIT boundary. -- A follow-on Bleu module - the Rust-side equivalent of `cowprotocol/refunder` (permissionless `invalidateOrder` triggering for expired EthFlow orders) - becomes natural to ship once an ethflow-watcher module lands. Out of scope for M2 but explicitly enabled by the same primitives. -- TWAP polling logic (decode `ConditionalOrderCreated`, eth_call `getTradeableOrderWithSignature`, decode return, build `OrderCreation`) and EthFlow event decoding stay entirely in guest module code. The `cowprotocol` crate provides only the types and the orderbook client; the strategy is the module's. - -_Errata: `crates/nexum-engine` was renamed to `crates/nexum-runtime` + `crates/nexum-cli` in the 0.2 refactor. The `cowprotocol` crate now publishes to crates.io (the workspace pins `0.2.0`), so the PR-#5-head consumption model above is historical; a `[patch.crates-io]` git override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor - see the comment above the patch block in the root `Cargo.toml` for the current state._ +- The runtime repo stays free of CoW Protocol semantics: it holds WIT, host wiring, supervisor, redb store, provider pool, and the `engine.toml` schema. +- Guest modules consume `cowprotocol` types directly, gated on the wasm32 feature. +- `cowprotocol` now publishes to crates.io (workspace pins `0.2.0`); a `[patch.crates-io]` override to `nullislabs/cow-rs` remains active pending a release with the hash-only `OrderCreationAppData` constructor (ADR-0004). diff --git a/docs/adr/0008-factory-subscriptions-in-manifest.md b/docs/adr/0008-factory-subscriptions-in-manifest.md index f7fff7da..6b9f253e 100644 --- a/docs/adr/0008-factory-subscriptions-in-manifest.md +++ b/docs/adr/0008-factory-subscriptions-in-manifest.md @@ -1,56 +1,28 @@ --- status: deferred -deferred-to: 0.3 --- -# Dynamic address registration for log subscriptions (deferred to 0.3) +# Dynamic address registration for log subscriptions ## Status -**Deferred to 0.3.** Neither TWAP nor EthFlow (the M2 grant deliverables) needs this capability, and the design's complexity is not justified by current need. - -This ADR is preserved as a reference for the design space; the final shape will be revisited when the first module actually requiring dynamic address registration emerges. +**Deferred.** No current module needs it, and the schema and host-function surface add runtime complexity nothing exercises yet. Preserved as a record of the design space; the shape is revisited when a module actually requiring dynamic address registration emerges. ## Context -Some module archetypes need to track contracts deployed dynamically by a factory, for example Uniswap V3 pools (deployed by `UniswapV3Factory`). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are not known when the module's manifest is authored. - -Neither TWAP nor EthFlow needs this; both subscribe to a single well-known contract per chain. This ADR was originally framed as forward-looking work to land in 0.2's breaking-change window. - -## Why deferred - -Two considerations motivate the deferral: - -1. **`eth_getLogs` already supports topic-only filtering.** The JSON-RPC method accepts a filter without an `address` field, so a module subscribing to a topic across all addresses can be served by the existing primitives if the operator's RPC endpoint cooperates. If topic-only filters at the JSON-RPC layer are good enough for the common case, the engine does not need a manifest-and-host-function mechanism on top. -2. **The schema and host-function surface add engine complexity that no M2 deliverable consumes.** The historical-backfill story is the largest contributor to that complexity and was already trimmed once; deferring the rest in the same spirit avoids paying for a mechanism nothing exercises yet. - -Combined: the dynamic-subscription design is not load-bearing for M2 deliverables, and the simplest path (topic-only `eth_subscribe` filters with module-side address filtering) may suffice for a wide range of indexer use cases. The dynamic-registration mechanism originally proposed (Envio-style `register-address`) addresses scaling concerns at high address counts but should land when a real consumer is on the table to validate the trade-off. - -## Reference design (not adopted in 0.2) - -The original proposal - kept here so future discussions have a starting point - was a hybrid of static topics and dynamic addresses: - -- `[[subscription.template]]` block in `module.toml` declaring `chain_id`, `name`, `event_topics` (no address). -- `chain.register-address(chain_id, template_name, address)` host function for the module to add addresses at runtime. -- `chain.unregister-address(chain_id, template_name, address)` mirror function. -- `log-source.template(string)` variant on the event dispatch so modules route by template name. -- Engine maintains a single aggregated `eth_subscribe logs` per chain per template, with filter `(topic ∈ event_topics) ∧ (address ∈ current_set)`. The address set is mutated as the module discovers new contracts. -- Historical backfill (`from-block` argument on register, paginated `eth_getLogs` orchestration) was contentious and was already trimmed before deferral. +Some module archetypes track contracts deployed by a factory (for example Uniswap V3 pools). Static `[[subscription]]` declarations in `module.toml` cannot express this: the child addresses are unknown at manifest authorship. The current CoW modules do not need it; each subscribes to a single well-known contract per chain. -Envio HyperIndex's `context..register()` API is the closest existing pattern, validated in production for indexers tracking thousands of dynamically-discovered contracts. +`eth_getLogs` already accepts a topic-only filter (no `address` field), so a module can subscribe to a topic across all addresses and filter module-side, covering the common case without a new manifest-and-host mechanism. -## Alternatives left open for 0.3 +## Design space (not adopted) -- **Topic-only `[[subscription]]`** (no address field; engine forwards `eth_subscribe logs` with topic-only filter; module client-side filters logs by address it cares about). Simplest, no new host functions. Trade-off: firehose volume for common topics like `Transfer`. -- **Dynamic register-address** (the original reference design above). -- **Engine-extracted factory child addresses** (Ponder-style declarative schema with ABI-aware extraction rules). Schema complexity grows with exotic factory shapes. -- **No factory pattern; modules wanting dynamic discovery use raw `chain.subscribe-logs` with topic-only filter and persist the discovered address set themselves**. +- **Topic-only `[[subscription]]`**: no address field, module filters client-side. Simplest, no new host functions; trade-off is firehose volume for common topics. +- **Dynamic register-address**: a `[[subscription.template]]` block plus `chain.register-address` / `unregister-address` host functions maintaining a per-chain aggregated `eth_subscribe logs` whose address set the module mutates at runtime. Envio HyperIndex's `register()` is the closest existing pattern. +- **Runtime-extracted factory child addresses**: declarative ABI-aware extraction rules; schema complexity grows with exotic factory shapes. -The choice depends on what the first consumer actually needs. +The choice depends on what the first consumer needs. -## Consequences of deferring +## Consequences -- The `shepherd:cow` and `nexum:host` WIT surfaces remain unchanged in 0.2. -- `module.toml` schema does not gain `[[subscription.template]]` in 0.2. -- 0.2 is the breaking-change window; adding any of the above options in 0.3 may require a major version bump if the chosen shape extends `module.toml` or `nexum:host/chain` non-additively. This risk is accepted on the basis that the M2 grant deliverables do not require this surface. -- TWAP and EthFlow modules ship in 0.2 against the existing static `[[subscription]]` declarations (one address per subscription, known at manifest authorship time). This is consistent with how the autopilot ethflow indexer and watch-tower configure their subscriptions today. +- The `nexum:host` WIT surface and the `module.toml` schema stay unchanged: no `[[subscription.template]]`. +- Current modules ship against static `[[subscription]]` (one address per subscription, known at authorship). diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index fde9b463..a205f3dd 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -1,103 +1,25 @@ --- -status: proposed -implemented-in: bleu/nullis-shepherd#12, #13, #15, #22, #23, #24, #25 +status: superseded-in-part --- -# M3 Host trait surface: four per-capability traits + supertrait `Host`, with per-module `strategy.rs` / `lib.rs` split +# Host trait surface: per-capability traits plus a supertrait, with a `strategy.rs` / `lib.rs` split -> **Superseded in part by [ADR-0011](0011-per-interface-typed-errors.md).** The single `HostError` / `HostErrorKind` envelope this ADR mirrors was later replaced by per-interface typed errors over a shared `fault` vocabulary: `ChainHost::request` returns `ChainError`, `CowApiHost::submit_order` returns `CowApiError`, and the remaining traits return `Fault`. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. +> **The error envelope is superseded by [ADR-0011](0011-per-interface-typed-errors.md):** the single `HostError` / `HostErrorKind` record is replaced by per-interface typed errors over a shared `fault` vocabulary. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. ## Context -`docs/05-sdk-design.md` describes a much richer M5+ SDK (`#[nexum::module]` proc macro, alloy `Provider`, `TypedState`, `Signer`, named event handlers with async dispatch). M3's scope was narrower: deliver a testable host abstraction that lets module logic compile against an in-memory mock without a `wasm32-wasip2` toolchain, and that the M2 modules (twap-monitor, ethflow-watcher) can adopt without breaking their existing dispatch. - -The constraint is unusual: `wit_bindgen::generate!` emits per-cdylib types - every module gets its own `HostError`, `Event`, `Log`, etc. - so a single shared SDK type cannot be re-used across the wit boundary. Mocks live in their own crate (`shepherd-sdk-test`) and need to compile for the host target (not wasm). +The runtime needs a testable host abstraction so module logic compiles against an in-memory mock without a `wasm32-wasip2` toolchain. `wit_bindgen::generate!` emits per-cdylib types, so a single shared SDK type cannot cross the WIT boundary; the mocks live in their own crate (`nexum-sdk-test`) and compile for the host target. ## Decision Three coupled choices: -### 1. Four per-capability traits with a supertrait `Host` - -`shepherd-sdk` exposes four traits, one per host import: - -```rust -pub trait ChainHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -pub trait LocalStoreHost { fn get / set / delete / list_keys ... } -pub trait CowApiHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result; } -pub trait LoggingHost { fn log(&self, level: LogLevel, message: &str); } - -pub trait Host: ChainHost + LocalStoreHost + CowApiHost + LoggingHost {} -impl Host for T {} -``` - -Module strategy code takes `&impl Host` (or ``), so it can call any of the four interfaces uniformly. Tests inject `shepherd_sdk_test::MockHost`; production inject `WitBindgenHost`. The blanket `impl Host for T` means callers never write `impl Host for MyHost {}` by hand. - -### 2. SDK-side `HostError` mirroring the wit struct field-for-field - -`shepherd_sdk::host::HostError` has the same fields as the wit-bindgen-generated `HostError` in each module crate, but is its own type: - -```rust -pub struct HostError { - pub domain: String, - pub kind: HostErrorKind, - pub code: i32, - pub message: String, - pub data: Option, -} -``` - -Each module's `lib.rs` writes a one-liner `convert_err` and `sdk_err_into_wit` to bridge the two. The traits stay world-neutral: `shepherd-sdk-test` compiles for the host target without needing a wasm toolchain, and the mocks are usable from any module's tests. - -### 3. Per-module `strategy.rs` + `lib.rs` split - -Every module is shaped as: - -- `strategy.rs` - pure logic. Imports `shepherd_sdk::host::{Host, HostError, LogLevel}`. Defines small carrier types (`LogView<'a>`, `BlockInfo`, `Settings`) so the strategy is wit-independent. Tests live here under `#[cfg(test)]` against `MockHost`. -- `lib.rs` - per-cdylib glue. `wit_bindgen::generate!`, the `WitBindgenHost` struct implementing all four traits with `chain::request` / `local_store::*` / `cow_api::submit_order` / `logging::log` calls, the `convert_err` + `sdk_err_into_wit` + `convert_level` helpers, and the `Guest` impl that destructures `types::Event` and delegates to `strategy`. - -Reference implementations: `modules/examples/price-alert/`, `modules/examples/stop-loss/`, `modules/twap-monitor/`, `modules/ethflow-watcher/`. The wit-bindgen adapter is intentionally mechanical and is a candidate for a future declarative macro in `shepherd-sdk` (the `#[nexum::module]` design in doc 05). - -## Considered options - -- **Single fat `Host` trait.** Rejected: pulls every module's tests into mocking the full surface even when the strategy only touches one or two capabilities. The four-trait split lets tests `respond_to` exactly the calls the strategy makes. -- **`#[nexum::module]` proc macro now.** Rejected for M3 scope. The proc macro is the right shape long-term (see doc 05) but adds a macro crate, parsing logic, and a debugging surface we did not need to ship M2 modules with MockHost coverage. The manual adapter is verbose but understandable in one read; we land the macro as M5 work. -- **Re-export wit-bindgen `HostError` from the SDK.** Rejected: the wit-bindgen types are per-cdylib. Re-exporting one module's `HostError` would break all others. A shared SDK struct with field-equivalent shape and module-local `From` impls is the only way the SDK stays world-neutral. -- **Strategy lives in `lib.rs` next to the wit-bindgen adapter.** Rejected after the price-alert refactor showed the dispatch matrix was not unit-testable without MockHost, and the twap-monitor / ethflow-watcher ports confirmed the split. The wit-bindgen adapter is ~150 lines of mechanical glue; the strategy is hundreds of lines of logic - colocating them obscures both. +1. **Four per-capability traits (`ChainHost`, `LocalStoreHost`, `CowApiHost`, `LoggingHost`) with a blanket-implemented supertrait `Host`.** Strategy code takes `&impl Host` and calls any interface uniformly; tests inject `nexum_sdk_test::MockHost`, production injects `WitBindgenHost`. The four-trait split lets a test mock only the calls its strategy makes. +2. **An SDK-side error type mirroring the WIT struct field-for-field**, its own type (the WIT-generated one is per-cdylib), bridged by a one-line `From` in each module's `lib.rs`. This keeps the traits world-neutral so `nexum-sdk-test` needs no wasm toolchain. Superseded by ADR-0011's typed errors. +3. **Per-module `strategy.rs` (pure, wit-independent logic, unit tests against `MockHost`) plus `lib.rs` (per-cdylib `wit_bindgen::generate!` glue, the `WitBindgenHost` impl, and the `Guest` dispatch).** Colocating hundreds of lines of strategy with the mechanical adapter obscures both. ## Consequences -- **Strategy code is testable in native Rust** without `wasm32-wasip2`. Every shepherd-side module ships a unit-test suite that exercises this seam via `MockHost`; CI is the authoritative count. -- **The `WitBindgenHost` adapter is duplicated across modules.** ~150 lines of identical glue (the four trait impls plus the two converters and `convert_level`). Acceptable today; the M5 `#[nexum::module]` macro is the path to eliminate it. -- **`shepherd-sdk-test` does not need wit-bindgen.** It depends only on `shepherd-sdk` and `std`; no wasm toolchain involved. Tests compile and run as plain Rust. -- **`HostError` round-trips lossily at the WIT boundary.** The wit-bindgen and SDK types have identical fields today; if either evolves (new variant on `HostErrorKind`, new field), modules need a one-line `From` update. **Applied in M4**: `HostErrorKind` and `LogLevel` are `#[non_exhaustive]`; each module's `sdk_err_into_wit` and `convert_level` adapter carries a wildcard arm mapping unknown SDK-side variants to `HostErrorKind::Internal` / `Level::Info` respectively. `RetryAction` and `PollOutcome` stay exhaustive (domain-locked to the cow-rs `OrderPostErrorKind::is_retriable` and `IConditionalOrder` Solidity interfaces). -- **The four-trait split is not an interface contract with mfw78's WIT.** WIT defines the wire shape; the SDK traits are a Rust-side ergonomics layer. The two evolve together but are not the same artifact. -- **Future capabilities (e.g. `messaging`, `remote-store`, `http`) add new traits.** Each new host interface becomes a new trait + new `MockX` in `shepherd-sdk-test`, and the supertrait `Host` is bumped to bound on the new trait. Modules that do not use the new capability are unaffected (they only need `` etc. on the subset they actually touch - the supertrait is a convenience for full-surface modules, not a hard requirement). - -## Capability enforcement vs. the WIT world (load-bearing assumption) - -`enforce_capabilities` (in `crates/nexum-engine/src/manifest/capabilities.rs`) checks the loaded component's *actual* import set against the manifest's `[capabilities].required + [capabilities].optional`. A component that imports a `nexum:host/` or `shepherd:cow/` whose `` is a known capability NOT in either list fails to boot with `CapabilityViolation`. - -This interacts with `wit_bindgen::generate!` in a way worth pinning here, because the example modules and the production modules use different strategies: - -| Module | WIT world | `generate!` mode | Capabilities the manifest declares | -|---|---|---|---| -| twap-monitor | `shepherd:cow/shepherd` (supertype) | `generate_all` | logging, local-store, chain, cow-api | -| ethflow-watcher | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, cow-api (chain optional - PR #55 review) | -| stop-loss | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain, cow-api | -| price-alert | `shepherd:cow/shepherd` | `generate_all` | logging, local-store, chain (no cow-api) | -| balance-tracker | `nexum:host/event-module` | `generate_all` | logging, local-store, chain | - -`price-alert` and `balance-tracker` compile against worlds that import `shepherd:cow/cow-api`, but their manifests do not declare it. Boot succeeds today because the `wasm-tools` / `wit-component` pipeline elides any WIT import the produced `.wasm` does not actually exercise from the component's import section. `enforce_capabilities` then sees the trimmed set and finds nothing missing. - -**The elision is load-bearing**, not a manifest convenience: if a future toolchain bump changes the elision behaviour (or if a module starts importing a capability transitively without declaring it), modules that worked before suddenly fail capability enforcement at boot. - -**Mitigation today**: rely on the elision and treat the assumption as part of the supported build pipeline. Both wasm-tools 1.x and wasmtime 41-45 elide unreferenced imports for our build profile; CI exercises this implicitly on every `cargo build --target wasm32-wasip2`. - -**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. - -**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._ +- Strategy code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. +- New capabilities add a new trait plus a `MockX` in `nexum-sdk-test`; modules that do not use a capability bound only on the subset they touch. +- The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. diff --git a/docs/adr/0011-per-interface-typed-errors.md b/docs/adr/0011-per-interface-typed-errors.md index 662e2445..26a179c3 100644 --- a/docs/adr/0011-per-interface-typed-errors.md +++ b/docs/adr/0011-per-interface-typed-errors.md @@ -6,26 +6,20 @@ status: accepted ## Context -The host once returned one unified envelope, `host-error`, from every imported function and every module export: a record of `domain` (a stringly subsystem tag), a `host-error-kind` enum, a numeric `code` (a JSON-RPC code or an HTTP status, depending on the caller), a `message`, and an optional opaque `data` blob. Dispatch on the failure cause meant reading `kind`, sometimes cross-checking `domain` and `code`. Modules re-named their own domain in every error they built and prefixed each message with the module name, duplicating context the runtime already had (the module name, the interface). - -The envelope conflated two things that want to move independently: the shared cross-domain failure vocabulary (unavailable, timeout, denied, ...) and the per-interface structured detail (a JSON-RPC revert carries a node code and decoded revert bytes; an orderbook rejection carries a typed `{errorType, description}`). Squeezing both through one flat record meant every interface paid for fields it did not use and lost the fields it did. +The host once returned one flat envelope (`host-error`: a stringly `domain`, a `host-error-kind` enum, a numeric `code`, a message, an optional `data` blob) from every function and export. It conflated the shared cross-domain failure vocabulary (unavailable, timeout, denied) with per-interface structured detail (a JSON-RPC revert's node code and decoded bytes, an orderbook rejection's typed `{errorType, description}`). Every interface paid for fields it did not use and lost the fields it did, and modules restated their own identity in every error they built. ## Decision -Adopt the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. - -`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. A richer interface embeds `fault` as one case of its own variant and adds the cases only it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. Interfaces with nothing to add report `fault` directly (local-store, and now the module exports). +Follow the WASI idiom: each interface declares its own typed error, and the errors share one payload-bearing `fault` vocabulary for the cross-domain cases. -The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business: it holds the module name and does not need each fault to re-declare it, so the `domain` self-naming and the message-prefix duplication in modules are gone. The supervisor derives its error metric label and structured-log `kind` field from the fault case (via the `HostFault` label), which drops a `format!("{:?}")` allocation per erroring dispatch. +`fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. A richer interface embeds `fault` as one case of its own variant and adds only the cases it needs: `chain-error` adds an `rpc` case carrying the node code and decoded revert bytes; `cow-api-error` adds `http` and `rejected`. Interfaces with nothing to add report `fault` directly. -`host-error` and `host-error-kind` are deleted from `types.wit` and from every mirror: the runtime host constructors, both guest SDKs, both SDK test crates, and the module glue. The SDK exposes `Fault` (mirroring the wire vocabulary) plus the `HostFault` trait that recovers an embedded fault and a stable snake_case label; `From for Fault` folds a chain error into the shared vocabulary so a strategy aggregating store and chain calls returns one `Fault`. +The module exports (`init`, `on-event`, `evaluate`) return `result<_, fault>`. Module identity is the supervisor's business, so the `domain` self-naming and message-prefix duplication are gone; the supervisor derives its metric label and log `kind` from the fault case via the `HostFault` label. `host-error` and `host-error-kind` are deleted from `types.wit` and every mirror. The SDK exposes `Fault` plus the `HostFault` trait, with `From for Fault` folding a chain error into the shared vocabulary. -This is a pre-1.0 wire break. CI rebuilds every module wasm on a world change, so no compatibility shim is warranted. +This is a pre-1.0 wire break; CI rebuilds every module wasm on a world change, so no shim is warranted. ## Consequences - A caller dispatches on the structured cause by matching the typed variant, with no stringly `domain`/`code` cross-check. -- Interfaces carry exactly the detail they have; the shared cases stay uniform across interfaces and yield one stable label vocabulary for metrics and logs. -- Modules no longer restate their identity or prefix messages; the runtime supplies both. -- The numeric `code` and opaque `data` fields are gone. An interface that needs structured detail (the JSON-RPC code, decoded revert bytes) carries it in a typed case instead. -- Old bindings do not interoperate with the new world. Because this predates 1.0 and CI rebuilds all module wasms per world change, that break is accepted rather than shimmed. +- The shared cases yield one stable label vocabulary for metrics and logs; interfaces carry exactly the detail they have. +- The numeric `code` and opaque `data` fields are gone; structured detail lives in a typed case instead. diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 63764ab3..49fa4ea9 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -1,47 +1,34 @@ --- -status: proposed +status: accepted --- # ComposableCoW poll is a structured non-reverting verdict; the module is a generic handler-agnostic monitor ## Context -ADR-0006 decided that TWAP and EthFlow run as guest modules over low-level host primitives, with the host protocol-neutral and no specialised `shepherd:cow/twap` interface. That decision stands. What ADR-0006 baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])`, decoding the return *or the revert reason* with `alloy_sol_types`, mapping the revert selectors to a rich off-chain `PollOutcome`, and computing its own poll schedule (the TWAP epoch gates). ADR-0006 itself anticipated this change: "A newer ComposableCoW iteration in development may simplify the polling surface significantly." +ADR-0006 kept TWAP and EthFlow as guest modules over low-level host primitives, with the host protocol-neutral. What it baked in, and what this ADR supersedes, is the poll *mechanism*: a module calling `getTradeableOrderWithSignature`, decoding the return or the revert reason, mapping revert selectors to an off-chain `PollOutcome`, and computing its own schedule. -That iteration exists. The nullisLabs composable-cow fork (`/code/nullisLabs/composable-cow`, `docs/architecture.md`) splits settlement from polling and makes the polling path structured and non-reverting. `getTradeableOrderWithSignature()` and `checkOrder()` now return a `PollResult { GeneratorResult generator; FillStatus fill; uint filledAmount; Restriction restriction }`. The handler's `poll()` (via `BaseConditionalOrder.poll()`) wraps `generateOrder()` in a try/catch on-chain and returns a `GeneratorResult { code: POST | WAIT_TIMESTAMP | WAIT_BLOCK | TRY_NEXT_BLOCK | INVALID | NEEDS_INPUT; order; nextPollTimestamp; waitUntil; bytes4 reasonCode }`. The revert-decode moved on-chain; the schedule is supplied by the contract (`nextPollTimestamp` sentinels: `0` = poll at `validTo + 1`, `type(uint256).max` = stop); the fill and restriction overlays are composed by the registry, orthogonal to the verdict. - -The consequence is that a monitor no longer needs any handler-specific off-chain logic. TWAP's epoch arithmetic is on-chain in `getNextPollTimestamp()`; the off-chain side reads a struct field. One generic poller drives every handler (TWAP, StopLoss, GoodAfterTime, PerpetualStableSwap, TradeAboveThreshold): none of shepherd's flagship roster emits `PollNeedsOffchainInput`, so none needs an order-module sandbox. - -The blocker is deployment. The fork is `abiVersion 2.0.0-dev`, `deployments/networks.json` is `networks: {}`, and every chain shepherd targets has only the upstream reverting `ComposableCoW`. The poll wire cannot retarget to a contract that is not on-chain. +The nullisLabs composable-cow fork splits settlement from polling and makes the poll path structured and non-reverting: `getTradeableOrderWithSignature` and `checkOrder` return a `PollResult` whose `GeneratorResult` carries a `code` (`POST` | `WAIT_TIMESTAMP` | `WAIT_BLOCK` | `TRY_NEXT_BLOCK` | `INVALID` | `NEEDS_INPUT`), the order, schedule sentinels, and a `bytes4 reasonCode`. The revert-decode and schedule move on-chain, so a monitor needs no handler-specific off-chain logic and one generic poller drives every handler. ## Decision -The CoW module is a generic, handler-agnostic ComposableCoW monitor (a `ccow-monitor`), not a TWAP-specific strategy. It indexes `ConditionalOrderCreated`, polls each authorised order, and switches on `GeneratorResult.code` and nothing else: `POST` (with a signature emitted) submits the order through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule at `waitUntil`; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` hands to the offchainInput layer or parks. It decodes no revert selectors, computes no schedule, and holds no per-handler branch. TWAP survives as a watch scope and golden vectors, not as code. - -The chassis (ADR-0009) supplies the mechanism: the watch-set and journal stores are unchanged; the two-gate store (`next_block:` / `next_epoch:`) now holds the contract-supplied `waitUntil` / `nextPollTimestamp` slots, so its value source moves from off-chain revert-decode to the contract verdict while its shape is unchanged. The `ConditionalSource` seam returns a structured `Verdict` mirroring `GeneratorResultCode` plus the hints; it does not decode or schedule. - -Two classification concerns stay separate: the poll verdict comes from the contract (`GeneratorResultCode` + `bytes4 reasonCode`, log only), while the CoW orderbook-API submit-error `errorType` table (the REST POST `/api/v1/orders` response) is a distinct data-driven concern that the module keeps. +The CoW module is a generic ComposableCoW monitor: it indexes `ConditionalOrderCreated`, polls each authorised order, and switches on the poll verdict alone (`POST` submits through `videre:venue/client`; `WAIT_TIMESTAMP` / `WAIT_BLOCK` reschedule; `TRY_NEXT_BLOCK` re-polls; `INVALID` drops the watch; `NEEDS_INPUT` parks). It decodes no revert selectors, computes no schedule, and holds no per-handler branch. -Migration is HYBRID and gated on deployment: +Migration is hybrid and gated on deployment: -- Migrate the Rust verdict seam now (zero contract dependency): the `ConditionalSource::Outcome` becomes the structured `Verdict`, and the sweep dispatches on it. -- Quarantine the deployed 1.x reverting poll behind a named `LegacyRevertAdapter` that maps the five upstream selectors to the `Verdict` (`PollTryAtEpoch` to `WAIT_TIMESTAMP`, `PollTryNextBlock` to `TRY_NEXT_BLOCK`, `PollNever` / `OrderNotValid` to `INVALID`). The module posts through the target seam against the deployed contract, so the grant demo stays green. -- When the fork's `deployments/networks.json` is non-empty on a shepherd target chain, replace the adapter with a direct `PollResult` struct-read, delete the adapter and `shepherd-sdk/src/cow/composable.rs`, and regenerate golden vectors against `bytes4 reasonCode` and `PollResult`. +- The Rust verdict seam migrates now (zero contract dependency): the poll resolves to a structured `Verdict` and the sweep dispatches on it. +- The deployed 1.x reverting poll is quarantined behind a `LegacyRevertAdapter` that maps each upstream selector onto a `Verdict`, so the module posts through the target seam against the deployed contract. +- When the fork's `deployments/networks.json` is non-empty on a target chain, the adapter is replaced by a direct `PollResult` struct-read and golden vectors regenerate against `reasonCode` / `PollResult`. -Merge gate: the poll retarget (`shepherd-sdk/src/cow/composable.rs`, `modules/*/src/strategy.rs`, and the acceptance of this ADR over the reverting model) must not merge until `deployments/networks.json` is non-empty on a shepherd target chain. +The orderbook-API submit-error `errorType` table (the REST POST response) is a separate data-driven concern the module keeps; it is not the poll contract. -## Considered options +## Current state -- **Redirect now: retarget the poll to the fork's structured surface immediately.** Rejected. The fork is deployed on no chain shepherd targets; every `eth_call` would revert selector-not-found, and shepherd cannot deploy a third party's registry on its own clock. This regresses grant M2 from "posts orders on testnet" to "compiles against an undeployed ABI." -- **Land-then-migrate: merge the M1 train on the old model, migrate wholesale after the grant.** Rejected as the default because it ships a public verdict seam (`PollOutcome`) that is immediately broken by the target, forcing a re-port of the consumer. The seam type carries no contract dependency, so it is migrated now for free. -- **Keep the off-chain revert-decode as the permanent model.** Rejected. It duplicates on-chain the decode the fork does once, per-handler, and it is the source of the handler-specific off-chain logic this ADR removes. +The seam migration has shipped: `crates/composable-cow` exports the structured `Verdict`, the `run` sweep dispatches on it (`crates/composable-cow/src/run.rs`), and the deployed reverting wire is served by `LegacyRevertAdapter`. The fork is deployed on no target chain, so `LegacyRevertAdapter` is the live poll path; the structured non-reverting `PollResult` wire is not yet exercised in production. The wire-swap remains gated on the fork deploying. ## Consequences -- `shepherd-sdk/src/cow/composable.rs` (the five-selector `sol!` mirror, `decode_revert`, `classify_poll_error`, the `PollOutcome` enum) is deleted at the wire-swap; its replacement is a struct-read, not a re-port. Until then it survives only as the `LegacyRevertAdapter`'s guts. -- The `twap-monitor` module generalises into `ccow-monitor`; the TWAP-specific poll body (`poll_one`, `decode_return`, `classify_poll_error`, the module-side `TryAtEpoch` / `TryOnBlock` gates) is removed. Existing MockHost tests remain the behaviour-identity proof through the seam migration. -- The chassis two-gate store is re-documented: `next_block:` and `next_epoch:` are contract-supplied `waitUntil` and `nextPollTimestamp` slots, not off-chain epoch math. -- New capabilities land as follow-ons and are the only places a per-handler off-chain concern reappears: `IOrderModule` for `NEEDS_INPUT` offchainInput acquisition, `IOrderManifest` enumeration with `offchainInput = abi.encode(index)` fan-out, and merkle-payload discovery. None is needed for the flagship roster. -- The rename set threads through the module and its vectors: `getTradeableOrder` to `generateOrder`; `PollTryAtEpoch(uint256, string)` to `PollTryAtTimestamp(uint256, bytes4)`; `PollNever` removed in favour of `OrderNotValid`; `getTradeableOrderWithSignature` returns `PollResult`, not `(order, bytes)`; string reason payloads become `bytes4 reasonCode`; the poll no longer reverts for order conditions. -- `docs/diagrams/sequence-twap.mmd` (already flagged in ADR-0006's consequences) is updated to show the structured poll: one `checkOrder` call returning a verdict and hints, no revert path. -- ADR-0006's host-neutrality decision is unchanged. This ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint()` remains the orderbook-submit contract; it was never the poll contract. +- The five-selector revert mirror survives only as the `LegacyRevertAdapter` internals; at the wire-swap it is replaced by a struct-read, not re-ported. +- The two-gate store holds contract-supplied `waitUntil` / `nextPollTimestamp` slots rather than off-chain epoch math. +- `NEEDS_INPUT` offchainInput acquisition, manifest enumeration, and merkle-payload discovery land as follow-ons; none is needed by the current module roster. +- ADR-0006's host-neutrality decision is unchanged; this ADR supersedes only its poll-mechanism description. ADR-0007's `OrderPostError::retry_hint` remains the orderbook-submit contract, never the poll contract. From dd3f69d27fe9b84cbfd0da7d4bd721ef07851615 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:08:45 +0000 Subject: [PATCH 112/139] docs: trim platform generalisation and linker seam to current contract (#600) * docs: trim platform generalisation and linker seam to current contract Rewrite docs/08 as a terse description of the shipped layered WIT model: keep the Layer 1/2/3 world model, the verified universal interface signatures, the venue-adapter Layer 3 mechanics, and the complete WIT package layout; collapse the mobile, WebView, and super-app targets and their per-primitive implementation tables to one sentence each; cut the motivation essay, the host adapter specification, and the summary. Reduce the 0.2 status banner to one line and point the SDK and taxonomy topics at their owning docs. Rewrite docs/design/linker-extension-seam.md against the current Extension trait: drop the retired cow-api / shepherd-cow-host / CowBackend example, describe namespace, capabilities, link, service, provider, admit and event members, the HostServices and ExtState reach paths, and the videre venue platform as the live extension the shepherd binary registers. Align the reproduced WIT with wit/: chain request-batch takes rpc-request and returns rpc-result, the event variant carries custom, and identity and chain are stated at their actual reference-host behaviour. Part of #598. * docs: unwrap paragraphs to one logical line for diff-friendliness Hard-wrapped prose churns diffs: a one-word edit reflows the whole paragraph. Join each paragraph onto a single logical line and let it soft-wrap. Content unchanged (word and heading counts preserved); code fences, tables, lists, blockquotes and headings untouched. --- docs/08-platform-generalisation.md | 875 +++------------------------ docs/design/linker-extension-seam.md | 131 +--- 2 files changed, 114 insertions(+), 892 deletions(-) diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index fab96f38..20cb3867 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -1,47 +1,10 @@ # Platform Generalisation -> **Status (0.2):** Nexum is **designed** to be portable to mobile and browser hosts; the 0.2 **reference runtime is server-only**. The mobile, WebView, and super-app targets in this document describe architectural direction, not shipping artifacts. They remain in the docs because they're load-bearing design - the WIT contract is shaped by the requirement that all four can implement it - but they are **planned** work, conditional on a named design partner for 0.3. See the per-target rows below for current status. +> **Status (0.2):** the WIT contract is host-portable; the reference server runtime (docs 01-07) is its sole implementation. Mobile, WebView, and super-app hosts are design direction only and ship nothing in 0.2. -## Motivation +A module compiles against `nexum:host/event-module`. Any host that implements the required interfaces runs the same module binary; platforms differ only in how they back those interfaces. This document defines the layered world model, the universal interface set, and the venue-adapter extension mechanism. [Doc 00](00-overview.md) owns the six-primitive taxonomy. -The Nexum runtime (docs 01-07) is designed as a server-side Rust binary embedding wasmtime. But the core abstractions - WIT-defined host interfaces, content-addressed module distribution, declarative manifests - are not inherently server-specific. The same module binary, the same packaging, and the same distribution mechanism are intended to serve multiple platform targets: - -1. **Server runtime** *(shipping in 0.2)* - the current design (Rust/Tokio/wasmtime). Headless automation: blockchain event monitoring, order submission, background computation. -2. **Mobile app (Flutter/Dart)** *(planned - see roadmap)* - a WASM runtime embedded in a native mobile application via FFI. Modules run on-device, backed by local state (SQLite) and RPC over HTTP. -3. **WebView** *(planned - see roadmap)* - a browser engine (V8/JSC/SpiderMonkey) executing WASM natively, with host functions injected from the native layer via a JavaScript bridge. Enables rich web-based UIs with blockchain-native capabilities. -4. **Decentralised super app** *(planned - see roadmap)* - a shell application (mobile or desktop) that dynamically loads modules discovered via ENS and fetched from Swarm. Some modules are headless (automation); others are interactive (UI). All are sandboxed, all are distributed without a central app store. - -The key insight: **the WIT contract is the universal interface**. Any host that implements the required interfaces can run the same module binary. The differences between platforms are in *how* the host implements those interfaces - not in what the module sees. - -This document defines the layered architecture that enables this generalisation and specifies the universal interface set. The 0.2 server runtime is the first host implementation; the experimental `nexum:host/query-module` WIT world (published but unhosted in 0.2) exists to give mobile/wallet embedders a stable target to implement against before 0.3. - -## Primitive Taxonomy - -Before diving into WIT definitions, the universal runtime is built on six primitive capabilities. These are the fundamental building blocks that any decentralised application needs: - -| Primitive | Interface | Backed by | Purpose | -|-----------|-----------|-----------|---------| -| **Chain** | `chain` | JSON-RPC (eth_*) | Read/write blockchain consensus state | -| **Identity** | `identity` | Keystore / KMS / device keychain / wallet extension | Cryptographic identity - key management and signing | -| **Local Store** | `local-store` | redb / SQLite / IndexedDB | Per-module private persistence on the device | -| **Remote Store** | `remote-store` | Ethereum Swarm | Decentralised content-addressed storage | -| **Messaging** | `messaging` | Waku | Decentralised pub/sub messaging | -| **Logging** | `logging` | tracing / console | Diagnostic output | - -These six primitives are orthogonal: - -- **Chain** is the source of truth - the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic agency - key management and signing. Modules can enumerate available accounts and request signatures (ECDSA secp256k1 by default, extensible). The `chain` host implementation depends on `identity` internally - signing RPC methods (e.g. `eth_sendTransaction`) delegate to `identity` for the actual signature. -- **Local Store** is the module's private scratchpad - fast, local, scoped to one module on one device. Does not replicate. -- **Remote Store** is shared persistent content - content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. -- **Messaging** is real-time communication - ephemeral pub/sub messages between modules, devices, or users. Unlike remote store (persistent, content-addressed), messaging is transient and topic-based. -- **Logging** is diagnostics - one-way output for debugging and monitoring. Not a data channel. - -Together they cover the full spectrum: persistent truth (chain), cryptographic agency (identity), local scratch (local-store), shared content (remote-store), real-time coordination (messaging), and diagnostics (logging). - -The 0.2 `event-module` world imports all six. (In 0.1 the WIT inadvertently omitted `identity` from the world definition despite the docs claiming six primitives; 0.2 makes the contract match the taxonomy.) Two additional **additive** capabilities are declared via the manifest's `[capabilities]` section but are not part of the six-primitive core: `http` (allowlisted), serviced by the standard `wasi:http/outgoing-handler` interface rather than a `nexum:host` one, and `client`, the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). - -## Architectural Principle: Layered WIT Worlds +## Layered WIT Worlds Universal runtime capabilities and domain-specific surfaces live in separate layers: @@ -50,17 +13,16 @@ graph TD subgraph L3["Layer 3: Domain Venues (adapter components)"] COW["cow-venue - CoW Protocol orderbook"] DEX["dex-venue - a DEX (hypothetical)"] - LEND["lend-venue - a lending market (hypothetical)"] end subgraph L2["Layer 2: Capability Extensions (optional, composable)"] VC["videre:venue/client - typed intent access to installed venues"] - UI["ui - user interface bridge (planned)"] + UI["ui - user interface bridge (design only, no WIT)"] end subgraph L1["Layer 1: Universal Runtime Interfaces"] CSN["chain - consensus access (JSON-RPC passthrough)"] - ID["identity - cryptographic identity (key management, signing)"] + ID["identity - cryptographic identity"] LS["local-store - local key-value persistence"] RS["remote-store - decentralised content-addressed storage"] MSG["messaging - decentralised pub/sub messaging"] @@ -72,236 +34,112 @@ graph TD L2 -->|"adds imports to"| L1 ``` -Layer 1 is the world every module compiles against. A Layer 2 capability adds an import to a module's manifest-derived world: a keeper module declares `client` and gains `videre:venue/client`. Layer 3 is not a world at all: a domain enters the system as a **venue adapter component** installed into the host's venue registry, and modules reach it through the Layer 2 client interface. No module compiles against a domain world - see [Layer 3](#layer-3-domain-extensions-venue-adapters). +Layer 1 is the world every module compiles against. A Layer 2 capability adds an import to a module's manifest-derived world: a keeper module declares `client` and gains `videre:venue/client`. Layer 3 is not a world: a domain enters as a venue adapter component installed into the host's venue registry, and modules reach it through the Layer 2 client interface. No module compiles against a domain world. ## Layer 1: Universal Interfaces -These six interfaces form the universal runtime contract. Any platform - server, mobile, WebView, desktop - can implement them. +Six interfaces form the universal runtime contract. The signatures below are the shipped `wit/nexum-host` package. -### `chain` - Consensus Access +### `chain` -The module's window into blockchain consensus. A single generic function that forwards JSON-RPC requests to the host's provider infrastructure, plus an additive batched variant. The host decides *how* to reach the chain - the module only specifies *what* to ask. +Forwards JSON-RPC to the host's provider infrastructure, plus a batched variant. The reference server host forwards a read-only method surface and refuses signing or mutating methods (`eth_sendTransaction`, `eth_sign`, `personal_sign`, ...) with a `denied` fault before they reach the provider. ```wit interface chain { use types.{chain-id, fault}; - /// A structured JSON-RPC error carrying the node code and revert bytes. record rpc-error { code: s32, message: string, data: option> } - - /// Either a shared host `fault` or a structured JSON-RPC error. variant chain-error { fault(fault), rpc(rpc-error) } + record rpc-request { method: string, params: string } + variant rpc-result { ok(string), err(chain-error) } - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host routes to its configured provider for the given chain, - /// applying whatever middleware is appropriate for the platform - /// (timeout, retry, rate-limit, fallback on server; simple HTTP - /// on mobile; window.ethereum or injected provider in WebView). - /// - /// `method` includes the namespace prefix (e.g. "eth_call"). - /// `params` and the success value are JSON-encoded strings. + /// `method` includes the namespace prefix (e.g. "eth_call"); `params` + /// and the success value are JSON-encoded strings. request: func(chain-id: chain-id, method: string, params: string) -> result; - /// Additive 0.2 method: batched JSON-RPC. - request-batch: func(chain-id: chain-id, calls: list>) - -> result>, chain-error>; + /// Batched JSON-RPC over one chain. The result list matches `requests` + /// in length and order; hosts that cannot batch natively fall back to + /// sequential `request` calls. + request-batch: func(chain-id: chain-id, requests: list) + -> result, chain-error>; } ``` -**Platform implementations:** - -| Platform | `chain::request` backed by | -|----------|--------------------------| -| Server (Nexum) | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | -| Mobile (Flutter) | HTTP client (reqwest via FFI, or Dart `http` package) to configured RPC endpoint | -| WebView | JavaScript bridge -> `window.ethereum` (injected wallet) or native HTTP via message channel | -| Super app | Same as mobile, with per-module chain permissions | - -The Rust SDK's `HostTransport` (doc 07) works identically on all platforms - it implements alloy's `Transport` trait over `chain::request`, so module authors get the full alloy `Provider` API regardless of where the module runs. +The SDK's `HostTransport` (doc 07) implements alloy's `Transport` trait over `chain::request` / `chain::request-batch`, so module authors get the alloy `Provider` API regardless of host. -### `identity` - Cryptographic Identity +### `identity` -Provides key management and signing capabilities to modules. ECDSA secp256k1 by default (the Ethereum standard), extensible to other schemes. Modules can enumerate available accounts and request signatures over arbitrary data. - -The `chain` host implementation depends on `identity` internally - signing RPC methods such as `eth_sendTransaction` or `eth_signTypedData_v4` delegate to `identity` for the actual cryptographic signature. Modules can also import `identity` directly for raw signing operations outside of JSON-RPC (e.g. signing EIP-712 typed data for off-chain order submission). +Account enumeration and signing. ECDSA secp256k1 by default. The reference server host leaves this interface unimplemented: `accounts` returns an empty roster and `sign` / `sign-typed-data` return the `unsupported` fault. A keystore / KMS backend is not yet shipped. ```wit interface identity { use types.{fault}; - /// List available accounts (public keys or addresses). - /// Returns a list of account identifiers (e.g. 20-byte Ethereum addresses). + /// Account addresses (20-byte EVM) the host will sign for. Empty means + /// no signing capability. accounts: func() -> result>, fault>; - /// Sign arbitrary data with the specified account's private key. - /// Returns the signature bytes (e.g. 65-byte ECDSA signature with recovery id). - sign: func(account: list, data: list) -> result, fault>; + /// personal_sign semantics; returns a 65-byte signature. + sign: func(account: list, message: list) -> result, fault>; - /// Sign EIP-712 typed structured data. - /// `typed-data` is the JSON-encoded EIP-712 typed data structure. - /// Returns the signature bytes. + /// Sign EIP-712 typed data; `typed-data` is JSON-encoded. sign-typed-data: func(account: list, typed-data: string) -> result, fault>; } ``` -**Platform implementations:** - -| Platform | `identity` backed by | -|----------|---------------------| -| Server (Nexum) | Keystore file, AWS KMS, or HSM | -| Mobile (Flutter) | Device keychain (Keystore/Keychain) or wallet SDK | -| WebView | window.ethereum (wallet extension) or native bridge to keychain | -| Super app | Device keychain + per-module permission grants | - -**Relationship with `chain`:** - -The `chain` host implementation uses `identity` internally when it encounters signing methods. For example, when a module calls `chain::request` with `eth_sendTransaction`, the host: - -1. Constructs the transaction from the JSON-RPC params. -2. Calls `identity::sign` to produce the signature. -3. Sends the signed transaction via the provider. - -This means modules that only need to sign transactions via standard JSON-RPC methods do not need to import `identity` directly - `chain` handles it transparently. Modules that need raw signing (e.g. off-chain message signing for order submission, attestations, or custom protocols) import `identity` explicitly. - -### `local-store` - Local Key-Value Persistence +### `local-store` -The module's private scratchpad. **Local to the device/process** - does not replicate, sync, or share across instances. Scoped to one module: module A cannot read module B's local state. +The module's private, device-local scratchpad. Scoped to one module: module A cannot read module B's state. Does not replicate. ```wit interface local-store { use types.{fault}; - /// Get a value by key. Returns None if the key does not exist. get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites any existing value. - /// The host MAY enforce a size quota; if exceeded, returns fault.invalid-input. + /// The host may enforce a size quota; if exceeded, `set` returns err. set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if the key does not exist. delete: func(key: string) -> result<_, fault>; - - /// List all keys matching a prefix. Empty prefix returns all keys. + /// Empty prefix returns all keys. list-keys: func(prefix: string) -> result, fault>; + contains: func(key: string) -> result; + len: func(key: string) -> result, fault>; + count: func(prefix: string) -> result; } ``` -**Platform implementations:** - -| Platform | `local-store` backed by | -|----------|-------------------------| -| Server (Nexum) | redb (per-module database file, ACID, MVCC) | -| Mobile (Flutter) | SQLite (per-module table or database, via `sqflite`) | -| WebView | IndexedDB (per-module object store) or `localStorage` | -| Super app | SQLite (shared database, per-module namespace isolation) | - -The semantics are deliberately minimal - get, set, delete, prefix scan. This is the LCD (lowest common denominator) that every platform can implement efficiently. Advanced features (transactions, MVCC, crash-safety) are host-specific and not exposed in the WIT. +The server runtime's transactional semantics (doc 04) are an implementation detail, not a cross-platform guarantee; modules that need stronger guarantees design for idempotency. -The server runtime's all-or-nothing transactional semantics (doc 04) remain an implementation detail of the Nexum host, not a guarantee modules can rely on across platforms. Modules that need stronger guarantees should design for idempotency. +### `remote-store` -### `remote-store` - Decentralised Content-Addressed Storage - -Backed by Ethereum Swarm. Provides decentralised persistence beyond the local device - content-addressed, censorship-resistant, and accessible from any host on any device. - -Swarm is both the distribution mechanism (modules are fetched from Swarm) and a runtime capability. This interface closes the loop - modules can publish to the same network they were distributed through. +Backed by Ethereum Swarm: content-addressed persistence beyond the local device, plus mutable feeds. The same network modules are distributed through (docs 02, 03). ```wit interface remote-store { use types.{fault}; - /// Upload raw data to the decentralised store. /// Returns the 32-byte content reference (Swarm address). - /// - /// The host routes to its configured Bee node. Postage batch - /// management is the host's responsibility - the module only - /// provides data and gets back a reference. upload: func(data: list) -> result, fault>; - - /// Download raw data by 32-byte content reference. - /// - /// The host fetches from its Bee node or a public gateway. - /// Returns the raw bytes. The caller is responsible for - /// interpreting the content (JSON, protobuf, WASM, etc.). download: func(reference: list) -> result, fault>; - /// Read the latest value from a mutable feed. - /// - /// Feeds are mutable pointers: (owner, topic) -> latest chunk. - /// `owner`: 20-byte Ethereum address of the feed owner. - /// `topic`: 32-byte topic hash. - /// - /// Returns None if the feed has no updates. - read-feed: func( - owner: list, - topic: list, - ) -> result>, fault>; - - /// Update a mutable feed with new data. - /// - /// The host signs the feed update with its configured identity - /// (Bee node's Ethereum key). Only the host's own feeds can be - /// updated - the owner is implicit (the host's address). - /// - /// `topic`: 32-byte topic hash. - /// `data`: the payload to publish. - /// - /// Returns the 32-byte reference of the new chunk. - write-feed: func( - topic: list, - data: list, - ) -> result, fault>; + /// Feeds are mutable pointers (owner, topic) -> latest chunk. + read-feed: func(owner: list, topic: list) -> result>, fault>; + /// The host signs the update with its configured identity; the owner + /// is implicit. Returns the 32-byte reference of the new chunk. + write-feed: func(topic: list, data: list) -> result, fault>; } ``` -**Platform implementations:** - -| Platform | `remote-store` backed by | -|----------|--------------------------| -| Server (Nexum) | Direct Bee API (`http://localhost:1633`) | -| Mobile (Flutter) | Bee API via HTTP (local light node or remote gateway) | -| WebView | JavaScript bridge -> native HTTP to Bee gateway | -| Super app | Embedded Bee light node or gateway proxy | - -**Why remote-store as a universal interface:** +### `messaging` -- **Decentralised persistence.** `local-store` is device-local. `remote-store` gives modules access to content-addressed storage that persists independent of any single device. -- **Content distribution.** Modules can publish data (feeds, references) that other modules or users can consume - without a central server. -- **Cross-device coordination.** Two instances of the same module on different devices can share data via feed topics - one writes via `write-feed`, the other reads via `read-feed`. -- **Consistency with distribution model.** Modules are already fetched from Swarm (doc 02, 03). Exposing `remote-store` at runtime means modules participate in the same content-addressed network they were distributed through. - -### `messaging` - Decentralised Messaging - -Backed by Waku. Provides real-time, privacy-preserving pub/sub messaging between modules, devices, and users. Unlike `remote-store` (persistent, content-addressed), `messaging` is transient and topic-based - fire-and-forget messages on content topics. +Backed by Waku: transient, topic-based pub/sub. Sending uses `publish`; receiving is declared as a manifest subscription and delivered through `on-event`. ```wit interface messaging { - use types.{fault}; - - record message { - content-topic: string, - payload: list, - timestamp: u64, // milliseconds since Unix epoch, UTC - /// Optional sender identity (protocol-dependent). - sender: option>, - } + use types.{fault, message}; - /// Publish a message to a content topic. - /// - /// The host routes to its configured Waku node. The message is - /// propagated to all subscribers of the content topic via the - /// Waku relay (gossipsub) or light push protocol. - /// - /// Content topics follow the format: //// - /// e.g. "/nexum/1/twap-updates/proto" + /// Content topics follow ////. publish: func(content-topic: string, payload: list) -> result<_, fault>; - - /// Query historical messages from the Waku store protocol. - /// - /// Returns messages matching the content topic within the - /// optional time range. Not all hosts support store queries - /// (depends on Waku node configuration). query: func( content-topic: string, start-time: option, @@ -311,68 +149,24 @@ interface messaging { } ``` -**Receiving messages** is handled through the event system, not the `messaging` interface. Modules declare message subscriptions in their manifest, and the host delivers them as events: - ```toml [[subscription]] kind = "message" content_topic = "/nexum/1/twap-updates/proto" ``` -The event variant in 0.2 carries `message` as a first-class variant: - -```wit -record message { - content-topic: string, - payload: list, - timestamp: u64, // milliseconds since Unix epoch, UTC - sender: option>, -} - -variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), -} -``` - -This follows the same pattern as all other event sources: sending uses the import interface (`messaging::publish`), receiving uses the declarative subscription + `on-event` dispatch. - -**Platform implementations:** - -| Platform | `messaging` backed by | -|----------|-----------------| -| Server (Nexum) | Waku node (nwaku or go-waku) via JSON-RPC or REST API | -| Mobile (Flutter) | Waku light client via FFI (libwaku) or HTTP to remote Waku node | -| WebView | JavaScript bridge -> native Waku client, or js-waku in-browser | -| Super app | Embedded Waku light node | - -**Why messaging as a universal interface:** - -- **Module-to-module communication.** Two modules on different devices can exchange real-time messages via shared content topics. The TWAP monitor on a server can notify a mobile dashboard module that a new part was posted. -- **User notifications.** A headless server module can publish an alert to a content topic; the user's mobile app module subscribes and displays a notification. -- **Decentralised coordination.** Multiple instances of the same module (e.g. running on different operator nodes) can coordinate via messaging - leader election, work distribution, heartbeats. -- **Privacy.** Waku supports encrypted messaging and ephemeral relay. Modules can communicate without exposing data to the public chain. -- **Complementary to remote-store.** `remote-store` is for persistent content (data that should survive). `messaging` is for ephemeral signals (notifications, coordination, real-time feeds). Together they cover the full persistence spectrum. - -### `logging` - Structured Logging - -Unchanged from the current design: +### `logging` ```wit interface logging { enum level { trace, debug, info, warn, error } - - /// Emit a structured log message. - /// The host decides how to handle it (stdout, file, discard). log: func(level: level, message: string); } ``` -Every platform implements this trivially. On server: `tracing` crate. On mobile: platform logger (`android.util.Log`, `os_log`). In WebView: `console.log`. The SDK's `info!`, `debug!`, etc. macros compile to this. +### Universal world definition -### Universal World Definition +The shared types and the `event-module` world: ```wit package nexum:host@0.1.0; @@ -380,70 +174,39 @@ package nexum:host@0.1.0; interface types { type chain-id = u64; - record block { - chain-id: chain-id, - number: u64, - hash: list, - timestamp: u64, // ms since Unix epoch, UTC - } + record block { chain-id: chain-id, number: u64, hash: list, timestamp: u64 } record chain-log { - address: list, - topics: list>, - data: list, - block-hash: option>, // block-scoped fields absent on a pending log - block-number: option, - block-timestamp: option, - transaction-hash: option>, - transaction-index: option, - log-index: option, - removed: bool, + address: list, topics: list>, data: list, + block-hash: option>, block-number: option, + block-timestamp: option, transaction-hash: option>, + transaction-index: option, log-index: option, removed: bool, } - - // A batch of logs from one subscription; the alloy log carries no chain - // id, so it sits here once and every log shares the subscription's chain. - record chain-logs { - chain-id: chain-id, - logs: list, - } - - record tick { - fired-at: u64, // ms since Unix epoch, UTC - } - + record chain-logs { chain-id: chain-id, logs: list } + record tick { fired-at: u64 } record message { - content-topic: string, - payload: list, - timestamp: u64, // ms since Unix epoch, UTC + content-topic: string, payload: list, timestamp: u64, sender: option>, } + /// A domain extension's own event kind and opaque payload; the core + /// routes by `kind` and never reads `payload`. + record custom-event { kind: string, payload: list } variant event { - block(block), - chain-logs(chain-logs), - tick(tick), - message(message), + block(block), chain-logs(chain-logs), tick(tick), + message(message), custom(custom-event), } - /// Opaque config (typed variant deferred to 0.3). type config = list>; - /// Shared cross-domain failure vocabulary. Richer interfaces embed it - /// as a case; interfaces with nothing to add report it directly. variant fault { unsupported(string), unavailable(string), denied(string), rate-limited(rate-limit), timeout, invalid-input(string), internal(string), } - - record rate-limit { - retry-after-ms: option, - } + record rate-limit { retry-after-ms: option } } -// ... chain, identity, local-store, remote-store, messaging, logging interfaces as above ... - -/// Event-driven module - automation, background processing. -/// No UI capabilities. Runs on any conforming host. Six imports in 0.2. +/// Event-driven module. No UI capabilities. Runs on any conforming host. world event-module { import chain; import identity; @@ -457,129 +220,22 @@ world event-module { } ``` -A module compiled against `nexum:host/event-module` is the **maximally portable** artifact. In 0.2 it runs on the server reference runtime; mobile and WebView hosts are planned (see the status banner at the top of this doc). - -## Layer 2: UI Interface - -Interactive modules - those with a user-facing presence in a super app or WebView container - import the `ui` interface in addition to the Layer 1 universals. - -### Design Approach - -The `ui` interface is a **bridge**, not a rendering engine. It does not define a widget tree, layout system, or styling language. Instead, it provides the communication channel between the module's logic (running in WASM) and the host's UI surface (a WebView, native view, or terminal). - -For WebView-based hosts (the primary target for interactive modules), the module's UI is a web application (HTML/CSS/JS) served into a WebView by the host. The `ui` interface gives the module control over this surface and access to native capabilities that a normal web page cannot reach. - -```wit -interface ui { - record ui-error { - code: u16, - message: string, - } +Time, randomness, and outbound HTTP are WASI concerns, not `nexum:host` interfaces: `wasi:clocks` and `wasi:random` are linked into every module store; `wasi:http/outgoing-handler` is linked gated per-module by the `[capabilities.http].allow` allowlist. - /// Emit a UI update. - /// - /// For WebView hosts: `content` is an HTML fragment or a JSON - /// message that the WebView's JavaScript layer interprets. - /// For native hosts: `content` is a declarative description - /// (format negotiated via host-info). - /// - /// The `target` identifies which UI surface to update - /// (e.g. "main", "overlay", "notification-badge"). - render: func(target: string, content: string) -> result<_, ui-error>; - - /// Request navigation to a different view or module. - /// - /// `target`: a route string (e.g. "/settings", "module:price-alert"). - /// `params`: key-value parameters for the target. - navigate: func( - target: string, - params: list>, - ) -> result<_, ui-error>; - - /// Show a native notification (outside the WebView). - notify: func(title: string, body: string) -> result<_, ui-error>; - - /// Prompt the user for a yes/no decision via native dialog. - confirm: func(title: string, body: string) -> result; - - /// Query the host's UI capabilities. - record host-capabilities { - /// "android" | "ios" | "desktop" | "web" | "terminal" - platform: string, - /// Content format the host expects for render(). - /// "html" | "json-widget" | "markdown" - render-format: string, - supports-notifications: bool, - supports-biometric: bool, - } - - host-info: func() -> host-capabilities; -} -``` +The experimental `nexum:host/query-module` world (a pure `evaluate` entry point over `local-store` and `logging`) is published but has no host implementation in 0.2. -### Module Exports for Interactive Modules +## Layer 2: Capability Extensions -Interactive modules export additional lifecycle hooks beyond `init` and `on-event`: +A capability adds imports to a module's manifest-derived world beyond the Layer 1 core. Two are additive, declared in the manifest's `[capabilities]` section and not part of the six-primitive core: -```wit -/// Interactive module - has a UI presence. -world app-module { - include event-module; - import ui; - - /// Called when the module's UI surface is first displayed. - /// Returns initial content to render. - export on-render: func() -> result; - - /// Called when the user interacts with a UI element. - /// - /// `element-id`: identifier of the element (set by the module in its render output). - /// `action`: interaction type ("click", "submit", "change", etc.). - /// `data`: optional payload (form data, input value, etc.). - export on-interact: func( - element-id: string, - action: string, - data: option, - ) -> result<_, string>; -} -``` +- `client`: the `videre:venue/client` intent surface (see [Layer 3](#layer-3-domain-extensions-venue-adapters)). +- `http`: allowlisted `wasi:http/outgoing-handler`. -This creates a bidirectional loop: - -```mermaid -flowchart TD - A["Host calls on-render"] --> B["Module returns initial UI content"] - B --> C["Host displays in WebView/native surface"] - C --> D["User interacts"] - D --> E["Host calls on-interact(element, action, data)"] - E --> F["Module processes interaction"] - F --> G["module calls ui::render(target, new-content) to update UI"] - F --> H["module calls chain::request to read chain state"] - F --> I["module calls local-store::set to persist"] - G --> C -``` - -The module's logic runs in the WASM sandbox. The UI runs in the WebView (or native surface). The `ui` interface is the bridge between them. This is analogous to Elm's update loop or React's message-passing model, but across the WASM-host boundary. - -### WebView Module Packaging - -A WebView-based interactive module bundles its web assets alongside the WASM component: - -``` -price-dashboard/ -├── module.toml # manifest (declares world: app-module) -├── module.wasm # compiled WASM component -└── ui/ - ├── index.html # entry point (loaded into WebView) - ├── app.js # UI logic (receives on-interact, calls render) - └── style.css # styling -``` - -The host loads `index.html` into a WebView and injects the bridge JavaScript that connects DOM events to `on-interact` and `ui::render` calls to DOM updates. +A `ui` interface for interactive modules (a bridge between module logic and a host UI surface) is designed but ships no WIT; there is no `app-module` world in 0.2. ## Layer 3: Domain Extensions (Venue Adapters) -A domain (CoW Protocol, a DEX, a lending market) extends the platform as a **venue adapter**: a component authored with `#[videre_sdk::venue]` that exports the `videre:venue/adapter` interface and imports scoped transport only (`chain`, `messaging`, allowlisted `wasi:http`). This is the domain-extension mechanism - the domain's wire protocol, body codec, and error projection live inside the adapter component, and nothing domain-specific enters the host or any module world. +A domain (CoW Protocol, a DEX, a lending market) extends the platform as a **venue adapter**: a component authored with `#[videre_sdk::venue]` that exports the `videre:venue/adapter` interface and imports scoped transport only (`chain`, `messaging`, allowlisted `wasi:http`). The domain's wire protocol, body codec, and error projection live inside the adapter component; nothing domain-specific enters the host or any module world. ```wit package videre:venue@0.1.0; @@ -610,406 +266,47 @@ interface adapter { } ``` -The two faces meet in the host. The venue platform (`crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this whole path - typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). +The two faces meet in the host. The venue platform (`crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this path; typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). -A new domain therefore adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter - the engine itself stays venue- and cow-free. +A new domain adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter. ### The legacy read path: `shepherd:cow` -The retired predecessor to venue adapters was a Layer-3 *world* extension: the `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world including `event-module` and importing it, and a host-side extension cone implementing the interface over a cached orderbook client. That model put the domain in the module's own world and a backend in every host that ran it; each new domain would have needed its own world, host cone, and SDK glue. - -The path is deleted: the `cow-api` interface, the `shepherd`/`cow-ext` worlds, and the host cone are gone, and orderbook I/O lives in the `cow-venue` adapter behind `videre:venue/client`. The `shepherd:cow` package remains only as `cow-events`, the package of record for the CoW on-chain event ABIs (signatures and topic-0 hashes) that keeper manifests and decoders are parity-tested against. [ADR-0005](adr/0005-cow-api-via-cached-orderbookapi.md) and [ADR-0006](adr/0006-cow-twap-ethflow-host-helpers.md) are superseded accordingly. +The retired predecessor to venue adapters was a Layer-3 *world* extension: a `shepherd:cow/cow-api` interface (orderbook passthrough plus `submit-order`), a `shepherd` world importing it, and a host-side extension cone implementing it over a cached orderbook client. That path is deleted. The `cow-api` interface, the `shepherd` / `cow-ext` worlds, and the host cone are gone; orderbook I/O lives in the `cow-venue` adapter behind `videre:venue/client`. The `shepherd:cow` package remains only as `cow-events`, the package of record for the CoW on-chain event ABIs (signatures and topic-0 hashes) that keeper manifests and decoders are parity-tested against. [ADR-0005](adr/0005-cow-api-via-cached-orderbookapi.md) and [ADR-0006](adr/0006-cow-twap-ethflow-host-helpers.md) are superseded accordingly. ## Complete WIT Package Layout ``` wit/ ├── nexum-host/ -│ ├── types.wit # chain-id, block, log, tick, message, event, config, fault -│ ├── chain.wit # chain interface (consensus access + request-batch) -│ ├── identity.wit # identity interface (key management, signing) -│ ├── local-store.wit # local-store interface -│ ├── remote-store.wit # remote-store interface (Swarm) -│ ├── messaging.wit # messaging interface (Waku) -│ ├── logging.wit # logging interface +│ ├── types.wit # chain-id, block, log, tick, message, custom-event, event, config, fault +│ ├── chain.wit # chain interface (request + request-batch) +│ ├── identity.wit # identity interface (accounts, signing) +│ ├── local-store.wit +│ ├── remote-store.wit # Swarm +│ ├── messaging.wit # Waku +│ ├── logging.wit │ ├── event-module.wit # event-module world (6 imports) -│ └── query-module.wit # experimental: query-module world (no host impl in 0.2) +│ └── query-module.wit # experimental: no host impl in 0.2 │ ├── videre-value-flow/ │ └── types.wit # asset + asset-amount vocabulary -│ ├── videre-types/ │ └── types.wit # intent-header, quotation, receipt, submit-outcome, intent-status, venue-error -│ ├── videre-venue/ │ └── venue.wit # client + adapter interfaces, venue-adapter world -│ └── shepherd-cow/ └── cow-events.wit # CoW event-ABI package of record (legacy package name) ``` -The `nexum-host` package is domain-agnostic and reusable. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages: the universal and venue layers are closed. (The `ui` interface and `app-module` world are design-only and ship no WIT yet.) +The `nexum-host` package is domain-agnostic. The `videre` packages are the venue-neutral intent contract. `shepherd-cow` carries only the CoW event ABIs. New domains add adapter components, not packages. ## Platform Targets -### Server Runtime (Reference Implementation - Nexum) - -This is the current design (docs 01-07), adapted for the layered WIT. Shepherd is the Nexum composition root that registers the videre venue platform and bundles the `cow-venue` adapter. - -| Interface | Implementation | -|-----------|---------------| -| `chain` | alloy provider with tower middleware (timeout, retry, rate-limit, fallback) | -| `identity` | Keystore file, AWS KMS, or HSM - operator-configured signing backend | -| `local-store` | redb (per-module database file, ACID, MVCC, crash-safe) | -| `remote-store` | Bee API (`http://localhost:1633`) - operator runs a Bee node | -| `messaging` | Waku node (nwaku) via JSON-RPC or REST API | -| `logging` | `tracing` crate -> JSON structured logs | -| `videre:venue/client` | videre-host `VenueRegistry` -> installed venue adapter components (CoW via the bundled `cow-venue` adapter over allowlisted `wasi:http`) | -| Event sources | `eth_subscribe` (blocks, logs), cron (Tokio interval), Waku relay (messages) | -| WASM engine | wasmtime 45.x (Component Model, fuel, epoch metering) | - -### Mobile App (Flutter/Dart) - Planned - -> **Status:** No mobile host ships in 0.2. The design below is the target architecture for a future release (0.3+, conditional on a named design partner). It's retained because the WIT contract was shaped to make this implementation possible, and the `query-module` world in 0.2 is the experimental contract a mobile/wallet embedder would target. - -A Flutter application would embed a WASM runtime and provide the universal interfaces via Dart implementations: - -```mermaid -flowchart TD - subgraph FlutterApp["Flutter App"] - subgraph WASMRuntime["WASM Runtime (via FFI)"] - ENGINE["wasmtime C API or wasmer_dart or wasm3 (lightweight, C-based)"] - ModA["Module A (headless)"] - ModB["Module B (headless)"] - end - - subgraph HostAdapter["Host Adapter (Dart)"] - HA_CHAIN["chain -> HTTP client to RPC endpoint"] - HA_ID["identity -> device keychain (Keystore/Keychain) or wallet SDK"] - HA_LS["local-store -> SQLite (sqflite)"] - HA_RS["remote-store -> HTTP to Bee gateway"] - HA_MSG["messaging -> libwaku via FFI"] - HA_LOG["logging -> platform logger"] - end - - subgraph EventSources["Event Sources (Dart)"] - ES["Block polling (HTTP, no WebSocket on mobile background), timer via Dart Timer, Waku subscription via light client, push notifications (optional)"] - end - - ModA --> HostAdapter - ModB --> HostAdapter - HostAdapter --> EventSources - end -``` - -**WASM engine options:** - -| Engine | Component Model | Mobile support | Notes | -|--------|----------------|----------------|-------| -| wasmtime (C API) | Full | aarch64 (iOS/Android ARM64) | Best compatibility, largest binary size (~15 MB) | -| wasmer | Partial | Good (wasmer_dart exists) | Component Model support is partial | -| wasm3 | None | Excellent (tiny C library, ~100 KB) | Interpreter only, no Component Model - requires core module + shim | - -For full Component Model support (identical module binaries across server and mobile), **wasmtime via C API** is the recommended path. Dart's FFI (`dart:ffi`) can call the wasmtime C API directly. The binary size cost (~15 MB) is acceptable for a mobile app. - -**Mobile-specific constraints:** - -- **Background execution.** iOS and Android aggressively suspend background processes. A mobile host cannot maintain persistent WebSocket subscriptions. Event sourcing must be adapted: poll on foreground, use push notifications or local alarms for time-sensitive events. -- **Battery.** Continuous block polling drains battery. The mobile host should use adaptive polling intervals and batch event processing. -- **Connectivity.** Mobile networks are intermittent. Host functions should handle offline gracefully (queue requests, retry on reconnect). -- **Waku light client.** Mobile devices should use Waku's light push and filter protocols rather than full relay to minimise bandwidth and battery consumption. - -### WebView (Browser Engine + Injected Host Functions) - Planned - -> **Status:** No WebView host ships in 0.2. The architecture below describes a future target. The `jco`-based transpilation path is the strongest candidate, but it depends on Component Model browser support stabilising and on a concrete embedder design partner. - -A WebView host would run inside a native app (or standalone browser). The WASM module executes in the browser's native WASM engine. Host functions are injected via a JavaScript bridge. - -```mermaid -flowchart TD - subgraph NativeApp["Native App Shell"] - subgraph WebView["WebView"] - WASMModule["WASM Module (browser's WASM engine)\nCalls imported functions:\nchain.request(...)\nidentity.sign(...)\nlocalStore.get(...)\nremoteStore.download(...)\nmessaging.publish(...)\nlogging.log(...)"] - - subgraph JSBridge["JavaScript Bridge (injected)"] - JS["window.nexumRuntime = {\n chain: { request: (c, m, p) =>\n nativeBridge.call('chain', ...) },\n identity: { accounts: () =>\n nativeBridge.call('identity', ...) },\n localStore: { get: (k) =>\n nativeBridge.call('store', ..) },\n remoteStore: { download: (ref) =>\n nativeBridge.call('store', ..) },\n messaging: { publish: (t, p) =>\n nativeBridge.call('messaging', ...) },\n logging: { log: (l, m) =>\n console.log(${`[l] m`}) }\n}"] - end - - WASMModule --> JSBridge - end - - subgraph NativeHost["Native Host Adapter"] - NH_CHAIN["chain -> HTTP to RPC / wallet bridge"] - NH_ID["identity -> window.ethereum / native keychain"] - NH_LS["local-store -> SQLite / IndexedDB"] - NH_RS["remote-store -> HTTP to Bee gateway"] - NH_MSG["messaging -> Waku node / js-waku"] - NH_LOG["logging -> native logger"] - end - - JSBridge -->|"message channel"| NativeHost - end -``` - -**Component Model in the browser:** - -Browsers don't natively support the WASM Component Model (as of early 2026). Two approaches: - -1. **`jco` transpilation** (recommended). The Bytecode Alliance's `jco` tool transpiles a WASM component to a core WASM module + JavaScript glue code. The JS glue implements the canonical ABI marshalling. The result runs in any browser. This means the **same `.wasm` component** built for the server can be transpiled and run in a WebView. - -2. **Core module variant.** Compile the module as a core WASM module (not a component) with a JS shim layer that maps the WIT interface to JavaScript imports. This requires a separate build target but avoids the `jco` dependency. - -Approach 1 is preferred - it preserves the single-artifact property (one `.wasm` component, multiple platforms). - -**WebView-specific capability: `window.ethereum`** +The reference server runtime is the sole host. `shepherd` is the composition root: it registers the videre venue platform and bundles the `cow-venue` adapter over the core lattice (alloy provider pool for `chain`, redb for `local-store`, Bee for `remote-store`, Waku for `messaging`, `tracing` for `logging`, wasmtime Component Model engine). -In a browser context, the user may have a wallet extension (MetaMask, Rabby, etc.) that injects `window.ethereum`. The `chain::request` host function can optionally route through this: - -```javascript -// In the JS bridge -chain: { - request: async (chainId, method, params) => { - if (window.ethereum && useWalletProvider) { - // Route through user's wallet (gets signing capabilities too) - return await window.ethereum.request({ method, params: JSON.parse(params) }); - } else { - // Route through native bridge to configured RPC endpoint - return await nativeBridge.call('chain', { chainId, method, params }); - } - } -} -``` - -This is powerful: the same module that runs headless on a server (reading chain state via a configured RPC endpoint) can run in a WebView and read chain state via the user's wallet - gaining access to the user's connected accounts and signing capabilities. - -Similarly, the `identity` interface in a WebView context can delegate to `window.ethereum` for account enumeration and signing, providing a seamless bridge between the module's signing needs and the user's wallet extension. - -**WebView-specific capability: `js-waku`** - -For messaging in the browser, `js-waku` provides a pure JavaScript Waku client. The `messaging` host function can route through `js-waku` directly in the WebView without needing the native bridge - peer-to-peer messaging from the browser. - -### Decentralised Super App - Planned - -> **Status:** The super app is the convergence of the mobile and WebView targets. No super-app host ships in 0.2. The content below describes the target architecture for a future release once mobile and WebView are live. - -The super app is the convergence of all targets. A native shell (Flutter) that would: - -1. **Discover modules** via ENS (doc 03) - the same discovery mechanism as the server runtime. -2. **Fetch modules** from Swarm/IPFS - the same content-addressed distribution. -3. **Run event-driven modules** in an embedded WASM runtime (automation, background tasks). -4. **Run interactive modules** in WebViews (UI, dashboards, transaction builders). -5. **Provide the universal interfaces** to all modules (chain, identity, local-store, remote-store, messaging, logging). -6. **Provide the UI interface** to interactive modules. - -```mermaid -flowchart TD - subgraph SuperApp["Super App Shell (Flutter)"] - subgraph ModMgr["Module Manager"] - DISC["Discovery: ENS -> Swarm -> content store -> verify"] - LIFE["Lifecycle: Load -> Init -> Run -> Restart -> Dead"] - PERM["Permissions: per-module capability grants"] - end - - subgraph HeadlessRT["Headless WASM Runtime"] - TWAP["TWAP Monitor"] - PRICE["Price Alert"] - end - - subgraph WebViewPool["WebView Pool"] - PORTFOLIO["Portfolio Dashboard (HTML)"] - DEX["DEX Swap Interface (HTML)"] - end - - subgraph HostLayer["Host Adapter Layer"] - HL_CHAIN["chain -> HTTP to RPC endpoints"] - HL_ID["identity -> device keychain + per-module grants"] - HL_LS["local-store -> SQLite"] - HL_RS["remote-store -> Bee light node / gateway"] - HL_MSG["messaging -> Waku light client"] - HL_LOG["logging -> app logger + optional cloud"] - HL_UI["ui -> WebView bridge (interactive modules)"] - end - - subgraph ShellUI["Shell UI (Flutter)"] - SHELL["Module gallery - Navigation - Settings - Wallet"] - end - - ModMgr --> HeadlessRT - ModMgr --> WebViewPool - TWAP --> HostLayer - PRICE --> HostLayer - PORTFOLIO --> HostLayer - DEX --> HostLayer - end -``` - -**What makes this different from Telegram/WeChat mini-programs:** - -| Aspect | Telegram/WeChat | Decentralised Super App | -|--------|-----------------|------------------------| -| Distribution | Central app store / bot platform | ENS -> Swarm/IPFS (no gatekeeper) | -| Integrity | Trust the platform | Content-addressed (hash-verified) | -| Execution | JavaScript in WebView (unrestricted) | WASM sandbox (capability-based) | -| Capabilities | Platform APIs (payments, camera, etc.) | Blockchain-native (consensus, identity, state, messaging) | -| Updates | Platform-mediated | Author updates ENS -> instant propagation | -| Censorship resistance | Platform can ban apps | ENS + Swarm = no single point of removal | -| Interoperability | Walled garden | Modules from any author, any domain | -| Communication | Platform's messaging API | Waku (decentralised, privacy-preserving) | - -**Permissions model:** - -The super app adds a capability-grant layer on top of the WIT world. When a module is installed, the user reviews what it imports: - -``` -"TWAP Monitor" requests: - ✓ chain - read blockchain state (chains: 42161) - ✓ identity - sign with your accounts - ✓ local-store - store data on your device - ✓ remote-store - read/write to Swarm network - ✓ messaging - send/receive messages (topics: /nexum/1/twap-*) - ✗ ui - (not requested - event-driven module) - ✓ client - submit intents to installed venues (cow) - - [Allow] [Deny] -``` - -The host only links interfaces the user has approved. A module that doesn't import `messaging` structurally cannot publish messages - the same structural sandboxing property that the server runtime uses (doc 01). - -## Host Adapter Specification - -Any platform that wants to run modules must implement the **Host Adapter** - the set of host functions backing the WIT interfaces. The specification defines the contract: - -### Required Behaviours - -Each interface returns its own typed error over the shared `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). The fault case is normative - embedders MUST pick the most specific case for each backend failure. See ADR-0011 for the embedder-side mapping table. - -**`chain::request` / `chain::request-batch`** (Chain) -- MUST forward the JSON-RPC request to a provider for the given chain. -- MUST return the JSON-encoded result (the `result` field from the JSON-RPC response). -- MUST return `chain-error` for provider errors, method-not-found, and transport failures. A structured JSON-RPC error (a node code plus decoded revert bytes) MUST use the `rpc` case; otherwise use a `fault`: `invalid-input` for method-not-found, `unavailable`/`timeout` for transport, `rate-limited` for 429s, `denied` for 401/403. -- SHOULD enforce a method allowlist (configurable by the operator/user). -- MAY apply middleware (timeout, retry, rate-limit, fallback) - this is platform-specific. - -**`identity::accounts/sign/sign-typed-data`** (Identity) -- `accounts` MUST return the list of available account identifiers (addresses) for the current host configuration. -- `sign` MUST produce a valid cryptographic signature over the provided data using the specified account's private key. -- `sign-typed-data` MUST produce a valid EIP-712 signature over the provided typed data structure. -- MUST return a `fault`. User rejection is `denied`; unknown account is `invalid-input`; backend offline is `unavailable`. -- MAY prompt the user for approval before signing (platform-dependent - e.g. wallet extension popup in WebView, biometric prompt on mobile). -- SHOULD NOT expose private key material to the module. The module sends data in, gets a signature out. - -**`local-store::get/set/delete/list-keys`** -- MUST provide per-module isolation (module A cannot read module B's state). -- MUST persist across module restarts within the same host process/session. -- SHOULD persist across host process restarts (platform-dependent). -- MAY enforce size quotas. If exceeded, `set` returns `fault.invalid-input` (not a trap). -- MAY provide transactional semantics. Modules SHOULD NOT rely on this across platforms. - -**`remote-store::upload/download/read-feed/write-feed`** -- MUST route to a Swarm-compatible node or gateway. -- `upload` MUST return the 32-byte content reference of the stored data. -- `download` MUST return the raw bytes for a valid reference, or `fault.unavailable` for missing/unreachable content. -- `write-feed` signs with the host's identity. The owner is implicit. -- MAY return `fault.unavailable` for offline / no-node-configured. - -**`messaging::publish/query`** -- MUST route `publish` to a Waku-compatible node. -- `publish` MUST deliver the message to the content topic's relay network on a best-effort basis. -- `query` SHOULD return historical messages if the host's Waku node supports the store protocol. -- `query` MAY return an empty list or `fault.unsupported` if store is unavailable. -- MAY apply rate limits (returning `fault.rate-limited`) to prevent message spam. - -**`logging::log`** -- MUST accept log calls without blocking or erroring. -- MAY discard logs (e.g. below a configured level threshold). -- Output destination is entirely host-specific. - -**Event dispatch (`on-event`)** -- MUST call `init(config)` exactly once before any `on-event` calls. -- MUST call `on-event` for each subscribed event (per manifest). -- MUST support all four event variants: `block`, `logs`, `tick`, `message`. -- SHOULD guarantee in-order delivery within a single module. -- MAY dispatch events concurrently across modules. -- SHOULD handle panics/traps gracefully (restart module, not crash host). - -### Optional Behaviours (Platform-Specific) - -| Capability | Server | Mobile | WebView | -|------------|--------|--------|---------| -| Fuel metering | Yes (wasmtime) | Maybe (engine-dependent) | No (browser engine) | -| Epoch interruption | Yes (Tokio task) | No | No (browser manages scheduling) | -| Memory limits | Yes (`ResourceLimiter`) | Limited (engine-dependent) | No (browser enforces its own limits) | -| Transactional state | Yes (redb write txn) | Optional (SQLite txn) | No (IndexedDB is async) | -| WebSocket subscriptions | Yes | Limited (background constraints) | Yes (if tab is active) | -| Push-based events | N/A | Yes (FCM/APNs) | N/A | -| Waku full relay | Yes | No (light client) | Maybe (js-waku) | - -## Content-Addressed Distribution: Works Everywhere - -The packaging and distribution model (doc 02, 03) is already platform-agnostic: - -``` -Module author: - 1. Build WASM component - 2. Create manifest (module.toml) - 3. Upload bundle to Swarm -> get content hash - 4. Set ENS contenthash -> content hash - -Any host (server, mobile, WebView): - 1. Resolve ENS name -> contenthash - 2. Fetch bundle from Swarm (or IPFS/OCI/HTTP gateway) - 3. Verify sha256(module.wasm) matches manifest - 4. Load module -``` - -The only platform-specific part is **how** the host fetches from Swarm: -- Server: direct Bee API -- Mobile: Bee gateway over HTTP -- WebView: fetch API to Bee gateway - -The content hash is the trust anchor. The transport is interchangeable. +Mobile (Flutter embedding a WASM runtime), WebView (browser WASM engine with host functions injected over a JavaScript bridge), and a decentralised super app (ENS discovery plus Swarm distribution over the same interfaces) are design directions the portable WIT contract is shaped to admit; none ships in 0.2. ## SDK Layering -The SDK mirrors the architecture, one crate per layer, with no re-export between them. See [doc 05](05-sdk-design.md) for the full treatment. - -- **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, the `#[nexum_sdk::module]` attribute macro, chain / config / address helpers, the `http` fetch seam over wasi:http, the keeper store primitives, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore`, `Messaging`, and `Signer` typed wrappers as future direction. Any module author - CoW, DeFi, gaming, whatever - uses this. - -- **`videre-sdk` (shipped)** - the venue layer, serving both venue sides: the `VenueAdapter` trait under `#[videre_sdk::venue]` for adapter authors, and the `IntentBody` codec, typed `VenueClient` and `#[videre_sdk::keeper]` for keeper authors, plus the generic run assembler and the `videre-test` conformance kit alongside. - -- **Per-venue crates (shipped for CoW)** - each domain ships as crates on the venue layer, not as an SDK layer: `cow-venue` (body codec, typed client, adapter component) and `composable-cow` (conditional-order keeper machinery). A new domain adds its own. - -A generic automation module depends only on `nexum-sdk`; a keeper adds `videre-sdk` and the venue's body crate; a venue adapter depends on `videre-sdk` and its own crate. - -For **non-Rust** module authors (JavaScript, Python, Go, C++), the SDK is unnecessary - they use `wit-bindgen` directly against the WIT package for their target world. The WIT is the universal contract; the SDK is a Rust ergonomics layer on top. - -## Summary - -### Primitive Taxonomy - -| Primitive | Interface | Implementation | Persistence | Scope | -|-----------|-----------|---------------|-------------|-------| -| Chain | `chain` | JSON-RPC (eth_*) | Blockchain | Global (chain) | -| Identity | `identity` | Keystore / KMS / HSM | Key material | Per-account | -| Local Store | `local-store` | redb / SQLite / IndexedDB | Device-local | Per-module | -| Remote Store | `remote-store` | Ethereum Swarm | Decentralised | Global (content-addressed) | -| Messaging | `messaging` | Waku | Ephemeral | Topic-based pub/sub | -| Logging | `logging` | tracing / console | None | Diagnostic | - -### Architecture - -| Concept | Scope | -|---------|-------| -| `nexum:host` WIT package | Universal - any blockchain app, any platform | -| `event-module` world (0.2, shipping) | Event-driven modules - server today, mobile/background planned | -| `query-module` world (0.2 experimental) | Request/response modules - WIT published, no host impl in 0.2 | -| `app-module` world | Interactive modules - design only; planned hosts | -| `videre:types` / `videre:value-flow` / `videre:venue` WIT packages | Venue-neutral intent contract: types, asset vocabulary, client + adapter faces, venue-adapter world | -| `shepherd:cow` WIT package | CoW event-ABI package of record (`cow-events` only; the legacy `cow-api` read path and `shepherd` world are retired) | -| Venue adapter components | The domain-extension mechanism: `#[videre_sdk::venue]` components installed into the videre platform (`cow-venue` shipped) | -| `nexum-sdk` crate (shipped) | Universal Rust SDK: host-trait seam (ADR-0009), Fault / ChainError, bind macro, module macro, chain / config / address helpers, keeper store primitives, guest `http` helper, tracing facade | -| `videre-sdk` crate (shipped) | Venue Rust SDK: VenueAdapter + venue macro, IntentBody codec, typed VenueClient + keeper macro, run assembler; `videre-test` conformance kit alongside | -| Content-addressed distribution | Platform-agnostic (Swarm/IPFS, ENS discovery, hash verification) | -| Host Adapter | Platform-specific implementation of universal interfaces | - -The module binary is the portable artifact. The WIT contract is the universal interface. The host adapter is the platform-specific implementation. Everything else - packaging, distribution, discovery, SDK - layers cleanly on top. +[Doc 05](05-sdk-design.md) owns the SDK. In summary: `nexum-sdk` is the universal SDK for `nexum:host/event-module`; `videre-sdk` is the venue layer serving both venue faces; per-venue crates (`cow-venue`, `composable-cow`) sit on the venue layer. No crate re-exports another. Non-Rust authors use `wit-bindgen` directly against the WIT. diff --git a/docs/design/linker-extension-seam.md b/docs/design/linker-extension-seam.md index f24d4b19..45703ef1 100644 --- a/docs/design/linker-extension-seam.md +++ b/docs/design/linker-extension-seam.md @@ -1,121 +1,46 @@ # The linker extension seam -## Why +## What -The core host binds the `nexum:host/event-module` world: the six core -primitives (chain, identity, local-store, remote-store, messaging, logging) -plus the allowlisted wasi:http outgoing surface. A domain capability such as -cow-api is not a core seam. It plugs into the host through an extension seam -that is assembled at the composition root, so the core runtime compiles and -runs with no domain backend at all (`Ext = ()`, no hooks registered). +The core host binds the `nexum:host/event-module` world: the six core primitives (chain, identity, local-store, remote-store, messaging, logging) plus the allowlisted `wasi:http` outgoing surface. A domain capability such as a venue platform is not a core seam. It plugs into the host through an extension assembled at the composition root, so the core runtime compiles and runs with no domain backend at all (`Ext = ()`, no extensions registered). -An extension contributes four things that travel together: +## The `Extension` trait -1. an `Ext` payload carrying its backend, held in the runtime `HostState`; -2. a linker hook that adds its WIT interfaces to each module linker; -3. a capability namespace so enforcement recognises its imports; -4. its own operator config, parsed from the `[extensions.]` table - of `engine.toml`. +One trait, `Extension` (`host::extension`), is what a domain contributes. Its members: -## The seam +- `namespace()`: the namespace it owns; keys its service on `HostServices`. +- `capabilities() -> NamespaceCaps`: the `{ prefix, ifaces }` merged into +enforcement so a module importing its interfaces still validates. +- `link(&mut Linker>)`: adds its WIT imports to each worker +linker, after the core interfaces and before instantiation. Takes only `&mut Linker`, never the wasmtime `Store` (not `Sync`), so the seam stays compatible with a future per-extension call router that serializes access to a `Store`. +- `service() -> Option>`: a type-erased service +published under the namespace on the shared `HostServices` map and downcast at the call site. +- `provider() -> Option>>`: a provider component +kind (e.g. the venue-adapter kind) the extension installs. +- `manifest_sections`, `admit_provider`, `admit_worker`: the non-core +manifest sections it claims and its install-time predicates over them (an `Err` refuses the install fail-fast). +- `subscriptions`, `events`: the manifest subscription kinds it emits and +the event sources it opens once the engine is booted. -### `Ext` slot and the `ExtState` accessor +An extension defines its own `bindgen!` for its world, generating a `Host` trait local to the extension, and implements it for the foreign `HostState` (orphan-legal: the trait is local). It reaches its backend either through the `HostServices` map (`state.services.get::(namespace)`, downcast) or, for a per-store payload, through the `ExtState` accessor over the lattice `Ext` slot (`RuntimeTypes::Ext`, held as `HostState.ext`). The shipped venue platform uses the service map. The bindgen shares `nexum:host/types` with the core bindings via `with`, so the extension's `fault` is the same type the core host constructs. -`RuntimeTypes` names an associated `Ext: Clone + Send + Sync + 'static`. The -per-module `HostState` holds one `ext: T::Ext`. The generic accessor -trait is the load-bearing piece: +## Registration and enforcement -```rust -pub trait ExtState { - type Ext; - fn ext(&self) -> &Self::Ext; -} -impl ExtState for HostState { - type Ext = T::Ext; - fn ext(&self) -> &Self::Ext { &self.ext } -} -``` +`CapabilityRegistry` starts from the core namespace (`nexum:host/`) and registers each extension's namespace; `enforce_capabilities` and manifest name validation both consult it. The composition root assembles the `Vec>>` once and threads it through the runtime builder (`with_extensions`), which builds the linker and the registry from it; the supervisor caches the list so the module-restart path rebuilds an identical linker. -An extension defines its own `bindgen!` for its world. That generates a -`Host` trait local to the extension. The extension implements it for the -foreign `HostState`, which is orphan-legal because the trait is local. -To reach its own backend without knowing the concrete lattice `T`, the impl -goes through `ExtState::ext`, then bounds the payload on an -extension-defined trait: +An extension lives in its own crate depending on the runtime for the seam types (`HostState`, `Extension`, the `nexum:host/types` bindgen) and depended on by the composition-root binary. The runtime carries no dependency on any extension crate, so a domain cone stays out of the bare engine. The `shepherd` binary registers one extension, the videre venue platform (`videre_host::platform`), through its `Runtime::extensions` impl. -```rust -pub trait CowBackend { type Cow: CowApi; fn cow(&self) -> &Self::Cow; } +## Extension config -impl cow_bindings::...::Host for HostState -where T: RuntimeTypes, T::Ext: CowBackend { - async fn request(&mut self, ...) { self.ext().cow().request(...).await } -} -``` - -Two traits, two owners: `ExtState` is the runtime's generic reach into the -slot; `CowBackend` is the extension's own payload shape. The bindgen shares -`nexum:host/types` with the core bindings via `with`, so the extension's -`fault` is the same type the core host constructs, and `cow-api-error` embeds -it alongside the extension's own `http` and `rejected` cases. - -### Linker hook and capability registry - -An extension is one value: - -```rust -pub struct Extension { - pub link: LinkerHook, // Arc Result<()>> - pub capabilities: NamespaceCaps, // { prefix, ifaces } -} -``` - -`build_linker` binds the core world then runs each hook. `CapabilityRegistry` -starts from the core namespace (`nexum:host/`) and registers each extension's -namespace; `enforce_capabilities` and manifest name validation both consult -it. The composition root (`nexum-cli`'s `launch::run_from_config`) assembles -the `Extension` list once and threads it into the generic -`nexum_runtime::bootstrap::run`, which builds the linker and the registry -from it. The supervisor caches the list so the module-restart path rebuilds -an identical linker. - -An extension such as cow-api lives in its own crate (`shepherd-cow-host`) -that depends on the runtime for the seam types (`HostState`, `Extension`, -the `nexum:host/types` bindgen) and is depended on by `nexum-cli` at the -composition root. The runtime carries no dependency on any extension crate, -so the cow cone stays out of the bare engine. - -The hook takes only `&mut Linker`, never the wasmtime `Store` (which is not -`Sync`). This keeps the seam compatible with a future per-extension call -router that serialises access to a `Store`. - -### Extension config - -`engine.toml` stays domain-free. The engine deserialises every -`[extensions.]` table into an opaque `toml::Value` -(`EngineConfig::extensions`) and never interprets it; the composition root -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). +`engine.toml` stays domain-free. The engine deserializes every `[extensions.]` table into an opaque `toml::Value` (`EngineConfig::extensions`) and never interprets it; the composition root hands each extension its own entry to parse. Venue adapter components install from the `[[adapters]]` table. ## 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: +Modules built through `#[nexum_sdk::module]` compile against a per-module world derived from their manifest's `[capabilities]`, so a module that never declares an extension capability has no such import and boots with a core-only linker by construction. 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 +- the extension's linker hook is registered (else an unsatisfied-import +trap), AND - the extension's capability namespace is registered (else the manifest's - declaration of that capability is rejected as unknown, or the imported - interface is not recognised as a declared capability). +declaration of that capability is rejected as unknown). -Therefore the linker hook and the capability namespace of an extension MUST -be registered as a pair, from the same `Extension` value, before any module -is instantiated. Registering one without the other is a boot-time failure, -not a compile-time one. This is exercised in both directions: the runtime's -supervisor tests pin the negative (a cow-importing module fails to boot with -the extension absent), and `shepherd-cow-host`'s own boot tests pin the -positive (the same module boots and dispatches with the extension present). +Therefore the linker hook and the capability namespace of an extension MUST be registered as a pair, from the same `Extension` value, before any module is instantiated. Registering one without the other is a boot-time failure, not a compile-time one. From 789867250ea1ca6029edc6760c589f05b090630a Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:10:05 +0000 Subject: [PATCH 113/139] docs: rewrite sdk and rpc namespace docs to shipped contract (#601) * docs: rewrite sdk and rpc design docs to the shipped contract Rewrite 07 (rpc namespace) from a pre-code design essay into a terse description of the shipped chain interface: the chain.request / request-batch WIT surface, the closed ChainMethod read-only surface enforced host-side, the separate identity signing interface, the HostTransport / ProviderHost / block_on alloy provider seam, synchronous module handlers, videre:venue as the order-submission path, and the MockHost testing seam. Delete the aspirational cow-api namespace, shepherd-sdk crate, #[shepherd::module] macro, the Cow SDK type, MockProvider and MockCow, and the async-handler / block_on-elimination / provider-injection narrative that never shipped. Fix 05's videre-sdk crate layout: poll_once lives in client.rs, not a non-existent rt.rs, and add the event.rs and value_flow.rs entries. Part of #598. * docs: unwrap paragraphs to one logical line for diff-friendliness Hard-wrapped prose churns diffs: a one-word edit reflows the whole paragraph. Join each paragraph onto a single logical line and let it soft-wrap. Content unchanged (word and heading counts preserved); code fences, tables, lists, blockquotes and headings untouched. --- docs/05-sdk-design.md | 282 ++----- docs/07-rpc-namespace-design.md | 1237 +------------------------------ 2 files changed, 92 insertions(+), 1427 deletions(-) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index af944182..05ece59c 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,43 +1,20 @@ # SDK Design: The Two-Persona SDK -This document describes the guest-side SDK crates. There are two -personas, and both are shipped: the **module author**, served by -`nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which -covers both sides of a venue: the adapter author who speaks one venue's -protocol, and the keeper author who drives venues through the typed -client. - -For the architectural decision behind the host-trait seam both personas -build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the -rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc -under `crates/nexum-sdk/` and `crates/videre-sdk/`. +This document describes the guest-side SDK crates. There are two personas, and both are shipped: the **module author**, served by `nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which covers both sides of a venue: the adapter author who speaks one venue's protocol, and the keeper author who drives venues through the typed client. + +For the architectural decision behind the host-trait seam both personas build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc under `crates/nexum-sdk/` and `crates/videre-sdk/`. ## The two personas 1. **Module author.** Writes an automation module against - `nexum:host/event-module`: react to blocks, chain logs, ticks, or - messages; read and write local state. Served by `nexum-sdk` plus the - `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). +`nexum:host/event-module`: react to blocks, chain logs, ticks, or messages; read and write local state. Served by `nexum-sdk` plus the `#[nexum_sdk::module]` attribute macro (from `nexum-module-macros`). 2. **Venue author.** Writes the component that exposes a trading venue - (CoW Protocol, a DEX, a lending market, ...) to modules through the - `videre:venue` intent surface, so a module author never sees the - venue's wire format. Served by `videre-sdk`: the `VenueAdapter` - trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the - `videre-test` conformance kit. - -A keeper - a module that drives venues - sits between the two: it is a -module by world, but it authors with `#[videre_sdk::keeper]` and calls -venues through the typed `VenueClient`. The domain itself (CoW, a DEX) -lives in the venue adapter, never in the host: see -[doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). - -Each persona has its own proc-macro crate (`nexum-module-macros`, -`videre-macros`), reached through the SDK re-exports. Both share the -same host-trait philosophy: guest code is written against small Rust -traits that mirror the WIT interfaces one-for-one, so strategy logic -can be unit-tested against an in-memory mock without a `wasm32-wasip2` -toolchain or a running wasmtime instance. +(CoW Protocol, a DEX, a lending market, ...) to modules through the `videre:venue` intent surface, so a module author never sees the venue's wire format. Served by `videre-sdk`: the `VenueAdapter` trait under `#[videre_sdk::venue]`, the `IntentBody` derive, and the `videre-test` conformance kit. + +A keeper - a module that drives venues - sits between the two: it is a module by world, but it authors with `#[videre_sdk::keeper]` and calls venues through the typed `VenueClient`. The domain itself (CoW, a DEX) lives in the venue adapter, never in the host: see [doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). + +Each persona has its own proc-macro crate (`nexum-module-macros`, `videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so strategy logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. ## Crate layout @@ -63,11 +40,12 @@ videre-sdk/ # venue SDK: both venue sides ├── lib.rs # crate docs, macro re-exports (venue, keeper, IntentBody) ├── adapter.rs # VenueAdapter trait (init + the five intent functions) ├── body.rs # IntentBody trait + BodyError (versioned borsh codec) - ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues + ├── client.rs # Venue, VenueId, VenueClient, VenueTransport, HostVenues; poll_once completes async handlers on the sync guest boundary ├── keeper.rs # Keeper::run - the generic run assembler; Outcome, RunReport ├── transport.rs # HostChain, HostMessaging, http, BoundedFetch ├── faults.rs # VenueFault + conversions across wire fault / SDK fault / VenueError - ├── rt.rs # completes async keeper handlers on the sync guest boundary + ├── event.rs # intent-status event decoding for on_intent_status handlers + ├── value_flow.rs # value-flow asset helpers (erc20 amounts) └── bindings.rs # the shared import-only bindgen the macros remap onto videre-macros/ # #[venue], #[keeper], derive(IntentBody) (proc-macro) @@ -79,24 +57,15 @@ cow-venue/ # the CoW venue, as feature slices composable-cow/ # ComposableCoW keeper machinery (body, poll seam, run) ``` -`nexum-sdk` is host-neutral and domain-free: any module targeting the -runtime pulls helpers and canonical primitive types from it regardless -of which world it exports. `videre-sdk` layers the venue platform's -guest surface on top. Domain crates (`cow-venue`, `composable-cow`) -depend on the SDKs; nothing is re-exported between layers. +`nexum-sdk` is host-neutral and domain-free: any module targeting the runtime pulls helpers and canonical primitive types from it regardless of which world it exports. `videre-sdk` layers the venue platform's guest surface on top. Domain crates (`cow-venue`, `composable-cow`) depend on the SDKs; nothing is re-exported between layers. -Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over -`ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and -keepers, and `videre-test` for adapters. See -[Testing](#testing-nexum-sdk-test-and-videre-test) below. +Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over `ChainHost` / `LocalStoreHost` / `LoggingHost`) for modules and keepers, and `videre-test` for adapters. See [Testing](#testing-nexum-sdk-test-and-videre-test) below. ## Module-author persona: `nexum-sdk` ### The host-trait seam -`nexum-sdk` never calls `wit_bindgen`-generated functions directly. -Instead `nexum_sdk::host` exposes small traits that mirror the WIT -interfaces: +`nexum-sdk` never calls `wit_bindgen`-generated functions directly. Instead `nexum_sdk::host` exposes small traits that mirror the WIT interfaces: ```rust pub trait ChainHost { @@ -115,44 +84,15 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -Strategy code takes `&impl Host` (or a narrower -`` bound when it only needs part of the -surface) so tests inject `nexum_sdk_test::MockHost` while the compiled -module injects the wit-bindgen-backed adapter. See -[ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and -[ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error -model. +Strategy code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject `nexum_sdk_test::MockHost` while the compiled module injects the wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and [ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error model. ### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` -Every module keeps its own `wit_bindgen::generate!` call (the macro -emits types into the calling crate; re-exporting wit-bindgen output -from a library crate would duplicate symbols and break the -component-export contract). What the SDK removes is the ~80 lines of -mechanical glue that used to sit next to it: the -`nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a -`WitBindgenHost` struct, the trait impls over the generated import -shims, the `Fault` / `ChainError` converters in both directions, a -`Level` converter, a `From for nexum_sdk::events::Log` impl, -and an `install_tracing()` helper that routes `tracing::info!(...)` -through the bound host logging call. The adapter is -capability-selected: the zero-argument form emits the full set for -blanket-world modules, and the `caps: [chain, logging]` form (what -`#[nexum_sdk::module]` generates from the manifest) emits only the -pieces whose imports the module's world carries. +Every module keeps its own `wit_bindgen::generate!` call (the macro emits types into the calling crate; re-exporting wit-bindgen output from a library crate would duplicate symbols and break the component-export contract). What the SDK removes is the ~80 lines of mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` declarative macro emits a `WitBindgenHost` struct, the trait impls over the generated import shims, the `Fault` / `ChainError` converters in both directions, a `Level` converter, a `From for nexum_sdk::events::Log` impl, and an `install_tracing()` helper that routes `tracing::info!(...)` through the bound host logging call. The adapter is capability-selected: the zero-argument form emits the full set for blanket-world modules, and the `caps: [chain, logging]` form (what `#[nexum_sdk::module]` generates from the manifest) emits only the pieces whose imports the module's world carries. ### The `#[nexum_sdk::module]` macro -`nexum-module-macros` ships one attribute macro, re-exported as -`nexum_sdk::module`. Apply it to an inherent `impl` block whose -methods are named event handlers - `init`, `on_block`, -`on_chain_logs`, `on_tick`, `on_message` - and the macro reads the -crate's `module.toml`, synthesizes the per-module world from its -`[capabilities]`, and generates the `wit_bindgen::generate!` call for -that world, the capability-selected `bind_host_via_wit_bindgen!` -invocation, a `Guest` implementation whose `on_event` dispatches to -whichever handlers are present (absent handlers become a no-op for -that event), and `export!`: +`nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose methods are named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - and the macro reads the crate's `module.toml`, synthesizes the per-module world from its `[capabilities]`, and generates the `wit_bindgen::generate!` call for that world, the capability-selected `bind_host_via_wit_bindgen!` invocation, a `Guest` implementation whose `on_event` dispatches to whichever handlers are present (absent handlers become a no-op for that event), and `export!`: ```rust // modules/examples/http-probe/src/lib.rs (shipped) @@ -181,49 +121,21 @@ impl HttpProbe { Two things worth being precise about: - **The world is derived from the manifest.** The macro generates - against a per-module world whose imports are exactly the - `[capabilities].required`/`optional` declarations: a chain + - local-store module simply has no `identity` bindings to call. Its - imports equal its declarations by construction, and the runtime's - capability check is a backstop rather than a consumer of toolchain - dead-import elision. +against a per-module world whose imports are exactly the `[capabilities].required`/`optional` declarations: a chain + local-store module simply has no `identity` bindings to call. Its imports equal its declarations by construction, and the runtime's capability check is a backstop rather than a consumer of toolchain dead-import elision. - **Handlers are synchronous.** `init` and the named handlers are - plain `fn`, called directly with no `block_on` wrapper. Modules - call `host.request(chain_id, method, params_json)` (or the - `chain::eth_call_params` / `parse_eth_call_result` helpers) directly - against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). - (Keeper handlers are the exception: see below.) - -The `Guest`/`export!` shape the macro emits follows the `strategy.rs` -(pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus -the macro attribute) split from ADR-0009. The keeper primitives in -`nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, -`Poller`, `Retrier` - give conditional-commitment modules -a shared set of `LocalStoreHost` conventions instead of hand-rolled -key schemes; `videre_sdk::keeper` assembles them into the generic -run. +plain `fn`, called directly with no `block_on` wrapper. Modules call `host.request(chain_id, method, params_json)` (or the `chain::eth_call_params` / `parse_eth_call_result` helpers) directly against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). (Keeper handlers are the exception: see below.) + +The `Guest`/`export!` shape the macro emits follows the `strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, `Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic run. ## Venue persona: `videre-sdk` ### The venue contract -An adapter targets the `videre:venue/venue-adapter` world: it exports -the `adapter` interface (`body-versions`, `derive-header`, `quote`, -`submit`, `status`, `cancel`) plus `init`, and imports scoped -transport only - `chain`, `messaging`, and allowlisted `wasi:http`. -No local-store, remote-store, identity, or logging import: an adapter -structurally cannot touch host key material or persistent state. -Keepers reach venues through the host-implemented -`videre:venue/client` interface, which mirrors `adapter` with a venue -selector per call. The types (`intent-header`, `quotation`, -`submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in -`videre:types`, over the `videre:value-flow` asset vocabulary. +An adapter targets the `videre:venue/venue-adapter` world: it exports the `adapter` interface (`body-versions`, `derive-header`, `quote`, `submit`, `status`, `cancel`) plus `init`, and imports scoped transport only - `chain`, `messaging`, and allowlisted `wasi:http`. No local-store, remote-store, identity, or logging import: an adapter structurally cannot touch host key material or persistent state. Keepers reach venues through the host-implemented `videre:venue/client` interface, which mirrors `adapter` with a venue selector per call. The types (`intent-header`, `quotation`, `submit-outcome`, `receipt`, `intent-status`, `venue-error`) live in `videre:types`, over the `videre:value-flow` asset vocabulary. ### Bodies: the `IntentBody` derive -A venue's intent body is opaque on the wire; typing is a guest-side -agreement between keeper and adapter, spelled as an outer per-venue -version enum under `#[derive(videre_sdk::IntentBody)]`: +A venue's intent body is opaque on the wire; typing is a guest-side agreement between keeper and adapter, spelled as an outer per-venue version enum under `#[derive(videre_sdk::IntentBody)]`: ```rust #[derive(videre_sdk::IntentBody)] @@ -232,36 +144,15 @@ enum EchoBody { } ``` -The wire form is the borsh enum layout: a one-byte version tag (the -variant's declaration index) then the borsh payload - so the tag order -is the schema: append new versions, never reorder. Decoding an unknown -tag fails typedly as `BodyError::UnknownVersion` rather than as a -stringly decode error. The versions an adapter decodes are declared in -its manifest `[venue] body_versions` and asserted at install against -its `body-versions` export; a keeper declares the single -`[venue] body_version` it encodes and install refuses it unless every -installed adapter decodes that version. +The wire form is the borsh enum layout: a one-byte version tag (the variant's declaration index) then the borsh payload - so the tag order is the schema: append new versions, never reorder. Decoding an unknown tag fails typedly as `BodyError::UnknownVersion` rather than as a stringly decode error. The versions an adapter decodes are declared in its manifest `[venue] body_versions` and asserted at install against its `body-versions` export; a keeper declares the single `[venue] body_version` it encodes and install refuses it unless every installed adapter decodes that version. ### `#[videre_sdk::venue]` -The single blessed venue authoring path. Apply it to the adapter's -`impl VenueAdapter for MyVenue` block: the macro reads the crate's -`module.toml`, asserts its `[module] kind` is `venue-adapter`, -synthesizes a per-component world exporting the `videre:venue/adapter` -face and importing exactly the manifest's declared scoped transport, -then emits the `wit_bindgen::generate!` call, the untouched trait -impl, and the export glue. An undeclared capability's bindings do not -exist (using one is a compile error), and a capability outside the -venue-permitted set (`chain`, `messaging`, `http`) is rejected at -expansion. The generated world remaps the shared interfaces onto -`videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly -and shares type identity with the conformance kit and the client core. +The single blessed venue authoring path. Apply it to the adapter's `impl VenueAdapter for MyVenue` block: the macro reads the crate's `module.toml`, asserts its `[module] kind` is `venue-adapter`, synthesizes a per-component world exporting the `videre:venue/adapter` face and importing exactly the manifest's declared scoped transport, then emits the `wit_bindgen::generate!` call, the untouched trait impl, and the export glue. An undeclared capability's bindings do not exist (using one is a compile error), and a capability outside the venue-permitted set (`chain`, `messaging`, `http`) is rejected at expansion. The generated world remaps the shared interfaces onto `videre_sdk::bindings`, so the impl speaks `videre_sdk` types directly and shares type identity with the conformance kit and the client core. ### The typed client and `#[videre_sdk::keeper]` -The wire carries opaque bodies and a stringly venue selector; typing -is recovered in `videre_sdk::client`. A keeper names a venue once, as -a `Venue` marker carrying its `VenueId` and body schema: +The wire carries opaque bodies and a stringly venue selector; typing is recovered in `videre_sdk::client`. A keeper names a venue once, as a `Venue` marker carrying its `VenueId` and body schema: ```rust struct CowVenue; @@ -271,38 +162,15 @@ impl Venue for CowVenue { } ``` -`VenueClient` then drives the venue with typed bodies - `quote` -returns a `Quoted` typestate whose `submit` sends exactly the priced -bytes - encoding through `IntentBody` before the byte-level, -native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the -module's own `videre:venue/client` import; tests implement -`VenueTransport` in memory. - -`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it -to an `impl` block whose associated functions are the event handlers -(`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, -`on_intent_status`). It requires the `client` capability (the -`videre:venue/client` import is what makes a keeper a keeper), wires -that import onto the SDK's shared shims, and lets handlers be `async` -so they can await the typed client directly; -`videre_sdk::client::poll_once` completes the futures on the -synchronous guest boundary. A -`From` impl onto the wire fault is emitted, so `?` -applies to client calls inside handlers. - -`videre_sdk::keeper::Keeper::run` assembles the world-neutral -`nexum_sdk::keeper` stores - `WatchSet` to `Gates` to -`Poller::poll` to `Retrier` to `Journal` - over the -`VenueTransport` seam, so a conditional-commitment keeper writes one -`poll` producing the shared `Outcome` outcome and inherits the whole -gate/journal/retry pass. +`VenueClient` then drives the venue with typed bodies - `quote` returns a `Quoted` typestate whose `submit` sends exactly the priced bytes - encoding through `IntentBody` before the byte-level, native-AFIT `VenueTransport` seam. `HostVenues` binds the seam to the module's own `videre:venue/client` import; tests implement `VenueTransport` in memory. + +`#[videre_sdk::keeper]` is the keeper mirror of `#[module]`: apply it to an `impl` block whose associated functions are the event handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_intent_status`). It requires the `client` capability (the `videre:venue/client` import is what makes a keeper a keeper), wires that import onto the SDK's shared shims, and lets handlers be `async` so they can await the typed client directly; `videre_sdk::client::poll_once` completes the futures on the synchronous guest boundary. A `From` impl onto the wire fault is emitted, so `?` applies to client calls inside handlers. + +`videre_sdk::keeper::Keeper::run` assembles the world-neutral `nexum_sdk::keeper` stores - `WatchSet` to `Gates` to `Poller::poll` to `Retrier` to `Journal` - over the `VenueTransport` seam, so a conditional-commitment keeper writes one `poll` producing the shared `Outcome` outcome and inherits the whole gate/journal/retry pass. ### Testing: `nexum-sdk-test` and `videre-test` -Keeper strategy logic tests exactly as module logic does: against the -host traits with `nexum_sdk_test::MockHost`, plus an in-memory -`VenueTransport` for the client seam. Tests run as plain native Rust - -no `wasm32-wasip2` target, no wasmtime instance, no network. +Keeper strategy logic tests exactly as module logic does: against the host traits with `nexum_sdk_test::MockHost`, plus an in-memory `VenueTransport` for the client seam. Tests run as plain native Rust - no `wasm32-wasip2` target, no wasmtime instance, no network. ```rust use nexum_sdk::host::*; @@ -318,28 +186,20 @@ assert_eq!(host.chain.calls().len(), 1); Adapters are held to the `videre-test` conformance kit instead: - **`CodecVectors`** - the venue's `IntentBody` wire bytes as a JSON - file (bytes as lowercase hex). A Rust adapter checks its derived - enum with `assert_conforms`; a non-Rust author reads the same file - and proves byte-exactness without linking Rust. +file (bytes as lowercase hex). A Rust adapter checks its derived enum with `assert_conforms`; a non-Rust author reads the same file and proves byte-exactness without linking Rust. - **`HeaderGoldens`** - published bodies paired with the header a - conforming `derive-header` projects from them. +conforming `derive-header` projects from them. - **`MockTransport`** - the three transports an adapter is granted - (chain, messaging, outbound HTTP) as programmable in-memory mocks - behind the SDK's own seams. +(chain, messaging, outbound HTTP) as programmable in-memory mocks behind the SDK's own seams. -(The runtime crate separately ships a feature-gated component-level -harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a -compiled `.wasm` plus manifest under real wasmtime; that is -runtime-internal tooling, not part of either SDK contract.) +(The runtime crate separately ships a feature-gated component-level harness - `nexum-runtime`'s `test_utils::TestRuntime` - that loads a compiled `.wasm` plus manifest under real wasmtime; that is runtime-internal tooling, not part of either SDK contract.) ## Walkthrough: authoring a venue on videre -The shipped reference pair is `modules/examples/echo-venue` (adapter) -and `modules/examples/echo-keeper` (driver); the production instance -of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. +The shipped reference pair is `modules/examples/echo-venue` (adapter) and `modules/examples/echo-keeper` (driver); the production instance of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. 1. **Declare the manifest.** A venue adapter is a component with a - `module.toml` whose kind names it: +`module.toml` whose kind names it: ```toml [module] @@ -359,7 +219,7 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. ``` 2. **Implement `VenueAdapter`.** One impl block under the macro; the - five intent functions plus `init` and `body_versions`: +five intent functions plus `init` and `body_versions`: ```rust use videre_sdk::{IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueAdapter, VenueError}; @@ -378,18 +238,13 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. } ``` - `derive_header` is the policy seam: it projects the guard-facing - `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) - from a body, pure and I/O-free, and the host's egress guard runs on - it before every submit. +`derive_header` is the policy seam: it projects the guard-facing `IntentHeader` (`gives`, `wants`, `settlement`, `authorisation`) from a body, pure and I/O-free, and the host's egress guard runs on it before every submit. 3. **Publish the fixtures.** Ship the venue's codec vectors and header - goldens, and hold the adapter to them with `videre-test` in the - crate's tests. Non-Rust keeper authors read the same files. +goldens, and hold the adapter to them with `videre-test` in the crate's tests. Non-Rust keeper authors read the same files. 4. **Build and install.** Build the cdylib for `wasm32-wasip2` - (`cargo build --target wasm32-wasip2 --release -p echo-venue`) and - install it via the engine config: +(`cargo build --target wasm32-wasip2 --release -p echo-venue`) and install it via the engine config: ```toml [[adapters]] @@ -398,13 +253,10 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. http_allow = [] # the operator's outbound-HTTP grant ``` - Install boots the component, checks the `body-versions` handshake - against the manifest, and registers the venue under its manifest - name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). +Install boots the component, checks the `body-versions` handshake against the manifest, and registers the venue under its manifest name in the `VenueRegistry` ([doc 08](08-platform-generalisation.md)). 5. **Drive it from a keeper.** A keeper declares the `client` - capability plus its `[venue] body_version`, names the venue as a - `Venue` marker, and speaks types end to end: +capability plus its `[venue] body_version`, names the venue as a `Venue` marker, and speaks types end to end: ```rust #[videre_sdk::keeper] @@ -420,54 +272,32 @@ of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. } ``` - An accepted submit is watched implicitly: the registry polls the - adapter's `status` and fans transitions back as `intent-status` - events, which the keeper subscribes to in its manifest and handles - in `on_intent_status`. +An accepted submit is watched implicitly: the registry polls the adapter's `status` and fans transitions back as `intent-status` events, which the keeper subscribes to in its manifest and handles in `on_intent_status`. ## The CoW venue -CoW ships as the production instance of the persona, in two crates so -the venue stays orderbook-only: +CoW ships as the production instance of the persona, in two crates so the venue stays orderbook-only: - **`cow-venue`** - feature slices. `body` (default, `no_std`): the - order intent body types and codec, light enough for any keeper or - adapter to carry. `client`: the typed `CowClient` bound to the CoW - venue, the deterministic `intent_id` journal key, and the - table-driven retry classification generated from the shipped - `data/classification.toml`. `assembly`: the chain-edge order - projections and orderbook submission bodies. `adapter`: the venue - adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, - manifest at `crates/cow-venue/module.toml`). +order intent body types and codec, light enough for any keeper or adapter to carry. `client`: the typed `CowClient` bound to the CoW venue, the deterministic `intent_id` journal key, and the table-driven retry classification generated from the shipped `data/classification.toml`. `assembly`: the chain-edge order projections and orderbook submission bodies. `adapter`: the venue adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, manifest at `crates/cow-venue/module.toml`). - **`composable-cow`** - the ComposableCoW keeper machinery, kept out - of the venue: the conditional-order `ComposableBody`, the structured - poll seam (`Verdict`, with the deployed 1.x reverting wire - quarantined behind `LegacyRevertAdapter`, per - [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the - `run` slice composing the poll loop over the typed `CowClient`. +of the venue: the conditional-order `ComposableBody`, the structured poll seam (`Verdict`, with the deployed 1.x reverting wire quarantined behind `LegacyRevertAdapter`, per [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the `run` slice composing the poll loop over the typed `CowClient`. -The shipped CoW keepers - `modules/twap-monitor`, -`modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` -modules on this surface. +The shipped CoW keepers - `modules/twap-monitor`, `modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` modules on this surface. ## Non-Rust module and adapter authors -For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is -relevant - they generate bindings directly from the WIT package for -their target world with their language's `wit-bindgen`, and prove body -conformance against the venue's published `videre-test` fixture files. -The WIT is the universal contract; both Rust SDKs are an ergonomics -layer on top of it, not a requirement. +For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is relevant - they generate bindings directly from the WIT package for their target world with their language's `wit-bindgen`, and prove body conformance against the venue's published `videre-test` fixture files. The WIT is the universal contract; both Rust SDKs are an ergonomics layer on top of it, not a requirement. ## Where to go next - [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry - point. +point. - [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam - decision this document builds on. +decision this document builds on. - [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed - error model the host traits return. +error model the host traits return. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough - design and why module authors call `host.request` directly. +design and why module authors call `host.request` directly. - [doc 08](08-platform-generalisation.md) - the layered WIT and why - venue adapters are the domain-extension mechanism. +venue adapters are the domain-extension mechanism. diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 25246aac..117e636d 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -1,1243 +1,78 @@ -# RPC Namespace Design: Generic JSON-RPC Passthrough +# RPC Namespace Design: the `chain` interface -> **Status: Partially shipped.** The reference runtime ships the single -> `chain::request(chain_id, method, params)` WIT entry point, and the -> seam behind it is typed to a closed read-only method set: a method -> outside that surface is refused with a `chain-error` carrying a `denied` fault before it -> reaches the provider. The rest of the "Method Allowlisting" section -> below remains design intent: per-module `[module.chain] -> extra_allowed_methods` (now bounded by the typed surface rather than -> free-form) and identity-delegated signing methods are not wired into -> `chain::request` in the shipped binary. +Modules reach chain state through one host function, `chain.request`, plus a batch form `chain.request-batch`. A single generic JSON-RPC entry point means no WIT change per method: the guest SDK layers an alloy `Provider` on top, so every read method on the permitted surface works without host-side per-method plumbing. -> **Naming note (0.2):** This document describes the `chain` interface in the -> `nexum:host` WIT package. In the 0.1 design history it was called `chain` -> (short for "consensus"); 0.2 renamed it to `chain` because `chain.request(...)` -> reads itself at the call site. The function signatures below are the 0.2 shape, -> returning `chain-error` rather than the 0.1-era `json-rpc-error`. -> -> **SDK-shape note (0.2):** The macro-driven authoring model below (`#[nexum::module]` / `#[shepherd::module]` with named event handlers and `&RootProvider` injection) and the separate `nexum-sdk` crate are **future direction, not in 0.2 scope** - see [ADR-0009](adr/0009-host-trait-surface.md) for the shipped host-trait seam that replaces the macro design. 0.2 modules call `host.request(chain_id, method, params_json)` directly against `ChainHost`. The WIT contract for `chain` is unchanged; only the guest-side ergonomics differ. +## The WIT interface -## Problem Statement - -The 0.1 design started with a `blockchain` interface that defined individual functions for each Ethereum RPC method: - -```wit -interface blockchain { - eth-call: func(chain-id: chain-id, to: list, data: list) -> result, string>; - eth-get-logs: func(filter: log-filter) -> result, string>; - eth-block-number: func(chain-id: chain-id) -> result; -} -``` - -This creates several problems: - -1. **Boilerplate multiplication.** Every new `eth_` method requires changes in three places: WIT definition, host trait implementation, and SDK wrapper. The Ethereum JSON-RPC namespace has 30+ methods; most modules will need more than the three currently exposed. - -2. **Alloy incompatibility.** Module authors using Rust cannot use alloy's `Provider` API - which provides 80+ typed convenience methods - because the transport layer is locked behind per-method WIT functions. They're forced to manually ABI-encode calldata, call `blockchain::eth_call`, and ABI-decode the result for every interaction. - -3. **Namespace rigidity.** Adding a `cow_` namespace for CoW Protocol API calls would duplicate the same per-method pattern. Future namespaces (debug_, trace_, etc.) compound this further. - -The goal: **one WIT function to rule the entire `eth_` namespace**, with a guest-side SDK that gives module authors the full alloy `Provider` API - no manual ABI wrangling, no WIT changes when new methods are needed. - -## Design: Generic JSON-RPC Passthrough - -### Core Insight - -alloy's `Transport` trait is a Tower `Service`. If we expose a single JSON-RPC dispatch function in WIT, the SDK can implement `Transport` on top of it. This gives guest modules the entire alloy `Provider` API for free - every current and future `eth_` method works automatically. - -From the guest's perspective, host function calls are synchronous (they block until the host returns). The returned future resolves in a single poll. This means alloy's async `Provider` methods work with a trivial executor - no real async machinery needed. - -### Architecture - -```mermaid -flowchart TD - A["Module author code - provider.get_block_number() - provider.call(tx).latest() - provider.get_logs(&filter)"] -->|full alloy Provider API| B - - B["HostTransport (SDK) - implements alloy Transport trait"] -->|"chain::request(chain_id, "eth_blockNumber", "[]")"| C - - C["WIT boundary - single generic function"] --> D - - D["Host chain::request impl - forwards to alloy provider"] -->|"provider.raw_request_dyn(method, params)"| E - - E["Alloy provider stack - timeout -> retry -> rate-limit -> fallback -> RPC"] -``` - -## Updated WIT Interface - -Replace the `blockchain` interface with `chain`: +`nexum:host/chain` (`wit/nexum-host/chain.wit`): ```wit -package nexum:host@0.1.0; - interface chain { use types.{chain-id, fault}; - /// A structured JSON-RPC error carrying the node code and revert bytes. record rpc-error { code: s32, message: string, data: option> } - - /// Either a shared host `fault` or a structured JSON-RPC error. variant chain-error { fault(fault), rpc(rpc-error) } - /// Execute a JSON-RPC request against the specified chain. - /// - /// The host forwards the request to the configured alloy provider for the - /// given chain, applying timeout/retry/rate-limit/fallback middleware - /// transparently. The method string should include the namespace prefix - /// (e.g. "eth_call", "eth_getBlockByNumber"). - /// - /// `params` and the success return value are JSON-encoded strings matching - /// the JSON-RPC specification. The host handles id/jsonrpc framing; the - /// guest only provides method + params and receives the `result` field. + record rpc-request { method: string, params: string } + variant rpc-result { ok(string), err(chain-error) } + request: func(chain-id: chain-id, method: string, params: string) -> result; - - /// 0.2 additive: batched JSON-RPC. alloy's HostTransport routes - /// RequestPacket::Batch through this, so provider.multicall(...) actually - /// batches on the wire (it silently fanned-out single requests in 0.1). - request-batch: func(chain-id: chain-id, calls: list>) - -> result>, chain-error>; + request-batch: func(chain-id: chain-id, requests: list) + -> result, chain-error>; } ``` -Errors are reported via `chain-error`: either a shared `fault` (see doc 00 and ADR-0011) or a structured `rpc` case carrying the node code and decoded revert bytes - the 0.1 `json-rpc-error` shape is gone. Modules match on the `fault` case (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) for retry/backoff, and on the `rpc` case to decode a revert without parsing numeric JSON-RPC codes by hand. - -The `types` interface now exposes the shared `fault` / `rate-limit`. The `local-store`, `remote-store`, `messaging`, and `logging` interfaces are unchanged in shape (the first three report `fault` directly). - -The `identity` interface provides cryptographic identity - key management and signing: - -```wit -interface identity { - use types.{fault}; - - /// Get available signing accounts (20-byte Ethereum addresses). - accounts: func() -> result>, fault>; +`method` carries the namespace prefix (`eth_call`). `params` and the success value are JSON strings; the host frames the id/jsonrpc envelope. A failure is a `chain-error`: either a shared host `fault` (`unavailable`, `rate-limited`, `timeout`, `denied`, `invalid-input`, ...) that a module matches for retry and backoff, or a structured `rpc` case carrying the node code and the host-decoded revert bytes so a revert reads without parsing numeric JSON-RPC codes by hand. `request-batch` runs several calls against one chain in a single round trip where the transport supports it, falling back to sequential `request` otherwise; the result list matches `requests` in length and order, each entry independently `ok` or `err`. - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - sign: func(account: list, data: list) -> result, fault>; +## Permitted method surface - /// Sign EIP-712 typed data with the specified account. - sign-typed-data: func(account: list, typed-data: string) -> result, fault>; -} -``` +The reference server host forwards only a closed read-only set, the `ChainMethod` enum in `nexum-world`. Host dispatch and the guest-side allowlist re-export the same type, so the two cannot drift. A method outside the set, which is every signing or mutating method, is refused with a `denied` fault before it reaches the provider. -The universal `event-module` world (in `nexum:host`) contains the platform-agnostic interfaces - six imports in 0.2: +The surface is `eth_blockNumber`, `eth_call`, `eth_chainId`, `eth_estimateGas`, `eth_feeHistory`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_getBalance`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getBlockReceipts`, `eth_getCode`, `eth_getLogs`, `eth_getProof`, `eth_getStorageAt`, `eth_getTransactionByHash`, `eth_getTransactionCount`, `eth_getTransactionReceipt`, and `net_version`. -```wit -world event-module { - import chain; // replaces `import blockchain;` from the early 0.1 sketch - import identity; // cryptographic identity (key management, signing) - import local-store; - import remote-store; - import messaging; - import logging; +Enforcement is host-side string-to-`ChainMethod` resolution, not a compile-time guarantee. The Component Model already sandboxes I/O, so a chain-capable module can only call `chain.request`, and the closed surface adds method-level defence in depth on top. - export init: func(config: types.config) -> result<_, fault>; - export on-event: func(event: types.event) -> result<_, fault>; -} -``` +## Signing -The CoW-specific `shepherd` world (in `shepherd:cow`) extends it with the merged `cow-api` interface: +`chain.request` neither signs nor delegates signing; signing methods simply fall outside the read surface. Signing is the separate `nexum:host/identity` interface: ```wit -world shepherd { - include nexum:host/event-module; - import cow-api; -} -``` - -### What This Replaces - -| Before (per-method) | After (generic) | -|---|---| -| `blockchain::eth-call(chain-id, to, data)` | `chain::request(chain-id, "eth_call", params_json)` | -| `blockchain::eth-get-logs(filter)` | `chain::request(chain-id, "eth_getLogs", params_json)` | -| `blockchain::eth-block-number(chain-id)` | `chain::request(chain-id, "eth_blockNumber", "[]")` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getBalance", params_json)` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getCode", params_json)` | -| *n/a - not exposed* | `chain::request(chain-id, "eth_getStorageAt", params_json)` | -| *n/a - not exposed* | Any `eth_*` method - no WIT change needed | - -### Why JSON Strings (Not `list`) - -- The Ethereum JSON-RPC spec is JSON. alloy serialises params to JSON internally. Using `string` means zero intermediate format - the guest produces JSON, the host forwards JSON to alloy's `raw_request_dyn` which accepts `&RawValue` (a JSON string). -- Debuggability: JSON is human-readable in logs and traces. -- The canonical ABI cost of copying a JSON string across the component boundary is negligible relative to the network RTT of an actual RPC call. -- Binary encoding (CBOR, postcard) would require custom (de)serialisation on both sides, defeating the purpose of minimising boilerplate. - -## Host Implementation - -The host implementation is minimal - one function handles the entire `eth_` namespace: - -```rust -use serde_json::value::RawValue; - -impl nexum::host::chain::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> wasmtime::Result> { - // 1. Check if this is a signing method that requires identity delegation - if self.is_signing_method(&method) { - return self.dispatch_signing(chain_id, &method, ¶ms).await; - } - - // 2. Method allowlisting for read-only methods - if !self.is_read_method_allowed(&method) { - return Ok(Err(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - // 3. Resolve the provider for this chain - let provider = self.provider_for(chain_id).map_err(|_| { - ChainError::Fault(Fault::Unsupported(format!("unknown chain: {chain_id}"))) - })?; - - // 4. Parse params as raw JSON and forward to alloy - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - // A structured JSON-RPC error folds to ChainError::Rpc (node code + - // decoded revert bytes); a transport failure folds to a Fault. - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} -``` - -That's it. The alloy provider already has the timeout/retry/rate-limit/fallback tower stack configured per chain (see doc 01). Every read-only `eth_*` method automatically inherits that middleware. - -### Method Allowlisting - -> **Status: Future direction (0.3+ target).** The shipped 0.2 host -> implementation of `chain::request` forwards any method string to -> the alloy provider; it does **not** consult a read-only allowlist -> and it does **not** intercept signing methods to delegate to the -> identity backend. The categorisation below is the planned 0.3 -> enforcement model. Until that tracking issue lands the gating -> code, operators must treat any chain-capable module as having -> access to the full RPC surface their configured provider exposes. - -The host maintains two categories of methods: **read-only methods** (always allowed through the RPC passthrough) and **signing methods** (delegated to the `identity` backend). - -#### Read-Only Methods (RPC Passthrough) - -```rust -impl NexumHostState { - fn is_read_method_allowed(&self, method: &str) -> bool { - // Default allowlist: read-only eth_ methods - matches!(method, - "eth_blockNumber" - | "eth_call" - | "eth_chainId" - | "eth_estimateGas" - | "eth_feeHistory" - | "eth_gasPrice" - | "eth_maxPriorityFeePerGas" - | "eth_getBalance" - | "eth_getBlockByHash" - | "eth_getBlockByNumber" - | "eth_getBlockReceipts" - | "eth_getCode" - | "eth_getLogs" - | "eth_getProof" - | "eth_getStorageAt" - | "eth_getTransactionByHash" - | "eth_getTransactionCount" - | "eth_getTransactionReceipt" - // net_ methods - | "net_version" - ) - } -} -``` - -This could be made configurable per-module via `module.toml`: - -```toml -[module.chain] -# Additional methods beyond the default read-only set. -# Use with caution - write methods can have side-effects. -extra_allowed_methods = ["eth_createAccessList"] -``` - -The allowlist is runtime-enforced (string matching), not compile-time. This is an acceptable trade-off: the Component Model already provides structural sandboxing (modules can only call `chain::request`, not arbitrary network I/O), and the allowlist adds defence-in-depth for method-level granularity. - -#### Signing Methods (Identity Delegation) - -When a module calls `chain::request` with a signing method, the host does **not** forward the request to the RPC provider. Instead, it delegates to the `identity` backend for signing, then broadcasts the signed result via RPC. - -```rust -impl NexumHostState { - fn is_signing_method(&self, method: &str) -> bool { - matches!(method, - "eth_sendTransaction" - | "eth_accounts" - | "eth_signTypedData_v4" - | "personal_sign" - ) - } -} -``` - -These methods are deliberately **not** in the read-only allowlist. They follow a completely different code path through the identity backend. - -### Identity Delegation Flow - -When a module calls a signing method through `chain::request`, the host intercepts it and delegates to the `Identity` trait: - -```mermaid -sequenceDiagram - participant M as Module (guest) - participant C as CsnHost - participant I as Identity backend - participant R as RPC provider - - M->>C: chain::request(1, "eth_sendTransaction", params) - C->>C: is_signing_method("eth_sendTransaction") → true - C->>C: Parse transaction from params - C->>I: sign(account, tx_hash) - I-->>C: 65-byte signature (r ‖ s ‖ v) - C->>C: Assemble signed transaction (RLP-encode with signature) - C->>R: eth_sendRawTransaction(signed_tx) - R-->>C: tx_hash - C-->>M: Ok(tx_hash) -``` - -The key insight: modules never call `eth_sendRawTransaction` directly (it's not in the read-only allowlist). Instead, `eth_sendTransaction` is intercepted by the host, which uses the `identity` backend to sign, then broadcasts the signed transaction itself. - -This pattern applies to all signing methods: - -| Method | Identity Delegation | -|---|---| -| `eth_accounts` | Returns accounts from `Identity::accounts()` | -| `eth_sendTransaction` | Signs the transaction via `Identity::sign()`, broadcasts via `eth_sendRawTransaction` | -| `eth_signTypedData_v4` | Signs EIP-712 typed data via `Identity::sign_typed_data()` | -| `personal_sign` | Signs the message via `Identity::sign()` (with EIP-191 prefix) | - -### Identity Trait and ChainHost - -The host's `chain` implementation is generic over an `Identity` trait. This allows different identity backends (hardware wallet, KMS, in-memory test keys, etc.): - -```rust -/// Trait for identity backends that provide signing capabilities. -/// -/// The host's chain implementation delegates signing methods to this trait. -/// Implementations can back onto hardware wallets, cloud KMS, in-memory -/// test keys, or any other signing infrastructure. -pub trait Identity: Send + Sync { - /// Get available signing accounts (20-byte Ethereum addresses). - fn accounts(&self) -> Result>, IdentityBackendError>; - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r ‖ s ‖ v). - fn sign(&self, account: &[u8], data: &[u8]) -> Result, IdentityBackendError>; - - /// Sign EIP-712 typed data with the specified account. - fn sign_typed_data(&self, account: &[u8], typed_data: &str) -> Result, IdentityBackendError>; -} - -/// The host state is generic over the identity backend. -pub struct ChainHost { - providers: HashMap, - identity: I, -} - -impl nexum::host::chain::Host for ChainHost { - async fn request( - &mut self, - chain_id: u64, - method: String, - params: String, - ) -> wasmtime::Result> { - if self.is_signing_method(&method) { - return self.dispatch_signing(chain_id, &method, ¶ms).await; - } - - if !self.is_read_method_allowed(&method) { - return Ok(Err(ChainError::Fault(Fault::Denied(format!( - "method not allowed: {method}" - ))))); - } - - let provider = self.provider_for(chain_id)?; - let raw_params: Box = RawValue::from_string(params) - .map_err(|e| wasmtime::Error::msg(format!("invalid JSON params: {e}")))?; - - match provider.raw_request_dyn(method.into(), &raw_params).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } -} - -impl ChainHost { - /// Dispatch signing methods to the identity backend. - async fn dispatch_signing( - &self, - chain_id: u64, - method: &str, - params: &str, - ) -> wasmtime::Result> { - match method { - "eth_accounts" => { - let accounts = self.identity.accounts() - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - let hex_accounts: Vec = accounts - .iter() - .map(|a| format!("0x{}", hex::encode(a))) - .collect(); - Ok(Ok(serde_json::to_string(&hex_accounts)?)) - } - - "eth_sendTransaction" => { - let provider = self.provider_for(chain_id)?; - // Parse the transaction params - let tx_params: Vec = serde_json::from_str(params)?; - let tx = &tx_params[0]; - - let from = parse_address(tx.get("from"))?; - - // Fill missing fields (nonce, gas, etc.) via the provider - let filled_tx = self.fill_transaction(provider, tx).await?; - - // Hash the transaction and sign it - let tx_hash = filled_tx.signing_hash(); - let signature = self.identity.sign(&from, tx_hash.as_ref()) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - - // Assemble signed transaction and broadcast - let signed_tx = filled_tx.with_signature(&signature); - let raw_tx = signed_tx.rlp_encode(); - - let raw_params = serde_json::to_string(&[format!("0x{}", hex::encode(&raw_tx))])?; - let raw_params_box: Box = RawValue::from_string(raw_params)?; - match provider.raw_request_dyn("eth_sendRawTransaction".into(), &raw_params_box).await { - Ok(result) => Ok(Ok(result.get().to_string())), - Err(e) => Ok(Err(e.into())), - } - } - - "eth_signTypedData_v4" => { - let params_arr: Vec = serde_json::from_str(params)?; - let account = parse_address(¶ms_arr[0])?; - let typed_data = params_arr[1].to_string(); - - let signature = self.identity.sign_typed_data(&account, &typed_data) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - "personal_sign" => { - let params_arr: Vec = serde_json::from_str(params)?; - let data = parse_hex_bytes(¶ms_arr[0])?; - let account = parse_address(¶ms_arr[1])?; - - // EIP-191 prefix - let prefixed = format!("\x19Ethereum Signed Message:\n{}", data.len()); - let mut msg = prefixed.into_bytes(); - msg.extend_from_slice(&data); - let hash = keccak256(&msg); - - let signature = self.identity.sign(&account, &hash) - .map_err(|e| ChainError::Fault(Fault::Internal(e.to_string())))?; - Ok(Ok(format!("\"0x{}\"", hex::encode(&signature)))) - } - - _ => Ok(Err(ChainError::Fault(Fault::InvalidInput(format!( - "unknown signing method: {method}" - ))))), - } - } -} -``` - -The `ChainHost` also implements `nexum::host::identity::Host` directly, delegating to the same `Identity` trait so modules can use the identity WIT interface for raw signing. The interface is the failure domain, so it reports a plain `fault`: - -```rust -impl nexum::host::identity::Host for ChainHost { - fn accounts(&mut self) -> wasmtime::Result>, Fault>> { - // From picks unavailable/denied/internal. - Ok(self.identity.accounts().map_err(Fault::from)) - } - - fn sign( - &mut self, - account: Vec, - data: Vec, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign(&account, &data).map_err(Fault::from)) - } - - fn sign_typed_data( - &mut self, - account: Vec, - typed_data: String, - ) -> wasmtime::Result, Fault>> { - Ok(self.identity.sign_typed_data(&account, &typed_data).map_err(Fault::from)) - } -} -``` - -## Guest SDK: `HostTransport` - -The key SDK addition is a `HostTransport` struct that implements alloy's `Transport` trait by routing through the WIT `chain::request` host function. - -### Transport Implementation - -```rust -use alloy_json_rpc::{ - ErrorPayload, RequestPacket, Response, ResponsePacket, ResponsePayload, - SerializedRequest, -}; -use alloy_transport::{BoxTransport, Transport, TransportError, TransportFut}; -use tower::Service; -use std::task::{Context, Poll}; - -/// An alloy-compatible transport that routes JSON-RPC requests through the -/// Nexum host engine. Synchronous from the guest's perspective - the host -/// function blocks until the RPC response is available. -#[derive(Debug, Clone)] -pub struct HostTransport { - chain_id: u64, -} - -impl HostTransport { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } -} - -impl Service for HostTransport { - type Response = ResponsePacket; - type Error = TransportError; - type Future = TransportFut<'static>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - // Always ready - host function calls are synchronous from the guest. - Poll::Ready(Ok(())) - } - - fn call(&mut self, req: RequestPacket) -> Self::Future { - let chain_id = self.chain_id; - Box::pin(async move { - match req { - RequestPacket::Single(req) => { - let resp = dispatch_single(chain_id, &req)?; - Ok(ResponsePacket::Single(resp)) - } - RequestPacket::Batch(reqs) => { - // 0.2: route batches through chain::request-batch so the - // host actually pipelines them on the wire. - let calls: Vec<(String, String)> = reqs.iter() - .map(|r| (r.method().to_string(), - r.params().map(|p| p.get()).unwrap_or("[]").to_string())) - .collect(); - let results = chain::request_batch(chain_id, &calls) - .map_err(|e| TransportError::from_host(e))?; - let resps: Vec<_> = reqs.iter().zip(results.into_iter()) - .map(|(req, result)| build_response(req, result)) - .collect(); - Ok(ResponsePacket::Batch(resps)) - } - } - }) - } -} - -impl Transport for HostTransport { - fn boxed(self) -> BoxTransport - where - Self: Sized + Clone + Send + Sync + 'static, - { - BoxTransport::new(self) - } -} - -/// Dispatch a single JSON-RPC request through the host function. -fn dispatch_single( - chain_id: u64, - req: &SerializedRequest, -) -> Result>, TransportError> { - let method = req.method(); - let params_json = req.params().map(|p| p.get()).unwrap_or("[]"); - - // This calls the WIT-imported host function. Synchronous from the guest's - // perspective - the host executes the RPC call asynchronously and returns - // the result when ready. - match chain::request(chain_id, method, params_json) { - Ok(result_json) => { - let payload: Box = RawValue::from_string(result_json) - .map_err(|e| TransportError::deser_err(e, "host response"))?; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Success(payload), - }) - } - Err(e) => { - // Map the chain-error onto an alloy error payload. A structured - // `rpc` case carries the node code and decoded revert bytes; a - // shared `fault` becomes a generic -32000 with the fault message. - let payload = match e { - ChainError::Rpc(rpc) => ErrorPayload { - code: rpc.code as i64, - message: rpc.message, - data: rpc.data.map(|d| { - RawValue::from_string(format!("\"0x{}\"", hex::encode(d))).unwrap() - }), - }, - ChainError::Fault(fault) => ErrorPayload { - code: -32000, - message: fault.to_string(), - data: None, - }, - }; - Ok(Response { - id: req.id().clone(), - payload: ResponsePayload::Failure(payload), - }) - } - } -} -``` - -### Why This Works Without Real Async - -The `call()` method returns a `Box::pin(async move { ... })` - but the body is entirely synchronous. The `chain::request` host function blocks from the guest's perspective (the host runs the actual RPC call asynchronously via wasmtime's `func_wrap_async`, but the guest sees a normal function call that returns a value). The future resolves in a single poll. - -This means alloy's `Provider` methods - which `await` the transport internally - complete immediately when driven by any executor. The SDK provides a minimal single-threaded executor: - -```rust -/// Drive a future to completion. Since the HostTransport resolves -/// synchronously, this is a single-poll operation - no actual async -/// scheduling occurs. -pub fn block_on(future: F) -> F::Output { - futures_executor::block_on(future) -} -``` - -`futures-executor` is no-std-compatible and adds no meaningful overhead. - -### Provider Constructor - -```rust -use alloy_provider::RootProvider; -use alloy_rpc_client::RpcClient; - -/// Create an alloy `Provider` backed by the Nexum host engine. -/// -/// The returned provider supports the full alloy `Provider` API - all `eth_*` -/// methods, builder patterns, typed responses - routing every request through -/// the host's RPC stack (timeout, retry, rate-limit, failover). -/// -/// ```rust -/// let provider = nexum_sdk::provider(42161); -/// let block = provider.get_block_number().await?; -/// ``` -pub fn provider(chain_id: u64) -> RootProvider { - let transport = HostTransport::new(chain_id); - let client = RpcClient::new(transport, false); // false = not local - RootProvider::new(client) -} -``` - -## Eliminating `block_on`: Async Module Functions - -### The Problem - -alloy's `Provider` is async. Without help, module authors would need `block_on()` around every RPC call: - -```rust -let block_num = block_on(provider.get_block_number())?; // noisy -let balance = block_on(provider.get_balance(addr).latest())?; // everywhere -``` - -This is verbose and obscures the actual logic. But we can't reimplement every `Provider` method as a synchronous wrapper - that defeats the purpose of the generic passthrough. - -### The Solution: Named Event Handlers + `async fn` - -The proc macro (see doc 05) already generates the WIT export boilerplate. We extend it in two ways. For universal modules, the `#[nexum::module]` macro is used; for CoW modules, the `#[shepherd::module]` macro (which extends the universal one with CoW-specific imports): - -1. **Named event handlers** - instead of writing the `match event { ... }` dispatch manually, module authors implement `on_block`, `on_chain_logs`, `on_tick`, and/or `on_message`. The macro generates the `on_event` match. -2. **`async fn` support** - handlers can be async. The macro wraps the generated `on_event` in `block_on()`, so `.await` works naturally. -3. **Provider injection** - if a handler accepts `&RootProvider` as a second parameter, the macro creates the provider from the event's chain_id and passes it in. - -**What the module author writes (universal module):** - -```rust -#[nexum::module] -struct MyModule; - -impl MyModule { - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; // natural .await - let balance = provider.get_balance(addr).latest().await?; // no block_on - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -**What the module author writes (CoW module):** - -```rust -#[shepherd::module] -struct MyModule; - -impl MyModule { - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let block_num = provider.get_block_number().await?; - cow.submit_order(&order)?; - Ok(()) - } -} -``` - -**What the macro generates:** - -```rust -impl Guest for MyModule { - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - MyModule::on_block(block, &provider).await - } - Event::ChainLogs(batch) => { - let provider = nexum_sdk::provider(batch.chain_id); - MyModule::on_chain_logs(batch.logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }) - } -} -``` - -The generated code calls `block_on` exactly once - at the top-level export boundary. Inside the async block, all `.await` calls resolve immediately (the `HostTransport` is synchronous under the hood). No real async scheduler runs. No tokio. No waker machinery. It's syntactic sugar that costs nothing at runtime. - -### Named Handler Conventions - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from `logs[0].chain_id`) | -| `on_tick(tick)` | `Tick` (`tick.fired_at` is ms UTC) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: -- **Second parameter is `&RootProvider`** -> inject `nexum_sdk::provider(chain_id)` -- **No second parameter** -> pass only the payload -- **Async handlers** -> wrapped in `block_on`; sync handlers called directly -- **Missing handlers** -> `Ok(())` for that variant (no-op) - -**Escape hatch:** defining `on_event` directly takes precedence - the macro uses it as-is (wrapping in `block_on` if async) and ignores named handlers. - -### Why This Works - -1. **WIT exports are synchronous.** The Component Model export signature is `func(event) -> result<_, string>` - no async. The macro bridges this by wrapping the generated dispatch in `block_on`. - -2. **The transport resolves in one poll.** `HostTransport::call()` returns a future whose body is entirely synchronous (it calls the WIT host function, which blocks). When alloy's `Provider` awaits the transport, the future completes immediately. - -3. **`futures_executor::block_on` is trivial.** It creates a waker, polls the future once, gets `Poll::Ready`. No thread parking, no event loop. On WASM single-threaded targets this is a no-op wrapper. - -4. **Composability.** Module authors can use alloy's builder patterns naturally inside any handler: - - ```rust - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // EthCall builder - .latest() and .await both work - let result = provider.call(tx).latest().await?; - - // Filter builder - standard alloy ergonomics - let logs = provider.get_logs(&filter).await?; - - // Raw request for unlisted methods - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - Ok(()) - } - ``` - -5. **Sync handlers still work.** Handlers that don't need RPC can be plain `fn`: - - ```rust - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } - ``` - -### Comparison - -| Approach | Event dispatch boilerplate | RPC call boilerplate | New methods need shimming? | alloy-native? | -|---|---|---|---|---| -| Manual `on_event` + `block_on()` | `match event { ... }` every module | `block_on(...)` every call | No | Yes | -| **Named handlers + async macro** | **None (generated)** | **None (`.await`)** | **No** | **Yes** | - -The named handler + async macro approach eliminates boilerplate at both the event dispatch level and the RPC call level. - -## Module Author Experience - -### Before (Per-Method WIT) - -```rust -use nexum_sdk::prelude::*; -use nexum_sdk::abi::sol; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn on_event(event: Event) -> Result<()> { - if let Event::Block(block) = event { - // Manual ABI encode - let calldata = balanceOfCall { owner: addr }.abi_encode(); - - // Raw host call - returns opaque bytes - let result_bytes = blockchain::eth_call( - block.chain_id, - &token_addr.to_vec(), - &calldata, - )?; - - // Manual ABI decode - let balance = balanceOfCall::abi_decode_returns(&result_bytes)?; - - // Want eth_getBalance? Not available. Want eth_getCode? Not available. - // Each new method needs WIT + host + SDK changes. - } - Ok(()) - } -} -``` - -### After (Generic RPC + named handlers + provider injection) - -```rust -use nexum_sdk::prelude::*; - -sol! { - function balanceOf(address owner) view returns (uint256); -} - -#[nexum::module] -struct MyModule; - -impl MyModule { - // Named handler - macro generates the match dispatch + provider injection - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API - natural .await, provider injected - let block_num = provider.get_block_number().await?; - let eth_balance = provider.get_balance(addr).latest().await?; - let code = provider.get_code_at(contract).latest().await?; - - // Typed contract calls with the EthCall builder - let tx = TransactionRequest::default() - .to(token_addr) - .input(balanceOfCall { owner: addr }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let balance = balanceOfCall::abi_decode_returns(&result)?; - - // Log queries with alloy's Filter builder - let filter = Filter::new() - .address(contract) - .event_signature(Transfer::SIGNATURE_HASH) - .from_block(block.number - 100); - let logs = provider.get_logs(&filter).await?; - - // Raw request for anything not wrapped by Provider - let proof: EIP1186AccountProofResponse = provider - .raw_request("eth_getProof".into(), (addr, keys, "latest")) - .await?; - - Ok(()) - } - - // Only implement handlers for event types you care about. - // No on_chain_logs, on_tick, or on_message -> those events are no-ops. -} -``` - -Every alloy `Provider` method works. No WIT changes. No host-side per-method code. No `block_on`. No `match event { ... }`. No manual provider construction. - -## The `cow-api` Namespace - -CoW Protocol's API is REST-based, not JSON-RPC. Two options: - -### Option A: Separate REST Interface (Recommended - chosen for 0.2) - -In 0.1 this was two interfaces, `cow` (REST passthrough) and `order` (typed `submit`). 0.2 merges them into a single `cow-api` interface, dropping the `cow::cow::request` triple-stutter: - -```wit -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A non-2xx reply with no typed rejection envelope; `body` is raw text. - record http-failure { status: u16, body: option } - - /// A typed orderbook rejection, parsed host-side from `{errorType, description}`. - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A shared host `fault`, a raw HTTP failure, or a typed order rejection. - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain - /// (e.g. https://api.cow.fi/mainnet for chain 1, /arbitrum for chain - /// 42161). The path is relative to the base URL. - /// - /// method: "GET" | "POST" | "PUT" | "DELETE" - /// path: relative API path, e.g. "/api/v1/orders" - /// body: optional JSON request body - /// - /// Returns the response body as a JSON string. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order. (Merged in from the 0.1 `order::submit`.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} -``` - -```wit -world shepherd { - include nexum:host/event-module; - import cow-api; -} -``` - -The host implementation is similarly minimal: - -```rust -impl shepherd::cow::cow_api::Host for NexumHostState { - async fn request( - &mut self, - chain_id: u64, - method: String, - path: String, - body: Option, - ) -> wasmtime::Result> { - let base_url = self.cow_api_url_for(chain_id)?; - let url = format!("{base_url}{path}"); - - let req = self.http_client.request(method.parse()?, &url); - let req = match body { - Some(b) => req.header("content-type", "application/json").body(b), - None => req, - }; - - let resp = req.send().await - .map_err(|e| CowApiError::Fault(Fault::Unavailable(e.to_string())))?; - let status = resp.status().as_u16(); - - if status >= 400 { - // A non-2xx with no typed rejection envelope surfaces as `http` - // so a caller matches on `status` (e.g. 404) and reads `body` - // only for diagnostics; submit-order parses the orderbook's - // `{errorType, description}` into the `rejected` case instead. - let body = resp.text().await.ok(); - return Ok(Err(CowApiError::Http(HttpFailure { status, body }))); - } - - Ok(Ok(resp.text().await.unwrap_or_default())) - } +interface identity { + use types.{fault}; + accounts: func() -> result>, fault>; + sign: func(account: list, message: list) -> result, fault>; + sign-typed-data: func(account: list, typed-data: string) -> result, fault>; } ``` -### Option B: JSON-RPC Style (Unified) - -Route `cow_*` methods through the same `chain::request` function: - -```rust -// Guest usage (illustrative): -let order_uid: String = block_on(provider.raw_request( - "cow_submitOrder".into(), - serde_json::json!({ "sellToken": "0x...", "buyToken": "0x...", ... }), -))?; -``` - -The host would dispatch by method prefix: +`accounts` returns the 20-byte addresses the host will sign for (an empty list means no signing capability); `sign` applies `personal_sign` semantics (the EIP-191 prefix) and returns a 65-byte signature; `sign-typed-data` signs an EIP-712 JSON payload. `nexum-sdk`'s `IdentityHost` trait mirrors the interface one-for-one. -```rust -async fn request(&mut self, chain_id: u64, method: String, params: String) - -> wasmtime::Result> -{ - if method.starts_with("eth_") || method.starts_with("net_") { - self.dispatch_rpc(chain_id, &method, ¶ms).await - } else if method.starts_with("cow_") { - self.dispatch_cow(chain_id, &method, ¶ms).await - } else { - Ok(Err(ChainError::Fault(Fault::InvalidInput("unknown namespace".into())))) - } -} -``` +## Guest SDK: the alloy provider seam -**Option A is recommended and is what 0.2 ships.** The CoW API is REST, not JSON-RPC - forcing it into JSON-RPC semantics adds a translation layer on both sides. A separate `cow-api` interface keeps the contract explicit and makes it clear in the WIT world what capabilities a module has. It also allows independent evolution - the `chain` interface doesn't need to know about CoW, and vice versa. +`nexum_sdk::chain` fronts `chain.request` with an alloy `Provider`, so strategy code calls typed provider methods instead of hand-building JSON-RPC: -### SDK: `Cow` +- `HostTransport` implements alloy's `Service` over any `ChainHost`, dispatching single requests through `chain.request` and batches through `chain.request-batch`. +- `ProviderHost::provider(chain)` mints an alloy `RootProvider` over that transport, blanket-implemented for every cloneable `ChainHost`. +- `block_on` drives the returned futures. The transport is a synchronous WIT import, so a future resolves on its first poll; a `Pending` panics, signalling that an alloy layer awaiting a reactor or timer has been introduced. ```rust -/// Typed client for the CoW Protocol API, backed by the host engine. -pub struct Cow { - chain_id: u64, -} - -impl Cow { - pub fn new(chain_id: u64) -> Self { - Self { chain_id } - } - - /// Submit an order via the typed cow-api::submit-order function. - pub fn submit_order(&self, order: &OrderCreation) -> Result { - let bytes = postcard::to_allocvec(order)?; - let uid = cow_api::submit_order(self.chain_id, &bytes)?; - Ok(uid.parse()?) - } +use nexum_sdk::chain::{Chain, ProviderHost, block_on}; - /// Get an order by UID. - pub fn get_order(&self, uid: &OrderUid) -> Result { - let resp = cow_api::request(self.chain_id, "GET", &format!("/api/v1/orders/{uid}"), None)?; - Ok(serde_json::from_str(&resp)?) - } - - /// Get the current auction. - pub fn get_auction(&self) -> Result { - let resp = cow_api::request(self.chain_id, "GET", "/api/v1/auction", None)?; - Ok(serde_json::from_str(&resp)?) - } - - /// Get a quote for a potential order. - pub fn get_quote(&self, params: &OrderQuoteRequest) -> Result { - let body = serde_json::to_string(params)?; - let resp = cow_api::request(self.chain_id, "POST", "/api/v1/quote", Some(&body))?; - Ok(serde_json::from_str(&resp)?) - } - - /// Raw request for endpoints not yet wrapped. - pub fn raw_request(&self, method: &str, path: &str, body: Option<&str>) -> Result { - Ok(cow_api::request(self.chain_id, method, path, body)?) - } -} +let provider = host.provider(Chain::mainnet()); +let block = block_on(provider.get_block_number())?; ``` -Usage in a module: - -```rust -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); +A module that only needs raw JSON calls `host.request(chain_id, method, params)` directly, or reaches for the `chain::eth_call_params` and `parse_eth_call_result` helpers. - // Read chain state via alloy - provider injected by macro - let block_num = provider.get_block_number().await?; +## Handlers are synchronous - // Submit order via CoW API - cow.submit_order(&OrderCreation { - sell_token: usdc, - buy_token: weth, - sell_amount: U256::from(1_000_000_000), - kind: OrderKind::Sell, - // block.timestamp is ms-since-epoch in 0.2 - divide for seconds - valid_to: (provider.get_block(block_num.into(), false).await? - .unwrap().header.timestamp / 1000) + 300, - ..Default::default() - })?; +`#[nexum_sdk::module]` dispatches events to synchronous named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, `on_custom`); an absent handler is a no-op for that event. There is no `block_on` wrapper around a handler and no provider injection: a handler that wants the alloy provider builds it with `host.provider(chain)` and drives calls with `block_on` itself. Keeper handlers are the exception, where `#[videre_sdk::keeper]` allows `async fn` completed by `videre_sdk::client::poll_once`; see [doc 05](05-sdk-design.md). - Ok(()) -} -``` +## Order submission -## Updated SDK Crate Structure - -``` -nexum-sdk/ -├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor -│ ├── bindings.rs # generated WIT bindings -│ ├── transport.rs # HostTransport (alloy Transport impl, batches via chain::request-batch) -│ ├── local_store.rs # TypedState helpers (serde over local-store) -│ ├── signer.rs # Signer (typed identity helpers) -│ ├── abi.rs # alloy-sol-types integration -│ ├── log.rs # logging macros -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro - -shepherd-sdk/ -├── Cargo.toml # depends on nexum-sdk, re-exports it -├── src/ -│ ├── lib.rs # re-exports nexum-sdk + CoW additions -│ └── cow.rs # Cow typed wrapper (submit + REST passthrough) -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (extends nexum::module) -``` - -New dependencies (in `nexum-sdk`): - -```toml -[dependencies] -alloy-transport = { version = "1.5", default-features = false } -alloy-json-rpc = { version = "1.5", default-features = false } -alloy-rpc-client = { version = "1.5", default-features = false } -alloy-provider = { version = "1.5", default-features = false } -alloy-rpc-types = { version = "1.5", default-features = false } -alloy-primitives = { version = "1.5", default-features = false } -alloy-sol-types = { version = "1.5", default-features = false } -futures-executor = { version = "0.3", default-features = false } -serde = { version = "1", default-features = false, features = ["derive"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -tower = { version = "0.5", default-features = false } -``` - -All alloy crates with `default-features = false` to avoid pulling in reqwest, tokio, or other dependencies that won't compile for `wasm32-wasip2`. The key crates (`alloy-primitives`, `alloy-sol-types`, `alloy-json-rpc`) are already `no_std`-compatible or have WASM-friendly feature flags. - -## Updated Prelude - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; - -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` - -```rust -// shepherd_sdk::prelude (re-exports nexum_sdk::prelude + CoW additions) -pub use nexum_sdk::prelude::*; -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` +Submitting an order or intent is not a chain namespace. It is the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the installed venue adapter (for CoW, `crates/cow-venue`) speaks the orderbook wire. See [doc 08](08-platform-generalisation.md) for the layer model and [doc 05](05-sdk-design.md) for the venue SDK. ## Testing -### MockTransport for Unit Tests - -The SDK testing module provides a mock transport that mirrors alloy's own `Asserter`-based testing pattern: - -```rust -use nexum_sdk::testing::MockProvider; - -#[test] -fn test_reads_balance() { - // block_on is still useful in tests - tests are sync by default. - // (Or use #[tokio::test] - MockProvider works with any executor.) - let mut mock = MockProvider::new(42161); - - // Queue mock responses (FIFO) - mock.push_success(&U256::from(1_000_000)); // for get_balance - mock.push_success(&19_000_001u64); // for get_block_number - - let provider = mock.provider(); - - let balance = block_on(provider.get_balance(addr).latest()).unwrap(); - assert_eq!(balance, U256::from(1_000_000)); - - let block = block_on(provider.get_block_number()).unwrap(); - assert_eq!(block, 19_000_001); -} -``` - -Note: `block_on` is still available and useful in test code where `#[test]` functions are synchronous. In module code, prefer `async fn on_event` with `.await` instead. - -### MockCow for Unit Tests - -```rust -use shepherd_sdk::testing::MockCow; - -#[test] -fn test_submits_order() { - let mut mock_cow = MockCow::new(42161); - mock_cow.on_submit(|order| { - assert_eq!(order.sell_token, usdc); - Ok(OrderUid::from([0x42; 56])) - }); - - let uid = mock_cow.submit_order(&order).unwrap(); - assert_eq!(uid, OrderUid::from([0x42; 56])); -} -``` - -## Trade-Offs - -| Concern | Generic passthrough | Per-method WIT functions | -|---|---|---| -| **WIT changes for new methods** | None | New function + types per method | -| **Host implementation** | ~20 lines total | Per-method impl + dispatch | -| **Guest API** | Full alloy Provider (80+ methods) | Only what WIT exposes | -| **alloy compatibility** | Native - IS an alloy transport | Manual ABI encode/decode | -| **Type safety at WIT boundary** | Runtime (JSON strings) | Compile-time (WIT types) | -| **Method allowlisting** | Runtime string match | Implicit (only exposed methods exist) | -| **Debugging** | JSON in/out visible in traces | Structured WIT types in traces | -| **Multi-language guests** | Must handle JSON serialisation | WIT types auto-generated | - -The primary trade-off is **type safety at the WIT boundary**: JSON strings vs. structured WIT types. This is mitigated by: - -1. **Rust guests** use alloy's type system - serialisation errors surface as alloy `TransportError` with clear messages. -2. **Non-Rust guests** (JS, Python, Go) typically work with JSON natively, so JSON strings are actually *more* natural than WIT record types. -3. **Tracing**: the host can log method + params as structured JSON before forwarding, providing equal or better debuggability. - -The compile-time guarantee that a module can only call methods in the WIT is traded for a runtime allowlist. Given that the Component Model already provides structural sandboxing (the module can only call `chain::request`, not arbitrary network I/O), and the allowlist is enforced at the host boundary before any RPC call is made, this is a sound trade-off. - -## Summary - -| Component | What 0.2 ships | -|---|---| -| **WIT** | `chain` interface with `request` + additive `request-batch`. `identity` (accounts, sign, sign-typed-data). Merged `cow-api` in `shepherd:cow`. `event-module` imports 6 interfaces: chain, identity, local-store, remote-store, messaging, logging. Plus the additive `http` capability and the experimental `query-module` world. | -| **Host** | `ChainHost` - one `chain::request` impl that forwards read-only methods to `provider.raw_request_dyn` and delegates signing methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) to the `Identity` backend. Plus `chain::request-batch` that actually pipelines. One `identity::Host` impl delegating to the same backend. One `cow-api::request` + `submit-order` impl forwarding to HTTP client. Chain calls return `chain-error`, cow-api calls return `cow-api-error`, and the rest report `fault` directly. | -| **SDK** | `nexum-sdk`: `HostTransport` (alloy `Transport` impl, batches via `chain::request-batch`), `provider()` constructor, `Signer` (typed identity wrapper), `Fault` / `HostFault` / `ChainError`. `shepherd-sdk`: `Cow` (extends `nexum-sdk`). `block_on` is internal. | -| **`#[nexum::module]` / `#[shepherd::module]` macros** | Named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`) with generated match dispatch. `async fn` support. Optional `&RootProvider` injection. `#[nexum::module]` for universal modules; `#[shepherd::module]` for CoW modules. | -| **Module author experience** | Full alloy `Provider` API via injected provider. Signing via `Signer` or transparently through `chain::request` signing methods. Full CoW API via `Cow`. No match boilerplate. No `block_on`. No manual ABI wrangling for RPC calls. Match on the `fault` case (or `ChainError::Rpc`) for retry/backoff. | -| **Existing ABI helpers** | Unchanged - `sol!` macro and `alloy-sol-types` still used for contract calldata encoding/decoding. | +`nexum_sdk_test::MockHost` implements the host traits (`ChainHost`, `IdentityHost`, `LocalStoreHost`, ...); strategy logic tests against `&impl Host` as plain native Rust, with no `wasm32-wasip2` target and no wasmtime instance. Provider-level tests wrap a stub `ChainHost` in `HostTransport` and drive it with `block_on`. From 1ea4db2ec817134d3c6f15638d8cee20b9d1015d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:11:29 +0000 Subject: [PATCH 114/139] docs: trim user-facing tutorial, sdk, testing and scripts docs (#602) * docs: trim user-facing docs, retarget the first-module tutorial Retarget tutorial-first-module.md from the removed stop-loss/cow-api walkthrough to the shipped price-alert example (block subscription, chain read, ABI decode) with current crate names and the #[nexum_sdk::module] macro surface; drop the aspirational time-budget framing. Delete qa-signoff.md, a dated pre-review sign-off snapshot. testing-runtime-harness.md: drop the nonexistent shepherd-sdk-test mock surface (module logic tests use nexum-sdk-test plus videre-test's transport mocks), repoint the sdk.md anchor, trim the intro. scripts/README.md: purge em dashes, drop the deleted e2e-prep.md reference and the stale stop-loss note. Part of #598. * docs: unwrap paragraphs to one logical line for diff-friendliness Hard-wrapped prose churns diffs: a one-word edit reflows the whole paragraph. Join each paragraph onto a single logical line and let it soft-wrap. Content unchanged (word and heading counts preserved); code fences, tables, lists, blockquotes and headings untouched. --- docs/qa-signoff.md | 80 ----- docs/sdk.md | 103 ++----- docs/testing-runtime-harness.md | 76 +---- docs/tutorial-first-module.md | 514 +++++++------------------------- scripts/README.md | 58 ++-- 5 files changed, 155 insertions(+), 676 deletions(-) delete mode 100644 docs/qa-signoff.md diff --git a/docs/qa-signoff.md b/docs/qa-signoff.md deleted file mode 100644 index 8953a78b..00000000 --- a/docs/qa-signoff.md +++ /dev/null @@ -1,80 +0,0 @@ -# Internal QA sign-off - pre-upstream review pass - -**Branch**: `qa/cleanup` (tip of M2 + M3 stack) -**Generated**: 2026-06-17 - -## Mechanical checks (workspace-wide) - -| Check | Status | Notes | -|---|---|---| -| `cargo fmt --all --check` | ✅ | One pre-existing drift in `supervisor/tests.rs` (M1) plus M2/M3 leaf modules; bulk applied as single cleanup commit. | -| `cargo clippy --all-targets --workspace -- -D warnings` | ✅ | Clean. | -| `cargo test --workspace` | ✅ | 145 host tests + 1 doctest passing. | -| Em-dashes in `crates/`, `modules/`, `docs/` | ✅ | 0. One was in `price-alert/strategy.rs:4` (mine), fixed. | -| Em-dashes in `wit/**.wit` | ⚠ | 3 in mfw78's M1 prose. Intentionally left alone; flag for him in upstream review. | -| `warn(unused_crate_dependencies)` on every crate root | ✅ | sdk, sdk-test, nexum-runtime, twap, ethflow, price-alert, balance-tracker, stop-loss. | -| WASM build (`wasm32-wasip2 --release`) | ✅ | All 5 modules build. Sizes: twap 314 KB, ethflow 282 KB, stop-loss 311 KB, price-alert 215 KB, balance-tracker 102 KB. | -| String-wrapped errors outside WIT boundary | ✅ | All hits in `crates/nexum-runtime/src/host/impls/*` (FFI boundary - exception per rust-idiomatic skill). No leaks in SDK or modules. | - -## Per-PR shape - -| PR | Module | Tests | Strategy/lib split | Notes | -|---|---|---|---|---| -| #2-#7 | twap-monitor M2 | 13 | ❌ no split until #24 | Stacked TWAP. Strategy ↔ lib.rs split landed at #24. | -| #8-#10 | ethflow-watcher M2 | 7 | ❌ no split until #25 | Split landed at #25. | -| #11 | module.toml manifests | - | - | Both M2 modules have manifests with capability + subscription comments. ✅ | -| #12 | shepherd-sdk skeleton | - | - | Public surface present. | -| #13 | sdk helpers extraction | - | - | OK. | -| #14 | M2 on SDK | - | - | M2 modules now consume `shepherd_sdk::cow` / `chain` helpers. | -| #15 | shepherd-sdk-test (MockHost) | 8 | - | Full mock surface; matches Host trait. | -| #16 | SDK docs | - | - | README + rustdoc on public items. **See architectural finding below.** | -| #17 | deployment guide | - | - | `docs/06-production-hardening.md` exists. | -| #18 | price-alert | 11 | ❌ no split until #22 | Refactor at #22. | -| #19 | balance-tracker | 13 | ❌ never refactored | Acceptable: balance-tracker has no submit path, dispatch matrix simpler. **Optional follow-up: bring to same shape for consistency.** | -| #20 | tutorial | - | - | Rewritten as guided tour at #23; reads top-to-bottom against real stop-loss source. | -| #21 | rust-idiomatic compliance | - | - | em-dash purge, thiserror, warn(unused_crate_dependencies). ✅ | -| #22 | price-alert host-trait | 16 | ✅ | Reference shape. | -| #23 | stop-loss | 7 | ✅ | First module with the full M3 surface (chain + local-store + cow-api + logging). | -| #24 | twap-monitor host-trait | 20 | ✅ | Strategy split; 7 new MockHost dispatch tests. | -| #25 | ethflow-watcher host-trait | 12 | ✅ | Strategy split; 5 new MockHost tests including PR #10 c5e4d7d regression guard. | - -## Architectural finding - DOC ↔ CODE divergence in M3 SDK - -**`docs/05-sdk-design.md` describes a 2-layer SDK that does not exist**: - -- `nexum-sdk` (universal) + `shepherd-sdk` (CoW extension) - we shipped only `shepherd-sdk`. No `nexum-sdk` crate. -- `#[nexum::module]` / `#[shepherd::module]` proc macros - not implemented. We use raw `wit_bindgen::generate!` + `WitBindgenHost` adapter pattern. -- Full alloy `Provider` backed by `HostTransport` - not implemented. We pass JSON-RPC method + params strings via `ChainHost::request`. -- Typed local-store helpers (serde over raw bytes) - not implemented. Modules call `host.set(&key, &value)` with raw bytes. -- Typed `Signer` for key management - not implemented. Modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side. - -**Two paths**, mfw78's call: - -1. Update `docs/05-sdk-design.md` to describe what M3 actually shipped (Host traits + helpers + MockHost; defer proc macros, Provider, Signer, `nexum-sdk` split to M5+). -2. Or treat the doc as M5 north-star and implement the missing layers as part of M4 / M5 scope. - -Doc is currently aspirational; code is M3-scoped. They need to agree before upstream review. - -## Outstanding / deferred - -| Item | Issue | Status | -|---|---|---| -| `#[non_exhaustive]` batch on SDK public enums (`Fault`, `LogLevel`, `PollOutcome`, `RetryAction`) | - | Held until just before upstream cut. | -| WIT-file em-dashes in upstream prose (3 occurrences) | - | Ask mfw78. | -| balance-tracker host-trait refactor (consistency with other 4 modules) | - | Optional follow-up. | -| PR description template (mfw78's "What does this PR do? / Why / Changes / Breaking changes / Testing / AI disclosure") | - | Cosmetic; could template-bump existing PR bodies before upstream push. | -| ADR for the M3 Host trait surface | - | None today. Worth one short ADR (0009 candidate) capturing the strategy/lib split decision before upstream review. | - -## Sign-off - -| Area | Ready for upstream? | -|---|---| -| M2 modules (twap + ethflow + manifests) | ✅ once PRs #24 + #25 land | -| M3 SDK + examples | ✅ pending doc 05 reconciliation | -| Tutorial | ✅ | -| Rust-idiomatic compliance | ✅ | -| Tests + builds | ✅ | -| Docs | ⚠ doc 05 vs code mismatch must resolve | -| ADRs | ⚠ M3 host trait surface lacks an ADR | - -**Recommendation**: address the two ⚠ items (doc 05 + ADR-0009) before opening the consolidated upstream PR. Everything else is green. diff --git a/docs/sdk.md b/docs/sdk.md index a465dff3..5dc7fa39 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -1,17 +1,8 @@ # The module SDK: nexum-sdk + videre-sdk -`nexum-sdk` is the guest-side library every module consumes: typed -primitives, ABI helpers, an effect-trait seam for testing, the -`#[nexum_sdk::module]` attribute macro and per-module adapter macro, -and a `prelude` that keeps boilerplate out of module crates. -`videre-sdk` layers the venue surface on top: the typed venue client, -the intent-body codec, the `VenueAdapter` seam and the keeper run. -Modules that talk to a venue 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/videre_sdk/`, -generated by: +`nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the `#[nexum_sdk::module]` attribute macro and per-module adapter macro, and a `prelude` that keeps boilerplate out of module crates. `videre-sdk` layers the venue surface on top: the typed venue client, the intent-body codec, the `VenueAdapter` seam and the keeper run. Modules that talk to a venue 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/videre_sdk/`, generated by: ```sh RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p nexum-sdk -p videre-sdk -p nexum-module-macros -p videre-macros --no-deps --open @@ -19,27 +10,11 @@ RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p nexum-sdk -p videre-sdk ## Authoring a module -Modules are authored with the `#[nexum_sdk::module]` attribute -(re-exported from `nexum-module-macros`). Apply it to an inherent `impl` -block whose associated functions are the named event handlers - -`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each -taking its wit-bindgen payload and returning `Result<(), Fault>`. -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-nexum_sdkmodule-macro) for a worked -example and the `nexum-module-macros` rustdoc for the fine print. +Modules are authored with the `#[nexum_sdk::module]` attribute (re-exported from `nexum-module-macros`). Apply it to an inherent `impl` block whose associated functions are the named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each taking its wit-bindgen payload and returning `Result<(), Fault>`. 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-nexum_sdkmodule-macro) for a worked example and the `nexum-module-macros` rustdoc for the fine print. ## Supported host capabilities -The SDK is host-neutral - it does not call wit-bindgen-generated -functions directly. Instead, it exposes traits that mirror the -on-the-wire host interfaces, and modules adapt their wit-bindgen -imports to the traits at the cdylib boundary (the -`bind_host_via_wit_bindgen!` macro generates that adapter). The traits -in [`nexum_sdk::host`][host-doc] and the venue seam in -[`videre_sdk::client`][client-doc] are: +The SDK is host-neutral - it does not call wit-bindgen-generated functions directly. Instead, it exposes traits that mirror the on-the-wire host interfaces, and modules adapt their wit-bindgen imports to the traits at the cdylib boundary (the `bind_host_via_wit_bindgen!` macro generates that adapter). The traits in [`nexum_sdk::host`][host-doc] and the venue seam in [`videre_sdk::client`][client-doc] are: | Trait | Mirrors | What it does | |---|---|---| @@ -49,86 +24,44 @@ in [`nexum_sdk::host`][host-doc] and the venue seam in | `Host` | supertrait | Bundles the three core traits; blanket impl | | `VenueTransport` (videre-sdk) | `videre:venue/client@0.1.0` | Quote, submit, observe, status and cancel against an installed venue adapter | -A module declaring `[capabilities].required = ["chain", "local-store", -"client", "logging"]` in its `module.toml` matches the host trait seam -one-for-one. +A module declaring `[capabilities].required = ["chain", "local-store", "client", "logging"]` in its `module.toml` matches the host trait seam one-for-one. -[host-doc]: ../target/doc/nexum_sdk/host/index.html -[client-doc]: ../target/doc/videre_sdk/client/index.html +[host-doc]: ../target/doc/nexum_sdk/host/index.html [client-doc]: ../target/doc/videre_sdk/client/index.html ## Modules - [`nexum_sdk::prelude`](../target/doc/nexum_sdk/prelude/index.html) - bulk re-exports covering the alloy primitives (`Address`, `B256`, - `Bytes`, `U256`, `keccak256`). `videre-sdk` has no prelude; it - re-exports its surface from the crate root. +`Bytes`, `U256`, `keccak256`). `videre-sdk` has no prelude; it re-exports its surface from the crate root. - [`client`](../target/doc/videre_sdk/client/index.html) - the typed - venue seam (in `videre-sdk`): a `Venue` marker drives `VenueClient`, - which encodes through `IntentBody` before the byte-level - `VenueTransport` seam. `keeper::retry_action` folds a `VenueFault` - into a `RetryAction`. +venue seam (in `videre-sdk`): a `Venue` marker drives `VenueClient`, which encodes through `IntentBody` before the byte-level `VenueTransport` seam. `keeper::retry_action` folds a `VenueFault` into a `RetryAction`. - CoW-specific pieces live in their own L3 crates rather than the SDK: - `cow_venue::assembly::gpv2_to_order_data` converts the on-chain - `GPv2OrderData` into the typed `OrderData` the orderbook signs - against, and `composable_cow::{Verdict, LegacyRevertAdapter}` give - typed dispatch over the five `IConditionalOrder` custom errors - (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, - `PollTryAtEpoch`, `PollNever`). +`cow_venue::assembly::gpv2_to_order_data` converts the on-chain `GPv2OrderData` into the typed `OrderData` the orderbook signs against, and `composable_cow::{Verdict, LegacyRevertAdapter}` give typed dispatch over the five `IConditionalOrder` custom errors (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, `PollTryAtEpoch`, `PollNever`). - [`chain`](../target/doc/nexum_sdk/chain/index.html) - `eth_call` - JSON plumbing (in `nexum-sdk`): +JSON plumbing (in `nexum-sdk`): - `chain::eth_call_params(to, data)` - build the `[{to, data}, "latest"]` params array. - `chain::parse_eth_call_result(json)` - parse the `"0x..."` hex response into bytes. - `chain::chainlink::read_latest_answer` - Chainlink AggregatorV3 reader over the two helpers above. - (The CoW-specific `LegacyRevertAdapter` - the `chain-error` rpc - revert bytes to a typed `Verdict` - lives in `composable-cow`.) +(The CoW-specific `LegacyRevertAdapter` - the `chain-error` rpc revert bytes to a typed `Verdict` - lives in `composable-cow`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait - seam plus the SDK's host-neutral `Fault` vocabulary (same cases - as wit-bindgen's, bridged via one-liner converters per module), - in `nexum-sdk`. `config` and `address` parsing helpers sit - alongside it. +seam plus the SDK's host-neutral `Fault` vocabulary (same cases as wit-bindgen's, bridged via one-liner converters per module), in `nexum-sdk`. `config` and `address` parsing helpers sit alongside it. - [`http`](../target/doc/nexum_sdk/http/index.html) - outbound - HTTP over wasi:http, in `nexum-sdk`. - `http::fetch` performs one synchronous - request from a `wasm32-wasip2` guest; requests and responses are - the standard `http` crate's `Request` / `Response` types, and the - SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase - `FetchOptions` timeouts compile on every target for host-free - strategy tests. The module must declare the `http` capability - and list the hosts it may contact in `[capabilities.http].allow` - in its `module.toml`; an off-list host surfaces as the matchable - `FetchError::Denied`, distinct from timeouts and transport - failures. See `modules/examples/http-probe` for a complete - module. +HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free strategy tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `modules/examples/http-probe` for a complete module. ## Companions: nexum-sdk-test and videre-test -Add `nexum-sdk-test` as a dev-dep on the module crate to write strategy -tests against in-memory mocks; its `MockHost` covers the chain, local -store and logging seams. `videre-test` is the venue-side kit: codec -vectors and header goldens for conformance runs, plus transport mocks -such as `MockFetch`. See the crate docs -([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), -[videre-test](../crates/videre-test/src/lib.rs)) for the usage -pattern. +Add `nexum-sdk-test` as a dev-dep on the module crate to write strategy tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), [videre-test](../crates/videre-test/src/lib.rs)) for the usage pattern. ## Versioning -The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` -and `crates/videre-sdk/` in the shepherd monorepo. They are not yet -published to crates.io; modules depend on them via workspace paths. - -The `cowprotocol` crate is published to crates.io; the workspace -declares `cowprotocol = "0.2.0"` in `[workspace.dependencies]`, with a -temporary `[patch.crates-io]` git override to `nullislabs/cow-rs` -pending a release that ships the hash-only `OrderCreationAppData` -constructor (see the comment above the patch block in the root -`Cargo.toml`). Module Cargo.toml files that inherit from the workspace -pick it up automatically. +The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` and `crates/videre-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. + +The `cowprotocol` crate is published to crates.io; the workspace declares `cowprotocol = "0.2.0"` in `[workspace.dependencies]`, with a temporary `[patch.crates-io]` git override to `nullislabs/cow-rs` pending a release that ships the hash-only `OrderCreationAppData` constructor (see the comment above the patch block in the root `Cargo.toml`). Module Cargo.toml files that inherit from the workspace pick it up automatically. diff --git a/docs/testing-runtime-harness.md b/docs/testing-runtime-harness.md index b8539b14..d825d07a 100644 --- a/docs/testing-runtime-harness.md +++ b/docs/testing-runtime-harness.md @@ -1,56 +1,28 @@ # Testing the Runtime: the engine-side `test-utils` harness -Two entirely separate mock surfaces exist in this repo, for testing two -entirely separate things. Conflating them wastes effort - either testing -module logic through the slow, heavy engine harness, or trying (and -failing) to reach engine correctness through a guest-side mock. Read this -before writing a runtime test. +Two separate mock surfaces exist, for testing two separate things: module logic against a guest-side mock, and engine correctness against a real compiled component. Read this before writing a runtime test. ## The guardrail - **Module business logic is tested in plain Rust, no wasm.** A module's - decision logic lives in a host-generic `strategy.rs` - (`fn on_block(host: &H, ...)`), and its tests drive it against - `nexum-sdk-test::MockHost` (CoW modules: `shepherd-sdk-test::MockHost`). - No wasmtime, no component boundary, no engine crate at all. This is - already the dominant pattern across every shipped module (twap-monitor, - ethflow-watcher, price-alert, balance-tracker) - see - [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-shepherd-sdk-test). - **New module-logic tests belong here.** +decision logic lives in a host-generic `strategy.rs` (`fn on_block(host: &H, ...)`), and its tests drive it against `nexum-sdk-test::MockHost`; venue-facing logic adds `videre-test`'s transport mocks. No wasmtime, no component boundary, no engine crate at all. This is the dominant pattern across every shipped module (twap-monitor, ethflow-watcher, price-alert, balance-tracker); see [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-videre-test). **New module-logic tests belong here.** - **The engine harness (this page) is reserved for engine, host, and - boundary correctness**: supervision (poison, restart, resource traps), - dispatch isolation across chains and modules, fault and log capture, the - WASI clock override a real guest observes, capability wiring, stream - reconnect. These need a real compiled `.wasm` component over async mock - backends and genuinely cannot be faked in-process. +boundary correctness**: supervision (poison, restart, resource traps), dispatch isolation across chains and modules, fault and log capture, the WASI clock override a real guest observes, capability wiring, stream reconnect. These need a real compiled `.wasm` component over async mock backends and genuinely cannot be faked in-process. - **Do not test module business logic through the wasm harness.** If a - test only needs "given input X the strategy does Y", it belongs in - `strategy.rs` against `MockHost`, not in a booted fixture here. A - harness test that could be rewritten as a plain-Rust `MockHost` test - without losing coverage is in the wrong place. +test only needs "given input X the strategy does Y", it belongs in `strategy.rs` against `MockHost`, not in a booted fixture here. A harness test that could be rewritten as a plain-Rust `MockHost` test without losing coverage is in the wrong place. ## What `test-utils` provides -`crates/nexum-runtime`'s `test_utils` module (gated behind the -`test-utils` cargo feature) ships two layers: +`crates/nexum-runtime`'s `test_utils` module (gated behind the `test-utils` cargo feature) ships two layers: - **The bare mock backends** - `MockChainProvider`, `MockStateStore`, and - `MockTypes` implement the engine's component-seam traits with no - network and no disk. `mock_components` / `mock_components_from` bundle - them into a `Components` ready for `Supervisor::boot`. Use these when a - test needs to drive the supervisor directly - multi-module scenarios, - custom extensions, or checking host-interface wiring the harness below - doesn't expose. +`MockTypes` implement the engine's component-seam traits with no network and no disk. `mock_components` / `mock_components_from` bundle them into a `Components` ready for `Supervisor::boot`. Use these when a test needs to drive the supervisor directly - multi-module scenarios, custom extensions, or checking host-interface wiring the harness below doesn't expose. - **`TestRuntime` / `TestRuntimeBuilder`** - a higher-level harness over - the same mocks: launch *one* module through the real public - `RuntimeBuilder` path, inject events, and read back logs and store - writes, with no supervisor ceremony. This is what most engine-level - tests want. +the same mocks: launch *one* module through the real public `RuntimeBuilder` path, inject events, and read back logs and store writes, with no supervisor ceremony. This is what most engine-level tests want. ### Feature gate and the self dev-dependency -`test_utils` only compiles under the `test-utils` feature (it pulls -`tempfile`, needed for manifest staging): +`test_utils` only compiles under the `test-utils` feature (it pulls `tempfile`, needed for manifest staging): ```toml # crates/nexum-runtime/Cargo.toml @@ -64,15 +36,11 @@ test-utils = ["dep:tempfile"] nexum-runtime = { path = ".", features = ["test-utils"] } ``` -A crate outside `nexum-runtime` that wants the harness - an extension -crate testing its own backend against a real supervisor, for instance - -depends on `nexum-runtime` with `features = ["test-utils"]` directly; no -self-dependency trick needed there. +A crate outside `nexum-runtime` that wants the harness - an extension crate testing its own backend against a real supervisor, for instance - depends on `nexum-runtime` with `features = ["test-utils"]` directly; no self-dependency trick needed there. ## Worked example: `TestRuntime` -Build the module fixture once (`cargo build --target wasm32-wasip2 ---release -p example`, or `just build-module`), then: +Build the module fixture once (`cargo build --target wasm32-wasip2 --release -p example`, or `just build-module`), then: ```rust use alloy_rpc_types_eth::Header; @@ -121,28 +89,16 @@ chain_id = 1 Beyond the happy path above: - **Chain-log injection** mirrors blocks: `rt.push_chain_log(log)` where - `log: alloy_rpc_types_eth::Log`. +`log: alloy_rpc_types_eth::Log`. - **Chain-request programming**, for a module that calls `chain::request` - (e.g. an `eth_call` oracle read): `rt.chain().on_method(ChainMethod::EthCall, - r#""0x...""#)` before `launch`, or `on_request` for a full - `(method, params)` match. `ChainMethod` is - `nexum_runtime::host::component::ChainMethod`. +(e.g. an `eth_call` oracle read): `rt.chain().on_method(ChainMethod::EthCall, r#""0x...""#)` before `launch`, or `on_request` for a full `(method, params)` match. `ChainMethod` is `nexum_runtime::host::component::ChainMethod`. - **Error and stream-end injection**, to exercise reconnect and fault - paths: `rt.chain().push_block_err(err)` delivers a transport error to - the open block stream (`err: nexum_runtime::host::provider_pool::ProviderError`); - `rt.chain().close_block_stream()` simulates the upstream ending the - subscription, so the test can assert the event loop's reconnect logic - re-opens it. +paths: `rt.chain().push_block_err(err)` delivers a transport error to the open block stream (`err: nexum_runtime::host::provider_pool::ProviderError`); `rt.chain().close_block_stream()` simulates the upstream ending the subscription, so the test can assert the event loop's reconnect logic re-opens it. - **Store assertions**: `rt.store()` exposes the same `MockStateStore` - the module wrote through `local-store` - read back what a dispatched - event persisted. +the module wrote through `local-store` - read back what a dispatched event persisted. - **Guest time**: `rt.clock()` is the `ManualClock` installed as the - module's WASI clock override; advance it to test time-dependent logic - without a real sleep. +module's WASI clock override; advance it to test time-dependent logic without a real sleep. ## If you landed here looking for module tests -You want `nexum-sdk-test::MockHost` (or `shepherd-sdk-test::MockHost` for -CoW modules) instead - no wasm build, no engine crate, runs in -milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's -`strategy.rs` test module for the pattern. +You want `nexum-sdk-test::MockHost` instead (plus `videre-test`'s transport mocks for venue-facing logic): no wasm build, no engine crate, runs in milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's `strategy.rs` test module for the pattern. diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 6dc7d2ae..1329878b 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -1,37 +1,18 @@ -# Build your first Shepherd module +# Build your first module -This is the cold-start guide for an external developer. Target -completion time: **under four hours** from "I cloned the repo" to -"I see my module's first event in the engine log". +A walkthrough of the shipped `price-alert` example: a module that polls a Chainlink oracle on every block and logs when the price crosses a threshold. It exercises the three load-bearing patterns of a module: a block subscription, a `chain` read with ABI decode, and `[config]`-driven behaviour. Read the real files alongside this page under [`modules/examples/price-alert`](../modules/examples/price-alert). -Scenario: a **stop-loss** module that watches a Chainlink price -oracle on every block and submits a CoW Protocol order when the -price drops below a configured trigger. It combines every -load-bearing pattern in the SDK: +Venue submission (signing and posting an order to a venue such as CoW) is a separate concern layered on `videre-sdk`; see [Where to go from here](#where-to-go-from-here). -| Pattern | Where this tutorial uses it | Already shown in | -|---|---|---| -| Block subscription | "react every block" | [`price-alert`](../modules/examples/price-alert) | -| `chain::request` + ABI decode | read the oracle | [`price-alert`](../modules/examples/price-alert) | -| `local-store` | dedup submitted orders | [`balance-tracker`](../modules/examples/balance-tracker) | -| `cow_api::submit_order` | submit the order | [`twap-monitor`](../modules/twap-monitor) | -| Host-free tests via `MockHost` | unit tests | [`shepherd-sdk-test`](../crates/shepherd-sdk-test) | +## Prerequisites -If you would rather read working code than a walkthrough, those -four crates are the worked examples. The rest of this guide -sequences the build so the patterns are introduced one at a time. - -## 0. Prerequisites (15 minutes) - -You need a recent Rust toolchain (`rustc 1.91+`, ships with `cargo`) -and the WASM Component Model target. From the repo root: +A Rust toolchain and the WASM Component Model target: ```sh rustup target add wasm32-wasip2 ``` -Verify the engine builds and runs against the example module that -ships in the workspace: +Build and run the minimal `example` module to confirm the engine works: ```sh cargo build --target wasm32-wasip2 --release -p example @@ -40,89 +21,53 @@ cargo run -p nexum-cli -- \ modules/example/module.toml ``` -You should see two log lines from the example module - one in -`init`, one on the synthetic block event. Stop here and triage if -the build fails or those log lines do not appear; the rest of the -tutorial assumes a working local engine. +You should see the example module's `init` log line. Triage the build before continuing if it does not appear. -## 1. Scaffold the workspace member (15 minutes) +## Crate layout -Create a new crate under `modules/examples/`: - -```sh -mkdir -p modules/examples/stop-loss/src -``` - -The `Cargo.toml` follows the same template as `price-alert`: +A module is a `cdylib` crate that compiles to a WASM Component. `price-alert`'s `Cargo.toml`: ```toml -# modules/examples/stop-loss/Cargo.toml [package] -name = "stop-loss" +name = "price-alert" version = "0.1.0" edition.workspace = true -license.workspace = true -repository.workspace = true [lib] crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } -shepherd-sdk = { path = "../../../crates/shepherd-sdk" } -cowprotocol = { version = "1.0.0-alpha.3", default-features = false } -alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } -alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } +alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } +tracing = { version = "0.1", default-features = false } +wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -shepherd-sdk-test = { path = "../../../crates/shepherd-sdk-test" } +nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } +alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } ``` -Note the four key features: - -- **`crate-type = ["cdylib"]`** - produces a WASM Component when - built for `wasm32-wasip2`. -- **`nexum-sdk` + `shepherd-sdk` path deps** - the generic helpers - (`chain::`, `host::`, `config::`, `prelude`) come from `nexum-sdk`; - the CoW surface (`cow::`, the cowprotocol `prelude`) from - `shepherd-sdk`. A module that never touches the orderbook depends - only on `nexum-sdk`. -- **`shepherd-sdk-test` as a dev-dep** - the CoW `MockHost` + - assertion helpers, only linked under `cargo test`. -- **No direct `nexum-runtime` dep** - modules never link the engine; - they communicate via wit-bindgen-generated shims. +The load-bearing points: -Add the new crate to the workspace `members` list in `Cargo.toml` -at the repo root: +- `crate-type = ["cdylib"]` produces a Component when built for `wasm32-wasip2`. +- `nexum-sdk` supplies the generic helpers (`chain`, `host`, `config`, `prelude`). A module that talks to a venue also depends on `videre-sdk`; `price-alert` does not. +- `nexum-sdk-test` is a dev-dep only: its `MockHost` links under `cargo test`, never into the artefact. +- Modules never depend on `nexum-runtime`. They reach the host through wit-bindgen-generated imports the SDK macro wires up. -```toml -[workspace] -members = [ - # ... existing members - "modules/examples/stop-loss", -] -``` +The crate is a workspace member; add its path to `members` in the root `Cargo.toml`. -`cargo check --target wasm32-wasip2 -p stop-loss` should fail with -"no library targets found" - expected, you have not written any -source yet. +## The manifest -## 2. Author the manifest (10 minutes) - -`module.toml` declares the capabilities, subscriptions, and -operator-supplied config. Drop this next to `Cargo.toml`: +`module.toml` declares capabilities, subscriptions, and operator config: ```toml -# modules/examples/stop-loss/module.toml [module] -name = "stop-loss" +name = "price-alert" version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -required = ["logging", "chain", "local-store", "cow-api"] +required = ["logging", "chain"] optional = [] [capabilities.http] @@ -133,410 +78,157 @@ kind = "block" chain_id = 11155111 # Sepolia [config] -# Chainlink AggregatorV3Interface address (ETH/USD on Sepolia). -oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" # ETH/USD on Sepolia decimals = "8" -# Trigger price in the oracle's native decimal units. Below this, -# we sell. -trigger_price = "2500.00" -# CoW order parameters (signed by the owner off-chain ahead of -# time, then the module submits the pre-signed body on trigger). -owner = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" -sell_token = "0x6810e776880C02933D47DB1b9fc05908e5386b96" # GNO on Sepolia -buy_token = "0xfff9976782d46cc05630d1f6ebab18b2324d6b14" # WETH on Sepolia -sell_amount_wei = "1000000000000000000" # 1 GNO -buy_amount_wei = "300000000000000000" # 0.3 ETH -valid_to_seconds = "4294967295" # u32::MAX (no expiry) +threshold = "2500.00" +direction = "below" +every_n_blocks = "1" ``` -Three patterns worth noting: - -- **`required` matches the WIT imports the module uses.** The - engine enforces this at instantiation - declaring a capability - the module does not use is fine; missing a capability the module - does use is a hard error. -- **`[capabilities.http].allow` is empty** because stop-loss makes - no outbound HTTP calls. A module that needs them declares the - `http` capability, lists the hosts it may contact in `allow`, - and calls `nexum_sdk::http::fetch` (which wraps the standard - wasi:http interface); a - request to an off-list host fails with the matchable - `FetchError::Denied`. See `modules/examples/http-probe` for a - working example. -- **`[config]` values are stringly-typed in 0.2.** Your `init` - parses them; the M3 SDK's `OnceLock` pattern (see - `price-alert`) is the recommended idiom. - -## 3. Write the strategy (60 minutes) - -The strategy logic splits into two layers: - -- A pure function that takes `&impl Host` and runs the decision - tree. This is what your tests exercise - no `wit-bindgen`, no - `wasmtime`, fast iteration. -- A thin `Guest` impl in `lib.rs` that adapts the wit-bindgen- - generated host imports into a struct implementing - `nexum_sdk::host::Host`. +- `required` lists the host capabilities the module imports. The engine enforces the list at instantiation: missing a used capability is a hard error. +- `[capabilities.http].allow` is empty because `price-alert` makes no outbound HTTP. A module that needs it declares the `http` capability, lists the hosts it may contact, and calls `nexum_sdk::http::fetch`; an off-list host returns the matchable `FetchError::Denied`. See [`modules/examples/http-probe`](../modules/examples/http-probe). +- `[config]` values are strings. `init` parses them into a typed `Settings`. -### 3a. The pure strategy (30 minutes) +## The pure strategy -Sketch in `src/strategy.rs`: +Decision logic lives in `strategy.rs` as a host-generic function. It never names `wit-bindgen` or `wasmtime`, so tests drive it directly: ```rust -use alloy_primitives::{Address, I256}; -use alloy_sol_types::{SolCall, sol}; -use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; -use nexum_sdk::host::Fault; -use nexum_sdk::prelude::*; -use shepherd_sdk::cow::{CowApiError, CowHost}; -use shepherd_sdk::prelude::*; - -sol! { - interface AggregatorV3 { - function latestRoundData() external view returns ( - uint80, int256 answer, uint256, uint256, uint80 - ); - } -} - -pub struct Settings { - pub oracle_address: Address, - pub trigger_price_scaled: I256, - pub owner: Address, - pub sell_token: Address, - pub buy_token: Address, - pub sell_amount: U256, - pub buy_amount: U256, - pub valid_to: u32, -} +use nexum_sdk::chain::chainlink::read_latest_answer; +use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; -pub fn on_block( +pub fn on_block( host: &H, chain_id: u64, settings: &Settings, + block_number: u64, ) -> Result<(), Fault> { - // 1. Read the oracle. `host.request` returns a ChainError; `?` folds - // it into Fault via `From`. - let call = AggregatorV3::latestRoundDataCall {}; - let params = eth_call_params(&settings.oracle_address, &call.abi_encode()); - let result_json = host.request(chain_id, "eth_call", ¶ms)?; - let Some(bytes) = parse_eth_call_result(&result_json) else { - tracing::warn!("stop-loss: cannot decode oracle result"); - return Ok(()); - }; - let decoded = AggregatorV3::latestRoundDataCall::abi_decode_returns(&bytes) - .map_err(|e| Fault::InvalidInput(format!("oracle decode: {e}")))?; - let price = decoded.answer; - - // 2. Are we above trigger? Stay idle. - if price > settings.trigger_price_scaled { - tracing::info!(price = %price, "stop-loss idle"); + if !block_number.is_multiple_of(settings.every_n_blocks) { return Ok(()); } - - // 3. Dedup: did we already submit? - let dedup_key = format!("submitted:{:#x}", settings.owner); - if host.get(&dedup_key)?.is_some() { - tracing::info!("stop-loss: already submitted, skipping"); - return Ok(()); + let Some(answer) = + read_latest_answer(host, chain_id, settings.oracle_address, "price-alert") + else { + return Ok(()); // read_latest_answer already logged the failure + }; + if classify(answer, settings.threshold_scaled, settings.direction) { + tracing::warn!(answer = %answer, "price-alert: TRIGGERED"); + } else { + tracing::info!(answer = %answer, "price-alert: ok"); } - - // 4. Build the OrderCreation. (See `twap-monitor` for the full - // helper; for tutorial brevity we elide the JSON encoding.) - let body = build_order_body(settings)?; - // A real strategy matches on `CowApiError::Rejected` to classify the - // orderbook's typed rejection; here we fold to a Fault for brevity. - let uid = host.submit_order(chain_id, &body).map_err(|e| match e { - CowApiError::Fault(f) => f, - other => Fault::Internal(other.to_string()), - })?; - - // 5. Persist + log. - host.set(&dedup_key, uid.as_bytes())?; - tracing::warn!(uid = %uid, "stop-loss triggered"); Ok(()) } - -fn build_order_body(_s: &Settings) -> Result, Fault> { - // Cross-reference: `modules/twap-monitor/src/lib.rs::build_order_creation` - // shows the full assembly path using cowprotocol::OrderCreation:: - // from_signed_order_data + serde_json::to_vec. - todo!("see modules/twap-monitor for the canonical assembly") -} ``` The shape to internalise: -- **Every interaction with the world goes through `host`.** No - global wit-bindgen functions in the strategy; everything is a - method on `&impl Host`. -- **The function is pure-ish:** the only effects are through the - host trait. Tests in §3c run this function against `MockHost` - and assert on the side effects (calls + log lines + state writes). -- **Errors propagate but the loop should not abort on transient - failure.** Wrap upstream calls so a single bad event does not - poison the supervisor - see `price-alert`'s warn-and-return - pattern. - -### 3b. The Guest adapter (15 minutes) - -`src/lib.rs` adapts wit-bindgen's free functions into a struct that -implements `Host`. Every module needs the same glue here: a -`WitBindgenHost` adapter, the `Fault` conversions, and the -`Level` <-> wire-enum mapping for logging. Rather than hand-write -it, call the SDK's bind macro. Plain modules use -`nexum_sdk::bind_host_via_wit_bindgen!()`; stop-loss also submits -CoW orders, so it reaches for the CoW-aware variant, -`shepherd_sdk::bind_cow_host_via_wit_bindgen!()`: +- Every interaction with the world goes through `host`, bounded by the traits the module actually imports (`ChainHost + LoggingHost` here, matching the two declared capabilities). +- The function recovers from transient upstream failure by logging and returning `Ok`, so one bad event does not poison the supervisor. + +Config parsing follows the same one-shot style: `parse_config(&[(String, String)]) -> Result`, using the `nexum_sdk::config` helpers (`get_required`, `scale_decimal`). See the full source in `strategy.rs`. + +## The glue + +`lib.rs` declares the handlers and defers all per-cdylib glue to `#[nexum_sdk::module]`: ```rust #![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`, the fault and level `From` impls, `HostLogSink`, -// and `install_tracing` are generated below. Single source -// of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); -struct StopLoss; +struct PriceAlert; -impl Guest for StopLoss { +#[nexum_sdk::module] +impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config)?; - tracing::info!( - "stop-loss init: owner={:#x} trigger={} sell={:#x} buy={:#x}", - cfg.owner, - cfg.trigger_price_scaled, - cfg.sell_token, - cfg.buy_token, - ); let _ = SETTINGS.set(cfg); Ok(()) } - fn on_event(event: types::Event) -> 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)?; - } - Ok(()) + fn on_block(block: types::Block) -> Result<(), Fault> { + let Some(cfg) = SETTINGS.get() else { return Ok(()) }; + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + .map_err(Into::into) } } - -export!(StopLoss); ``` -The macro generates `WitBindgenHost`, the `ChainHost` / -`LocalStoreHost` / `LoggingHost` / `CowApiHost` impls, the -`Fault` `From` impls (both directions), and -`install_tracing`, which installs the guest `tracing` facade so -the `tracing::info!`, `warn!`, and `error!` macros reach the host -log call with no `Host` value to thread through. Call it once at -the top of `Guest::init`. Only the `Guest` impl and `SETTINGS` -initialisation above are per-module code. - -Once `install_tracing()` has run, those macros reach the host from -anywhere with no `Host` value to thread through, -so both `init` and the strategy log through the macros. Prefer them: -they take structured fields (`tracing::warn!(code = err.code, "...")`) -that the host records as `key=value` pairs, rather than a -pre-formatted string. The wire-level `logging::log(...)` and the -`host.log(Level::INFO, ...)` trait method still exist for the rare -call site that runs before the subscriber is installed, but every -shipped module logs through the facade. See -`modules/examples/balance-tracker` for a strategy written against -the macros throughout. - -### 3c. Unit tests against `MockHost` (15 minutes) - -In `src/strategy.rs`, append: +Apply the attribute to an inherent `impl` whose functions are the named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`; absent handlers no-op). The macro generates the `wit_bindgen::generate!` call, the `WitBindgenHost` adapter, the `Fault` conversions, `install_tracing`, and the `Guest`/`export!` glue. Call `install_tracing()` once in `init`; after that the `tracing` macros reach the host log from anywhere with no `Host` value to thread through. + +## Tests against `MockHost` + +Because the strategy is host-generic, tests run in plain Rust with no wasm toolchain, driving it against `nexum_sdk_test::MockHost` and capturing the log output: ```rust #[cfg(test)] mod tests { use super::*; - use nexum_sdk::host::*; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk_test::MockHost; - - fn settings(trigger_scaled: i64) -> Settings { - Settings { - oracle_address: "0x694AA1769357215DE4FAC081bf1f309aDC325306".parse().unwrap(), - trigger_price_scaled: I256::try_from(trigger_scaled).unwrap(), - owner: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".parse().unwrap(), - sell_token: Address::ZERO, - buy_token: Address::ZERO, - sell_amount: U256::ZERO, - buy_amount: U256::ZERO, - valid_to: 0xffff_ffff, - } - } - - /// Encode a Chainlink `latestRoundData` return for tests. - fn oracle_returns(answer: i64) -> String { - let returns = AggregatorV3::latestRoundDataCall::abi_encode_returns(&( - 0u128, - I256::try_from(answer).unwrap(), - U256::ZERO, - U256::ZERO, - 0u128, - )); - let hex = alloy_primitives::hex::encode_prefixed(returns); - format!("\"{hex}\"") - } + use nexum_sdk_test::{MockHost, capture_tracing}; #[test] - fn idle_when_price_above_trigger() { + fn triggers_below_threshold() { let host = MockHost::new(); - let s = settings(/*trigger*/ 1_000); - // Oracle returns 2000 (above the 1000 trigger). - host.chain.respond_to( - "eth_call", - &nexum_sdk::chain::eth_call_params( - &s.oracle_address, - &AggregatorV3::latestRoundDataCall {}.abi_encode(), - ), - Ok(oracle_returns(2000)), - ); - - let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); + let settings = sample_settings(250_050_000_000, Direction::Below); + programmed_eth_call(&host, settings.oracle_address, Ok(oracle_response_json(200_000_000_000))); + + let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); result.unwrap(); - assert_eq!(host.cow_api.call_count(), 0); - assert!(logs.any(|e| e.message.contains("stop-loss idle"))); - } - - #[test] - fn triggers_below_threshold_once() { - let host = MockHost::new(); - let s = settings(/*trigger*/ 1_000); - host.chain.respond_to( - "eth_call", - &nexum_sdk::chain::eth_call_params( - &s.oracle_address, - &AggregatorV3::latestRoundDataCall {}.abi_encode(), - ), - Ok(oracle_returns(500)), - ); - host.cow_api.respond(Ok("0xdeadbeef".into())); - - // First block: submits. - let (first, first_logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); - first.unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(first_logs.any(|e| e.message.contains("triggered"))); - - // Second block at the same price: dedup'd by the - // `submitted:` key. - let (second, second_logs) = capture_tracing(|| on_block(&host, 11_155_111, &s)); - second.unwrap(); - assert_eq!(host.cow_api.call_count(), 1); - assert!(second_logs.any(|e| e.message.contains("already submitted"))); + let ev = logs.expect_one(|e| e.level == Level::WARN); + assert_eq!(ev.message, "price-alert: TRIGGERED"); } } ``` -Run with `cargo test -p stop-loss`. Both tests should pass on a -plain host - no wasm toolchain involved. +Run with `cargo test -p price-alert`. See `strategy.rs` for the full test module, including the `MockHost` chain programming (`host.chain.respond_to`) and the throttle and error-path cases. + +Any behaviour expressible as "given this host state, do that" belongs here, not in the engine harness. See [testing-runtime-harness.md](testing-runtime-harness.md) for the guardrail between the two. -The takeaway: any time you can express a behaviour as "given this -host state, do that", the `MockHost` route is faster to iterate -than a full engine restart. +## Build and run -## 4. Build the `.wasm` artefact (5 minutes) +Build the artefact: ```sh -cargo build --target wasm32-wasip2 --release -p stop-loss -ls -lh target/wasm32-wasip2/release/stop_loss.wasm +cargo build --target wasm32-wasip2 --release -p price-alert +ls -lh target/wasm32-wasip2/release/price_alert.wasm ``` -Expected size: 250–350 KB. If it ballooned past ~500 KB, look at -`cargo tree -p stop-loss --target wasm32-wasip2` - usually a fresh -dependency pulled `reqwest` or `tokio` into the wasm graph. - -## 5. Wire `engine.toml` and run it (10 minutes) - -Add an RPC endpoint for Sepolia in `engine.toml`: +Block subscriptions ride `eth_subscribe`, so the chain needs a WebSocket endpoint. Point `engine.toml` at one: ```toml [chains.11155111] rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" ``` -WebSocket is required because the `[[subscription]]` is `kind = -"block"` and block subscriptions ride `eth_subscribe`. - -Run the engine pointed at your new module: +Run the engine over the module, supplying the chain config: ```sh cargo run -p nexum-cli -- \ - target/wasm32-wasip2/release/stop_loss.wasm \ - modules/examples/stop-loss/module.toml + target/wasm32-wasip2/release/price_alert.wasm \ + modules/examples/price-alert/module.toml \ + --engine-config engine.toml ``` -Expected output on first run (one log per: - -- `init`: `stop-loss: init ok` -- on each new block: either `stop-loss idle` (price above trigger) - or `stop-loss triggered, uid=0x...` then `already submitted` - on subsequent blocks. - -If the engine reports `unsupported` for any capability, double- -check that the module's `[capabilities].required` list matches the -imports the strategy actually uses. - -## 6. Where to go from here (10 minutes) - -- **Production hardening**: replace the synthetic `init` with the - per-module fuel + memory limits in `engine.toml::[engine.limits]` - (see [`docs/deployment.md`](./deployment.md)). -- **Real order assembly**: the `build_order_body` `todo!` in §3a - is the only piece this tutorial elided. Cross-reference - [`modules/twap-monitor/src/lib.rs::build_order_creation`] - - it's the canonical assembly path - (`cowprotocol::OrderCreation::from_signed_order_data` + - `serde_json::to_vec`). -- **Tests for the adapter layer**: the wit-bindgen ↔ `Host` - conversion functions are mechanical but worth a smoke test that - forces each enum variant through. See `shepherd-sdk-test`'s own - tests for the pattern. -- **Multi-chain operation**: change `[[subscription]].chain_id` and - the `engine.toml::[chains.]` entry. The strategy stays - unchanged because every host call already passes `chain_id` - through. - -## Time-budget check - -If a section ran much longer than the rough estimate above, please -file an issue tagged `docs/tutorial` with the section that dragged. -The target is **<4h cold from a fresh checkout to a successful run -in §5**, and we tighten the prose against feedback. - -## Reference index - -- SDK overview: [`docs/sdk.md`](./sdk.md) -- Deployment runbook: [`docs/deployment.md`](./deployment.md) +You should see the `init` line, then one `price-alert: ok` or `price-alert: TRIGGERED` line per new block. An `unsupported` fault means the module imports a capability its `[capabilities].required` list omits. + +## Where to go from here + +- Venue submission: a module that signs and posts orders to a venue depends on `videre-sdk` and a venue adapter crate (`cow-venue` for CoW), and uses `#[videre_sdk::keeper]` rather than `#[nexum_sdk::module]`. See [`modules/twap-monitor`](../modules/twap-monitor) and [docs/sdk.md](sdk.md). +- Local state: declare `local-store` and persist through `host.set`/`host.get`; see [`modules/examples/balance-tracker`](../modules/examples/balance-tracker). +- Outbound HTTP: [`modules/examples/http-probe`](../modules/examples/http-probe). +- Resource limits: [docs/deployment.md](deployment.md). + +## Reference + +- SDK overview: [docs/sdk.md](sdk.md) +- Deployment: [docs/deployment.md](deployment.md) - ADR-0001 (`engine.toml` vs `module.toml` split) -- ADR-0006 (TWAP / EthFlow as guest modules, no specialised - WIT interfaces) -- ADR-0007 (push protocol primitives to `cow-rs` first) -- Worked examples: [`price-alert`](../modules/examples/price-alert/), - [`balance-tracker`](../modules/examples/balance-tracker/), - [`twap-monitor`](../modules/twap-monitor/), - [`ethflow-watcher`](../modules/ethflow-watcher/) +- ADR-0006 (TWAP and EthFlow as guest modules, no specialised WIT interfaces) +- Worked examples: [`price-alert`](../modules/examples/price-alert/), [`balance-tracker`](../modules/examples/balance-tracker/), [`twap-monitor`](../modules/twap-monitor/), [`ethflow-watcher`](../modules/ethflow-watcher/) diff --git a/scripts/README.md b/scripts/README.md index 4d4fad15..261158b5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,10 +1,6 @@ -# scripts/ — E2E automation +# scripts/: E2E automation -Three-step automation for the E2E run on Sepolia. Wraps -the runbook (`docs/operations/e2e-testnet-runbook.md`) + the prep -punch list (`docs/operations/e2e-prep.md`) into shell -scripts so the operator only has to (a) fill in `.env` and -(b) decide when to stop. +Three-step automation for the E2E run on Sepolia. Wraps the runbook (`docs/operations/e2e-testnet-runbook.md`) into shell scripts so the operator only has to (a) fill in `.env` and (b) decide when to stop. ## One-time setup @@ -13,13 +9,12 @@ cp scripts/env-template scripts/.env $EDITOR scripts/.env # fill in RPC URLs + EOA private key ``` -`.env` is gitignored — secrets stay on disk, never enter chat, -never get committed. +`.env` is gitignored: secrets stay on disk, never enter chat, never get committed. Required external tools: - `cargo` + the `wasm32-wasip2` target (already there if you've - built the workspace before). +built the workspace before). - `cast` from foundry (`curl -L https://foundry.paradigm.xyz | bash && foundryup`). - `jq`, `curl`, `python3` with `pip3 install eth-utils eth-abi pycryptodome`. @@ -41,79 +36,62 @@ Three artefacts land in `docs/operations/e2e-reports/`: | `metrics-end-.txt` | `/metrics` snapshot at SIGINT. | | `e2e-report-.md` | Auto-filled E2E report. Operator reviews + signs off + commits. | -The first three are gitignored; the report is committed manually -once you've reviewed it. +The first three are gitignored; the report is committed manually once you've reviewed it. ## Script details ### `e2e-run.sh` - Renders `engine.e2e.toml` → `engine.e2e.local.toml` - (gitignored via `*.local.toml`) with `RPC_URL_SEPOLIA` - substituted in. Embedded URL key never reaches git. +(gitignored via `*.local.toml`) with `RPC_URL_SEPOLIA` substituted in. Embedded URL key never reaches git. - Cleans `data/e2e/` for a fresh local-store. - Builds 5 modules + engine in `--release`. - Launches via `nohup`; engine survives the parent shell exiting. - Waits ≤ 60 s for `supervisor ready modules=5 chains=1`. - Persists `ENGINE_PID`, `LOG_FILE`, `METRICS_START`, `START_TS`, - `START_ISO` into `scripts/.state` (gitignored). +`START_ISO` into `scripts/.state` (gitignored). ### `e2e-onchain.sh` Pre-flight: - Derives the EOA address from `OPERATOR_PRIVATE_KEY` and asserts - it matches the pinned `0x7bF140727D27ea64b607E042f1225680B40ECa6A`. +it matches the pinned `0x7bF140727D27ea64b607E042f1225680B40ECa6A`. - Asserts EOA balance ≥ 0.02 ETH. Required actions: -1. **TWAP** — `cast send ComposableCoW.create((handler,salt,staticInput),true)` - with calldata derived freshly per invocation by - `scripts/_twap_calldata.py` (sets `t0 = now - 60` so part 0 is - Ready immediately; hardcoding `t0 = 0` is the prior bug). Fires - `ConditionalOrderCreated` → twap-monitor logs `watch:`. -2. **EthFlow** — calls `scripts/_ethflow_quote.py` to hit cow.fi - `/api/v1/quote`, encodes the returned `EthFlowOrder.Data`, - then `cast send EthFlow.createOrder` with the right msg.value. - Fires `OrderPlacement` → ethflow-watcher logs `submitted:`. +1. **TWAP**: `cast send ComposableCoW.create((handler,salt,staticInput),true)` +with calldata derived freshly per invocation by `scripts/_twap_calldata.py` (sets `t0 = now - 60` so part 0 is Ready immediately; hardcoding `t0 = 0` is the prior bug). Fires `ConditionalOrderCreated` → twap-monitor logs `watch:`. +2. **EthFlow**: calls `scripts/_ethflow_quote.py` to hit cow.fi +`/api/v1/quote`, encodes the returned `EthFlowOrder.Data`, then `cast send EthFlow.createOrder` with the right msg.value. Fires `OrderPlacement` → ethflow-watcher logs `submitted:`. Optional (gated on `RUN_OPTIONAL_PRESIGN=1` in `.env`): 3. `WETH9.deposit()` payable 0.01 ETH. 4. `GPv2Settlement.setPreSignature(uid, true)` with the pinned UID. 5. `WETH9.approve(GPv2VaultRelayer, 0.005 ETH)`. -Each tx hash appended to `scripts/.state` so the report generator -can link them. - -> stop-loss already produces `submitted:{uid}` on the very first -> block (verified in run-prep smoke — the CoW orderbook accepts -> PreSign orders upfront). The optional path is only needed if you -> want the order to actually **settle** on-chain. +Each tx hash appended to `scripts/.state` so the report generator can link them. The optional presign path is only needed if you want the order to actually **settle** on-chain. ### `e2e-finish.sh` - Captures `metrics-end-.txt`. - Sends `SIGINT` to the engine PID. - Waits ≤ 30 s for `graceful shutdown complete` in the log - (graceful-shutdown path). +(graceful-shutdown path). - Escalates to `SIGKILL` if the engine is still alive after 30 s. - Invokes `e2e-report-gen.sh` to write the filled-in report. ### `e2e-report-gen.sh` -Reads `LOG_FILE`, `METRICS_START`, `METRICS_END`, `START_ISO`, -`END_ISO`, and the `TX_*` hashes from `scripts/.state`; computes: +Reads `LOG_FILE`, `METRICS_START`, `METRICS_END`, `START_ISO`, `END_ISO`, and the `TX_*` hashes from `scripts/.state`; computes: - Chain coverage (first/last block from `block_number` log fields). - Per-module first terminal marker timestamp + sample line. - Delta of every `shepherd_*` Prometheus counter / histogram. - ERROR + trapped + poisoned tallies. - Per-row acceptance checklist (auto-checks block delta ≥ 1500, - marker per module, zero traps, zero poisons, zero ERRORs, - TWAP+EthFlow tx hashes present). +marker per module, zero traps, zero poisons, zero ERRORs, TWAP+EthFlow tx hashes present). -Writes `e2e-report-.md` in `docs/operations/e2e-reports/`. -Operator: review + add anomalies (section 6) + sign off -(section 8) + commit with `git add -f`. +Writes `e2e-report-.md` in `docs/operations/e2e-reports/`. Operator: review + add anomalies (section 6) + sign off (section 8) + commit with `git add -f`. ## Troubleshooting @@ -129,7 +107,7 @@ Operator: review + add anomalies (section 6) + sign off ## Re-running cleanly ```bash -scripts/e2e-finish.sh # safe even if it's the only command — graceful exit +scripts/e2e-finish.sh # safe even if it's the only command: graceful exit rm -rf data/e2e # wipe local-store rm scripts/.state # wipe run state scripts/e2e-run.sh # fresh start From e8a873e7b49c7b8c9a1c47da1f2ec1fe2df9489b Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:12:07 +0000 Subject: [PATCH 115/139] docs: tighten rustdoc crate and module headers (#603) Trim discursive and stale rustdoc across the workspace: cut the wit-bindgen companionship and "why no generate!" essays in nexum-sdk to the factual contract, drop the "before this macro existed" narrative in wit_bindgen_macro, and remove the #94 planning reference in nexum-sdk-test. Reduce version-promise notes to current behaviour: the identity, messaging, and remote-store host impls are stated as unimplemented stubs (confirmed against the code) rather than "deferred to 0.3"; manifest and engine-config headers drop the 0.3 and M3/M4 scheduling; restart_policy drops the 0.3/M5 follow-up and tuneable promises. Remove the external-project rationale citation in provider_pool ("per alloy's own guidance"), retarget load-gen off the M4 label and the deleted e2e-prep.md reference, and purge em dashes from the touched doc comments. Part of #598. --- crates/nexum-runtime/src/engine_config.rs | 11 ++++------ .../nexum-runtime/src/host/impls/identity.rs | 6 +++--- .../nexum-runtime/src/host/impls/messaging.rs | 7 +++---- .../src/host/impls/remote_store.rs | 3 ++- .../nexum-runtime/src/host/provider_pool.rs | 7 +++---- crates/nexum-runtime/src/manifest/mod.rs | 19 +++++++---------- crates/nexum-runtime/src/manifest/types.rs | 15 +++++++------ .../nexum-runtime/src/runtime/event_loop.rs | 2 +- .../src/runtime/restart_policy.rs | 7 +++---- crates/nexum-runtime/src/supervisor/tests.rs | 6 +++--- .../nexum-runtime/src/test_utils/harness.rs | 4 ++-- crates/nexum-sdk-test/src/lib.rs | 8 +++---- crates/nexum-sdk/src/lib.rs | 21 +++++++------------ crates/nexum-sdk/src/wit_bindgen_macro.rs | 9 +++----- tools/load-gen/src/main.rs | 11 +++++----- 15 files changed, 58 insertions(+), 78 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index c950cf1f..c1b821d7 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -159,7 +159,7 @@ pub struct EngineConfig { #[serde(default)] pub engine: EngineSection, /// Per-module wasmtime resource limits. Applies uniformly to every - /// module; per-module overrides land in 0.3. + /// module. #[serde(default)] pub limits: ModuleLimits, /// Per-chain RPC URLs keyed by EIP-155 chain id. Numeric TOML keys @@ -176,10 +176,7 @@ pub struct EngineConfig { #[serde(default)] pub extensions: HashMap, /// Modules the supervisor should boot. Each entry resolves a - /// `(component.wasm, module.toml)` pair on the local filesystem - /// for 0.2 - content-addressed resolution (Swarm / OCI / - /// `[[content.sources]]`) lands in 0.3 per - /// `docs/03-module-discovery.md`. + /// `(component.wasm, module.toml)` pair on the local filesystem. #[serde(default)] pub modules: Vec, /// Provider components the supervisor should boot alongside the @@ -271,8 +268,8 @@ fn default_log_backfill_concurrency() -> usize { /// `[engine.metrics]` config. When `enabled = true` the engine starts /// a Prometheus HTTP exporter on `bind_addr` and serves `/metrics`. /// -/// Default: disabled. Operators opt in explicitly so the M3 / M4 -/// runbook smoke runs do not bind a port unintentionally. +/// Default: disabled. Operators opt in explicitly so a run does not +/// bind a port unintentionally. #[derive(Debug, Deserialize)] pub struct MetricsSection { #[serde(default)] diff --git a/crates/nexum-runtime/src/host/impls/identity.rs b/crates/nexum-runtime/src/host/impls/identity.rs index bbea4d44..392ed552 100644 --- a/crates/nexum-runtime/src/host/impls/identity.rs +++ b/crates/nexum-runtime/src/host/impls/identity.rs @@ -1,6 +1,6 @@ -//! `nexum:host/identity`: deferred to 0.3 (keystore / KMS backend). -//! `accounts()` returns an empty roster so guests can probe-then-skip; -//! signing returns `unsupported`. +//! `nexum:host/identity`: unimplemented stub. `accounts()` returns an +//! empty roster so guests can probe-then-skip; signing returns +//! `unsupported`. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index 4cf45da5..459994ab 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,7 +1,6 @@ -//! `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 provider carrying a +//! `nexum:host/messaging`: unimplemented stub. `publish` reports +//! `unsupported` and `query` returns empty. The per-store topic scope is +//! enforced ahead of that stub: a provider carrying a //! `[[adapters]].messaging_topics` grant may only publish within it, so //! the egress boundary is live even though delivery is not. diff --git a/crates/nexum-runtime/src/host/impls/remote_store.rs b/crates/nexum-runtime/src/host/impls/remote_store.rs index f2c8661f..1894484f 100644 --- a/crates/nexum-runtime/src/host/impls/remote_store.rs +++ b/crates/nexum-runtime/src/host/impls/remote_store.rs @@ -1,4 +1,5 @@ -//! `nexum:host/remote-store`: deferred to 0.3 (Swarm backend). +//! `nexum:host/remote-store`: unimplemented stub; every call returns the +//! unsupported fault. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 55d5e912..b30c7a40 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -44,10 +44,9 @@ const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2); /// Transport retry-layer parameters. `watch_canonical_logs_from` surfaces /// RPC errors to the caller and ends the stream on the first one unless -/// the transport retries it (per alloy's own guidance on that builder). -/// This layer heals transient blips below the poller, so a momentary node -/// hiccup does not force a re-open - and a re-open is exactly where a gap -/// could reappear. +/// the transport retries it. This layer heals transient blips below the +/// poller, so a momentary node hiccup does not force a re-open, which is +/// exactly where a gap could reappear. const RPC_MAX_RETRIES: u32 = 10; const RPC_RETRY_BACKOFF_MS: u64 = 300; /// Compute-units-per-second budget the retry layer paces rate-limited diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index da7e0c06..17b49ab2 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -1,21 +1,16 @@ -//! `module.toml` parser and capability-enforcement helpers (0.2 scope). +//! `module.toml` parser and capability-enforcement helpers. //! -//! 0.2 intentionally ships a slim subset of the manifest spec: -//! -//! - `[capabilities].required` is parsed and validated (names must be in -//! the known capability set; the 0.2 reference engine always provides -//! all of them, so this is a sanity check + future-proofing). -//! - `[capabilities].optional` is parsed and logged; trap-stub fallback -//! for absent optionals is deferred to 0.3. +//! - `[capabilities].required` is parsed and validated: names must be in +//! the known capability set, which the engine always provides. +//! - `[capabilities].optional` is parsed and logged. //! - `[capabilities.http].allow` is parsed and consulted by the //! wasi:http gate before any outbound call. //! - `[config]` is flattened to `Vec<(String, String)>` and passed to the -//! module's `init`. Typed `config-value` variant is deferred to 0.3. +//! module's `init`. //! //! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted and the engine falls back to 0.1 -//! behaviour (treat every linked capability as required). This fallback -//! will be removed in 0.3. +//! a deprecation warning is emitted and the engine falls back to treating +//! every linked capability as required. //! //! ## Layout //! diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 1960dc61..a90f4abd 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -29,8 +29,8 @@ pub struct Manifest { pub config: toml::Table, /// Event subscriptions the runtime wires before calling /// `_init`. See `docs/02-modules-events-packaging.md` for the - /// schema; 0.2 implements `block` and `chain-log` kinds, `cron` is - /// parsed and ignored (deferred to 0.3). + /// schema. Implements `block` and `chain-log` kinds; `cron` is + /// parsed and ignored. #[serde(default, rename = "subscription")] pub subscriptions: Vec, /// Extension-owned sections: every non-core top-level key, parsed @@ -54,7 +54,7 @@ pub type ExtensionSections = BTreeMap; /// fails loudly rather than silently disabling an event source. #[derive(Debug, Clone)] pub enum Subscription { - /// New-block events. Fan-out is shared per chain - the + /// New-block events. Fan-out is shared per chain: the /// supervisor opens one subscription per chain id and routes to /// every module that asked for blocks on that chain. Block { @@ -62,7 +62,7 @@ pub enum Subscription { chain_id: u64, }, /// Chain-log events matching `address` + topic-0. Fan-out is - /// per-module - the supervisor opens one subscription per + /// per-module: the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. ChainLog { @@ -71,7 +71,7 @@ pub enum Subscription { /// Contract address as `0x`-prefixed 20-byte hex. Optional. address: Option, /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional - when absent the + /// prefixed 32-byte hex. Optional: when absent the /// subscription matches every event from the address(es). event_signature: Option, /// Resume across engine restarts. When `true` the host persists a @@ -87,10 +87,9 @@ pub enum Subscription { /// tolerates dropping the oldest missed blocks. max_lookback: Option, }, - /// Cron-scheduled tick. 0.2 parses but does not dispatch; the + /// Cron-scheduled tick. Parsed but not dispatched; the /// supervisor emits a warning so the operator knows the - /// declaration is currently inert. `schedule` is preserved so a - /// 0.3 dispatcher can pick it up without re-parsing the manifest. + /// declaration is currently inert. `schedule` is preserved verbatim. Cron { /// Standard 5-field cron expression. #[allow(dead_code)] diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 3b848306..265e2885 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -670,7 +670,7 @@ mod tests { /// `open_block_streams` spawns one independent reconnect task per chain. /// Per-chain task isolation means a slow or reconnecting chain does not - /// delay events from other chains — each chain has its own mpsc channel + /// delay events from other chains: each chain has its own mpsc channel /// and backoff timer. #[tokio::test] async fn open_block_streams_opens_one_task_per_chain() { diff --git a/crates/nexum-runtime/src/runtime/restart_policy.rs b/crates/nexum-runtime/src/runtime/restart_policy.rs index 7b80dd62..26be7eb7 100644 --- a/crates/nexum-runtime/src/runtime/restart_policy.rs +++ b/crates/nexum-runtime/src/runtime/restart_policy.rs @@ -16,14 +16,13 @@ //! | ... | doubles | //! | 9+ | capped at 5 minutes | //! -//! State is in-memory per supervisor process. Persistence across -//! engine restarts is out of scope (a separate 0.3 / M5 follow-up -//! that lands alongside `submitted:{uid}` cross-restart dedup). +//! State is in-memory per supervisor process; it does not persist +//! across engine restarts. use std::time::Duration; /// Hard cap on the restart backoff. After ~8 doublings we plateau -/// here. Tuneable in 0.3 via `engine.toml::[engine.restart]`. +/// here. pub const RESTART_MAX_BACKOFF: Duration = Duration::from_secs(300); /// Compute the wait window the supervisor honours before the next diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 829eae09..96bbb443 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -192,7 +192,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { // supervisor boundary, without loading a real wasm module. /// Block and chain-log streams are both consumed within the same `run()` -/// session — the `biased` select does not starve either event kind. One +/// session: the `biased` select does not starve either event kind. One /// item of each kind is queued before the loop starts; `run()`'s returned /// tally must show both were drained. A regression that breaks either /// select arm (or reorders the `biased` polling so one side never fires) @@ -259,8 +259,8 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { /// After `run()` returns on the shutdown path, all reconnect tasks are /// drained: the Shutdown arm calls `tasks.shutdown()`, which aborts every /// handle and then joins each one, so no task detaches and outlives the -/// engine. (The companion contract — a task parked on a dropped receiver -/// exits with `ReceiverGone` on its own — is asserted directly in +/// engine. (The companion contract, a task parked on a dropped receiver +/// exiting with `ReceiverGone` on its own, is asserted directly in /// `event_loop::tests::reconnect_task_exits_receiver_gone_when_receiver_drops`; /// it cannot be observed here because `TaskSet::shutdown` aborts first.) /// Issue #58. diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 10d808d6..7dc21eef 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -641,7 +641,7 @@ chain_id = 1 rt.wait().await.expect("clean shutdown"); } - /// Blocks pushed in order arrive at the module in the same order — + /// Blocks pushed in order arrive at the module in the same order: /// the per-chain stream, the select, and the dispatch path preserve /// delivery order. Issue #56's ordering guarantee, asserted on the /// module's own log records rather than inferred from termination. @@ -693,7 +693,7 @@ chain_id = 1 /// so a block that was picked up finishes its wasmtime call and its /// log record survives `wait()`. The test first proves the dispatch /// completed (log line present), then shuts down and re-reads the same - /// record after the engine is fully torn down — if teardown dropped or + /// record after the engine is fully torn down: if teardown dropped or /// truncated completed work, the second read fails. Issue #58. #[tokio::test] async fn harness_shutdown_preserves_completed_dispatch() { diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 1da359e2..6d8b31b4 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -51,7 +51,7 @@ //! //! The traits report failures as [`nexum_sdk::host::Fault`] rather than //! the `Fault` `wit_bindgen::generate!` emits per-module. A module -//! bridges with a trivial converter on its own crate boundary - see the +//! bridges with a trivial converter on its own crate boundary; see the //! tutorial for the exact shape. //! //! Domain test crates compose these mocks with their own scripted @@ -600,11 +600,11 @@ impl RemoteStoreHost for MockRemoteStore { /// /// # 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 +/// Two gaps vs `redb`: +/// - **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, +/// - **No concurrent access**: the backing `RefCell` is single-threaded, /// whereas `redb` uses MVCC. #[derive(Default)] pub struct MockLocalStore { diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 9961cc24..c131660b 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -5,11 +5,10 @@ //! use them regardless of which world it exports. Domain layers such as //! the CoW SDK depend on this crate and add their own surface on top. //! -//! The crate is the shared companion to the per-module -//! `wit_bindgen::generate!` invocation: modules keep their own -//! wit-bindgen call (which emits the world-specific `Guest` trait, -//! `Fault` shape, and host import shims into the module's own -//! crate) and pull helpers and canonical primitive types from here. +//! Modules keep their own `wit_bindgen::generate!` call, which emits +//! the world-specific `Guest` trait, `Fault` shape, and host import +//! shims into the module's own crate, and pull helpers and canonical +//! primitive types from here. //! //! ## What lives here //! @@ -69,14 +68,10 @@ //! sink so module authors emit `tracing::info!(...)` with no host //! parameter to thread. //! -//! ## Why no `wit_bindgen::generate!` here -//! -//! The macro emits types into the calling crate (the module's -//! cdylib). Re-exporting wit-bindgen output from a library crate -//! would duplicate symbols and break the component-export contract. -//! Helpers in this SDK therefore take primitive types (`&[u8]`, -//! `Option<&str>`, slices) rather than the per-module `Fault` -//! type; modules unpack their `Fault` on the way in. +//! Helpers take primitive types (`&[u8]`, `Option<&str>`, slices) +//! rather than the per-module `Fault` type, so this library emits no +//! wit-bindgen output of its own; modules unpack their `Fault` on the +//! way in. //! //! [`Address`]: alloy_primitives::Address //! [`B256`]: alloy_primitives::B256 diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 98059b8b..003af051 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -1,10 +1,7 @@ //! Declarative macro that generates the `WitBindgenHost` adapter -//! every module ships in `lib.rs`. -//! -//! Before this macro existed, each module hand-rolled ~80 lines of -//! mechanical glue: the `struct WitBindgenHost;` plus the core trait -//! impls plus the fault, chain-error, and level conversions. The code -//! differed across modules in zero places that were not bugs. +//! every module ships in `lib.rs`: the `struct WitBindgenHost;` plus +//! the core trait impls and the fault, chain-error, and level +//! conversions. //! //! The adapter is capability-selected: the `caps: [...]` form emits //! only the pieces backed by the module's declared capabilities diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 15a05760..60d20588 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -1,11 +1,11 @@ -//! Anvil-side load generator for shepherd's M4 load test. +//! Anvil-side load generator for the runtime load test. //! //! Connects to an Anvil fork of Sepolia, impersonates the pinned test -//! EOA (no signer required - `anvil_impersonateAccount` skips +//! EOA (no signer required: `anvil_impersonateAccount` skips //! signature verification), and submits N `ComposableCoW.create(...)` //! plus M `CoWSwapEthFlow.createOrder(...)` calls per new block. The //! resulting `ConditionalOrderCreated` and `OrderPlacement` events are -//! what shepherd's twap-monitor and ethflow-watcher dispatch on. +//! what the twap-monitor and ethflow-watcher modules dispatch on. //! //! Knobs (`--help` for the full list): //! - `--anvil ` WebSocket URL of the Anvil fork @@ -13,9 +13,8 @@ //! - `--ethflow-per-block M` calls to CoWSwapEthFlow.createOrder per block //! - `--duration ` wall-clock window the loop runs for //! -//! Pinned identities mirror `docs/operations/e2e-prep.md`: -//! EOA, ComposableCoW, TWAP handler, CoWSwapEthFlow, WETH9, COW token, -//! Safe. These are constant across the Sepolia fork. +//! Pinned identities: EOA, ComposableCoW, TWAP handler, CoWSwapEthFlow, +//! WETH9, COW token, Safe. These are constant across the Sepolia fork. #![cfg_attr(not(test), warn(unused_crate_dependencies))] From aeccd60af6aa50b4dc9d650e62c79638370d84d0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:15:12 +0000 Subject: [PATCH 116/139] docs: operations sweep, delete dated reports and trim runbooks (#604) * docs: operations sweep, delete dated reports and trim runbooks Delete every point-in-time artefact (load, backtest, baseline, and e2e report snapshots, the m3 edge-case validation write-up, and the e2e run-prep punch list) so no dated result crosses the carve. Trim the five runbooks (m2, m3, e2e, load, soak) to repeatable numbered steps: cut motivation, milestone scheduling, grant-evidence framing, and result commentary, and purge em dashes. Salvage the reusable UID and ComposableCoW calldata re-derivation recipes from the deleted e2e-prep into the e2e runbook. Align the engine run commands to the shepherd binary the just recipes and load-run.sh invoke, and trim the e2e report template to its fields. Part of #598. * docs: unwrap paragraphs to one logical line for diff-friendliness Hard-wrapped prose churns diffs: a one-word edit reflows the whole paragraph. Join each paragraph onto a single logical line and let it soft-wrap. Content unchanged (word and heading counts preserved); code fences, tables, lists, blockquotes and headings untouched. --- .../backtest-7d-2026-06-22.md | 289 -------------- .../baselines/baseline-latency-2026-06-19.md | 60 --- docs/operations/e2e-prep.md | 334 ---------------- .../e2e-reports/e2e-report-2026-06-18.md | 242 ------------ .../e2e-reports/e2e-report.template.md | 50 +-- docs/operations/e2e-testnet-runbook.md | 363 ++++++------------ .../load-reports/load-20x20-2026-06-19.md | 84 ---- .../load-reports/load-50x50-2026-06-19.md | 113 ------ .../load-50x50-parallel-2026-06-19.md | 148 ------- .../load-reports/load-5x5-2026-06-19.md | 161 -------- docs/operations/load-testnet-runbook.md | 183 ++------- docs/operations/m2-testnet-runbook.md | 209 ++-------- docs/operations/m3-edge-case-validation.md | 230 ----------- docs/operations/m3-testnet-runbook.md | 193 ++-------- docs/operations/soak-runbook.md | 201 ++-------- 15 files changed, 296 insertions(+), 2564 deletions(-) delete mode 100644 docs/operations/backtest-reports/backtest-7d-2026-06-22.md delete mode 100644 docs/operations/baselines/baseline-latency-2026-06-19.md delete mode 100644 docs/operations/e2e-prep.md delete mode 100644 docs/operations/e2e-reports/e2e-report-2026-06-18.md delete mode 100644 docs/operations/load-reports/load-20x20-2026-06-19.md delete mode 100644 docs/operations/load-reports/load-50x50-2026-06-19.md delete mode 100644 docs/operations/load-reports/load-50x50-parallel-2026-06-19.md delete mode 100644 docs/operations/load-reports/load-5x5-2026-06-19.md delete mode 100644 docs/operations/m3-edge-case-validation.md diff --git a/docs/operations/backtest-reports/backtest-7d-2026-06-22.md b/docs/operations/backtest-reports/backtest-7d-2026-06-22.md deleted file mode 100644 index a1796243..00000000 --- a/docs/operations/backtest-reports/backtest-7d-2026-06-22.md +++ /dev/null @@ -1,289 +0,0 @@ -# Pre-soak backtest - 7d window on Sepolia (2026-07-10T18:55:13Z) - -Replays every collected EthFlow `OrderPlacement` event through the production `ethflow_watcher::strategy::on_chain_logs` code path via `shepherd_sdk_test::MockHost`. The orderbook is **never hit**: the MockHost programs a catch-all 200 for all `cow_api_request` calls so the observe+verify strategy sees every fixture as already indexed. Success is measured by whether the strategy wrote the exact `observed:{uid}` marker to the local store after the 200 confirmation. - -## Run metadata - -| Field | Value | -|---|---| -| Chain | Sepolia (id=11155111) | -| Window | 7d (11066372..11116772) | -| Collected at | 2026-06-22T15:47:06Z | -| RPC | `https://sepolia.drpc.org` | -| Orderbook | `https://api.cow.fi/sepolia/api/v1` | -| EthFlow owner | `0xba3cb449bd2b4adddbc894d8697f5170800eadec` | -| ComposableCoW | `0xfdafc9d1902f4e0b84f65f49f244b32b31013b74` | -| Accept threshold | 95% | - -## EthFlow replay summary - -- Events replayed: **240** -- Observed: **240** (100.0%) - -Accepted (Observed + RejectedExpected): **240/240 = 100.0%** - PASS vs. threshold (95%). - -## Anomalies - -None. Every replayed event landed in `Observed` or `RejectedExpected`. - -## TWAP lane status - -26 `ConditionalOrderCreated` events were collected in this window. **Replay deferred to Phase 2B** because driving `twap_monitor::strategy::on_block` requires walking each watch's `eth_call(getTradeableOrderWithSignature)` per-block - a workload public-tier RPCs refuse (see the baseline-latency finding). The fixtures are committed for the future re-run; the TWAP gap on the sign-off is intentional and tracked separately. - -## Sign-off - -**PASS.** EthFlow replay clears the 95% acceptance bar with no outstanding anomalies. All fixtures were Observed (strategy wrote `observed:{uid}` to local store). Soak is unblocked from the backtest side; remaining blockers are external (paid RPC + VM for the wall-clock run). - -## Reproducing - -```bash -python3 tools/backtest-collect/backtest_collect.py --days 7 -cargo run -p shepherd-backtest -- \ - --fixtures tools/backtest-collect/fixtures-YYYY-MM-DD.json -``` - -## Appendix: per-event classification - -| # | uid | block | timestamp | class | -|---:|---|---:|---:|---| -| 1 | `0x5e43c584..ffffff` | 11066776 | 1781541696 | Observed | -| 2 | `0xf56cba95..ffffff` | 11066777 | 1781541708 | Observed | -| 3 | `0xb47d7c7f..ffffff` | 11066798 | 1781541960 | Observed | -| 4 | `0x4069c497..ffffff` | 11066799 | 1781541972 | Observed | -| 5 | `0xed31c793..ffffff` | 11066818 | 1781542200 | Observed | -| 6 | `0x5da63e4e..ffffff` | 11066819 | 1781542212 | Observed | -| 7 | `0x6c3227cb..ffffff` | 11066844 | 1781542512 | Observed | -| 8 | `0x97fa5d54..ffffff` | 11066844 | 1781542512 | Observed | -| 9 | `0xe5197c47..ffffff` | 11066863 | 1781542740 | Observed | -| 10 | `0xf13dc9a1..ffffff` | 11066863 | 1781542740 | Observed | -| 11 | `0x6118ba38..ffffff` | 11067068 | 1781545200 | Observed | -| 12 | `0xb2efc9f1..ffffff` | 11067190 | 1781546664 | Observed | -| 13 | `0x7f76bfa1..ffffff` | 11067200 | 1781546784 | Observed | -| 14 | `0x2c6cca82..ffffff` | 11067729 | 1781553132 | Observed | -| 15 | `0xe32c8541..ffffff` | 11068198 | 1781558760 | Observed | -| 16 | `0x5af30b3e..ffffff` | 11068620 | 1781563836 | Observed | -| 17 | `0xfa067d01..ffffff` | 11069102 | 1781569632 | Observed | -| 18 | `0xf242a25b..ffffff` | 11069119 | 1781569836 | Observed | -| 19 | `0x8bd36dc7..ffffff` | 11069495 | 1781574348 | Observed | -| 20 | `0x591da4be..ffffff` | 11069501 | 1781574420 | Observed | -| 21 | `0xa9a747d5..ffffff` | 11069948 | 1781579796 | Observed | -| 22 | `0x8b778cc7..ffffff` | 11070077 | 1781581344 | Observed | -| 23 | `0x0a56f14f..ffffff` | 11070107 | 1781581704 | Observed | -| 24 | `0x43445124..ffffff` | 11070306 | 1781584104 | Observed | -| 25 | `0x01026a74..ffffff` | 11070324 | 1781584320 | Observed | -| 26 | `0x71b74cf6..ffffff` | 11070394 | 1781585160 | Observed | -| 27 | `0x8834595a..ffffff` | 11070716 | 1781589024 | Observed | -| 28 | `0x6f4fae10..ffffff` | 11071674 | 1781600520 | Observed | -| 29 | `0x50211d94..ffffff` | 11071675 | 1781600532 | Observed | -| 30 | `0xcd925b4d..ffffff` | 11071676 | 1781600544 | Observed | -| 31 | `0x297cb16d..ffffff` | 11071677 | 1781600556 | Observed | -| 32 | `0x57e24641..ffffff` | 11071679 | 1781600580 | Observed | -| 33 | `0xb30c35c2..ffffff` | 11071681 | 1781600604 | Observed | -| 34 | `0x743f9609..ffffff` | 11071682 | 1781600616 | Observed | -| 35 | `0x713bc286..ffffff` | 11071683 | 1781600628 | Observed | -| 36 | `0x7925f236..ffffff` | 11071684 | 1781600640 | Observed | -| 37 | `0x11d613bf..ffffff` | 11071687 | 1781600676 | Observed | -| 38 | `0xd42de36d..ffffff` | 11072025 | 1781604732 | Observed | -| 39 | `0xe81f3615..ffffff` | 11072165 | 1781606412 | Observed | -| 40 | `0xfe8b70cc..ffffff` | 11072663 | 1781612388 | Observed | -| 41 | `0xfb9ebfe2..ffffff` | 11072665 | 1781612412 | Observed | -| 42 | `0x76c0a5ea..ffffff` | 11072730 | 1781613192 | Observed | -| 43 | `0xb2e7c4b5..ffffff` | 11072738 | 1781613288 | Observed | -| 44 | `0x20484bb9..ffffff` | 11072742 | 1781613336 | Observed | -| 45 | `0x541fb237..ffffff` | 11072774 | 1781613720 | Observed | -| 46 | `0x2404f184..ffffff` | 11072774 | 1781613720 | Observed | -| 47 | `0x30e44c53..ffffff` | 11072791 | 1781613924 | Observed | -| 48 | `0x9a340499..ffffff` | 11072801 | 1781614044 | Observed | -| 49 | `0x7f7b151d..ffffff` | 11072849 | 1781614620 | Observed | -| 50 | `0xb68eeaf4..ffffff` | 11072850 | 1781614632 | Observed | -| 51 | `0x5395405d..ffffff` | 11072881 | 1781615004 | Observed | -| 52 | `0x45d5563b..ffffff` | 11072881 | 1781615004 | Observed | -| 53 | `0x15431ff4..ffffff` | 11072907 | 1781615316 | Observed | -| 54 | `0xab2d5a81..ffffff` | 11072907 | 1781615316 | Observed | -| 55 | `0x3918584c..ffffff` | 11072915 | 1781615412 | Observed | -| 56 | `0x433ded5d..ffffff` | 11072937 | 1781615676 | Observed | -| 57 | `0x38e4a8d5..ffffff` | 11072938 | 1781615688 | Observed | -| 58 | `0x2391bfae..ffffff` | 11073096 | 1781617584 | Observed | -| 59 | `0xf6cd036e..ffffff` | 11073102 | 1781617656 | Observed | -| 60 | `0x157fa6bc..ffffff` | 11073102 | 1781617656 | Observed | -| 61 | `0x70aeba19..ffffff` | 11073107 | 1781617716 | Observed | -| 62 | `0x8d4ca0b6..ffffff` | 11073145 | 1781618172 | Observed | -| 63 | `0x45902e3e..ffffff` | 11073145 | 1781618172 | Observed | -| 64 | `0x19d6b26a..ffffff` | 11073178 | 1781618568 | Observed | -| 65 | `0x8ecd3588..ffffff` | 11073179 | 1781618580 | Observed | -| 66 | `0x2baee5d9..ffffff` | 11073185 | 1781618652 | Observed | -| 67 | `0x0a684eb7..ffffff` | 11073186 | 1781618664 | Observed | -| 68 | `0xccf652d3..ffffff` | 11073220 | 1781619072 | Observed | -| 69 | `0x51bd84d8..ffffff` | 11073220 | 1781619072 | Observed | -| 70 | `0x3f2f050f..ffffff` | 11073251 | 1781619444 | Observed | -| 71 | `0x9b53383b..ffffff` | 11073251 | 1781619444 | Observed | -| 72 | `0xd55d2e7e..ffffff` | 11073259 | 1781619540 | Observed | -| 73 | `0x355e6c2e..ffffff` | 11073304 | 1781620080 | Observed | -| 74 | `0xb75a1e65..ffffff` | 11073309 | 1781620140 | Observed | -| 75 | `0x812f257a..ffffff` | 11073316 | 1781620224 | Observed | -| 76 | `0x25efa78a..ffffff` | 11073319 | 1781620260 | Observed | -| 77 | `0x72aee1ae..ffffff` | 11073340 | 1781620512 | Observed | -| 78 | `0xe10e143e..ffffff` | 11073345 | 1781620572 | Observed | -| 79 | `0x1cb540d1..ffffff` | 11073377 | 1781620956 | Observed | -| 80 | `0x4a27b3b7..ffffff` | 11073446 | 1781621784 | Observed | -| 81 | `0x6f60c9b7..ffffff` | 11073450 | 1781621832 | Observed | -| 82 | `0x2afac9af..ffffff` | 11073618 | 1781623884 | Observed | -| 83 | `0x09eed081..ffffff` | 11073639 | 1781624136 | Observed | -| 84 | `0x0f04118e..ffffff` | 11073639 | 1781624136 | Observed | -| 85 | `0xb1445f46..ffffff` | 11073661 | 1781624400 | Observed | -| 86 | `0x1b9f5ae8..ffffff` | 11073662 | 1781624412 | Observed | -| 87 | `0xbf218ce6..ffffff` | 11073684 | 1781624676 | Observed | -| 88 | `0xcd000d8e..ffffff` | 11073684 | 1781624676 | Observed | -| 89 | `0x41e05a80..ffffff` | 11073706 | 1781624940 | Observed | -| 90 | `0x46728c28..ffffff` | 11073706 | 1781624940 | Observed | -| 91 | `0x7d517f6b..ffffff` | 11073716 | 1781625060 | Observed | -| 92 | `0xcd0993d3..ffffff` | 11073738 | 1781625324 | Observed | -| 93 | `0xd41c9e0b..ffffff` | 11073738 | 1781625324 | Observed | -| 94 | `0x728367f6..ffffff` | 11073789 | 1781625936 | Observed | -| 95 | `0xa78cabeb..ffffff` | 11074351 | 1781632692 | Observed | -| 96 | `0x211dd498..ffffff` | 11074351 | 1781632692 | Observed | -| 97 | `0x5f686fb7..ffffff` | 11074387 | 1781633124 | Observed | -| 98 | `0x3b7b5aaa..ffffff` | 11074387 | 1781633124 | Observed | -| 99 | `0x8cb5b94f..ffffff` | 11074421 | 1781633532 | Observed | -| 100 | `0x7545eda0..ffffff` | 11074421 | 1781633532 | Observed | -| 101 | `0x6c4472c1..ffffff` | 11074463 | 1781634048 | Observed | -| 102 | `0x134a3f32..ffffff` | 11074464 | 1781634060 | Observed | -| 103 | `0x625b15a3..ffffff` | 11074482 | 1781634276 | Observed | -| 104 | `0x8b981bae..ffffff` | 11074491 | 1781634408 | Observed | -| 105 | `0x2316cffb..ffffff` | 11074491 | 1781634408 | Observed | -| 106 | `0x019e8adf..ffffff` | 11076610 | 1781659896 | Observed | -| 107 | `0xe0ccf0ed..ffffff` | 11076616 | 1781659968 | Observed | -| 108 | `0xfcfd0fa6..ffffff` | 11076620 | 1781660016 | Observed | -| 109 | `0x9fabbf08..ffffff` | 11077214 | 1781667204 | Observed | -| 110 | `0x84fd9342..ffffff` | 11077272 | 1781667900 | Observed | -| 111 | `0xa1b4b41b..ffffff` | 11078078 | 1781677608 | Observed | -| 112 | `0x9e4ceb19..ffffff` | 11078078 | 1781677608 | Observed | -| 113 | `0xd6eaa1a9..ffffff` | 11078093 | 1781677788 | Observed | -| 114 | `0x819ff1ec..ffffff` | 11078093 | 1781677788 | Observed | -| 115 | `0xa06aafbb..ffffff` | 11078113 | 1781678028 | Observed | -| 116 | `0xd6f7b83b..ffffff` | 11078114 | 1781678040 | Observed | -| 117 | `0x1ac7b661..ffffff` | 11078214 | 1781679240 | Observed | -| 118 | `0x9fc72d42..ffffff` | 11078215 | 1781679252 | Observed | -| 119 | `0x642b7890..ffffff` | 11078225 | 1781679372 | Observed | -| 120 | `0x7cf68a7b..ffffff` | 11078246 | 1781679624 | Observed | -| 121 | `0xb6e10751..ffffff` | 11078248 | 1781679648 | Observed | -| 122 | `0xd3c38d90..ffffff` | 11078475 | 1781682372 | Observed | -| 123 | `0x22e93a3a..ffffff` | 11078475 | 1781682372 | Observed | -| 124 | `0x5e60eb4e..ffffff` | 11078500 | 1781682672 | Observed | -| 125 | `0xaf138bc2..ffffff` | 11078501 | 1781682684 | Observed | -| 126 | `0xcbb6226f..ffffff` | 11078504 | 1781682720 | Observed | -| 127 | `0x7bc92f46..ffffff` | 11078513 | 1781682828 | Observed | -| 128 | `0x7bf72b31..ffffff` | 11078520 | 1781682912 | Observed | -| 129 | `0x9dd59d5b..ffffff` | 11078520 | 1781682912 | Observed | -| 130 | `0x8f8bed50..ffffff` | 11078539 | 1781683140 | Observed | -| 131 | `0x4e04244d..ffffff` | 11078539 | 1781683140 | Observed | -| 132 | `0xe16973fa..ffffff` | 11078557 | 1781683356 | Observed | -| 133 | `0xfb362d58..ffffff` | 11078558 | 1781683368 | Observed | -| 134 | `0x6068b9d9..ffffff` | 11080029 | 1781701044 | Observed | -| 135 | `0x299fe688..ffffff` | 11080365 | 1781705076 | Observed | -| 136 | `0xbdbb9773..ffffff` | 11082360 | 1781729064 | Observed | -| 137 | `0xbaedfe1c..ffffff` | 11083644 | 1781744496 | Observed | -| 138 | `0x8194545d..ffffff` | 11083764 | 1781745936 | Observed | -| 139 | `0x9c92f5f3..ffffff` | 11083770 | 1781746008 | Observed | -| 140 | `0x98906b97..ffffff` | 11083812 | 1781746512 | Observed | -| 141 | `0x627afacc..ffffff` | 11083859 | 1781747076 | Observed | -| 142 | `0x240bcbde..ffffff` | 11083866 | 1781747160 | Observed | -| 143 | `0x2559867e..ffffff` | 11084049 | 1781749380 | Observed | -| 144 | `0x7ce8d855..ffffff` | 11084079 | 1781749740 | Observed | -| 145 | `0xffc3686e..ffffff` | 11084150 | 1781750592 | Observed | -| 146 | `0x7b51bb4a..ffffff` | 11084744 | 1781757792 | Observed | -| 147 | `0x8b4e73ec..ffffff` | 11085093 | 1781762016 | Observed | -| 148 | `0x8bd2dbf8..ffffff` | 11085093 | 1781762016 | Observed | -| 149 | `0xd3d9da38..ffffff` | 11085123 | 1781762376 | Observed | -| 150 | `0x518e19aa..ffffff` | 11085123 | 1781762376 | Observed | -| 151 | `0x8bb95de2..ffffff` | 11085229 | 1781763648 | Observed | -| 152 | `0x3f80482c..ffffff` | 11085229 | 1781763648 | Observed | -| 153 | `0x6217df15..ffffff` | 11085285 | 1781764320 | Observed | -| 154 | `0xb8b7945e..ffffff` | 11085290 | 1781764380 | Observed | -| 155 | `0xbeaa3ed3..ffffff` | 11085296 | 1781764452 | Observed | -| 156 | `0xac9baa9a..ffffff` | 11085311 | 1781764632 | Observed | -| 157 | `0x5471a5aa..ffffff` | 11085316 | 1781764692 | Observed | -| 158 | `0xb9af72ee..ffffff` | 11085768 | 1781770116 | Observed | -| 159 | `0x577b183c..ffffff` | 11086236 | 1781775732 | Observed | -| 160 | `0xafd52f06..ffffff` | 11086284 | 1781776308 | Observed | -| 161 | `0x4ee00443..ffffff` | 11086290 | 1781776380 | Observed | -| 162 | `0x64427828..ffffff` | 11086493 | 1781778816 | Observed | -| 163 | `0x31cce049..ffffff` | 11087438 | 1781790168 | Observed | -| 164 | `0x927561c1..ffffff` | 11087442 | 1781790216 | Observed | -| 165 | `0x33e40575..ffffff` | 11087462 | 1781790456 | Observed | -| 166 | `0x5fe03b4e..ffffff` | 11087468 | 1781790528 | Observed | -| 167 | `0x5e7f3fe1..ffffff` | 11087473 | 1781790588 | Observed | -| 168 | `0x0cfa5720..ffffff` | 11087748 | 1781793888 | Observed | -| 169 | `0x88e25f26..ffffff` | 11087749 | 1781793900 | Observed | -| 170 | `0x3d47b55b..ffffff` | 11089218 | 1781811528 | Observed | -| 171 | `0x91b7bb98..ffffff` | 11089257 | 1781811996 | Observed | -| 172 | `0x47931578..ffffff` | 11089274 | 1781812200 | Observed | -| 173 | `0x006b940d..ffffff` | 11089296 | 1781812464 | Observed | -| 174 | `0x104f25a0..ffffff` | 11089394 | 1781813640 | Observed | -| 175 | `0x6d296984..ffffff` | 11089725 | 1781817624 | Observed | -| 176 | `0xf5788a8b..ffffff` | 11089920 | 1781819964 | Observed | -| 177 | `0xdd4a43c4..ffffff` | 11090323 | 1781824800 | Observed | -| 178 | `0x3d1098b8..ffffff` | 11091082 | 1781833920 | Observed | -| 179 | `0xe11c2022..ffffff` | 11091089 | 1781834004 | Observed | -| 180 | `0x1b7ecce8..ffffff` | 11091095 | 1781834076 | Observed | -| 181 | `0x17413c36..ffffff` | 11091102 | 1781834160 | Observed | -| 182 | `0xe18203b8..ffffff` | 11091111 | 1781834268 | Observed | -| 183 | `0x362dcbfb..ffffff` | 11091361 | 1781837316 | Observed | -| 184 | `0x7fe88a51..ffffff` | 11093487 | 1781863032 | Observed | -| 185 | `0x0c37aa67..ffffff` | 11094315 | 1781872980 | Observed | -| 186 | `0x2170ca89..ffffff` | 11094609 | 1781876508 | Observed | -| 187 | `0xcc734808..ffffff` | 11094664 | 1781877168 | Observed | -| 188 | `0x30fbd136..ffffff` | 11095162 | 1781883144 | Observed | -| 189 | `0x35e2b54d..ffffff` | 11095647 | 1781888964 | Observed | -| 190 | `0x2300a9f6..ffffff` | 11096098 | 1781894376 | Observed | -| 191 | `0xe2e97306..ffffff` | 11097901 | 1781916048 | Observed | -| 192 | `0x15d6d916..ffffff` | 11098198 | 1781919612 | Observed | -| 193 | `0xd0186dc0..ffffff` | 11098367 | 1781921652 | Observed | -| 194 | `0xe188b861..ffffff` | 11098372 | 1781921712 | Observed | -| 195 | `0xd13cad5b..ffffff` | 11098766 | 1781926452 | Observed | -| 196 | `0xbd8cc161..ffffff` | 11099348 | 1781933460 | Observed | -| 197 | `0x77446407..ffffff` | 11099353 | 1781933520 | Observed | -| 198 | `0xfec45a42..ffffff` | 11100012 | 1781941428 | Observed | -| 199 | `0xd97a9fa0..ffffff` | 11100016 | 1781941476 | Observed | -| 200 | `0xb1c28fdb..ffffff` | 11102012 | 1781965476 | Observed | -| 201 | `0x62ac8489..ffffff` | 11102181 | 1781967528 | Observed | -| 202 | `0x64fc616d..ffffff` | 11103512 | 1781983536 | Observed | -| 203 | `0x809b0200..ffffff` | 11105542 | 1782007956 | Observed | -| 204 | `0x2efe5755..ffffff` | 11105545 | 1782008004 | Observed | -| 205 | `0xa801952a..ffffff` | 11105551 | 1782008076 | Observed | -| 206 | `0xe51fcd2e..ffffff` | 11105566 | 1782008256 | Observed | -| 207 | `0x87e7ed80..ffffff` | 11105602 | 1782008688 | Observed | -| 208 | `0xa249ff22..ffffff` | 11105613 | 1782008820 | Observed | -| 209 | `0xfbe5e123..ffffff` | 11105618 | 1782008880 | Observed | -| 210 | `0x72bf47dc..ffffff` | 11105715 | 1782010044 | Observed | -| 211 | `0x1293c734..ffffff` | 11106911 | 1782024444 | Observed | -| 212 | `0xeeb9580b..ffffff` | 11106932 | 1782024696 | Observed | -| 213 | `0x42e1019e..ffffff` | 11107190 | 1782027792 | Observed | -| 214 | `0x863285b8..ffffff` | 11108038 | 1782037968 | Observed | -| 215 | `0x3545ede8..ffffff` | 11109421 | 1782054600 | Observed | -| 216 | `0x98d90da1..ffffff` | 11112811 | 1782095304 | Observed | -| 217 | `0xf60dc92a..ffffff` | 11112823 | 1782095448 | Observed | -| 218 | `0xbad0af58..ffffff` | 11112886 | 1782096204 | Observed | -| 219 | `0xda12aeee..ffffff` | 11112894 | 1782096300 | Observed | -| 220 | `0x271f933d..ffffff` | 11113205 | 1782100044 | Observed | -| 221 | `0x354f7970..ffffff` | 11114331 | 1782113580 | Observed | -| 222 | `0x52d511d9..ffffff` | 11114353 | 1782113844 | Observed | -| 223 | `0x39ba5342..ffffff` | 11115217 | 1782124248 | Observed | -| 224 | `0x8f627309..ffffff` | 11115224 | 1782124332 | Observed | -| 225 | `0x4d0b40ff..ffffff` | 11115229 | 1782124392 | Observed | -| 226 | `0xdc658bc7..ffffff` | 11115320 | 1782125508 | Observed | -| 227 | `0x3ad5be48..ffffff` | 11115412 | 1782126624 | Observed | -| 228 | `0xa412cc0c..ffffff` | 11115417 | 1782126684 | Observed | -| 229 | `0x6cce9b62..ffffff` | 11115424 | 1782126768 | Observed | -| 230 | `0x45902f9e..ffffff` | 11115429 | 1782126828 | Observed | -| 231 | `0xfac5eea4..ffffff` | 11115499 | 1782127668 | Observed | -| 232 | `0x3f581643..ffffff` | 11115786 | 1782131112 | Observed | -| 233 | `0x303a3415..ffffff` | 11115796 | 1782131232 | Observed | -| 234 | `0xc6bf93cb..ffffff` | 11115816 | 1782131472 | Observed | -| 235 | `0xc70930be..ffffff` | 11116414 | 1782138732 | Observed | -| 236 | `0xbdc6f0ae..ffffff` | 11116631 | 1782141408 | Observed | -| 237 | `0x0bab3b08..ffffff` | 11116635 | 1782141456 | Observed | -| 238 | `0x1916db8f..ffffff` | 11116641 | 1782141528 | Observed | -| 239 | `0xda1c4056..ffffff` | 11116645 | 1782141576 | Observed | -| 240 | `0xa2d2d863..ffffff` | 11116660 | 1782141756 | Observed | - diff --git a/docs/operations/baselines/baseline-latency-2026-06-19.md b/docs/operations/baselines/baseline-latency-2026-06-19.md deleted file mode 100644 index 4faa5ccb..00000000 --- a/docs/operations/baselines/baseline-latency-2026-06-19.md +++ /dev/null @@ -1,60 +0,0 @@ -# CoW orderbook EthFlow indexer baseline (2026-06-22T14:03:22Z) - -Per-chain pairing of every on-chain `EthFlow.OrderPlacement` event in the trailing window with the orderbook's record for the same UID, plus the `(creationDate - block.timestamp)` delta. Each pair is rigorous — the script ABI-decodes the event's GPv2OrderData and derives the OrderUid via EIP-712 before looking it up — so the data is ground-truth, not a temporal-FIFO approximation. - -## Headline finding - -**For EthFlow orders the orderbook indexer sets `creationDate := block.timestamp`** (not the indexer's ingest time), so the historical delta is structurally 0s on every chain. This is the orderbook's intentional behaviour for back-fill-style flows; it is **not** a measurement bug. The implication for the M4 / M5 KPIs is that EthFlow indexer latency cannot be derived from historical orderbook data — the meaningful relayer-latency baseline lives on the TWAP lane (where the orderbook records the indexer's `now()` per child order PUT). TWAP child-latency is tracked as a follow-up since it requires per-part UID derivation from each parent `ConditionalOrderCreated` static input. - -What the run below **is** useful for: confirming the orderbook's `creationDate` semantics across every supported chain, and yielding ground-truth UID ↔ block pairings the M4 e2e harness can cross-check against. - -## Method - -- Window: trailing **7 days** from the run. -- Event source: `eth_getLogs` against the chain's ETH_FLOW_PRODUCTION (ETH_FLOW_SEPOLIA on Sepolia) for the `OrderPlacement` topic. -- Order source: `GET /account/{ETH_FLOW_ADDRESS}/orders` from the chain's cow.fi orderbook, paginated. -- Pairing: per-event EIP-712 UID derivation. For each event the script ABI-decodes the GPv2OrderData payload, computes the order digest against the chain's GPv2Settlement domain, and assembles UID = digest || ethflow_owner || validTo. Each UID is then looked up against the bulk `/account/.../orders` fetch, falling back to `GET /api/v1/orders/{uid}` if the bulk page missed it. No temporal-FIFO approximation. -- Sanity filters: negative deltas dropped (clock skew between block and indexer); deltas > 1 hour dropped (stale/re-indexed order). -- Event cap per chain: **200** (most recent). - -## EthFlow latency, per chain - -| Chain | Events scanned | Orders fetched | Pairs | Median (s) | p95 (s) | -|---|---:|---:|---:|---:|---:| -| Mainnet | 0 | 0 | 0 | n/a | n/a | -| Gnosis | 0 | 0 | 0 | n/a | n/a | -| Arbitrum One | 0 | 0 | 0 | n/a | n/a | -| Base | 0 | 0 | 0 | n/a | n/a | -| Sepolia | 256 | 5000 | 200 | 0.00 | 0.00 | - -## TWAP latency, per chain - -*Not measured in v1 of this baseline.* TWAP requires reconstructing `(t0, n, t)` from each parent `ConditionalOrderCreated` static input and deriving each child order's UID per part, then matching to the orderbook's child orders. Tracked as a follow-up; **EthFlow alone is sufficient anchor for the M4 KPI bar** since both modules share the same dispatch path in shepherd. - -## Notes per chain - -- **Mainnet**: - - RPC-LIMITED: public endpoint (https://eth.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 408 Client Error: Request Timeout for url: https://eth.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Gnosis**: - - RPC-LIMITED: public endpoint (https://gnosis.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://gnosis.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Arbitrum One**: - - RPC-LIMITED: public endpoint (https://arbitrum.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://arbitrum.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Base**: - - RPC-LIMITED: public endpoint (https://base.drpc.org) refused the log scan even at 50-block chunks (endpoint refused 3 consecutive calls at chunk=31: 500 Server Error: Internal Server Error for url: https://base.drpc.org/). Re-run with a paid endpoint via RPC_URL_* env to get real data; this baseline cell stays blank. Matches the paid-endpoint requirement. -- **Sepolia**: - - capped to last 200 events of 256 - - match diagnostics: bulk_hit=200 - -## Reproducing - -```bash -python3 tools/baseline-latency/baseline_latency.py \ - --window-days 7 --max-events-per-chain 200 \ - --out docs/operations/baselines/baseline-latency-$(date -u +%Y-%m-%d).md -``` - -Override individual RPCs via env: `RPC_URL_MAINNET`, `RPC_URL_GNOSIS`, `RPC_URL_ARBITRUM`, `RPC_URL_BASE`, `RPC_URL_SEPOLIA_HTTP`. - -## Provenance - -Script: `tools/baseline-latency/baseline_latency.py`. Raw data dump per chain: `tools/baseline-latency/data/`. diff --git a/docs/operations/e2e-prep.md b/docs/operations/e2e-prep.md deleted file mode 100644 index 795dd890..00000000 --- a/docs/operations/e2e-prep.md +++ /dev/null @@ -1,334 +0,0 @@ -# E2E run-prep punch list - -Companion to `docs/operations/e2e-testnet-runbook.md`. This file -captures every **pinned value** for the 2026-06-18 dry run so the operator can copy-paste through the on-chain -actions without re-deriving any UID, address, or calldata. - -If you are running a *later* E2E run (different EOA, different -Safe, different config), do not reuse the UIDs / calldatas — they -are a function of all the pinned config below. Either re-derive -via the Python recipes in this doc, or re-run -`cargo test -p stop-loss --lib cow_1064` to lock the new UID. - ---- - -## 0. Pinned identities (2026-06-18 run) - -| Role | Address | Network | Notes | -|---|---|---|---| -| Test EOA | `0x7bF140727D27ea64b607E042f1225680B40ECa6A` | Sepolia | Bruno-controlled. Funds itself via faucet. | -| Test Safe (single-sig, threshold 1) | `0x14995a1118Caf95833e923faf8Dd155721cd53c2` | Sepolia | EOA is the sole owner. Submits TWAP order. | -| ComposableCoW | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | Sepolia | Where `create((address,bytes32,bytes),bool)` lands. | -| TWAP handler | `0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5` | Sepolia | `ConditionalOrderParams.handler`. | -| CoWSwapEthFlow | `0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC` | Sepolia | EthFlow's production deployment; emits `OrderPlacement`. | -| GPv2Settlement | `0x9008D19f58AAbD9eD0D60971565AA8510560ab41` | Sepolia | `setPreSignature(orderUid, signed)` lives here. | -| GPv2VaultRelayer | `0xc92e8bdf79f0507f65a392b0ab4667716bfe0110` | Sepolia | Spender for sell-token ERC-20 approvals. | -| WETH9 | `0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14` | Sepolia | `deposit()` payable wraps ETH; `balanceOf(EOA)` is the sell-side balance. | -| COW Token | `0x0625aFB445C3B6B7B929342a04A22599fd5dBB59` | Sepolia | name="CoW Protocol Token", symbol="COW", decimals=18. | -| GPv2 domain separator | `0xdaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b` | Sepolia | EIP-712 domain digest queried from chain. | - -All addresses verified via `eth_getCode > 0` on -`https://ethereum-sepolia-rpc.publicnode.com` as of run prep. - ---- - -## 1. Per-module config pinning - -### stop-loss - -`modules/examples/stop-loss/module.toml` is checked in on the -`feat/e2e-run-config-cow-1064` branch with the production-ready -config for this run. Effective values: - -| Field | Value | Notes | -|---|---|---| -| `oracle_address` | `0x694AA1769357215DE4FAC081bf1f309aDC325306` | Chainlink ETH/USD Sepolia. | -| `decimals` | `8` | Chainlink USD-pair convention. | -| `trigger_price` | `2000.00` | Above the live Sepolia mocked answer (~$1681), `direction=below` → triggers on first block. | -| `owner` | `0x7bF1...Ca6A` | Test EOA. | -| `sell_token` | `0xfFf9...6B14` | WETH9 Sepolia. | -| `buy_token` | `0x0625...BB59` | COW Sepolia. | -| `sell_amount_wei` | `5000000000000000` | 0.005 WETH. | -| `buy_amount_wei` | `20000000000000000000` | 20 COW. Conservative quote at run-prep time. | -| `valid_to_seconds` | `4294967295` | uint32::MAX. | - -### Resulting OrderUid - -The strategy's `build_creation` is pinned by the -`cow_1064_e2e_settings_yield_expected_uid` regression test -(`crates/.../stop-loss/src/strategy.rs`). The canonical UID: - -``` -0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff -``` - -Decomposition (per `packOrderUidParams`): - -| Offset | Bytes | Field | Value | -|---|---|---|---| -| 0..32 | 32 | `orderDigest` (EIP-712) | `0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be8` | -| 32..52 | 20 | `owner` | `0x7bf140727d27ea64b607e042f1225680b40eca6a` | -| 52..56 | 4 | `validTo` (uint32) | `0xffffffff` | - -### balance-tracker - -Pinned to the EOA + Safe so the run sees ETH-balance diffs: - -| Field | Value | -|---|---| -| `addresses` | `0x7bF1...Ca6A,0x1499...53c2` | -| `change_threshold` | `1000000000000000` (0.001 ETH) | - ---- - -## 2. On-chain actions for the run window - -> Order: action 1 can be done at any time before/during the run. -> Actions 2-4 should fire **after** the engine prints -> `INFO supervisor ready modules=5 chains=1` so the modules -> observe the events. They are independent; do them in any order. - -### Action 1 (optional, pre-run): wrap 0.01 ETH → 0.01 WETH - -Without WETH, stop-loss will hit `TransferSimulationFailed` -> -`backoff:` write (which is itself a valid terminal-marker per -the acceptance bar). To get the **`submitted:`** path, -wrap first then do action 2. - -- Etherscan: https://sepolia.etherscan.io/address/0xfff9976782d46cc05630d1f6ebab18b2324d6b14#writeContract -- Connect Web3 from the EOA in Metamask -- Function `deposit` → payable value `0.01` ETH → Write - -Verify: `balanceOf(EOA)` returns `10000000000000000` post-tx. - -### Action 2 (optional, only if action 1 done): pre-sign stop-loss order - -- Etherscan: https://sepolia.etherscan.io/address/0x9008d19f58aabd9ed0d60971565aa8510560ab41#writeProxyContract -- Connect Web3 from the EOA -- Function `setPreSignature(bytes orderUid, bool signed)`: - - `orderUid`: - ``` - 0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff - ``` - - `signed`: `true` -- Write - -Also approve WETH → GPv2VaultRelayer so the settle path is real: - -- Etherscan: https://sepolia.etherscan.io/address/0xfff9976782d46cc05630d1f6ebab18b2324d6b14#writeContract -- Function `approve(address guy, uint256 wad)`: - - `guy`: `0xc92e8bdf79f0507f65a392b0ab4667716bfe0110` - - `wad`: `5000000000000000` (0.005 WETH — matches the order's sell_amount) -- Write - -### Action 3: TWAP conditional order via Safe TX Builder - -Triggers `ConditionalOrderCreated` → twap-monitor writes -`watch:{orderHash}`. The Safe pays the gas (~0.003 ETH); the -order will TRY to settle later but the Safe holds no WETH so -settlement will fail. **That's fine** — only the `create()` -event is required for the acceptance marker. - -- Safe app: https://app.safe.global/transactions/queue?safe=sep:0x14995a1118Caf95833e923faf8Dd155721cd53c2 -- New transaction → Transaction Builder -- Enter contract address: `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` -- Toggle "Use custom data (hex encoded)" ON -- Generate the calldata locally (do NOT paste a pinned blob): - -```bash -python3 scripts/_twap_calldata.py -``` - -The helper backdates `t0` by 60 s on every invocation so part 0 is -Ready immediately. The constants (sell/buy tokens, amounts, n, t, -salt) mirror section 4.2; edit there + in the helper in lockstep -if the TWAP shape changes. - -Copy the helper's stdout into the Transaction Builder's custom-data -field. The blob is ~516 bytes - the `create(ConditionalOrderParams, -bool dispatch)` call with a 2-part TWAP from WETH → COW, 0.001 WETH -per part, 600 s between parts, salt pinned to `0x...6670f000`. - -> Historical note: a previously-pinned variant of this calldata -> hardcoded `t0 = 0`, which silently produced an -> `AFTER_TWAP_FINISHED` revert on every poll because -> `calculateValidTo` divided `block.timestamp` by `t` and exceeded -> `n`. Surfaced in the 2026-06-18 dry run. Always derive -> via the helper. - -- ETH value: `0` -- Create batch → Send batch → sign with the EOA - -Expected log within 1-2 Sepolia blocks: - -``` -INFO twap-monitor watch:0x chain_id=11155111 -``` - -### Action 4: EthFlow swap via cow-swap UI - -Triggers `OrderPlacement` → ethflow-watcher writes -`submitted:{uid}` (or `dropped:{uid}` if the orderbook rejects; -both are valid terminal markers). - -Easiest path is the cow-swap UI: - -1. https://swap.cow.fi/#/11155111/swap/ETH/COW (Sepolia) -2. Connect Metamask, EOA selected, network=Sepolia -3. Sell amount: `0.005` ETH -4. Click "Swap" → it builds the EthFlow `createOrder` tx -5. Approve in Metamask - -The UI handles `quoteId` resolution + `appData` IPFS pinning + -EthFlow contract call. Sell amount is small enough to fit in the -~0.05 ETH budget plus gas. - -Expected log within 1-2 Sepolia blocks: - -``` -INFO ethflow-watcher submitted:0x -``` - -If the UI errors out (Sepolia orderbook can be flaky), fallback -to calling EthFlow directly via Etherscan: - -- https://sepolia.etherscan.io/address/0xba3cb449bd2b4adddbc894d8697f5170800eadec#writeContract -- Function `createOrder((address,address,uint256,uint256,bytes32,uint256,uint32,bool,int64))` -- The shape of the tuple needs the orderbook quote endpoint hit - first to get `feeAmount` + `quoteId` — easier to defer to the - UI for the run. - ---- - -## 3. Validation snippets for the operator - -Run these in a separate shell while the engine is up: - -```bash -RPC="wss://eth-sepolia.g.alchemy.com/v2/" # replace -EOA="0x7bF140727D27ea64b607E042f1225680B40ECa6A" -SAFE="0x14995a1118Caf95833e923faf8Dd155721cd53c2" -WETH="0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14" - -# EOA + Safe balances -cast balance $EOA --rpc-url $RPC -cast balance $SAFE --rpc-url $RPC - -# EOA WETH balance + GPv2VaultRelayer allowance -cast call $WETH "balanceOf(address)(uint256)" $EOA --rpc-url $RPC -cast call $WETH "allowance(address,address)(uint256)" \ - $EOA 0xc92e8bdf79f0507f65a392b0ab4667716bfe0110 --rpc-url $RPC - -# Did setPreSignature land? -cast call 0x9008D19f58AAbD9eD0D60971565AA8510560ab41 \ - "preSignature(bytes)(uint256)" \ - 0xc2b9cb4ea1ee5a86d8049ac09d8f494bf04cca0a68407285f31e2e6379800be87bf140727d27ea64b607e042f1225680b40eca6affffffff \ - --rpc-url $RPC -# Returns 1 if pre-signed, 0 otherwise. - -# Mine the supervisor log for terminal markers in real time -journalctl -u shepherd -f --output=json \ - | jq -r '.MESSAGE | fromjson? | select(.fields.message | test("watch:|submitted:|dropped:|backoff:|TRIGGERED")) | "\(.fields.module): \(.fields.message)"' -``` - -(If you don't have `cast` installed: `curl -L https://foundry.paradigm.xyz | bash && foundryup`.) - ---- - -## 4. Recipes for re-deriving the pinned values - -If anything in section 0 drifts, regenerate from these recipes. - -### 4.1 OrderUid - -Either: - -```bash -cargo test -p stop-loss --lib cow_1064 -- --nocapture -``` - -(asserts against the same constants pinned in `module.toml`, -fails loudly if the EIP-712 type-hash or domain separator -shifts). - -Or with raw Python: - -```python -from eth_utils import keccak - -# Replace these 8 values to re-derive -DOMAIN_SEP = bytes.fromhex("daee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b") -SELL_TOKEN = bytes.fromhex("fFf9976782d46CC05630D1f6eBAb18b2324d6B14") -BUY_TOKEN = bytes.fromhex("0625aFB445C3B6B7B929342a04A22599fd5dBB59") -OWNER = bytes.fromhex("7bF140727D27ea64b607E042f1225680B40ECa6A") -RECEIVER = OWNER -SELL_AMOUNT = 5_000_000_000_000_000 -BUY_AMOUNT = 20_000_000_000_000_000_000 -VALID_TO = 4_294_967_295 - -APP_DATA = bytes.fromhex("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d") # keccak("{}") -KIND_SELL = keccak(b"sell") -ERC20 = keccak(b"erc20") -TYPE_HASH = keccak(b"Order(address sellToken,address buyToken,address receiver,uint256 sellAmount,uint256 buyAmount,uint32 validTo,bytes32 appData,uint256 feeAmount,string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)") -pad32 = lambda b: bytes(32-len(b)) + b -uint = lambda v: v.to_bytes(32, "big") -struct_hash = keccak( - TYPE_HASH + pad32(SELL_TOKEN) + pad32(BUY_TOKEN) + pad32(RECEIVER) - + uint(SELL_AMOUNT) + uint(BUY_AMOUNT) + uint(VALID_TO) - + APP_DATA + uint(0) + KIND_SELL - + b"\x00"*32 + ERC20 + ERC20 # partiallyFillable=false -) -order_digest = keccak(b"\x19\x01" + DOMAIN_SEP + struct_hash) -uid = order_digest + OWNER + VALID_TO.to_bytes(4, "big") -print("0x" + uid.hex()) -``` - -### 4.2 ComposableCoW.create() calldata - -```python -import time -from eth_utils import keccak -from eth_abi import encode - -selector = keccak(b"create((address,bytes32,bytes),bool)")[:4] -# Edit these 10 fields to retarget the TWAP -static = encode( - ["(address,address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes32)"], - [( - "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", # sellToken - "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59", # buyToken - "0x14995a1118Caf95833e923faf8Dd155721cd53c2", # receiver - 1_000_000_000_000_000, 500_000_000_000_000_000, # partSellAmount, minPartLimit - int(time.time()) - 60, 2, 600, 0, # t0 (NEVER 0 - see note above), n, t, span - b"\x00" * 32, # appData - )] -) -calldata = selector + encode( - ["(address,bytes32,bytes)", "bool"], - [( - "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5", # TWAP handler - bytes.fromhex("000000000000000000000000000000000000000000000000000000006670f000"), # salt - static, - ), True] -) -print("0x" + calldata.hex()) -``` - ---- - -## 5. Acceptance checklist for THIS run - -Hand-check at the end of the run (also goes in -`e2e-report-YYYY-MM-DD.md` section 7): - -- [ ] EOA at `0x7bF1...Ca6A` still has ≥ 0.03 ETH remaining -- [ ] twap-monitor logged `watch:0x...` after action 3 -- [ ] ethflow-watcher logged `submitted:0x...` after action 4 -- [ ] stop-loss logged `backoff:` or `TRIGGERED + submitted:` (depending on whether action 1+2 ran) -- [ ] price-alert logged `TRIGGERED` on first block -- [ ] balance-tracker logged a `last:0x7bf1...` write on first block + at least one Warn diff log over the run window -- [ ] `shepherd_module_poisoned{...} == 0` for all 5 modules at end -- [ ] `shepherd_module_errors_total{error_kind="trap"} == 0` for all modules -- [ ] ≥ 1500 Sepolia blocks dispatched (`block delta` in report section 2) - -If all green the run is complete and the 7-day soak can start. diff --git a/docs/operations/e2e-reports/e2e-report-2026-06-18.md b/docs/operations/e2e-reports/e2e-report-2026-06-18.md deleted file mode 100644 index 9cc93774..00000000 --- a/docs/operations/e2e-reports/e2e-report-2026-06-18.md +++ /dev/null @@ -1,242 +0,0 @@ -# E2E testnet integration report — 2026-06-18 - -> Auto-generated by `scripts/e2e-report-gen.sh`. Operator -> review each section + flesh out anomalies + sign off in -> section 8 before committing. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Start (UTC) | 2026-06-18T20:01:58Z | -| End (UTC) | 2026-06-18T21:25:36Z | -| Wall clock | 1h 23m | -| Engine commit | `cd68de0b4764b6836fe06ceb396e771cb7771468` | -| Engine config | `engine.e2e.local.toml` (rendered from `engine.e2e.toml`) | -| RPC provider | drpc.live (Sepolia WS) | -| Engine restarts | 2 (mid-run, to validate PR #47 — see §6.5) | -| Engine commits exercised | `5bcd47b` (pre-PR-47), `acc9654` (PR #47 twap-monitor), `cd68de0` (PR #47 ethflow-watcher) | - -## 2. Chain coverage - -| Chain | First block | Last block | Block delta | -|---|---|---|---| -| Sepolia (11155111) | 11089335 | 11089749 | 415 | - -Acceptance: block delta ≥ 1500 → **FAIL** - -## 3. On-chain actions submitted - -| Action | Tx | -|---|---| -| TWAP ComposableCoW.create() — script (t0=0 bug) | [0xa3d8a36f...4d02d](https://sepolia.etherscan.io/tx/0xa3d8a36f8a7dd8b097635ac59249b908d3f634bf5ede87c9336619e319e4d02d) | -| TWAP ComposableCoW.create() — cow-swap UI | [via UI; observed at block 11089497, indexed at 20:35:49Z, orderHash `0xc4bc4296...`](https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74) | -| EthFlow.createOrder() — script (empty appData) | [0x622375d8...5731](https://sepolia.etherscan.io/tx/0x622375d89119df6419324ad4e5603688261fb01a4d47d717d686b6dd426b5731) | -| EthFlow.createOrder() — cow-swap UI (rich appData) | [0x82da5ced...b878](https://sepolia.etherscan.io/tx/0x82da5ceda6e28337625a991d4fc7db6b82a1695012b58a6b660ec92b8a88b878) | -| WETH-to-Safe transfer + GPv2VaultRelayer approve | manual via Safe UI (see §6.5) | -| WETH9.deposit() / setPreSignature for stop-loss | _(not run — stop-loss `submitted:` produced via PreSign-orderbook-accept path, see §6.3)_ | - -## 4. Per-module terminal-state markers - -| Module | First marker | Sample line | -|---|---|---| -| twap-monitor | 2026-06-18T20:07:36.495145Z | `indexed watch:0x7bf140727d27ea64b607e042f1225680b40eca6a:0x2ef7e76456176904e518b068744aad0e97a0d6...` | -| ethflow-watcher | 2026-06-18T20:14:00.841145Z | `ethflow backoff 0x104f25a0d633f9f39840723fc7e72a87d327829c9bc541a08ad9c8a62b9ecc9eba3cb449bd2b4ad...` | -| price-alert | 2026-06-18T20:02:10.605669Z | `price-alert: TRIGGERED answer=169974867813 threshold=250000000000 (Below)` | -| balance-tracker | 2026-06-18T20:02:10.772149Z | `balance-tracker 0x7bf140727d27ea64b607e042f1225680b40eca6a changed +50581434977874097 wei (prior=...` | -| stop-loss | 2026-06-18T20:02:12.874405Z | `stop-loss retry on next block (0): orderbook error (DuplicatedOrder): order already exists` | - -## 5. Error counts (Prometheus delta) - -| Metric | Start | End | Delta | -|---|---|---|---| -| `shepherd_event_latency_seconds_count{module="balance-tracker",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | 0 | 1 | 1 | -| `shepherd_event_latency_seconds_count{module="price-alert",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="stop-loss",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | 17 | 33 | 16 | -| `shepherd_event_latency_seconds_sum{module="balance-tracker",event_kind="block"}` | 5.38369 | 9.72033 | 4.33664 | -| `shepherd_event_latency_seconds_sum{module="ethflow-watcher",event_kind="log"}` | 0 | 0.442872 | 0.442872 | -| `shepherd_event_latency_seconds_sum{module="price-alert",event_kind="block"}` | 2.86219 | 5.03446 | 2.17227 | -| `shepherd_event_latency_seconds_sum{module="stop-loss",event_kind="block"}` | 18.835 | 27.4352 | 8.60022 | -| `shepherd_event_latency_seconds_sum{module="twap-monitor",event_kind="block"}` | 0.0018655 | 56.1652 | 56.1633 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0"}` | 0.310814 | 0.271721 | -0.0390927 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.5"}` | 0.334306 | 0.272832 | -0.0614738 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.9"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.95"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.99"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="0.999"}` | 0.334306 | 0.282889 | -0.0514163 | -| `shepherd_event_latency_seconds{module="balance-tracker",event_kind="block",quantile="1"}` | 0.347925 | 0.322888 | -0.0250366 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0"}` | 0.141162 | 0.130526 | -0.0106367 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.5"}` | 0.165117 | 0.152575 | -0.0125423 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.9"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.95"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.99"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="0.999"}` | 0.165117 | 0.152727 | -0.0123897 | -| `shepherd_event_latency_seconds{module="price-alert",event_kind="block",quantile="1"}` | 0.199031 | 0.170941 | -0.0280894 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0"}` | 0.731767 | 0.680018 | -0.051749 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.5"}` | 0.899515 | 0.719139 | -0.180375 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.9"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.95"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.99"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="0.999"}` | 1.3033 | 0.719139 | -0.584161 | -| `shepherd_event_latency_seconds{module="stop-loss",event_kind="block",quantile="1"}` | 1.56857 | 0.740204 | -0.828361 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0"}` | 8.2e-05 | 0.86952 | 0.869438 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.5"}` | 0.000110411 | 1.35921 | 1.35909 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.9"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.95"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.99"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="0.999"}` | 0.000110411 | 1.49466 | 1.49455 | -| `shepherd_event_latency_seconds{module="twap-monitor",event_kind="block",quantile="1"}` | 0.000132833 | 1.94945 | 1.94932 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_call",outcome="err"}` | 0 | 33 | 33 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_call",outcome="ok"}` | 34 | 100 | 66 | -| `shepherd_chain_request_total{chain_id="11155111",method="eth_getBalance",outcome="ok"}` | 34 | 66 | 32 | -| `shepherd_cow_api_submit_total{chain_id="11155111",outcome="err"}` | 17 | 67 | 50 | - -## 6. Anomalies + defects - -Four anomalies surfaced by this run. Each was filed as a separate issue. - -### 6.1 SDK + modules: non-empty `appData` hash rejected client-side - -**Status: fixed in this run via PR #47, live-validated in §6.5.** - -`twap-monitor` and `ethflow-watcher` strategies hard-coded -`EMPTY_APP_DATA_JSON` when assembling `OrderCreation`. Any -order with a richer `appData` (cow-swap UI orders carry -partner-id + slippage + quote-id metadata) hit -"app_data JSON digest does not match signed app_data hash" -client-side and was silently skipped. - -Pre-PR-47 evidence (block 11089387, before mid-run restart): -``` -INFO twap-monitor poll watch:0x14995a...:0xc4bc4296... -> Ready -INFO twap-monitor twap submit skipped for 0x14995a1118caf95833e923faf8dd155721cd53c2: - invalid OrderCreation: app_data JSON digest does not match signed app_data hash -``` - -Post-PR-47 (validated in §6.5): the submit body builds with -the matching JSON resolved from `GET /api/v1/app_data/{hash}`, -reaches the orderbook server, and rejects only on -server-side reasons (`DuplicatedOrder` for TWAP, since the UI -already submitted; `ExcessiveValidTo` for EthFlow — see §6.2). - -### 6.2 ethflow-watcher: `ExcessiveValidTo` from Sepolia orderbook - -**Status: open.** - -EthFlow on-chain orders carry `validTo = type(uint32).max` so -cancellation is operator-controlled via the EthFlow contract, -not orderbook-time-bounded. The Sepolia orderbook has a -max-validTo cap that rejects this shape. - -Evidence: -``` -WARN ethflow backoff 0x6d296984...ba3cb449bd2b4adddbc894d8697f5170800eadecffffffff - (0): orderbook error (ExcessiveValidTo): validTo is too far into the future -``` - -Last 4 bytes of UID = `ffffffff` = uint32::MAX. Pending -upstream investigation (Sepolia config drift vs mainnet -behaviour; needs cross-check before filing in -cowprotocol/services). - -### 6.3 stop-loss: `DuplicatedOrder` not classified as `Drop` - -**Status: open.** - -The stop-loss order from the E2E prep smoke (run earlier -on 2026-06-18) is still in the Sepolia orderbook (valid until -2106). The run-1 + run-2 stop-loss strategy re-submits the -same `OrderUid` on every block; orderbook responds -`DuplicatedOrder` (400); `shepherd_sdk::cow::classify_api_error` -maps to `TryNextBlock` and the retry loops forever (76 occurrences -in the first 170 blocks). - -Correct classification: `Drop` (the order is logically already -submitted; nothing to retry). PR sketch: -`crates/shepherd-sdk/src/cow/error.rs` `errorType` arm for -`DuplicatedOrder` → `RetryAction::Drop` + write -`submitted:{uid}` (or new `already-on-server:{uid}` marker). - -This run's stop-loss `submitted:` marker (via the PreSign- -upfront-accept path) was logged during the E2E prep smoke; -the marker persists in the orderbook and was observed -as `DuplicatedOrder` in this run. - -### 6.4 scripts/e2e-onchain.sh: TWAP `t0=0` produces permanently-finished order - -**Status: open.** - -`scripts/e2e-onchain.sh` hardcoded `t0=0` in the TWAP -`create()` calldata. TWAP `validateData` does NOT reject -t0=0 (only checks `t0 >= type(uint32).max`), so the create() -succeeds. But `TWAPOrderMathLib.calculateValidTo` computes -`part = (block.timestamp - 0) / t = ~3M`, which is `>= n=2`, -triggering `AFTER_TWAP_FINISHED` reverts on every -`getTradeableOrderWithSignature` poll. - -Evidence (custom error selector `0xc8fc2725` decoded): -``` -WARN twap-monitor eth_call failed (server returned an error response: - error code 3: execution reverted, data: "0xc8fc272500...616674657220747761702066696e6973686564" - [= ASCII "after twap finished"]) -``` - -Caller-side bug introduced by an AI-drafted helper. Fix is a -2-line edit to the encoder + a new comment; tracked separately. - -### 6.5 Live validation of PR #47 (this run's key methodology note) - -Mid-run, after observing §6.1, three engine binaries were -exercised back-to-back on the same `data/e2e` local-store -(restart preserved watches; no replay of past on-chain events -was needed — the indexed `watch:` keys in the redb survive -process restarts by design): - -| Engine commit | What it validates | -|---|---| -| `5bcd47b` (pre-PR-47) | Surfaces §6.1: twap-monitor + ethflow-watcher both log `submit skipped: digest does not match` for non-empty appData orders | -| `acc9654` (PR #47 twap-monitor) | After restart, the existing `watch:0x14995a...:0xc4bc4296...` (cow-swap UI TWAP) polled to Ready → resolve_app_data succeeded → submit reached orderbook → DuplicatedOrder (the order is already in the orderbook from the UI's original submission). **Client-side digest check was bypassed.** | -| `cd68de0` (PR #47 ethflow-watcher) | New cow-swap UI EthFlow swap submitted (tx `0x82da5ced...`); ethflow-watcher observes the OrderPlacement event with `order.appData = 0xe46e7d0c...` (NON-empty). resolve_app_data calls `GET /api/v1/app_data/0xe46e7d0c...` against the orderbook; orderbook returns `{"fullAppData": "{\"appCode\":\"CoW Swap\",\"environment\":\"production\",\"metadata\":{...,\"quote\":{\"slippageBips\":857,\"smartSlippage\":true}},...}"}`. The SDK extracts `fullAppData`; build_eth_flow_creation produces a body with matching digest; submit reaches orderbook; rejects only on ExcessiveValidTo (§6.2). **Client-side digest check was bypassed for ethflow-watcher too.** | - -The PR #47 fix is therefore live-validated end-to-end against -the real Sepolia orderbook in **both** affected modules. -Section 7's `block delta ≥ 1500` row is the only acceptance -row that does not clear; the engine was restarted twice for -this validation, totalling 415 blocks across the three -generations. A continuous 5h run with PR #47 included from -boot is the natural validation for the 7-day soak -rather than re-running the E2E. - -## 7. Acceptance checklist - -- [ ] block delta ≥ 1500 (got 415) -- [x] all 5 modules emitted ≥ 1 terminal-state marker -- [x] shepherd_module_errors_total{error_kind="trap"} == 0 (offenders: none) -- [x] no module poisoned at end (offenders: none) -- [x] 0 ERROR lines from nexum_engine::* (got 0) -- [x] TWAP + EthFlow on-chain txs submitted - -## 8. Sign-off (operator) - -> Auto-generated report. Operator: in 1-2 sentences confirm whether this run is clean enough to unblock the 7-day soak. If any acceptance row above is `[ ]`, file the defect before signing off. - -**Bruno (operator)** — _pending sign-off_ - -Recommended sign-off text (delete + replace as appropriate): - -> "Run validated the engine + 5-module dispatch path end-to-end against -> live Sepolia. Surfaced 4 anomalies (described in §6); the appData -> issue was fixed in-run via PR #47 and live-validated for both -> twap-monitor and ethflow-watcher (§6.5). Block delta short (415/1500) -> only because the run included two intentional restarts to validate -> the in-flight PR. **The 7-day soak is unblocked** to start on -> PR #47 merged + `feat/e2e-run-config` branch state; the -> other three follow-ups do not block the soak." - -## 9. Attachments - -- Engine log: `engine-combined-20260618.log` -- Metrics start: `metrics-start-20260618T200158Z.txt` -- Metrics end: `metrics-end-20260618T212514Z.txt` diff --git a/docs/operations/e2e-reports/e2e-report.template.md b/docs/operations/e2e-reports/e2e-report.template.md index 632fdac1..3a63cd71 100644 --- a/docs/operations/e2e-reports/e2e-report.template.md +++ b/docs/operations/e2e-reports/e2e-report.template.md @@ -1,9 +1,6 @@ -# E2E testnet integration report — YYYY-MM-DD +# E2E testnet integration report: YYYY-MM-DD -> Copy this file to `e2e-report-YYYY-MM-DD.md` in the same directory -> at the start of the run and fill it in as the run progresses. -> Sections marked **(operator)** must be filled in manually; the rest -> are derived from logs and `/metrics` snapshots. +> Copy to `e2e-report-YYYY-MM-DD.md` in this directory at the start of the run and fill in as it progresses. Sections marked **(operator)** are manual; the rest derive from logs and `/metrics` snapshots. ## 1. Run metadata @@ -15,8 +12,8 @@ | Wall clock | Hh Mm | | Engine commit | (`git rev-parse HEAD`) | | Engine config | `engine.e2e.toml` | -| Run host | (e.g. `bruno@bleu-mbp-m1`, `ec2-...`) | -| RPC provider | (alchemy / infura / publicnode / ...) | +| Run host | | +| RPC provider | | ## 2. Chain coverage @@ -24,8 +21,7 @@ |---|---|---|---|---| | Sepolia (11155111) | | | | | -Target: `block delta >= 1500` to clear the acceptance bar -(>= 1500 Sepolia blocks ≈ 5 h at 12 s block time). +Target: `block delta >= 1500` (>= 5 h at 12 s block time). ## 3. On-chain actions submitted by operator @@ -57,13 +53,11 @@ Target: `block delta >= 1500` to clear the acceptance bar | `sell_token` allowance tx hash | 0x... | | Owner EOA | 0x... | | Expected UID | 0x... | -| Expected detection | stop-loss logs `submitted:{uid}` once oracle trips | +| Expected detection | stop-loss logs `submitted:{uid}` once the oracle trips | ## 4. Per-module terminal-state markers -> Pull from the engine log with the JSON filter -> `jq 'select(.fields.message | test("submitted:|dropped:|backoff:|TRIGGERED|trapped"))'`. -> Each module must show at least ONE marker for the acceptance bar. +> Pull from the log with `jq 'select(.fields.message | test("submitted:|dropped:|backoff:|TRIGGERED|trapped"))'`. Each module must show at least one marker. | Module | First marker timestamp | Marker | Sample line | |---|---|---|---| @@ -75,15 +69,13 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 5. Error counts (from `/metrics` delta) -> Capture two snapshots: at boot (`/metrics > metrics-start.txt`) and -> immediately before shutdown (`/metrics > metrics-end.txt`). Fill in -> the delta column. +> Snapshot at boot and immediately before shutdown; fill the delta column. | Metric | Start | End | Delta | |---|---|---|---| | `shepherd_module_errors_total{module="...",error_kind="trap"}` (per module) | | | | | `shepherd_module_restarts_total{module="..."}` (per module) | | | | -| `shepherd_module_poisoned{module="..."}` (gauge, end-state per module) | n/a | | n/a | +| `shepherd_module_poisoned{module="..."}` (gauge, end-state) | n/a | | n/a | | `shepherd_cow_api_submit_total{outcome="ok"}` | | | | | `shepherd_cow_api_submit_total{outcome="err"}` | | | | | `shepherd_chain_request_total{outcome="ok"}` | | | | @@ -94,9 +86,7 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 6. Anomalies + defects -> Anything outside the expected log shape. Each anomaly that is -> reproducible OR has an unclear root cause must be filed as a -> separate issue and linked here. +> Each reproducible or unexplained anomaly is filed as a separate issue and linked here. | # | Time (UTC) | Module | Summary | |---|---|---|---| @@ -104,29 +94,21 @@ Target: `block delta >= 1500` to clear the acceptance bar ## 7. Acceptance checklist -- [ ] `block delta >= 1500` (≥ 5 h coverage) -- [ ] All 5 modules have ≥ 1 terminal-state marker in section 4 +- [ ] `block delta >= 1500` +- [ ] All 5 modules have >= 1 terminal-state marker in section 4 - [ ] `shepherd_module_errors_total{error_kind="trap"}` for well-behaved modules == 0 - [ ] No `[[modules]]`-listed module is `shepherd_module_poisoned == 1` at end -- [ ] No `ERROR` lines from `nexum_engine` in the supervisor log -- [ ] At least one orderbook submit attempt landed (`ok` or typed - `err` with retry/drop classification) on twap-monitor, - ethflow-watcher, AND stop-loss +- [ ] No `ERROR` lines from `nexum_runtime` in the supervisor log +- [ ] At least one orderbook submit attempt landed on twap-monitor, ethflow-watcher, and stop-loss - [ ] Report committed in this directory - [ ] Defects filed and linked in section 6 ## 8. Sign-off (operator) -> Brief paragraph: ran clean / found N defects / blocking issues for -> soak Y/N. The soak MUST NOT start until this -> section says "no blocking issues". - -… +> Ran clean / found N defects / blocking issues for the soak Y/N. The soak must not start until this says "no blocking issues". ## 9. Attachments -- `engine.log` (full supervisor JSON log; ≥ 4 h) +- `engine.log` (full supervisor JSON log) - `metrics-start.txt` - `metrics-end.txt` -- (optional) `metrics-snapshots/` — every 60 s scrape if a soak-style - Prometheus pull was not running diff --git a/docs/operations/e2e-testnet-runbook.md b/docs/operations/e2e-testnet-runbook.md index 3054b372..047334fc 100644 --- a/docs/operations/e2e-testnet-runbook.md +++ b/docs/operations/e2e-testnet-runbook.md @@ -1,52 +1,24 @@ # E2E testnet runbook -How to exercise **all 5 modules** — twap-monitor, ethflow-watcher, -price-alert, balance-tracker, stop-loss — on a real Sepolia host -**simultaneously for 4-6 hours**. Same shape as the M2 + M3 -runbooks, but this one runs the full production module suite and -captures a structured report (`docs/operations/e2e-reports/`). - -The E2E run is the integration step between unit-test coverage -(MockHost, per-module strategy tests) and the 7-day soak. -The soak validates *stability*; this validates *correctness in a -live dispatch context* and surfaces cross-module bugs the soak -should not be discovering. - -The acceptance bar is: - -- ≥ 1500 Sepolia blocks (≈ 5 h at 12 s block time). -- Each of the 5 modules writes at least one terminal-state marker - (`submitted:` / `dropped:` / `backoff:` / `TRIGGERED` / `last:`). +Runs all 5 production modules (twap-monitor, ethflow-watcher, price-alert, balance-tracker, stop-loss) on a live Sepolia host simultaneously for 4-6 h and captures a structured report under `docs/operations/e2e-reports/`. This is the correctness step between unit-test coverage and the 7-day soak. + +Acceptance bar: + +- >= 1500 Sepolia blocks (~5 h at 12 s block time). +- Each of the 5 modules writes at least one terminal-state marker (`submitted:` / `dropped:` / `backoff:` / `TRIGGERED` / `last:`). - 0 unexpected errors in the supervisor log. - 0 well-behaved modules trapped or poisoned at end of run. -- A committed report + filed defects. - ---- +- A committed report. ## 0. Prerequisites ### Toolchain -Same as the M2 + M3 runbooks (`rustup target add wasm32-wasip2`, -optionally `just`, a Sepolia WS RPC). +Same as the M2 + M3 runbooks (`rustup target add wasm32-wasip2`, `just`, a Sepolia WS RPC). ### RPC -The public Sepolia node (`wss://ethereum-sepolia-rpc.publicnode.com`) -throttles `eth_subscribe` and `eth_call` under sustained load. The -E2E run does at minimum: - -- 1 block subscription (shared across 4 modules — price-alert, - balance-tracker, stop-loss, twap-monitor block-tick). -- 2 log subscriptions (twap-monitor's - `ComposableCoW.ConditionalOrderCreated` + ethflow-watcher's - `CoWSwapEthFlow.OrderPlacement`). -- ≥ 4 `eth_call` per block from price-alert + balance-tracker - (×2 addresses) + stop-loss, + 1 per registered TWAP order - per block. - -Override the `[chains.11155111] rpc_url` in `engine.e2e.toml` -with an Alchemy / Infura WS for the run: +The public Sepolia node throttles `eth_subscribe` and `eth_call` under sustained load. The run holds 1 block subscription (shared across 4 modules), 2 log subscriptions (twap-monitor `ConditionalOrderCreated`, ethflow-watcher `OrderPlacement`), and >= 4 `eth_call` per block. Override `rpc_url` in `engine.e2e.toml` with an Alchemy / Infura WS: ```toml [chains.11155111] @@ -55,277 +27,190 @@ rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/" ### On-chain prep (operator) -The acceptance bar requires real on-chain submissions. Before -launching the run, prepare: - -1. **A funded test EOA on Sepolia** (≥ 0.05 ETH for gas; the same - EOA can satisfy the EthFlow swap + stop-loss `setPreSignature` - sub-tasks). -2. **A Safe (or direct caller) that can call ComposableCoW** on - Sepolia — for the TWAP conditional-order submission. -3. **stop-loss config aligned with that EOA**: update - `modules/examples/stop-loss/module.toml::[config].owner` to the - EOA address you control, and pick a `sell_token` / `buy_token` - pair the EOA holds + has approved to the GPv2VaultRelayer. - See `docs/operations/m3-testnet-runbook.md` section 2 for the - full pre-sign + allowance recipe. +The acceptance bar requires real on-chain submissions. Prepare: -The E2E run will start cleanly without (1)/(2)/(3), but the -acceptance bar requires at least one `submitted:` marker on each -of twap-monitor / ethflow-watcher / stop-loss, and you only get -those by triggering each path on-chain. +1. A funded test EOA on Sepolia (>= 0.05 ETH for gas; also covers the EthFlow swap + stop-loss `setPreSignature`). +2. A Safe (or direct caller) that can call ComposableCoW, for the TWAP conditional-order submission. +3. stop-loss config aligned with that EOA: set `[config].owner` in `modules/examples/stop-loss/module.toml` to the EOA, and pick a `sell_token` / `buy_token` pair the EOA holds and has approved to the GPv2VaultRelayer (M3 runbook section 2 has the pre-sign + allowance recipe). ---- +The run boots without (1)/(2)/(3), but the acceptance bar needs one `submitted:` marker on each of twap-monitor / ethflow-watcher / stop-loss, which only on-chain triggers produce. ## 1. Boot -The engine + all 5 modules + Prometheus `/metrics` endpoint: - ```bash just run-e2e ``` -Equivalent long form: +Long form: ```bash just build-e2e # builds the 5 module .wasm artefacts -cargo build -p nexum-cli -cargo run -p nexum-cli -- --engine-config engine.e2e.toml +cargo run -p shepherd -- --engine-config engine.e2e.toml ``` -### Expected boot sequence (~5 s) +Expected boot (~5 s) ends with: ``` -INFO nexum starting -INFO opening chain RPC provider chain_id=11155111 url="wss://..." INFO metrics exporter listening at /metrics addr=127.0.0.1:9100 -INFO loading module manifest manifest=modules/twap-monitor/module.toml -INFO compiling component component=...twap_monitor.wasm INFO init succeeded module=twap-monitor -INFO loading module manifest manifest=modules/ethflow-watcher/module.toml INFO init succeeded module=ethflow-watcher -INFO loading module manifest manifest=modules/examples/price-alert/module.toml INFO init succeeded module=price-alert -INFO loading module manifest manifest=modules/examples/balance-tracker/module.toml INFO init succeeded module=balance-tracker -INFO loading module manifest manifest=modules/examples/stop-loss/module.toml INFO init succeeded module=stop-loss -INFO supervisor up count=5 INFO supervisor ready modules=5 chains=1 -INFO block subscription open chain_id=11155111 INFO log subscription open chain_id=11155111 module=twap-monitor INFO log subscription open chain_id=11155111 module=ethflow-watcher ``` -If any of `count=5`, `modules=5`, or both log subscriptions are -missing, **stop the run and triage** — running 4-6 h on a -degraded engine wastes time the operator does not get back. - -### Smoke at first block (~12 s after boot) - -Within the first Sepolia block dispatched: +If `modules=5` or either log subscription is missing, stop the run and triage before committing to 4-6 h. -``` -DEBUG dispatch block chain_id=11155111 number=N -DEBUG chain::request method=eth_call # price-alert oracle read -DEBUG chain::request method=eth_getBalance # balance-tracker addr 1 -DEBUG chain::request method=eth_getBalance # balance-tracker addr 2 -DEBUG chain::request method=eth_call # stop-loss oracle read -WARN price-alert: TRIGGERED answer=... threshold=... -``` +## 2. The run -(See `docs/operations/m3-testnet-runbook.md` for the per-module -single-block expectations — the E2E run reproduces those plus -twap-monitor's empty poll loop until a `watch:` is registered.) - ---- - -## 2. The 4-6 h run - -### 2.1 Start the clock - -Pipe the engine output to a JSON log file the operator can mine -with `jq` after the run: +### 2.1 Start the clock and baseline ```bash just run-e2e 2>&1 | tee -a docs/operations/e2e-reports/engine-$(date -u +%Y%m%dT%H%M%SZ).log +curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-start.txt ``` -Record `date -u --iso-8601=seconds` and `git rev-parse HEAD` in -section 1 of the report template. +Record `date -u --iso-8601=seconds` and `git rev-parse HEAD` in section 1 of the report. -### 2.2 Capture the metrics baseline +### 2.2 Trigger each on-chain action -```bash -curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-start.txt -``` +Run as soon as the supervisor is `ready`: + +1. **TWAP order**: call ComposableCoW from the Safe. Within 1-2 blocks: `INFO twap-monitor watch:{orderHash}`. +2. **EthFlow swap**: execute a small ETH-flow swap from the EOA via the CoW Swap front-end on Sepolia. Within 1-2 blocks: `INFO ethflow-watcher submitted:{uid}` (or a typed `dropped:{uid}`, both terminal markers). +3. **stop-loss trigger**: once the owner EOA has called `setPreSignature` and approved the sell token, lower `trigger_price` in `modules/examples/stop-loss/module.toml` to <= the current Chainlink ETH/USD answer and reload. Within 1 block: `INFO stop-loss TRIGGERED` then `submitted:{uid}`. + +### 2.3 Idle until end of run -### 2.3 Trigger each on-chain action - -Run these as soon as the supervisor is `ready`: - -1. **TWAP order** — call ComposableCoW from your Safe (or directly - if you control the user). Within 1-2 blocks, twap-monitor logs: - ``` - INFO twap-monitor watch:{orderHash} chain_id=11155111 - ``` -2. **EthFlow swap** — execute a small ETH-flow swap from your EOA - via the cow-swap front-end pointed at Sepolia. Within 1-2 blocks - ethflow-watcher logs: - ``` - INFO ethflow-watcher submitted:{uid} - ``` - (or a typed `dropped:{uid}` if the orderbook rejected — both - count as a terminal-state marker for section 4.) -3. **stop-loss trigger** — once your owner EOA has called - `setPreSignature` and approved the sell token, lower - `trigger_price` in `modules/examples/stop-loss/module.toml` to - ≤ the current Sepolia Chainlink ETH/USD answer and reload the - engine (or set it pre-boot if you already know the feed value). - Within 1 block stop-loss logs: - ``` - INFO stop-loss TRIGGERED price=... trigger=... - INFO stop-loss submitted:{uid} - ``` - -### 2.4 Idle until end of run - -Once all three terminal markers are observed and the report's -section 4 has at least one entry per module, leave the engine -running undisturbed for the remainder of the 4-6 h window. - -The operator should watch for these red flags (if any appears, -the run is a defect and section 6 must capture it): +Once all three markers are observed, leave the engine undisturbed for the remainder of the window. Red flags (each is a defect for report section 6): | Red flag | Why it matters | |---|---| -| `ERROR` from `nexum_runtime::*` | Acceptance #5: zero ERROR lines. | -| `module ... trapped:` for a non-fixture module | Trapping production-side modules is a defect. | -| `module ... poisoned` | Quarantine of a real module is a defect. | -| `stream reconnect attempt=N` with N rising | The WS is flapping (RPC issue or bug). One reconnect per chain is fine. | -| `chain::request` `err` rate > 5% | The RPC is degraded. Switch keys / providers. | +| `ERROR` from `nexum_runtime::*` | Acceptance: zero ERROR lines | +| `module ... trapped:` for a non-fixture module | Trapping a production module is a defect | +| `module ... poisoned` | Quarantine of a real module is a defect | +| `stream reconnect attempt=N` with N rising | WS flapping. One reconnect per chain is fine | +| `chain::request` `err` rate > 5% | RPC degraded. Switch keys / providers | -### 2.5 Capture metrics deltas + shutdown - -At the end of the run window: +### 2.4 Capture deltas and shut down ```bash curl -s http://127.0.0.1:9100/metrics > docs/operations/e2e-reports/metrics-end.txt -# Ctrl-C the engine — graceful shutdown writes last_dispatched_block: -# > INFO graceful shutdown complete dispatched_blocks=N dispatched_logs=M uptime_secs=K -``` - -Diff the two snapshots to fill in the report's section 5: - -```bash +# Ctrl-C: graceful shutdown logs `dispatched_blocks=N dispatched_logs=M uptime_secs=K`. diff <(grep '^shepherd_' docs/operations/e2e-reports/metrics-start.txt) \ <(grep '^shepherd_' docs/operations/e2e-reports/metrics-end.txt) ``` ---- +## 3. Report -## 3. Filling in the report - -Copy the template at the start of the run: +Copy the template at the start of the run and fill it as the run progresses: ```bash DATE=$(date -u +%Y-%m-%d) -cp docs/operations/e2e-reports/e2e-report.template.md \ - docs/operations/e2e-reports/e2e-report-${DATE}.md -$EDITOR docs/operations/e2e-reports/e2e-report-${DATE}.md -``` - -Fill sections in this order: - -1. **Section 1 (run metadata)** at boot. -2. **Section 3 (on-chain actions)** as you submit each one. -3. **Section 4 (terminal markers)** as each first marker fires. -4. **Section 5 (metrics)** once `metrics-end.txt` is captured. -5. **Section 6 (anomalies)** continuously — anything unexpected - gets a row + an issue. -6. **Section 7 (acceptance checklist)** at the end — every box - must be `[x]` for the run to pass. -7. **Section 8 (sign-off)** is the gating decision for the - 7-day soak. - -Commit the filled-in report on the same branch as this runbook: - -```bash -git add docs/operations/e2e-reports/e2e-report-${DATE}.md -git commit -m "ops(e2e): report from ${DATE} run" -git push +cp docs/operations/e2e-reports/e2e-report.template.md docs/operations/e2e-reports/e2e-report-${DATE}.md ``` ---- - -## 4. What this does NOT prove - -- **Stability beyond ~5 h** → the 7-day soak (Sepolia + Arb Sepolia). -- **Adversarial resource exhaustion** → a fuel/memory adversarial fixtures run (M4 territory). -- **Security review** → tracked separately. -- **Production deployment story** → `docs/production.md`. -- **Multi-chain isolation under live WS drops** → partially - proven by integration tests; full validation - requires Arb Sepolia + Sepolia simultaneously, which the soak - exercises. - ---- +Every acceptance box in the template's section 7 must be `[x]` for the run to pass. Commit the filled report on this branch. -## 5. Troubleshooting - -Inherits the M2 + M3 runbook tables. E2E-specific: +## 4. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `supervisor ready modules=4 chains=1` (or less) at boot | One of the 5 module manifests failed to load — likely a missing wasm artefact under `target/wasm32-wasip2/release/` | Re-run `just build-e2e` and verify all 5 `.wasm` files are present. | -| `INFO log subscription open chain_id=11155111` appears only once | One of the two log-subscribing modules failed init | Check the immediately preceding `init failed module=...` line; the failing module's `[capabilities]` or subscription `address` is the usual culprit. | -| RPC drops every ~30 min on `publicnode.com` | Public node rate limits | Switch to Alchemy / Infura per section 0. | -| `stop-loss TRIGGERED` fires immediately on default config | Default `trigger_price = 2500.00` is above Sepolia Chainlink ETH/USD (~$1745) and `direction = "below"`. See M3 runbook §1. | Tune `trigger_price` lower to test the "silent until trigger" path. | -| `twap-monitor` never logs `watch:` | No `ConditionalOrderCreated` event observed on Sepolia during the window | Submit the TWAP order from section 2.3 step 1. | -| `ethflow-watcher` never logs `submitted:` | No `OrderPlacement` event observed on Sepolia during the window | Execute the EthFlow swap from section 2.3 step 2. | +| `supervisor ready modules=4 chains=1` at boot | A module manifest failed to load, likely a missing wasm artefact | Re-run `just build-e2e`; verify all 5 `.wasm` present | +| Only one `log subscription open` | One log-subscribing module failed init | Check the preceding `init failed module=...`; usual culprit is `[capabilities]` or the subscription `address` | +| RPC drops every ~30 min on `publicnode.com` | Public node rate limits | Switch to Alchemy / Infura | +| `stop-loss TRIGGERED` fires immediately | Default `trigger_price` above the feed with `direction = below` | Tune `trigger_price` lower | +| `twap-monitor` never logs `watch:` | No `ConditionalOrderCreated` observed | Submit the TWAP order (2.2 step 1) | +| `ethflow-watcher` never logs `submitted:` | No `OrderPlacement` observed | Execute the EthFlow swap (2.2 step 2) | ---- +## 5. Known Sepolia constraint: EthFlow `validTo = u32::MAX` -## 5.5. Known upstream constraints on Sepolia +EthFlow on-chain orders carry `validTo = type(uint32).max` by design (cancellation is operator-controlled via the EthFlow contract). The Sepolia orderbook's max-validTo cap rejects this shape with `errorType = "ExcessiveValidTo"`, so every EthFlow placement on Sepolia terminates as `Drop`. The strategy recognises this and degrades gracefully: -These are not bugs in shepherd; they are documented gaps between -the on-chain protocol and the Sepolia orderbook's validation -config. The strategy code recognises each and degrades gracefully -(Drop, not retry storm). The soak report should call them out so -the reader does not file them as anomalies. +- `ethflow dropped (400): orderbook error (ExcessiveValidTo)...` at Info level. +- `dropped:{uid}` written once per placement. +- `shepherd_cow_api_submit_total{outcome="err"}` grows by exactly the EthFlow placement count, then stops. -### EthFlow `validTo = u32::MAX` → `ExcessiveValidTo` +This is a testnet orderbook constraint, not a bug; the report should note it so it is not filed as an anomaly. -EthFlow on-chain orders carry `validTo = type(uint32).max` by -design: cancellation is operator-controlled via the EthFlow -contract, not orderbook-time-bounded. `cowprotocol::eth_flow` -documents this as the canonical CoW-side shape on every chain. +## 6. Re-deriving pinned values -The Sepolia orderbook's max-validTo cap rejects this shape with -`errorType = "ExcessiveValidTo"`. Every `POST /api/v1/orders` -ethflow-watcher forwards on Sepolia therefore terminates as -`Drop` (since the host fix; before that fix the same case -manifested as an infinite `backoff:` loop). +If the pinned identities in a run config drift, regenerate. -Operator-visible behaviour after the strategy refinement: +### OrderUid -- `ethflow dropped (400): orderbook error (ExcessiveValidTo)...` -- Log level: **Info** (not Warn). -- `dropped:{uid}` marker written exactly once per placement. -- The soak's Prometheus - `shepherd_cow_api_submit_total{outcome="err"}` curve grows by - exactly the EthFlow placement count, then stops. +```bash +cargo test -p stop-loss --lib cow_1064 -- --nocapture +``` -Upstream confirmation with the cowprotocol/services team is -pending; if mainnet also rejects this shape the design needs -revisiting at the contract level (which is out of scope for -shepherd). +Asserts against the constants in `module.toml`; fails loudly if the EIP-712 type-hash or domain separator shifts. Raw Python equivalent: + +```python +from eth_utils import keccak + +DOMAIN_SEP = bytes.fromhex("daee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b") +SELL_TOKEN = bytes.fromhex("fFf9976782d46CC05630D1f6eBAb18b2324d6B14") +BUY_TOKEN = bytes.fromhex("0625aFB445C3B6B7B929342a04A22599fd5dBB59") +OWNER = bytes.fromhex("7bF140727D27ea64b607E042f1225680B40ECa6A") +RECEIVER = OWNER +SELL_AMOUNT = 5_000_000_000_000_000 +BUY_AMOUNT = 20_000_000_000_000_000_000 +VALID_TO = 4_294_967_295 + +APP_DATA = bytes.fromhex("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d") # keccak("{}") +KIND_SELL = keccak(b"sell") +ERC20 = keccak(b"erc20") +TYPE_HASH = keccak(b"Order(address sellToken,address buyToken,address receiver,uint256 sellAmount,uint256 buyAmount,uint32 validTo,bytes32 appData,uint256 feeAmount,string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalance)") +pad32 = lambda b: bytes(32-len(b)) + b +uint = lambda v: v.to_bytes(32, "big") +struct_hash = keccak( + TYPE_HASH + pad32(SELL_TOKEN) + pad32(BUY_TOKEN) + pad32(RECEIVER) + + uint(SELL_AMOUNT) + uint(BUY_AMOUNT) + uint(VALID_TO) + + APP_DATA + uint(0) + KIND_SELL + + b"\x00"*32 + ERC20 + ERC20 # partiallyFillable=false +) +order_digest = keccak(b"\x19\x01" + DOMAIN_SEP + struct_hash) +uid = order_digest + OWNER + VALID_TO.to_bytes(4, "big") +print("0x" + uid.hex()) +``` ---- +### ComposableCoW.create() calldata + +Generate locally with `python3 scripts/_twap_calldata.py` (never paste a pinned blob). The helper backdates `t0` by 60 s per invocation so part 0 is Ready immediately; `t0` must never be 0 or every poll reverts `AFTER_TWAP_FINISHED`. Constants: + +```python +import time +from eth_utils import keccak +from eth_abi import encode + +selector = keccak(b"create((address,bytes32,bytes),bool)")[:4] +static = encode( + ["(address,address,address,uint256,uint256,uint256,uint256,uint256,uint256,bytes32)"], + [( + "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14", # sellToken + "0x0625aFB445C3B6B7B929342a04A22599fd5dBB59", # buyToken + "0x14995a1118Caf95833e923faf8Dd155721cd53c2", # receiver + 1_000_000_000_000_000, 500_000_000_000_000_000, # partSellAmount, minPartLimit + int(time.time()) - 60, 2, 600, 0, # t0 (never 0), n, t, span + b"\x00" * 32, # appData + )] +) +calldata = selector + encode( + ["(address,bytes32,bytes)", "bool"], + [( + "0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5", # TWAP handler + bytes.fromhex("000000000000000000000000000000000000000000000000000000006670f000"), # salt + static, + ), True] +) +print("0x" + calldata.hex()) +``` -## 6. References +## 7. References -- M2 runbook (sister doc): `docs/operations/m2-testnet-runbook.md` -- M3 runbook (sister doc): `docs/operations/m3-testnet-runbook.md` +- M2 + M3 runbooks (sister docs) - Engine config: `engine.e2e.toml` - Report template: `docs/operations/e2e-reports/e2e-report.template.md` diff --git a/docs/operations/load-reports/load-20x20-2026-06-19.md b/docs/operations/load-reports/load-20x20-2026-06-19.md deleted file mode 100644 index 9495fe36..00000000 --- a/docs/operations/load-reports/load-20x20-2026-06-19.md +++ /dev/null @@ -1,84 +0,0 @@ -# Load test report - medium 20x20 - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T16:03:24Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | medium (20 TWAP + 20 EthFlow per block, 2 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=14 - twap_attempted=280 twap_ok=280 - ethflow_attempted=280 ethflow_ok=280 -``` - -The `blocks_seen=14` is load-gen's perspective - it processes the next block only after finishing the previous burst of 40 submissions. Anvil itself mined **128 blocks** during the run (per `shepherd_event_latency_seconds_count{event_kind="block"}`), so shepherd's supervisor fired 128 block-dispatch cycles. - -## 3. Engine throughput - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **128** | One per Anvil block. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **280** | 1:1 with load-gen. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **280** | 1:1 with load-gen. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **280** | All EthFlow submissions reached the mock orderbook successfully. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **18 442** | Watch polls reverting (no settle-time allowance); strategy correctly classifies as TryNextBlock. | -| `shepherd_module_errors_total` | **0** | Zero. | - -### Latency - -**twap-monitor block (poll loop over all 280 active watches):** - -| Quantile | Value | -|---|---| -| p50 | 56 ms | -| p95 | 66 ms | -| p99 | 67 ms | -| max | 67 ms | - -**ethflow-watcher log:** p50/p95/p99 = 8 / 9.5 / 12 ms. - -Engine-log-derived dispatch_block max: 471 ms (cold-start outlier, same pattern as the baseline). - -## 4. Mock orderbook stats - -``` -submits_ok = 280 -submits_err = 0 -app_data_lookups = 0 -``` - -## 5. Acceptance vs. medium bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 20 TWAP + 20 EthFlow events delivered per load-gen iteration | 280 + 280 across 14 iterations = exactly 20 per iteration | **PASS** | -| Graceful degradation (`backoff:` markers OK; `shepherd_module_errors_total = 0`) | zero module_errors_total | **PASS** | -| `cow_api_submit{outcome="err"}` stays 0 | zero | **PASS** | -| Zero traps | zero | **PASS** | -| p99 < 2 s (informal carry-over from baseline) | TWAP block p99 = 67 ms | **PASS** (30x margin) | - -**Medium: full PASS.** - -## 6. Scaling observation - -Compared to the baseline (130 watches → 49 ms p99) the medium run holds 280 watches → 67 ms p99 - **sub-linear growth** in dispatch latency, not the strict linear scaling extrapolated earlier. Encouraging signal for the saturation scenario. - -## 7. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T160324Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T160324Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 8. Sign-off - -**Bruno (operator) - PASS, medium.** Engine handles 20+20 events per load-gen iteration with the same 30x latency margin as baseline, zero errors. Saturation scenario unblocked. diff --git a/docs/operations/load-reports/load-50x50-2026-06-19.md b/docs/operations/load-reports/load-50x50-2026-06-19.md deleted file mode 100644 index 3a2266ff..00000000 --- a/docs/operations/load-reports/load-50x50-2026-06-19.md +++ /dev/null @@ -1,113 +0,0 @@ -# Load test report - saturation 50x50 - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T16:08:51Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | saturation (50 TWAP + 50 EthFlow per block, 2 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=6 - twap_attempted=300 twap_ok=300 - ethflow_attempted=300 ethflow_ok=300 -``` - -300 + 300 events delivered. Anvil mined **138 blocks** during the -run (per `shepherd_event_latency_seconds_count{event_kind="block"}`) -- the load-gen's `blocks_seen=6` is its own perspective (the burst of -100 sequential tx submissions per iteration takes ~20 s per round, -so it only processes 6 block-tick events from the WS subscription -during the 120 s window). - -## 3. Engine throughput - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **138** | One per Anvil block. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **300** | 1:1 with load-gen. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **300** | 1:1 with load-gen. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **300** | All EthFlow submissions reached the mock orderbook successfully. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **22 137** | Watch polls (300 watches × ~74 blocks). | -| `shepherd_module_errors_total` | **0** | Zero. | - -### Latency - -**twap-monitor block (poll loop over all 300 active watches):** - -| Quantile | Value | -|---|---| -| p50 | 67 ms | -| p95 | 76 ms | -| p99 | 78 ms | -| max | 88 ms | - -**ethflow-watcher log:** p50/p95/p99 = 8.0 / 8.1 / 8.9 ms - basically flat vs. baseline. - -**twap-monitor log:** p50/p95/p99 = 4.0 / 5.9 / 7.0 ms. - -Engine-log-derived dispatch_block max: 497 ms (cold-start outlier, same pattern). - -## 4. Mock orderbook stats - -``` -submits_ok = 300 -submits_err = 0 -app_data_lookups = 0 -``` - -## 5. Acceptance vs. saturation bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 50 TWAP + 50 EthFlow events delivered per load-gen iteration | 300 + 300 across 6 iterations = exactly 50 per iteration | **PASS** | -| Identify the bottleneck | **Bottleneck is on the load-gen side, not the engine** - see §6 | (informative) | -| `shepherd_module_errors_total = 0` | zero | **PASS** | -| Zero traps | zero | **PASS** | - -**Saturation: PASS - and the test did NOT saturate the engine.** - -## 6. The unexpected finding: engine did not saturate - -The hypothesis going in (informed by lgahdl's PR #9 thread on sequential per-module dispatch) was that 50x50 would push the supervisor past its single-module dispatch budget and surface a per-block latency outlier or a backlog. None of that happened: - -- TWAP block p99 grew from 49 ms (130 watches, baseline) to **78 ms (300 watches, saturation)** - **sub-linear growth.** -- EthFlow log p99 held at **8.9 ms** across all three scenarios - the submit-to-mock round-trip is dominated by the network hop, not engine bookkeeping. -- Zero `shepherd_module_errors_total`, zero traps, zero backoff: markers. -- The cold-start outlier (~500 ms on the first watch-heavy block) is consistent across runs and does not scale with the watch count - it's a one-shot first-block redb / eth_call warmup cost. - -**Actual bottleneck:** load-gen's sequential `eth_sendTransaction` submission. At 100 tx/iteration (50+50) and ~200 ms per submission roundtrip, each iteration takes ~20 s, vs. Anvil's 1 s block time. So the load-gen processes 6 block-events of its own but Anvil mines 138 blocks during the same window. The engine handles those 138 dispatch cycles cleanly. - -### Implications - -1. **lgahdl's sequential-dispatch concern**: not surfaced at this scale (300 watches, 138 dispatch cycles in 2 min). To genuinely test it would require an order of magnitude more watches (3 000 - 10 000) or parallel load generators. -2. **What this proves**: shepherd's M4 supervisor handles **at least 300 concurrent watches and 138 block-dispatch cycles in 2 min** with p99 < 80 ms and zero errors. -3. **What it does NOT prove**: behaviour at 3 000+ watches, behaviour under real-network RPC variability, behaviour over 7 days (the 7-day soak's actual job). - -## 7. Followups - -The bottleneck shifted from engine to load-gen; to actually saturate the engine, future iterations should: - -1. **Run multiple load-gens in parallel**, each impersonating a different EOA (Anvil supports arbitrary impersonation), so the per-EOA nonce serialisation does not gate the throughput. -2. **Use a smaller `--block-time`** (e.g. 100 ms) so blocks emit faster and the engine has to handle more dispatch cycles per second. -3. **Pre-seed thousands of watches via direct redb writes** before starting the dispatch loop, then run a small steady-state load - this isolates dispatch cost from indexing cost. - -These are not blocking for the acceptance sign-off; this is a saturation **target**, not a saturation **failure**. The acceptance bar ("identify the bottleneck") is met: bottleneck identified, on the test-tool side, not the engine side. - -## 8. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T160851Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T160851Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 9. Sign-off - -**Bruno (operator) - PASS, saturation.** Engine handles 50+50 events per load-gen iteration without flinching. Bottleneck is the test tool, not the engine. The three-scenario acceptance sweep is complete. diff --git a/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md b/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md deleted file mode 100644 index afb3665a..00000000 --- a/docs/operations/load-reports/load-50x50-parallel-2026-06-19.md +++ /dev/null @@ -1,148 +0,0 @@ -# Load test report - aggressive saturation (10 workers, 0.5s blocks) - -> The saturation push the prior `load-50x50` report flagged as "engine -> did not saturate, the bottleneck is on the load-gen side". This run -> removes both load-gen-side limits and finds the engine's actual -> saturation knee. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T17:05:40Z | -| Wall clock | 120 s (2 min) | -| Engine commit | `feat/load-gen-calibration` head | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 0.5` | -| Mock orderbook | `tools/orderbook-mock --port 9999` | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | saturation-parallel (10 workers × (5 TWAP + 5 EthFlow) per block, `--block-time 0.5`, 2 min) | -| load-gen flags | `--parallel 10 --twap-per-block 5 --ethflow-per-block 5 --block-time 0.5 --duration-min 2` | - -The parallel-mode flag is new: each worker impersonates its own synthetic EOA (`0x57...01` … `0x57...0a`), has its own WS connection + nonce stream, runs its own per-block submission loop. Removes the per-EOA nonce serialisation bottleneck the single-worker saturation report (`load-50x50-2026-06-19.md`) identified. - -## 2. Load generator output - -``` -load-gen finished workers_finished=10 blocks_seen=179 - twap_attempted=895 twap_ok=895 - ethflow_attempted=895 ethflow_ok=895 -``` - -895 TWAP + 895 EthFlow `eth_sendTransaction` acks across 10 workers; zero load-gen-side errors (the first attempt at this run had a sellAmount-overflow bug that blew past the EOA's 1M ETH balance; fixed by namespacing `ethflow_seq` to a 10 000-wide per-worker window). - -## 3. Engine throughput - the saturation signal - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **110** | Block events dispatched. With `--block-time 0.5` we expected ~240; the engine saw 110 - **the block stream itself dropped under load**, see §4. | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **381** | `ConditionalOrderCreated` events delivered. load-gen submitted 895 → only **43%** reached the engine. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **343** | load-gen submitted 895 → **38%** reached the engine. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **343** | Matches EthFlow events 1:1 - the engine submitted every event it saw. | -| `shepherd_cow_api_submit_total{outcome="err"}` | **0** | Zero submit errors. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **31 097** | Watch polls (381 watches × ~80 effective dispatch cycles). | -| `shepherd_module_errors_total` | **0** | Engine never traps. | - -### Latency (Prometheus histogram) - -**twap-monitor block dispatch:** - -| Quantile | Value | -|---|---| -| p50 | **145 ms** | -| p95 | 145 ms | -| p99 | 145 ms | -| **max** | **101 593 ms** ≈ 101 s | - -(The histogram bucketing collapses p50-p99 to the same value because the sample is sparse + bucket-bounded; the `max` is the meaningful upper tail.) - -Engine-log-derived dispatch_block (more granular): -- n = 586 dispatches -- p50 = 4 ms -- p95 = 46 ms -- p99 = 74 ms -- **max = 101 593 ms** (the same 101-second outlier the histogram caught) - -**twap-monitor log + ethflow-watcher log:** histogram-buckets to 0 across all quantiles - per-event indexing + submit completed in < 1 ms even at the peak. The slow path is the watch-polling loop, NOT the indexing or submit. - -## 4. Saturation knee identified - -Two distinct signals - both new vs. the earlier 50×50 run: - -### 4.1 Engine dispatch outlier: 101 s on a single block - -In the prior runs (130 / 280 / 300 watches), the dispatch_block max was bounded between 50 ms and 88 ms steady-state (plus a ~500 ms cold-start outlier on the first watch-heavy block). This run, with 381 active watches and a 0.5 s block time, hit **a 101-second dispatch on at least one block**. That is 200× the prior worst case. - -The likely chain: a 0.5 s block cadence + 381 watches × per-watch `eth_call` against the TWAP handler + 10 parallel WS connections producing log events concurrently → either Anvil's serialised JSON-RPC handling backs up (most likely), the engine's redb writes block, or the per-module dispatch hits a worst-case queue contention. - -Distinguishing among these is the natural follow-up. For the saturation sign-off the headline matters: **the engine has a saturation knee**, it reaches it at ~380 active watches + 10 parallel submitters + 0.5 s block-time on a M-class laptop, and even at that knee it sustains 343 EthFlow round-trips end-to-end + 31 097 `eth_call` polls without producing a single `shepherd_module_errors_total`, `trap`, or `poison`. - -### 4.2 Event-delivery loss: 38-43% of load-gen events never reached the engine - -- 895 TWAP txs → 381 `ConditionalOrderCreated` events delivered. -- 895 EthFlow txs → 343 `OrderPlacement` events delivered. - -That is **a 57-62% drop rate** between the load-gen's `eth_sendTransaction` ack and shepherd's WS subscription. Three plausible causes: - -1. **Anvil's WS subscription buffer overflows** under 10 concurrent connections × 0.5 s block × 10+ log events per block. Anvil is not built for this kind of subscriber load. -2. **Alloy's pubsub client drops events** when its internal channel fills (we DID see "Pubsub service request channel closed" lines in the load-gen output - some workers' WS connections dropped before the 2-min deadline). -3. **Anvil includes only a subset of mempool txs in each block** when the mempool grows faster than the miner can drain (gas-limit-bound or mempool-eviction). - -The block-event drop signal (engine saw 110 of an expected ~240 blocks) is consistent with #1 + #2. - -### 4.3 Engine health under saturation - -Despite the 101 s dispatch outlier and the event-drop ratio: - -- ✓ Zero `shepherd_module_errors_total`. -- ✓ Zero traps. Zero poisoned modules. -- ✓ Every event the engine **did** see was dispatched and submitted: 343 EthFlow → 343 mock orderbook hits, 1:1. -- ✓ One log-side ERROR line, which is the post-teardown WS reset (same as every prior run). - -Shepherd's failure mode under saturation is **graceful degradation, not breakage**. It processes events more slowly when the surrounding system (Anvil + WS transport) cannot keep up; it does not corrupt state, drop events on its own, or kill modules. - -## 5. Comparison across the four saturation runs - -| Scenario | Workers | Block-time | Watches | TWAP block p99 | Engine errors | -|---|---|---|---|---|---| -| baseline 5×5 | 1 | 1 s | 130 | 49 ms | 0 | -| medium 20×20 | 1 | 1 s | 280 | 67 ms | 0 | -| saturation 50×50 | 1 | 1 s | 300 | 78 ms | 0 | -| **saturation-parallel** | **10** | **0.5 s** | **381** | **74 ms (log) / 101 s (max)** | **0** | - -The watch-count grew only modestly (300 → 381), but the surrounding stress (10 connections, 2× block rate) is where the new pressure came from. **The engine itself still scales sub-linearly with watch count - the 101 s outlier is correlated with Anvil + WS, not with watch count.** - -## 6. Bottleneck identified - -In order of severity: - -1. **Anvil + alloy WS subscription** chokes under 10 concurrent subscribers × 0.5 s block cadence. Event-drop ratio 57-62%. -2. **Engine dispatch** has rare worst-case 100-second outliers when polling 380+ watches against a stressed JSON-RPC backend. The dispatch itself is fine; it is waiting on synchronous `eth_call` responses that Anvil cannot serve fast enough. -3. **load-gen** is no longer the bottleneck (was in the prior run). 10 workers in parallel sustain 895 + 895 acks per 2 min. - -For the 7-day soak: this matters because Sepolia's public RPC is closer in shape to Anvil-under-pressure than to a dedicated archive node. The soak should use Alchemy/drpc/QuickNode paid endpoints, not publicnode, OR accept that some event drops will happen and rely on the `eth_getLogs` re-indexing on reconnect. - -## 7. Acceptance - -The saturation scenario's acceptance bar is "identify the bottleneck". Identified: - -1. Engine survives 380+ concurrent watches with zero errors. -2. The dispatch p99 outlier (101 s) at peak load is a **surrounding-system** symptom (Anvil + WS), not an engine bug. -3. 57-62% of upstream events are dropped before they reach the engine under this configuration - **operator must use a faster RPC than publicnode for the 7-day soak**. - -**Saturation-parallel: PASS with caveats** - engine acceptance criteria met; the test surfaces the surrounding infrastructure as the next limiting factor. - -## 8. Followups - -1. **Re-run with a paid Sepolia archive endpoint** (Alchemy / drpc / QuickNode) and confirm the event-drop ratio falls below 5%. This is mostly a one-liner in `scripts/.env`. -2. **Re-run with `anvil --no-mining` + explicit `evm_mine` calls** to remove the timing race entirely. Each block can be packed with N+M txs deterministically. -3. **redb pre-seed** (option 3 from the load-test follow-up list) - bypass `create()` entirely, write 3 000+ watch entries directly to the local-store before engine boot. Isolates "watch-count → dispatch cost" scaling perfectly. Not blocking for this acceptance sign-off. - -## 9. Attachments - -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T170540Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T170540Z.txt` -- Engine + load-gen logs under `/tmp/shepherd-load/`. - -## 10. Sign-off - -**Bruno (operator) - PASS, saturation-parallel.** Engine survives the heaviest load we could synthesise without breaking. The saturation knee is real (101 s dispatch outlier, 38-43% event delivery) but the symptoms point at Anvil + WS, not at shepherd. Engine continues to scale sub-linearly with watch count and never produces a `module_errors_total`, trap, or panic. diff --git a/docs/operations/load-reports/load-5x5-2026-06-19.md b/docs/operations/load-reports/load-5x5-2026-06-19.md deleted file mode 100644 index 5e4f9095..00000000 --- a/docs/operations/load-reports/load-5x5-2026-06-19.md +++ /dev/null @@ -1,161 +0,0 @@ -# Load test report — baseline 5×5 - -> Second baseline run on 2026-06-19 after the load-gen calibration landed. -> Supersedes the conditional-pass first run recorded earlier today. - -## 1. Run metadata - -| Field | Value | -|---|---| -| Stamp (UTC) | 2026-06-19T14:48:46Z | -| Wall clock | 60 s | -| Engine commit | `feat/load-gen-calibration` head | -| Engine config | `engine.load.toml` (state_dir=./data/load wiped per run) | -| Anvil command | `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1` | -| Sepolia archive | `https://ethereum-sepolia-rpc.publicnode.com` | -| Mock orderbook | `tools/orderbook-mock --port 9999` (no latency, no errors) | -| Modules | `twap-monitor`, `ethflow-watcher` | -| Scenario | baseline (5 TWAP + 5 EthFlow per block, 1 min) | - -## 2. Load generator output - -``` -load-gen finished blocks_seen=26 - twap_attempted=130 twap_ok=130 - ethflow_attempted=130 ethflow_ok=130 -``` - -130 `ComposableCoW.create(...)` + 130 `CoWSwapEthFlow.createOrder(...)` -delivered across 26 Anvil blocks. Counters now reflect *delivered -events* because the load-gen calibration removed the nonce-race + -EthFlow OrderUid dedup that suppressed the first run. - -## 3. Engine throughput (the answer to "how does shepherd do under 5+5/block?") - -### Counts (Prometheus delta) - -| Metric | Delta | Notes | -|---|---|---| -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="block"}` | **64** | One per Anvil block (60 s / ~1 s block; some pre-load-gen + post-load-gen blocks captured). | -| `shepherd_event_latency_seconds_count{module="twap-monitor",event_kind="log"}` | **130** | `ConditionalOrderCreated` indexings; matches load-gen 1:1. | -| `shepherd_event_latency_seconds_count{module="ethflow-watcher",event_kind="log"}` | **130** | `OrderPlacement` dispatches; matches load-gen 1:1. | -| `shepherd_cow_api_submit_total{outcome="ok"}` | **130** | Every EthFlow strategy submit reached the mock orderbook successfully. | -| `shepherd_chain_request_total{method="eth_call",outcome="err"}` | **4 157** | `getTradeableOrderWithSignature` reverts (no settle-time allowance); strategy correctly classifies as `TryNextBlock`. 130 watches × ~32 blocks ≈ 4 160 - matches. | -| `shepherd_module_errors_total` | **0** | Zero traps, zero panics, zero poisoned modules. | - -### Latency - -**twap-monitor block (poll loop over all 130 watches):** - -| Quantile | Value | -|---|---| -| p50 | 34 ms | -| p95 | 45 ms | -| p99 | 49 ms | -| max | 50 ms | - -**twap-monitor log (`ConditionalOrderCreated` decode + persist):** - -| Quantile | Value | -|---|---| -| p50 | 4 ms | -| p95 | 5 ms | -| p99 | 6 ms | -| max | 11 ms | - -**ethflow-watcher log (decode → resolve_app_data → build OrderCreation → mock submit → marker):** - -| Quantile | Value | -|---|---| -| p50 | 8 ms | -| p95 | 10 ms | -| p99 | 11 ms | -| max | 11 ms | - -Engine-log-derived dispatch_block max: 474 ms (one cold-start outlier -on the first block where all 130 watches were freshly indexed and -the eth_call cache was cold). Subsequent blocks 34-50 ms steady. - -## 4. Mock orderbook - -``` -submits_ok = 130 -submits_err = 0 -app_data_lookups = 0 -``` - -The empty appData hash matches the `EMPTY_APP_DATA_HASH` short-circuit -in `shepherd_sdk::cow::resolve_app_data`, so the mock's app-data -endpoint sees zero traffic in this scenario - that's expected -behaviour, not a load-gen miss. - -## 5. Acceptance vs. baseline bar - -| Criterion | Observed | Pass? | -|---|---|---| -| 5 TWAP + 5 EthFlow events delivered per block | 130 TWAP + 130 EthFlow in 26 blocks = exactly 5+5/block | **✓** | -| 100% terminal markers within 3 blocks of event | Every EthFlow dispatch reaches mock + writes marker in 8-11 ms (single Anvil block) | **✓** | -| p99 latency < 2 s | TWAP block p99 = 49 ms; EthFlow log p99 = 11 ms | **✓** (40× margin) | -| Zero fuel exhaust | zero | **✓** | -| Zero traps | zero | **✓** | -| `shepherd_module_errors_total = 0` | zero | **✓** | - -**Baseline: full PASS.** Engine sustains the 5+5/block scenario with -40× margin on the latency bar. - -## 6. Observed bottleneck signal - -The twap-monitor *block* dispatch grows linearly with the watch count: -each block re-polls every watch (`eth_call` of -`getTradeableOrderWithSignature`). At 130 watches the p99 is 49 ms; -extrapolating naively, ~3 000 watches would put us at ~1 s which is -still under the 2 s bar but visible. - -The saturation scenario (50 × 50 = 3 000 events in a 60 s window) is -explicitly designed to test that extrapolation. **Medium 20 × 20 and -saturation 50 × 50 are unblocked - run them in follow-up sessions.** - -## 7. Engine health summary - -- ✓ Zero `shepherd_module_errors_total`. -- ✓ Zero traps, zero `init failed`, zero poisoned modules. -- ✓ All 130 EthFlow submissions reached the mock orderbook (0 errors). -- ✓ All 130 TWAP indexings persisted to the local store. -- ✓ `ConditionalOrderCreated` and `OrderPlacement` event streams - delivered 1:1 from Anvil to the modules with no drops. -- The one `WARN reconnect failed` line late in the engine log is the - expected post-teardown WS reset when `scripts/load-run.sh`'s trap - killed Anvil. Not an anomaly. - -## 8. Followups surfaced by this run - -1. **`scripts/load-bootstrap.sh` PID-file truncation** - on a fresh - run, the bootstrap wipes `/tmp/shepherd-load.pids`, so a previous - run's leaked engine process (port 9100) is invisible to teardown. - We hit this between the calibration smoke and this run; - manual `pkill nexum-engine` was required. Fix: pid-by-port - teardown, or move PID files into per-run timestamps. Not a load - test finding; just operational hygiene. -2. **Cold-start outlier on first watch-heavy block** (474 ms vs. - 34-50 ms steady-state). Probably redb's first-write barrier plus - the cold `eth_call` provider connection. Re-confirm under medium - scenario; if the outlier scales with watch count, worth a - supervisor-side investigation. - -## 9. Attachments - -- Engine log: `/tmp/shepherd-load/engine.log` -- Load-gen log: `/tmp/shepherd-load/load-gen.log` -- Anvil log: `/tmp/shepherd-load/anvil.log` -- Mock log: `/tmp/shepherd-load/orderbook-mock.log` -- Metrics start: `/tmp/shepherd-load/metrics-start-20260619T144846Z.txt` -- Metrics end: `/tmp/shepherd-load/metrics-end-20260619T144846Z.txt` - -(Local-only; auto-archiving into `docs/operations/load-reports/` -remains a follow-up.) - -## 10. Sign-off - -**Bruno (operator) — PASS, baseline.** Engine handles 5+5/block -with 40× margin on latency, zero errors, every event delivered -end-to-end. Medium 20×20 and saturation 50×50 are unblocked. diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index d4bfb659..09d94c48 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -1,196 +1,85 @@ # Load test runbook -How to stress shepherd's `twap-monitor` + `ethflow-watcher` modules -under synthetic load using a local Anvil fork of Sepolia and a mock -orderbook. +Stresses the `twap-monitor` + `ethflow-watcher` modules under synthetic load using a local Anvil fork of Sepolia and a mock orderbook. Answers one question: how many TWAP+EthFlow events per block the engine dispatches before something breaks. -The acceptance bar is: +Acceptance bar: | Scenario | Per-block load | Expected outcome | |---|---|---| -| Baseline | 5 TWAP + 5 EthFlow | 100% terminal markers within 3 blocks; p99 latency < 2s; zero fuel exhaust; zero traps | -| Medium | 20 TWAP + 20 EthFlow | Graceful degradation - `backoff:` markers OK, `shepherd_module_errors_total` stays 0 | +| Baseline | 5 TWAP + 5 EthFlow | 100% terminal markers within 3 blocks; p99 latency < 2 s; zero fuel exhaust; zero traps | +| Medium | 20 TWAP + 20 EthFlow | Graceful degradation: `backoff:` markers OK, `shepherd_module_errors_total` stays 0 | | Saturation | 50 TWAP + 50 EthFlow | Expected to saturate; report identifies the bottleneck | -This runbook is distinct from -`docs/operations/e2e-testnet-runbook.md` (correctness on live Sepolia) -and the 7-day soak (wall-clock stability). - ---- - ## 0. Prerequisites -### Toolchain - ``` rustup target add wasm32-wasip2 -brew install foundry # for `anvil` + `cast` +brew install foundry # anvil + cast cargo --version >= 1.87 ``` -### Sepolia archive endpoint - -`anvil --fork-url` needs an HTTP archive endpoint to seed the fork. -Add to `scripts/.env`: +`anvil --fork-url` needs an HTTP archive endpoint. Add to `scripts/.env`: ``` RPC_URL_SEPOLIA_HTTP=https://eth-sepolia.g.alchemy.com/v2/ ``` -(Public nodes throttle the initial fork warmup; use Alchemy / drpc / -similar.) - ---- +(Public nodes throttle the fork warmup; use Alchemy / drpc / similar.) ## 1. Boot -The three supporting processes (Anvil, orderbook-mock, engine) live in -the background; `scripts/load-run.sh` is the single entry point. +`scripts/load-run.sh` is the single entry point: ```bash -# baseline (default knobs: 5 TWAP + 5 EthFlow per block, 1 minute) +# baseline (5 TWAP + 5 EthFlow per block, 1 minute) ./scripts/load-run.sh -# medium load -./scripts/load-run.sh --twap-per-block 20 --ethflow-per-block 20 \ - --duration-min 2 --scenario medium +# medium +./scripts/load-run.sh --twap-per-block 20 --ethflow-per-block 20 --duration-min 2 --scenario medium -# saturation probe -./scripts/load-run.sh --twap-per-block 50 --ethflow-per-block 50 \ - --duration-min 2 --scenario saturation +# saturation +./scripts/load-run.sh --twap-per-block 50 --ethflow-per-block 50 --duration-min 2 --scenario saturation ``` The script: -1. Sources `scripts/load-bootstrap.sh` -> starts Anvil (`port 8545`) - and `tools/orderbook-mock` (`port 9999`). -2. Builds `twap-monitor` + `ethflow-watcher` `.wasm`, the - `nexum` binary, and `tools/load-gen`. -3. Starts the engine pointed at `engine.load.toml`. -4. Snapshots `/metrics` from the engine. +1. Sources `scripts/load-bootstrap.sh`: starts Anvil (port 8545) and `tools/orderbook-mock` (port 9999). +2. Builds the two module `.wasm`, the `shepherd` binary, and `tools/load-gen`. +3. Starts the engine on `engine.load.toml`. +4. Snapshots `/metrics`. 5. Runs `tools/load-gen` for the requested duration. 6. Snapshots `/metrics` again. -7. Tears everything down. -8. Drops a report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md`. - -If you Ctrl-C, the trap calls `load_teardown` and kills the children -before exit. If something escapes (bash trap missed), run -`./scripts/load-teardown.sh` explicitly. - ---- - -## 2. What each component does - -### Anvil (port 8545) - -``` -anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1 -``` - -Forks Sepolia at the latest block. Inherits every contract the test -needs (ComposableCoW, CoWSwapEthFlow, TWAP handler, WETH9, COW token) -at their pinned Sepolia addresses, so the test EOA can call -`ComposableCoW.create(...)` and `CoWSwapEthFlow.createOrder(...)` -against real bytecode without any local deployment step. - -`--block-time 1` mines a block per second, matching Sepolia's -~12s cadence... loosely. The point of the load test is to push N+M -transactions into each block, not to mimic mainnet block times. - -### Mock orderbook (port 9999) - -`tools/orderbook-mock` serves the one endpoint shepherd's `cow-api` -host backend hits per submission: +7. Tears everything down and drops a report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md`. -- `POST /api/v1/orders` - returns a synthetic 56-byte OrderUid. +Ctrl-C triggers `load_teardown`. If a child escapes, run `./scripts/load-teardown.sh`. -Knobs (set via env in `scripts/load-bootstrap.sh` if needed): +## 2. Components -- `--latency-ms` - inject artificial latency on every response. -- `--error-rate` - fraction of POST /orders responses that return a - recognised `ApiError` envelope. Alternates between - `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). - -For the saturation probe, leaving `latency_ms=0` and `error_rate=0` -isolates the engine-side bottleneck from orderbook-side variability. - -### Engine (engine.load.toml) - -- `[chains.11155111] rpc_url = "ws://localhost:8545"` -- `[extensions.cow.orderbook_urls] 11155111 = "http://localhost:9999"` -- Prometheus enabled on `127.0.0.1:9100` -- `state_dir = ./data/load` (wiped at the start of every run) -- Module list: `twap-monitor` + `ethflow-watcher` only - -### Load generator (tools/load-gen) - -Connects to the Anvil WebSocket, calls `anvil_impersonateAccount` + -`anvil_setBalance` on the pinned EOA -(`0x7bF140727D27ea64b607E042f1225680B40ECa6A`), then in a loop, every -new block, fires N `ComposableCoW.create(...)` calls plus M -`CoWSwapEthFlow.createOrder(...)` calls. Each create uses a fresh -salt (counter-derived) so the txs do not collide on the -ComposableCoW dedup check. - -`anvil_impersonateAccount` skips signing entirely - one fewer -overhead under load. - ---- +- **Anvil (port 8545)**: `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1`. Forks Sepolia at the latest block, inheriting ComposableCoW, CoWSwapEthFlow, the TWAP handler, WETH9, and COW at their pinned addresses, so the test EOA calls real bytecode with no local deployment. +- **Mock orderbook (port 9999)**: `tools/orderbook-mock` serves `POST /api/v1/orders`, returning a synthetic 56-byte OrderUid. Knobs (env in `scripts/load-bootstrap.sh`): `--latency-ms` injects response latency; `--error-rate` returns a fraction as an `ApiError` envelope, alternating `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). Leave both 0 for the saturation probe to isolate the engine-side bottleneck. +- **Engine (`engine.load.toml`)**: RPC `ws://localhost:8545`; cow orderbook URL `http://localhost:9999`; Prometheus on `127.0.0.1:9100`; `state_dir = ./data/load` (wiped each run); modules `twap-monitor` + `ethflow-watcher`. +- **Load generator (`tools/load-gen`)**: connects to the Anvil WS, calls `anvil_impersonateAccount` + `anvil_setBalance` on the pinned EOA, then each new block fires N `ComposableCoW.create(...)` + M `CoWSwapEthFlow.createOrder(...)` calls, each with a fresh counter-derived salt. ## 3. Acceptance reading -After a run, the report at -`docs/operations/load-reports/load-NxM-YYYY-MM-DD.md` carries: - -- mock-orderbook stats (success vs. error count) - matches load-gen's - reported submit-attempt count, modulo `error_rate`. -- load-gen tail - submit success/failure breakdown per block. -- engine log tail - watch for `module trap`, `poisoned`, - `init failed`, `WS reconnect`. -- metrics delta filename pair (auto-delta lands in a follow-up). +The report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md` carries mock-orderbook stats, the load-gen submit breakdown, the engine log tail, and the metrics snapshot pair. Look at: -Look at: +- `shepherd_event_latency_seconds{module="twap-monitor"}` quantiles: p99 < 2 s for baseline. +- `shepherd_cow_api_submit_total{outcome="ok"}`: tracks the load-gen success count. +- `shepherd_module_errors_total`: must stay 0 for baseline/medium; any non-zero count on saturation is the headline. +- `shepherd_chain_request_total{method="eth_call"}`: twap-monitor polls via `eth_call`; the count shows how hard the poll races the next block. -- `shepherd_event_latency_seconds{module="twap-monitor"}` quantiles - - p99 < 2s for the baseline scenario. -- `shepherd_cow_api_submit_total{outcome="ok"}` - should track the - load-gen success count. -- `shepherd_module_errors_total` - must stay 0 for baseline/medium; - any non-zero count on saturation is the headline. -- `shepherd_chain_request_total{method="eth_call"}` - twap-monitor - polls via `eth_call`; the count tells you how aggressively the - poll is racing the next block. - ---- - -## 4. What this does NOT prove - -- WS reconnect resilience (7-day soak). -- Diverse appData / order-shape correctness (the watcher replays real - placements from contract genesis at startup). -- Multi-day memory drift (7-day soak). -- Real-orderbook 4xx variety (only a live-orderbook run exercises this). -- Provider rate-limit handling on the live network. - -This test answers exactly one question: "How many TWAP+EthFlow events -per block can shepherd dispatch before something breaks?" Use it -alongside the soak, not instead of it. - ---- - -## 5. Troubleshooting +## 4. Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| Anvil exits within 5s | Forking endpoint rejected | Check `RPC_URL_SEPOLIA_HTTP` is an archive endpoint, not a pruned node. Alchemy free tier works. | -| `cargo build --target wasm32-wasip2` fails on `wit-bindgen` | Toolchain stale | `rustup target add wasm32-wasip2` (re-run; may have rolled). | -| Engine never reaches `supervisor ready` | wasm artefacts not built | The script builds them, but a stale `target/wasm32-wasip2/release/*` from another branch can collide. `rm -rf target/wasm32-wasip2` and rerun. | -| `/metrics` never comes up | Port 9100 in use | Edit `engine.load.toml` `bind_addr` (and the curl URL in `scripts/load-run.sh`). | -| `load-gen` errors with "EOA not impersonated" | Anvil restarted mid-run | `scripts/load-teardown.sh && scripts/load-run.sh` from scratch. | - ---- +| Anvil exits within 5 s | Forking endpoint rejected | Ensure `RPC_URL_SEPOLIA_HTTP` is an archive endpoint | +| `wasm32-wasip2` build fails on `wit-bindgen` | Toolchain stale | `rustup target add wasm32-wasip2` | +| Engine never reaches `supervisor ready` | Stale wasm artefacts | `rm -rf target/wasm32-wasip2` and rerun | +| `/metrics` never comes up | Port 9100 in use | Edit `engine.load.toml` `bind_addr` and the curl URL in `scripts/load-run.sh` | +| `load-gen` errors with "EOA not impersonated" | Anvil restarted mid-run | `scripts/load-teardown.sh && scripts/load-run.sh` | -## 6. References +## 5. References - Sister doc (live Sepolia E2E): `docs/operations/e2e-testnet-runbook.md` - Engine config: `engine.load.toml` diff --git a/docs/operations/m2-testnet-runbook.md b/docs/operations/m2-testnet-runbook.md index a8cc9782..8891d17d 100644 --- a/docs/operations/m2-testnet-runbook.md +++ b/docs/operations/m2-testnet-runbook.md @@ -1,37 +1,18 @@ # M2 testnet runbook (Sepolia) -How to actually run the M2 modules - twap-monitor and ethflow-watcher - -on Sepolia and exercise the full path the unit tests cannot: real -`eth_subscribe` streams, real `eth_call` reverts, real orderbook -submissions. +Runs twap-monitor and ethflow-watcher on Sepolia against real `eth_subscribe` streams, `eth_call` reverts, and orderbook submissions. -Two flavours: +Two flavours share the same boot: -1. **Smoke run**: boot the engine, watch the supervisor pick up every - `ConditionalOrderCreated` / `OrderPlacement` log that lands on - Sepolia. Passive; you do not produce traffic. 15-30 min wall clock. -2. **Round-trip run**: smoke run plus you author a TWAP order via a - Sepolia Safe and an EthFlow swap via the public CoW Swap UI. The - engine indexes / decodes / submits. 1-2 h. - -Both share the same boot. The round-trip is the smoke run with a hand -on the wheel. - ---- +1. Smoke run: boot the engine and watch it pick up every `ConditionalOrderCreated` / `OrderPlacement` log that lands on Sepolia. Passive, 15-30 min. +2. Round-trip run: the smoke run plus you author a TWAP order via a Sepolia Safe and an EthFlow swap via the CoW Swap UI, 1-2 h. ## 0. Prerequisites -- Rust toolchain matching `rust-toolchain.toml` (nightly with - `wasm32-wasip2` target). `rustup target add wasm32-wasip2` once. -- `just` (`cargo install just` or `brew install just`). -- Sepolia RPC. Public endpoint in `engine.m2.toml` works for short - runs; switch to Alchemy/Infura with a key for anything past ~20 min. -- For the round-trip: - - A Sepolia EOA with some test ETH ([Alchemy faucet](https://sepoliafaucet.com)). - - A [Sepolia Safe](https://app.safe.global/?chain=sep) (only for the - TWAP half). - ---- +- Rust toolchain matching `rust-toolchain.toml` (nightly with `wasm32-wasip2`). `rustup target add wasm32-wasip2` once. +- `just`. +- Sepolia RPC. The public endpoint in `engine.m2.toml` works for short runs; switch to Alchemy/Infura for anything past ~20 min. +- Round-trip only: a Sepolia EOA with test ETH, and a Sepolia Safe for the TWAP half. ## 1. Smoke run @@ -39,28 +20,20 @@ on the wheel. just run-m2 ``` -Equivalent long form: +Long form: ```bash cargo build -p twap-monitor --target wasm32-wasip2 --release cargo build -p ethflow-watcher --target wasm32-wasip2 --release -cargo run -p nexum-cli -- --engine-config engine.m2.toml +cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs ``` -### What you should see in the first ~5 seconds (observed) +Expected boot (~5 s): ``` INFO nexum_runtime nexum starting INFO nexum_runtime::host::provider_pool opening chain RPC provider chain_id=11155111 url="wss://..." -INFO nexum_runtime::supervisor loading module manifest manifest=modules/twap-monitor/module.toml -[manifest] required capabilities: logging, local-store, chain, cow-api -INFO nexum_runtime::supervisor compiling component component=target/wasm32-wasip2/release/twap_monitor.wasm -INFO nexum_runtime::host::impls::logging twap-monitor init module="twap-monitor" INFO nexum_runtime::supervisor init succeeded module=twap-monitor -INFO nexum_runtime::supervisor loading module manifest manifest=modules/ethflow-watcher/module.toml -[manifest] required capabilities: logging, local-store, chain, cow-api -INFO nexum_runtime::supervisor compiling component component=target/wasm32-wasip2/release/ethflow_watcher.wasm -INFO nexum_runtime::host::impls::logging ethflow-watcher init module="ethflow-watcher" INFO nexum_runtime::supervisor init succeeded module=ethflow-watcher INFO nexum_runtime::supervisor supervisor up count=2 INFO nexum_runtime supervisor ready modules=2 chains=1 @@ -69,172 +42,72 @@ INFO nexum_runtime::runtime::event_loop log subscription open module=twap-monit INFO nexum_runtime::runtime::event_loop log subscription open module=ethflow-watcher chain_id=11155111 ``` -Then every ~12s (Sepolia block time): - -``` -INFO nexum_runtime::runtime::event_loop dispatch block chain_id=11155111 number=N -``` +Then a `dispatch block` line every ~12 s (Sepolia block time). -### What to verify +Verify: | Check | How | |---|---| -| Both modules booted | `module_count: 2` + 2 `loaded module` lines | +| Both modules booted | `count=2` + 2 `init succeeded` lines | | Subscriptions wired | 2 log subs + 1 block sub | -| No traps in the first 10 blocks | `alive: 2` stays at 2; no `module ... trapped` lines | +| No traps in the first 10 blocks | no `module ... trapped` lines | | State persistence works | `ls data/m2/` shows `ls.redb` growing | -### Stopping cleanly +Ctrl-C to stop. Remove `./data/m2/` between runs for a fresh slate. -Ctrl-C. Tear down `./data/m2/` between runs if you want a fresh slate. - -### Common surprises +## 2. Round-trip run -- **Public RPC throttles after a few minutes.** Symptom: `eth_subscribe` - reconnects in a loop. Fix: switch to Alchemy/Infura. Edit the - `[chains.11155111]` block in `engine.m2.toml` (env-substitution is - not wired yet). -- **You see `eth_call failed (...); defaulting to TryNextBlock`.** This - is twap-monitor polling watches that are still empty (no - `ConditionalOrderCreated` indexed yet). Expected on a fresh `./data/m2`. -- **You see NO log dispatches for hours.** Sepolia has low ComposableCoW - / EthFlow traffic. The smoke run is mostly a "stay alive" test until - you produce events yourself (see round-trip below). +Same boot; you produce the events. ---- +### 2a. TWAP half (Safe + Compose) -## 2. Round-trip run +ComposableCoW expects the conditional-order owner to be an EIP-1271 verifier, so the TWAP flow runs behind a Safe, not an EOA. -Same boot as #1; you produce the events. - -### 2a. TWAP half (via Safe + Compose) - -The TWAP flow lives behind a Safe, not an EOA, because ComposableCoW -expects the conditional-order owner to be an EIP-1271 verifier. - -1. **Create a Sepolia Safe** at . - Single signer with your EOA is fine. Fund it with ~0.05 Sepolia - ETH (gas) and ~10 of a Sepolia ERC-20 you want to sell. -2. **Install the Compose app** in the Safe. CoW Protocol publishes the - ComposableCoW Watch Tower as a Safe app on Sepolia. - - In Safe -> Apps -> Add custom app: use the URL from - README ("Add to - Safe"). -3. **Author a TWAP order**. Compose UI -> "TWAP". Recommended for the - first run: - - Sell: 1 of your test ERC-20. - - Buy: any Sepolia stable. - - Split into 2 parts, 5-minute interval, validity 30 min. - - Confirm + sign the Safe tx. -4. **Watch the engine logs.** Within ~12s of the Safe tx confirming, - you should see: +1. Create a Sepolia Safe at (single signer with your EOA). Fund it with ~0.05 Sepolia ETH and ~10 of a Sepolia ERC-20 to sell. +2. Add the ComposableCoW Compose app (Safe -> Apps -> Add custom app, URL from the composable-cow README). +3. Author a TWAP order in the Compose UI: sell 1 test ERC-20, buy any Sepolia stable, 2 parts, 5-minute interval, 30-minute validity. Sign the Safe tx. +4. Within ~12 s of the tx confirming: ``` INFO twap-monitor indexed watch:0x:0x - ``` - Then on the next blocks where the tranche is ready: - ``` INFO twap-monitor poll watch:... -> Ready INFO twap-monitor submitted submitted:0x ``` - Sometimes you see `TryAtEpoch(t)` instead of `Ready` - that means - the tranche is gated until time `t`. Wait the configured interval. -5. **Confirm on the orderbook.** Get the UID from the log, then: +`TryAtEpoch(t)` instead of `Ready` means the tranche is gated until time `t`; wait the configured interval. +5. Confirm on the orderbook (settlement on Sepolia is spotty; reaching the orderbook is the goal): ```bash curl https://api.cow.fi/sepolia/api/v1/orders/0x ``` - You should see the order JSON back. Trade settlement on Sepolia is - spotty (solvers do not always pick up); the goal of this test is - that the order reached the orderbook, not that it filled. -### 2b. EthFlow half (via swap.cow.fi) +### 2b. EthFlow half (swap.cow.fi) -EthFlow does not need a Safe - any EOA works. +Any EOA works. -1. Go to (Sepolia native - ETH selector). -2. Connect your EOA, select a small swap (e.g. 0.001 SETH -> any - token), confirm. -3. The CoWSwapEthFlow contract on Sepolia - (`0xbA3cB4...EadeC`) emits `OrderPlacement`. -4. **Watch the engine logs:** +1. Go to . +2. Connect the EOA, select a small swap (e.g. 0.001 SETH -> any token), confirm. +3. CoWSwapEthFlow (`0xbA3cB4...EadeC`) emits `OrderPlacement`. Expected log: ``` INFO ethflow-watcher ethflow submitted 0x ``` - If you see `ethflow backoff 0x ...` instead: orderbook - classified the submit as retriable. Wait one block, the watcher - does not retry on its own today (planned for M4 supervisor - restart wiring). - - If you see `ethflow dropped 0x ...`: orderbook rejected - permanently (most likely `DuplicateOrder` - CoW Swap submits the - order itself first, ethflow-watcher races and loses). Expected; the - `dropped:{uid}` row is the regression guard, not the - failure signal here. +`ethflow backoff 0x` means the orderbook classified the submit as retriable; wait one block. `ethflow dropped 0x` means a permanent rejection (commonly `DuplicateOrder`, since CoW Swap submits the order first and the watcher races it); the `dropped:{uid}` row is the expected marker. -### What "passing M2 round-trip" looks like - -- At least one `submitted:{uid}` row in `data/m2/ls.redb` written by - each module. -- Both modules still alive (`alive: 2`) at the end of the run. -- Zero `module ... trapped` lines in the engine log. -- `curl api.cow.fi/sepolia/api/v1/orders/` returns the order JSON - for at least one submitted UID (`null` means the orderbook never - accepted; non-null means we round-tripped). - ---- +Passing round-trip: at least one `submitted:{uid}` row per module in `data/m2/ls.redb`, both modules alive at the end, zero `trapped` lines, and `curl api.cow.fi/sepolia/api/v1/orders/` returns the order JSON for at least one UID. ## 3. Inspecting state after a run -The local-store is a redb file. Quick inspection without writing a -tool: - -```bash -# Build the example mini-CLI the engine ships -cargo run -p nexum-cli --bin ls-dump -- data/m2/ls.redb 2>/dev/null \ - || echo "no ls-dump bin in 0.2 - read via the engine on next boot" -``` - -Today the canonical way to read the store is to boot the engine again -on the same `state_dir`: the supervisor logs every `watch:` / -`submitted:` / `dropped:` row it loads. A proper inspector is -production-hardening scope (M4). +The local store is a redb file with no standalone dump tool. Reboot the engine on the same `state_dir`: the supervisor logs every `watch:` / `submitted:` / `dropped:` row it loads. ---- - -## 4. What this run does NOT prove - -- **Throughput / soak stability**. That is the 7-day soak. -- **Cross-module isolation under load**. That is the 4-6h - multi-module E2E run. The local-store namespace test guarantees the - invariant in unit; the runbook above is a single-Safe / single-EOA - setup. -- **Resource-limit enforcement under adversarial guests**. Fuel + memory tests (M4 territory). -- **Security review**. Tracked separately (M4 territory). - -The M2 runbook covers: "does the engine actually boot the two M2 -modules end-to-end against Sepolia, route real subscription events -through the wit-bindgen + WitBindgenHost path, and round-trip orders -to the CoW orderbook". That is the deliverable M2 is responsible for. - ---- - -## 5. Troubleshooting +## 4. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `connection refused` / WS retries | Public node throttled | Switch RPC to Alchemy / Infura | -| `module twap-monitor trapped: OutOfFuel` | Dispatch path exceeded fuel budget | Almost certainly an upstream issue, file as a separate issue; raise `[engine.limits]` fuel temporarily | +| `connection refused` / WS retries | Public node throttled | Switch RPC to Alchemy / Infura in `engine.m2.toml` | +| `module twap-monitor trapped: OutOfFuel` | Dispatch exceeded fuel budget | File an issue; raise `[engine.limits]` fuel temporarily | | `eth_call failed (rate limited)` repeatedly | Public node | Same as above | -| `ParseManifestError: missing capability cow-api` | Engine version mismatch with module.toml | `cargo build -p nexum-cli --release` and use the fresh binary | -| `data/m2/ls.redb` not created | `state_dir` not writable | Check permissions, or change `state_dir` in `engine.m2.toml` | - ---- +| `ParseManifestError: missing capability cow-api` | Engine/module.toml version mismatch | `cargo build -p shepherd --release` and use the fresh binary | +| `data/m2/ls.redb` not created | `state_dir` not writable | Check permissions or change `state_dir` in `engine.m2.toml` | -## 6. References +## 5. References - Engine config schema: `crates/nexum-runtime/src/engine_config.rs` - M2 modules: `modules/twap-monitor/`, `modules/ethflow-watcher/` -- ADR-0005 (cow-api routing): `docs/adr/0005-cow-api-via-cached-orderbookapi.md` -- ADR-0006 (twap + ethflow helpers): `docs/adr/0006-cow-twap-ethflow-host-helpers.md` -- ADR-0009 (host trait surface): `docs/adr/0009-host-trait-surface.md` -- M2 PRs in `bleu/nullis-shepherd`: #2-#11 +- ADR-0005 (cow-api routing), ADR-0006 (twap + ethflow helpers), ADR-0009 (host trait surface) diff --git a/docs/operations/m3-edge-case-validation.md b/docs/operations/m3-edge-case-validation.md deleted file mode 100644 index 62b681ab..00000000 --- a/docs/operations/m3-edge-case-validation.md +++ /dev/null @@ -1,230 +0,0 @@ -# M3 testnet edge-case validation (2026-06-18) - -Five edge cases run against the live `engine.m3.toml` boot on Sepolia. -Each takes ~10-15 s of wall clock; together they exercise the error -paths the runbook section 1 cannot cover passively. **All five -passed with one minor observation** (init-failed module stays -`alive=true`; safe in practice, worth a follow-up issue). - -Run on commit `feat/m3-edge-case-validation` tip; engine debug log -level. - ---- - -## 1.1 Bad RPC URL -> structured connect error, clean exit - -**Mutation**: `engine.m3.toml` `rpc_url = "wss://nonexistent.example.com"`. - -**Observed**: - -``` -INFO nexum starting -INFO opening chain RPC provider chain_id=11155111 url="wss://nonexistent.example.com" -Error: connect chain 11155111: IO error: failed to lookup address information: - nodename nor servname provided, or not known -``` - -**Verdict**: ✅ engine exits with structured `connect chain N: ...` -error chain. No panic, no retry loop, no silent hang. Operator -gets a clear cue to fix the URL. - -**Implication**: an operator misconfiguring an RPC URL fails fast and -loud. Combined with the supervisor restart loop (planned for M4), -this gives "kill engine, fix config, restart, no orphaned state". - ---- - -## 1.2 Bad oracle address -> module Warn + stays alive - -**Mutation**: `modules/examples/price-alert/module.toml::[config]` -`oracle_address = "0x0000000000000000000000000000000000000001"` (an -EOA with no code; `eth_call` returns empty bytes). - -**Observed**: boot clean; on the first block: - -``` -WARN price-alert: latestRoundData decode failed: - ABI decoding failed: buffer overrun while deserializing -``` - -Engine stays at `supervisor up count=3`; balance-tracker and -stop-loss continue to operate normally. - -**Verdict**: ✅ module gracefully handles upstream giving the wrong -shape. The decode error names the failing call (`latestRoundData`) -and the failure mode (buffer overrun), so an operator can correlate -to a misconfigured `oracle_address` without reading source. - -**Implication**: validates the SDK error model end-to-end: -`chain::request` returns Ok with empty bytes, `parse_eth_call_result` -returns `Some(vec![])`, `latestRoundDataCall::abi_decode_returns` -fails with `alloy_sol_types::Error::Buffer overrun`, the strategy's -`map_err` surfaces it as a `Warn` log via `LoggingHost::log`. All -four host traits + the `cow` helper path exercised. - ---- - -## 1.3 Capability mismatch -> boot rejects module - -**Mutation**: `modules/examples/stop-loss/module.toml::[capabilities]` -`required = ["logging"]` (dropped `chain`, `local-store`, `cow-api`). - -**Observed**: - -``` -INFO loading module manifest manifest=modules/examples/stop-loss/module.toml -[manifest] required capabilities: logging -INFO compiling component component=...stop_loss.wasm -Error: load module target/wasm32-wasip2/release/stop_loss.wasm - -Caused by: - 0: capability violation in target/wasm32-wasip2/release/stop_loss.wasm - 1: component imports `cow-api` (shepherd:cow/cow-api@0.1.0) but it - is not listed in [capabilities].required or [capabilities].optional -``` - -Engine exits with non-zero. The whole boot fails because the -supervisor cannot honour the (intentionally under-declared) manifest. - -**Verdict**: ✅ the capability security boundary is enforced at module -load, not deferred to first host call. Error chain identifies the -specific `cow-api` import that the manifest does not authorise. This -is the `enforce capability declarations at module -instantiation` invariant working in production. - -**Implication**: a malicious or buggy module cannot import a host -capability without explicitly declaring it. This is the M3 SDK -contract's core security guarantee. - ---- - -## 1.4 Malformed `[config]` -> init returns typed `InvalidInput` - -**Mutation**: `modules/examples/price-alert/module.toml::[config]` -`threshold = "not-a-number"`. - -**Observed**: - -``` -INFO loading module manifest manifest=modules/examples/price-alert/module.toml -WARN init failed - module=price-alert - kind=invalid_input - "invalid [config]: threshold: non-digit character in - \"not-a-number\"" -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -INFO stop-loss init: owner=..., trigger=..., ... -INFO init succeeded module=stop-loss -INFO supervisor up count=3 -``` - -**Verdict**: ✅ init failure isolated to the offending module. -Balance-tracker and stop-loss boot normally. The export returns -`fault.invalid-input`; the supervisor supplies the module name and -derives the log `kind` from the fault label, with a clear message -identifying the field + the invalid character. - -**Update (landed in this PR series)**: the supervisor now -flips `alive = false` when `init` returns `Err`, and the boot log -shows `supervisor up loaded=3 alive=2` so the discrepancy is -visible. Re-running scenario 1.4 against live Sepolia after the fix: - -``` -WARN init failed - module loaded but marked dead; dispatcher will skip it - module=price-alert kind=invalid_input - "invalid [config]: threshold: non-digit character in 'not-a-number'" -INFO supervisor up loaded=3 alive=2 -``` - -Subsequent block dispatches reach only the 2 alive modules; the -init-failed price-alert is now skipped by the dispatch fast-path -without surfacing the no-op fuel cost. Regression test: -`supervisor::tests::init_failure_marks_module_dead_and_excludes_from_dispatch`. - ---- - -## 1.5 Persistence cross-restart -> redb file preserved - -**Mutation**: boot 1 with `rm -rf data/m3` (fresh state), then boot 2 -without rm. - -**Observed**: - -``` -=== Boot 1 (fresh) === -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -(stopped after 14s) - -=== State after boot 1 === -total 7200 --rw-r--r-- brunotavaresdosanjos 3686400 data/m3/local-store.redb - -=== Boot 2 (state preserved) === -INFO balance-tracker init: 2 addresses, ... -INFO init succeeded module=balance-tracker -(stopped after 14s) -``` - -Both boots clean; `local-store.redb` file size stable (3.6 MB - redb -pre-allocates pages; actual key/value content is bytes, not MB). - -**Verdict**: ✅ the redb file survives `kill -TERM` cleanly, can be -re-opened on the next boot, and the supervisor reads from it -without corruption. This validates the 32-byte hash prefix -namespace in production: modules wrote -keys, the engine shut down, modules re-attached on restart, no -panic. - -**Implication**: the local-store invariant -(`namespaces_isolate_modules` unit test + cross-restart durability) -is now confirmed against a real Sepolia run. Combined with the -supervisor integration tests, this is sufficient -evidence that local-store persistence works at the production -boundary, not only in mocks. - -**Caveat**: there is no built-in CLI to dump the redb contents, so -visual confirmation of specific keys (`last:0x...`, etc.) requires -either re-booting the engine on the same state_dir or writing an -ad-hoc inspector. Filed as a future M4-territory nice-to-have. - ---- - -## Summary - -| # | Scenario | Verdict | New issue? | -|---|---|---|---| -| 1.1 | Bad RPC URL | ✅ structured error + clean exit | no | -| 1.2 | Bad oracle address | ✅ Warn + module alive + clear decode error | no | -| 1.3 | Capability mismatch | ✅ boot rejects with structured error chain | no | -| 1.4 | Malformed `[config]` | ✅ typed `InvalidInput`; init-failed module marked dead + excluded from dispatch | resolved in this PR series | -| 1.5 | Cross-restart persistence | ✅ redb file preserved + re-attaches cleanly | no (a state-dump CLI would help; M4 nice-to-have) | - -**One follow-up issue**: in `Supervisor::load`, when `init` returns -`Err(fault)`, set `alive=false` (or skip pushing the module into -`self.modules`). Subsequent dispatch wastes fuel on a no-op -short-circuit otherwise. Safe today; cleanup before M4. - -**Not in scope here** (M4 territory, already filed): -- Fuel exhaustion (M4 territory) -- Memory exhaustion (M4 territory) -- Module trap during `on_event` + restart with backoff (M4 territory) -- WS reconnect logic instead of bail → not filed (current behaviour - is documented in `runtime/event_loop.rs` as "0.3 fix") - ---- - -## How to reproduce - -Each scenario is a one-line config mutation + `just run-m3` (or the -equivalent `cargo run`). Mutations are listed inline above. Restore -config between runs: - -```bash -git checkout modules/examples/price-alert/module.toml \ - modules/examples/stop-loss/module.toml \ - engine.m3.toml -``` - -Tested on commit `` at 2026-06-18, Sepolia public WS. diff --git a/docs/operations/m3-testnet-runbook.md b/docs/operations/m3-testnet-runbook.md index 8f2f9335..6e0b4a17 100644 --- a/docs/operations/m3-testnet-runbook.md +++ b/docs/operations/m3-testnet-runbook.md @@ -1,208 +1,99 @@ # M3 testnet runbook (Sepolia) -How to exercise the M3 example modules - price-alert, balance-tracker, -stop-loss - on Sepolia. Same shape as the M2 runbook but the modules -are different: - -- **price-alert** validates SDK `chain` helpers + Chainlink ABI decode. - Read-only; no on-chain or orderbook action. -- **balance-tracker** validates SDK `chain::request` (raw RPC) + - `local-store` per-key diff persistence. Read-only. -- **stop-loss** validates the full M3 surface: `chain::request` + - `local-store` dedup + `cow-api::submit-order` with - `Signature::PreSign`. Will attempt to submit a real CoW order to the - Sepolia orderbook when the oracle price crosses the trigger. - -In other words: M3 exercises the *strategy*-side SDK surface that M2 -modules eventually consume. The runbook below validates everything in -~8 seconds of wall clock against the real Sepolia ETH/USD Chainlink -feed. - ---- +Exercises the example modules price-alert, balance-tracker, and stop-loss on Sepolia: -## 0. Prerequisites +- price-alert: SDK `chain` helpers + Chainlink ABI decode. Read-only. +- balance-tracker: SDK `chain::request` (raw RPC) + `local-store` per-key diff persistence. Read-only. +- stop-loss: `chain::request` + `local-store` dedup + `cow-api::submit-order` with `Signature::PreSign`. Submits a real CoW order to the Sepolia orderbook when the oracle price crosses the trigger. -- Same as the M2 runbook (Rust nightly + `wasm32-wasip2`, `just` - optional, Sepolia RPC). -- For stop-loss to actually settle an order (not just submit and get - rejected) you also need: - - An EOA matching `[config] owner = ...` in - `modules/examples/stop-loss/module.toml` that has called - `setPreSignature(orderUid, true)` on the GPv2Settlement Sepolia - contract for the computed UID. - - That EOA holds + has approved enough of `sell_token` to settle. +All three subscribe to blocks only and start working immediately; a single Sepolia block (~12 s) drives each through its full strategy. - Without those, stop-loss will hit `TransferSimulationFailed` (or - `InvalidSignature` / `InsufficientAllowance`) and log it as a - retriable error or drop. **That outcome alone validates the - orderbook round-trip** - same shape as the M2 EthFlow validation. +## 0. Prerequisites ---- +- Same as the M2 runbook (Rust nightly + `wasm32-wasip2`, `just`, Sepolia RPC). +- For stop-loss to settle (not just submit and get rejected): + - An EOA matching `[config] owner` in `modules/examples/stop-loss/module.toml` that has called `setPreSignature(orderUid, true)` on the GPv2Settlement Sepolia contract for the computed UID. + - That EOA holds and has approved enough `sell_token` to settle. -## 1. Smoke + active run +Without those, stop-loss hits `TransferSimulationFailed` (or `InvalidSignature` / `InsufficientAllowance`) and logs it as a retriable error or drop. That outcome still validates the orderbook round-trip. -The M3 modules all subscribe to blocks only and start working -immediately - there is no `[[subscription]] kind = "chain-log"` to wait for. -A single Sepolia block (~12 s) drives all three through their full -strategy. +## 1. Smoke + active run ```bash just run-m3 ``` -Equivalent long form: +Long form: ```bash cargo build -p price-alert --target wasm32-wasip2 --release cargo build -p balance-tracker --target wasm32-wasip2 --release cargo build -p stop-loss --target wasm32-wasip2 --release -cargo run -p nexum-cli -- --engine-config engine.m3.toml +cargo run -p shepherd -- --engine-config engine.m3.toml --pretty-logs ``` -### What you should see in the first ~10 seconds (observed) +Expected boot (~10 s): ``` INFO nexum starting -INFO opening chain RPC provider chain_id=11155111 url="wss://..." -INFO loading module manifest manifest=modules/examples/price-alert/module.toml -[manifest] required capabilities: logging, chain -INFO compiling component component=...price_alert.wasm -INFO price-alert init: oracle=0x694aa1769357215de4fac081bf1f309adc325306 - threshold=250000000000 direction=Below every_n_blocks=1 INFO init succeeded module=price-alert -INFO loading module manifest manifest=modules/examples/balance-tracker/module.toml -[manifest] required capabilities: logging, chain, local-store -INFO compiling component component=...balance_tracker.wasm -INFO balance-tracker init: 2 addresses, threshold=100000000000000000 wei INFO init succeeded module=balance-tracker -INFO loading module manifest manifest=modules/examples/stop-loss/module.toml -[manifest] required capabilities: logging, chain, local-store, cow-api -INFO compiling component component=...stop_loss.wasm -INFO stop-loss init: owner=0x70997970c51812dc3a010c7d01b50e0d17dc79c8 - trigger=250000000000 sell=0x6810e776880c02933d47db1b9fc05908e5386b96 - buy=0xfff9976782d46cc05630d1f6ebab18b2324d6b14 INFO init succeeded module=stop-loss -INFO supervisor up count=3 INFO supervisor ready modules=3 chains=1 INFO block subscription open chain_id=11155111 ``` -Then on the FIRST Sepolia block dispatch (~5-15s after boot): +On the first Sepolia block dispatch (~5-15 s after boot): ``` -DEBUG chain::request chain_id=11155111 method=eth_call # price-alert reads oracle +DEBUG chain::request method=eth_call # price-alert reads oracle WARN price-alert: TRIGGERED answer=174553978080 threshold=250000000000 (Below) -DEBUG chain::request chain_id=11155111 method=eth_getBalance # balance-tracker addr 1 -DEBUG chain::request chain_id=11155111 method=eth_getBalance # balance-tracker addr 2 -DEBUG chain::request chain_id=11155111 method=eth_call # stop-loss reads oracle -DEBUG cow-api::submit-order chain_id=11155111 bytes=561 -WARN stop-loss retry on next block (0): orderbook error (TransferSimulationFailed): - sell token cannot be transferred +DEBUG chain::request method=eth_getBalance # balance-tracker addr 1 +DEBUG chain::request method=eth_getBalance # balance-tracker addr 2 +DEBUG chain::request method=eth_call # stop-loss reads oracle +DEBUG cow-api::submit-order bytes=561 +WARN stop-loss retry on next block (0): orderbook error (TransferSimulationFailed): sell token cannot be transferred ``` -That single block proves the entire M3 strategy surface end-to-end: -oracle read + ABI decode + multi-key local-store + cow-api submit + -typed retry classification, all routed through real wit-bindgen + -WitBindgenHost + supervisor dispatch on a live testnet. - -### Why TRIGGERED fires immediately +That block proves the M3 strategy surface end-to-end: oracle read + ABI decode + multi-key local-store + cow-api submit + typed retry classification. -The default `threshold = "2500.00"` in `module.toml::[config]` is -above the Sepolia Chainlink ETH/USD feed (which tracks a stale or -mocked value, often around $1745). Direction is `below`, so the very -first poll trips the alert. Tune `threshold` if you want to test the -"silent" path. +Why TRIGGERED fires immediately: the default `trigger_price` in `module.toml` is above the Sepolia Chainlink ETH/USD feed (a stale or mocked value), and `direction = below`, so the first poll trips. Raise `trigger_price` to test the silent path. -### Why stop-loss logs TransferSimulationFailed - -The default `owner = 0x70997970...` in stop-loss's config is the -canonical hardhat test EOA (`anvil` account index 1). It does not own -or approve the `sell_token` on Sepolia, so the orderbook simulates -the would-be settle and rejects with -`TransferSimulationFailed`. **This is the orderbook returning a typed -error - the full submit path worked.** The module's -`classify_api_error` SDK helper correctly tagged it as retriable -(`TryNextBlock`), so the watch is left in place for the next block. - -For the silent ("idle until trigger") run path, set `owner` to a real -EOA with the right allowances + pre-signature - see section 2 below. - ---- +Why stop-loss logs TransferSimulationFailed: the default `owner` does not own or approve `sell_token` on Sepolia, so the orderbook simulates the settle and rejects with a typed error. The `classify_api_error` SDK helper tags it retriable (`TryNextBlock`) and leaves the watch for the next block. ## 2. Active validation (optional) -To see stop-loss actually submit + persist `submitted:{uid}` you need -to set up a real signed order: - -1. Pick a Sepolia EOA you control. -2. In `modules/examples/stop-loss/module.toml`, set `owner = "0x..."` - to that EOA. -3. Choose a `sell_token` / `buy_token` pair the EOA holds. -4. Compute the OrderUid the module will submit (the `build_creation` - helper in `strategy.rs` shows the construction; you can also boot - the engine once with a high trigger so it stays idle, then - simulate-decode the would-be submit by reading the supervisor's - debug log). -5. Call `GPv2Settlement.setPreSignature(uid, true)` from that EOA on - Sepolia. -6. Approve `sell_token` to the GPv2VaultRelayer for the sell amount. -7. Lower the `trigger_price` in `module.toml` so the next poll fires. +To see stop-loss submit and persist `submitted:{uid}`: + +1. Set `owner` in `modules/examples/stop-loss/module.toml` to a Sepolia EOA you control. +2. Choose a `sell_token` / `buy_token` pair the EOA holds. +3. Compute the OrderUid (see `build_creation` in `strategy.rs`). +4. Call `GPv2Settlement.setPreSignature(uid, true)` from that EOA. +5. Approve `sell_token` to the GPv2VaultRelayer for the sell amount. +6. Lower `trigger_price` so the next poll fires. On the next block: ``` INFO stop-loss TRIGGERED price=... trigger=... -DEBUG cow-api::submit-order ... INFO stop-loss submitted submitted:0x ``` -This is the M3 equivalent of the M2 EthFlow validation: same -end-to-end surface, different module. - ---- - ## 3. State inspection -`./data/m3/ls.redb` accumulates the `last:{addr}` keys -(balance-tracker), `submitted:{uid}` / `dropped:{uid}` (stop-loss). -Same caveat as M2 - no `ls-dump` CLI today; reboot the engine on the -same `state_dir` and the supervisor logs every key it loads. - -`rm -rf ./data/m3` between runs for a fresh slate. - ---- - -## 4. What this does NOT prove - -Same boundary as M2's section 4: +`./data/m3/ls.redb` accumulates `last:{addr}` (balance-tracker) and `submitted:{uid}` / `dropped:{uid}` (stop-loss). No standalone dump tool: reboot the engine on the same `state_dir` and the supervisor logs every key it loads. `rm -rf ./data/m3` for a fresh slate. -- Throughput / 7-day soak. -- Cross-module isolation under load (the 4-6 h E2E run). -- Adversarial resource exhaustion (M4 territory). -- Security review (M4 territory). -- `app_data` resolution for stop-loss orders with non-empty metadata - -> M5 (typed `Cow` client with `raw_request`). - ---- - -## 5. Troubleshooting - -Most of the M2 runbook's section 5 applies verbatim. M3-specific: +## 4. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `module stop-loss trapped: TransferSimulationFailed` | Trap vs warn confusion | The "sell token cannot be transferred" line is a Warn, not a trap. Module stays alive. Read again carefully. | -| Engine bails immediately with `log stream ended (WebSocket dropped?)` | Pre-fix M1 bug | Should not happen on this commit. The fix lands in `runtime/event_loop.rs`: `select_all` over empty `Vec` is replaced with `stream::pending()`. Regression test at `supervisor::tests::run_does_not_bail_when_both_stream_kinds_are_empty`. | -| `price-alert: TRIGGERED` does not fire | Oracle returned shape we cannot decode, or Sepolia public node throttled the `eth_call` | Check for `eth_call failed` warnings; switch to Alchemy. | -| `balance-tracker` only logs 1 of 2 addresses | RPC dropped a request mid-block | Same RPC throttle path; switch RPC. | - ---- +| `module stop-loss trapped: TransferSimulationFailed` | Trap vs warn confusion | `sell token cannot be transferred` is a Warn, not a trap; the module stays alive. | +| `price-alert: TRIGGERED` does not fire | Undecodable oracle shape, or throttled `eth_call` | Check for `eth_call failed`; switch to Alchemy. | +| `balance-tracker` logs only 1 of 2 addresses | RPC dropped a request mid-block | Switch RPC. | -## 6. References +## 5. References - M3 modules: `modules/examples/{price-alert,balance-tracker,stop-loss}/` -- SDK helpers exercised: `crates/shepherd-sdk/src/{chain,cow}/` -- ADR-0009 (host trait surface): `docs/adr/0009-host-trait-surface.md` -- M3 PRs in `bleu/nullis-shepherd`: #12-#26 (SDK + examples + tutorial + QA cleanup) -- M3 fix tail PRs: #27-#31 (CI matrix, rustdoc gate, doctests, supervisor integration, M2 runbook) -- M2 runbook (sister doc, same shape): `docs/operations/m2-testnet-runbook.md` +- SDK chain helpers: `crates/nexum-sdk/src/chain/` +- ADR-0009 (host trait surface) +- M2 runbook (sister doc): `docs/operations/m2-testnet-runbook.md` diff --git a/docs/operations/soak-runbook.md b/docs/operations/soak-runbook.md index 76e3feea..90acb573 100644 --- a/docs/operations/soak-runbook.md +++ b/docs/operations/soak-runbook.md @@ -1,146 +1,66 @@ -# 7-Day Soak Runbook +# 7-day soak runbook -How to run the **7-day unattended stability soak** — all 5 modules on -Sepolia, continuously, with hourly metrics snapshots. - -## Purpose - -Grant evidence. The milestones require: - -- **M1 (24h):** snapshots from hour 0-24 proving sustained operation. -- **M2 (48h):** snapshots from hour 0-48 proving 48-hour stability. -- **M4 (7-day):** full run artifact set (logs + snapshots + start/end metrics). - -The soak validates *stability* — it is the step after the E2E run -(`docs/operations/e2e-testnet-runbook.md`) which validates correctness. -Do not start the soak unless the E2E run has passed its acceptance bar. - ---- +Runs all 5 modules on Sepolia continuously and unattended, with hourly metrics snapshots, to validate stability. The soak follows the E2E run (`docs/operations/e2e-testnet-runbook.md`), which validates correctness; do not start it until the E2E run has passed its acceptance bar. ## How it works -Two Docker containers managed by `docker-compose.soak.yml`: - -- **engine** — the nexum binary with `restart: unless-stopped`. Docker - handles log rotation (json-file driver, 500 MB × 14 files ≈ 7 GB cap) - and automatic crash recovery. -- **snapshotter** — an Alpine container that runs `scripts/soak-snapshot.sh`: - captures a baseline on start, then scrapes `/metrics` every hour and - writes `metrics-snap-.txt` to `docs/operations/soak-reports/` - (bind-mounted from the host so files are immediately accessible). +Two containers managed by `docker-compose.soak.yml`: ---- +- **engine**: the shepherd binary with `restart: unless-stopped`. Docker handles log rotation (json-file driver, 500 MB x 14 files) and crash recovery. +- **snapshotter**: an Alpine container running `scripts/soak-snapshot.sh`: captures a baseline on start, then scrapes `/metrics` every hour to `metrics-snap-.txt` under `docs/operations/soak-reports/` (bind-mounted from the host). ## Pre-flight checklist - [ ] Docker installed and running. -- [ ] Paid RPC endpoint with WebSocket support. Public nodes (e.g. - `wss://ethereum-sepolia-rpc.publicnode.com`) throttle `eth_subscribe` - under sustained load. Alchemy or Infura growth tier recommended. +- [ ] Paid RPC endpoint with WebSocket support (public nodes throttle `eth_subscribe` under sustained load). - [ ] `SEPOLIA_RPC_URL` set in the repo-root `.env`: ```bash echo "SEPOLIA_RPC_URL=wss://eth-sepolia.g.alchemy.com/v2/YOUR_KEY" >> .env ``` - Docker Compose reads `.env` automatically and forwards the variable - into the engine container. -- [ ] ≥ 20 GB free disk (engine logs capped at ~7 GB by Docker log - rotation; snapshots are negligible). -- [ ] Machine will not sleep: - - **macOS:** System Settings → Battery → Prevent automatic sleep when power - adapter is connected. - - **Linux:** `systemd-inhibit --what=sleep` or configure Docker to start - on boot (`sudo systemctl enable docker`). -- [ ] E2E run has passed its acceptance bar (see `e2e-testnet-runbook.md`). -- [ ] **Set `SHEPHERD_IMAGE` to an image that exists.** The compose - default (`ghcr.io/nullislabs/shepherd:latest`) is only published on - pushes to `main`; until a release lands there, `pull` fails with - `denied`. Two working options: - - **(a) Build locally from the soak commit** (recommended — the image - digest in the evidence then provably matches the reviewed tree): +- [ ] >= 20 GB free disk (engine logs cap at ~7 GB via rotation). +- [ ] Machine will not sleep (macOS: prevent sleep on power adapter; Linux: `systemd-inhibit --what=sleep` or `sudo systemctl enable docker`). +- [ ] E2E run has passed its acceptance bar. +- [ ] `SHEPHERD_IMAGE` set to an image that exists. The compose default (`ghcr.io/nullislabs/shepherd:latest`) is only published on pushes to `main`; until then, `pull` fails with `denied`. Either build locally from the soak commit: ```bash docker build -t shepherd-soak:$(git rev-parse --short HEAD) . echo "SHEPHERD_IMAGE=shepherd-soak:$(git rev-parse --short HEAD)" >> .env ``` - - **(b) Publish via CI:** trigger the `docker` workflow with - `gh workflow run docker.yml --ref develop`, wait for it to push, then - pin the tag it printed: - ```bash - echo "SHEPHERD_IMAGE=ghcr.io/nullislabs/shepherd:sha-abc1234" >> .env - docker compose -f docker-compose.soak.yml pull - ``` - - Record the resolved image in the evidence set either way: +or publish via CI (`gh workflow run docker.yml --ref develop`), then pin the printed tag and `docker compose -f docker-compose.soak.yml pull`. Record the resolved image: ```bash docker inspect --format '{{.Config.Image}} {{.Image}}' soak-engine \ > docs/operations/soak-reports/image-pin.txt # after `up` ``` ---- - ## Starting the soak ```bash docker compose -f docker-compose.soak.yml up -d -``` - -Docker starts the engine, waits for it to become healthy (up to 90 s), then -starts the snapshotter which immediately captures `metrics-start-.txt`. - -Check it started cleanly: - -```bash docker compose -f docker-compose.soak.yml ps -# Both services should show "Up" and engine shows "(healthy)" +# Both services show "Up"; engine shows "(healthy)". ``` ---- - ## Monitoring -**Follow engine logs live:** +Follow engine logs: ```bash docker compose -f docker-compose.soak.yml logs -f engine ``` -**Filter per-module activity markers:** +Filter per-module markers (`docker logs`, not `docker compose logs`, so jq is not fed the service-name prefix; the JSON formatter flattens fields, message at `.message`): ```bash -# `docker logs` (not `docker compose logs`): compose prefixes every -# line with the service name, which breaks jq. The engine's JSON -# formatter flattens event fields to the top level, so the message -# lives at `.message`. docker logs -f soak-engine \ | jq -r 'select(.message // "" | test("watch:|submitted:|dropped:|backoff:|TRIGGERED")) | .message' ``` -**Check snapshot count:** +Snapshot count (expect one file per completed hour; >= 23 at 24 h): ```bash ls docs/operations/soak-reports/metrics-snap-*.txt | wc -l ``` -Expect one file per completed hour. At 24 h you should see ≥ 23 files. - -**Scrape live metrics:** - -```bash -curl http://127.0.0.1:9100/metrics -``` - -**Check both containers are alive:** - -```bash -docker compose -f docker-compose.soak.yml ps -``` - -**Memory evidence (start once, right after `up`):** - -The Prometheus exporter has no process collector, so `/metrics` carries -no RSS — without this loop there is no data to prove memory stayed flat -across 7 days. Run it on the host: +Memory evidence (start once, right after `up`). The exporter has no process collector, so `/metrics` carries no RSS; this host-side loop is the only memory record: ```bash nohup sh -c 'while true; do @@ -151,18 +71,14 @@ done' >/dev/null 2>&1 & echo $! > docs/operations/soak-reports/.memory-loop.pid ``` ---- - ## Stopping cleanly ```bash -# Capture final metrics before stopping. +# Final metrics before stopping. curl -sf http://127.0.0.1:9100/metrics \ > "docs/operations/soak-reports/metrics-end-$(date -u +%Y%m%dT%H%M%SZ).txt" -# Capture the restart record: RestartCount > 0 means Docker recovered a -# crash during the run — that must be visible in the evidence, not -# discovered by a reviewer noticing counters reset between snapshots. +# Restart record: RestartCount > 0 means Docker recovered a crash; that must be in the evidence. docker inspect soak-engine --format \ 'restarts={{.RestartCount}} started={{.State.StartedAt}} oom={{.State.OOMKilled}}' \ > "docs/operations/soak-reports/engine-state-$(date -u +%Y%m%dT%H%M%SZ).txt" @@ -170,97 +86,54 @@ docker inspect soak-engine --format \ # Stop the host-side memory loop. kill "$(cat docs/operations/soak-reports/.memory-loop.pid)" 2>/dev/null || true -# Bring everything down (SIGINT → graceful shutdown → SIGKILL after 30 s). +# Bring everything down (SIGINT -> graceful shutdown -> SIGKILL after 30 s). docker compose -f docker-compose.soak.yml down ``` ---- +## Evidence artefacts -## Evidence artifacts +All files in `docs/operations/soak-reports/`: -All files in `docs/operations/soak-reports/` constitute the grant evidence: - -| Artifact | Pattern | Purpose | +| Artefact | Pattern | Purpose | |---|---|---| -| Engine log | `engine.log.gz` (see below) | Full operation history | +| Engine log | `engine.log.gz` | Full operation history | | Image pin | `image-pin.txt` | Image + digest the run executed | | Baseline metrics | `metrics-start-.txt` | Counter values at t=0 | -| Hourly snapshots | `metrics-snap-.txt` × N | Hourly Prometheus scrapes | +| Hourly snapshots | `metrics-snap-.txt` x N | Hourly Prometheus scrapes | | Final metrics | `metrics-end-.txt` | Counter values at shutdown | -| Memory samples | `memory.log` | Hourly RSS/CPU — proves no leak | +| Memory samples | `memory.log` | Hourly RSS/CPU | | Restart record | `engine-state-.txt` | RestartCount / OOMKilled | +Save the engine log compressed (uncompressed it approaches the ~7 GB cap; attach the `.gz`, do not commit it): + ```bash -# Save the engine log compressed. Uncompressed it can approach the -# ~7 GB rotation cap — do NOT commit it to the repo; attach the .gz to -# the evidence bundle. Note rotation drops anything past the cap, so -# archive before the run ends if log volume runs high. -docker logs soak-engine 2>&1 | gzip \ - > docs/operations/soak-reports/engine.log.gz +docker logs soak-engine 2>&1 | gzip > docs/operations/soak-reports/engine.log.gz ``` -These satisfy: - -- **M1 (24h):** `metrics-snap-*.txt` files timestamped within 0-24 h of `metrics-start-*.txt`. -- **M2 (48h):** `metrics-snap-*.txt` files timestamped within 0-48 h. -- **M4 (7-day):** The full artifact set above covering ≥ 7 days of uptime. - -To extract the shutdown summary from the log: +Extract the shutdown summary: ```bash -docker compose -f docker-compose.soak.yml logs engine \ - | grep "graceful shutdown complete" | tail -1 +docker compose -f docker-compose.soak.yml logs engine | grep "graceful shutdown complete" | tail -1 ``` ---- - ## Troubleshooting -**Engine container exited early:** +Engine container exited early: ```bash docker compose -f docker-compose.soak.yml logs --tail=100 engine ``` -Common causes: +- OOM kill: `docker inspect soak-engine | jq '.[0].State'`, look for `OOMKilled: true`. Raise the `memory` limit in `docker-compose.soak.yml` or reduce module count. +- RPC errors: look for `connection refused` / `rate limit`. Switch to a paid endpoint. +- WASM trap: look for `module trapped` / `module poisoned`. File a bug. -- OOM kill: `docker inspect soak-engine | jq '.[0].State'` — look for - `OOMKilled: true`. Increase the `memory` limit in `docker-compose.soak.yml` - or reduce module count. -- RPC errors: look for `connection refused` or `rate limit` in the log. - Switch to a paid endpoint with higher rate limits. -- WASM trap: look for `module trapped` or `module poisoned`. File a bug. - -**Snapshotter not producing files:** - -Verify the engine is healthy and the metrics port is up: - -```bash -curl -v http://127.0.0.1:9100/metrics -docker compose -f docker-compose.soak.yml ps -``` - -**Restarting an interrupted run:** - -Both services carry `restart: unless-stopped`, so an engine crash is -recovered automatically — Docker restarts it, the snapshotter keeps -looping through failed scrapes until the engine is healthy again, and -no operator action is needed. Check whether this happened with: - -```bash -docker inspect soak-engine --format 'restarts={{.RestartCount}} oom={{.State.OOMKilled}}' -``` +Snapshotter not producing files: verify the engine is healthy and the metrics port is up (`curl -v http://127.0.0.1:9100/metrics`). -Manual intervention is only needed when the operator stopped the stack -(`down`) or the host rebooted without Docker auto-start: +Restarting an interrupted run: both services carry `restart: unless-stopped`, so an engine crash recovers automatically. Manual intervention is only needed after an operator `down` or a host reboot without Docker auto-start: ```bash docker compose -f docker-compose.soak.yml up -d ``` -The engine's local store is in the `soak-state` Docker volume and -persists across restarts — no state is lost. A new -`metrics-start-.txt` appears only when the snapshotter *container* -restarts (crash or manual `up`), not on engine-only recoveries. -Preserve all files from every run — reviewers can see the combined -coverage, and the `engine-state` snapshot explains any counter resets. +The local store lives in the `soak-state` Docker volume and persists across restarts. A new `metrics-start-.txt` appears only when the snapshotter container restarts, not on engine-only recoveries. Preserve files from every run; the `engine-state` snapshot explains any counter resets. From 15bd93be0a06a55141ca5b1e667f0219bda399a4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:18:35 +0000 Subject: [PATCH 117/139] docs: prune and de-drift deployment and production docs (#605) * docs: prune and de-drift deployment and production docs Trim the deployment and production corpus to the current contract and fix ownership: docs/deployment.md owns the engine.toml reference, module artefact builds, and local runs; docs/production.md owns the production deploy (systemd, backup, observability wiring); docs/deployment/docker.md owns containers; docs/deployment/multi-chain.md owns multi-chain config; docs/06-production-hardening.md owns the hardening design facts and stops restating deploy steps. Each of the others links the owner rather than repeating. Verify every config key, default, metric name, port, and binary against the code on dev/m1: the production binary is shepherd (not nexum-cli), resource caps live under [limits] (not [engine.limits]), the redb file is local-store.redb, the metric prefix is shepherd_* with the eleven-metric surface the runtime actually emits, and the orderbook URL override is the cow-venue adapter's own [config] orderbook-url rather than an engine-side extensions.cow table. Drop the fabricated cli subcommands, epoch mechanism, health endpoint, and nexum_* metric table. Purge em dashes and repo-name and planning drift. * docs: unwrap paragraphs to one logical line for diff-friendliness Hard-wrapped prose churns diffs: a one-word edit reflows the whole paragraph. Join each paragraph onto a single logical line and let it soft-wrap. Content unchanged (word and heading counts preserved); code fences, tables, lists, blockquotes and headings untouched. --- README.md | 90 ++--- docs/06-production-hardening.md | 468 ++---------------------- docs/deployment.md | 247 +++---------- docs/deployment/docker.md | 181 ++------- docs/deployment/multi-chain.md | 252 ++----------- docs/production.md | 627 +++++--------------------------- 6 files changed, 278 insertions(+), 1587 deletions(-) diff --git a/README.md b/README.md index 21aa9c19..95b7bdd4 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,42 @@ # Shepherd -[![CI](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml/badge.svg)](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml) -[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) +[![CI](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml/badge.svg)](https://github.com/nullislabs/shepherd/actions/workflows/ci.yml) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](LICENSE) -**Shepherd is a CoW Protocol-extended [Nexum Runtime](https://github.com/nullislabs): on-chain automation that runs as sandboxed WebAssembly, not scripts.** +Shepherd is a CoW Protocol extension of the [Nexum Runtime](https://github.com/nullislabs): on-chain automation that runs as sandboxed WebAssembly. -The Nexum Runtime executes untrusted automation as WASM components against the `nexum:host` WIT contract. Every module receives exactly the host capabilities it declares in its manifest and nothing more - no ambient filesystem or network. Execution is metered by fuel and epoch, memory-capped, and transactional per event: state commits on success and rolls back on trap. Modules are distributed content-addressed and verified by hash. There is no central service to depend on; you run the node. +The Nexum Runtime executes untrusted automation as WASM components against the `nexum:host` WIT contract. A module receives only the host capabilities it declares in its manifest: no ambient filesystem or network. Execution is metered by fuel and epoch, memory-capped, and transactional per event: state commits on success and rolls back on trap. Modules are content-addressed and verified by hash. There is no central service; you run the node. -Shepherd extends that runtime with `shepherd:cow` - CoW Protocol order APIs and submission - so a TWAP, EthFlow, or ComposableCoW watch-tower is an ordinary module, not a special case baked into the engine. Write the strategy once as a component; the runtime supervises, restarts, meters, and sandboxes it. +Shepherd registers the `videre:venue` platform and bundles the `cow-venue` adapter, so a keeper module submits CoW Protocol orders through the venue registry rather than through engine-baked logic. A keeper watching `ComposableCoW` or `EthFlow` is an ordinary module. -A module built against the universal `nexum:host` world runs on any Nexum-compatible host. A module built against `shepherd:cow` additionally gains CoW Protocol access and requires a Shepherd host. +A module built against `nexum:host` runs on any Nexum-compatible host. CoW order submission additionally needs a host that registers the venue platform, which the `shepherd` binary does. -> **Pre-release** and under active development. Testnets and lab environments only. - -Looking for the org? See **[github.com/nullislabs](https://github.com/nullislabs)**. - ---- - -## Why - -- **WASM Component Model, not a plugin API** - a WIT-typed host/guest contract with structural isolation and multi-language guests (Rust today; anything that compiles to a component next). -- **Capability-scoped by construction** - a module sees only the host primitives it declares. No filesystem; outbound HTTP only against a per-module allowlist (`wasi:http`); WASI clocks and randomness are linked in ambiently. -- **Metered and transactional** - per-event fuel and epoch limits, a memory cap, and all-or-nothing state. A runaway module cannot starve its neighbours or corrupt its store. -- **Declarative subscriptions** - modules declare their block, log, and cron events in a manifest; the runtime wires and multiplexes the sources. -- **Content-addressed distribution** - modules are fetched by hash (Swarm, IPFS, OCI, HTTPS) and integrity-checked before they load. -- **Self-hosted** - one binary, your keys, your RPC. No centralised dependency. - ---- +> Pre-release, under active development. Testnets and lab environments only. ## Layout | Path | Purpose | | --- | --- | -| `crates/nexum-runtime/` | The **engine** - the Nexum Runtime's reference host: a wasmtime implementation of the `nexum:host` contract. | -| `crates/nexum-launch/` | The generic launcher library - shared CLI, config load, tracing, and the preset-driven launch. | -| `crates/nexum-cli/` | The bare `nexum` binary - the core lattice with no extension payload. | -| `crates/shepherd/` | The `shepherd` binary - the cow composition root registering the videre venue platform. | -| `crates/nexum-sdk/` | Generic guest SDK - the host trait seam, bind macro, chain/config/address helpers, wasi:http `fetch`, and tracing facade for any module. | -| `wit/nexum-host/` | The **`nexum:host`** WIT package - the host/guest contract every engine implements and every module imports. | -| `wit/shepherd-cow/` | The `shepherd:cow` WIT package - the CoW event ABIs of record. | -| `modules/` | Guest modules - TWAP and EthFlow watch-towers, examples, and test fixtures. | +| `crates/nexum-runtime/` | The engine: a wasmtime host implementing the `nexum:host` contract. | +| `crates/nexum-launch/` | Launcher library: shared CLI, config load, tracing, preset launch. | +| `crates/nexum-cli/` | The bare `nexum` binary: the core lattice, no extension payload. | +| `crates/shepherd/` | The `shepherd` binary: the cow composition root registering the videre venue platform and the Prometheus add-on. | +| `crates/nexum-sdk/` | Guest SDK: host trait seam, bind macro, chain/config/address helpers, `wasi:http` fetch, tracing facade. | +| `crates/videre-sdk/` | Venue-platform SDK: the `videre:venue` client and adapter contracts. | +| `crates/cow-venue/` | The bundled CoW venue adapter component. | +| `wit/nexum-host/` | The `nexum:host` WIT package: the host/guest contract. | +| `wit/videre-venue/` | The `videre:venue` WIT package: the venue-adapter contract. | +| `wit/shepherd-cow/` | `cow-events.wit`: the CoW event ABIs of record. | +| `modules/` | Guest modules: TWAP and EthFlow keepers, examples, and test fixtures. | | `docs/` | Architecture and design notes. Start with [`docs/00-overview.md`](docs/00-overview.md). | -> **Engine vs. host.** An *engine* is a concrete implementation that runs WASM components (today `nexum`, a wasmtime daemon). The `nexum:host` WIT package is the *contract* - the host imports a guest sees. Other engines (mobile, browser) can implement the same contract, and modules built against it run on any compliant engine. - ---- +An engine is a concrete implementation that runs WASM components (`nexum`, a wasmtime daemon). The `nexum:host` WIT package is the contract. Modules built against it run on any compliant engine. ## Build from source Shepherd uses [Nix](https://nixos.org/) flakes to pin the toolchain and [just](https://github.com/casey/just) as the task runner. ```sh -nix develop # enter the dev shell (Rust, wasm-tools, just, ...) +nix develop # dev shell (Rust, wasm-tools, just) just build # build the engine and the example module just run # run the engine against the example module just test # unit tests @@ -59,55 +44,30 @@ just test # unit tests Without Nix you need Rust (edition 2024), the `wasm32-wasip2` target, and `wasm-tools`. ---- - ## Running -Single module (development): +Development, a single module against a synthetic event: ```sh nexum [] ``` -Multi-module (production) - `engine.toml` declares RPC endpoints, the state directory, and a `[[modules]]` list: +Production, an `engine.toml` declaring chains, state directory, modules, and adapters: ```sh -nexum --engine-config engine.toml -``` - -A module's own `module.toml` declares its capabilities and event subscriptions: - -```toml -[module] -name = "twap-monitor" -version = "0.1.0" - -[capabilities] -required = ["chain", "local-store", "client"] -optional = ["http"] - -[[subscription]] -kind = "chain-log" -chain_id = 1 -address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" # ComposableCoW +shepherd --engine-config engine.toml ``` -See [`docs/`](docs) for the full schema and the design corpus - start with [`docs/00-overview.md`](docs/00-overview.md). - ---- +See [`docs/deployment.md`](docs/deployment.md) for the `engine.toml` reference and [`docs/production.md`](docs/production.md) for the production deploy. ## Contributing -Open an issue before non-trivial PRs - this is a pre-release codebase under active churn. Conventional Commits. CI runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and per-module `wasm32-wasip2` builds. +Open an issue before non-trivial PRs. Conventional Commits. CI runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`, and per-module `wasm32-wasip2` builds. ## Security -Capability sandboxing, key handling, and order signing are security-critical. Please report vulnerabilities privately rather than in public issues. +Capability sandboxing, key handling, and order signing are security-critical. Report vulnerabilities privately rather than in public issues. ## License AGPL-3.0-or-later © Nullis Labs LLC and contributors. See [LICENSE](LICENSE). - -``` -● AGPL-3.0 · pre-release · Nexum Runtime -``` diff --git a/docs/06-production-hardening.md b/docs/06-production-hardening.md index e1944384..1b66b4c9 100755 --- a/docs/06-production-hardening.md +++ b/docs/06-production-hardening.md @@ -1,473 +1,59 @@ -# Production Hardening & Observability +# Production hardening -## Resource Enforcement +The design facts behind the runtime's resource enforcement, restart policy, RPC resilience, and error model. Deploy procedures live in [`docs/production.md`](./production.md); the metric surface and alert rules in [`docs/production.md`](./production.md) §6-7; containers in [`docs/deployment/docker.md`](./deployment/docker.md). -Four resource dimensions are capped per module. **In 0.2, caps come from compile-time global defaults** (`DEFAULT_FUEL_PER_EVENT = 1_000_000_000`, `DEFAULT_MEMORY_LIMIT = 64 MiB` in `crates/nexum-runtime/src/runtime/limits.rs`); per-module overrides via the manifest's `[module.resources]` section are a future direction (0.3). The mechanism and shape below describe what the 0.2 engine actually enforces - using global values where this doc previously read `module_config.max_*`. +## Resource enforcement -### CPU: Fuel +Four dimensions are capped per module. Caps come from `[limits]` in `engine.toml`, resolving to built-in defaults (`crates/nexum-runtime/src/engine_config.rs`). They apply uniformly to every module; per-module overrides land in 0.3. -Each `on_event` call is budgeted `DEFAULT_FUEL_PER_EVENT` fuel units. Exhaustion traps the call (state rolled back -- see doc 04). The budget prevents infinite loops and excessive computation. +### Fuel -```rust -store.set_fuel(DEFAULT_FUEL_PER_EVENT)?; -``` +Each `on_event` call is granted `fuel_per_event` fuel (default 1_000_000_000, ~1s of compute). Exhaustion traps the call and rolls back its state (doc 04). Fuel is deterministic: the same WASM consumes the same fuel regardless of host speed. It meters only guest instructions. -Fuel is deterministic -- the same WASM code consumes the same fuel regardless of host machine speed. +### Wall-clock deadline -### CPU: Epoch (wall-clock) - -A background Tokio task increments the engine epoch on a fixed interval (e.g. 100ms). Each module store has a deadline: if a callback exceeds N epochs, it yields back to the Tokio runtime. This prevents one module from starving others even if fuel is generous. - -```rust -// Runtime startup -let engine = engine.clone(); -tokio::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_millis(100)); - loop { - interval.tick().await; - engine.increment_epoch(); - } -}); - -// Per-module store setup -store.epoch_deadline_async_yield_and_update(10); // yield after 10 epochs (~1s) -``` +Fuel does not meter time spent in host calls (chain RPC, HTTP, redb). A per-dispatch wall-clock deadline (`event_deadline_secs`, default 120, floor 1) is the backstop: the supervisor runs each dispatch under a `tokio::time::timeout`, and a dispatch that outlives it, guest plus every awaited host call, is cancelled and the module marked dead. Fuel and the deadline are complementary, not redundant. ### Memory -The engine builds a `wasmtime::StoreLimitsBuilder` with `DEFAULT_MEMORY_LIMIT` and attaches it to each module's store at dispatch time (see `crates/nexum-runtime/src/supervisor.rs`). `memory.grow` is denied past the cap. Future direction: a per-module `ResourceLimiter` driven by `module_config.max_memory_bytes` from the manifest; not in 0.2 scope. +The supervisor attaches a `wasmtime::StoreLimitsBuilder` built from `memory_bytes` (default 64 MiB) to each module store; `memory.grow` past the cap is denied. ### Storage -Local-store quota is enforced on the `local-store::set` host path. The 0.2 engine treats the cap as a host-side constant; a manifest-driven `max_state_bytes` is future direction. Rejected with a clear `fault` (see doc 04), not a trap -- the module can handle it gracefully. - -### Summary +The local-store byte quota (`state_bytes`, default 50 MiB) is enforced on the `local-store::set` host path. Overrun is rejected with a `fault.invalid-input`, not a trap, so the module can handle it. | Resource | Mechanism | Failure mode | |----------|-----------|-------------| -| CPU (deterministic) | Fuel | Trap -> rollback -> restart | -| CPU (wall-clock) | Epoch interruption | Yield -> resume or trap | -| Memory | `ResourceLimiter` | `memory.grow` returns -1 | +| CPU (guest instructions) | Fuel | Trap, rollback, restart | +| Wall-clock | Per-dispatch tokio timeout | Cancel, module marked dead | +| Memory | `StoreLimits` | `memory.grow` denied | | Storage | Host-side byte tracking | `local-store::set` returns `fault.invalid-input` | -## Crash Handling & Restart Policy - -### Restart with Exponential Backoff - -When a module's `on_event` (or `init`) traps or returns `Err`: - -``` -Attempt 1: restart after 1s -Attempt 2: restart after 2s -Attempt 3: restart after 4s -Attempt 4: restart after 8s -... -Attempt N: restart after min(2^(N-1), 300)s <- capped at 5 minutes -``` - -A successful `on_event` resets the backoff counter to zero. - -```rust -struct RestartPolicy { - consecutive_failures: u32, - max_backoff: Duration, // 5 minutes - base: Duration, // 1 second -} - -impl RestartPolicy { - fn next_backoff(&self) -> Duration { - let backoff = self.base * 2u32.saturating_pow(self.consecutive_failures - 1); - backoff.min(self.max_backoff) - } - - fn record_success(&mut self) { - self.consecutive_failures = 0; - } - - fn record_failure(&mut self) { - self.consecutive_failures += 1; - } -} -``` - -### Poison Pill Detection - -A module that crashes on every event is a poison pill. After `max_consecutive_failures` (default: 10), the module transitions to `Dead` state: - -```mermaid -flowchart TD - A["Consecutive failures >= 10"] --> B["Module state -> Dead"] - B --> C["All event dispatch stops"] - B --> D["Alert emitted (metric + log)"] - B --> E["Requires manual intervention"] - E --> F["nexum module restart twap-monitor"] - E --> G["nexum module reload twap-monitor\n(re-fetch + recompile)"] -``` - -The threshold is configurable per-module in the manifest under `[module.restart]` (separate from resource caps): - -```toml -[module.restart] -max_consecutive_failures = 10 -``` - -### Restart Scope - -A restart creates a fresh `Store` (clean WASM memory) but reuses the `InstancePre` (no recompilation). The module's `init` is called again. Local-store data persists (doc 04). - -## RPC Resilience - -All RPC I/O flows through alloy providers configured by the runtime operator. The `chain::request` host function (see doc 07) forwards to the provider, which is wrapped with resilience layers using alloy's tower-based middleware. The additive `chain::request-batch` (0.2) routes alloy's `RequestPacket::Batch` to actually batch on the wire. - -### Provider Stack +## Restart policy -```mermaid -flowchart TD - A["Module calls chain::request\n(via alloy Provider in SDK)"] --> B["Host chain::request impl\n-> alloy Provider"] - B --> C["Timeout\n(10s default)"] - C --> D["Retry\n(3 attempts, exponential\nbackoff + jitter)"] - D --> E["Rate Limit\n(per-endpoint)"] - E --> F["Fallback\n(primary -> secondary)"] - F --> G["RPC Endpoint"] +When a module's `init` or `on_event` traps or returns `Err`, the supervisor restarts it after an exponential backoff: 1s, 2s, 4s, 8s, doubling to a 300s (5 min) cap (`runtime/restart_policy.rs`). A restart creates a fresh `Store` (clean WASM memory) but reuses the compiled `InstancePre`; `init` runs again and local-store data persists (doc 04). A successful dispatch resets the counter. - subgraph Tower Layer Stack - C - D - E - F - end -``` +A module that keeps trapping is a poison pill. After `max_failures` traps within a sliding `window_secs` (`[limits.poison]`, default 5 / 600s in `runtime/poison_policy.rs`), the module is quarantined: dispatch to it stops, the `shepherd_module_poisoned` gauge goes to `1`, and a WARN is logged. Quarantine clears only on an engine restart. Venue adapters follow the same policy under the `shepherd_adapter_poisoned` gauge. -### Operator Configuration +## RPC resilience -```toml -[[chains]] -chain_id = 42161 -name = "arbitrum" +RPC I/O flows through one alloy provider per chain, opened from `engine.toml` at boot (`crates/nexum-runtime/src/host/provider_pool.rs`). Each chain has a single `rpc_url`; there is no secondary-endpoint failover. The `chain::request` host function forwards the typed method to the provider. -[[chains.endpoints]] -url = "wss://arb-mainnet.g.alchemy.com/v2/KEY" -priority = 1 +Two layers harden it: -[[chains.endpoints]] -url = "https://arb1.arbitrum.io/rpc" -priority = 2 # fallback +- A transport `RetryBackoffLayer` (10 retries, 300ms base backoff, 100 CU/s pacing) heals transient node blips below the poller, so a momentary hiccup does not force a stream re-open. +- A per-request timeout (`request_timeout_secs`, default 30) bounds each `chain::request`; it does not apply to the long-lived subscription and log-poller streams. -[chains.rpc_policy] -timeout_ms = 10_000 -max_retries = 3 -rate_limit_per_second = 50 -``` +Block following uses `eth_subscribe(newHeads)` on a WebSocket URL and polls `eth_getBlockByNumber` on HTTP; logs poll `eth_getLogs` on either transport. On a dropped WebSocket the event loop reconnects with backoff, then backfills the gap between the last dispatched block and head so modules do not silently miss events. A `resume` chain-log subscription persists its cursor under `last_dispatched_block:{chain_id}` and resumes from it across restarts. -### Subscription Reconnection +## Error model -WebSocket subscriptions (`eth_subscribe`) drop when the connection is lost. The event source manager detects this and reconnects: +Host interfaces surface a common `fault` variant (`wit/nexum-host/types.wit`): `unsupported`, `unavailable`, `denied`, `rate-limited` (carrying `retry-after-ms` guidance), `timeout`, `invalid-input`, `internal`. A fault is a typed, recoverable return the guest can handle; a trap (fuel, memory, panic) is not, and drives the restart path above. -```mermaid -flowchart TD - A["Subscription drops"] --> B["Reconnect with backoff\n(1s, 2s, 4s, ... 30s cap)"] - B --> C["On reconnect: check for missed blocks"] - C --> D["Query eth_blockNumber"] - C --> E["Compare with last dispatched block"] - C --> F["Backfill missed blocks\nvia eth_getBlockByNumber"] - F --> G["Resume live subscription"] -``` +## Structured logging -This ensures modules don't silently miss events during RPC outages. - -## Structured Logging - -### Stack: `tracing` + `tracing-subscriber` - -All runtime logging uses the `tracing` crate with structured fields, output as JSON in production: - -```rust -use tracing::{info, warn, error, instrument, Span}; - -#[instrument(skip(store), fields(module = %module_id, chain_id))] -async fn dispatch_event(module_id: &str, event: &Event, store: &mut Store) { - info!(event_type = %event.type_name(), "dispatching event"); - // ... -} -``` - -### Log Contexts - -Every log line includes: - -| Field | Source | -|-------|--------| -| `module` | Module name from manifest | -| `chain_id` | Chain the event originated from | -| `event_type` | `block` / `logs` / `tick` / `message` | -| `block_number` | For block/log events | -| `level` | trace / debug / info / warn / error | -| `timestamp` | ISO 8601 | -| `span_id` | Tracing span (correlates related logs) | - -### Module Guest Logs - -When a module calls `logging::log(level, message)`, the host writes a `tracing` event tagged with the module's context: - -```rust -impl logging::Host for NexumHostState { - fn log(&mut self, level: Level, message: String) { - let span = tracing::info_span!("module", module = %self.module_id); - let _enter = span.enter(); - match level { - Level::Trace => tracing::trace!("{message}"), - Level::Debug => tracing::debug!("{message}"), - Level::Info => tracing::info!("{message}"), - Level::Warn => tracing::warn!("{message}"), - Level::Error => tracing::error!("{message}"), - } - } -} -``` - -### Output Formats - -| Environment | Format | Config | -|-------------|--------|--------| -| Development | Pretty, coloured | `RUST_LOG=nexum=debug` | -| Production | JSON, one line per event | `--log-format json` | - -```json -{ - "timestamp": "2026-02-18T12:00:00.123Z", - "level": "INFO", - "module": "twap-monitor", - "chain_id": 42161, - "event_type": "block", - "block_number": 19000001, - "message": "posted TWAP part 3/10", - "span_id": "abc123" -} -``` +All runtime logging uses `tracing` with structured fields, emitted as JSON in production (or the pretty format under `--pretty-logs`). Every per-module event carries the module name and, on chain events, the `chain_id` and `block_number`. When a guest calls `logging::log`, the host writes a `tracing` event tagged with the module's context. Log operations (retention, aggregation) are in [`docs/production.md`](./production.md) §5. ## Metrics -### Stack: `metrics` crate + Prometheus exporter - -The `metrics` crate provides a facade (like `log` for logging). We use `metrics-exporter-prometheus` to expose a `/metrics` HTTP endpoint. - -> Note: The OpenTelemetry Prometheus exporter crate is deprecated. The `metrics` + `metrics-exporter-prometheus` combo remains the simplest, most stable path for Prometheus scraping. - -### Metric Definitions - -#### Runtime-level - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_modules_loaded` | Gauge | -- | Number of modules currently in Run state | -| `nexum_modules_dead` | Gauge | -- | Number of modules in Dead state | -| `nexum_uptime_seconds` | Counter | -- | Runtime uptime | -| `nexum_content_fetch_total` | Counter | `scheme` | Content store fetches by scheme | -| `nexum_content_fetch_errors` | Counter | `scheme` | Content store fetch failures | - -#### Per-module - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_events_dispatched_total` | Counter | `module`, `event_type` | Events dispatched | -| `nexum_events_processed_total` | Counter | `module`, `event_type` | Events successfully processed | -| `nexum_events_failed_total` | Counter | `module`, `event_type` | Events that trapped or returned Err | -| `nexum_event_duration_seconds` | Histogram | `module`, `event_type` | Wall-clock time per on_event call | -| `nexum_fuel_consumed` | Histogram | `module` | Fuel consumed per on_event call | -| `nexum_restarts_total` | Counter | `module` | Total restart count | -| `nexum_consecutive_failures` | Gauge | `module` | Current consecutive failure count | -| `nexum_state_bytes_used` | Gauge | `module` | Current local-store usage in bytes | -| `nexum_memory_bytes_used` | Gauge | `module` | Current WASM linear memory size | - -#### Per-chain RPC - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_rpc_requests_total` | Counter | `chain_id`, `method` | RPC calls made | -| `nexum_rpc_errors_total` | Counter | `chain_id`, `method`, `endpoint` | RPC errors | -| `nexum_rpc_duration_seconds` | Histogram | `chain_id`, `method` | RPC call latency | -| `nexum_rpc_fallbacks_total` | Counter | `chain_id` | Times a fallback endpoint was used | -| `nexum_subscription_reconnects_total` | Counter | `chain_id` | Subscription reconnection count | -| `nexum_blocks_behind` | Gauge | `chain_id` | Blocks behind head (0 = caught up) | - -#### Identity - -| Metric | Type | Labels | Description | -|--------|------|--------|-------------| -| `nexum_identity_sign_total` | Counter | `module`, `account` | Signing operations performed | -| `nexum_identity_errors_total` | Counter | `module`, `error_code` | Identity operation failures | - -### Exposition - -```toml -[metrics] -enabled = true -listen = "0.0.0.0:9090" -path = "/metrics" -``` - -```bash -curl http://localhost:9090/metrics -# HELP nexum_events_dispatched_total Events dispatched to modules -# TYPE nexum_events_dispatched_total counter -nexum_events_dispatched_total{module="twap-monitor",event_type="block"} 150234 -``` - -## Health Checks - -> **Future direction, not in 0.2 scope.** A dedicated `:8080/health` JSON endpoint is described below as design intent for 0.3. The 0.2 engine does **not** bind a separate health port; liveness is signalled by the metrics scrape (`:9100/metrics` returns 200 iff the engine is running and the Prometheus exporter is up) and by the structured `tracing` JSON on stdout (per-module state transitions and quarantine events). Docker / compose configurations use a TCP/bash health probe against the metrics port (see [`docs/deployment/docker.md`](deployment/docker.md)). - -### HTTP Health Endpoint (future direction) - -``` -GET /health -> 200 OK | 503 Service Unavailable -``` - -Intended response shape: - -```json -{ - "status": "healthy", - "uptime_seconds": 86400, - "modules": { - "twap-monitor": { "state": "running", "last_event_age_seconds": 2 }, - "ethflow-watcher": { "state": "running", "last_event_age_seconds": 5 } - }, - "chains": { - "42161": { "connected": true, "head_block": 19000500, "blocks_behind": 0 }, - "1": { "connected": true, "head_block": 21500000, "blocks_behind": 0 } - } -} -``` - -When this endpoint lands, health would be `unhealthy` if: -- Any required chain's RPC is disconnected. -- Any module is in `Dead` state. -- Last event age exceeds a configurable staleness threshold (suggests subscription dropped and backfill failed). - -```toml -[health] -listen = "0.0.0.0:8080" -stale_event_threshold_seconds = 60 -``` - -### Docker / Kubernetes (0.2 today) - -Today the metrics endpoint and the supervisor `tracing` stream cover the liveness signal. A TCP probe against the metrics port works as a liveness check (see `docker-compose.yml` and `docs/deployment/docker.md` for the shipped configuration). Once the dedicated `:8080/health` endpoint lands, the recommended probe shape would be: - -```yaml -livenessProbe: - httpGet: - path: /health - port: 8080 - periodSeconds: 10 -readinessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 5 -``` - -## Alerting Conditions - -The runtime itself doesn't send alerts -- it exposes metrics and health for external systems (Prometheus + Alertmanager, Grafana, PagerDuty, etc). Recommended alert rules: - -| Condition | Severity | Prometheus Expression | -|-----------|----------|-----------------------| -| Module entered Dead state | Critical | `nexum_modules_dead > 0` | -| High event failure rate | Warning | `rate(nexum_events_failed_total[5m]) / rate(nexum_events_dispatched_total[5m]) > 0.1` | -| Event processing latency spike | Warning | `histogram_quantile(0.99, nexum_event_duration_seconds) > 5` | -| RPC endpoint down | Critical | `nexum_rpc_errors_total` sustained increase with no successes | -| Chain falling behind | Warning | `nexum_blocks_behind > 10` | -| State store near quota | Warning | `nexum_state_bytes_used / nexum_state_bytes_limit > 0.9` | -| Subscription reconnect storm | Warning | `rate(nexum_subscription_reconnects_total[5m]) > 1` | -| Identity signing failures | Warning | `rate(nexum_identity_errors_total[5m]) > 0.5` | - -## Runtime Configuration Summary - -```toml -# -- Logging -- -[logging] -format = "json" # "json" | "pretty" -level = "info" # default filter level -module_level = "debug" # filter for module guest logs - -# -- Metrics -- -[metrics] -enabled = true -listen = "0.0.0.0:9090" -path = "/metrics" - -# -- Health (future direction; not bound in 0.2) -- -# [health] -# listen = "0.0.0.0:8080" -# stale_event_threshold_seconds = 60 - -# -- Epoch ticker -- -[runtime] -epoch_interval_ms = 100 -epoch_deadline = 10 # epochs before yield (~1s) - -# -- Resource defaults -- -# In 0.2 these come from compile-time constants in -# crates/nexum-runtime/src/runtime/limits.rs (DEFAULT_FUEL_PER_EVENT = 1B, -# DEFAULT_MEMORY_LIMIT = 64 MiB). Manifest-driven per-module overrides are -# a future direction (0.3). - -# -- Global restart defaults -- -# In 0.2 the restart policy is the global exponential backoff (1s -> 2s -> -# ... cap 5 min) defined in runtime/restart_policy.rs and the poison -# threshold (POISON_MAX_FAILURES = 5 within 600 s) in runtime/poison_policy.rs. -# Per-module overrides via [module.restart] are a future direction. -``` - -## Deployment: Docker - -The shipped Dockerfile lives at the repo root; see [`docs/deployment/docker.md`](deployment/docker.md) for the canonical operator-facing configuration. The shape is a multi-stage build with `tini` PID1, a non-root `shepherd` user, the five reference modules baked in, and the metrics port exposed: - -```dockerfile -FROM rust:1.96-slim-bookworm AS builder -# ... cargo build --release -p nexum-cli + module wasm builds ... - -FROM debian:bookworm-slim -COPY --from=builder /build/target/release/nexum /usr/local/bin/ -EXPOSE 9100 # metrics -ENTRYPOINT ["tini", "--", "nexum"] -``` - -```bash -docker run -d \ - -v /etc/shepherd:/etc/shepherd \ - -v /var/shepherd:/var/shepherd \ - -p 9100:9100 \ - shepherd:latest -``` - -Volumes: -- `/etc/shepherd/` -- engine config, module manifests. -- `/var/shepherd/` -- local-store (`state.redb`), content cache, logs. - -## Operational Runbook (CLI) - -```bash -# List loaded modules and their state -nexum module list - -# Restart a dead or failed module -nexum module restart twap-monitor - -# Reload a module (re-fetch wasm, recompile) -nexum module reload twap-monitor - -# Purge a module's local-store -nexum state purge --module twap-monitor - -# Compact the state database -nexum state compact - -# Check runtime health -nexum health - -# Dump metrics -nexum metrics -``` +The runtime records through the `metrics` crate facade. The `shepherd` binary installs a `metrics-exporter-prometheus` exporter (the Prometheus add-on) that binds `/metrics` on `[engine.metrics] bind_addr` when `enabled = true`; the bare `nexum` binary installs the recorder but binds no listener. The runtime emits no alerts itself. The metric surface and recommended alert rules are in [`docs/production.md`](./production.md) §6-7. diff --git a/docs/deployment.md b/docs/deployment.md index 88213615..c430ceaa 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,255 +1,124 @@ # Deploying Shepherd -This guide covers the **operator** side - running a `nexum` -instance against a fleet of WASM modules. For module-author topics -(building a module from scratch, writing tests, packaging) see the -[SDK overview](./sdk.md) and the [first-module -tutorial](./tutorial-first-module.md). +The operator side: the `engine.toml` reference, building module artefacts, and local runs. For the production deploy (systemd, backup, observability) see [`docs/production.md`](./production.md); for containers see [`docs/deployment/docker.md`](deployment/docker.md); for multiple chains see [`docs/deployment/multi-chain.md`](deployment/multi-chain.md). Module-author topics are in the [SDK overview](./sdk.md) and the [first-module tutorial](./tutorial-first-module.md). ## What an operator runs -A Shepherd deployment is one or more `nexum` processes, each -pointed at: +A deployment is one or more engine processes, each pointed at: -1. an `engine.toml` describing the local environment (chain RPCs, - resource caps, where state lives); -2. one or more `[[modules]]` entries listing `.wasm` artefacts and - their `module.toml` manifests; -3. a `state_dir` the engine creates / owns (the redb local-store - database). +1. an `engine.toml` describing the local environment (chain RPCs, resource caps, state directory); +2. `[[modules]]` entries listing `.wasm` artefacts and their `module.toml` manifests; +3. `[[adapters]]` entries listing venue-adapter components (the bundled `cow-venue` adapter); +4. a `state_dir` the engine creates and owns (the redb local-store). -Modules are statically declared in `engine.toml`. The engine does -not pull them from a registry today; you ship the `.wasm` files -alongside the binary and reference them by path. +CoW order submission needs the venue platform, so run the `shepherd` binary, not the bare `nexum`. Modules and adapters are declared statically; the engine does not pull them from a registry today. ## `engine.toml` reference +`engine.example.toml` at the repo root is the annotated template. The shape: + ```toml [engine] -# Directory the local-store redb file (and future engine artefacts) -# will be created under. Created automatically at boot. +# Local-store redb directory, created at boot. state_dir = "./data" - -# `tracing_subscriber::EnvFilter`-compatible directive. `RUST_LOG` -# overrides at process start. +# `tracing_subscriber::EnvFilter` directive; `RUST_LOG` overrides at start. log_level = "info" -# Resource caps applied to every module store at instantiation. -# wasmtime traps a module that overruns either; the supervisor then -# logs and continues on the next event. -[engine.limits] -# Fuel budget granted before every `on_event` invocation. -# 1 unit ~ 1 wasm instruction. 1 billion ~ ~1 second of pure compute. -fuel_per_event = 1_000_000_000 -# Per-dispatch wall-clock backstop, in seconds; bounds host-call time fuel does not meter. Default 120. -event_deadline_secs = 120 -# Linear-memory ceiling per module, in bytes. Default 64 MiB. -memory_bytes = 67_108_864 - -# One [chains.] table per chain the engine should be able to -# reach. Chain ids are EVM decimal. -# -# ws:// + wss:// — alloy pubsub transport (REQUIRED for the -# eth_subscribe-backed [[subscription]] kinds: -# `block`, `log`). -# http:// + https:// — HTTP transport; request/response only, -# no subscriptions. -# -# Mix and match: a chain used only for eth_call (e.g. a Chainlink -# oracle module) can be HTTP; chains carrying log subscriptions -# need WebSocket. - +[engine.metrics] +# Prometheus exporter. Disabled unless enabled = true (bare `nexum` never binds it). +enabled = true +bind_addr = "127.0.0.1:9100" + +# Per-module wasmtime resource caps. Every field is optional; omitted +# values resolve to built-in defaults. Applies uniformly to every module. +[limits] +fuel_per_event = 1_000_000_000 # ~1s of pure compute; wasmtime traps on exhaustion +event_deadline_secs = 120 # wall-clock backstop for unmetered host-call time (min 1) +memory_bytes = 67_108_864 # 64 MiB linear-memory ceiling +state_bytes = 52_428_800 # 50 MiB local-store quota + +# One [chains.] per chain, keyed by EVM decimal id. `ws://`/`wss://` +# engage the pubsub transport (blocks push via eth_subscribe); `http://`/ +# `https://` poll (blocks via eth_getBlockByNumber, logs via eth_getLogs). +# Both work; prefer wss:// where the provider offers it. [chains.1] rpc_url = "https://ethereum-rpc.publicnode.com" - -[chains.100] -rpc_url = "https://rpc.gnosischain.com" +# request_timeout_secs = 30 # per-request JSON-RPC timeout (default 30, 0 rejected at boot) [chains.11155111] rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" - -[chains.42161] -rpc_url = "https://arb1.arbitrum.io/rpc" - -# Extension-owned tables. The engine hands each [extensions.] -# table to the matching extension verbatim; the engine itself never -# interprets them. The cow-api extension reads per-chain orderbook -# base URL overrides here - chains without an entry use the canonical -# api.cow.fi URL. Point this at a staging/barn instance or a local -# mock (tools/orderbook-mock for the load test). -[extensions.cow.orderbook_urls] -# 11155111 = "http://localhost:9999" ``` -### `[[modules]]` entries +The full `[limits.*]` subtables (`http`, `chain`, `logs`, `poison`, `dispatch`, `quota`, `watch`) are documented inline in `engine.example.toml`. -> 0.2 takes the module path + manifest as positional CLI args (a -> single module per engine process). The multi-module -> `[[modules]]` array is shipped by the supervisor work in nullislabs/shepherd PR #9. - -Once the supervisor PR lands, the syntax is: +### `[[modules]]` and `[[adapters]]` ```toml [[modules]] -name = "twap-monitor" -wasm = "modules/twap-monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +path = "modules/twap-monitor.wasm" +manifest = "modules/twap-monitor/module.toml" # defaults to module.toml beside `path` -[[modules]] -name = "ethflow-watcher" -wasm = "modules/ethflow-watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.toml" +http_allow = ["api.cow.fi"] # outbound wasi:http allowlist the operator grants ``` +The orderbook base URL is not an engine setting: the `cow-venue` adapter reads it from its own `module.toml` `[config]` (`chain`, and optional `orderbook-url` to point at a barn or mock). See [`docs/deployment/multi-chain.md`](deployment/multi-chain.md). + ## Building module `.wasm` artefacts -Modules compile to the `wasm32-wasip2` target. Add the target once -per dev machine: +Modules compile to `wasm32-wasip2`. Add the target once: ```sh rustup target add wasm32-wasip2 ``` -Then build release artefacts from the workspace root: +Build release artefacts from the workspace root: ```sh cargo build --target wasm32-wasip2 --release \ -p twap-monitor -p ethflow-watcher +cargo build --target wasm32-wasip2 --release -p cow-venue --features adapter ``` -The `.wasm` files land in -`target/wasm32-wasip2/release/{twap_monitor,ethflow_watcher}.wasm`. -Copy them to wherever your `engine.toml` points (typical: -`./modules/` next to the binary). - -Size sanity check after a build (CI guards regression): +Artefacts land in `target/wasm32-wasip2/release/*.wasm`. Copy them to wherever `engine.toml` points. CI guards a size regression: ```sh ls -lh target/wasm32-wasip2/release/*.wasm ``` -The M2 modules sit at 270–310 KB optimised. Sudden +10× growth -usually means a fresh dependency landed in the wasm graph — review -`cargo tree -p --target wasm32-wasip2` to confirm. - -## Single-binary local runs +## Local runs -The 0.2 engine ships as the `nexum` binary. From the -workspace root, dispatch a module against a test event: +Build and run the `shepherd` binary against an `engine.toml`: ```sh -cargo run -p nexum-cli -- \ - target/wasm32-wasip2/release/twap_monitor.wasm \ - modules/twap-monitor/module.toml +cargo run -p shepherd -- --engine-config engine.toml ``` -On a fresh checkout, the engine creates `./data/local-store.redb`, -opens RPC providers for the chains in `engine.toml`, loads the -component, calls `init`, and dispatches a synthetic block event. -Console output is `tracing` JSON (or pretty if you set -`RUST_LOG=info,nexum_runtime=debug`). - -For systemd-style production runs, see `docs/production.md`. - -## Docker - -A `Dockerfile` + `docker-compose.yml` ship at the repo root. See -[`docs/deployment/docker.md`](deployment/docker.md) for the full -container workflow. The quick start: +The single-module shortcut takes positional paths and synthesizes a one-module config: ```sh -cp .env.example .env -$EDITOR .env # paste wss:// RPC URLs -docker compose up -d -``` - -The image is published to -`ghcr.io/nullislabs/shepherd:` on every merged commit to `main`. - -Mount the `state_dir` as a volume so the redb file survives container -restarts. - -## Observability - -### Logs - -Every host backend logs through `tracing`. Set `RUST_LOG` to filter: - -```sh -RUST_LOG=info,nexum_runtime=debug,nexum_runtime::host::cow_orderbook=trace \ - cargo run -p nexum-cli -- ... -``` - -Recommended baseline for production: - -``` -RUST_LOG=info,nexum_runtime::host=debug -``` - -The structured-logging audit consolidates the field set -across every dispatch / state change / submission path so a single -JSON grep reconstructs each order's timeline. - -### Prometheus metrics - -A planned metrics exporter wires a `metrics-exporter-prometheus` endpoint at -`engine.toml::[engine.metrics].bind_addr` (default -`127.0.0.1:9100`). Once it lands, scrape with: - -```yaml -scrape_configs: - - job_name: shepherd - static_configs: - - targets: ['shepherd-host:9100'] +cargo run -p shepherd -- \ + target/wasm32-wasip2/release/twap_monitor.wasm \ + modules/twap-monitor/module.toml ``` -Suggested Grafana panels (dashboard JSON planned): - -- Module uptime — `shepherd_module_uptime_seconds{module}` -- Event latency p50 / p95 / p99 — - `shepherd_event_latency_seconds{module}` -- Submit success rate — - `rate(shepherd_cow_api_submit_total{outcome="success"}[5m])` - / - `rate(shepherd_cow_api_submit_total[5m])` -- Fuel headroom — - `1 - (shepherd_fuel_consumed / 1_000_000_000)` -- Memory pressure — - `shepherd_memory_peak_bytes / 67_108_864` - -## Backups - -`state_dir/local-store.redb` is the only durable state the engine -holds. redb's WAL means a file-level snapshot taken while the -engine is running is consistent; for safety, either: - -- Pause the engine (`systemctl stop shepherd`), copy the file, then - restart. Sub-second downtime on a small store. -- Use `redb::Database::backup` from a sidecar. - -The store is per-module-namespaced (32-byte keccak prefix per -`module.name`), so a fresh deployment can re-import partial backups -without cross-module bleed. +At boot the engine creates `state_dir/local-store.redb`, opens RPC providers, loads components, calls `init`, and begins dispatching. Console output is `tracing` JSON, or pretty with `--pretty-logs`. For systemd runs see [`docs/production.md`](./production.md). ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| -| `init failed: unsupported` | Module imports a capability that needs a chain RPC not configured. | Add the missing `[chains.]` entry to `engine.toml`. | -| `unknown chain ... (no engine.toml RPC entry)` | Module dispatched `chain::request` for a chain not in `engine.toml`. | Same — add the chain. | -| `OutOfFuel` trap, immediate restart loop | Module's `on_event` exceeds `[engine.limits].fuel_per_event`. | Bump `fuel_per_event`, or audit the module's loop bounds. | -| `MemoryOutOfBounds` trap | Module's linear-memory growth exceeds `[engine.limits].memory_bytes`. | Bump `memory_bytes`; profile the module for runaway allocations. | -| `dispatch exceeded its ...s wall-clock deadline`, module marked dead | A host call (RPC / HTTP) blocked, or a chain of slow calls, ran past `[engine.limits].event_deadline_secs`. | Audit the module's host calls; tighten the per-call chain/HTTP timeouts, or bump `event_deadline_secs` if the workload is legitimately long. | -| `submit failed (... InvalidAppData)` | Module sent an `OrderCreation` with a non-empty app-data hash but `app_data = "{}"`. | Out of M2 scope — modules currently only support `EMPTY_APP_DATA_JSON`. Patch is on the M3 follow-up board. | +| `init failed: unsupported` | Module needs a chain RPC not configured. | Add the missing `[chains.]` entry. | +| `unknown chain ... (no engine.toml RPC entry)` | `chain::request` for a chain not in `engine.toml`. | Add the chain. | +| `OutOfFuel` trap, restart loop | `on_event` exceeds `[limits] fuel_per_event`. | Bump `fuel_per_event`, or audit the module's loop bounds. | +| `MemoryOutOfBounds` trap | Linear-memory growth exceeds `[limits] memory_bytes`. | Bump `memory_bytes`; profile the module. | +| dispatch exceeded its wall-clock deadline, module marked dead | A host call blocked past `[limits] event_deadline_secs`. | Tighten the module's host-call timeouts, or bump `event_deadline_secs`. | ## Reference - [SDK overview](./sdk.md) - [First-module tutorial](./tutorial-first-module.md) -- ADR-0001 (`docs/adr/0001-engine-toml-separate-from-nexum-toml.md`) - — why `engine.toml` and `module.toml` are split. -- ADR-0003 (`docs/adr/0003-local-store-namespacing.md`) — how the - `state_dir/local-store.redb` file partitions across modules. -- ADR-0005 (`docs/adr/0005-cow-api-via-cached-orderbookapi.md`) — - how the `cow-api` host backend caches per-chain `OrderBookApi` - clients. +- ADR-0001 (`docs/adr/0001-engine-toml-separate-from-nexum-toml.md`): why `engine.toml` and `module.toml` are split. +- ADR-0003 (`docs/adr/0003-local-store-namespacing.md`): how `state_dir/local-store.redb` partitions across modules. diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index e6d1220d..6c48958f 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -1,194 +1,87 @@ -# Docker deployment runbook +# Docker deployment -Operator-facing quickstart for running Shepherd in production via the -published container image. For the full hardening surface (systemd -unit, backup recipes, RPC selection, alerting rules) read -`docs/production.md`. +Running Shepherd from the published container image. The hardening surface (systemd, backup, RPC selection, alerting) is in [`docs/production.md`](../production.md); the `engine.toml` reference is in [`docs/deployment.md`](../deployment.md). -The image is published on every push to `main` and on every -`v*` tag: +The image is published to `ghcr.io/nullislabs/shepherd` on every push to `main` and every `v*` tag: ``` -ghcr.io/bleu/nullis-shepherd:latest # main branch HEAD -ghcr.io/bleu/nullis-shepherd:sha- # exact-build pin -ghcr.io/bleu/nullis-shepherd:v0.2.0 # tag +ghcr.io/nullislabs/shepherd:latest # main HEAD +ghcr.io/nullislabs/shepherd:sha- # exact-build pin +ghcr.io/nullislabs/shepherd:v0.2.0 # release tag ``` -`linux/amd64` only for now (the soak VM is x86_64; add `arm64` once -an operator surfaces a real need). +The image is a multi-stage build: a `debian:bookworm-slim` runtime, `tini` as PID 1 (forwards SIGINT/SIGTERM), a non-root `shepherd` user, the `shepherd` binary, and the four production modules plus the `cow-venue` adapter baked under `/opt/shepherd/`. `linux/amd64` only. ---- - -## 1. First boot on a fresh VM +## 1. First boot ```bash -# On the VM: -git clone https://github.com/bleu/nullis-shepherd /opt/shepherd +git clone https://github.com/nullislabs/shepherd /opt/shepherd cd /opt/shepherd -# Operator-supplied RPC URLs. `.env` is gitignored; the template -# committed at `.env.example` lists every variable the engine -# substitutes into `engine.docker.toml` via `${VAR}` placeholders. +# Operator RPC URLs. `.env` is gitignored; `.env.example` lists every +# variable the engine substitutes into engine.docker.toml via ${VAR}. cp .env.example .env -${EDITOR:-vi} .env # paste your paid wss:// URLs +${EDITOR:-vi} .env -# Pull the published image (no local build needed). docker compose pull - -# Start the engine. Compose reads `.env` automatically and passes -# the listed variables into the container, where the engine -# substitutes them at config-load time. docker compose up -d - -# Logs (JSON line-per-event, see `docs/production.md §5`). docker compose logs -f shepherd ``` -If you want the observability stack on the same host: +The observability profile adds Prometheus on the same host: ```bash -docker compose --profile observability up -d -# Prometheus UI: http://127.0.0.1:9090 +docker compose --profile observability up -d # Prometheus UI: http://127.0.0.1:9090 ``` -The metrics endpoint binds the **host's loopback** by default -(`127.0.0.1:9100`); the Prometheus container scrapes via the -compose-internal DNS name `shepherd:9100`. Never expose `:9100` to -the public internet without authn — see `docs/production.md §7`. - ---- - -## 2. Configuring `engine.toml` - -The image bind-mounts the committed `engine.docker.toml` at -`/etc/shepherd/engine.toml` read-only. It uses `${VAR}` placeholders -for every paid-RPC URL, which the engine substitutes at load time -from environment (Docker compose forwards them in from `.env`). -A missing variable fails the boot fast with the exact name. - -To run with a custom config (different module mix, extra chains) -instead of `engine.docker.toml`, point compose at it via -`SHEPHERD_ENGINE_CONFIG=./engine.local.toml` in `.env` — the bind -mount picks up whichever path is set. - -Minimum production shape if you write your own: - -```toml -[engine] -state_dir = "/var/lib/shepherd" # mapped to the `shepherd-state` named volume -log_level = "info" - -[engine.metrics] -enabled = true -bind_addr = "0.0.0.0:9100" # inside the container; compose maps to 127.0.0.1 - -# One per chain you subscribe to. `${VAR}` placeholders are -# substituted at load time from environment — keep the actual URL -# in `.env`, not in any committed file. Must be `wss://`; the -# engine emits a boot-time ERROR otherwise (see docs/production.md §6). -[chains.11155111] -rpc_url = "${SEPOLIA_RPC_URL}" - -[chains.42161] -rpc_url = "${ARBITRUM_RPC_URL}" - -# One [[modules]] per .wasm baked into /opt/shepherd/modules/. -# `manifest` defaults to /module.toml if omitted. -[[modules]] -path = "/opt/shepherd/modules/twap_monitor.wasm" -manifest = "/opt/shepherd/manifests/twap-monitor.toml" - -[[modules]] -path = "/opt/shepherd/modules/ethflow_watcher.wasm" -manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -# Add price-alert / balance-tracker the same way. -``` +The metrics endpoint binds the host loopback (`127.0.0.1:9100`); the Prometheus container scrapes via the compose DNS name `shepherd:9100`. Never expose `:9100` publicly without authn (see [`docs/production.md`](../production.md)). -If you want compose to use this file instead of the bundled -`engine.docker.toml`, set `SHEPHERD_ENGINE_CONFIG=./engine.local.toml` -in `.env` and put your file there (the `*.local.toml` pattern is -already gitignored). +## 2. Configuring the engine -Public RPCs throttle `eth_subscribe` + `eth_getLogs` under sustained -load (independently confirmed by the baseline-latency tool -- see -`docs/operations/baselines/`). The soak explicitly requires paid -endpoints. +The image bind-mounts the committed `engine.docker.toml` at `/etc/shepherd/engine.toml` read-only. It uses `${VAR}` placeholders for every RPC URL, substituted at load time from the environment; a missing variable fails the boot with the exact name. To run a custom config, set `SHEPHERD_ENGINE_CONFIG=./engine.local.toml` in `.env` (the `*.local.toml` pattern is gitignored). ---- +Inside the container the metrics exporter binds `0.0.0.0:9100` so the compose port mapping reaches it; the mapping keeps it on the host loopback. Public RPCs throttle `eth_subscribe` and `eth_getLogs` under load, so use paid endpoints. -## 3. Upgrade / rollback +## 3. Upgrade and rollback ```bash -# Roll forward to the latest main-branch build. -docker compose pull -docker compose up -d # picks up the new image; graceful - # shutdown drains in-flight dispatch - # before the new container takes over. +# Roll forward to the latest main build. Graceful shutdown drains the +# in-flight dispatch before the new container takes over. +docker compose pull && docker compose up -d -# Roll back to a specific build. -export SHEPHERD_IMAGE=ghcr.io/bleu/nullis-shepherd:sha-abc1234 +# Pin a specific build. +export SHEPHERD_IMAGE=ghcr.io/nullislabs/shepherd:sha-abc1234 docker compose up -d - -# Cold roll: stop, prune image, pull fresh. -docker compose down -docker image rm ghcr.io/bleu/nullis-shepherd:latest -docker compose pull && docker compose up -d ``` -The `shepherd-state` named volume survives container recreation — -the redb file with all `submitted:` / `dropped:` / `backoff:` markers -persists across upgrades by design (idempotency lives there). +The `shepherd-state` named volume survives container recreation, so the redb file and its idempotency markers persist across upgrades. ---- +## 4. Building locally -## 4. Building the image locally - -The CI publishes on every push, so the local build path is only for -testing un-merged changes: - -```bash -docker compose build # uses repo-root Dockerfile -docker compose up -d # runs the locally-built image -``` - -To pin the locally-built tag and avoid accidentally pulling `:latest`: +For testing unmerged changes: ```bash +docker compose build # repo-root Dockerfile export SHEPHERD_IMAGE=shepherd:local docker build -t "$SHEPHERD_IMAGE" . docker compose up -d ``` ---- - -## 5. Verifying the deploy +## 5. Verifying ```bash -# Engine is up, modules are loaded, no module is quarantined. +# Engine up, no module or adapter quarantined. curl -s http://127.0.0.1:9100/metrics \ - | grep -E '^shepherd_(module_poisoned|module_restarts_total|stream_reconnects_total)' + | grep -E '^shepherd_(module_poisoned|adapter_poisoned|stream_reconnects_total)' -# Tail the structured logs. -docker compose logs -f shepherd | grep -E '"level":(("ERROR")|("WARN"))' - -# In a separate shell: confirm the engine wrote a last-dispatched- -# block marker after the first 30s of uptime (proof the supervisor -# is dispatching events, not just idle-looping). +docker compose logs -f shepherd | grep -E '"level":("ERROR"|"WARN")' docker compose exec shepherd ls -la /var/lib/shepherd/ ``` -Green: `shepherd_module_poisoned == 0`, no ERROR/WARN lines beyond -boot, and a non-empty redb file under `/var/lib/shepherd/`. - ---- +Green: poisoned gauges `0`, no ERROR/WARN beyond boot, and a non-empty `local-store.redb` under `/var/lib/shepherd/`. -## 6. Cross-references +## 6. See also -- `docs/production.md` — full process-level deploy (systemd path), - backup recipes, RPC selection, alerting rules, runbook. -- `docs/06-production-hardening.md` — resource-limit design (fuel, - memory, storage), restart policy, RPC resilience, observability - design. -- `docs/operations/m3-testnet-runbook.md` — staging validation - playbook; reuse the same steps before the production soak. -- `engine.example.toml` — annotated reference for the engine config. +- [`docs/production.md`](../production.md): systemd, backup, RPC selection, alerting. +- [`docs/06-production-hardening.md`](../06-production-hardening.md): resource-limit design, restart policy, RPC resilience. +- `engine.example.toml`: annotated engine config reference. diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index b15e3a80..36a1e138 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -1,50 +1,24 @@ -# Multi-chain deployment patterns +# Multi-chain deployment -This guide covers running Shepherd against multiple EVM chains simultaneously. -The engine dispatches each module only to the chains it subscribes to, so a -single `nexum` process can serve modules watching Mainnet, Gnosis Chain, -Arbitrum One, and Base at the same time. +Running Shepherd against multiple EVM chains. The engine dispatches each module only to the chains it subscribes to, so one process can serve modules watching Mainnet, Gnosis Chain, Arbitrum One, and Base at once. For the base `engine.toml` reference see [`docs/deployment.md`](../deployment.md). -That covers keeper modules, which subscribe per chain. It does not extend to -venue adapters. The CoW adapter fixes its orderbook chain at `init` from its -own manifest `[config] chain`, and registers under the fixed venue id `cow` -(`CowVenue::ID`), which carries no chain component. Two cow adapters installed -in one process would therefore register under the same id, with nothing at the -venue registry to tell them apart. - -Single-process multi-chain *submission* is an explicit non-goal for M4. Run one -engine process per submitting chain, each paired with the matching adapter -manifest (`module.toml` for Mainnet, `module.sepolia.toml` for Sepolia). -Modules that only watch chains are unaffected and may span chains freely. - ---- +Keeper modules subscribe per chain and may span chains freely. Venue adapters do not: the `cow-venue` adapter fixes its orderbook chain at `init` from its own `[config] chain`, and registers under the fixed venue id `cow` (`CowVenue::ID`), which carries no chain component. Two cow adapters in one process would register under the same id. Single-process multi-chain submission is a non-goal: run one engine process per submitting chain, each paired with the matching adapter manifest (`module.toml` for Mainnet, `module.sepolia.toml` for Sepolia). ## Chain support matrix -The table lists the chains this repo's modules target. The CoW Protocol -orderbook supports more (the `cowprotocol` crate's `Chain` enum also carries -BNB, Polygon, Avalanche, Linea, and Plasma); the same wiring pattern applies -to any of them. Shepherd can subscribe to block and log events on any EVM -chain; add only the chains your modules actually use. - -| Chain | Chain ID | Orderbook slug | Barn (staging) | Notes | -|-------|----------|----------------|----------------|-------| -| Ethereum Mainnet | 1 | `mainnet` | ✓ | Primary production chain | -| Gnosis Chain | 100 | `xdai` | ✓ | Second-most-active CoW deployment | -| Base | 8453 | `base` | ✗ | | -| Arbitrum One | 42161 | `arbitrum_one` | ✓ | | -| Sepolia | 11155111 | `sepolia` | ✓ | Recommended testnet for soak runs | - -The CoW Protocol orderbook is not deployed on every EVM chain. A `[chains.]` -entry in `engine.toml` for a chain with no orderbook deployment will open block -and log subscriptions, but any module that calls `cow-api` will fail at runtime. +The chains this repo's modules target. The CoW orderbook supports more (the `cowprotocol` crate's `Chain` enum also carries BNB, Polygon, Avalanche, Linea, Plasma); the same wiring applies. A `[chains.]` entry for a chain with no orderbook deployment still opens block and log subscriptions, but any submission on it fails at runtime. ---- +| Chain | Chain ID | Orderbook slug | Barn (staging) | +|-------|----------|----------------|----------------| +| Ethereum Mainnet | 1 | `mainnet` | yes | +| Gnosis Chain | 100 | `xdai` | yes | +| Base | 8453 | `base` | no | +| Arbitrum One | 42161 | `arbitrum_one` | yes | +| Sepolia | 11155111 | `sepolia` | yes | -## `engine.toml` — per-chain RPC wiring +## Per-chain RPC wiring -Add one `[chains.]` table per chain. Log-subscription modules require a -WebSocket (`wss://`) transport; request-only modules can use HTTP. +One `[chains.]` table per chain. `ws://`/`wss://` engage the pubsub transport (blocks push via `eth_subscribe(newHeads)`); `http://`/`https://` poll (blocks via `eth_getBlockByNumber`, logs via `eth_getLogs`). Both transports carry block and log subscriptions; there is no WebSocket requirement. Prefer `wss://` where the provider offers it, since push is lower-latency than polling. ```toml [chains.1] # Ethereum Mainnet @@ -59,16 +33,13 @@ rpc_url = "${BASE_RPC_URL}" [chains.42161] # Arbitrum One rpc_url = "${ARBITRUM_RPC_URL}" -[chains.11155111] # Sepolia (soak / staging) +[chains.11155111] # Sepolia rpc_url = "${SEPOLIA_RPC_URL}" ``` -`${VAR}` tokens are substituted at engine boot from environment variables. A -missing variable fails fast with the exact variable name. In Docker Compose, -forward the variables from the host `.env` file: +`${VAR}` tokens are substituted at boot from the environment; a missing variable fails fast with the exact name. Under Docker Compose, forward the variables from the host `.env`: ```yaml -# docker-compose.yml (engine service environment section) environment: MAINNET_RPC_URL: GNOSIS_RPC_URL: @@ -77,56 +48,28 @@ environment: SEPOLIA_RPC_URL: ``` -### Opt out of the WebSocket requirement +## Orderbook URLs -By default the engine logs an ERROR at boot when a chain is configured with an -HTTP URL because `block` and `log` subscriptions need WebSocket. If a chain is -used only for `chain::request` (poll-only modules with no `[[subscription]]`), -suppress it: +The `cow-venue` adapter resolves the orderbook base URL from its `[config] chain` using the canonical `https://api.cow.fi//` pattern. Override it per adapter in the adapter's `module.toml` to point at a barn (staging) instance or a local mock: ```toml -[chains.1] -rpc_url = "https://eth.llamarpc.com" -require_ws = false +# crates/cow-venue/module.toml +[config] +chain = 11155111 +orderbook-url = "https://barn.api.cow.fi/sepolia/" ``` ---- - -## CoW Protocol orderbook URLs - -The engine's `cow-api` extension resolves the orderbook URL automatically from -the chain ID using the canonical `https://api.cow.fi//` pattern. No -extra config is needed for production. - -Override individual chains to point at a staging ("barn") instance or a local -mock: - -```toml -[extensions.cow.orderbook_urls] -# Point chain 11155111 at the barn (staging) orderbook: -11155111 = "https://barn.api.cow.fi/sepolia/" - -# Point chain 1 at a local Wiremock during integration testing: -1 = "http://localhost:9999/" -``` - -**Canonical URLs by chain:** - | Chain | Production URL | Barn (staging) URL | |-------|---------------|-------------------| | Mainnet (1) | `https://api.cow.fi/mainnet/` | `https://barn.api.cow.fi/mainnet/` | | Gnosis (100) | `https://api.cow.fi/xdai/` | `https://barn.api.cow.fi/xdai/` | -| Base (8453) | `https://api.cow.fi/base/` | — | +| Base (8453) | `https://api.cow.fi/base/` | none | | Arbitrum One (42161) | `https://api.cow.fi/arbitrum_one/` | `https://barn.api.cow.fi/arbitrum_one/` | | Sepolia (11155111) | `https://api.cow.fi/sepolia/` | `https://barn.api.cow.fi/sepolia/` | ---- - ## Contract addresses -### CREATE2-stable (identical on every chain) - -These addresses are the same across all supported chains: +CREATE2-stable, identical on every supported chain: | Contract | Address | |----------|---------| @@ -134,176 +77,57 @@ These addresses are the same across all supported chains: | `GPv2VaultRelayer` | `0xC92E8bdf79f0507f65a392b0ab4667716BFE0110` | | `ComposableCoW` | `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74` | -### EthFlow - -The **current** production EthFlow deployment is address-identical on every -chain CoW Protocol supports (the `cowprotocol` crate pins it as -`ETH_FLOW_PRODUCTION`, sourced from -[`cowprotocol/ethflowcontract`](https://github.com/cowprotocol/ethflowcontract) -`networks.prod.json`): +EthFlow is not CREATE2-stable. The current production deployment is address-identical on every chain CoW supports (the `cowprotocol` crate pins it as `ETH_FLOW_PRODUCTION` from `cowprotocol/ethflowcontract` `networks.prod.json`): ``` -0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC (all supported chains) +0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC ``` -This is what `modules/ethflow-watcher/module.toml` wires for Sepolia, and the -same value carries to Mainnet, Gnosis Chain, Arbitrum One, and Base today. - -Unlike ComposableCoW, however, the sameness is **not a CREATE2 guarantee** - -EthFlow has legacy per-version deployments at other addresses (e.g. the -v1.0.0 contracts), and a future version may land at a new address on a -per-chain schedule. When porting `ethflow-watcher` to a new chain or bumping -the contract version, verify the `[[subscription]] address` against -`networks.prod.json` for that chain rather than assuming the constant holds. - ---- - -## Module manifests — the `[[subscription]]` duplication pattern +Legacy per-version EthFlow deployments live at other addresses, and a future version may land per-chain. When porting `ethflow-watcher` to a new chain, verify the `[[subscription]] address` against `networks.prod.json` for that chain. -A module subscribes per chain. To watch the same event on multiple chains, -declare one `[[subscription]]` block per chain. The engine opens a separate -stream for each and routes dispatches independently. +## Subscription duplication -### twap-monitor on Mainnet + Gnosis +A module subscribes per chain: to watch the same event on multiple chains, declare one `[[subscription]]` block per chain. The engine opens a separate stream for each and routes dispatches independently, tagging each event with its `chain_id`. ```toml -# module.toml for twap-monitor (multi-chain) - -# ComposableCoW.ConditionalOrderCreated on Mainnet +# twap-monitor on Mainnet + Gnosis: ComposableCoW.ConditionalOrderCreated [[subscription]] kind = "chain-log" chain_id = 1 address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" -# New-block ticks on Mainnet (drives the poll loop) [[subscription]] kind = "block" chain_id = 1 -# ComposableCoW.ConditionalOrderCreated on Gnosis Chain [[subscription]] kind = "chain-log" chain_id = 100 address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" -# New-block ticks on Gnosis Chain [[subscription]] kind = "block" chain_id = 100 ``` -The module's `on_event` receives every event tagged with its `chain_id`; use -the chain ID to dispatch to the correct `chain::request` target and the correct -`cow-api` submission path. - -### ethflow-watcher on Mainnet + Sepolia - -```toml -# module.toml for ethflow-watcher (multi-chain) +## Event topics -# CoWSwapEthFlow.OrderPlacement on Mainnet -# IMPORTANT: verify this address against cowprotocol/ethflowcontract -# networks.prod.json before deploying — EthFlow is NOT CREATE2-stable. -[[subscription]] -kind = "chain-log" -chain_id = 1 -address = "" -event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" - -# CoWSwapEthFlow.OrderPlacement on Sepolia -[[subscription]] -kind = "chain-log" -chain_id = 11155111 -address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" -event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" -``` - ---- - -## Event topic reference - -These are keccak256 hashes of the event signatures. They are the same on every -chain; only the contract `address` changes for EthFlow. Package of record: -`wit/shepherd-cow/cow-events.wit`. +keccak256 of the event signatures, identical on every chain; only the EthFlow `address` changes. Package of record: `wit/shepherd-cow/cow-events.wit`. | Event | Topic-0 | |-------|---------| | `ComposableCoW.ConditionalOrderCreated(address,(address,bytes32,bytes))` | `0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361` | -| `CoWSwapEthFlow.OrderPlacement(address,(address,address,address,uint256,uint256,uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)` | `0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9` | - ---- - -## Resource sizing (per additional chain) - -Each new chain adds: - -- **1 block subscription** (always-on WS stream, ~zero CPU when idle). -- **N log subscriptions**, where N = number of modules with a `chain-log` - subscription on that chain. -- **M `eth_call`s per block** for polling modules (e.g. TWAP), where M scales - linearly with the number of active registered orders on that chain. - -RPC provider sizing: budget **≥ 25 sustained req/s per chain** (a paid -Alchemy / Infura / QuickNode tier - free tiers throttle `eth_subscribe` -under exactly this load). Dedicated endpoints per chain are preferable to -shared-rate plans when running `block` subscriptions simultaneously. - -Monitor `shepherd_chain_request_total{outcome="err"}` per `chain_id` — a -sustained rate above 5% on any chain indicates RPC degradation. - ---- - -## Full multi-chain `engine.docker.toml` example - -```toml -[engine] -state_dir = "/var/lib/shepherd" -log_level = "info" +| `CoWSwapEthFlow.OrderPlacement(...)` | `0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9` | -[engine.metrics] -enabled = true -bind_addr = "0.0.0.0:9100" - -[chains.1] -rpc_url = "${MAINNET_RPC_URL}" - -[chains.100] -rpc_url = "${GNOSIS_RPC_URL}" - -[chains.8453] -rpc_url = "${BASE_RPC_URL}" - -[chains.42161] -rpc_url = "${ARBITRUM_RPC_URL}" - -[chains.11155111] -rpc_url = "${SEPOLIA_RPC_URL}" - -[extensions.cow.orderbook_urls] -# Uncomment to override individual chains with barn or a local mock: -# 11155111 = "https://barn.api.cow.fi/sepolia/" - -[[modules]] -path = "/opt/shepherd/modules/twap_monitor.wasm" -manifest = "/opt/shepherd/manifests/twap-monitor.toml" - -[[modules]] -path = "/opt/shepherd/modules/ethflow_watcher.wasm" -manifest = "/opt/shepherd/manifests/ethflow-watcher.toml" -``` +## Resource sizing ---- +Each new chain adds one always-on block subscription, N log subscriptions (one per module subscribing on that chain), and M `eth_call` per block for polling modules (M scales with active orders). Budget a paid RPC tier per chain; see [`docs/production.md`](../production.md) for capacity and the `shepherd_chain_request_total{outcome="err"}` degradation signal. ## See also -- [`docs/deployment.md`](../deployment.md) — `engine.toml` reference and - single-module quickstart -- [`docs/production.md`](../production.md) — systemd, Docker, RPC provider - recommendations, and alerting rules -- [`docs/deployment/docker.md`](./docker.md) — container image layout -- [`modules/twap-monitor/module.toml`](../../modules/twap-monitor/module.toml) - — canonical subscription example -- [`modules/ethflow-watcher/module.toml`](../../modules/ethflow-watcher/module.toml) - — EthFlow subscription with per-chain address caveat +- [`docs/deployment.md`](../deployment.md): `engine.toml` reference and single-module quickstart. +- [`docs/production.md`](../production.md): systemd, RPC selection, alerting. +- [`docs/deployment/docker.md`](./docker.md): container image layout. +- `modules/twap-monitor/module.toml`, `modules/ethflow-watcher/module.toml`: canonical subscription examples. diff --git a/docs/production.md b/docs/production.md index 96f57755..ab2d73d7 100644 --- a/docs/production.md +++ b/docs/production.md @@ -1,62 +1,24 @@ -# Production deployment guide - -Operator handbook for running `nexum` (Shepherd) in -production. Focused on **concrete artefacts** — unit files, -backup recipes, alert rules — not the design rationale, which -lives in `docs/06-production-hardening.md` (resource enforcement, -restart policy, RPC resilience, logging + metrics design). - -Audience: someone deploying Shepherd onto a Linux host or a -container orchestrator for the first time, with the assumption -that the runtime, modules, and module manifests are already -known-good (M3 + M4 milestones complete; module developer's -handbook is `docs/tutorial-first-module.md`). - ---- - -## 1. Pre-flight checklist - -Before launching: - -- [ ] **Engine binary built in `--release`** mode. - `cargo build -p nexum-cli --release` → `target/release/nexum`. -- [ ] **All module artefacts present** under - `target/wasm32-wasip2/release/` and content-addressable - (the operator pins the sha256 in each module's manifest - `[module] component = "sha256:..."` once 0.3 verification - lands; for 0.2 the field exists but is not enforced). -- [ ] **`engine.toml`** (the production-shape config) exists with: - - `[engine] state_dir = "/var/lib/shepherd"` (or equivalent - persistent path; never `/tmp`). - - `[engine] log_level = "info"` (NOT debug — see §5). - - `[engine.metrics] enabled = true` and `bind_addr` on - `127.0.0.1:9100` (NOT `0.0.0.0` — see §7). - - One `[chains.]` entry per chain you intend to - subscribe to, with a **paid** WS URL (Alchemy / Infura / - QuickNode — public nodes will throttle under sustained - load, see §6). - - One `[[modules]]` entry per module to load. -- [ ] **`/var/lib/shepherd`** exists, writable by the engine's - service user, and on a volume large enough for the local-store - growth budget (§4). -- [ ] **A Prometheus instance** scraping the engine's `/metrics` - endpoint (§7) and an alert pipeline pointed at the rules in §9. -- [ ] **A log aggregator** ingesting the engine's JSON stdout - (§5) — stdout, not a file written by the engine. -- [ ] **An on-call runbook reference** — link to this document - and to `docs/operations/m3-testnet-runbook.md` (testnet - validation, useful for staging deploys). - ---- - -## 2. Process-level deploy: systemd unit +# Production deployment + +Operator handbook for running the `shepherd` binary in production: systemd unit, state backup, observability wiring. The hardening design (resource enforcement, restart policy, RPC resilience, error model) is in [`docs/06-production-hardening.md`](./06-production-hardening.md); the `engine.toml` reference is in [`docs/deployment.md`](./deployment.md); containers in [`docs/deployment/docker.md`](./deployment/docker.md). + +## 1. Pre-flight + +- Engine built in release: `cargo build -p shepherd --release` gives `target/release/shepherd`. +- Module and adapter `.wasm` artefacts present under `target/wasm32-wasip2/release/`. +- `engine.toml` with `state_dir` on a persistent path (never `/tmp`), `log_level = "info"`, `[engine.metrics] enabled = true` and `bind_addr = "127.0.0.1:9100"`, one `[chains.]` per subscribed chain with a paid RPC URL, one `[[modules]]` per module, and the `[[adapters]]` cow entry. +- The `state_dir` exists and is writable by the service user. +- A Prometheus instance scraping `/metrics` (§6) with the alert rules in §7. +- A log aggregator ingesting the engine's JSON stdout (§5). + +## 2. systemd unit `/etc/systemd/system/shepherd.service`: ```ini [Unit] -Description=Shepherd (nexum) - CoW Protocol off-chain automation runtime -Documentation=https://github.com/bleu/nullis-shepherd +Description=Shepherd CoW Protocol automation runtime +Documentation=https://github.com/nullislabs/shepherd After=network-online.target Wants=network-online.target @@ -64,52 +26,39 @@ Wants=network-online.target Type=simple User=shepherd Group=shepherd - -# Working directory + binary. WorkingDirectory=/opt/shepherd -ExecStart=/opt/shepherd/bin/nexum \ - --engine-config /etc/shepherd/engine.toml - -# Graceful shutdown — engine handles SIGINT/SIGTERM by: -# 1. closing chain subscription tasks, -# 2. finishing the in-flight dispatch, -# 3. writing `last_dispatched_block:{chain_id}` to local-store, -# 4. logging `graceful shutdown complete ...` and exiting 0. -# Give it 30 s — production runs can have ~5 s of in-flight RPC. +ExecStart=/opt/shepherd/bin/shepherd --engine-config /etc/shepherd/engine.toml + +# SIGINT/SIGTERM ends the event loop between dispatches: it drains the +# in-flight dispatch, commits the last_dispatched_block cursor, and exits 0. +# 30s covers in-flight RPC. KillSignal=SIGINT TimeoutStopSec=30s -# Hardening +# Hardening. NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true ReadWritePaths=/var/lib/shepherd -# Engine binds 127.0.0.1:9100 for metrics. No other listeners. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX LockPersonality=true -MemoryDenyWriteExecute=false # wasmtime JIT requires writable+executable memory pages +MemoryDenyWriteExecute=false # wasmtime JIT needs writable-executable pages -# Restart policy — supervisor handles per-module poison/restart -# itself, but if the host process exits non-zero (panic, OOM, -# etc.) restart after 5 s. RestartSec=0 would loop fast on -# config errors. +# The supervisor restarts poisoned modules itself; this restarts the host +# process on a non-zero exit. RestartSec avoids a fast loop on config errors. Restart=on-failure RestartSec=5s -# Resource caps (defence in depth — wasmtime is already capping -# per-module memory at 64 MiB and fuel at ~1B inst/event). +# Defence in depth on top of the per-module wasmtime caps. LimitNOFILE=65536 MemoryMax=2G CPUQuota=200% -# Environment Environment=RUST_BACKTRACE=1 -# RUST_LOG overrides engine.toml::log_level if set. Leave unset -# in production; tune via the config file so the change is -# auditable. -# Environment=RUST_LOG=info,nexum_runtime=debug +# RUST_LOG overrides engine.toml log_level; leave unset so the config is +# the single auditable source. [Install] WantedBy=multi-user.target @@ -121,13 +70,11 @@ Bring up: sudo useradd -r -s /usr/sbin/nologin -d /var/lib/shepherd shepherd sudo install -d -o shepherd -g shepherd /var/lib/shepherd sudo install -d -o shepherd -g shepherd /opt/shepherd/bin -sudo install -m 0755 -o shepherd -g shepherd \ - target/release/nexum /opt/shepherd/bin/ +sudo install -m 0755 -o shepherd -g shepherd target/release/shepherd /opt/shepherd/bin/ sudo install -d /etc/shepherd -sudo install -m 0644 -o root -g root engine.toml /etc/shepherd/ +sudo install -m 0644 engine.toml /etc/shepherd/ sudo systemctl daemon-reload sudo systemctl enable --now shepherd -sudo systemctl status shepherd ``` Tail the logs: @@ -136,302 +83,60 @@ Tail the logs: journalctl -u shepherd -f --output=json | jq '.MESSAGE | fromjson?' ``` ---- - -## 3. Container deploy: Docker Compose - -### 3.1 Dockerfile - -The official multi-stage `Dockerfile` ships at the repo root - it -builds the `nexum` binary, compiles all five module wasms, and runs as -a non-root user under `tini`. Build it with `docker build -t shepherd .` -or let the root `docker-compose.yml` build it for you. The full image -reference (published tags, pinning, .env wiring) is in -[`docs/deployment/docker.md`](deployment/docker.md). - -### 3.2 docker-compose.yml - -```yaml -version: "3.9" -services: - shepherd: - build: . - image: shepherd:latest - restart: unless-stopped - volumes: - - shepherd-state:/var/lib/shepherd - - ./engine.toml:/etc/shepherd/engine.toml:ro - ports: - # Bind metrics endpoint to the host loopback only — - # Prometheus scrapes it via docker network, no public - # exposure. - - "127.0.0.1:9100:9100" - stop_signal: SIGINT - stop_grace_period: 30s - healthcheck: - # Metrics endpoint serves a Prometheus exposition page; - # treating a successful GET as liveness is good enough - # until a dedicated /health endpoint lands. - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9100/metrics > /dev/null"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s - deploy: - resources: - limits: - memory: 2G - cpus: "2.0" - - prometheus: - image: prom/prometheus:latest - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./prometheus-rules.yml:/etc/prometheus/rules.yml:ro - - prometheus-data:/prometheus - ports: - - "127.0.0.1:9090:9090" - -volumes: - shepherd-state: - prometheus-data: -``` - -`prometheus.yml`: - -```yaml -scrape_configs: - - job_name: shepherd - scrape_interval: 15s - static_configs: - - targets: ["shepherd:9100"] -rule_files: - - /etc/prometheus/rules.yml -``` - ---- +For the container path (Docker Compose, image tags, `.env` wiring) see [`docs/deployment/docker.md`](./deployment/docker.md). -## 4. State store backup (`redb`) +## 3. State backup (`redb`) -The local-store is a single redb file at -`/ls.redb`. It accumulates per-module -`watch:`, `submitted:`, `dropped:`, `backoff:`, `last:`, and -`last_dispatched_block:{chain_id}` keys; losing it on a -production module forces a from-scratch resync (twap-monitor -re-discovers `watch:` from the next `ConditionalOrderCreated` -log). +The local-store is a single redb file at `/local-store.redb`. It holds per-module keys (`watch:`, `submitted:`, `dropped:`, `backoff:`, `last_dispatched_block:{chain_id}`); losing it forces a from-scratch resync as modules re-discover state from chain logs. -### 4.1 Cold backup (recommended for first deploy + before upgrades) - -The engine writes to redb only during dispatch. On a SIGINT the -graceful shutdown path drains in-flight dispatches and the file -becomes quiescent within ≤ 5 s. +Cold backup (recommended before upgrades). The engine writes to redb only during dispatch, and the graceful shutdown drains in-flight dispatches, so the stopped file is quiescent: ```bash -sudo systemctl stop shepherd # or: docker compose stop shepherd -sudo cp /var/lib/shepherd/ls.redb /backup/shepherd-ls-$(date -u +%Y%m%dT%H%M%SZ).redb +sudo systemctl stop shepherd +sudo cp /var/lib/shepherd/local-store.redb \ + /backup/shepherd-$(date -u +%Y%m%dT%H%M%SZ).redb sudo systemctl start shepherd ``` -Cold copies are byte-identical to a fresh database and need no -verification. - -### 4.2 Hot backup (live process) - -redb 2.x is single-file MVCC + a commit-on-disk log; an `cp` -under a live writer can capture an in-flight commit and produce -a database that fails `Database::check_integrity` on restore. -For the M4 release the supported path is: - -1. Send SIGSTOP to the engine PID (`kill -STOP `). -2. `cp` the file (redb's on-disk format is consistent at any - commit boundary, and SIGSTOP guarantees no writer is mid- - commit). -3. Send SIGCONT (`kill -CONT `). - -The pause-and-copy window is ≤ 1 s on a ~100 MiB local-store -(typical 30-day production size). Subscribers won't drop because -the alloy WS connection survives a brief process stop. - -A `redb::Database::backup`-style API (snapshot from within a -read transaction) is on the roadmap — track in upstream redb -releases > 2.6. - -### 4.3 Restore + integrity check +Live copy. A plain `cp` under a live writer can capture an in-flight commit; pause the process first: ```bash -sudo systemctl stop shepherd -sudo cp /backup/shepherd-ls-.redb /var/lib/shepherd/ls.redb -sudo -u shepherd /opt/shepherd/bin/nexum \ - --engine-config /etc/shepherd/engine.toml \ - --check-integrity-only # planned 0.3 flag; manual call today: -# rust: redb::Database::open(path)?.check_integrity()? -> bool -sudo systemctl start shepherd +kill -STOP +cp /var/lib/shepherd/local-store.redb /backup/... +kill -CONT ``` -If the integrity check returns `false`, do **not** start the -engine on the restored file. Roll forward from the previous -known-good snapshot; in the worst case start with an empty -state directory and accept the resync cost above. - -### 4.4 Retention policy (suggested) - -- 7 daily cold backups. -- 4 weekly cold backups (rotated every Sunday). -- 12 monthly cold backups. +The pause window is sub-second on a small store, and the WS connections survive it. Restore by stopping the engine, copying the snapshot back, and restarting. If a restored file does not open, roll forward from the previous snapshot or start with an empty `state_dir` and accept the resync. -Total cost on a 100 MiB store ≈ 23 × 100 MiB = 2.3 GiB. +## 4. Chain-log cursor ---- +A `resume` subscription persists its progress under `last_dispatched_block:{chain_id}`, written after each successful dispatch, so a restart resumes from the last committed block. The engine backfills the gap on reconnect (see `docs/06-production-hardening.md`). ## 5. Logs -### 5.1 Format - -The engine emits JSON-formatted `tracing` events on stdout -(unless `--pretty-logs` is passed; only the runbook docs use -that flag). Sample event: - -```json -{ - "timestamp": "2026-06-18T15:30:00.000Z", - "level": "INFO", - "target": "nexum_runtime::supervisor", - "fields": { - "message": "init succeeded", - "module": "twap-monitor" - } -} -``` - -Important fields on every event: - -| Field | Meaning | -|---|---| -| `target` | Crate + module path. Useful filters: `nexum_runtime`, `nexum_runtime::supervisor`, `nexum_runtime::host::impls::cow_api`. | -| `level` | `TRACE` < `DEBUG` < `INFO` < `WARN` < `ERROR`. **Production should never see `ERROR`** from `nexum_runtime::*` (only from third-party crates the supervisor wraps as warnings). | -| `fields.message` | Human-readable summary. Greppable. | -| `fields.module` | Set on every per-module event — supervisor, host calls, guest log emissions. Use this for per-module dashboards. | - -### 5.2 Retention + aggregation - -Two-tier model: - -1. **Hot (last 7 days)** — full INFO + DEBUG. Lives in your - log aggregator (Loki / CloudWatch Logs / Datadog). Used for - incident investigation. -2. **Cold (90 days)** — INFO only, drop DEBUG at ingest time. - S3 / GCS with lifecycle rule to Glacier at 90 days. Used for - audit + post-mortem. - -INFO-level retention sizing: each dispatch produces ~1 KB of -INFO/DEBUG output combined. 5 modules × 1 block / 12 s × 7 -days ≈ 200 MiB/week. DEBUG roughly doubles this; the cold tier -dropping DEBUG keeps the long-term cost trivial. - -### 5.3 Aggregation pattern: Vector → Loki - -`vector.toml`: - -```toml -[sources.shepherd] -type = "journald" -include_units = ["shepherd.service"] - -[transforms.parse_json] -type = "remap" -inputs = ["shepherd"] -source = ''' - . = parse_json!(.message) -''' - -[transforms.drop_debug_cold] -type = "filter" -inputs = ["parse_json"] -condition = '.level != "DEBUG"' - -[sinks.loki_hot] -type = "loki" -inputs = ["parse_json"] -endpoint = "http://loki:3100" -labels = { app = "shepherd", level = "{{ .level }}", module = "{{ .fields.module }}" } - -[sinks.s3_cold] -type = "aws_s3" -inputs = ["drop_debug_cold"] -bucket = "shepherd-logs-cold" -key_prefix = "year=%Y/month=%m/day=%d/" -compression = "gzip" -``` - ---- +The engine emits JSON `tracing` events on stdout (`--pretty-logs` switches to the human format used in the runbooks). Every event carries `target` (crate + module path), `level`, `fields.message`, and `fields.module` on per-module events. Production should not see `ERROR` from `nexum_runtime::*`. -## 6. RPC selection +Aggregate stdout into your log stack (Loki, CloudWatch, Datadog). A Vector journald source parsing the JSON `message` field and routing by `level` is the typical pattern. -The engine talks to chains exclusively through alloy providers -configured at boot. Public nodes throttle `eth_subscribe` and -`eth_call` aggressively; production deployments **must** use a -paid endpoint. +## 6. Metrics -> **Prefer `wss://` over `https://` where the provider offers it.** -> A WebSocket URL pushes new blocks via `eth_subscribe(newHeads)`; an -> HTTP URL polls `eth_getBlockByNumber` at the chain's block time -> instead (logs poll `eth_getLogs` on either transport). Both work, so -> HTTP-only endpoints are supported, but push is lower-latency and -> cheaper than polling. Every paid provider exposes both schemes per -> endpoint. - -| Provider | Plan recommendation | Notes | -|---|---|---| -| Alchemy | Growth tier (≥ 660M CU/mo) | First-class WS pubsub; SLA-backed. | -| Infura | Developer Plus (≥ 6M req/day) | Solid WS; rate-limits per project key. | -| QuickNode | Discover tier (≥ 25 req/s) | Dedicated endpoints; recommended for multi-chain swarms. | - -`engine.toml`: - -```toml -[chains.11155111] -rpc_url = "wss://eth-sepolia.g.alchemy.com/v2/" - -[chains.42161] -rpc_url = "wss://arb-mainnet.g.alchemy.com/v2/" -``` - -Capacity sizing (per chain): - -- `1` block subscription, always-on. WS. -- `N` chain-log subscriptions, where `N` = number of modules with - `[[subscription]] kind = "chain-log"`. -- `M` `eth_call` per block, where `M` ≈ sum of polling modules' - active orders. The TWAP module's load grows linearly with the - number of registered orders; budget accordingly. - -`shepherd_chain_request_total{outcome="err"}` rate is the -canonical "the RPC is degraded" signal — see §9 alerts. - ---- - -## 7. Metrics + scraping - -`/metrics` is exposed when `[engine.metrics] enabled = true` in -`engine.toml`. **Always** bind to a loopback address; never -`0.0.0.0`. Prometheus scrapes via the loopback / container -network. - -### 7.1 Metric surface +`/metrics` binds when `[engine.metrics] enabled = true`. Always bind loopback, never `0.0.0.0`; Prometheus scrapes over the loopback or container network. The bare `nexum` binary does not register the exporter; run `shepherd`. | Metric | Type | Labels | Meaning | |---|---|---|---| -| `shepherd_event_latency_seconds` | histogram | `module`, `event_kind` | Per-module dispatch latency. p95 > 1 s on a non-RPC-heavy module is suspicious. | -| `shepherd_module_errors_total` | counter | `module`, `error_kind` | All host errors + traps. `error_kind="trap"` = wasmtime trap (fuel / memory / panic); other kinds are the `fault` case labels. | -| `shepherd_module_restarts_total` | counter | `module` | Increments on every `reinstantiate_one` attempt (per-module restart backoff). | -| `shepherd_module_poisoned` | gauge | `module` | `1` if the module has been quarantined per `POISON_MAX_FAILURES=5` / `POISON_WINDOW=10m`. Stays `1` until process restart. | -| `shepherd_dispatch_dropped_total` | counter | `module`, `event_kind` | Events dropped at the dispatch boundary by the per-module rate limit (`[limits.dispatch]`, default `burst=256` / `refill_per_sec=128`). A sustained rate = a source flooding one module; the drop protects the host and never starves other modules. | -| `shepherd_chain_request_total` | counter | `chain_id`, `method`, `outcome` | Every `chain::request` host call. `outcome="err"` rate > 5% = RPC degraded. | -| `shepherd_cow_api_submit_total` | counter | `chain_id`, `outcome` | Every orderbook submit. `outcome="err"` covers both retriable and dropped — drill into supervisor logs to discriminate. | -| `shepherd_stream_reconnects_total` | counter | `kind`, `chain_id`, `module?` | WS reconnect attempts. `kind="block"` is per-chain; `kind="chain-log"` carries the `module` label too. | - -### 7.2 Prometheus config snippet +| `shepherd_event_latency_seconds` | histogram | `module`, `event_kind` | Per-module dispatch latency. | +| `shepherd_dispatch_dropped_total` | counter | `module`, `event_kind` | Events dropped by the per-module dispatch rate limit (`[limits.dispatch]`, default `burst=256` / `refill_per_sec=128`). | +| `shepherd_module_errors_total` | counter | `module`, `error_kind` | Host faults and traps. `error_kind="trap"` is a wasmtime trap; other kinds are fault labels. | +| `shepherd_module_restarts_total` | counter | `module` | Per-module restart attempts. | +| `shepherd_module_poisoned` | gauge | `module` | `1` once a module crosses `[limits.poison]` (default 5 failures / 600 s). Stays `1` until process restart. | +| `shepherd_adapter_errors_total` | counter | `adapter`, `error_kind` | Venue-adapter faults and traps. | +| `shepherd_adapter_restarts_total` | counter | `adapter` | Venue-adapter restart attempts. | +| `shepherd_adapter_poisoned` | gauge | `adapter` | `1` once an adapter is quarantined. | +| `shepherd_chain_request_total` | counter | `chain_id`, `method`, `outcome` | Every `chain::request`. `outcome="err"` rate is the RPC-degraded signal. | +| `shepherd_chain_response_capped_total` | counter | `chain_id`, `method` | Responses rejected for exceeding `[limits.chain] response_body_max_bytes`. | +| `shepherd_stream_reconnects_total` | counter | `kind`, `chain_id`, `module?` | WS/poller reconnects. `kind="block"` is per-chain; `kind="chain-log"` also carries `module`. | + +Prometheus scrape: ```yaml scrape_configs: @@ -441,237 +146,91 @@ scrape_configs: - targets: ["127.0.0.1:9100"] ``` -15 s is conservative; the metrics cardinality is bounded by -modules × chains, which on a 5-module / 2-chain deploy is ~15 -series for the gauges + ~30 for the counters. - ---- - -## 8. Workload-class tuning - -Resource limits today are compile-time constants. Per-module -overrides via `[engine.limits]` are tracked as a 0.3 follow-up -(referenced from `crates/nexum-runtime/src/runtime/limits.rs`). -The tuning advice below is therefore advisory — adjust by -changing the constants in `runtime/limits.rs` and rebuilding, -or by ensuring per-module loads fit within the current -defaults. - -| Class | Modules typical | Fuel/event | Memory cap | Notes | -|---|---|---|---|---| -| **Light indexer** | price-alert, balance-tracker | 200M | 16 MiB | Block-tick poll + 1-2 RPC reads. Defaults are 5× headroom. | -| **TWAP-style polling** | twap-monitor | 1B (default) | 64 MiB (default) | Per-block `getTradeableOrderWithSignature` calls per registered order; long ABI decode + signature work. Defaults sized for this case. | -| **Multi-chain swarm** | 5+ modules × 2+ chains | 2B | 128 MiB | More headroom for parallel dispatch overhead; modules don't share state, but the per-store wasmtime overhead is per-(module, chain). | - -A module that consistently traps `OutOfFuel` is a bug, not a -tuning miss -- open an issue with the supervisor log -snippet rather than raising the fuel budget. The defaults are -already 5-10× the largest observed real-world dispatch. - ---- +## 7. Alerting -## 9. Alerting - -Prometheus alert rules (`prometheus-rules.yml`): +`prometheus-rules.yml`: ```yaml groups: - name: shepherd interval: 30s rules: - # P0: a production module is permanently quarantined. - # Recovery requires operator action (process restart + - # module triage). - alert: ShepherdModulePoisoned - expr: shepherd_module_poisoned > 0 + expr: shepherd_module_poisoned > 0 or shepherd_adapter_poisoned > 0 for: 1m - labels: - severity: page + labels: { severity: page } annotations: - summary: "Shepherd module {{ $labels.module }} is poisoned" - description: | - Module has crossed POISON_MAX_FAILURES traps within - POISON_WINDOW. Engine has stopped dispatching to it. - Investigate: journalctl -u shepherd | jq 'select(.fields.module=="{{ $labels.module }}")' - - # P1: trap rate climbing. Pre-poison signal — gives 5 min - # of warning before ShepherdModulePoisoned fires. + summary: "Shepherd {{ $labels.module }}{{ $labels.adapter }} is poisoned" + - alert: ShepherdModuleTraps expr: rate(shepherd_module_errors_total{error_kind="trap"}[5m]) > 0 for: 5m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd module {{ $labels.module }} trapping" - description: | - Module is restart-looping. Investigate before - POISON_MAX_FAILURES (5 traps / 10 min) trips. - # P1: RPC layer degraded. Engine keeps running but - # dispatches will degrade; operator should switch - # endpoints or escalate to provider. - alert: ShepherdRpcErrorRate expr: | sum by (chain_id) (rate(shepherd_chain_request_total{outcome="err"}[5m])) - / - sum by (chain_id) (rate(shepherd_chain_request_total[5m])) - > 0.05 + / sum by (chain_id) (rate(shepherd_chain_request_total[5m])) > 0.05 for: 10m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd RPC error rate > 5% on chain {{ $labels.chain_id }}" - # P1: WS reconnect storm. A flapping endpoint is worse - # than a hard-down one (subscriptions keep partially - # working but events get dropped during reconnect windows). - alert: ShepherdReconnectStorm expr: rate(shepherd_stream_reconnects_total[5m]) > 0.1 for: 5m - labels: - severity: ticket + labels: { severity: ticket } annotations: summary: "Shepherd WS reconnecting frequently" - # P2: orderbook degraded. Modules will retry per the SDK's - # `classify_api_error` taxonomy; this alert fires only on - # sustained errs and is a CoW-side signal more than a - # Shepherd signal. - - alert: ShepherdCowApiErrorRate - expr: | - sum by (chain_id) (rate(shepherd_cow_api_submit_total{outcome="err"}[10m])) - / - sum by (chain_id) (rate(shepherd_cow_api_submit_total[10m])) - > 0.20 - for: 15m - labels: - severity: ticket - annotations: - summary: "Shepherd cow-api submit error rate > 20% on chain {{ $labels.chain_id }}" - - # P2: dispatch latency. Modules with sustained p95 > 5 s - # are usually doing more on-chain reads than budgeted; not - # an outage but worth tuning. - alert: ShepherdDispatchLatency expr: | histogram_quantile(0.95, - sum by (module, le) (rate(shepherd_event_latency_seconds_bucket[10m])) - ) > 5 + sum by (module, le) (rate(shepherd_event_latency_seconds_bucket[10m]))) > 5 for: 15m - labels: - severity: ticket + labels: { severity: ticket } annotations: - summary: "Shepherd module {{ $labels.module }} p95 latency > 5 s" + summary: "Shepherd module {{ $labels.module }} p95 latency > 5s" - # P3: engine absent. Either crashed and systemd hasn't - # restarted yet, or metrics binding failed. - alert: ShepherdDown expr: up{job="shepherd"} == 0 for: 2m - labels: - severity: page + labels: { severity: page } annotations: summary: "Shepherd is down (metrics scrape failing)" ``` -Severity convention: +`page` wakes on-call (poison, down); `ticket` routes during business hours. + +## 8. RPC selection -| Label | Action | -|---|---| -| `page` | On-call wakes up. ShepherdModulePoisoned + ShepherdDown only. | -| `ticket` | Routed to the Shepherd team during business hours. | +The engine reaches chains through alloy providers configured at boot. Public nodes throttle `eth_subscribe` and `eth_call`, so production must use a paid endpoint (Alchemy, Infura, QuickNode). Prefer `wss://` where offered: a WebSocket pushes new blocks via `eth_subscribe(newHeads)`, an HTTP URL polls `eth_getBlockByNumber`; both work, push is lower-latency. `shepherd_chain_request_total{outcome="err"}` is the degradation signal. ---- +Resource caps are engine defaults today; per-module overrides in `[limits]` apply uniformly. A module that consistently traps `OutOfFuel` is a bug, not a tuning miss. -## 10. Operational runbook (common tasks) +## 9. Runbook -### 10.1 Tail a single module's events +Tail one module: ```bash journalctl -u shepherd -f --output=json \ | jq 'select(.MESSAGE | fromjson? | .fields.module == "twap-monitor")' ``` -### 10.2 Reset a poisoned module - -A poisoned module stays poisoned until process restart (M4 -design — no live un-poison API yet). The recovery flow: - -1. Triage the failure: `journalctl -u shepherd | jq 'select(.MESSAGE | fromjson? | .level == "ERROR" or (.fields.message | test("trapped|poisoned")))'`. -2. Fix the underlying bug (in the module's Rust code, or the - manifest config, or the on-chain target). Rebuild the module. -3. Restart the engine: `sudo systemctl restart shepherd`. The - `failure_count` + `failure_timestamps` ring is in-memory and - resets at boot. +Recover a poisoned module: fix the underlying bug, rebuild the artefact, then `sudo systemctl restart shepherd` (the failure ring is in-memory and clears at boot). The engine reads `[[modules]]` and `[[adapters]]` at boot only, so adding a module means editing `engine.toml` and restarting. Logging-level changes also require a restart. -### 10.3 Add a module to a running deploy +## 10. Pre-upgrade -The engine reads `[[modules]]` at boot only. To add a module: +- Read the CHANGELOG for breaking config or manifest changes. +- Cold-backup the local-store (§3). +- Stage the new binary, run it once with the production `engine.toml`, and confirm the supervisor-ready line before Ctrl-C. +- Swap the binary and `sudo systemctl restart shepherd`. +- Watch `journalctl -u shepherd -f` for new ERROR/WARN lines for at least 5 minutes. -1. Build the module's wasm artefact + drop it in the artefacts - directory. -2. Append a `[[modules]]` entry to `engine.toml`. -3. `sudo systemctl restart shepherd`. The graceful shutdown - writes `last_dispatched_block:{chain_id}` so new modules - know which block to start from (if they care). - -A live `engine::reload` API is not in scope for 0.2; tracked as -a 0.3+ follow-up. - -### 10.4 Inspect the local-store contents - -There is no `ls-dump` CLI today. Workarounds: - -- Boot a one-shot Rust script with `redb::Database::open` (read- - only) against the live file. Safe — redb supports concurrent - readers + a single writer. -- Stop the engine + use any redb inspector tool against the - copy. - -### 10.5 Bump the log level live - -Logging-level changes today require an engine restart (the -filter is wired at boot). On 0.3, a SIGHUP handler will re-read -`engine.toml::log_level`. Until then: - -```bash -sudo sed -i 's/log_level = "info"/log_level = "info,nexum_runtime=debug"/' \ - /etc/shepherd/engine.toml -sudo systemctl restart shepherd -# revert when the investigation is done -``` +## References ---- - -## 11. Pre-upgrade checklist - -Before bumping `nexum` between minor versions: - -- [ ] Read the CHANGELOG for breaking config / manifest - changes. -- [ ] Cold-backup the local-store per §4.1. -- [ ] Stage the new binary in `/opt/shepherd/bin/nexum.new` - + run it once with `--engine-config /etc/shepherd/engine.toml` - + Ctrl-C after `supervisor ready modules=N chains=M` to - validate the config still parses. Roll forward only if the - ready line appears. -- [ ] `mv /opt/shepherd/bin/nexum.new /opt/shepherd/bin/nexum`. -- [ ] `sudo systemctl restart shepherd`. -- [ ] Watch `journalctl -u shepherd -f` for ≥ 5 min after - restart. Look for any new ERROR / WARN lines that weren't - present pre-upgrade. - ---- - -## 12. References - -- Architectural rationale: `docs/06-production-hardening.md` -- Per-module developer handbook: `docs/tutorial-first-module.md` -- Testnet runbooks (staging validation): - - `docs/operations/m2-testnet-runbook.md` - - `docs/operations/m3-testnet-runbook.md` - - `docs/operations/e2e-testnet-runbook.md` (full 5-module run) -- ADRs touching production posture: - - `docs/adr/0001-engine-toml-separate-from-nexum-toml.md` - - `docs/adr/0002-provider-pool-transport-by-scheme.md` - - `docs/adr/0003-local-store-namespacing.md` +- Hardening design: `docs/06-production-hardening.md` +- Module handbook: `docs/tutorial-first-module.md` +- ADR-0001, ADR-0002, ADR-0003 (`docs/adr/`) From d0c5df5245cc7f6b7d27e3bbf7638433ce2b83a1 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:20:32 +0000 Subject: [PATCH 118/139] docs: trim core architecture guides and diagrams (#606) Rewrite docs 00-04 and the diagram captions as terse descriptions of the current contract, cutting rationale essays, grant-milestone and platform-target planning material, and 0.3 version promises reduced to current fact. Verify every retained claim against crates/nexum-runtime: engine.toml [limits] keys and defaults (fuel 1B, memory 64 MiB, state 50 MiB, deadline 120s), the six-primitive event-module world, and the single-file keccak256-prefixed local store (ADR-0003). Correct substantive drift: module discovery is filesystem-only in 0.2 with ENS and registry as design direction; the shepherd:cow package now carries only cow-events and order submission is the videre:venue venue-adapter contract; the local store commits per host call rather than per event. Part of #598. --- docs/00-overview.md | 230 ++++++----------------- docs/01-runtime-environment.md | 238 ++++-------------------- docs/02-modules-events-packaging.md | 66 ++----- docs/03-module-discovery.md | 277 ++-------------------------- docs/04-state-store.md | 270 +++------------------------ docs/diagrams/README.md | 10 +- docs/diagrams/diagrams.md | 8 +- 7 files changed, 163 insertions(+), 936 deletions(-) diff --git a/docs/00-overview.md b/docs/00-overview.md index 768448e1..7bec0431 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -1,27 +1,23 @@ # Nexum: Universal WASM Component Model Runtime -Nexum is a WASM Component Model runtime that provides secure, sandboxed execution for WebAssembly modules. Modules react to blockchain events, read chain state, persist data locally and to decentralised storage, communicate via decentralised messaging - all within a capability-based sandbox with zero implicit permissions. +Nexum is a WASM Component Model runtime that provides secure, sandboxed execution for WebAssembly modules. Modules react to blockchain events, read chain state, persist data locally and to decentralised storage, and communicate via decentralised messaging, all within a capability-based sandbox with zero implicit permissions. -**Shepherd** is the Nexum distribution that includes CoW Protocol extensions (`shepherd:cow` WIT package). A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. A module compiled against `shepherd:cow/shepherd` additionally gains access to CoW Protocol APIs and order submission - and requires a Shepherd host. +**Shepherd** is the Nexum distribution that adds CoW Protocol support. A module compiled against the universal `nexum:host/event-module` world runs on any Nexum-compatible host. CoW order submission is provided by the `videre:venue` venue-adapter layer (see doc 08), not by a domain-specific host interface. ### Vocabulary: engine vs. host (`nexum` vs. `nexum:host`) -Two project names look similar but mean different things - keeping them straight is load-bearing for everything that follows: - | Term | What it is | Where you find it | |---|---|---| -| **engine** (`nexum`) | A concrete *implementation* that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. Mobile / browser / embedded engines could exist later - each is a separate engine. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | -| **host** (`nexum:host`) | The WIT *contract* - the set of host-imported interfaces (chain, identity, local-store, etc.), types, and worlds that every engine must implement and every module imports. The contract is one; engines are many. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | - -The relationship: an engine *implements* `nexum:host` so that modules *built against* `nexum:host` can run on it. The `nexum:host` package itself does not run anything - it's a specification. When this doc says "the host", it means whichever engine the module currently runs on, as seen through the `nexum:host` contract. +| **engine** (`nexum`) | A concrete implementation that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | +| **host** (`nexum:host`) | The WIT contract: the host-imported interfaces (chain, identity, local-store, ...), types, and worlds that every engine implements and every module imports. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | -The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`, a thin consumer of it. A Rust embedder skips the binary entirely, constructs an `EngineConfig` in code, and calls `nexum_runtime::bootstrap::run_from_config`. See `crates/nexum-runtime/examples/embed.rs` for a minimal end-to-end example. +An engine implements `nexum:host` so that modules built against `nexum:host` can run on it. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`. A Rust embedder constructs an `EngineConfig` in code and calls `nexum_runtime::bootstrap::run_from_config`; see `crates/nexum-runtime/examples/embed.rs`. ## Architecture ```mermaid flowchart TB - disc["Module Discovery\nStatic · ENS · On-chain Registry"] --> mm + disc["Module Discovery\nStatic (0.2) · ENS · On-chain Registry (0.3)"] --> mm subgraph nexum["Nexum Runtime"] mm["Module Manager\nLoad → Init → Run → Restart → Dead"] @@ -34,7 +30,7 @@ flowchart TB subgraph host["Host API - WIT Interfaces"] uni["nexum:host\nchain · identity · local-store · remote-store · messaging · logging"] - ext["shepherd:cow\ncow-api"] + ext["videre:venue\nvenue adapters"] end subgraph back["Backends"] @@ -60,8 +56,8 @@ flowchart TB - **Component Model from day 1** - WIT-defined API contract; structural sandboxing (no filesystem, no ambient network); multi-language guests. - **Declarative subscriptions** - modules declare events in their manifest; the runtime wires sources. -- **Transactional state** - per-event all-or-nothing semantics; commit on success, rollback on trap. -- **Content-addressed distribution** - modules are fetched by hash (Swarm, IPFS, OCI, HTTPS); integrity always verified. +- **Durable state** - each local-store write is its own fsync-durable committed transaction; state survives traps and restarts. +- **Content-addressed distribution** - modules are fetched by hash; integrity always verified. - **Self-hosted** - no centralised dependency; operator runs their own node. ## The Six Primitives @@ -77,48 +73,23 @@ Every module has access to six orthogonal capabilities through the `nexum:host` | **Messaging** | `messaging` | Decentralised pub/sub messaging | Topic-based | Waku | | **Logging** | `logging` | Diagnostic output | Per-module | tracing | -These primitives are orthogonal: - -- **Chain** is the source of truth - the blockchain consensus state. Modules read chain state and (indirectly) write to it via order submission or transactions. -- **Identity** is cryptographic identity - key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules can also import `identity` directly for raw signing operations. -- **Local Store** is the module's private scratchpad - fast, local, scoped to one module on one device. Does not replicate. -- **Remote Store** is shared persistent content - content-addressed, decentralised, survives independent of any device. Any module on any device can read what another module wrote. -- **Messaging** is real-time communication - ephemeral pub/sub messages between modules, devices, or users. Transient and topic-based. -- **Logging** is diagnostics - one-way output for debugging and monitoring. Not a data channel. +The `chain` host implementation depends on `identity`: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) delegate to the identity backend. Modules may also import `identity` directly for raw signing. ## Additive 0.2 Capabilities -In addition to the six core primitives, 0.2 introduces one optional capability that modules can declare in their manifest: +Beyond the six core primitives, 0.2 adds one optional capability modules declare in their manifest: -- **`http`** - allowlisted outbound HTTP via the standard `wasi:http/outgoing-handler` interface, gated by a `[capabilities.http].allow` domain list. The capability name lives in the manifest; the wire surface is plain wasi:http. The host MUST enforce the allowlist on every outgoing request: an off-list host is denied before any connection is made. The SDK's `http::fetch` helper wraps the interface for Rust guests. This replaces the 0.1 anti-pattern of tunnelling notifications through Waku. +- **`http`** - allowlisted outbound HTTP via `wasi:http/outgoing-handler`, gated by a `[capabilities.http].allow` domain list. The host enforces the allowlist on every request: an off-list host is denied before any connection is made. The SDK's `http::fetch` helper wraps the interface. -Time and secure randomness are WASI concerns rather than Nexum capabilities: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. +Time and secure randomness are WASI concerns: `wasi:clocks` and `wasi:random` are linked into every module store ambiently. -0.2 also publishes (but does not yet host) the experimental **`query-module`** world for request/response modules (wallet rule evaluators, signature validators, pricing oracles). The WIT is stable enough to target with `MockHost` tests; production host support lands in 0.3. +0.2 also publishes (but does not host) the experimental **`query-module`** world for request/response modules. The WIT is provisional and may change without a major bump; it is a target for mock-host tests only. ## WIT Worlds -The WIT is split into layered packages. The universal layer (`nexum:host`) provides blockchain-agnostic capabilities. Domain extensions (e.g. `shepherd:cow`) add protocol-specific interfaces. +The WIT is layered. The universal `nexum:host` package provides blockchain-agnostic capabilities; domain packages layer on top. -```mermaid -graph TB - subgraph l3["Layer 3 - Domain Extensions"] - cow["shepherd:cow\ncow-api"] - other["future:domain\nvault · strategy · …"] - end - - subgraph l1["Layer 1 - Universal Runtime"] - pkg["nexum:host"] - ifaces["chain · identity · local-store · remote-store · messaging · logging"] - exports["Exports: init · on-event"] - end - - cow -->|include event-module| l1 - other -->|include event-module| l1 ``` - -``` -// Universal layer - any platform, any blockchain app package nexum:host@0.1.0 world event-module { @@ -127,28 +98,18 @@ world event-module { import local-store - local key-value persistence import remote-store - decentralised storage (Swarm) import messaging - decentralised messaging (Waku) - import logging - log (trace/debug/info/warn/error) - - export init(config) - called once on load - export on_event(event) - called per subscribed event (block, logs, tick, message) -} - -// CoW Protocol extension -package shepherd:cow@0.1.0 + import logging - log (trace/debug/info/warn/error) -world shepherd { - include event-module - import cow-api - CoW Protocol REST API + order submission + export init(config) - called once on load + export on_event(event) - called per subscribed event (block, logs, tick, message) } ``` -The `event-module` world imports **six** interfaces - chain, identity, local-store, remote-store, messaging, logging. The 0.1 WIT framing claimed six primitives but only actually imported five; 0.2 brings `identity` into the world definition so the contract matches the documentation. +The `event-module` world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked ambiently, and modules that declare the `http` capability additionally import `wasi:http`. The `chain` interface exposes a single generic `request` function (plus an additive `request-batch`); the SDK implements alloy's `Transport` on top of it, giving modules the full alloy `Provider` API with zero WIT churn. -The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked into every store ambiently, and modules that declare the `http` capability additionally import `wasi:http`; all other I/O is mediated through host interfaces. The `chain` interface exposes a single generic `request` function (plus an additive `request-batch` in 0.2) - the SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API (80+ methods) with zero WIT churn. +CoW Protocol support is two packages. `shepherd:cow@0.1.0` carries `cow-events`, the canonical decoded on-chain event enum (topic-0 hashes for `ConditionalOrderCreated`, `ConditionalOrderRemoved`, `OrderPlacement`) that keepers and manifests are parity-tested against. Order submission is the `videre:venue@0.1.0` venue-adapter contract: a keeper drives venues through `videre:venue/client` by name, and each installed adapter component exports the provider face for one venue (the CoW venue is the `cow-venue` crate). See doc 08. -> Design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md) | Platform generalisation: [08-platform-generalisation.md](08-platform-generalisation.md) - --> Full WIT definition: [01-runtime-environment.md](01-runtime-environment.md) +> Design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md) | Platform generalisation and the venue layer: [08-platform-generalisation.md](08-platform-generalisation.md) | Full WIT: [01-runtime-environment.md](01-runtime-environment.md) ## Technology Stack @@ -156,7 +117,7 @@ The world imports no WASI interfaces. `wasi:clocks` and `wasi:random` are linked |---------|--------|---------| | Language | Rust | 1.90+ | | WASM runtime | wasmtime (Component Model) | 45.x | -| API contract | WIT (`nexum:host@0.1.0`, `shepherd:cow@0.1.0`) | - | +| API contract | WIT (`nexum:host@0.1.0`) | - | | Guest bindings | wit-bindgen | 0.57.x | | Async | Tokio | - | | Ethereum RPC | alloy | 1.5.x | @@ -177,9 +138,6 @@ name = "twap-monitor" version = "0.3.0" component = "sha256:9f86d081…" # content hash of module.wasm -[chains] -required = [42161] # must have RPC for these chains - [capabilities] required = ["chain", "local-store", "logging"] optional = ["messaging", "remote-store"] @@ -189,29 +147,20 @@ kind = "block" chain_id = 42161 [config] -cow_api_url = "https://api.cow.fi/arbitrum" -slippage_bps = 50 # integers stay integers in 0.2 +slippage_bps = 50 # integers stay integers ``` -The manifest declares identity, chain requirements, event subscriptions, capability grants, and typed module config - everything the runtime needs to load and run the module. In 0.2, `[capabilities]` is the canonical place to declare what host primitives a module needs; the engine cross-checks the component's WIT imports against `required` + `optional` at boot (link-time) and refuses to instantiate a module that imports an undeclared capability. Omitting `[capabilities]` falls back to "all imports required" with a deprecation warning. +The manifest declares chain requirements, event subscriptions, capability grants, and typed module config. `[capabilities]` is the canonical place to declare needed host primitives; the engine cross-checks the component's WIT imports against `required` + `optional` at boot (link-time) and refuses to instantiate a module that imports an undeclared capability. Omitting `[capabilities]` falls back to "all imports required" with a deprecation warning. -> Per-module resource caps (`[module.resources]`: `max_memory_bytes`, `max_fuel_per_event`, `max_state_bytes`) are **not in 0.2 scope** - the engine uses global defaults (`DEFAULT_FUEL_PER_EVENT = 1B`, `DEFAULT_MEMORY_LIMIT = 64 MiB`). Per-module overrides via the manifest are a future direction; today, an operator who needs different caps changes the global defaults at build time. The `optional` trap-stub fallback for absent host imports is also deferred to 0.3 - in 0.2, every linked import resolves to a real host function. +Resource caps are global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120). Per-module `[module.resources]` overrides are a 0.3 direction. -> Full spec: [02-modules-events-packaging.md](02-modules-events-packaging.md) ## Module Discovery -Three layers, from simplest to most decentralised: - -| Method | How it works | -|--------|-------------| -| **Static** | Operator points at a local manifest path | -| **ENS** | Module author sets ENS `contenthash` (ENSIP-7) to a Swarm/IPFS reference; runtime resolves and fetches | -| **On-chain registry** | Runtime watches contract events or ENS `TextChanged` events for module registrations | - -All methods converge: resolve content reference -> fetch via content store -> verify hash -> load. +0.2 loads modules from local filesystem paths listed in `engine.toml`. ENS `contenthash` resolution and on-chain registry discovery are a 0.3 design direction. --> Full design: [03-module-discovery.md](03-module-discovery.md) +-> Full design and current status: [03-module-discovery.md](03-module-discovery.md) ## Module Lifecycle @@ -231,12 +180,12 @@ stateDiagram-v2 Dead --> [*] ``` -- **Resolve**: fetch WASM by content hash from Swarm/IPFS/OCI/local. +- **Resolve**: fetch WASM by content hash (local in 0.2). - **Load**: compile `Component`, validate WIT world, create `InstancePre`. - **Init**: create `Store`, instantiate, call `init(config)`. - **Run**: dispatch subscribed events to `on_event`. Each call gets a fuel budget. -- **Restart**: on crash - exponential backoff (1s -> 5min cap), fresh `Store`, state persists. -- **Dead**: after N consecutive failures (poison pill) - requires manual intervention. +- **Restart**: on crash, exponential backoff (1s -> 5min cap), fresh `Store`, state persists. +- **Dead**: after N consecutive failures (poison pill), requires manual intervention. -> Full lifecycle: [02-modules-events-packaging.md](02-modules-events-packaging.md) @@ -245,109 +194,50 @@ stateDiagram-v2 - **Sources**: `block` (new heads via `eth_subscribe`), `chain-log` (filtered contract events), `cron` (schedule-based), `message` (Waku content topics). - **Shared subscriptions**: one block subscription per chain, fanned out to all subscribed modules. - **Dispatch**: concurrent across modules, sequential within a module (ordered delivery). -- **Declared in manifest**: `[[subscription]]` blocks - the runtime wires sources, not the module. +- **Declared in manifest**: `[[subscription]]` blocks; the runtime wires sources. -> Full design: [02-modules-events-packaging.md](02-modules-events-packaging.md) ## Local Store - **Backend**: redb (pure Rust, ACID, MVCC, crash-safe). -- **Isolation**: one database file per module; modules cannot access each other's state. -- **Transactions**: each `on_event` runs in an implicit write transaction - commit on success, rollback on failure. -- **Survives restarts**: state is external to WASM instance. -- **Size enforcement**: `max_state_bytes` from manifest, enforced host-side. -- **Prefix scanning**: `list-keys(prefix)` for namespaced key organisation. +- **Isolation**: modules cannot access each other's state. +- **Transactions**: each store call commits its own redb transaction; there is no per-event atomic rollback. +- **Survives restarts**: state is external to the WASM instance. +- **Isolation**: single redb file, 32-byte `keccak256(module_name)` key prefix (ADR-0003). +- **Size enforcement**: `[limits].state_bytes` quota, enforced host-side. +- **Prefix scanning**: `list-keys(prefix)` for namespaced keys. -> Full design: [04-state-store.md](04-state-store.md) ## SDK -The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (host trait seam, bind macro, chain / config / address helpers, wasi:http `http::fetch`, tracing facade) with `nexum-sdk-test` providing the generic mock-host surface, and `shepherd-sdk`, the CoW-domain layer (cow-api trait, order bridging, revert decoding) with `shepherd-sdk-test` providing the CoW mock host. Modules that never touch the orderbook depend only on the nexum pair. See [ADR-0009](adr/0009-host-trait-surface.md) for the shipped host-trait seam that replaces the proc-macro design described in earlier drafts of doc 05. +The guest SDK ships as `nexum-sdk` (the generic strategy-module SDK: host-trait seam, chain/config/address helpers, `http::fetch`, tracing facade) with `nexum-sdk-test` for the mock-host surface, and `videre-sdk` (the venue and keeper SDK: the `venue-adapter` export trait and the typed venue client) with `videre-test`. Modules are built with `cargo build --target wasm32-wasip2 --release`. The operator CLI is the `nexum` binary itself. -| Crate | Provides | -|-------|----------| -| `nexum-sdk` | `host::{ChainHost, LocalStoreHost, LoggingHost, Host}` - per-capability traits + supertrait, the seam modules implement against | -| | `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, Poller}` - 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`, `Verdict`, `LegacyRevertAdapter`, `RetryAction`, `classify_api_error`) | -| | `cow::run` - the shared poll-loop composition: run the keeper watch set, poll a `Poller`, 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 | -| `shepherd-sdk-test` | CoW `MockHost` + `MockCowApi`, composing the `nexum-sdk-test` mocks | +Multi-language support: authors can target the WIT world directly from Rust, C/C++, Go, JavaScript, or Python via `wit-bindgen`. The SDK is a Rust ergonomics layer. -Future direction (not in 0.2): a `#[nexum::module]` / `#[shepherd::module]` proc macro that subsumes the `wit_bindgen::generate!` + `WitBindgenHost` adapter boilerplate, a typed `TypedState` / `Signer` / `Cow` API client, alloy `Provider` injection via `HostTransport`, and filling out `nexum-sdk` into the full universal SDK for non-CoW modules. None of those land in 0.2. - -The operator CLI is the `nexum` binary itself (`cargo run -p nexum-cli`); a separate `cargo nexum` subcommand for module authors (new / build / package / publish / check / migrate) is future direction, not in 0.2 scope. Today modules are built with `cargo build --target wasm32-wasip2 --release`. - -Multi-language support: module authors can use Rust, C/C++, Go, JavaScript, or Python - all compile to valid components against the same WIT world via `wit-bindgen`. The SDK is a Rust ergonomics layer on top of the WIT contract; non-Rust authors target the WIT directly. - --> Full design: [05-sdk-design.md](05-sdk-design.md) | M3 architectural decision: [ADR-0009](adr/0009-host-trait-surface.md) - -`nexum-sdk-test` / `shepherd-sdk-test` above are for **module business logic** - no wasm, no engine crate. Testing the *engine* itself (supervision, dispatch, capability wiring, reconnect) is a different, wasm-backed surface: see [testing-runtime-harness.md](testing-runtime-harness.md). +-> Full design: [05-sdk-design.md](05-sdk-design.md) | Host-trait seam: [ADR-0009](adr/0009-host-trait-surface.md) ## Production Hardening -### Resource Enforcement - | Resource | Mechanism | On breach | |----------|-----------|-----------| -| CPU (deterministic) | Fuel | Trap -> rollback -> restart | -| CPU (wall-clock) | Epoch interruption | Yield to Tokio | +| CPU (deterministic) | Fuel | Trap -> restart | +| CPU (wall-clock) | Epoch interruption + dispatch deadline | Yield / abort dispatch | | Memory | `ResourceLimiter` | `memory.grow` denied | | Storage | Host-side tracking | `local-store::set` returns `fault.invalid-input` | -### RPC Resilience - -Tower layer stack per chain: timeout -> retry (exponential + jitter) -> rate limit -> fallback endpoint. WebSocket subscriptions auto-reconnect with missed-block backfill. - -### Error Model - -In 0.2 each interface declares its own typed error and they share one payload-bearing `fault` vocabulary for the cross-domain cases. `fault` has seven cases: `unsupported(string)`, `unavailable(string)`, `denied(string)`, `rate-limited(rate-limit)`, `timeout`, `invalid-input(string)`, and `internal(string)`. Interfaces with nothing to add report `fault` directly (identity, local-store, remote-store, messaging, and the module exports); a richer interface embeds `fault` as one case of its own variant and adds the cases only it needs (`chain-error` adds an `rpc` case carrying the node code and decoded revert bytes). Modules match on the typed variant for retry/backoff decisions; the per-protocol error types from 0.1 (`json-rpc-error`, `msg-error`, `store-error`, `api-error`) are gone. See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model. +Each interface declares its own typed error over a shared payload-bearing `fault` vocabulary (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add report `fault` directly; `chain-error` embeds `fault` and adds an `rpc` case. See [ADR-0011](adr/0011-per-interface-typed-errors.md). -### Observability - -| Signal | Stack | Endpoint | -|--------|-------|----------| -| Logs | `tracing` -> JSON | stdout | -| Metrics | `metrics` -> Prometheus | `:9100/metrics` (default; see `docs/production.md`) | - -Metrics cover three groups: runtime-level (modules loaded/dead), per-module (events, latency, fuel, restarts, state usage), per-chain RPC (requests, errors, fallbacks, blocks behind). Liveness is signalled by the metrics scrape (`/metrics` returns 200 iff the engine is running and the Prometheus exporter is up) plus the structured `tracing` JSON on stdout. A dedicated `:8080/health` JSON endpoint with a per-module table is a future direction, not in 0.2 scope - operators today scrape `/metrics` and inspect the JSON log stream. +Observability: `tracing` JSON to stdout; a Prometheus exporter on `127.0.0.1:9100/metrics` when `[engine.metrics]` is enabled (disabled by default). -> Full design: [06-production-hardening.md](06-production-hardening.md) ## Platform Generalisation -Nexum is **designed** to be portable to mobile and browser hosts: the WIT contract is the universal interface and any host that implements it can run modules unchanged. The **0.2 reference runtime ships server-only** - a Rust/Tokio/wasmtime binary. The mobile, WebView, and super-app targets remain on the roadmap and live in the docs as architectural direction, not shipping artifacts. - -| Platform | WASM Engine | Local Store | RPC Backend | Status | -|----------|-------------|-------------|-------------|--------| -| **Server** (reference) | wasmtime | redb | alloy provider | **Shipping in 0.2** | -| **Mobile** (Flutter/Dart) | wasmtime C API / wasm3 | SQLite | HTTP client | Planned - see roadmap | -| **WebView** | Browser engine + `jco` | IndexedDB | JS bridge / wallet | Planned - see roadmap | -| **Super app** | All of the above | SQLite | HTTP + wallet | Planned - see roadmap | - -The mobile/wallet host story - including the experimental `query-module` world's production support, the C ABI for non-Rust embedders, and the `nexum-host` embedder facade - is on the 0.3 roadmap, conditional on a named design partner. A minimal Rust embedding path already exists today via the `nexum-runtime` library entrypoint, with the richer facade remaining a 0.3 direction. +The `nexum:host` WIT contract is host-portable: any host implementing it can run modules unchanged. The 0.2 reference runtime is server-only (Rust/Tokio/wasmtime). Mobile, WebView, and super-app targets are architectural direction. --> Full design (and the design rationale for each target): [08-platform-generalisation.md](08-platform-generalisation.md) - -## Grant Milestones - -| # | Milestone | Effort | Key Deliverables | -|---|-----------|--------|------------------| -| 1 | Core Runtime & Event System | 120h | wasmtime Component Model host, WIT interfaces, event sources, redb local store, CLI | -| 2 | TWAP & Ethflow Modules | 100h | TWAP monitor, Ethflow monitor, ComposableCoW contract mods\* | -| 3 | SDK & Developer Experience | 60h | `shepherd-sdk` + `shepherd-sdk-test` crates (host-trait seam per ADR-0009), example modules, tutorial, docs | -| 4 | Production Hardening | 60h | Resource limits, restart policy, logging, metrics, health checks | -| 5 | Multi-Chain & Deployment | 40h | Multi-chain config, Docker image, deployment docs | - -\* **M2 divergence.** The "ComposableCoW contract mods" deliverable (enhanced polling interfaces, optimized getters, monitoring events) was intentionally met off-chain: no Solidity was modified. The TWAP module polls `getTradeableOrderWithSignature` via raw `eth_call` with SDK helpers. Contract-side interfaces would fix one concrete TWAP implementation behind the boundary and block competing strategies (ADR-0006). The functional goal stands; the literal deliverable does not. +-> Full design and the venue layer: [08-platform-generalisation.md](08-platform-generalisation.md) ## Repository Structure @@ -355,30 +245,30 @@ The mobile/wallet host story - including the experimental `query-module` world's shepherd/ ├── crates/ │ ├── nexum-runtime/ Core WASM host (server) library: event system, local store, bootstrap -│ ├── nexum-cli/ The `nexum` binary: clap CLI entry point over the runtime library -│ ├── nexum-sdk/ Generic guest SDK: host-trait seam, Fault, chain/config/address helpers, wasi:http fetch, tracing facade (ADR-0009) -│ ├── nexum-sdk-test/ Generic mock host (MockChain / MockLocalStore / MockLogging) for strategy tests -│ ├── shepherd-sdk/ CoW-domain SDK: cow-api trait + CoW Protocol helpers on top of nexum-sdk -│ └── shepherd-sdk-test/ CoW mock host (MockCowApi + composed MockHost) for strategy tests +│ ├── nexum-cli/ The `nexum` binary: clap CLI over the runtime library +│ ├── nexum-sdk/ Generic guest SDK: host-trait seam, chain/config/address helpers, wasi:http fetch +│ ├── nexum-sdk-test/ Generic mock host for strategy tests +│ ├── videre-sdk/ Venue + keeper SDK: venue-adapter export trait, typed venue client +│ ├── videre-host/ Host-side venue registry + status watch +│ ├── videre-test/ Venue/keeper test surface +│ └── cow-venue/ The CoW venue: order body types + IntentBody codec ├── modules/ │ ├── twap-monitor/ TWAP order monitoring module │ ├── ethflow-watcher/ Ethflow order monitoring module -│ └── examples/ price-alert, balance-tracker, http-probe reference modules +│ └── examples/ reference modules (price-alert, balance-tracker, http-probe, echo-*) ├── wit/ │ ├── nexum-host/ Universal WIT package (chain, identity, local-store, remote-store, messaging, logging) -│ └── shepherd-cow/ CoW Protocol WIT package (cow-api, shepherd) +│ ├── shepherd-cow/ CoW event enum (cow-events) +│ └── videre-venue/ Venue-adapter contract (client + adapter faces) ├── Dockerfile ├── docker-compose.yml └── docs/ - ├── 00-overview.md - ├── 01-runtime-environment.md … 08-platform-generalisation.md - ├── adr/ ADR-0001 … ADR-0009 (canonical architectural decisions) + ├── 00-overview.md … 08-platform-generalisation.md + ├── adr/ Architectural decision records ├── deployment/ Docker + Prometheus operator config ├── diagrams/ Mermaid diagrams + reference captions - ├── operations/ Runbooks, E2E reports, load reports, baselines + ├── operations/ Runbooks ├── production.md Operator handbook - ├── sdk.md Module-author entry point (shipped SDK reference) + ├── sdk.md Module-author entry point └── tutorial-first-module.md ``` - -The SDK split is in place: `nexum-sdk` carries the universal surface and `shepherd-sdk` layers the CoW domain on top, with no re-export between them. Shipping a `cargo-nexum` subcommand for module authors remains future direction. diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 8ad5f84f..6dff10d1 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -2,54 +2,19 @@ ## Version Target -**wasmtime 45.x** (latest stable as of Feb 2026). +**wasmtime 45.x**, requiring **Rust 1.90.0+**. Guest bindings pin `wit-bindgen` 0.57.x. -- Release cadence: new major on the 20th of each month. -- LTS every 12th version (24 months support). Nearest LTS: v36. -- Requires **Rust 1.90.0+**. -- Repo: https://github.com/bytecodealliance/wasmtime +## Component Model -## Why wasmtime +The engine targets the Component Model, not raw core modules, for: -| Criterion | wasmtime | wasmer | wasm3 | -|-----------|----------|--------|-------| -| Rust-native embedding | First-class | Yes | C FFI | -| Async host functions | Yes | No | No | -| Component Model / WASI | Full | Partial | No | -| Fuel / epoch metering | Both | Fuel only | Injection | -| Production users | Fastly, Fermyon, Cloudflare, Zed | General | Embedded | -| Sandboxing | Proven | Similar | Similar | +- **Structural sandboxing.** A component compiled against a WIT world with no filesystem import cannot access the filesystem: enforced at the type level, not by omission of host functions. +- **Type-safe contract.** The WIT definition is the API spec; host and guest get generated bindings (`wasmtime::component::bindgen!`, `wit_bindgen::generate!`). +- **Resource types.** Opaque handles with lifecycle management via `ResourceTable`. +- **Multi-language guests.** Rust, C/C++, Go, JavaScript, Python all produce valid components against the same WIT world. +- **No WASI required.** The pure `nexum:host` world imports exactly the host APIs; zero WASI imports means zero implicit capabilities. -## Decision: Component Model from Day 1 - -### Rationale - -The Component Model is **production-viable in wasmtime 45** and gives us critical advantages over raw core modules: - -1. **Structural sandboxing.** A component compiled against a WIT world with no filesystem import literally *cannot* access the filesystem - enforced at the type level, not just by omission of host functions. This is stronger than core module sandboxing where imports are stringly-typed. - -2. **Type-safe API contract.** The WIT definition *is* the API spec. Both host and guest get generated bindings (`wasmtime::component::bindgen!` on the host, `wit_bindgen::generate!` on the guest). No manual ABI wrangling, no serialisation disagreements. - -3. **Resource types.** Opaque handles with lifecycle management (constructors, methods, destructors via `ResourceTable`). Ideal for subscription handles, RPC connections, etc. - -4. **Multi-language guests from day 1.** Module authors can use Rust, C/C++, Go, JavaScript (ComponentizeJS), or Python (componentize-py) - all producing valid components against the same WIT world. This dramatically lowers the barrier for community modules. - -5. **No WASI required.** The Component Model and WASI are architecturally separate. We define a pure `nexum:host` world with exactly our host APIs. Zero WASI imports means zero implicit capabilities. - -6. **Acceptable overhead.** The canonical ABI adds marshalling for strings/lists (memory copy across boundary), but for a plugin system with coarse-grained calls this is negligible. `InstancePre` front-loads validation costs. - -### What we give up - -- **Tooling churn.** `wit-bindgen` (v0.57) and `cargo-component` (v0.21) are functional but APIs are not yet stable. Pin versions in the SDK. -- **Native async Component Model** (`stream`, `future`) is still evolving. We use basic async host functions (`func_wrap_async`) which are stable. - -### Risk assessment - -| Aspect | Risk | -|--------|------| -| `bindgen!` macro, custom worlds, resource types | Low - stable, well-documented | -| `wit-bindgen` guest bindings | Medium - API churn between versions | -| Component Model native async (streams/futures) | High - not needed yet, avoid for now | +The engine uses wasmtime's basic async host functions (`func_wrap_async`), not the still-evolving Component Model native async (`stream`, `future`). ## Core Concepts @@ -97,9 +62,9 @@ let pre = linker.instantiate_pre(&component)?; let bindings = EventModule::instantiate_pre(&mut store, &pre)?; ``` -## WIT Worlds: Universal and CoW-Specific +## WIT Worlds -Nexum uses a two-layer WIT architecture. The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. The **CoW-specific** package `shepherd:cow` extends it with CoW Protocol interfaces and the `shepherd` world. +The **universal** package `nexum:host` defines platform-agnostic interfaces and the `event-module` world. CoW Protocol support layers on top: `shepherd:cow` carries the `cow-events` enum, and order submission is the `videre:venue` venue-adapter contract (below). ### Universal Package: `nexum:host@0.1.0` @@ -303,87 +268,44 @@ world event-module { In addition to the six core imports, 0.2 publishes one additive optional capability - `http` (allowlisted outbound HTTP) - which modules declare in their `module.toml` `[capabilities]` section. The declaration is a manifest concern only: the capability is serviced by the standard `wasi:http/outgoing-handler` interface, not a `nexum:host` one. 0.2 also publishes the experimental **`query-module`** world for request/response modules; the WIT is stable but no host implementation ships in 0.2, so it's a target for `MockHost` testing only. -### CoW-Specific Package: `shepherd:cow@0.1.0` +### CoW Protocol packages -The `shepherd:cow` package extends the universal world with CoW Protocol interfaces. In 0.2 the two 0.1 interfaces (`cow` + `order`) merge into a single `cow-api` interface to eliminate the `cow::cow::request` triple-stutter: +`shepherd:cow@0.1.0` carries a single interface, `cow-events`: the canonical decoded on-chain event enum whose variants pin each CoW Solidity signature and its topic-0 hash. Keeper constants and module manifests are parity-tested against it. ```wit package shepherd:cow@0.1.0; -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A non-2xx reply with no typed rejection envelope; `body` is raw text. - record http-failure { status: u16, body: option } - - /// A typed orderbook rejection, parsed host-side from `{errorType, description}`. - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A cow-api call failure: a shared host `fault`, a raw HTTP failure, - /// or a typed order rejection. - variant cow-api-error { - fault(fault), - http(http-failure), - rejected(order-rejection), +interface cow-events { + enum cow-event { + conditional-order-created, // ComposableCoW registration + conditional-order-removed, // ComposableCoW v2 removal + order-placement, // CoWSwapOnchainOrders (EthFlow) } - - /// HTTP-style request to the CoW Protocol API. - /// - /// The host routes to the correct CoW API base URL for the given chain. - /// `method`: "GET" | "POST" | "PUT" | "DELETE" - /// `path`: relative API path, e.g. "/api/v1/orders" - /// `body`: optional JSON request body - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order to the CoW Protocol. - /// (Replaces the 0.1 `order::submit` interface.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} - -/// CoW Protocol module world. Extends the universal event-module -/// with CoW-specific imports. -world shepherd { - include nexum:host/event-module; - - import cow-api; } ``` +Order submission is not a host interface. It is the `videre:venue@0.1.0` venue-adapter contract: a keeper calls `videre:venue/client` (`quote` / `submit` / `observe` / `status` / `cancel`) naming a venue by string, and each installed adapter component exports the provider face for one venue over scoped transport only. The CoW venue is the `cow-venue` crate. See doc 08 for the venue layer. + ### Key properties - **Constrained WASI** - the WASI p2 surface linked into every store includes `wasi:clocks` and `wasi:random` ambiently; there is no filesystem grant and no inbound network. The only network path is allowlisted outbound HTTP through `wasi:http/outgoing-handler`, available to modules that declare the `http` capability in the manifest's `[capabilities]` section. The `wasi:sockets` bindings are linked as part of the p2 surface but stay inert because the WASI context grants no network. -- **All I/O through our interfaces** - RPC reads, identity/signing, CoW API, local-store, order submission, logging. +- **All I/O through host interfaces** - RPC reads, identity/signing, local-store, messaging, logging; venue submission through `videre:venue/client`. - **Generic JSON-RPC passthrough** - the `chain` interface exposes a single `request` function (plus an additive `request-batch`). The SDK implements alloy's `Transport` trait on top of it, giving modules the full alloy `Provider` API. See doc 07 for details. -- **Identity as a first-class primitive** - the `identity` interface provides key management and signing. The `chain` host implementation depends on `identity` internally: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for `personal_sign`-style message signing, EIP-712 typed data signing, and listing accounts. (Raw-bytes signing, gated by an explicit capability, is on the 0.3 roadmap; the current `sign` MUST prepend the EIP-191 prefix.) -- **Per-interface typed errors over a shared `fault` vocabulary** - each interface declares its own error type; the cross-domain cases share one payload-bearing `fault` (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add return `fault` directly (identity, local-store, remote-store, messaging, the module exports); `chain-error` embeds `fault` and adds an `rpc` case, `cow-api-error` adds `http` and `rejected`. The 0.1 per-protocol error types (`json-rpc-error`, `identity-error`, `msg-error`, `store-error`, `api-error`) are gone. Modules match on the typed variant for retry/backoff decisions. See ADR-0011. -- **`list` for raw bytes** - local-store values, order payloads, signatures, accounts, etc. The SDK provides typed wrappers. -- **Resource types** can be added later (e.g. subscription handles, cursor-based log iteration). -- **Two worlds in 0.2's reference runtime** - `nexum:host/event-module` for platform-agnostic modules; `shepherd:cow/shepherd` for CoW Protocol modules that need the `cow-api` import. The experimental `nexum:host/query-module` world is published but not yet hosted. +- **Identity as a first-class primitive** - the `identity` interface provides key management and signing. The `chain` host implementation depends on `identity`: signing RPC methods (`eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`) are intercepted and delegated to the identity backend. Modules can also import `identity` directly for `personal_sign` message signing, EIP-712 typed data signing, and listing accounts. `sign` prepends the EIP-191 prefix; a raw-bytes signing primitive is a 0.3 direction. +- **Per-interface typed errors over a shared `fault` vocabulary** - each interface declares its own error type; the cross-domain cases share one payload-bearing `fault` (`unsupported`, `unavailable`, `denied`, `rate-limited`, `timeout`, `invalid-input`, `internal`). Interfaces with nothing to add return `fault` directly (identity, local-store, remote-store, messaging, the module exports); `chain-error` embeds `fault` and adds an `rpc` case. Modules match on the typed variant for retry/backoff decisions. See ADR-0011. +- **`list` for raw bytes** - local-store values, signatures, accounts, order bodies. The SDK provides typed wrappers. +- **Worlds** - `nexum:host/event-module` for automation modules; `videre:venue/venue-adapter` for venue adapter components. The experimental `nexum:host/query-module` world is published but not yet hosted. ## Host-Side Embedding -The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT. For universal interfaces, the generated traits live under `nexum::host::`. For CoW-specific interfaces, they live under `shepherd::cow::`. +The host uses `wasmtime::component::bindgen!` to generate Rust traits from the WIT; the generated traits for universal interfaces live under `nexum::host::`. ```rust -// Universal event-module world wasmtime::component::bindgen!({ path: "wit/nexum-host", world: "event-module", async: true, }); - -// CoW-specific shepherd world (extends event-module) -wasmtime::component::bindgen!({ - path: "wit/shepherd-cow", - world: "shepherd", - async: true, -}); ``` ### Identity Host Trait @@ -498,101 +420,13 @@ impl nexum::host::local_store::Host for NexumHostState { } // ... } - -impl shepherd::cow::cow_api::Host for NexumHostState { - // CoW-specific host implementation - // ... -} ``` -See doc 07 for the full `chain` and `cow-api` host implementations, method allowlisting, and the `HostTransport` that bridges this to alloy's `Provider` API on the guest side. +See doc 07 for the full `chain` host implementation, method allowlisting, and the `HostTransport` that bridges it to alloy's `Provider` API on the guest side. ## Guest-Side (Module Author) Experience -> The two subsections below describe the **0.3+ macro-driven authoring model** (`#[nexum::module]` / `#[shepherd::module]`, alloy `RootProvider` injection, `TypedState`). It is future direction, not in 0.2 scope. In 0.2, modules ship today using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `strategy.rs` (pure logic against `&impl Host`) plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. See [`sdk.md`](sdk.md) and the example modules under `modules/examples/` for the shipped pattern. - -### Universal modules (future direction; `nexum-sdk`) - -In the future direction, module authors targeting the universal `event-module` world would add the `nexum-sdk` crate and use the `#[nexum::module]` proc macro. Modules can access identity for signing operations - either indirectly through `chain` (signing RPC methods are handled transparently) or directly via the `identity` interface for raw signing: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct BlockLogger; - -impl BlockLogger { - fn init(config: Config) -> Result<()> { - info!("Block logger starting"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("New block: {block_num}"); - - TypedState::set("last_block", &block_num)?; - Ok(()) - } -} -``` - -### CoW Protocol modules (future direction; `shepherd-sdk` macro form) - -In the future direction, module authors targeting the CoW-specific `shepherd` world would add the `shepherd-sdk` crate and use the `#[shepherd::module]` proc macro. The macro provides **named event handlers** (`on_block`, `on_chain_logs`, `on_tick`, `on_message`) - it generates the `on_event` match dispatch, WIT export wrapper, and optional provider injection. Handlers can be `async fn` for natural `.await`: - -```rust -use shepherd_sdk::prelude::*; - -sol! { - function getTradeableOrderWithSignature( - address owner, bytes32 ctx, bytes32 orderHash - ) external view returns (bytes memory order, bytes memory signature); -} - -#[shepherd::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - info!("TWAP monitor starting"); - Ok(()) - } - - // Named handler - macro generates on_event match dispatch. - // provider is injected from block.chain_id. - // async fn - macro wraps in block_on (single-poll, zero overhead). - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API - natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract) - .input(getTradeableOrderWithSignatureCall { - owner, ctx, orderHash: order_hash, - }.abi_encode().into()); - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - - // CoW API via typed client - let cow = Cow::new(block.chain_id); - cow.submit_order(&order)?; - - // State persistence - TypedState::set("last_block", &block_num)?; - Ok(()) - } - - // Only define handlers for events you subscribe to. - // No on_chain_logs, on_tick, or on_message → those events are silently ignored. -} -``` - -Build with `cargo component build --release` (or `cargo build --target wasm32-wasip2` + `wasm-tools component new`). - -See doc 05 for the full macro design (named handlers, provider injection, escape hatch) and doc 07 for the `HostTransport` implementation and `provider()` constructor. +Modules ship using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `strategy.rs` of pure logic against `&impl Host`, plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. Build with `cargo build --target wasm32-wasip2 --release`. See [`sdk.md`](sdk.md), doc 05, and the example modules under `modules/examples/`. ## Multi-Language Guest Support @@ -605,7 +439,7 @@ See doc 05 for the full macro design (named handlers, provider injection, escape | **Python** | componentize-py (CPython) | Maturing | | **C#** | `wit-bindgen-csharp` | Emerging | -All produce valid components against the same WIT worlds (`nexum:host/event-module` for universal, `shepherd:cow/shepherd` for CoW). +All produce valid components against the `nexum:host/event-module` world. ## Execution Metering @@ -624,17 +458,11 @@ Both are needed: fuel for correctness, epochs for liveness. ## Resource Limits -Implement `ResourceLimiter` to cap per-module: - -- **Memory growth** - target <10 MB default. -- **Table growth** - max entries. -- **Instance count** - max concurrent. - -Enforced synchronously on every `memory.grow` / `table.grow`. +A `ResourceLimiter` caps linear-memory growth per module store, enforced synchronously on every `memory.grow`. The cap is `[limits].memory_bytes` from `engine.toml` (default 64 MiB). Fuel, the per-dispatch wall-clock deadline, and the local-store byte quota are the other resolved caps (`fuel_per_event` 1B, `event_deadline_secs` 120, `state_bytes` 50 MiB); all live in `crates/nexum-runtime/src/engine_config.rs` and apply uniformly, per-module overrides being a 0.3 direction. ## Async Integration -All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges this: +All RPC and outbound I/O is async (alloy / reqwest on the host). wasmtime bridges this: - `Config::async_support(true)`. - Host functions registered with `func_wrap_async` (or via `async: true` in `bindgen!`). @@ -659,7 +487,7 @@ All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges |------------------|--------------------| | Runtime process | `Engine` (one, shared) | | Universal API contract | WIT world (`nexum:host/event-module`) | -| CoW API contract | WIT world (`shepherd:cow/shepherd`) | +| Venue adapter contract | WIT world (`videre:venue/venue-adapter`) | | Compiled module | `Component` (cached, thread-safe) | | Pre-validated module | `InstancePre` (linker + component) | | Running instance | `Store` + `Instance` | @@ -669,5 +497,5 @@ All RPC and CoW API I/O is async (alloy / reqwest on the host). wasmtime bridges | Per-call budget | Fuel | | Wall-clock fairness | Epoch interruption | | Memory/table caps | `ResourceLimiter` | -| Async RPC / CoW I/O | `func_wrap_async` + Tokio | +| Async RPC / outbound I/O | `func_wrap_async` + Tokio | | Persistent state | redb (per-module database file, via `local-store` interface host fns) | diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 15c1e0c4..e9da3b92 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -2,7 +2,7 @@ ## Module Package: the Nexum Module Bundle -A module is distributed as a **bundle** - a WASM component plus a manifest that declares its identity, event subscriptions, chain requirements, and resource limits. The manifest is the bridge between packaging, the event system, and the runtime lifecycle. +A module is distributed as a **bundle** - a WASM component plus a manifest that declares its identity, event subscriptions, chain requirements, and capability grants. The manifest is the bridge between packaging, the event system, and the runtime lifecycle. ### Manifest (`module.toml`) @@ -60,7 +60,7 @@ Key design points: - **Chain ids are declared per-subscription**, not in a top-level `[chains]` table - each `[[subscription]]` names its own `chain_id`. If `engine.toml` has no `[chains.]` entry for a chain a subscription names, the engine bails at boot, before any events dispatch (fast, clear error). - **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. -> **Future direction (not in 0.2):** per-module resource caps via `[module.resources]` (`max_memory_bytes`, `max_fuel_per_event`, `max_state_bytes`), per-module restart policy via `[module.restart]`, and `optional`-import trap stubs that return `fault.unsupported` on call. The 0.2 engine enforces resource limits using global defaults (`DEFAULT_FUEL_PER_EVENT = 1B`, `DEFAULT_MEMORY_LIMIT = 64 MiB` from `crates/nexum-runtime/src/runtime/limits.rs`) and uses a global restart policy. Per-module overrides are on the 0.3 roadmap. +> Resource caps are engine-global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120), resolved in `crates/nexum-runtime/src/engine_config.rs`. Per-module `[module.resources]` overrides, per-module restart policy, and `optional`-import trap stubs are 0.3 directions. ### Bundle Format @@ -165,9 +165,9 @@ stateDiagram-v2 | State | Description | |-------|-------------| | **Resolve** | Content store resolves `component` hash to local path. Fail -> `Dead`. | -| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the target WIT world (`nexum:host/event-module` or `shepherd:cow/shepherd`). Installs trap stubs for capabilities the manifest declares `optional` but the host does not provide. Fail -> `Dead`. | -| **Init** | Create `Store`, instantiate, call `init(config)` inside an implicit write transaction (same semantics as `on_event` - commit on success, rollback on failure). Module sets up internal state. Fail -> `Restart` (might be transient). | -| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (chain, local-store, identity, cow-api, etc.). | +| **Load** | `Component::from_file`, create `InstancePre`. Validates that the component satisfies the `nexum:host/event-module` world. Fail -> `Dead`. | +| **Init** | Create `Store`, instantiate, call `init(config)`. Each local-store write commits its own transaction; there is no per-call atomic rollback. Module sets up internal state. Fail -> `Restart` (might be transient). | +| **Run** | Runtime dispatches events to `on_event`. Each call gets a fuel budget. Module processes events and may call host imports (chain, local-store, identity, messaging, logging). | | **Restart** | After a trap or error. Backoff: 1s -> 2s -> 4s -> ... -> 5min cap. A fresh `Store` is created (clean memory), but **local-store data persists** (it's in redb, external to the WASM instance). | | **Dead** | After N consecutive failures (poison pill detection) or explicit operator shutdown. No further event dispatch. Requires manual intervention. | @@ -275,7 +275,7 @@ The runtime serialises event data via the canonical ABI (handled automatically b ## Updated WIT Worlds -The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The architecture uses two packages: `nexum:host` for universal interfaces and `shepherd:cow` for CoW Protocol extensions. +The initial WIT in `01-runtime-environment.md` is extended to support the lifecycle and config. The universal package is `nexum:host`; CoW Protocol support layers on via `shepherd:cow` (the `cow-events` enum) and the `videre:venue` venue-adapter contract. ### Universal Package: `nexum:host@0.1.0` @@ -409,61 +409,26 @@ world event-module { } ``` -### CoW-Specific Package: `shepherd:cow@0.1.0` +### CoW Protocol packages -```wit -package shepherd:cow@0.1.0; - -interface cow-api { - use nexum:host/types.{chain-id, fault}; - - /// A raw non-2xx reply, or a typed orderbook rejection parsed host-side. - record http-failure { status: u16, body: option } - record order-rejection { status: u16, error-type: string, description: string, data: option } - - /// A shared host `fault`, a raw HTTP failure, or a typed order rejection. - variant cow-api-error { fault(fault), http(http-failure), rejected(order-rejection) } - - /// HTTP-style request to the CoW Protocol API. - request: func( - chain-id: chain-id, - method: string, - path: string, - body: option, - ) -> result; - - /// Submit a serialised order. (Merged in from the 0.1 `order` interface.) - submit-order: func(chain-id: chain-id, order-data: list) - -> result; -} - -/// CoW Protocol module world - extends event-module with cow-api. -world shepherd { - include nexum:host/event-module; - - import cow-api; -} -``` +`shepherd:cow@0.1.0` carries the `cow-events` enum (canonical CoW on-chain event signatures and topic-0 hashes). Order submission is the `videre:venue@0.1.0` venue-adapter contract: a keeper drives venues through `videre:venue/client`, and each installed adapter component (the CoW venue is the `cow-venue` crate) exports the provider face for one venue. See doc 08. ## Putting It All Together Operator deploys a module: ``` -1. Operator adds entry to runtime config: +1. Operator adds entry to engine.toml: [[modules]] - manifest = "/var/nexum/twap-monitor/module.toml" + path = "/var/nexum/twap-monitor/twap_monitor.wasm" -2. Runtime reads manifest: - - Resolves component content hash → fetches from Swarm/local/OCI - - Verifies integrity (sha256 match) +2. Runtime reads the sibling module.toml and verifies sha256(module.wasm) + against the manifest component hash. 3. Runtime compiles Component, creates InstancePre: - - Validates component satisfies target world - (nexum:host/event-module or shepherd:cow/shepherd) - - Installs trap stubs for any [capabilities].optional imports the host doesn't provide - - Enforces resource limits from manifest + - Validates component satisfies the nexum:host/event-module world + - Cross-checks WIT imports against [capabilities] required + optional (link-time) 4. Runtime calls init(config): - Module receives [config] section as typed key-value pairs @@ -478,7 +443,8 @@ Operator deploys a module: Block 19_000_001 on Arbitrum → Router → twap-monitor's dispatch queue → Tokio task calls on_event(Event::Block(…)) - → Module calls chain::request (via alloy Provider), local-store get, cow-api submit-order + → Module calls chain::request (via alloy Provider), local-store get, + videre:venue/client submit → Returns Ok(()) - runtime logs success 7. On crash: diff --git a/docs/03-module-discovery.md b/docs/03-module-discovery.md index 349e601c..21812b02 100755 --- a/docs/03-module-discovery.md +++ b/docs/03-module-discovery.md @@ -1,279 +1,42 @@ # Module Discovery -Doc 02 defines how modules are packaged (bundle = `module.toml` + `module.wasm`) and how content is fetched by hash (pluggable content store). This document defines how the runtime **discovers which modules to load** - the layer above content resolution. +Doc 02 defines how modules are packaged (bundle = `module.toml` + `module.wasm`) and how content is fetched by hash. This document defines how the runtime **discovers which modules to load**. -Three discovery sources, from simplest to most decentralised: +## 0.2: static (local path) -```mermaid -flowchart TB - subgraph runtime["Nexum Runtime"] - subgraph discovery["Module Discovery"] - static["Static\n(local)"] - ens["ENS\n(name)"] - registry["Registry\n(contract)"] - end - subgraph content["Content Store (doc 02)\nSwarm / IPFS / OCI / local / HTTPS"] - end - static --> content - ens --> content - registry --> content - end -``` - -## 1. Static (local path) - -Operator points the runtime at a local manifest. No on-chain interaction. +The 0.2 engine loads modules from local filesystem paths listed in `engine.toml`. Each `[[modules]]` entry names the compiled component and, optionally, its manifest (defaulting to a sibling `module.toml`): ```toml [[modules]] -source = "static" +path = "/var/nexum/twap-monitor/twap_monitor.wasm" manifest = "/var/nexum/twap-monitor/module.toml" ``` -Use case: local development, air-gapped deployments, CI testing. - -## 2. ENS Name Resolution - -A module author publishes their bundle to Swarm (or IPFS) and associates it with an ENS name. The runtime resolves the name to a content reference, fetches the bundle, and loads it. +This is the whole of discovery in 0.2. Content-addressed resolution (Swarm / IPFS / OCI) and `[[content.sources]]` are not wired: `EngineConfig::modules` resolves a `(component.wasm, module.toml)` pair on disk, nothing more (see `crates/nexum-runtime/src/engine_config.rs`). -### How it works +## 0.3 direction: ENS and on-chain registry -ENS already has native support for content-addressed storage: - -- **`contenthash`** (ENSIP-7 / EIP-1577): binary field that encodes a protocol code + content hash. Swarm is protocol `0xe4`, IPFS is `0xe3`. This is the primary pointer to the bundle. -- **Text records** (ENSIP-5 / EIP-634): arbitrary key-value UTF-8 strings. Applications use reverse-domain keys to avoid collisions. - -A module author sets up their ENS name: - -``` -twap-monitor.shepherd.eth -├── contenthash → 0xe40101fa011b20{32-byte-keccak256} -│ (Swarm reference to the bundle) -├── text: shepherd.version → "0.2.0" -├── text: shepherd.chains → "42161,1" -└── text: shepherd.name → "twap-monitor" -``` +The remainder of this document is design contract for a future minor release, not shipped behaviour. It is retained because the WIT and manifest shapes are stable enough to build against. -The `contenthash` points to the full bundle on Swarm (a directory containing `module.toml` + `module.wasm`). Text records provide lightweight metadata the runtime can read without fetching the bundle - useful for filtering or display. +### ENS name resolution -### Runtime resolution flow +A module author publishes a bundle to Swarm (or IPFS) and points an ENS name at it. The runtime would resolve the name to a content reference, fetch the bundle, and load it. -```mermaid -sequenceDiagram - participant R as Nexum Runtime - participant ENS as ENS Registry - participant Resolver as Resolver Contract - participant Swarm as Content Store (Swarm) - - R->>ENS: 1. Resolve ENS name - ENS-->>R: Resolver contract address - R->>Resolver: 2. resolver.contenthash(namehash) - Resolver-->>R: Encoded content reference - R->>R: 3. Decode: protocol 0xe4 (Swarm) + keccak256 hash - R->>Swarm: 4. Fetch bundle from Swarm - Swarm-->>R: Bundle (module.toml + module.wasm) - R->>R: 5. Verify bundle integrity (sha256 of module.wasm matches manifest) - R->>R: 6. Load module via standard lifecycle (doc 02) -``` - -### Runtime config - -```toml -[[modules]] -source = "ens" -name = "twap-monitor.shepherd.eth" -chain_id = 1 # which chain to resolve ENS on -poll_interval = "5m" # check for updates - -[[modules]] -source = "ens" -name = "ethflow.shepherd.eth" -chain_id = 1 -poll_interval = "5m" -``` +- **`contenthash`** (ENSIP-7 / EIP-1577): binary field encoding a protocol code plus content hash (Swarm `0xe4`, IPFS `0xe3`). The primary pointer to the bundle. +- **Text records** (ENSIP-5 / EIP-634): lightweight metadata (version, chains, name) readable without fetching the bundle. -### Updates - -When the module author publishes a new version, they: -1. Upload the new bundle to Swarm → get new content hash -2. Update the ENS `contenthash` record - -The runtime detects the change on its next poll (or via event - see below), fetches the new bundle, and hot-reloads the module. - -## 3. On-Chain Registry (Contract Events) - -For fully autonomous discovery - the runtime watches a contract for registration events and auto-loads modules without operator intervention. - -### Option A: Dedicated registry contract - -A simple contract where module authors register their ENS name: - -```solidity -// SPDX-License-Identifier: AGPL-3.0 -pragma solidity ^0.8.0; - -interface INexumRegistry { - event ModuleRegistered( - string indexed ensNameHash, - string ensName, - address indexed registrant - ); - event ModuleRemoved( - string indexed ensNameHash, - string ensName - ); - - function register(string calldata ensName) external; - function remove(string calldata ensName) external; -} -``` - -The runtime subscribes to `ModuleRegistered` events, resolves the ENS name from the event, and enters the ENS resolution flow above. - -### Option B: No ad-hoc registry - contracts self-declare via ENS - -This is the more decentralised approach. Instead of a central registry: - -1. **Any contract** can associate itself with a Nexum module by setting a text record on its own ENS name. -2. The runtime watches for `TextChanged` events on the ENS Public Resolver filtered to the `shepherd.module` key. - -For example, ComposableCoW (`composablecow.cow.eth`) sets: - -``` -composablecow.cow.eth -├── text: shepherd.module → "twap-monitor.shepherd.eth" -``` +Resolution flow: resolve ENS name -> resolver `contenthash(namehash)` -> decode protocol + hash -> fetch bundle from the content store -> verify `sha256(module.wasm)` against the manifest -> load via the standard lifecycle (doc 02). On a `contenthash` change the runtime re-fetches and hot-reloads. -This says: "the Nexum module for this contract lives at `twap-monitor.shepherd.eth`". +### On-chain registry -The runtime can either: -- **Poll** known ENS names for `shepherd.module` text records. -- **Watch** `TextChanged` events on the ENS resolver, filtered to the `shepherd.module` key: +For autonomous discovery the runtime would watch a contract for registration events and enter the ENS flow. Three shapes are viable: -``` -event TextChanged( - bytes32 indexed node, - string indexed indexedKey, - string key, // "shepherd.module" - string value // "twap-monitor.shepherd.eth" -); -``` - -### Option C: Wildcard subdomain registry (ENSIP-10) - -A parent name like `modules.shepherd.eth` uses wildcard resolution (ENSIP-10). A resolver contract serves subdomains dynamically: - -``` -twap.modules.shepherd.eth → contenthash of TWAP bundle -ethflow.modules.shepherd.eth → contenthash of Ethflow bundle -*.modules.shepherd.eth → resolved by registry contract -``` - -The wildcard resolver is itself the registry - anyone can register a subdomain. The runtime subscribes to events from the resolver contract to discover new modules. - -This gives us human-readable, permissionless module discovery under a shared namespace. - -### Runtime config for registry discovery - -```toml -[[modules]] -source = "registry" -contract = "0x1234…" # registry contract address -chain_id = 1 -# All modules registered here are auto-loaded - -[[modules]] -source = "ens-watch" -resolver = "0x231b…" # ENS Public Resolver -chain_id = 1 -text_key = "shepherd.module" -# Watch for any ENS name that sets this text record -``` - -## Layered Trust Model - -Discovery is permissionless, but **execution requires operator consent**. The runtime config controls what gets auto-loaded: - -```toml -[discovery] -# "allowlist" - only load modules from these sources -# "auto" - load anything discovered (use with caution) -mode = "allowlist" - -# If mode = "allowlist", only these ENS names / registries are trusted -allowed_ens_names = [ - "twap-monitor.shepherd.eth", - "ethflow.shepherd.eth", -] -allowed_registries = [ - "0x1234…" -] - -# Resource caps applied to ALL discovered modules (override manifest if lower) -[discovery.resource_limits] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -``` - -In `auto` mode, the runtime loads any module it discovers (useful for a public "run all CoW automation" node). In `allowlist` mode, discovered modules are staged for operator review. - -## ENS Name Conventions - -Suggested naming under a shared parent (e.g. `shepherd.eth` or a subdomain of the protocol): - -``` -.shepherd.eth - community / independent modules -..eth - protocol-owned modules - -Examples: - twap-monitor.shepherd.eth - ethflow-watcher.shepherd.eth - rebalancer.shepherd.eth - twap.cow.eth -``` - -## How the Pieces Fit Together - -```mermaid -sequenceDiagram - participant Author as Module Author - participant Swarm as Swarm - participant ENS as ENS - participant Runtime as Nexum Runtime - - Note over Author: 1. Write module (Rust/Go/JS/...) - Note over Author: 2. Compile to WASM component - Note over Author: 3. Create module.toml manifest - Author->>Swarm: 4. Upload bundle - Swarm-->>Author: Content hash (bzz:abc123...) - Author->>ENS: 5. Set contenthash on twap-monitor.shepherd.eth - - Note over Runtime: 6. Config: source="ens", name="twap-monitor.shepherd.eth" - Runtime->>ENS: 7. Resolve ENS → contenthash - ENS-->>Runtime: Content reference - Runtime->>Swarm: 8. Fetch bundle - Swarm-->>Runtime: Bundle - Runtime->>Runtime: 9. Verify integrity (hash match) - Runtime->>Runtime: 10. Load module (compile, init, run) - - Note over Author, Runtime: On update - Author->>Swarm: 11. Upload new bundle - Swarm-->>Author: New content hash - Author->>ENS: 12. Update contenthash - Runtime->>ENS: 13. Detect change (poll/event) - ENS-->>Runtime: New content reference - Runtime->>Swarm: 14. Fetch new bundle - Swarm-->>Runtime: New bundle - Runtime->>Runtime: 15. Hot-reload module -``` +- **Dedicated registry contract** emitting `ModuleRegistered` / `ModuleRemoved`. +- **ENS self-declaration**: a contract sets a `shepherd.module` text record on its own ENS name pointing at the module; the runtime watches `TextChanged` filtered to that key. +- **Wildcard subdomain registry** (ENSIP-10): `*.modules.shepherd.eth` resolved by a registry contract that anyone can register a subdomain against. -## Summary +### Layered trust -| Discovery Method | Decentralisation | Operator Effort | Use Case | -|-----------------|------------------|-----------------|----------| -| Static (local path) | None | Manual | Dev, CI, air-gapped | -| ENS (named) | High | Configure names | Production, known modules | -| Registry (contract) | Full | Point at contract | Public nodes, auto-discovery | -| ENS self-declare | Full | Watch resolver | Protocol-native automation | +Discovery is permissionless; execution requires operator consent. A `[discovery]` config would gate what auto-loads (`mode = "allowlist"` with `allowed_ens_names` / `allowed_registries`, versus `mode = "auto"` for public nodes) and apply resource caps to discovered modules. -All methods converge on the same flow: resolve a content reference → fetch via content store → verify → load via module lifecycle. +All methods converge on the same flow: resolve a content reference -> fetch via content store -> verify hash -> load. diff --git a/docs/04-state-store.md b/docs/04-state-store.md index 96d5434b..0cca3712 100755 --- a/docs/04-state-store.md +++ b/docs/04-state-store.md @@ -1,48 +1,21 @@ # Local Store Architecture -## Overview - -Every Nexum module has access to a persistent key-value store that survives restarts, crashes, and module updates. The store is backed by **redb** (v3.1, pure Rust, embedded, ACID, MVCC) and exposed to modules through the `local-store` WIT interface. - -The local store is the only durable memory a module has - WASM linear memory is wiped on every restart. Modules must be written to reconstruct their working state from the store on `init`. +Every Nexum module has a persistent key-value store that survives restarts, crashes, and module updates, backed by **redb** (v3.1, pure Rust, embedded, ACID, MVCC) and exposed through the `local-store` WIT interface. It is the only durable memory a module has: WASM linear memory is wiped on every restart, so modules reconstruct working state from the store on `init`. ## redb Fundamentals | Property | Detail | |----------|--------| | Engine | Copy-on-write B-tree | -| Concurrency | MVCC - concurrent readers, single writer, no blocking | -| Durability | Crash-safe by default (fsync on commit) | -| Transactions | Full ACID - read txns and write txns | -| Key types | `&str`, `&[u8]`, integers, tuples, `Option`, fixed arrays | -| Value types | All key types + `Vec`, `f32`/`f64`, `()` | -| Size | No hard limit; v3 file format starts at ~50 KiB | +| Concurrency | MVCC: concurrent readers, single writer | +| Durability | Crash-safe (fsync on commit) | +| Transactions | Full ACID | ## Isolation Model -Each module gets its own **redb database file**. Modules cannot read or write each other's state - enforced by filesystem-level separation. - -```rust -// Runtime side - one database per module -fn open_module_db(module_id: &str) -> Result { - let path = format!("/var/nexum/state/{module_id}.redb"); - Database::create(&path) -} - -// Single table within each module's database -const LOCAL_STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("state"); -``` - -Module identity = `name` from `module.toml`. If two module instances share a name, they share state (intentional - enables hot-reload with state continuity). Different modules have different names and fully isolated database files. - -``` -/var/nexum/state/ -├── twap-monitor.redb → { "last_block": [...], "posted_parts": [...], ... } -├── ethflow-watcher.redb → { "pending_orders": [...], ... } -└── price-alert.redb → { "thresholds": [...], ... } -``` +A single redb file lives under `EngineConfig.engine.state_dir`. Every key is namespaced host-side by a fixed 32-byte prefix `keccak256(module_name)` prepended before the raw key, so modules sharing a key string see disjoint data and cannot forge a key into another module's range. keccak256 matches ENS node derivation (see [ADR-0003](adr/0003-local-store-namespacing.md)). The module never observes the prefix. -This per-file design ensures concurrent modules never contend on write locks (see Concurrency section below). +Module identity is `name` from `module.toml`. Two instances sharing a name share a namespace (intentional: hot-reload with state continuity). ## WIT Interface @@ -50,246 +23,51 @@ This per-file design ensures concurrent modules never contend on write locks (se interface local-store { use nexum:host/types.{fault}; - /// Get a value by key. Returns none if key doesn't exist. get: func(key: string) -> result>, fault>; - - /// Set a key-value pair. Overwrites existing value. - /// Returns fault.invalid-input or fault.internal on failure. - /// Quota exhaustion surfaces as fault.invalid-input. set: func(key: string, value: list) -> result<_, fault>; - - /// Delete a key. No-op if key doesn't exist. delete: func(key: string) -> result<_, fault>; - - /// List keys matching a prefix. Returns keys only (not values). list-keys: func(prefix: string) -> result, fault>; + contains: func(key: string) -> result; + len: func(key: string) -> result, fault>; + count: func(prefix: string) -> result; } ``` -In 0.1 `local-store` errors were bare `string` values. 0.2 replaces them with the shared `fault` vocabulary so modules can match on the `fault` case rather than parsing error strings. The interface is the failure domain, so it reports `fault` directly with no subsystem tag. +`contains`, `len`, and `count` answer existence, value length, and prefix cardinality without transferring the value or materialising the key list. Errors are the shared `fault` vocabulary; the interface is its own failure domain, so it reports `fault` directly. Keys are UTF-8 strings; values are opaque bytes. -Keys are UTF-8 strings. Values are opaque bytes - the SDK provides typed wrappers (see doc 05). - -`list-keys` enables prefix-based namespacing within a module's state: +`list-keys` and `count` enable prefix-based namespacing within a module: ``` orders/active/0x1234 → [serialised order] orders/active/0x5678 → [serialised order] -orders/completed/… → [serialised order] list_keys("orders/active/") → ["orders/active/0x1234", "orders/active/0x5678"] ``` ## Transaction Semantics -Both `init` and `on_event` execute within an **implicit write transaction**: - -```mermaid -flowchart TD - A["Event arrives (or init called)"] --> B["Runtime opens redb WriteTransaction"] - B --> C["Calls module init(config) or on_event(event)"] - C --> D["module calls local-store::set('key', value) -- buffered in txn"] - C --> E["module calls local-store::get('key') -- reads from txn (sees own writes)"] - C --> F["module calls local-store::delete('key') -- buffered in txn"] - D --> G["Call returns Ok(())"] - E --> G - F --> G - G --> H["Runtime commits WriteTransaction"] - H --> I["State changes are durable"] -``` - -**On failure** (trap, fuel exhaustion, explicit `Err`): - -```mermaid -flowchart TD - A["Call traps / returns Err"] --> B["Runtime aborts WriteTransaction"] - B --> C["No state changes persisted -- atomically rolled back"] -``` - -This gives us **all-or-nothing semantics per call**: either all state mutations from a single `init` or `on_event` callback are applied, or none are. This is critical for correctness - a module that crashes halfway through processing a block doesn't leave behind partial state. Equally, a failed `init` during restart doesn't corrupt state from the previous version. - -### Read-your-own-writes - -Within a single `on_event` call, a module sees its own uncommitted writes: - -```rust -local_store::set("counter", &42u64.to_le_bytes())?; -let val = local_store::get("counter")?; -// val == Some([42, 0, 0, 0, 0, 0, 0, 0]) ✓ -``` - -This works because all operations within one event go through the same `WriteTransaction`. - -### Concurrency: One Database Per Module - -redb allows only **one `WriteTransaction` at a time** per `Database` - a second `begin_write()` blocks until the first commits or aborts. Since modules dispatch events concurrently (doc 02), a single shared redb file would serialise all write transactions across modules, negating concurrency. - -**Design decision:** each module gets its own redb `Database` file: - -``` -/var/nexum/state/ -├── twap-monitor.redb -├── ethflow-watcher.redb -└── price-alert.redb -``` - -This gives true write isolation - module A's transaction never blocks module B. The cost is more file handles (one per module), which is negligible for the expected module count. - -Within a single module, events are already sequential (doc 02 dispatch semantics), so there is never contention on a module's own database. +Each host call is its own redb transaction: a `get` / `contains` / `len` / `count` / `list-keys` opens a read transaction, and a `set` / `delete` opens a write transaction and commits (fsync-durable) before returning. There is no transaction spanning a whole `on_event`, so a module that traps midway through processing an event keeps whatever writes already committed; per-event atomicity is the module's responsibility (checkpoint a "last processed" key last, and make `init` idempotent). A `set` rejected for quota aborts its own write untouched. ## Size Enforcement -The manifest declares `max_state_bytes`. The runtime tracks total bytes stored per module and rejects `local-store::set` calls that would exceed the limit: - -```rust -// Host-side enforcement (simplified) -impl local_store::Host for NexumHostState { - async fn set(&mut self, key: String, value: Vec) -> Result> { - let new_size = self.state_bytes_used - - self.current_value_size(&key) - + key.len() + value.len(); - - if new_size > self.module_config.max_state_bytes { - return Ok(Err(Fault::InvalidInput("state quota exceeded".into()))); - } - - self.write_txn.insert(&*key, value.as_slice())?; - self.state_bytes_used = new_size; - Ok(Ok(())) - } -} -``` - -The tracking is approximate (doesn't account for B-tree overhead) but sufficient for enforcing a meaningful cap. +A module's namespace is capped at the engine-global `[limits].state_bytes` quota (default 50 MiB). `set` charges the on-disk footprint (prefix + key + value + a fixed per-entry overhead), summed across the namespace's keys, and rejects an over-quota write with `fault.invalid-input`, leaving the store untouched. The footprint is tracked by an incremental per-namespace counter, seeded once by a prefix-range scan. ## State Lifecycle -### Init / Cold Start - -On first load, the module's table is empty. The module's `init` function should handle this: - -```rust -fn init(config: Config) -> Result<(), Fault> { - if local_store::get("initialized")?.is_none() { - // First run - set up initial state - local_store::set("initialized", &[1])?; - local_store::set("last_block", &0u64.to_le_bytes())?; - } - Ok(()) -} -``` - -### Restart (crash recovery) - -On restart, the module gets a fresh WASM instance but the **same state table**. The last committed transaction's data is intact. Any in-flight transaction from the crashed event was rolled back. - -The module should read its checkpoint from state in `init` and resume: - -```rust -fn init(_config: Config) -> Result<(), Fault> { - let last_block = local_store::get("last_block")? - .map(|b| u64::from_le_bytes(b.try_into().unwrap())) - .unwrap_or(0); - logging::log(Level::Info, &format!("resuming from block {last_block}")); - Ok(()) -} -``` - -### Module Update (new version, same name) - -When a module is updated (new WASM binary, same `name` in manifest), the new version inherits the existing state table. The new version's `init` is responsible for any migration: - -```rust -fn init(config: Config) -> Result<(), Fault> { - let version = local_store::get("schema_version")? - .map(|b| u64::from_le_bytes(b.try_into().unwrap())) - .unwrap_or(0); - - if version < 2 { - // Migrate from v1 → v2 schema - migrate_v1_to_v2()?; - local_store::set("schema_version", &2u64.to_le_bytes())?; - } - Ok(()) -} -``` - -### Module Removal - -When an operator removes a module, its state table can optionally be: -- **Retained** (default) - in case the module is re-added later. -- **Purged** - operator explicitly requests deletion via CLI. - -```bash -nexum state purge --module twap-monitor -``` - -## Backup and Compaction - -redb supports online reads during writes (MVCC), so backup is straightforward: - -```rust -// Runtime holds a read transaction, copies the file -let _guard = db.begin_read()?; -std::fs::copy("state.redb", "state.redb.backup")?; -``` - -Compaction (`db.compact()`) reclaims space from deleted keys. The runtime can run this periodically or on operator command. - -## Host-Side Implementation Sketch - -```rust -const LOCAL_STORE_TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("state"); - -struct ModuleStateCtx { - db: Database, // per-module database file - max_bytes: usize, - bytes_used: usize, - write_txn: Option, -} - -impl ModuleStateCtx { - /// Called by runtime before dispatching init or on_event - fn begin(&mut self) -> Result<()> { - self.write_txn = Some(self.db.begin_write()?); - Ok(()) - } - - /// Called by runtime after successful return - fn commit(&mut self) -> Result<()> { - if let Some(txn) = self.write_txn.take() { - txn.commit()?; - } - Ok(()) - } - - /// Called by runtime on failure/trap - fn rollback(&mut self) { - // WriteTransaction::drop aborts automatically - self.write_txn.take(); - } - - fn table<'txn>( - &self, - txn: &'txn WriteTransaction, - ) -> Result> { - txn.open_table(LOCAL_STORE_TABLE) - } -} -``` +- **Cold start.** On first load the namespace is empty; `init` seeds any initial keys. +- **Restart.** A crash yields a fresh WASM instance over the same namespace. The last committed write is intact. `init` reads its checkpoint and resumes. +- **Update.** A new version (same `name`) inherits the namespace; its `init` handles any schema migration. +- **Removal.** A removed module's keys are retained by default. ## Summary | Concern | Design | |---------|--------| | Backend | redb v3.1 (pure Rust, ACID, MVCC) | -| Isolation | One database file per module (keyed by `name`) | -| Key type | UTF-8 string | -| Value type | Opaque bytes (`list` in WIT) | -| Namespacing within module | Convention: slash-separated prefixes + `list-keys` | -| Transaction scope | Per `init` / `on_event` call - commit on success, rollback on failure | -| Read-your-own-writes | Yes (same `WriteTransaction`) | -| Size limit | Enforced per-module via manifest `max_state_bytes` | -| Survives restart | Yes - state is external to WASM instance | -| Module update | New version inherits state; `init` handles migration | -| Backup | Online copy under read transaction | +| File layout | Single redb file under `state_dir` | +| Isolation | 32-byte `keccak256(module_name)` key prefix (ADR-0003) | +| Key / value | UTF-8 string / opaque bytes (`list`) | +| Namespacing within module | Slash-separated prefixes + `list-keys` / `count` | +| Transaction scope | Per host call, committed on return | +| Size limit | Per-namespace quota from `[limits].state_bytes` | +| Survives restart | Yes, external to the WASM instance | diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index ada2eb5e..756b05f3 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -1,14 +1,16 @@ # Diagrams -Mermaid sources and rendered PNGs covering the engine architecture, the CoW workflows that the M2 modules implement (TWAP and EthFlow, both as guest modules using low-level host primitives), and the engine internals that new contributors most often need to reason about. +Mermaid sources and rendered PNGs covering the engine architecture, the CoW workflows (TWAP and EthFlow), and the engine internals that new contributors most often need to reason about. + +> The rendered CoW-flow diagrams predate the venue-adapter rework: they show a `shepherd:cow/cow-api` host interface and an in-engine `OrderBookPool`. Order submission is now the `videre:venue` venue-adapter contract (the CoW venue is the `cow-venue` crate) and `shepherd:cow` carries only the `cow-events` enum. Treat the CoW-submission path in these diagrams as historical until the sources are regenerated. ## Architecture and CoW flows | File | Type | Shows | |---|---|---| -| `architecture.png` / `.mmd` | Component | Static view: external infra, nexum internals, WASM modules (twap-monitor, ethflow-watcher) consuming low-level host primitives, and the `cowprotocol` crate (consumed via `[patch.crates-io]` and the wasm32 feature). The `shepherd:cow` package contains only `cow-api`; no specialised TWAP or EthFlow interfaces. | -| `sequence-ethflow.png` / `.mmd` | Sequence | `OrderPlacement` on-chain event handled entirely in the `ethflow-watcher` guest module: `alloy_sol_types` decodes the event, the module builds an `OrderCreation` with the EIP-1271 signing scheme using `cowprotocol` types, and submits via `cow-api/submit-order`. The orderbook error path runs through `OrderPostError::try_from(cow-api-error).retry_hint()`. | -| `sequence-twap.png` / `.mmd` | Sequence | `ConditionalOrderCreated` registration plus the per-block polling loop driven by the `twap-monitor` guest module: `alloy_sol_types` decodes registrations and `eth_call` returns, the module makes the `getTradeableOrderWithSignature` call via `chain.request`, builds `OrderCreation` via `cowprotocol` types, and submits via `cow-api/submit-order`. Orderbook errors flow through `OrderPostError::retry_hint`. | +| `architecture.png` / `.mmd` | Component | Static view: external infra, nexum internals, WASM modules (twap-monitor, ethflow-watcher) consuming host primitives, and the `cowprotocol` crate (consumed via `[patch.crates-io]` and the wasm32 feature). | +| `sequence-ethflow.png` / `.mmd` | Sequence | `OrderPlacement` on-chain event handled in the `ethflow-watcher` guest module: `alloy_sol_types` decodes the event, the module builds an order with the EIP-1271 signing scheme using `cowprotocol` types, and submits it. | +| `sequence-twap.png` / `.mmd` | Sequence | `ConditionalOrderCreated` registration plus the per-block polling loop in the `twap-monitor` guest module: `alloy_sol_types` decodes registrations and `eth_call` returns, the module makes the `getTradeableOrderWithSignature` call via `chain.request`, builds the order via `cowprotocol` types, and submits it. | ## Engine internals (for contributors) diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index 8e67ba53..5bd8ba1e 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -1,8 +1,8 @@ # Shepherd - Architecture Diagrams -Visual reference for the Shepherd engine, its interactions with Nexum, CoW Protocol, and the WASM module layer. Derived from ADRs 0001–0008 and the internal architecture document. +Visual reference for the Shepherd engine, its interactions with Nexum, CoW Protocol, and the WASM module layer. -> **Scope note** - diagrams 1–4 and 7–8 reflect the **M1 implemented state** plus the **M2 target design** as described by the ADRs. Diagrams 5–6 (TWAP, EthFlow) describe **guest-module-driven flows**: the modules do all the protocol work themselves using low-level host primitives, with no specialised `twap` or `ethflow` host interfaces. Where the current code differs from the target design, a note is included in the relevant block reference. +> **Scope note.** These diagrams predate the venue-adapter rework and still show a `shepherd:cow/cow-api` host interface with an in-engine `OrderBookPool`. Current: order submission is the `videre:venue` venue-adapter contract (a keeper drives venues through `videre:venue/client`; the CoW venue is the `cow-venue` crate), and `shepherd:cow` carries only the `cow-events` enum. Read the CoW-submission path below as historical. The universal-primitive, boot, dispatch, and lifecycle diagrams remain accurate. --- @@ -188,8 +188,8 @@ graph TD | **identity · messaging · remote-store** | Capabilities stubbed at 0.2 - they return `Unsupported`. `identity` will provide keystore-backed signing. `messaging` will send Waku messages. `remote-store` will read/write Swarm/IPFS. | | **logging** | Lightweight utility. `logging` emits to the engine's `tracing` subscriber (inherits `RUST_LOG` filters). Time and secure randomness are available ambiently via `wasi:clocks` and `wasi:random`. | | **(outbound HTTP)** | Not a `nexum:host` interface: a module that declares the `http` capability imports the standard `wasi:http/outgoing-handler`, and the host checks every outgoing request against the manifest's `[capabilities.http].allow` list before any connection is made. | -| **shepherd:cow@0.1.0** | The CoW Protocol extension package. Imports `nexum:host/types` for shared types so modules don't re-define `chain-id` or `chain-log`. Only CoW-aware modules need to import this package. Contains exactly **one** interface in 0.2: `cow-api`. | -| **cow-api** | Generic orderbook access. `request` is a raw REST passthrough (returns JSON string). `submit-order` takes raw order bytes and returns a `result` where the string is the order UID. Routes through the engine's `OrderBookPool`. This is the only protocol-level CoW interface in 0.2 - the boundary between "what CoW Protocol *is*" (orderbook submission, order types) and "what's implemented *on top* of CoW" (TWAP polling, EthFlow event handling). | +| **shepherd:cow@0.1.0** | The CoW Protocol package. In current code it carries a single interface, `cow-events`: the canonical decoded on-chain event enum (`ConditionalOrderCreated`, `ConditionalOrderRemoved`, `OrderPlacement`) with pinned topic-0 hashes, parity-tested against keeper constants and manifests. The `cow-api` host interface the diagram shows is gone. | +| **cow-api** (historical) | Orderbook access was once a host interface (`request` REST passthrough + `submit-order`) backed by an in-engine `OrderBookPool`. Order submission is now the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the `cow-venue` adapter component speaks the CoW orderbook. See doc 08. | | **(no twap interface)** | Per ADR-0006, no specialised TWAP host interface exists. The TWAP module implements polling, decoding, and submission entirely in guest code, using `chain.request` for `eth_call`, `local-store` for state, `alloy_sol_types` (in-module) for ABI decoding, `cowprotocol` types for `OrderCreation`, and `cow-api.submit-order` for orderbook submission. Multiple TWAP strategies can coexist as separate modules with different polling policies and error tolerances. | | **(no ethflow interface)** | Per ADR-0006, no specialised EthFlow host interface exists. The EthFlow module decodes `OrderPlacement` directly in guest code via `alloy_sol_types`, constructs the `OrderCreation` with the EIP-1271 signing scheme via `cowprotocol` types, and submits via `cow-api`. | From 20163c8fbeba99dffae5f600edec1533000b1fe9 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:20:56 +0000 Subject: [PATCH 119/139] docs: unwrap remaining hard-wrapped markdown (#608) docs: unwrap remaining hard-wrapped markdown for diff-friendliness The last tracked markdown outside the docs cruft-sweep areas. Join each paragraph onto one logical line so a one-word edit no longer reflows the block. Content unchanged (word and heading counts preserved). --- GRANT_APPLICATION.md | 52 +++++++++++++++----------------------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/GRANT_APPLICATION.md b/GRANT_APPLICATION.md index 21d5262a..ef1c4568 100644 --- a/GRANT_APPLICATION.md +++ b/GRANT_APPLICATION.md @@ -99,12 +99,9 @@ Many DeFi automation use cases require monitoring + execution patterns: ### The Missing Piece -We have: -✅ On-chain conditional orders (ComposableCoW) -⚠️ Fixed-case off-chain execution layer (watch-tower and EthFlow) +We have: ✅ On-chain conditional orders (ComposableCoW) ⚠️ Fixed-case off-chain execution layer (watch-tower and EthFlow) -We’re missing: -❌ **Programmable off-chain execution layer** +We’re missing: ❌ **Programmable off-chain execution layer** Shepherd fills this gap and becomes the foundation for future innovations like gas abstraction (Methane). @@ -172,11 +169,9 @@ fn state_set(key, value) -> Result<()> ### Core Protocol Use Cases (Enabled by Shepherd) -**1. TWAP Order Monitoring (Initial Implementation)** -Replace current watch-tower with WASM module. Easy to update, community can customise. +**1. TWAP Order Monitoring (Initial Implementation)** Replace current watch-tower with WASM module. Easy to update, community can customise. -**2. Ethflow Order Monitoring (Initial Implementation)** -Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific logic from the backend and reducing cross-domain concerns. +**2. Ethflow Order Monitoring (Initial Implementation)** Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific logic from the backend and reducing cross-domain concerns. **3. Methane Gas Abstraction (Future - Built on Shepherd)** @@ -196,17 +191,13 @@ Replace existing Ethflow monitoring with WASM module, removing Ethflow-specific ### Community Use Cases -**5. Stop-Loss / Take-Profit Orders** -Monitor price oracles, submit CoW order when conditions met. +**5. Stop-Loss / Take-Profit Orders** Monitor price oracles, submit CoW order when conditions met. -**6. Automated Portfolio Rebalancing** -Track wallet balances, trigger rebalance when allocation drifts. +**6. Automated Portfolio Rebalancing** Track wallet balances, trigger rebalance when allocation drifts. -**7. Yield Farming Automation** -Monitor lending positions, automatically compound rewards when profitable. +**7. Yield Farming Automation** Monitor lending positions, automatically compound rewards when profitable. -**8. DAO Governance Automation** -Automatically vote on proposals based on predefined rules. +**8. DAO Governance Automation** Automatically vote on proposals based on predefined rules. **Key Point:** Shepherd is the **foundation**. Once built, Methane and Fee Automation become modules rather than separate infrastructure projects—dramatically reducing complexity and development time. @@ -263,8 +254,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 1: Core Runtime & Event System -**Duration:** 3 weeks -**Effort Estimate:** 120 hours (3 weeks FTE) +**Duration:** 3 weeks **Effort Estimate:** 120 hours (3 weeks FTE) **Deliverables:** @@ -286,8 +276,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 2: TWAP & Ethflow Module Implementation -**Duration:** 2.5 weeks -**Effort Estimate:** 100 hours (2.5 weeks FTE) +**Duration:** 2.5 weeks **Effort Estimate:** 100 hours (2.5 weeks FTE) **Deliverables:** @@ -316,8 +305,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 3: SDK & Developer Experience -**Duration:** 1.5 weeks -**Effort Estimate:** 60 hours (1.5 weeks FTE) +**Duration:** 1.5 weeks **Effort Estimate:** 60 hours (1.5 weeks FTE) **Deliverables:** @@ -343,8 +331,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 4: Production Hardening -**Duration:** 1.5 weeks -**Effort Estimate:** 60 hours (1.5 weeks FTE) +**Duration:** 1.5 weeks **Effort Estimate:** 60 hours (1.5 weeks FTE) **Deliverables:** @@ -366,8 +353,7 @@ Automatically vote on proposals based on predefined rules. ### Milestone 5: Multi-Chain Considerations & Final Testing -**Duration:** 1 week -**Effort Estimate:** 40 hours (1 week FTE) +**Duration:** 1 week **Effort Estimate:** 40 hours (1 week FTE) **Deliverables:** @@ -392,8 +378,7 @@ Automatically vote on proposals based on predefined rules. ## Total Effort Estimate -**Total Duration:** 9.5 weeks -**Total Effort:** 380 hours (9.5 weeks FTE) +**Total Duration:** 9.5 weeks **Total Effort:** 380 hours (9.5 weeks FTE) **Breakdown:** @@ -410,8 +395,7 @@ Automatically vote on proposals based on predefined rules. ## Grant Funding Request -**Rate:** €100/hour -**Total Grant Amount:** €38,000 +**Rate:** €100/hour **Total Grant Amount:** €38,000 **Payment Terms:** @@ -657,8 +641,7 @@ This creates the most powerful automation infrastructure in DeFi: fully programm * ✅ ComposableCoW SDK: Widely adopted by developers * ✅ Core Contributor: 14+ months of consistent contributions -**Why This Project:** -Shepherd represents the culmination of 18+ months working on CoW Protocol automation: +**Why This Project:** Shepherd represents the culmination of 18+ months working on CoW Protocol automation: 1. Built the conditional order framework (ComposableCoW) 2. Implemented specific order types (TWAP) @@ -709,8 +692,7 @@ Shepherd completes the automation story for CoW Protocol: 3. **Fee Automation/Methane** → Advanced automation patterns ✅ 4. **Shepherd** → Programmable execution layer ✅ -**The Vision:** -Make CoW Protocol the platform for programmable DeFi automation—where any developer can build sophisticated trading strategies, yield optimisation, and automation without building infrastructure from scratch. +**The Vision:** Make CoW Protocol the platform for programmable DeFi automation—where any developer can build sophisticated trading strategies, yield optimisation, and automation without building infrastructure from scratch. **Why Now:** From df6c6e7c7409296b4edf102318500539f14cd101 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 06:21:19 +0000 Subject: [PATCH 120/139] docs: add ADR-0014, local store durability is per-call not per-event atomic (#610) Record the decision from the local-store durability study: per-call committed durability is the contract, per-event atomicity is rejected (it would roll back the keeper Journal's RESERVED marker on the trap it exists to survive), and one opt-in apply batch verb is the sanctioned atomicity scope. Closes the doc-vs-code gap that claimed a per-event rollback that never existed. --- docs/adr/0014-local-store-durability-model.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/adr/0014-local-store-durability-model.md diff --git a/docs/adr/0014-local-store-durability-model.md b/docs/adr/0014-local-store-durability-model.md new file mode 100644 index 00000000..4555e94e --- /dev/null +++ b/docs/adr/0014-local-store-durability-model.md @@ -0,0 +1,29 @@ +--- +status: accepted +--- + +# Local store durability is per-call, not per-event atomic + +## Context + +The `nexum:host/local-store` seam is call-by-call (get, set, delete, list-keys, contains, len, count) and the redb backend commits each set/delete in its own fsynced transaction. No transaction spans an `on_event` or `init` call; the dispatch path holds none. A handler that writes then traps (typed fault, panic, OutOfFuel, deadline, process crash) keeps every write already returned. Earlier docs claimed an implicit per-event write transaction that rolls back on trap; it never existed. + +The keeper Journal (#583-#587) reserves a marker before a venue submit and commits after, and a top-of-sweep reconcile re-posts stranded reservations. Its correctness requires the `RESERVED` marker, committed before the submit, to survive a trap during or after it. Per-event rollback would erase that marker while the venue may already hold the order: silent divergence or double-submit, the failure #574 closed. + +## Decision + +Per-call committed durability is the contract. Every set/delete/apply is fsync-durable when `Ok` returns, program-order, read-your-writes; a trap freezes state at the last completed call and never rewinds it. Single-actor serialised dispatch means no observer sees a torn mid-event state. + +Per-event atomicity is rejected in every form: + +- A held redb `WriteTransaction` across `on_event` locks the single shared write head across handler awaits (wasi:http, chain RPC) for up to the dispatch deadline, and the deadline drop leaks the open transaction while `HostState` survives, write-freezing every module through the poison-backoff window. +- A host-side staged overlay defers the `RESERVED` marker's durability past the wire send, so any durable-now escape hatch carries all load-bearing writes and the transaction protects nothing new. +- A snapshot or undo log is dominated by both. + +Cross-boundary atomicity is impossible: no store transaction undoes an order the venue holds. + +The sanctioned atomicity scope is one opt-in batch verb, `apply(ops: list)`, committed in a single synchronous host call (#609). Multi-key state invariants use `apply`; any write sequence crossing an await or an external effect uses the Journal, whose reliance on no-rollback is load-bearing, not a missing feature. A logical change that spans keys without `apply` orders its writes so every prefix is a valid state (the recoverable key last). + +## Consequences + +The store is a write-ahead intent log with key-value convenience, not a transactional database: traps freeze state, they never rewind it. At-least-once effects composed with the per-venue idempotency key (#574) are effectively exactly-once at the venue, the strongest guarantee attainable across the boundary. `apply` is an additive `nexum:host@0.1.0` verb landed pre-cleave. Whole-event staging is rejected rather than parked; reopen only on a measured need for cursor-joined exactly-once that redelivery plus idempotence demonstrably fails to cover. From d6439504c000c9c01190ea46d822dc54170bbf09 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:07:43 +0000 Subject: [PATCH 121/139] docs: tersen rustdoc in videre-sdk (#615) --- crates/videre-sdk/src/adapter.rs | 57 ++++++-------- crates/videre-sdk/src/bindings.rs | 30 +++----- crates/videre-sdk/src/body.rs | 49 +++++------- crates/videre-sdk/src/client.rs | 120 +++++++++++------------------ crates/videre-sdk/src/event.rs | 9 +-- crates/videre-sdk/src/faults.rs | 54 +++++-------- crates/videre-sdk/src/keeper.rs | 105 ++++++++++--------------- crates/videre-sdk/src/lib.rs | 118 +++++++--------------------- crates/videre-sdk/src/transport.rs | 46 ++++------- crates/videre-sdk/tests/adapter.rs | 10 +-- 10 files changed, 210 insertions(+), 388 deletions(-) diff --git a/crates/videre-sdk/src/adapter.rs b/crates/videre-sdk/src/adapter.rs index 28d4451b..d7c5b5fc 100644 --- a/crates/videre-sdk/src/adapter.rs +++ b/crates/videre-sdk/src/adapter.rs @@ -1,12 +1,9 @@ //! The [`VenueAdapter`] trait and the internal export codegen that turns //! an impl of it into the component's `venue-adapter` world surface. //! -//! The trait mirrors the world's export face one to one: `init` from the -//! world itself, the intent functions and the body-version declaration -//! from `videre:venue/adapter`. -//! Functions are associated (no `self`): the component model instantiates -//! one adapter per venue and calls exports statically, so adapter state -//! lives in the adapter's own statics, exactly as in event modules. +//! The trait mirrors the world's export face one to one. Functions are +//! associated (no `self`): the component model calls exports statically, +//! so adapter state lives in the adapter's own statics. use crate::{Config, Fault, IntentHeader, IntentStatus, Quotation, SubmitOutcome, VenueError}; @@ -21,58 +18,48 @@ pub fn guard_receipt(receipt: &[u8]) -> Result<(), VenueError> { } /// One venue's protocol speaker: the guest-side face of the -/// `venue-adapter` world. Implement it on a unit struct and apply +/// `venue-adapter` world. Implement on a unit struct and apply /// [`#[videre_sdk::venue]`](crate::venue) to the impl; bodies and -/// receipts arrive as the opaque bytes the wire carries, and impls -/// recover typing through [`IntentBody`](crate::IntentBody) (whose -/// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). +/// receipts arrive as opaque bytes, typed through +/// [`IntentBody`](crate::IntentBody). 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. + /// submission. fn init(config: Config) -> Result<(), Fault>; - /// Body-schema versions this adapter decodes. Install asserts it - /// equals the manifest `[venue] body_versions` set. Defaults to - /// declaring none. + /// Body-schema versions this adapter decodes; install asserts it + /// equals the manifest `[venue] body_versions` set. Defaults to none. fn body_versions() -> Vec { Vec::new() } - /// Project an opaque intent body onto the stable header guard - /// policy runs on. Must be a pure derivation: no transport, no side - /// effects, so the host can inspect a header before deciding to - /// submit. The host's guard checkpoint is advisory-only until the - /// egress-guard epic lands: a would-deny is logged, not enforced. + /// Project an opaque body onto the header guard policy runs on. A pure + /// derivation: no transport, no side effects. fn derive_header(body: Vec) -> Result; - /// Price an opaque intent body: an indicative quotation, not an - /// offer the venue is bound to fill. + /// Price an opaque intent body: indicative, not an offer the venue is + /// bound to fill. fn quote(body: Vec) -> Result; - /// Submit an opaque intent body to this adapter's venue. Success is - /// either the venue's receipt or `requires-signing`: a transaction - /// the host must sign and send before the intent exists. + /// Submit an opaque intent body. Success is 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. The - /// export shim rejects an empty receipt as `invalid-receipt` before - /// dispatch. + /// export shim rejects an empty receipt as `invalid-receipt`. 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. The export shim rejects an empty receipt - /// as `invalid-receipt` before dispatch. + /// accepted the cancellation, not that an in-flight settlement can no + /// longer win the race. The export shim rejects an empty receipt. fn cancel(receipt: Vec) -> Result<(), VenueError>; } /// Internal codegen `#[videre_sdk::venue]` expands to: a hidden shim -/// wiring a [`VenueAdapter`] impl to the macro-synthesized world's -/// `Guest` faces, then that world's `export!`. `Guest`, `exports`, and -/// `export!` resolve at the expansion site (the adapter crate root, -/// where the attribute put the world's bindgen), so the macro is -/// meaningful only inside the attribute's output. Not public API. +/// wiring a [`VenueAdapter`] impl to the world's `Guest` faces, then its +/// `export!`. Meaningful only inside the attribute's output, where the +/// world bindgen resolves `Guest`, `exports`, and `export!`. #[doc(hidden)] #[macro_export] macro_rules! __export_venue_adapter { diff --git a/crates/videre-sdk/src/bindings.rs b/crates/videre-sdk/src/bindings.rs index 9f57dde8..cf8cdd0b 100644 --- a/crates/videre-sdk/src/bindings.rs +++ b/crates/videre-sdk/src/bindings.rs @@ -1,23 +1,15 @@ -//! Guest bindings for the videre SDK, generated once from an -//! import-only inline world carrying every interface both personas -//! speak: the videre type vocabulary, the host types and scoped -//! transport, and the keeper-facing `videre:venue/client` shims. +//! Guest bindings for the videre SDK, generated once from an import-only +//! inline world carrying every interface both personas speak: the videre +//! types, the host types and scoped transport, and the keeper-facing +//! `videre:venue/client` shims. The [`VenueAdapter`](crate::VenueAdapter) +//! trait, transport wrappers, and client core are all expressed over +//! them; the per-cdylib bindgens remap the shared interfaces onto these +//! modules with `with`, so a macro-built component shares type identity. //! -//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, -//! the SDK generates these bindings once: the -//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport -//! wrappers, and the client core are all expressed over them. The -//! per-cdylib bindgens (`#[videre_sdk::venue]`, `#[videre_sdk::keeper]`) -//! remap the shared interfaces onto these modules with `with`, so a -//! macro-built component shares type identity (and, for the keeper's -//! client, one shim set) with the SDK and the conformance kit. -//! -//! The world is import-only on purpose: an embedded world section is -//! unioned into every linking module at componentization, where an -//! unused import prunes but an export is a hard obligation. Keeping the -//! adapter export out of the SDK world is what lets a keeper module -//! link this crate without being asked to be a venue; the export face -//! is emitted per-cdylib by `#[videre_sdk::venue]` alone. +//! Import-only on purpose: keeping the adapter export out of the SDK +//! world lets a keeper module link this crate without becoming a venue, +//! since an unused import prunes at componentization but an export is a +//! hard obligation. wit_bindgen::generate!({ inline: "package videre:sdk-shims; diff --git a/crates/videre-sdk/src/body.rs b/crates/videre-sdk/src/body.rs index 33c40378..8c7a5e48 100644 --- a/crates/videre-sdk/src/body.rs +++ b/crates/videre-sdk/src/body.rs @@ -1,27 +1,21 @@ //! 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. +//! A body crosses the pool and adapter boundaries as opaque bytes; typing +//! is recovered guest-side against the venue's schema, an outer version +//! enum whose wire form is a one-byte version tag (the variant's +//! declaration index) plus the borsh payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) is the intended impl. //! -//! The one non-obvious invariant: the tag order is the schema. Venues -//! append new versions at the end and never reorder or remove variants. +//! Invariant: the tag order is the schema. Venues append new versions 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. Sealed to -/// `#[derive(IntentBody)]` on the outer version enum: the derive owns -/// the tag rules, so no hand impl can break them. +/// The codec between a venue's typed body enum and the opaque wire bytes. +/// Sealed to `#[derive(IntentBody)]`, which owns the tag rules. pub trait IntentBody: Sized + __private::Derived { /// Encode as the one-byte version tag plus the borsh payload. fn to_bytes(&self) -> Result, BodyError>; @@ -32,10 +26,8 @@ pub trait IntentBody: Sized + __private::Derived { 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. +/// Why a body failed to cross the [`IntentBody`] codec. `IntoStaticStr` +/// yields a snake_case label per case. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -43,10 +35,7 @@ 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. + /// The version tag names no published version of this body. #[error("unknown body version {version}")] UnknownVersion { /// The unrecognised wire tag. @@ -61,9 +50,8 @@ pub enum BodyError { /// Borsh's decode failure detail. detail: String, }, - /// A payload failed to encode. Only reachable through a fallible - /// custom `BorshSerialize` impl; derived payloads encode - /// infallibly. + /// A payload failed to encode; only reachable through a fallible + /// custom `BorshSerialize` impl. #[error("version {version} payload failed to encode: {detail}")] Encode { /// The wire tag whose payload failed. @@ -74,8 +62,8 @@ pub enum BodyError { } /// Fold a codec failure into the wire error an adapter returns: decode -/// failures are the caller's malformed body (`invalid-body`); an encode -/// failure is the adapter's own bug, reported retryable (`unavailable`). +/// failures are the caller's `invalid-body`; an encode failure is the +/// adapter's own bug, reported retryable `unavailable`. impl From for VenueError { fn from(err: BodyError) -> Self { match err { @@ -87,9 +75,8 @@ impl From for VenueError { } } -/// Re-exports for `#[derive(IntentBody)]` generated code only; not a -/// public surface. `alloc` rides along so the expansion resolves in a -/// `#![no_std]` consumer without its own `extern crate alloc`. +/// Re-exports for `#[derive(IntentBody)]` generated code only. `alloc` +/// rides along so the expansion resolves in a `#![no_std]` consumer. #[doc(hidden)] pub mod __private { pub extern crate alloc; diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index cc87a19b..9d699e8e 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -1,15 +1,9 @@ //! The typed venue client: [`VenueClient`] binds one [`Venue`] over the -//! byte-level [`VenueTransport`] seam. -//! -//! The wire carries opaque bodies and a stringly venue selector; typing -//! is recovered here. A venue is named once, as a [`Venue`] marker -//! carrying its [`VenueId`] and body schema, and every call encodes -//! through [`IntentBody`] before the seam, so keeper code never handles -//! wire bytes. [`HostVenues`] is the seam bound to the module's own -//! `videre:venue/client` import; tests and in-process adapters -//! implement [`VenueTransport`] directly. The transport methods are -//! native AFIT, so dispatch is static and nothing on the call path -//! boxes. +//! byte-level [`VenueTransport`] seam, encoding each call through +//! [`IntentBody`] so keeper code never handles wire bytes. [`HostVenues`] +//! binds the seam to the module's `videre:venue/client` import; tests +//! implement [`VenueTransport`] directly. Transport methods are native +//! AFIT, so dispatch is static. use std::borrow::Cow; use std::fmt; @@ -29,7 +23,7 @@ use crate::{BodyError, IntentBody, IntentStatus, Quotation, SubmitOutcome, Venue pub struct VenueId(Cow<'static, str>); impl VenueId { - /// Wrap a static id without allocating: the [`Venue::ID`] spelling. + /// Wrap a static id without allocating. #[must_use] pub const fn from_static(id: &'static str) -> Self { Self(Cow::Borrowed(id)) @@ -66,8 +60,7 @@ impl fmt::Display for VenueId { } } -/// One venue as a keeper types it: the id its adapter registers under -/// and the body schema it decodes. Implement on a unit marker +/// One venue as a keeper types it. Implement on a unit marker /// (`struct CowVenue;`) and drive it through [`VenueClient`]. pub trait Venue { /// The id the venue's adapter registers under. @@ -88,10 +81,8 @@ pub mod sealed { /// The byte-level seam under the typed client: `videre:venue/client` /// with the venue named per call. Native AFIT, so a [`VenueClient`] -/// over any transport dispatches statically. [`HostVenues`] binds it to -/// the module's own import; tests implement it in memory. -/// -/// Sealed: a transport opts in by also implementing the sealing marker. +/// over any transport dispatches statically. Sealed: a transport opts +/// in by also implementing the sealing marker. pub trait VenueTransport: sealed::SealedTransport { /// Price an opaque intent body at the named venue. fn quote( @@ -107,10 +98,9 @@ pub trait VenueTransport: sealed::SealedTransport { body: Vec, ) -> impl Future>; - /// Put an externally-obtained receipt under the host's status - /// watch; an accepted submit is watched implicitly. Defaults to - /// `unsupported`: a transport that can watch foreign receipts opts - /// in. + /// Put an externally-obtained receipt under the host's status watch; + /// an accepted submit is watched implicitly. Defaults to + /// `unsupported`. fn observe( &self, venue: &VenueId, @@ -137,46 +127,32 @@ pub trait VenueTransport: sealed::SealedTransport { ) -> impl Future>; } -/// The reconcile contract a venue adapter honours so a keeper can -/// recover a stranded reservation without double-placing. A marker: it -/// adds no methods, it names the guarantees the adapter's +/// Marker naming the reconcile guarantees an adapter's /// [`submit`](crate::VenueAdapter::submit) and -/// [`status`](crate::VenueAdapter::status) already give. Exactly-once -/// across the external POST is unreachable from the host alone (the call -/// is not inside the reserve transaction), so the floor is venue-side and -/// per-adapter. -/// -/// An adapter opts in by implementing it (and the sealing marker), and -/// proves it with `videre_test::venue_reconcile_compliance!`. +/// [`status`](crate::VenueAdapter::status) give, so a keeper recovers a +/// stranded reservation without double-placing. An adapter opts in by +/// implementing it (and the sealing marker), and proves it with +/// `videre_test::venue_reconcile_compliance!`. /// /// # Contract /// -/// 1. Mandatory re-POST idempotency. A [`submit`](crate::VenueAdapter::submit) -/// of a body the venue already holds resolves to the SAME outcome as -/// the first submit: a signed order folds to -/// [`SubmitOutcome::Accepted`] with the same receipt, a pre-sign order -/// to the same [`SubmitOutcome::RequiresSigning`] call. A held body -/// NEVER surfaces as a terminal [`VenueFault`]: it folds to the accept -/// outcome, never to a classified `denied`. This floor is what makes a -/// reconcile resubmit safe. -/// 2. Optional status fast-path. An adapter MAY derive a receipt from the -/// body, so a reconcile can [`status`](crate::VenueAdapter::status) it -/// first and commit without a redundant POST. -/// [`observe`](VenueTransport::observe) (defaulting `unsupported`) is -/// not the reconcile primitive; `submit` is. +/// 1. Re-POST idempotency (mandatory): a +/// [`submit`](crate::VenueAdapter::submit) of a body the venue already +/// holds resolves to the SAME outcome as the first, never to a +/// terminal [`VenueFault`]. This is what makes a reconcile resubmit +/// safe. +/// 2. Status fast-path (optional): an adapter MAY derive a receipt from +/// the body, so a reconcile can [`status`](crate::VenueAdapter::status) +/// first and commit without a redundant POST. The reconcile primitive +/// is `submit`, not [`observe`](VenueTransport::observe). /// -/// # Validity -/// -/// Reconcile trusts venue-side order validity (its `validTo` and limit -/// price); it does not re-poll the watch source. The contract therefore -/// requires adapters to carry self-describing order validity. (Maintainer -/// decision, 2026-07-24.) +/// Reconcile trusts venue-side order validity and does not re-poll the +/// watch source, so adapters must carry self-describing order validity. pub trait VenueReconcile: crate::VenueAdapter + sealed::SealedReconcile {} -/// Poll a future once and return its state. `videre:venue/client@0.1.0` -/// declares plain funcs, so a [`VenueTransport`] over the host import -/// resolves on the first poll. [`Poll::Pending`] means a foreign -/// [`VenueTransport`] impl suspended, which the keeper macro folds to +/// Poll a future once and return its state. A [`VenueTransport`] over the +/// host import resolves on the first poll; [`Poll::Pending`] means a +/// foreign impl suspended, which the keeper macro folds to /// `Fault::Internal`. pub fn poll_once(future: F) -> Poll { let mut future = pin!(future); @@ -185,8 +161,7 @@ pub fn poll_once(future: F) -> Poll { } /// The module's `videre:venue/client` import behind the -/// [`VenueTransport`] seam: the transport every guest-side -/// [`VenueClient`] defaults to. +/// [`VenueTransport`] seam; the default transport for a [`VenueClient`]. #[derive(Clone, Copy, Debug, Default)] pub struct HostVenues; @@ -215,9 +190,8 @@ impl VenueTransport for HostVenues { } /// A typed client bound to one [`Venue`]: encodes the venue's -/// [`IntentBody`] to wire bytes and forwards through the -/// [`VenueTransport`] seam under [`Venue::ID`]. Zero-sized over the -/// default [`HostVenues`] transport. +/// [`IntentBody`] and forwards through the [`VenueTransport`] seam under +/// [`Venue::ID`]. Zero-sized over the default [`HostVenues`] transport. pub struct VenueClient { transport: T, venue: PhantomData, @@ -242,8 +216,7 @@ impl Default for VenueClient { } impl VenueClient { - /// Bind the venue over a caller-supplied transport (tests, - /// in-process adapters). + /// Bind the venue over a caller-supplied transport. pub const fn with_transport(transport: T) -> Self { Self { transport, @@ -257,16 +230,16 @@ impl VenueClient { V::ID } - /// The bound transport, so a keeper reconcile pass resubmits reserved - /// bodies through the same seam this client submits on. + /// The bound transport, so a reconcile pass resubmits reserved bodies + /// through the same seam this client submits on. #[must_use] pub fn transport(&self) -> &T { &self.transport } - /// Encode the typed body and price it at the bound venue. The - /// returned [`Quoted`] carries the encoded bytes, so `submit` sends - /// exactly the body the venue priced. + /// Encode the typed body and price it at the bound venue. The returned + /// [`Quoted`] carries the encoded bytes, so `submit` sends exactly the + /// body the venue priced. pub async fn quote(&self, body: &V::Body) -> Result, ClientError> { let bytes = body.to_bytes()?; let quotation = self.transport.quote(&V::ID, bytes.clone()).await?; @@ -322,10 +295,9 @@ impl fmt::Debug for VenueClient { } /// A priced intent: the quotation plus the exact bytes it prices, bound -/// to the client that fetched it. Consuming it with -/// [`submit`](Self::submit) is the only way from a quote to a -/// submission, so a keeper cannot submit a body other than the one -/// quoted. +/// to the client that fetched it. [`submit`](Self::submit) is the only +/// way from a quote to a submission, so the submitted body is always the +/// quoted one. pub struct Quoted<'a, V: Venue, T: VenueTransport> { client: &'a VenueClient, bytes: Vec, @@ -355,10 +327,8 @@ impl fmt::Debug for Quoted<'_, V, T> { } /// Why a typed client call failed: before the wire (the body failed to -/// encode) or beyond it (the registry or venue refused). -/// -/// `IntoStaticStr` yields a snake_case label per case for log and -/// metric fields. +/// encode) or beyond it (the registry or venue refused). `IntoStaticStr` +/// yields a snake_case label per case. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] diff --git a/crates/videre-sdk/src/event.rs b/crates/videre-sdk/src/event.rs index 31a188a0..c0b48cfd 100644 --- a/crates/videre-sdk/src/event.rs +++ b/crates/videre-sdk/src/event.rs @@ -1,8 +1,7 @@ -//! Typed recovery of videre events from the core `custom` channel. -//! -//! A venue transition reaches a module as a `custom` event; this module -//! decodes it back into the typed [`IntentStatusUpdate`] a keeper handler -//! works with. The `#[keeper]` macro calls it for `on_intent_status`. +//! Typed recovery of videre events from the core `custom` channel: decode +//! a `custom` event back into the typed [`IntentStatusUpdate`] a keeper +//! handler works with. The `#[keeper]` macro calls it for +//! `on_intent_status`. use crate::IntentStatusUpdate; diff --git a/crates/videre-sdk/src/faults.rs b/crates/videre-sdk/src/faults.rs index a4516a55..016b478f 100644 --- a/crates/videre-sdk/src/faults.rs +++ b/crates/videre-sdk/src/faults.rs @@ -1,13 +1,11 @@ -//! 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; plus [`VenueFault`], the owned client-side -//! mirror of the wire error. +//! Conversions between the 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; +//! plus [`VenueFault`], the owned client-side mirror of the wire error. //! -//! Every conversion here is lossy only downward (a structured case folds -//! to a payload-bearing string case, never the reverse), so `?` in an -//! adapter always preserves the most structured form the target -//! vocabulary can carry. +//! Conversions are lossy only downward (a structured case folds to a +//! string case, never the reverse), so `?` preserves the most structured +//! form the target vocabulary can carry. use nexum_sdk::host; use strum::IntoStaticStr; @@ -16,13 +14,9 @@ use crate::bindings::nexum::host::types::RateLimit as WireRateLimit; use crate::client::ClientError; use crate::{Fault, RateLimit, VenueError}; -/// Owned mirror of the wire `venue-error` with `Display`: what typed -/// client code reports when the registry or a venue refuses. The -/// structured retry hint (`rate-limited`'s `retry-after-ms`) survives -/// the lift. -/// -/// `IntoStaticStr` yields a snake_case label per case for log and -/// metric fields. +/// Owned mirror of the wire `venue-error`: what typed client code reports +/// when the registry or a venue refuses. `IntoStaticStr` yields a +/// snake_case label per case. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -60,8 +54,8 @@ pub enum VenueFault { ReceiptMismatch, } -/// Lift the wire error into the owned mirror. Exhaustive: the wire enum -/// is this crate's own bindgen, so a new WIT case fails here first. +/// Lift the wire error into the owned mirror; exhaustive, so a new WIT +/// case fails to compile here. impl From for VenueFault { fn from(err: VenueError) -> Self { match err { @@ -81,8 +75,7 @@ impl From for VenueFault { } /// 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. +/// seams speak; exhaustive, so a new WIT case fails to compile here. pub fn fault_into_sdk(fault: Fault) -> host::Fault { match fault { Fault::Unsupported(s) => host::Fault::Unsupported(s), @@ -98,10 +91,8 @@ pub fn fault_into_sdk(fault: Fault) -> host::Fault { } /// 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. +/// `init` returns. `host::Fault` is `#[non_exhaustive]`, so a future case +/// lands as `internal` carrying its `Display` detail. impl From for Fault { fn from(fault: host::Fault) -> Self { match fault { @@ -121,10 +112,9 @@ impl From for Fault { /// Fold a transport fault into the venue error an intent function /// returns: `denied`, `rate-limited`, `timeout`, and `unsupported` map -/// structurally; `unavailable` keeps its detail; the caller-shaped cases -/// (`invalid-input`, `internal`) fold to retryable `unavailable` because -/// inside an intent function the transport's caller is the adapter -/// itself, never the module. +/// structurally; the caller-shaped cases (`invalid-input`, `internal`) +/// fold to retryable `unavailable`, since inside an intent function the +/// caller is the adapter itself. impl From for VenueError { fn from(fault: host::Fault) -> Self { match fault { @@ -142,9 +132,8 @@ impl From for VenueError { /// Fold a typed client failure into the SDK-neutral fault a keeper /// handler returns: an encode failure, a misnamed venue, and an invalid -/// receipt are the caller's `invalid-input`; a receipt mismatch is -/// `internal` (a venue integrity failure, not the caller's); other venue -/// refusals map structurally. +/// receipt are the caller's `invalid-input`; a receipt mismatch is a +/// venue integrity `internal`; other refusals map structurally. impl From for host::Fault { fn from(err: ClientError) -> Self { match err { @@ -168,8 +157,7 @@ impl From for host::Fault { /// Fold a wasi:http fetch failure into the venue error an intent /// function returns: an allowlist refusal stays `denied`, a timeout is -/// `timeout`, and transport failures (including a request the adapter -/// itself malformed) are retryable `unavailable`. +/// `timeout`, and transport failures are retryable `unavailable`. impl From for VenueError { fn from(err: nexum_sdk::http::FetchError) -> Self { use nexum_sdk::http::FetchError; diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index b066e0fc..cb7608f3 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -1,13 +1,8 @@ -//! The generic keeper run: one pass assembling the world-neutral -//! stores - [`WatchSet`] to [`Gates`] to [`Poller::poll`] to -//! [`Retrier`] to [`Journal`] - and routing submissions through the -//! [`VenueTransport`] seam. -//! -//! [`Outcome`] is the shared poll outcome: the concrete -//! [`Poller::Outcome`] a keeper's pollers produce so -//! [`Keeper::run`] can act on every one of them. The world-neutral -//! primitives stay in `nexum_sdk::keeper`; this module only assembles -//! them. +//! The generic keeper run: [`Keeper::run`] assembles the world-neutral +//! `nexum_sdk::keeper` stores ([`WatchSet`], [`Gates`], [`Retrier`], +//! [`Journal`]) over a [`Poller`] and routes submissions through the +//! [`VenueTransport`] seam. [`Outcome`] is the shared [`Poller::Outcome`] +//! a keeper's pollers produce. use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ @@ -76,21 +71,19 @@ impl Keeper { } impl Keeper { - /// Run one sweep at `tick`. First the [`reconcile`] pass resolves - /// every stranded reservation on the `submitted:` reserve/commit - /// [`Journal`], budget-bounded by the reconcile budget; then every - /// gate-ready watch is polled, and an [`Outcome::Submit`] body is - /// reserved on its [`submission_key`] before the venue await and - /// committed on acceptance. A `RESERVED` marker seen by the submit - /// arm is owned by this tick's reconcile pass and never re-POSTed; a - /// `COMMITTED` marker is an idempotent skip. `release` runs only on a - /// known synchronous non-accept (`requires-signing` or a venue - /// refusal): no crash, trap, `OutOfFuel`, or deadline-cancel during - /// the await reaches a release, so the marker persists for the next - /// tick's reconcile pass. This is the reconcile-not-release contract. - /// Store faults abort the run, bar the best-effort commit and marker - /// clear; venue refusals never do - they fold into per-watch retry - /// actions. + /// Run one sweep at `tick`: the [`reconcile`] pass first (budget-bounded), + /// then every gate-ready watch is polled and an [`Outcome::Submit`] + /// body reserved on its [`submission_key`] before the venue await, + /// committed on acceptance. + /// + /// Reconcile-not-release contract: `release` runs only on a known + /// synchronous non-accept (`requires-signing` or a venue refusal); a + /// crash, trap, `OutOfFuel`, or deadline-cancel mid-await leaves the + /// `RESERVED` marker for the next tick's reconcile pass. A `RESERVED` + /// marker at the submit arm is owned by this tick's reconcile and never + /// re-POSTed; a `COMMITTED` marker is an idempotent skip. Store faults + /// abort the run (bar the best-effort commit and marker clear); venue + /// refusals fold into per-watch retry actions. pub async fn run(&self, host: &H, tick: &Tick) -> Result where H: LocalStoreHost, @@ -189,12 +182,10 @@ impl Keeper { } } -/// Top-of-sweep reconcile pass over the reserve journal: before the -/// fresh-watch loop, resolve each stranded reservation against the -/// venue. A `RESERVED` marker is an unknown submit outcome - an -/// accepted-but-uncommitted write, or a crash, trap, or deadline-cancel -/// mid-submit - and is NEVER skipped. Each reservation from -/// [`Journal::pending`], budget-bounded by `max_per_tick`: +/// Top-of-sweep reconcile pass: resolve each stranded reservation from +/// [`Journal::pending`] against the venue, budget-bounded by +/// `max_per_tick`. A `RESERVED` marker is an unknown submit outcome and +/// is NEVER skipped. /// /// - `next_eligible > tick.epoch_s`: still backing off, left untouched. /// - accepted: committed. @@ -202,9 +193,6 @@ impl Keeper { /// - terminal refusal (a [`RetryAction::Drop`] fault): released. /// - transient refusal: left `RESERVED` for the next tick; a rate-limit /// hint re-parks `next_eligible`. -/// -/// No `release` runs on a post-await success or unknown path. Shared so -/// an L3 keeper reuses the exact resolution. pub async fn reconcile( venue: &VenueId, venues: &P, @@ -273,16 +261,14 @@ pub struct RunReport { pub polled: usize, /// Watches skipped by an unexpired gate. pub gated: usize, - /// Watches skipped unread: a malformed key, or a row that vanished - /// mid-run. + /// Watches skipped unread: a malformed key or a vanished row. pub skipped: usize, /// Bodies the venue accepted, submission key newly journalled. pub submitted: usize, - /// Bodies whose key an earlier run had journalled, skipped - /// without a venue call. + /// Bodies an earlier run journalled, skipped without a venue call. pub duplicates: usize, - /// Watches left in place for a later tick, plus submit arms whose - /// marker the reconcile pass already owns this tick. + /// Watches left in place for a later tick, plus submit arms the + /// reconcile pass already owns this tick. pub retried: usize, /// Watches dropped. pub dropped: usize, @@ -297,9 +283,8 @@ pub struct RunReport { /// Reservations the reconcile pass answered `requires-signing`; the /// txs ride [`unsigned`](Self::unsigned). pub reconciled_unsigned: usize, - /// Transactions the venue answered `requires-signing`; a run - /// cannot sign, so the caller owns them. Carries both fresh-watch - /// and reconcile-pass answers. + /// Transactions the venue answered `requires-signing`; a run cannot + /// sign, so the caller owns them. Fresh-watch and reconcile answers. pub unsigned: Vec, } @@ -321,20 +306,16 @@ pub struct ReconcileReport { pub unsigned: Vec, } -/// Deterministic pre-submit journal key: the venue id and the -/// keccak-256 of the body. The hash is a fixed-length suffix, so the -/// key is unambiguous whatever the venue id contains. Public so a -/// keeper journalling outside [`Keeper::run`] writes the key the -/// run checks. +/// Deterministic pre-submit journal key: the venue id and the keccak-256 +/// of the body as a fixed-length suffix, so the key is unambiguous +/// whatever the venue id contains. pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) } -/// Fold a venue refusal into the retry action the ledger runs: the -/// throttle hint becomes an epoch gate, transient failures retry next -/// block, and refusals no retry can cure drop the watch. Public so a -/// keeper running outside [`Keeper::run`] folds refusals the same -/// way. +/// Fold a venue refusal into a retry action: a throttle hint becomes an +/// epoch gate, transient failures retry next block, and refusals no retry +/// can cure drop the watch. pub fn retry_action(fault: &VenueFault) -> RetryAction { match fault { VenueFault::RateLimited { @@ -712,10 +693,9 @@ mod tests { .expect("reserve writes"); } - /// Venue that counts POSTs and models re-POST idempotency: a body it - /// already holds re-accepts (never a fresh refusal), so a reconcile - /// resubmit is safe. A body it does not hold gets the programmed - /// `outcome`; an accepted outcome joins the held set. + /// Venue that counts POSTs and models re-POST idempotency: a held + /// body re-accepts, an unheld one gets the programmed `outcome` and + /// joins the held set on acceptance. struct CountingVenue { outcome: RefCell>, posts: RefCell>>, @@ -743,8 +723,8 @@ mod tests { self.held.borrow().len() } - /// Pre-seed a held body: a POST the venue received before the - /// caller lost its outcome to a deadline-cancel. + /// Pre-seed a held body: a POST received before the caller lost + /// its outcome to a deadline-cancel. fn preload(&self, body: &[u8]) { self.held.borrow_mut().insert(body.to_vec()); } @@ -786,10 +766,9 @@ mod tests { } } - /// Wraps a store, faulting the first `COMMITTED` write to - /// `submitted:` once, then delegating. Models an accepted submit - /// whose commit write faults: the `RESERVED` marker persists and no - /// release runs. + /// Wraps a store, faulting the first `COMMITTED` write to `submitted:` + /// once: models an accepted submit whose commit write faults, leaving + /// the `RESERVED` marker with no release. struct FlakyCommit { inner: MockLocalStore, arm: Cell, diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index d32e97f3..238c4b73 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -1,69 +1,14 @@ -//! # videre-sdk +//! Guest-side SDK for the videre venue personas: the venue author +//! (a venue's protocol speaker exporting the `venue-adapter` world) and +//! the keeper author driving venues through the client seam. //! -//! Guest-side SDK for the videre personas: the venue author (one -//! venue's protocol speaker exporting the `venue-adapter` world) and -//! the keeper author driving venues through the client seam. Where -//! `nexum-sdk` serves the strategy-module persona, this crate serves -//! both venue sides of it. -//! -//! ## What lives here -//! -//! - [`VenueAdapter`] - the trait mirroring the world's export face -//! (`init` plus the five intent functions). `#[videre_sdk::venue]` -//! on the impl turns it into the component's export glue: the single -//! blessed authoring path. -//! -//! - [`IntentBody`] (trait and derive) with [`BodyError`] - the borsh -//! codec over the outer per-venue version enum. The wire form is a -//! one-byte version tag plus the borsh payload; an unknown tag fails -//! typedly rather than as a stringly decode error. -//! -//! - [`client`] - the typed venue client: a [`Venue`] marker (its -//! [`VenueId`] plus body schema) drives [`VenueClient`], which -//! encodes through [`IntentBody`] before the byte-level, native-AFIT -//! [`VenueTransport`] seam ([`HostVenues`] binds it to the module's -//! own `videre:venue/client` import). Lives here (not in the -//! strategy SDK) so the codec and the client that speaks it version -//! together. `#[videre_sdk::keeper]` on a handler impl wires the -//! import and drives async handlers; -//! [`poll_once`](client::poll_once) completes their futures on the -//! synchronous guest boundary. -//! -//! - [`keeper`](mod@keeper) - the generic run assembler: -//! [`Keeper::run`] runs the world-neutral `nexum_sdk::keeper` -//! stores over a -//! [`Poller`](nexum_sdk::keeper::Poller) -//! producing the shared [`Outcome`] outcome, submitting through the -//! [`VenueTransport`] seam. -//! -//! - [`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), the -//! wasi:http surface re-exported as [`transport::http`], and -//! [`BoundedFetch`](transport::BoundedFetch), which caps the -//! wasi:http phase timeouts of every adapter request. -//! -//! - [`faults`] - the conversions that make `?` work across the wire -//! fault, the SDK-neutral fault, and [`VenueError`]; plus -//! [`VenueFault`], the owned client-side mirror. -//! -//! - [`event`] - typed recovery of videre events from the core `custom` -//! escape hatch: [`event::intent_status_update`] decodes an -//! intent-status transition a keeper subscribes to. -//! -//! - [`status_body`] - the venue status-body codec: decode an -//! `intent-status` event's `status` bytes into a typed -//! [`StatusBody`](status_body::StatusBody). -//! -//! ## Why the bindgen lives in this crate -//! -//! The shared interfaces generate once, in [`bindings`], from an -//! import-only world: the trait, wrappers, and client core are all -//! typed over them. The per-cdylib bindgens (`#[venue]`, `#[keeper]`) -//! remap the shared interfaces onto [`bindings`], so a macro-built -//! component speaks these types while its world stays derived from its -//! own manifest. +//! Entry points: +//! - [`VenueAdapter`] plus [`venue`](macro@venue): the venue authoring path. +//! - [`IntentBody`] with [`BodyError`]: the versioned borsh body codec. +//! - [`client`]: [`VenueClient`] over the [`VenueTransport`] seam, plus +//! [`keeper`](macro@keeper) wiring and [`poll_once`](client::poll_once). +//! - [`keeper`](mod@keeper): [`Keeper::run`], the generic run assembler. +//! - [`transport`], [`faults`], [`event`], [`status_body`]. //! //! [`ChainHost`]: nexum_sdk::host::ChainHost //! [`VenueClient`]: client::VenueClient @@ -92,48 +37,37 @@ pub use faults::VenueFault; pub use keeper::{ DEFAULT_RECONCILE_BUDGET, Keeper, Outcome, ReconcileReport, RunReport, reconcile, retry_action, }; -/// Derive [`IntentBody`] on the outer per-venue version enum. See -/// [`videre_macros::IntentBody`]. +/// Derive [`IntentBody`] on the outer per-venue version enum. pub use videre_macros::IntentBody; -/// The blessed keeper authoring path. Apply to a worker's handler impl: -/// emits the per-cdylib bindgen for a world derived from `module.toml` -/// (asserting the `client` capability), remaps the videre interfaces -/// onto the SDK bindings so the module drives a [`VenueClient`] with -/// shared type identity, dispatches events to the handlers (async ones -/// completed through [`client::poll_once`]), and folds [`ClientError`] into -/// the wire fault so `?` works in handlers. See -/// [`videre_macros::keeper`]. +/// Keeper authoring path. Apply to a handler impl: emits the per-cdylib +/// bindgen for a `module.toml`-derived world, drives a [`VenueClient`] +/// with shared type identity, dispatches events (async ones through +/// [`client::poll_once`]), and folds [`ClientError`] into the wire fault +/// so `?` works in handlers. pub use videre_macros::keeper; -/// The single blessed venue authoring path. Apply to the adapter's -/// `impl VenueAdapter for MyVenue` block: emits the per-cdylib bindgen -/// for a world derived from `module.toml` (asserting its -/// `kind = "venue-adapter"`), the `videre:venue/adapter` export glue, -/// and `export!`. The built component imports exactly the manifest's -/// declared scoped transport. See [`videre_macros::venue`]. +/// Venue authoring path. Apply to the `impl VenueAdapter for MyVenue` +/// block: emits the per-cdylib bindgen for a `module.toml`-derived world, +/// the `videre:venue/adapter` export glue, and `export!`. pub use videre_macros::venue; -/// The intent ontology at its plain spellings: the types the -/// [`VenueAdapter`] face and the client core speak. +/// The intent ontology the [`VenueAdapter`] face and client core speak. pub use bindings::videre::types::types::{ AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, UnsignedTx, VenueError, }; /// The value-flow vocabulary intent headers are expressed in. pub mod value_flow; -/// The venue status-body codec: decode an `intent-status` event's -/// `status` bytes into a typed [`StatusBody`](status_body::StatusBody). +/// The venue status-body codec. pub use videre_status_body as status_body; /// The intent-status transition a keeper recovers from a `custom` event -/// through [`event::intent_status_update`]. Its wire form is a version -/// tag plus the borsh envelope defined by this struct, not a WIT record: -/// it crosses the `custom` event as opaque bytes, and an unknown tag -/// fails closed. The status body rides its own inner codec. +/// through [`event::intent_status_update`]. Wire form is a version tag +/// plus a borsh envelope; an unknown tag fails closed. pub use videre_status_body::IntentStatusUpdate; /// 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. +/// The wire fault (`nexum:host/types.fault`) `init` returns; the +/// [`faults`] conversions bridge it to the SDK-neutral +/// [`nexum_sdk::host::Fault`] the transport seams speak. pub use bindings::nexum::host::types::Fault; diff --git a/crates/videre-sdk/src/transport.rs b/crates/videre-sdk/src/transport.rs index 93add1e0..1a5640ba 100644 --- a/crates/videre-sdk/src/transport.rs +++ b/crates/videre-sdk/src/transport.rs @@ -1,14 +1,10 @@ -//! Typed wrappers over the adapter world's scoped transport imports: -//! chain RPC, messaging, and outbound wasi:http. +//! Typed wrappers over the adapter world's scoped transport imports +//! (chain RPC, messaging, outbound wasi:http), adapting the bindgen +//! shims to the SDK-neutral `nexum_sdk::host` vocabulary. //! -//! 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`. +//! The wrappers only translate; scoping is the host's: chain to its +//! read-only surface, messaging to `messaging_topics`, HTTP to +//! `http_allow`, each refusal surfacing as a typed `denied`. use core::time::Duration; @@ -19,17 +15,14 @@ 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`], which +/// An off-allowlist request fails as [`FetchError::Denied`], which /// converts into [`VenueError`](crate::VenueError) via `?`. pub use nexum_sdk::http; -/// Caps the wasi:http phase timeouts of any [`Fetch`]: every request -/// through it, including plain [`Fetch::fetch`], has its connect, -/// first-byte and between-bytes timeouts clamped to `bound`, so a hung -/// endpoint errors within it rather than stalling the export call. A -/// caller may ask for less; it can never exceed the bound. +/// Clamps every wasi:http phase timeout of the inner [`Fetch`] (connect, +/// first byte, between bytes) to at most `bound`, so a hung endpoint +/// errors rather than stalling the export call. A caller may ask for +/// less, never more. #[derive(Clone, Copy, Debug)] pub struct BoundedFetch { inner: F, @@ -61,9 +54,8 @@ impl Fetch for BoundedFetch { } } -/// 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. +/// The adapter's `nexum:host/chain` import behind the SDK's [`ChainHost`] +/// seam. #[derive(Clone, Copy, Debug, Default)] pub struct HostChain; @@ -75,10 +67,9 @@ impl ChainHost for HostChain { 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. + /// round trip. The outer error is the batch failing to execute at + /// all; the per-entry results carry each call's outcome, in request + /// order. pub fn request_batch( &self, chain_id: u64, @@ -102,9 +93,7 @@ impl HostChain { } } -/// 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. +/// One JSON-RPC call inside a [`HostChain::request_batch`]. #[derive(Clone, Debug, Eq, PartialEq)] pub struct RpcRequest { /// JSON-RPC method, namespace prefix included. @@ -114,7 +103,6 @@ pub struct RpcRequest { } /// 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)), diff --git a/crates/videre-sdk/tests/adapter.rs b/crates/videre-sdk/tests/adapter.rs index f50b108f..3c995337 100644 --- a/crates/videre-sdk/tests/adapter.rs +++ b/crates/videre-sdk/tests/adapter.rs @@ -1,9 +1,7 @@ -//! Acceptance surface for the venue SDK: a hand-written adapter -//! compiles against [`VenueAdapter`] and round-trips a versioned body -//! through `#[derive(IntentBody)]` - including the typed -//! unknown-version failure and the typed [`VenueClient`] driving the -//! adapter through the [`VenueTransport`] seam. The world-export glue -//! is `#[videre_sdk::venue]`'s alone; echo-venue is its worked target. +//! Acceptance surface for the venue SDK: a hand-written adapter compiles +//! against [`VenueAdapter`], round-trips a versioned body through +//! `#[derive(IntentBody)]` (including the typed unknown-version failure), +//! and drives a [`VenueClient`] through the [`VenueTransport`] seam. use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::value_flow::{Asset, AssetAmount}; From bff144d12b7d14369c85b350a42a2f402fd3451d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:08:01 +0000 Subject: [PATCH 122/139] docs: tersen rustdoc in nexum-sdk core (#614) Compress crate root, host trait seam, and keeper store docs to house-style terse one-liners: cut the module-tour essay, the 70-line Host doctest stub, external-project comparisons, and per-field restatement, while preserving the fail-open, idempotence, ordering, and corrupt-tag reconcile contracts. Part of #613 --- crates/nexum-sdk/src/host.rs | 217 ++++++------------------------- crates/nexum-sdk/src/keeper.rs | 198 +++++++++++----------------- crates/nexum-sdk/src/lib.rs | 142 ++++---------------- crates/nexum-sdk/src/prelude.rs | 11 +- crates/nexum-sdk/tests/keeper.rs | 13 +- 5 files changed, 145 insertions(+), 436 deletions(-) diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 254c8787..90913a4a 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,35 +1,23 @@ -//! Host traits - the seam between strategy logic and the wit-bindgen -//! shims a module generates per-cdylib. +//! Host traits, the seam between strategy logic and the wit-bindgen +//! shims a module generates per-cdylib. Each trait mirrors one nexum +//! host interface ([`ChainHost`], [`IdentityHost`], [`LocalStoreHost`], +//! [`RemoteStoreHost`], [`MessagingHost`], [`LoggingHost`]); [`Host`] +//! bundles all six. //! -//! Each trait mirrors one nexum host interface: [`ChainHost`], -//! [`IdentityHost`], [`LocalStoreHost`], [`RemoteStoreHost`], -//! [`MessagingHost`], and [`LoggingHost`]. A module that wants -//! host-free unit tests writes its strategy logic against the -//! [`Host`] supertrait (all six) or the exact traits it exercises, -//! and lets `nexum-sdk-test` slot in the in-memory mocks. Domain SDKs -//! bound extra host interfaces on top with their own traits over the -//! same [`Fault`]. -//! -//! ## Why a separate `Fault` -//! -//! `wit_bindgen::generate!` emits a `Fault` type into each module's -//! own crate, so its identity is per-module. The SDK exposes [`Fault`] -//! (this module) with the same case shape, so modules wire a one-liner -//! converter between the two and the traits stay world-neutral, letting -//! the mocks compile without a wasm toolchain. See `nexum-sdk-test`'s -//! crate docs for the adapter pattern. +//! Strategy logic written against these traits runs host-free against +//! the `nexum-sdk-test` mocks. The traits are world-neutral over this +//! module's [`Fault`], mirroring the per-module `Fault` that +//! `wit_bindgen::generate!` emits, so modules wire a one-line converter +//! between the two. use alloy_primitives::{Address, B256, Bytes, Signature}; use strum::IntoStaticStr; use tracing_core::Level; -/// The cross-domain failure vocabulary richer host interfaces embed as -/// a case, mirrored from `nexum:host/types.fault`. Typed per-interface -/// errors wrap this shared payload-bearing set so a caller recovers the -/// structured cause without a stringly-typed ladder. -/// -/// `#[non_exhaustive]` forces downstream `match` sites to carry a wildcard -/// arm, so the WIT can grow a case without breaking them. +/// Shared cross-domain failure vocabulary, mirrored from +/// `nexum:host/types.fault`. Typed per-interface errors embed it as a +/// case so a caller recovers the structured cause. `#[non_exhaustive]`: +/// the WIT can grow a case. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -82,15 +70,8 @@ impl sealed::SealedHost for T where impl sealed::SealedHostFault for Fault {} impl sealed::SealedHostFault for ChainError {} -/// Recovers the shared [`Fault`] from a richer, per-interface error. -/// -/// Typed interface errors that embed a fault case implement this so a -/// caller can dispatch on the structured cause and pull a stable -/// snake_case [`label`](HostFault::label) for logs and metrics without -/// matching the outer type. -/// -/// Sealed: an error type opts in by also implementing the sealing -/// marker. +/// Recovers the shared [`Fault`] and a stable snake_case label from a +/// richer per-interface error. Sealed. pub trait HostFault: sealed::SealedHostFault { /// The embedded fault, when this value represents one. fn fault(&self) -> Option<&Fault>; @@ -109,16 +90,8 @@ impl HostFault for Fault { } /// A structured JSON-RPC error response, mirrored from -/// `nexum:host/chain.rpc-error`. `code` is the node-reported numeric -/// (typically `-32000` for an `eth_call` revert). `data` is the decoded -/// `error.data` payload: the host hex-decodes the upstream JSON string -/// once, so a strategy receives the raw abi-encoded revert bytes and -/// can hand them straight to a revert decoder. -/// -/// This is a world-neutral mirror, not `alloy_json_rpc::ErrorPayload`: -/// that type widens `code` to `i64` and carries `data` as raw JSON, and -/// depending on it would drag the JSON-RPC client stack into every wasm -/// guest, which only ever sees the host-decoded bytes over WIT. +/// `nexum:host/chain.rpc-error`. `data` holds the host-decoded +/// `error.data` revert bytes, ready for a revert decoder. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] #[error("rpc error {code}: {message}")] pub struct RpcError { @@ -127,18 +100,13 @@ pub struct RpcError { /// Human-readable detail. pub message: String, /// Decoded `error.data` bytes, when the node returned a hex payload. - /// `Bytes` so a guest hands the host-decoded buffer to a revert - /// decoder without re-copying it. pub data: Option, } /// Failure of a `nexum:host/chain` call, mirrored from -/// `nexum:host/chain.chain-error`: either a shared host [`Fault`] -/// (transport down, timed out, denied, ...) or a structured JSON-RPC -/// [`RpcError`] carrying the node code and any decoded revert payload. -/// -/// [`HostFault`] recovers the embedded [`Fault`] (present only on the -/// `Fault` case) and a stable snake_case label for logs and metrics. +/// `nexum:host/chain.chain-error`: a shared host [`Fault`] or a +/// structured JSON-RPC [`RpcError`]. [`HostFault`] recovers the +/// embedded [`Fault`], present only on the `Fault` case. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] #[non_exhaustive] pub enum ChainError { @@ -166,11 +134,9 @@ impl HostFault for ChainError { } } -/// Fold a [`ChainError`] into the shared [`Fault`] a module returns -/// from `init` / `on_event`. The `fault` case passes through; a -/// structured JSON-RPC [`RpcError`] has no shared-vocabulary case, so -/// it becomes an [`Fault::Internal`] carrying the node code, message, -/// and any decoded revert bytes as a `0x` hex suffix. +/// Fold a [`ChainError`] into the shared [`Fault`]: the `Fault` case +/// passes through; an [`RpcError`] becomes [`Fault::Internal`] carrying +/// the code, message, and any revert bytes as a `0x` hex suffix. impl From for Fault { fn from(err: ChainError) -> Self { match err { @@ -190,20 +156,11 @@ impl From for Fault { /// `nexum:host/chain` - raw JSON-RPC dispatch. pub trait ChainHost { - /// Execute a JSON-RPC request against the given chain. The host - /// routes to its configured provider; the SDK does not care which - /// transport (HTTP / WebSocket / mock) implements the call. A - /// failure is a [`ChainError`]: a shared [`Fault`] or a structured - /// JSON-RPC [`RpcError`] carrying any decoded revert bytes. + /// Execute a JSON-RPC request against the given chain. fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } /// `nexum:host/local-store` - per-module key-value persistence. -/// -/// The interface reports failures as a [`Fault`]: the interface is the -/// failure domain, so the case vocabulary alone carries the cause. A -/// strategy that aggregates store and chain calls into one [`Fault`] -/// return relies on the `From` fold for `?`. pub trait LocalStoreHost { /// Fetch a value. `Ok(None)` when the key is absent. fn get(&self, key: &str) -> Result>, Fault>; @@ -213,18 +170,15 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; - /// Whether `key` exists. Default fetches the value; a backend - /// overrides when it can answer without. + /// Whether `key` exists. fn contains(&self, key: &str) -> Result { Ok(self.get(key)?.is_some()) } - /// Value byte length, `Ok(None)` when absent. Default fetches the - /// value; on some backends this may be a scan. + /// Value byte length, `Ok(None)` when absent. fn len(&self, key: &str) -> Result, Fault> { Ok(self.get(key)?.map(|v| v.len() as u64)) } - /// Number of keys starting with `prefix`. Default materialises the - /// key list; on some backends this may be a scan. + /// Number of keys starting with `prefix`. fn count(&self, prefix: &str) -> Result { Ok(self.list_keys(prefix)?.len() as u64) } @@ -232,9 +186,7 @@ pub trait LocalStoreHost { /// `nexum:host/logging` - structured runtime logs. pub trait LoggingHost { - /// Emit a log line at the given [`Level`]. The bind macro maps it - /// onto the generated wire enum; the WIT edge is the only place a - /// non-`Level` severity type appears. + /// Emit a log line at the given [`Level`]. fn log(&self, level: Level, message: &str); } @@ -265,9 +217,9 @@ pub struct Message { pub sender: Option>, } -/// `nexum:host/messaging` - publish to and query content topics. The -/// host confines both to the component's `messaging_topics` grant; an -/// off-scope topic fails as [`Fault::Denied`]. +/// `nexum:host/messaging` - publish to and query content topics, +/// confined to the component's `messaging_topics` grant; an off-scope +/// topic fails as [`Fault::Denied`]. pub trait MessagingHost { /// Publish a payload to a content topic /// (`////`). @@ -298,9 +250,8 @@ pub trait RemoteStoreHost { fn write_feed(&self, topic: B256, data: &[u8]) -> Result; } -/// Lift a host-returned account into an [`Address`]. The WIT edge -/// carries it as bytes; any length but 20 is a host-side bug, folded -/// to [`Fault::Internal`]. +/// Lift a host-returned account into an [`Address`]; any length but 20 +/// folds to [`Fault::Internal`]. pub fn account_from_wire(raw: &[u8]) -> Result { Address::try_from(raw).map_err(|_| { Fault::Internal(format!( @@ -311,15 +262,14 @@ pub fn account_from_wire(raw: &[u8]) -> Result { } /// Lift a host-returned 65-byte `r || s || v` signature into a -/// [`Signature`]. A malformed buffer is a host-side bug, folded to -/// [`Fault::Internal`]. +/// [`Signature`]; a malformed buffer folds to [`Fault::Internal`]. pub fn signature_from_wire(raw: &[u8]) -> Result { Signature::from_raw(raw) .map_err(|e| Fault::Internal(format!("identity returned a malformed signature: {e}"))) } -/// Lift a host-returned content reference into a [`B256`]. Any length -/// but 32 is a host-side bug, folded to [`Fault::Internal`]. +/// Lift a host-returned content reference into a [`B256`]; any length +/// but 32 folds to [`Fault::Internal`]. pub fn reference_from_wire(raw: &[u8]) -> Result { B256::try_from(raw).map_err(|_| { Fault::Internal(format!( @@ -329,95 +279,10 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { }) } -/// Supertrait that bundles all six core host interfaces. Modules that -/// want full host-free integration tests take `&impl Host` (or a -/// generic ``) in their strategy function; -/// `nexum-sdk-test::MockHost` is the in-memory implementation. -/// Strategies that exercise fewer interfaces bound exactly those -/// (`H: ChainHost + LoggingHost`, say) so their production adapter -/// only needs the capabilities the module declares; a domain -/// extension's host trait is bounded the same way (the CoW SDK's -/// `CowHost`). -/// -/// A blanket impl is provided for any type that implements all six -/// component traits, so callers do not have to add a redundant -/// `impl Host for MyHost {}`. -/// -/// # Example -/// -/// Strategy functions are generic over [`Host`]. Production code plugs -/// the per-module `WitBindgenHost` adapter (see `modules/examples/`); -/// unit tests plug `nexum_sdk_test::MockHost`. -/// -/// ``` -/// use nexum_sdk::Level; -/// use nexum_sdk::host::{ -/// ChainError, ChainHost, Fault, Host, IdentityHost, LocalStoreHost, LoggingHost, -/// Message, MessagingHost, RemoteStoreHost, -/// }; -/// # use nexum_sdk::prelude::{Address, B256, Signature}; -/// -/// /// Pure strategy logic - no wit-bindgen calls in here. -/// fn record_block(host: &H, chain_id: u64, key: &str) -> Result<(), Fault> { -/// host.log(Level::INFO, "recording block"); -/// host.set(key, b"")?; -/// let _block_number = host.request(chain_id, "eth_blockNumber", "[]")?; -/// Ok(()) -/// } -/// -/// // Minimal hand-rolled host so the doctest is self-contained. -/// // Real modules wire `nexum_sdk_test::MockHost` here. -/// # struct StubHost; -/// # impl ChainHost for StubHost { -/// # fn request(&self, _: u64, _: &str, _: &str) -> Result { -/// # Ok("\"0x0\"".into()) -/// # } -/// # } -/// # impl IdentityHost for StubHost { -/// # fn accounts(&self) -> Result, Fault> { Ok(vec![]) } -/// # fn sign(&self, _: Address, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn sign_typed_data(&self, _: Address, _: &str) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # } -/// # impl LocalStoreHost for StubHost { -/// # fn get(&self, _: &str) -> Result>, Fault> { Ok(None) } -/// # fn set(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } -/// # fn delete(&self, _: &str) -> Result<(), Fault> { Ok(()) } -/// # fn list_keys(&self, _: &str) -> Result, Fault> { Ok(vec![]) } -/// # } -/// # impl RemoteStoreHost for StubHost { -/// # fn upload(&self, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn download(&self, _: B256) -> Result, Fault> { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # fn read_feed(&self, _: Address, _: B256) -> Result>, Fault> { Ok(None) } -/// # fn write_feed(&self, _: B256, _: &[u8]) -> Result { -/// # Err(Fault::Unsupported("stub".into())) -/// # } -/// # } -/// # impl MessagingHost for StubHost { -/// # fn publish(&self, _: &str, _: &[u8]) -> Result<(), Fault> { Ok(()) } -/// # fn query( -/// # &self, -/// # _: &str, -/// # _: Option, -/// # _: Option, -/// # _: Option, -/// # ) -> Result, Fault> { -/// # Ok(vec![]) -/// # } -/// # } -/// # impl LoggingHost for StubHost { -/// # fn log(&self, _: Level, _: &str) {} -/// # } -/// record_block(&StubHost, 1, "block:42").unwrap(); -/// ``` -/// Sealed: the blanket impl is the only implementation. +/// Supertrait bundling all six core host interfaces. Strategy functions +/// take `` (or bound exactly the interfaces they exercise) and +/// run against `nexum_sdk_test::MockHost` in tests. Blanket-implemented +/// for any type carrying all six; sealed, so that impl is the only one. pub trait Host: sealed::SealedHost + ChainHost diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 7457acf3..594ea550 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -1,35 +1,21 @@ -//! Strategy-keeper stores: the persistent-state conventions shared by -//! conditional-commitment modules, expressed over [`LocalStoreHost`] -//! alone so they compile for any world and test against the in-memory -//! mocks. +//! Strategy-keeper stores: persistent-state conventions shared by +//! conditional-commitment modules, expressed over [`LocalStoreHost`] 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. +//! - [`WatchSet`] - watch-set registry, one `watch:{owner}:{hash}` row +//! per conditional commitment. +//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a u64 +//! little-endian threshold, with an [`is_ready`](Gates::is_ready) +//! predicate. +//! - [`Journal`] - receipt-keyed idempotency journal. +//! - [`Poller`] - world-neutral poll seam: one watch in, one outcome +//! out at a [`Tick`]. +//! - [`Retrier`] - runs a [`RetryAction`] through the stores after a +//! failed run. //! -//! Three stores cover the machinery watcher modules hand-roll: -//! -//! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` -//! row per conditional commitment. -//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a -//! u64 little-endian threshold, with an -//! [`is_ready`](Gates::is_ready) predicate the poll loop consults. -//! - [`Journal`] - the receipt-keyed idempotency journal of -//! `submitted:` / `observed:` presence markers. -//! -//! Two pieces drive the stores from the poll loop: -//! -//! - [`Poller`] - 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 keeper run attempt. -//! -//! [`WatchRef`] ties the first two together: gate and marker keys are -//! derived from the exact hex substrings of the stored watch key, and -//! [`WatchSet::remove`] drops a watch together with all of its derived -//! keys so no failure path can orphan one. +//! [`WatchRef`] derives gate and marker keys from the verbatim hex +//! substrings of the stored watch key; [`WatchSet::remove`] drops a +//! watch with all its derived keys so no failure path orphans one. //! //! ``` //! use nexum_sdk::keeper::{Gates, Journal, WatchRef, WatchSet}; @@ -92,30 +78,25 @@ pub const WATCH_PREFIX: &str = "watch:"; pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; /// Prefix of the Unix-seconds gate row paired with a watch. pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; -/// Journal prefix for receipts the module posted upstream itself: the -/// submit path recorded that it has sent an order on. +/// Journal prefix for receipts the module submitted upstream itself. pub const SUBMITTED_PREFIX: &str = "submitted:"; -/// Journal prefix for receipts the module confirmed but did not post: -/// the observe-and-verify path (e.g. ethflow) recorded an existing -/// upstream order as seen. +/// Journal prefix for receipts the module observed upstream but did not +/// submit. pub const OBSERVED_PREFIX: &str = "observed:"; -/// Prefix of the first-refusal marker paired with a watch: the block a -/// [`RetryAction::DropOnRepeat`] refusal was first seen at, u64 -/// little-endian. +/// First-refusal marker paired with a watch: block of the first +/// [`RetryAction::DropOnRepeat`] refusal, u64 little-endian. pub const REFUSED_PREFIX: &str = "refused:"; -/// Canonical watch key for an owner / commitment-hash pair (lowercase -/// `0x`-prefixed hex on both halves). Free-standing because the key -/// shape is a property of the store convention, not of any host. +/// Canonical watch key for an owner / commitment-hash pair; lowercase +/// `0x`-prefixed hex on both halves. #[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 -/// byte-stable regardless of how the original writer cased the hex. +/// Borrowed view of a watch key's two hex halves. Derived keys reuse +/// the verbatim substrings, so parse-then-derive is byte-stable +/// regardless of the original hex casing. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct WatchRef<'k> { owner_hex: &'k str, @@ -124,9 +105,7 @@ pub struct WatchRef<'k> { impl<'k> WatchRef<'k> { /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or - /// the separating colon is missing, or when either half is empty - /// (an empty half would derive a degenerate gate key like - /// `next_block::`). + /// colon is missing, or either half is empty. pub fn parse(key: &'k str) -> Option { let rest = key.strip_prefix(WATCH_PREFIX)?; let (owner_hex, hash_hex) = rest.split_once(':')?; @@ -183,15 +162,13 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair. Thin - /// delegate kept for discoverability; prefer the free - /// [`watch_key`], which needs no host turbofish. + /// Canonical key for an owner / commitment-hash pair; delegates to + /// [`watch_key`]. pub fn key(owner: &Address, hash: &B256) -> String { watch_key(owner, hash) } /// Insert or overwrite the watch row; returns the key written. - /// Overwriting in place makes re-indexing a replayed log a no-op. pub fn put(&self, owner: &Address, hash: &B256, value: &[u8]) -> Result { let key = Self::key(owner, hash); self.host.set(&key, value)?; @@ -208,10 +185,9 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { self.host.list_keys(WATCH_PREFIX) } - /// Drop the watch together with its gate and refusal keys. The - /// derived keys go first: a fault part-way leaves the watch row - /// behind so a retry re-drops it, and a derived key can never - /// outlive its watch. + /// Drop the watch with its gate and refusal keys. Derived keys go + /// first, so a fault leaves the watch row for a retry and never + /// orphans a derived key. pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { Gates::new(self.host).clear(watch)?; self.host.delete(&watch.refused_key())?; @@ -219,10 +195,9 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { } } -/// Gate-key discipline: `next_block:{owner}:{hash}` and -/// `next_epoch:{owner}:{hash}` rows holding a u64 little-endian -/// threshold. A malformed or absent row reads as "no gate", so a -/// corrupt value can only make a watch poll sooner, never wedge it. +/// `next_block:` / `next_epoch:` gate rows holding a u64 little-endian +/// threshold. A malformed or absent row reads as no gate (fail-open: a +/// corrupt value can only make a watch poll sooner, never wedge it). pub struct Gates<'h, H> { host: &'h H, } @@ -244,9 +219,8 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) } - /// Whether the watch is clear to poll at the given block height - /// and Unix-seconds timestamp. Both gates must pass; each is - /// inclusive at its threshold. + /// Whether the watch is clear to poll. Both gates must pass; each + /// is inclusive at its threshold. #[must_use = "the readiness verdict gates the poll; `?` alone drops the inner bool"] pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { if let Some(next) = read_u64(self.host, &watch.next_block_key())? @@ -269,11 +243,8 @@ impl<'h, H: LocalStoreHost> Gates<'h, H> { } } -/// Little-endian u64 row read shared by the gate and refusal-marker -/// keys. Absent key: silently `None`. Present but wrong length: the -/// value is corrupt, so warn before falling open to `None` - fail-open -/// is deliberate (a corrupt value can only make the watch act sooner, -/// never wedge it), but it must not pass unobserved. +/// Read a little-endian u64 row. Absent: `None`. Wrong length: warn and +/// fall open to `None`. fn read_u64(host: &H, key: &str) -> Result, Fault> { let Some(b) = host.get(key)? else { return Ok(None); @@ -293,10 +264,9 @@ const RESERVED_TAG: u8 = 0x01; /// COMMITTED value tag: the durable effect landed; nothing is owed. const COMMITTED_TAG: u8 = 0x02; -/// Presence class of a durable-effect marker under the tag-first value -/// scheme. RESERVED and COMMITTED can never collide by presence: the -/// leading tag byte tells them apart, and a corrupt tag falls to -/// [`Reserved`](Mark::Reserved) so a reconcile resubmits, never skips. +/// Presence class of a durable-effect marker. The leading tag byte +/// separates the two; a corrupt tag falls to [`Reserved`](Mark::Reserved) +/// so a reconcile resubmits rather than skips. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Mark { /// A submit is in flight or owed; the effect is not yet durable. @@ -305,9 +275,7 @@ pub enum Mark { Committed, } -/// A live reservation enumerated from the journal: the receipt key -/// (prefix-stripped), the earliest epoch-seconds it may retry at, and -/// the stored body. +/// A live reservation enumerated from the journal. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Reservation { /// Receipt key as passed to [`Journal::reserve`], prefix stripped. @@ -327,11 +295,11 @@ fn reserved_value(next_eligible: u64, body: &[u8]) -> Vec { v } -/// Receipt-keyed idempotency journal under a fixed prefix. The observed +/// Receipt-keyed idempotency journal under a fixed prefix. The presence /// path ([`record`](Self::record) / [`contains`](Self::contains)) stores -/// an empty presence marker, so re-recording is idempotent. The -/// durable-effect path ([`reserve`](Self::reserve) / [`commit`](Self::commit)) -/// tags the value so [`mark`](Self::mark) tells RESERVED from COMMITTED. +/// an empty marker; the durable-effect path ([`reserve`](Self::reserve) / +/// [`commit`](Self::commit)) tags the value so [`mark`](Self::mark) tells +/// RESERVED from COMMITTED. pub struct Journal<'h, H> { host: &'h H, prefix: &'static str, @@ -361,12 +329,9 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { self.host.set(&format!("{}{receipt}", self.prefix), b"") } - /// Whether the receipt is already journalled. - /// - /// Presence-only: reports the key exists, not whether it is RESERVED - /// or COMMITTED. MUST NOT guard a reserve/commit journal; use - /// [`mark`](Self::mark) there, else a corrupt marker is guard-skipped - /// yet never reconciled. + /// Whether the receipt is journalled. Presence-only: MUST NOT guard + /// a reserve/commit journal (use [`mark`](Self::mark)), else a + /// corrupt marker is guard-skipped yet never reconciled. pub fn contains(&self, receipt: &str) -> Result { Ok(self .host @@ -400,10 +365,9 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { self.host.delete(&format!("{}{key}", self.prefix)) } - /// Classify `key`'s marker. Absent: `None`. Legacy empty or a - /// leading `0x02`: [`Committed`](Mark::Committed). Any other first - /// byte (`0x01`, unknown, corrupt): [`Reserved`](Mark::Reserved), so - /// a corrupt tag reconciles rather than skips. + /// Classify `key`'s marker. Absent: `None`. Legacy empty or leading + /// `0x02`: [`Committed`](Mark::Committed). Any other first byte: + /// [`Reserved`](Mark::Reserved), so a corrupt tag reconciles. pub fn mark(&self, key: &str) -> Result, Fault> { let Some(v) = self.host.get(&format!("{}{key}", self.prefix))? else { return Ok(None); @@ -414,12 +378,10 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { })) } - /// Enumerate every live reservation: each key whose value is - /// non-empty and not COMMITTED. Parse is fail-safe and agrees with - /// [`mark`](Self::mark) - a well-formed `0x01` marker yields its - /// `next_eligible` and body, any other non-committed value yields - /// `next_eligible` 0 and the bytes after the tag. Feeds the sweep - /// reconcile and operator enumerate. + /// Enumerate every live reservation (non-empty, non-COMMITTED + /// value). Fail-safe and agrees with [`mark`](Self::mark): a `0x01` + /// marker yields its `next_eligible` and body, any other + /// non-committed value yields `0` and the post-tag bytes. pub fn pending(&self) -> Result, Fault> { let mut out = Vec::new(); for full in self.host.list_keys(self.prefix)? { @@ -447,10 +409,9 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { } } -/// 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. +/// One poll dispatch's world view: chain, block height, block clock. +/// Gate checks and backoff read the instant the source was polled at, +/// so a watch never gates against a clock it was not judged by. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct Tick { /// Chain the dispatch targets. @@ -462,37 +423,27 @@ pub struct Tick { } /// 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 run: `poll` is infallible by contract. +/// outcome. Generic over the host for mock-testability; the source owns +/// its own wire. `poll` is infallible by contract: a transient failure +/// surfaces as a retry-flavoured outcome rather than tearing down the +/// run. pub trait Poller { /// 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. + /// watch value, passed verbatim for the source to 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. + /// Short strategy name for log lines (e.g. `"twap"`). Diagnostic + /// only. fn label(&self) -> &'static str { "poller" } } -/// What the retry ledger should do to a watch after a failed -/// keeper 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). +/// What the retry ledger does to a watch after a failed run. +/// `#[non_exhaustive]`: treat an unknown variant as leave-in-place. #[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -505,8 +456,8 @@ pub enum RetryAction { seconds: u64, }, /// Grant one next-block retry: the first application records the - /// block and gates the watch to the next one; a repeat at a later - /// block removes the watch. + /// block and gates to the next; a repeat at a later block removes + /// the watch. DropOnRepeat, /// Remove the watch and its gates; no retry can succeed. Drop, @@ -556,10 +507,9 @@ impl<'h, H: LocalStoreHost> Retrier<'h, H> { } } - /// Clear the watch's first-refusal marker: an accepted submit ends - /// the refusal episode, so a later [`RetryAction::DropOnRepeat`] - /// refusal earns a fresh one-block grace. No-op when no marker is - /// set. + /// Clear the first-refusal marker, so a later + /// [`RetryAction::DropOnRepeat`] earns a fresh one-block grace. + /// No-op when unset. pub fn clear_refusal(&self, watch: WatchRef<'_>) -> Result<(), Fault> { self.host.delete(&watch.refused_key()) } diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index c131660b..4a862764 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -1,123 +1,29 @@ -//! # nexum-sdk -//! -//! Guest-side SDK for nexum runtime modules. The helpers here are -//! host-neutral and domain-free: any module targeting the runtime can -//! use them regardless of which world it exports. Domain layers such as -//! the CoW SDK depend on this crate and add their own surface on top. -//! -//! Modules keep their own `wit_bindgen::generate!` call, which emits -//! the world-specific `Guest` trait, `Fault` shape, and host import -//! shims into the module's own crate, and pull helpers and canonical -//! primitive types from here. -//! -//! ## What lives here -//! -//! - [`prelude`] - `use nexum_sdk::prelude::*` imports the alloy -//! primitives ([`Address`], [`B256`], [`Bytes`], [`U256`], -//! [`keccak256`]). -//! -//! - [`host`] - host trait seam: [`Host`] bundling all six core -//! interfaces ([`ChainHost`] / [`IdentityHost`] / [`LocalStoreHost`] -//! / [`RemoteStoreHost`] / [`MessagingHost`] / [`LoggingHost`]) plus -//! the host-neutral [`Fault`] vocabulary. Modules that want -//! host-free tests structure their strategy logic against these -//! traits and slot in the `nexum-sdk-test` mocks. See the host -//! module docs for the wit-bindgen adapter pattern. -//! -//! - [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) - -//! generates the per-module `WitBindgenHost` adapter over the -//! 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 -//! ([`Journal`]); plus the [`Poller`] poll seam and the -//! [`Retrier`] dispatching a [`RetryAction`] through the stores. -//! -//! - [`chain`] - typed chain access: alloy [`Chain`], -//! the closed [`ChainMethod`] read surface, and the alloy provider -//! seam ([`HostTransport`], [`provider`], [`block_on`]); plus -//! `eth_call` JSON plumbing ([`eth_call_params`], -//! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader -//! ([`read_latest_answer`]). -//! -//! - [`events`] - chain-log delivery: the native alloy [`Log`] modules -//! handle plus [`ChainLogParts`], the WIT-edge `From` input the bind -//! macro fills to rebuild it from the wire record. -//! -//! - [`config`] - `(key, value)` config-table lookups and decimal -//! scaling ([`get_required`], [`get_optional`], [`scale_decimal`]). -//! -//! - [`address`] - EVM address parsing with typed errors -//! ([`parse_address`], [`parse_address_list`]). -//! -//! - [`http`] - outbound HTTP over wasi:http in the standard `http` -//! crate's request/response vocabulary: a synchronous [`fetch`] -//! helper (guest target only), the [`Fetch`] trait seam for host-free -//! strategy tests, and a [`FetchError`] that distinguishes allowlist -//! denials from transport failures. -//! -//! - [`tracing`] - guest-side `tracing` facade: an events-only -//! subscriber plus panic hook that forward through a [`LogSink`] -//! seam. The bind macro wires the bound host logging call into the -//! sink so module authors emit `tracing::info!(...)` with no host -//! parameter to thread. -//! -//! Helpers take primitive types (`&[u8]`, `Option<&str>`, slices) -//! rather than the per-module `Fault` type, so this library emits no -//! wit-bindgen output of its own; modules unpack their `Fault` on the -//! way in. -//! -//! [`Address`]: alloy_primitives::Address -//! [`B256`]: alloy_primitives::B256 -//! [`Bytes`]: alloy_primitives::Bytes -//! [`U256`]: alloy_primitives::U256 -//! [`keccak256`]: alloy_primitives::keccak256 -//! [`Host`]: host::Host -//! [`ChainHost`]: host::ChainHost -//! [`IdentityHost`]: host::IdentityHost -//! [`LocalStoreHost`]: host::LocalStoreHost -//! [`RemoteStoreHost`]: host::RemoteStoreHost -//! [`MessagingHost`]: host::MessagingHost -//! [`LoggingHost`]: host::LoggingHost -//! [`Fault`]: host::Fault -//! [`WatchSet`]: keeper::WatchSet -//! [`Gates`]: keeper::Gates -//! [`Journal`]: keeper::Journal -//! [`Poller`]: keeper::Poller -//! [`Retrier`]: keeper::Retrier -//! [`RetryAction`]: keeper::RetryAction -//! [`Chain`]: alloy_chains::Chain -//! [`ChainMethod`]: chain::ChainMethod -//! [`HostTransport`]: chain::HostTransport -//! [`provider`]: chain::ProviderHost::provider -//! [`block_on`]: chain::block_on -//! [`eth_call_params`]: chain::eth_call_params -//! [`parse_eth_call_result`]: chain::parse_eth_call_result -//! [`read_latest_answer`]: chain::chainlink::read_latest_answer -//! [`Log`]: events::Log -//! [`ChainLogParts`]: events::ChainLogParts -//! [`get_required`]: config::get_required -//! [`get_optional`]: config::get_optional -//! [`scale_decimal`]: config::scale_decimal -//! [`parse_address`]: address::parse_address -//! [`parse_address_list`]: address::parse_address_list -//! [`fetch`]: http::Fetch::fetch -//! [`Fetch`]: http::Fetch -//! [`FetchError`]: http::FetchError -//! [`LogSink`]: tracing::LogSink +//! Guest-side SDK for nexum runtime modules: host-neutral, domain-free +//! helpers usable by any module regardless of the world it exports. +//! Domain layers such as the CoW SDK build on top. +//! +//! Modules keep their own `wit_bindgen::generate!` call and pull helpers +//! and canonical primitive types from here; this crate takes primitive +//! types (`&[u8]`, slices) rather than the per-module `Fault`, so it +//! emits no wit-bindgen output of its own. +//! +//! Modules: +//! - [`prelude`] - alloy primitive re-exports. +//! - [`host`] - the [`Host`](host::Host) seam over the six core host interfaces, plus the [`Fault`](host::Fault) vocabulary. +//! - [`keeper`] - strategy-keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). +//! - [`chain`] - typed chain access and the alloy provider seam. +//! - [`events`] - chain-log delivery. +//! - [`config`] - config-table lookups and decimal scaling. +//! - [`address`] - EVM address parsing. +//! - [`http`] - outbound HTTP over wasi:http. +//! - [`tracing`] - guest-side `tracing` facade. +//! - [`module`] and [`bind_host_via_wit_bindgen!`](crate::bind_host_via_wit_bindgen) generate the per-cdylib glue. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![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 +/// Generate the per-cdylib module glue from an `impl` block of named /// handlers. See [`nexum_module_macros::module`]. pub use nexum_module_macros::module; @@ -132,10 +38,8 @@ pub mod prelude; pub mod tracing; pub mod wit_bindgen_macro; -/// The level vocabulary for every SDK log path: the host logging trait, -/// the guest tracing facade sink, and the module mocks all speak -/// `tracing_core::Level`. Its `Ord` is filter-oriented (`ERROR` is the -/// least verbose), so compare with that in mind rather than as severity. +/// Shared log-level vocabulary for every SDK log path. `Ord` is +/// filter-oriented (`ERROR` is least verbose), not severity-ordered. pub use tracing_core::Level; #[cfg(test)] diff --git a/crates/nexum-sdk/src/prelude.rs b/crates/nexum-sdk/src/prelude.rs index b72a6b04..d587dda1 100644 --- a/crates/nexum-sdk/src/prelude.rs +++ b/crates/nexum-sdk/src/prelude.rs @@ -1,10 +1,5 @@ -//! Bulk-imports the primitives every module uses on every other line. -//! `use nexum_sdk::prelude::*` covers the alloy address / hash / -//! numeric types the chain helpers consume. -//! -//! The wit-bindgen-generated types (`Guest`, `Fault`, `Event`, ...) -//! are **not** re-exported here because they live in each module's own -//! crate (one `wit_bindgen::generate!` call per cdylib). Domain SDKs -//! ship their own prelude for their protocol surface. +//! Alloy address, hash, and numeric primitives the chain helpers +//! consume. The wit-bindgen-generated types are not re-exported here; +//! they live in each module's own crate. pub use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, hex, keccak256}; diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 1d7f33ee..6388fc6c 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,9 +1,6 @@ -//! 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. +//! Keeper-store acceptance tests against `nexum_sdk_test::MockHost`. +//! Integration-test placement (not `#[cfg(test)]`) keeps the host +//! traits linked externally, as a module would see them. use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; @@ -711,9 +708,7 @@ fn retry_action_labels_are_stable_snake_case() { // ---- 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. +/// The keeper passes stored params and the judged tick verbatim. #[test] fn poller_sees_params_and_tick_verbatim() { struct EchoSource; From edc328ef142bde19f864be9c7331773b2493ed42 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:09:23 +0000 Subject: [PATCH 123/139] docs: tersen rustdoc in test-harness crates (#616) Apply the terse-rustdoc house style to videre-test, videre-status-body, and nexum-sdk-test: one-line item docs, a few-line crate and module headers, wire-format and error contracts preserved but compressed, tutorial and rationale narrative cut. Part of #613. --- crates/nexum-sdk-test/src/lib.rs | 220 +++++++----------------- crates/videre-status-body/src/lib.rs | 80 ++++----- crates/videre-test/src/codec.rs | 51 ++---- crates/videre-test/src/fixture.rs | 18 +- crates/videre-test/src/header.rs | 40 ++--- crates/videre-test/src/lib.rs | 54 +----- crates/videre-test/src/reconcile.rs | 16 +- crates/videre-test/src/reference.rs | 34 ++-- crates/videre-test/src/report.rs | 8 +- crates/videre-test/src/transport.rs | 56 +++--- crates/videre-test/tests/conformance.rs | 10 +- 11 files changed, 182 insertions(+), 405 deletions(-) diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 6d8b31b4..a5aecedf 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -1,61 +1,11 @@ -//! # nexum-sdk-test +//! In-memory [`nexum_sdk::host`] trait implementations plus assertion +//! helpers, so a module can test its strategy logic without wit-bindgen, +//! wasmtime, or a network round-trip. //! -//! In-memory implementations of the [`nexum_sdk::host`] traits -//! plus assertion helpers, so a module can write integration -//! tests for its strategy logic without `wit-bindgen`, `wasmtime`, or -//! a network round-trip. -//! -//! ## Usage -//! -//! Add as a dev-dep on the module crate: -//! -//! ```toml -//! [dev-dependencies] -//! nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } -//! ``` -//! -//! Structure the module's strategy function around the host traits: -//! -//! ```rust,ignore -//! pub fn handle_block( -//! host: &H, -//! chain_id: u64, -//! block_number: u64, -//! ) -> Result<(), nexum_sdk::host::Fault> { -//! // ... -//! let res = host.request(chain_id, "eth_call", "[]")?; -//! host.set("last_block", &block_number.to_le_bytes())?; -//! host.log(nexum_sdk::Level::INFO, "saw block"); -//! Ok(()) -//! } -//! ``` -//! -//! Test against [`MockHost`]: -//! -//! ```rust -//! // Glob-import the host traits so the method shortcuts resolve. -//! use nexum_sdk::host::*; -//! use nexum_sdk_test::MockHost; -//! -//! let host = MockHost::new(); -//! host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); -//! -//! // Call the strategy directly: -//! assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); -//! -//! // Inspect: -//! assert_eq!(host.chain.calls().len(), 1); -//! ``` -//! -//! ## Adapting from wit-bindgen -//! -//! The traits report failures as [`nexum_sdk::host::Fault`] rather than -//! the `Fault` `wit_bindgen::generate!` emits per-module. A module -//! bridges with a trivial converter on its own crate boundary; see the -//! tutorial for the exact shape. -//! -//! Domain test crates compose these mocks with their own scripted -//! venue transports on the `videre:venue/client` seam. +//! [`MockHost`] composes the six per-seam mocks ([`MockChain`], +//! [`MockIdentity`], [`MockLocalStore`], [`MockRemoteStore`], +//! [`MockMessaging`], [`MockLogging`]); [`capture_tracing`] records +//! emitted `tracing` events. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] @@ -78,8 +28,7 @@ use tracing::level_filters::LevelFilter; use tracing::span::{Attributes, Id, Record}; use tracing::{Event, Metadata, Subscriber}; -/// Composed in-memory host. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. +/// Composed in-memory host; each field is the per-seam mock. #[derive(Default)] pub struct MockHost { /// `nexum:host/chain` mock. @@ -97,7 +46,7 @@ pub struct MockHost { } impl MockHost { - /// Fresh empty host. Equivalent to `Default::default`. + /// Fresh empty host. pub fn new() -> Self { Self::default() } @@ -185,8 +134,8 @@ impl LoggingHost for MockHost { // ---------------------------------------------------------------- chain -/// In-memory [`ChainHost`] backed by a `(method, params)` -> response -/// map. Records every call so tests can assert dispatch shape. +/// In-memory [`ChainHost`] over a `(method, params)` response map; +/// records every call. #[derive(Default)] pub struct MockChain { responses: RefCell>>, @@ -205,8 +154,7 @@ pub struct ChainCall { } impl MockChain { - /// Program a response for the `(method, params)` pair. Overwrites - /// any prior entry. + /// Program the response for `(method, params)`; overwrites any prior entry. pub fn respond_to( &self, method: impl Into, @@ -273,11 +221,10 @@ pub enum SignPayload { TypedData(String), } -/// In-memory [`IdentityHost`]. Holds a programmable account roster and -/// one programmed signing outcome; records every signing call. With no -/// outcome programmed, signing fails as [`Fault::Unsupported`], the -/// stub-backend posture; an account outside the roster fails as -/// [`Fault::Denied`] before the programmed outcome applies. +/// In-memory [`IdentityHost`] with a programmable roster and one signing +/// outcome; records every call. Off-roster accounts fail +/// [`Fault::Denied`]; with no outcome programmed signing fails +/// [`Fault::Unsupported`]. #[derive(Default)] pub struct MockIdentity { accounts: RefCell>, @@ -286,8 +233,7 @@ pub struct MockIdentity { } impl MockIdentity { - /// Add an account to the roster [`accounts`](IdentityHost::accounts) - /// reports and signing admits. + /// Add an account to the roster. pub fn add_account(&self, account: Address) { self.accounts.borrow_mut().push(account); } @@ -352,11 +298,9 @@ pub struct PublishRecord { pub payload: Vec, } -/// In-memory [`MessagingHost`]. Seeded messages answer queries, -/// publishes are recorded for assertion, and an optional topic scope -/// mirrors the host's `messaging_topics` grant. Seeded history and -/// published records are deliberately separate stores: a query answers -/// from what the test seeded, never from what the guest published. +/// In-memory [`MessagingHost`]: seeded messages answer queries, publishes +/// are recorded, an optional scope mirrors the `messaging_topics` grant. +/// Queries answer from seeds, never from what the guest published. #[derive(Default)] pub struct MockMessaging { history: RefCell>, @@ -372,7 +316,7 @@ impl MockMessaging { } /// Seed a payload on `content_topic` at `timestamp` (ms since the - /// Unix epoch, UTC), with no sender. + /// Unix epoch, UTC), no sender. pub fn seed_payload( &self, content_topic: impl Into, @@ -387,19 +331,16 @@ impl MockMessaging { }); } - /// Confine the mock to `topics`, playing the component's - /// `messaging_topics` grant with the host's matching: a topic is - /// admitted when it equals a grant entry or descends from one read - /// as a `/`-bounded path prefix; anything else fails as - /// [`Fault::Denied`]. An empty grant is unscoped, the host's module - /// default, as is an untouched mock. + /// Confine the mock to `topics`, mirroring the `messaging_topics` + /// grant: a topic is admitted if it equals an entry or descends from + /// one as a `/`-bounded prefix, else [`Fault::Denied`]. An empty + /// grant is unscoped. 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. + /// `prefix`; first registered match fires. pub fn fail_on(&self, prefix: impl Into, fault: Fault) { self.faults.borrow_mut().push((prefix.into(), fault)); } @@ -436,10 +377,8 @@ impl MockMessaging { } } -/// The host's `messaging_topics` matching: an empty scope admits every -/// topic; otherwise a topic is admitted when it equals a scope entry or -/// descends from one read as a path prefix bounded at `/`, so a grant -/// never leaks into a longer sibling segment. +/// Grant matching: empty scope admits all; else a topic must equal an +/// entry or descend from one as a `/`-bounded prefix. fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; @@ -465,10 +404,8 @@ impl MessagingHost for MockMessaging { 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. + /// Exact-topic seeds within the inclusive `start_time..=end_time` + /// window, in seed order; `limit` keeps the newest, the tail. fn query( &self, content_topic: &str, @@ -500,13 +437,9 @@ impl MessagingHost for MockMessaging { // ---------------------------------------------------------------- remote-store -/// In-memory [`RemoteStoreHost`]: content-addressed blobs plus mutable -/// `(owner, topic)` feeds. The mock addresses blobs by `keccak256` of -/// their content, so uploads are deterministic for assertion; the real -/// store's addressing scheme is the host's concern. Feed writes land -/// under the mock's own owner ([`set_owner`](Self::set_owner), -/// zero-address by default), mirroring the host signing feed updates -/// with its configured identity. +/// In-memory [`RemoteStoreHost`]: `keccak256`-addressed blobs plus +/// mutable `(owner, topic)` feeds. Feed writes land under the mock's own +/// owner ([`set_owner`](Self::set_owner), zero by default). #[derive(Default)] pub struct MockRemoteStore { blobs: RefCell>>, @@ -521,8 +454,7 @@ impl MockRemoteStore { self.owner.set(owner); } - /// Seed a blob without going through the trait; returns its - /// reference. + /// Seed a blob directly; returns its reference. pub fn seed_blob(&self, data: impl Into>) -> B256 { let data = data.into(); let reference = keccak256(&data); @@ -530,8 +462,7 @@ impl MockRemoteStore { reference } - /// Seed another owner's feed for [`read_feed`](RemoteStoreHost::read_feed) - /// tests. + /// Seed another owner's feed. pub fn seed_feed(&self, owner: Address, topic: B256, data: impl Into>) { self.feeds.borrow_mut().insert((owner, topic), data.into()); } @@ -586,26 +517,12 @@ impl RemoteStoreHost for MockRemoteStore { // ---------------------------------------------------------------- local-store -/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: -/// namespaced views over one shared row map, plus store-wide entry -/// and byte limits. -/// -/// 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. -/// -/// # Fidelity vs the real `redb` store -/// -/// Two gaps vs `redb`: -/// - **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. +/// In-memory [`LocalStoreHost`]: namespaced views over one shared row +/// map, with store-wide entry and byte limits. +/// [`namespaced`](Self::namespaced) derives a sibling view over the same +/// rows; identical keys in different namespaces never collide, and limits +/// are shared across namespaces. Every `set` commits immediately, with no +/// transaction rollback on trap. #[derive(Default)] pub struct MockLocalStore { shared: Rc, @@ -629,14 +546,12 @@ struct SharedRows { } impl MockLocalStore { - /// 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. + /// A view over the same rows under `namespace`; same-namespace views + /// alias, different namespaces isolate identical keys. /// /// # Panics /// - /// On an empty namespace - the runtime rejects those too. + /// On an empty namespace. pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { let namespace = namespace.into(); assert!( @@ -665,8 +580,7 @@ impl MockLocalStore { self.len() == 0 } - /// Direct read of this view's namespace for assertions - bypasses - /// the trait. + /// Direct read of this view's namespace, for assertions. pub fn snapshot(&self) -> HashMap> { self.shared .rows @@ -677,22 +591,20 @@ impl MockLocalStore { .collect() } - /// Cap the row count across every namespace. Once reached, `set` - /// on a new key fails; overwriting an existing key still succeeds. + /// Cap row count across all namespaces; `set` on a new key then + /// fails, overwrites still succeed. pub fn set_max_entries(&self, limit: usize) { 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. + /// Cap total stored bytes (key + value, all namespaces); an over-cap + /// `set` fails, deletes and overwrites release displaced bytes. 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 - /// `prefix`. Multiple patterns can be registered; the first - /// matching one fires. + /// Inject a fault for any operation whose key starts with `prefix`; + /// first registered match fires. pub fn fail_on(&self, prefix: impl Into, fault: Fault) { self.error_patterns .borrow_mut() @@ -956,9 +868,7 @@ impl CapturedEvents { type Buffer = Arc>>; std::thread_local! { - /// The capture buffer active on this thread, if any. `capture_tracing` - /// installs one for the duration of `f` and restores the prior slot on - /// return or unwind. + /// The capture buffer active on this thread, if any. static ACTIVE_CAPTURE: RefCell> = const { RefCell::new(None) }; } @@ -972,9 +882,8 @@ impl Drop for CaptureGuard { } } -/// Events-only subscriber that records each event as a typed -/// [`CapturedEvent`] into the buffer active on the emitting thread, -/// dropping events when none is set. Spans are inert. +/// Events-only subscriber recording each event into the thread's active +/// buffer; spans are inert. struct CaptureSubscriber { next_id: AtomicU64, } @@ -1019,9 +928,7 @@ impl Subscriber for CaptureSubscriber { fn exit(&self, _span: &Id) {} } -/// Splits an event into its `message` field and a name-keyed map of the -/// rest, mirroring the facade's dispatch so captured values match the -/// rendered line field-for-field. +/// Splits an event into its `message` field and a name-keyed map of the rest. #[derive(Default)] struct FieldVisitor { message: String, @@ -1069,20 +976,9 @@ impl Visit for FieldVisitor { static INSTALL_ROUTING: std::sync::Once = std::sync::Once::new(); -/// Run `f`, returning its value and every `tracing` event it emitted. -/// -/// Capture routes through a single process-global default subscriber -/// installed on first use, keyed to the emitting thread by a thread-local -/// buffer. A process-global default is required rather than a -/// `with_default` scoped one: `tracing` caches each callsite's `Interest` -/// the first time the callsite is hit, computed against whichever -/// dispatcher is current on that thread at that instant. Under parallel -/// tests a callsite exercised outside any capture (e.g. a sibling test -/// calling the same strategy function directly) registers against the -/// no-op default and is cached `never` for the rest of the process, -/// silently starving every later scoped capture of that event. Installing -/// the capture subscriber as the global default makes the cached interest -/// stable and capture independent of test scheduling. +/// Run `f`, returning its value and every `tracing` event it emitted on +/// the calling thread. Capture is thread-scoped; events emitted outside +/// any `capture_tracing` call are dropped. pub fn capture_tracing(f: impl FnOnce() -> R) -> (R, CapturedEvents) { INSTALL_ROUTING.call_once(|| { let _ = tracing::subscriber::set_global_default(CaptureSubscriber { diff --git a/crates/videre-status-body/src/lib.rs b/crates/videre-status-body/src/lib.rs index 327c285c..da621ce3 100644 --- a/crates/videre-status-body/src/lib.rs +++ b/crates/videre-status-body/src/lib.rs @@ -1,19 +1,15 @@ -//! The versioned opaque status-body codec. +//! Versioned opaque status-body codec. //! //! The host `event` stream carries an intent-status transition as opaque //! bytes: a leading `u8` version tag, then that version's borsh payload. -//! The emitter encodes, the keeper decodes, the host never inspects the -//! bytes. An unknown tag fails closed and a body is never empty. +//! An unknown tag fails closed; a body is never empty. //! -//! v1 wire form: `0x01`, the [`IntentStatus`] discriminant, then the -//! borsh `option` encodings of `proof` and `reason`. +//! v1 body: `0x01`, the [`IntentStatus`] discriminant, then borsh `option` +//! encodings of `proof` and `reason`. //! -//! The [`IntentStatusUpdate`] envelope that carries such a body is tagged -//! the same way and fails closed the same way, on its own version line: -//! the envelope tag describes the `venue`/`receipt`/`status` framing, the -//! body tag describes the status payload, and the two move independently. -//! v1 envelope wire form: `0x01`, then the borsh `{venue, receipt, -//! status}` payload. +//! The [`IntentStatusUpdate`] envelope is tagged and fails closed on its +//! own version line, independent of the body it carries. v1 envelope: +//! `0x01`, then borsh `{venue, receipt, status}`. #![warn(missing_docs)] @@ -22,20 +18,17 @@ use borsh::{BorshDeserialize, BorshSerialize}; /// Wire tag of the v1 payload. pub const VERSION_V1: u8 = 1; -/// Wire tag of the v1 [`IntentStatusUpdate`] envelope. Independent of -/// [`VERSION_V1`]: the framing versions apart from the body it carries. +/// Wire tag of the v1 [`IntentStatusUpdate`] envelope; independent of +/// [`VERSION_V1`]. pub const ENVELOPE_VERSION_V1: u8 = 1; -/// The extension event kind an intent-status transition rides on: the -/// `custom-event.kind` the venue platform stamps and a subscribing -/// module matches. Shared by the emit side and the decode side so the -/// one string is never spelt twice. +/// The `custom-event.kind` an intent-status transition rides on, matched +/// by subscribing modules. pub const INTENT_STATUS_KIND: &str = "intent-status"; -/// The intent-status transition an intent-status `custom` event carries -/// in its opaque payload: [`ENVELOPE_VERSION_V1`], then borsh -/// `{venue, receipt, status}`, where `status` is a [`StatusBody`]-encoded -/// body (the inner codec above). +/// Envelope an intent-status `custom` event carries: +/// [`ENVELOPE_VERSION_V1`], then borsh `{venue, receipt, status}`, where +/// `status` is a [`StatusBody`]-encoded body. #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub struct IntentStatusUpdate { /// Venue id the receipt was issued by. @@ -56,9 +49,8 @@ impl IntentStatusUpdate { Ok(out) } - /// Decode, failing typedly on an empty envelope, an unknown version - /// tag (fail-closed), or a payload that does not parse as the tagged - /// version (including trailing bytes). + /// Decode; fails on empty, an unknown tag (fail-closed), or a payload + /// that does not parse as the tagged version. pub fn decode(bytes: &[u8]) -> Result { match bytes { [] => Err(EnvelopeError::Empty), @@ -80,8 +72,7 @@ pub enum EnvelopeError { /// No bytes at all: not even a version tag. #[error("empty intent-status envelope: missing the version tag")] Empty, - /// The version tag names no published envelope version. Fail-closed: - /// a skewed peer's framing is refused, never guessed at. + /// The tag names no published envelope version (fail-closed). #[error("unknown intent-status envelope version {version}")] UnknownVersion { /// The unrecognised wire tag. @@ -98,8 +89,8 @@ pub enum EnvelopeError { }, } -/// Where an intent is in its life at the venue. The borsh discriminant -/// is the wire form: append new states, never reorder. +/// Where an intent is in its life at the venue; the borsh discriminant is +/// wire form, so append new states, never reorder. #[derive(BorshDeserialize, BorshSerialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum IntentStatus { /// Accepted for processing but not yet live at the venue. @@ -123,12 +114,8 @@ pub struct FailReason { pub detail: String, } -/// One decoded status body. -/// -/// `proof` is display-grade venue bytes (for an EVM venue, typically -/// the settlement transaction hash). There is no `failed` status: a -/// venue-reported terminal failure reads as a non-[`Fulfilled`] -/// terminal `status` plus a `reason`. +/// One decoded status body. There is no `failed` status: a terminal +/// failure reads as a non-[`Fulfilled`] terminal `status` plus a `reason`. /// /// [`Fulfilled`]: IntentStatus::Fulfilled #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] @@ -142,8 +129,7 @@ pub struct StatusBody { } impl StatusBody { - /// Encode as the version tag plus the borsh payload. Never empty: - /// at minimum the tag and the status discriminant. + /// Encode as the version tag plus the borsh payload; never empty. pub fn encode(&self) -> Result, EncodeError> { let mut out = vec![VERSION_V1]; borsh::to_writer(&mut out, self).map_err(|err| EncodeError { @@ -152,9 +138,8 @@ impl StatusBody { Ok(out) } - /// Decode, failing typedly on an empty body, an unknown version - /// tag (fail-closed), or a payload that does not parse as the - /// tagged version (including trailing bytes). + /// Decode; fails on empty, an unknown tag (fail-closed), or a payload + /// that does not parse as the tagged version. pub fn decode(bytes: &[u8]) -> Result { match bytes { [] => Err(DecodeError::Empty), @@ -185,8 +170,7 @@ pub enum DecodeError { /// No bytes at all: not even a version tag. #[error("empty status body: missing the version tag")] Empty, - /// The version tag names no published version. Fail-closed: a - /// keeper never guesses at a future layout. + /// The tag names no published version (fail-closed). #[error("unknown status-body version {version}")] UnknownVersion { /// The unrecognised wire tag. @@ -229,8 +213,7 @@ mod tests { assert_eq!(encoded[0], ENVELOPE_VERSION_V1); } - /// Pins the whole v1 envelope framing, not just the tag's position: - /// a borsh layout drift is a wire break, so it must fail here. + /// Pins the whole v1 envelope framing, not just the tag position. #[test] fn golden_envelope() { let encoded = envelope("cow").encode().expect("encode"); @@ -258,8 +241,7 @@ mod tests { assert_eq!(IntentStatusUpdate::decode(&[]), Err(EnvelopeError::Empty)); } - /// A peer that framed the envelope to a version this build does not - /// publish is refused, not misparsed into plausible-looking fields. + /// A future envelope version is refused, not misparsed. #[test] fn future_envelope_version_fails_closed() { let mut skewed = envelope("cow").encode().expect("encode"); @@ -272,8 +254,7 @@ mod tests { ); } - /// The pre-tag framing (bare borsh, no leading tag) is skew too: it - /// must never decode, whatever the leading length byte happens to be. + /// Bare borsh with no leading tag must never decode. #[test] fn untagged_envelope_never_decodes() { for venue in ["c", "cow", "a longer venue id"] { @@ -311,9 +292,8 @@ mod tests { )); } - /// The envelope tag and the body tag are separate wire lines: the - /// envelope decodes even when the body it carries is a version this - /// build refuses. + /// Envelope and body tags are separate wire lines: the envelope + /// decodes even when its body version is refused. #[test] fn envelope_and_body_versions_are_independent() { let mut update = envelope("cow"); diff --git a/crates/videre-test/src/codec.rs b/crates/videre-test/src/codec.rs index 538db948..ab13ce6c 100644 --- a/crates/videre-test/src/codec.rs +++ b/crates/videre-test/src/codec.rs @@ -1,15 +1,11 @@ -//! Codec conformance vectors: the file format that publishes a venue's -//! `IntentBody` wire bytes, and the check that holds a codec to them. +//! Codec conformance vectors: the JSON file format publishing a venue's +//! `IntentBody` wire bytes, and the check holding a codec to them. //! -//! A vector file is the venue's codec contract in portable form: JSON, -//! a leading format version (unknown versions fail closed), bytes as -//! lowercase hex, one entry per published body, never zero. A non-Rust -//! adapter author proves byte-exactness by decoding and re-encoding -//! each `round-trip` vector in their own language and comparing bytes; -//! a Rust author runs [`CodecVectors::assert_conforms`] against the -//! 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. +//! A vector file carries a leading format version (unknown versions fail +//! closed), bytes as lowercase hex, one entry per body, never zero. +//! [`CodecVectors::assert_conforms`] checks a derived enum against them. +//! Failure vectors pin the typed error contract: empty, unknown-version, +//! and malformed bodies must fail as [`BodyError`] names, not decode. use std::path::Path; @@ -25,11 +21,9 @@ use crate::report::{ConformanceReport, Violation, settle}; pub struct CodecVectors { /// File-format discriminator; an unknown version fails to parse. pub version: FormatVersion, - /// Name of the body schema the vectors bind, e.g. - /// `acme-dex/order-body`. Informational: the check never reads it. + /// Body schema the vectors bind; informational, the check never reads it. pub schema: String, - /// The vectors, in publication order. Never empty in a parsed - /// file: an empty set would conform vacuously. + /// The vectors, in publication order; never empty in a parsed file. #[serde(deserialize_with = "fixture::non_empty")] pub vectors: Vec, } @@ -50,9 +44,8 @@ pub struct CodecVector { 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. +/// The outcome a vector demands of a codec; failure cases mirror +/// [`BodyError`] minus its free-text detail. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Expectation { @@ -85,8 +78,7 @@ impl std::fmt::Display for Expectation { } impl CodecVectors { - /// An empty vector set for `schema`. Push at least one vector - /// before publishing: a parsed file is never empty. + /// An empty vector set for `schema`; push at least one before publishing. pub fn new(schema: impl Into) -> Self { Self { version: FormatVersion, @@ -95,9 +87,8 @@ impl CodecVectors { } } - /// 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). + /// Append a round-trip vector encoding `body`; returns it so the + /// caller can attach [`notes`](CodecVector::notes). pub fn push_round_trip( &mut self, name: impl Into, @@ -118,9 +109,7 @@ impl CodecVectors { /// /// # 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. + /// On [`Expectation::RoundTrip`]; use [`push_round_trip`](Self::push_round_trip). pub fn push_failure( &mut self, name: impl Into, @@ -160,13 +149,12 @@ impl CodecVectors { fixture::write(path.as_ref(), self) } - /// Check a codec against every vector, collecting all violations - /// rather than stopping at the first. + /// Check a codec against every vector, collecting all violations. /// /// 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). An - /// empty set is itself a violation: it would conform vacuously. + /// [`BodyError`] case (detail not compared). An empty set is itself a + /// violation. pub fn check(&self) -> Result<(), ConformanceReport> { let mut violations = Vec::new(); if self.vectors.is_empty() { @@ -186,8 +174,7 @@ impl CodecVectors { settle(violations) } - /// [`check`](Self::check), panicking with the full report on any - /// violation. The assertion form for adapter test suites. + /// [`check`](Self::check), panicking with the full report on any violation. pub fn assert_conforms(&self) { if let Err(report) = self.check::() { panic!("codec does not conform to {}:\n{report}", self.schema); diff --git a/crates/videre-test/src/fixture.rs b/crates/videre-test/src/fixture.rs index 6b2b5118..06f295f1 100644 --- a/crates/videre-test/src/fixture.rs +++ b/crates/videre-test/src/fixture.rs @@ -11,9 +11,8 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// The one published fixture file-format version. const FORMAT_VERSION: u32 = 1; -/// Fixture file-format discriminator: serializes as the current -/// version, refuses any other on parse (fail-closed), so a reader -/// never guesses at a future layout. +/// Fixture file-format discriminator: serializes as the current version, +/// refuses any other on parse (fail-closed). #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct FormatVersion; @@ -36,8 +35,7 @@ impl<'de> Deserialize<'de> for FormatVersion { } } -/// Deserialize a fixture's entry list, refusing an empty one: an empty -/// set would conform vacuously. +/// Deserialize a fixture's entry list, refusing an empty one. pub(crate) fn non_empty<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -50,9 +48,7 @@ where Ok(entries) } -/// Why a fixture file failed to load or save. The JSON case carries -/// serde's rendered detail rather than the error value so the type -/// stays independent of `serde_json`'s feature set. +/// Why a fixture file failed to load or save. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum FixtureError { @@ -77,8 +73,7 @@ pub enum FixtureError { Format(String), } -/// Render a fixture as its canonical published form: pretty-printed -/// JSON with a trailing newline, so a regenerated file diffs cleanly. +/// Render a fixture as canonical published form: pretty JSON, trailing newline. 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'); @@ -107,8 +102,7 @@ pub(crate) fn write(path: &Path, value: &T) -> Result<(), FixtureE }) } -/// Serde codec for byte fields: lowercase hex, no prefix, so the file -/// is legible without a borsh decoder. +/// Serde codec for byte fields: lowercase hex, no prefix. pub(crate) mod hex_bytes { use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serializer}; diff --git a/crates/videre-test/src/header.rs b/crates/videre-test/src/header.rs index 91063dc4..0b3dc352 100644 --- a/crates/videre-test/src/header.rs +++ b/crates/videre-test/src/header.rs @@ -1,14 +1,11 @@ -//! 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. +//! Header-derivation goldens: the JSON file format publishing what +//! `derive-header` must project from each body, and the check holding 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, a leading format version that fails closed on an unknown tag, -//! kebab-case case names matching the WIT, bytes as lowercase hex, -//! never zero goldens). The mirrors exist because wit-bindgen types -//! carry no serde; [`GoldenHeader`] converts from the venue SDK's -//! `IntentHeader`, which macro-built adapters speak too, so an +//! A golden file pairs wire bodies with the derived header, in the mirror +//! types below (leading format version fails closed, kebab-case case names +//! matching the WIT, bytes as lowercase hex, never zero goldens). +//! [`GoldenHeader`] converts from the SDK's `IntentHeader`, so an //! adapter's `derive_header` feeds the check directly. use std::fmt; @@ -27,11 +24,9 @@ use crate::report::{ConformanceReport, Violation, settle}; pub struct HeaderGoldens { /// File-format discriminator; an unknown version fails to parse. pub version: FormatVersion, - /// The venue the goldens bind. Informational: the check never - /// reads it. + /// The venue the goldens bind; informational, the check never reads it. pub venue: String, - /// The goldens, in publication order. Never empty in a parsed - /// file: an empty set would conform vacuously. + /// The goldens, in publication order; never empty in a parsed file. #[serde(deserialize_with = "fixture::non_empty")] pub goldens: Vec, } @@ -161,8 +156,7 @@ impl From for GoldenAuthScheme { } impl HeaderGoldens { - /// An empty golden set for `venue`. Record at least one golden - /// before publishing: a parsed file is never empty. + /// An empty golden set for `venue`; record at least one before publishing. pub fn new(venue: impl Into) -> Self { Self { version: FormatVersion, @@ -171,8 +165,7 @@ impl HeaderGoldens { } } - /// Append a golden by running the publishing adapter's own - /// `derive-header` on `body`. Returns the pushed golden so the + /// Append a golden by running `derive` on `body`; returns it so the /// caller can attach [`notes`](HeaderGolden::notes). pub fn record( &mut self, @@ -213,12 +206,10 @@ impl HeaderGoldens { fixture::write(path.as_ref(), self) } - /// Check an adapter's `derive-header` against every golden, - /// collecting all violations rather than stopping at the first. + /// Check `derive` against every golden, collecting all violations. /// - /// `derive` is the adapter's derivation; a trait-based adapter - /// passes `MyAdapter::derive_header` directly. An empty set is - /// itself a violation: it would conform vacuously. + /// A trait-based adapter passes `MyAdapter::derive_header` directly. + /// An empty set is itself a violation. pub fn check( &self, mut derive: impl FnMut(Vec) -> Result, @@ -257,8 +248,7 @@ impl HeaderGoldens { settle(violations) } - /// [`check`](Self::check), panicking with the full report on any - /// violation. The assertion form for adapter test suites. + /// [`check`](Self::check), panicking with the full report on any violation. pub fn assert_conforms(&self, derive: impl FnMut(Vec) -> Result) where H: Into, diff --git a/crates/videre-test/src/lib.rs b/crates/videre-test/src/lib.rs index dd907bd6..e9448c37 100644 --- a/crates/videre-test/src/lib.rs +++ b/crates/videre-test/src/lib.rs @@ -1,34 +1,14 @@ -//! # videre-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. +//! header-derivation goldens, and an in-memory transport mock. //! -//! ## Usage -//! -//! Add as a dev-dep on the adapter crate: -//! -//! ```toml -//! [dev-dependencies] -//! videre-test = { path = "../../crates/videre-test" } -//! ``` -//! -//! Hold the adapter to its published fixtures: +//! - [`CodecVectors`]: the venue's `IntentBody` wire bytes as JSON +//! (lowercase hex); [`CodecVectors::assert_conforms`] checks a derived +//! enum against them. +//! - [`HeaderGoldens`]: published bodies paired with the header a +//! conforming `derive-header` projects, in the [`GoldenHeader`] mirror +//! types. +//! - [`MockTransport`]: the chain, messaging, and outbound-HTTP +//! transports as programmable in-memory mocks behind the SDK seams. //! //! ```rust //! use videre_test::reference::{ @@ -36,27 +16,11 @@ //! }; //! use videre_test::{CodecVectors, HeaderGoldens}; //! -//! // In a real adapter test these load the venue's own published -//! // files; the kit's reference venue stands in here. //! 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 -//! -//! `#[videre_sdk::venue]` adapters speak the SDK's own types (the -//! macro remaps the type interfaces onto `videre_sdk::bindings`), so -//! both checks apply directly: pass `MyAdapter::derive_header` to -//! [`HeaderGoldens::assert_conforms`] and the derived enum to -//! [`CodecVectors::assert_conforms`]. No bridge types. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/videre-test/src/reconcile.rs b/crates/videre-test/src/reconcile.rs index 20d49e8a..dd0a7dfc 100644 --- a/crates/videre-test/src/reconcile.rs +++ b/crates/videre-test/src/reconcile.rs @@ -7,10 +7,9 @@ use videre_sdk::{IntentStatus, SubmitOutcome, VenueFault}; use crate::MockFetch; -/// The per-adapter fixtures the compliance suite drives. Implement it on -/// a unit type in the adapter's tests: the `program_*` hooks arm the -/// adapter's own [`MockFetch`], and `submit`/`status` run its paths, -/// lifting the adapter error into [`VenueFault`]. +/// The per-adapter fixtures the compliance suite drives: `program_*` hooks +/// arm a [`MockFetch`], `submit`/`status` run the adapter's paths lifting +/// its error into [`VenueFault`]. pub trait ReconcileFixture { /// A signed body the venue accepts and derives a receipt for. fn signed_body() -> Vec; @@ -30,8 +29,7 @@ pub trait ReconcileFixture { fn status(fetch: &MockFetch, receipt: &[u8]) -> Result; } -/// A fresh submit and a re-POST of a held body resolve identically: -/// mandatory re-POST idempotency (contract point 1). +/// A fresh submit and a re-POST of a held body resolve identically (re-POST idempotency). pub fn assert_re_post_idempotent(body: &[u8]) { let fresh = MockFetch::default(); F::program_accept(&fresh); @@ -47,8 +45,7 @@ pub fn assert_re_post_idempotent(body: &[u8]) { ); } -/// A held body never surfaces as a terminal fault, across both auth -/// paths (contract point 1's floor). +/// A held body never surfaces as a terminal fault, across both auth paths. pub fn assert_held_never_faults() { for body in [F::signed_body(), F::presign_body()] { let held = MockFetch::default(); @@ -60,8 +57,7 @@ pub fn assert_held_never_faults() { } } -/// An absent status read stays retryable (`unavailable`), never terminal, -/// so a lagging read path does not strand a reconcile. +/// An absent status read stays retryable (`unavailable`), never terminal. pub fn assert_status_absent_is_retryable() { let fetch = MockFetch::default(); F::program_absent(&fetch); diff --git a/crates/videre-test/src/reference.rs b/crates/videre-test/src/reference.rs index 020cd6f9..20ccb8f0 100644 --- a/crates/videre-test/src/reference.rs +++ b/crates/videre-test/src/reference.rs @@ -1,14 +1,10 @@ -//! The kit's reference venue: a published body schema, its codec -//! vector file, and its header golden file. +//! 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. +//! The payloads exercise every borsh primitive a body schema is likely to +//! carry, so a non-Rust author can prove their implementation byte-exact +//! against [`CODEC_VECTORS_JSON`]. The published files are pinned by this +//! crate's tests: regeneration must reproduce them byte for byte. use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::value_flow::{Asset, AssetAmount, Erc20}; @@ -48,8 +44,7 @@ pub struct ReferenceV2 { pub urgent: bool, } -/// The reference venue's outer version enum. Tag order is the schema: -/// versions append, never reorder. +/// The outer version enum; tag order is the schema, so append, never reorder. #[derive(IntentBody, Clone, Debug, Eq, PartialEq)] pub enum ReferenceBody { /// Version 1, wire tag 0. @@ -58,11 +53,9 @@ pub enum ReferenceBody { V2(ReferenceV2), } -/// The reference venue's pure header derivation, the subject the -/// published goldens pin. Gives the amount as the chain's native token, -/// wants (for v2) the same amount as an ERC-20 at the recipient token -/// address, and authorises via EIP-712. V1 wants nothing, spelled as a -/// zero native amount. +/// The reference header derivation the goldens pin: gives the amount as +/// native token, wants (v2) the same as an ERC-20 at the recipient +/// address, authorises EIP-712. V1 wants nothing, a zero native amount. pub fn derive_reference_header(body: Vec) -> Result { let (amount_wei, wants) = match ReferenceBody::from_bytes(&body)? { ReferenceBody::V1(quote) => ( @@ -93,8 +86,7 @@ pub fn derive_reference_header(body: Vec) -> Result Vec { let bytes = value.to_be_bytes(); let first = bytes.iter().position(|byte| *byte != 0); @@ -260,9 +252,7 @@ mod tests { /// Rewrite the published files from the reference schema. Run with /// `cargo test -p videre-test -- --ignored regenerate` after a - /// deliberate schema change, then commit the diff; the tests above - /// compare against the compiled-in copy, so they go green on the - /// next build. + /// deliberate schema change, then commit the diff. #[test] #[ignore = "writes the published fixture files in place"] fn regenerate_reference_fixtures() { diff --git a/crates/videre-test/src/report.rs b/crates/videre-test/src/report.rs index 97f56b97..c40ea198 100644 --- a/crates/videre-test/src/report.rs +++ b/crates/videre-test/src/report.rs @@ -4,8 +4,7 @@ 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. +/// One vector or golden the subject failed, with the detail to fix it. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Violation { /// The `name` of the failing vector or golden. @@ -20,9 +19,8 @@ impl fmt::Display for Violation { } } -/// 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. +/// Every violation a conformance check found, one per failing vector; +/// checks never stop at the first. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ConformanceReport { /// The violations, in vector order. diff --git a/crates/videre-test/src/transport.rs b/crates/videre-test/src/transport.rs index a7295c97..506ed073 100644 --- a/crates/videre-test/src/transport.rs +++ b/crates/videre-test/src/transport.rs @@ -1,15 +1,11 @@ -//! In-memory mocks for the three transports a venue adapter is -//! granted: chain RPC, messaging, and outbound HTTP. +//! 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. Grant play mirrors the host's: [`MockMessaging::scope_topics`] -//! plays the adapter's `messaging_topics` grant with the host's -//! `/`-bounded prefix matching, and [`MockFetch::scope_hosts`] plays the -//! `[capabilities.http].allow` list with the host's exact-or-`*.suffix` -//! matching; both refuse off-grant calls as a typed `denied`, exactly as -//! the host would. +//! [`MockTransport`] composes them behind the SDK seams ([`ChainHost`], +//! [`MessagingHost`], [`Fetch`]). [`MockMessaging::scope_topics`] plays +//! the `messaging_topics` grant and [`MockFetch::scope_hosts`] the +//! `[capabilities.http].allow` list; both refuse off-grant calls as a +//! typed `denied`, as the host would. use std::cell::RefCell; use std::collections::HashMap; @@ -19,8 +15,7 @@ use nexum_sdk::http::{Fetch, FetchError, FetchOptions}; pub use nexum_sdk_test::{ChainCall, MockChain, MockMessaging, PublishRecord}; pub use videre_sdk::transport::{Message, MessagingHost}; -/// Composed in-memory transport. Each field exposes the per-seam mock -/// so tests can program responses and assert on calls. +/// Composed in-memory transport; each field is the per-seam mock. #[derive(Default)] pub struct MockTransport { /// `nexum:host/chain` mock. @@ -32,7 +27,7 @@ pub struct MockTransport { } impl MockTransport { - /// Fresh empty transport. Equivalent to `Default::default`. + /// Fresh empty transport. pub fn new() -> Self { Self::default() } @@ -86,19 +81,16 @@ pub struct RecordedRequest { pub options: FetchOptions, } -/// A programmed response, rebuilt into an `http::Response` per call -/// because the standard response type is not `Clone`. +/// A programmed response, rebuilt per call since `http::Response` 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 -/// optional host scope plays the adapter's `[capabilities.http].allow` -/// grant ([`scope_hosts`](Self::scope_hosts)); one-off refusals can -/// still be programmed via [`fail_with`](Self::fail_with). +/// In-memory [`Fetch`] over a `(method, uri)` response map; records every +/// request. An optional host scope plays the `[capabilities.http].allow` +/// grant ([`scope_hosts`](Self::scope_hosts)). #[derive(Default)] pub struct MockFetch { responses: RefCell>>, @@ -107,19 +99,15 @@ pub struct MockFetch { } impl MockFetch { - /// Confine the mock to `hosts`, playing the adapter's - /// `[capabilities.http].allow` grant with the host's matching: - /// case-insensitive, an entry is an exact hostname or a `*.suffix` - /// wildcard, and an off-grant request fails as - /// [`FetchError::Denied`]. An empty grant denies every host, the - /// host's posture for an absent allow list; an untouched mock is - /// unscoped. + /// Confine the mock to `hosts`, mirroring the `[capabilities.http].allow` + /// grant: case-insensitive, an entry is an exact hostname or `*.suffix` + /// wildcard, off-grant fails [`FetchError::Denied`]. An empty grant + /// denies every host. pub fn scope_hosts(&self, hosts: impl IntoIterator>) { *self.scope.borrow_mut() = Some(hosts.into_iter().map(Into::into).collect()); } - /// Program a response for the `(method, uri)` pair. Overwrites any - /// prior entry. + /// Program the response for `(method, uri)`; overwrites any prior entry. /// /// # Panics /// @@ -142,8 +130,7 @@ impl MockFetch { ); } - /// Program a failure for the `(method, uri)` pair. Overwrites any - /// prior entry. + /// Program a failure for `(method, uri)`; overwrites any prior entry. pub fn fail_with(&self, method: http::Method, uri: impl Into, error: FetchError) { self.responses .borrow_mut() @@ -201,9 +188,8 @@ impl Fetch for MockFetch { } } -/// The host's `[capabilities.http].allow` matching: host-only and -/// case-insensitive, an entry admits its exact hostname or, as -/// `*.suffix`, any strict subdomain of the suffix. +/// Grant matching: case-insensitive, an entry admits its exact hostname +/// or, as `*.suffix`, any strict subdomain. fn host_allowed(host: &str, allowlist: &[String]) -> bool { let host = host.to_ascii_lowercase(); allowlist.iter().any(|pat| { diff --git a/crates/videre-test/tests/conformance.rs b/crates/videre-test/tests/conformance.rs index da0bc538..c2bac25a 100644 --- a/crates/videre-test/tests/conformance.rs +++ b/crates/videre-test/tests/conformance.rs @@ -1,7 +1,5 @@ -//! Acceptance surface for the conformance kit: an adapter written -//! against `videre-sdk` is held to the published vector and -//! golden files, and a deliberately divergent adapter is caught by -//! them. +//! Acceptance surface for the conformance kit: a reference adapter is held +//! to the published vector and golden files, and a divergent one is caught. use videre_sdk::value_flow::{Asset, AssetAmount}; use videre_sdk::{ @@ -13,9 +11,7 @@ use videre_test::reference::{ }; use videre_test::{CodecVectors, HeaderGoldens, MessagingHost, MockTransport}; -/// An adapter under test: the reference venue implemented through the -/// SDK trait, transport injected through the seams so the kit's mocks -/// drive it. +/// The reference venue implemented through the SDK trait, driven by the kit's mocks. struct ReferenceAdapter; impl VenueAdapter for ReferenceAdapter { From 17afbf3211af97b119edb9377ef85a37733f07bc Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:10:41 +0000 Subject: [PATCH 124/139] sdk: trap-injection harness proving reconcile converges from every torn write prefix (#612) nexum-sdk-test grows TrapStore, a LocalStoreHost wrapper that counts set and delete calls and, once armed, traps after the nth write and faults every later operation until disarmed, so nothing past the trap executes. The keeper tests sweep the accepted-submit tick through every torn write prefix (trap after 0, 1, and 2 of its 3 writes: reserve set, commit set, refusal-marker delete) and hold the next sweep to convergence: the journal ends COMMITTED, the venue holds exactly one order via the re-POST idempotency backstop, no reservation stays stranded, and a further tick is a pure idempotent skip. The review rule the sweep enforces sits next to the harness: no in-store invariant may span two set calls unless the intermediate state is self-healing or the writes ride the atomic apply batch verb (#609). --- crates/nexum-sdk-test/src/lib.rs | 171 +++++++++++++++++++++++++++++++ crates/videre-sdk/src/keeper.rs | 83 ++++++++++++++- 2 files changed, 253 insertions(+), 1 deletion(-) diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index a5aecedf..81439b5a 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -715,6 +715,124 @@ impl LocalStoreHost for MockLocalStore { } } +// ---------------------------------------------------------------- trap store + +/// Trap-injection wrapper over a [`LocalStoreHost`]: counts `set` and +/// `delete` calls and, once armed, simulates a guest trap mid-flow. +/// [`arm_after`](Self::arm_after)`(n)` lets the next `n` writes land; +/// the write after that trips the trap, and from then on every +/// operation - reads included - faults until +/// [`disarm`](Self::disarm), because nothing past a trap executes. +/// Sweeping `n` over a flow's write count drives the store through +/// every torn prefix a trap can strand, so a recovery pass can be +/// held to convergence from each one. +/// +/// Review rule this harness enforces (#609): no in-store invariant +/// may span two `set` calls unless the intermediate state is +/// self-healing or the writes ride the atomic `apply` batch verb. +pub struct TrapStore { + inner: H, + /// Write calls the trap let through. + writes: Cell, + /// Writes still allowed before the trap trips; `None` when unarmed. + remaining: Cell>, + tripped: Cell, +} + +impl TrapStore { + /// Wrap `inner`, unarmed: every operation delegates, writes are + /// counted. + pub fn new(inner: H) -> Self { + Self { + inner, + writes: Cell::new(0), + remaining: Cell::new(None), + tripped: Cell::new(false), + } + } + + /// Arm the trap: the next `n` writes land, the one after trips it. + pub fn arm_after(&self, n: u64) { + self.remaining.set(Some(n)); + self.tripped.set(false); + } + + /// Clear the trap and the tripped state; operations resume. The + /// write count keeps accumulating. + pub fn disarm(&self) { + self.remaining.set(None); + self.tripped.set(false); + } + + /// `set`/`delete` calls the trap let through since construction. + pub fn writes(&self) -> u64 { + self.writes.get() + } + + /// Whether the trap has fired. + pub fn tripped(&self) -> bool { + self.tripped.get() + } + + /// The wrapped store. + pub fn inner(&self) -> &H { + &self.inner + } + + /// Fault unless still executing: past the trap nothing runs. + fn read_gate(&self) -> Result<(), Fault> { + if self.tripped.get() { + return Err(Fault::Internal("TrapStore: trapped".into())); + } + Ok(()) + } + + /// Spend one write from the armed budget, tripping at zero. + fn write_gate(&self) -> Result<(), Fault> { + self.read_gate()?; + if let Some(remaining) = self.remaining.get() { + if remaining == 0 { + self.tripped.set(true); + return Err(Fault::Internal("TrapStore: trapped".into())); + } + self.remaining.set(Some(remaining - 1)); + } + self.writes.set(self.writes.get() + 1); + Ok(()) + } +} + +impl LocalStoreHost for TrapStore { + fn get(&self, key: &str) -> Result>, Fault> { + self.read_gate()?; + self.inner.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + self.write_gate()?; + self.inner.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.write_gate()?; + self.inner.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.read_gate()?; + self.inner.list_keys(prefix) + } + fn contains(&self, key: &str) -> Result { + self.read_gate()?; + self.inner.contains(key) + } + fn len(&self, key: &str) -> Result, Fault> { + self.read_gate()?; + self.inner.len(key) + } + fn count(&self, prefix: &str) -> Result { + self.read_gate()?; + self.inner.count(prefix) + } +} + // ---------------------------------------------------------------- logging /// One recorded log line. @@ -1099,6 +1217,59 @@ mod tests { assert!(store.list_keys("bad:").is_err()); } + #[test] + fn trap_store_counts_writes_unarmed() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.set("b", b"2").unwrap(); + store.delete("a").unwrap(); + assert_eq!(store.writes(), 3); + assert!(!store.tripped()); + assert_eq!(store.get("b").unwrap().as_deref(), Some(&b"2"[..])); + } + + #[test] + fn trap_store_trips_after_the_armed_budget() { + let store = TrapStore::new(MockLocalStore::default()); + store.arm_after(2); + store.set("a", b"1").unwrap(); + store.delete("a").unwrap(); + // The third write trips; the row never lands. + assert!(store.set("b", b"2").is_err()); + assert!(store.tripped()); + assert_eq!(store.writes(), 2); + assert!(store.inner().get("b").unwrap().is_none()); + } + + #[test] + fn trap_store_faults_every_operation_once_tripped() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.arm_after(0); + assert!(store.set("b", b"2").is_err()); + // Nothing past a trap executes, reads included. + assert!(store.get("a").is_err()); + assert!(store.list_keys("").is_err()); + assert!(store.contains("a").is_err()); + assert!(store.delete("a").is_err()); + } + + #[test] + fn trap_store_disarm_resumes_over_the_surviving_rows() { + let store = TrapStore::new(MockLocalStore::default()); + store.set("a", b"1").unwrap(); + store.arm_after(0); + assert!(store.set("b", b"2").is_err()); + + store.disarm(); + assert!(!store.tripped()); + // The torn prefix survives: `a` landed, `b` never did. + assert_eq!(store.get("a").unwrap().as_deref(), Some(&b"1"[..])); + assert!(store.get("b").unwrap().is_none()); + store.set("b", b"2").unwrap(); + assert_eq!(store.writes(), 2); + } + #[test] fn local_store_max_entries_enforced() { let store = MockLocalStore::default(); diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index cb7608f3..73d15bd7 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -345,7 +345,7 @@ mod tests { use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{Gates, Journal, Mark, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; - use nexum_sdk_test::MockLocalStore; + use nexum_sdk_test::{MockLocalStore, TrapStore}; use super::{Keeper, Outcome, RunReport, submission_key}; use crate::client::{VenueId, VenueTransport}; @@ -1050,4 +1050,85 @@ mod tests { assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); assert_ne!(mark(&host, b"order"), None); } + + // ---- #609 trap-injection: convergence from every torn prefix ---- + // + // Review rule the sweep enforces: no in-store invariant may span + // two `set` calls unless the intermediate state is self-healing or + // the writes ride the atomic `apply` batch verb (#609). The + // reserve/commit journal passes because each of its intermediate + // states - nothing, RESERVED, COMMITTED-sans-marker-clear - is one + // the next sweep's reconcile pass resolves on its own. + + /// Seed one watch on a trap store and pair it with an accepting + /// venue and a submitting keeper. + fn trap_rig( + venue: &CountingVenue, + ) -> ( + TrapStore, + Keeper, + ) { + let host = TrapStore::new(MockLocalStore::default()); + WatchSet::new(&host) + .put(&Address::ZERO, &B256::ZERO, b"params") + .expect("watch writes"); + let keeper = Keeper::new(StubSource(Outcome::Submit(b"order".to_vec())), venue, STUB); + (host, keeper) + } + + /// Writes one accepted submit tick performs, pinned by a dry run: + /// the reserve set, the commit set, and the refusal-marker delete. + fn accepted_tick_writes() -> u64 { + let venue = CountingVenue::accepting(); + let (host, keeper) = trap_rig(&venue); + let seeded = host.writes(); + run(keeper.run(&host, &TICK)).expect("dry run completes"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + host.writes() - seeded + } + + #[test] + fn trap_at_every_write_prefix_reconciles_to_exactly_one_held_order() { + let total = accepted_tick_writes(); + assert_eq!(total, 3, "reserve set, commit set, refusal-marker delete"); + + // Trap the tick after each n of its writes: every torn prefix + // from nothing-landed through all-but-the-last. + for n in 0..total { + let venue = CountingVenue::accepting(); + let (host, keeper) = trap_rig(&venue); + host.arm_after(n); + let _ = run(keeper.run(&host, &TICK)); + assert!(host.tripped(), "prefix {n}: the trap must fire mid-tick"); + + // Restart from the torn store: the next sweep's reconcile + // pass plus the fresh-watch loop must converge. + host.disarm(); + run(keeper.run(&host, &TICK)).expect("recovery tick runs"); + assert_eq!( + mark(&host, b"order"), + Some(Mark::Committed), + "prefix {n}: the journal must end COMMITTED", + ); + assert_eq!( + venue.held_count(), + 1, + "prefix {n}: exactly one held order, whatever the POST count", + ); + assert!( + Journal::submitted(&host) + .pending() + .expect("journal reads") + .is_empty(), + "prefix {n}: no reservation may stay stranded", + ); + + // Steady state: a further tick is a pure idempotent skip. + let posts = venue.post_count(); + let report = run(keeper.run(&host, &TICK)).expect("steady tick runs"); + assert_eq!(report.duplicates, 1, "prefix {n}: COMMITTED skips"); + assert_eq!(venue.post_count(), posts, "prefix {n}: no further POST"); + assert_eq!(venue.held_count(), 1, "prefix {n}: still one held order"); + } + } } From db219ddc3eef6f4f69d8051675991221f369c938 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:12:44 +0000 Subject: [PATCH 125/139] docs: tersen rustdoc in modules and tools (#617) Apply the terse-rustdoc house style across the shipped modules (twap-monitor, ethflow-watcher), example modules, test fixtures, and the load-gen and orderbook-mock tools: one-line item docs, module and crate headers cut to a few factual lines, restated signatures, rationale, history and external comparisons removed, invariants and fault-injection contracts preserved and compressed. --- modules/ethflow-watcher/src/lib.rs | 20 +-- modules/ethflow-watcher/src/strategy.rs | 135 ++++++---------- modules/example/src/lib.rs | 11 +- modules/examples/balance-tracker/src/lib.rs | 28 +--- .../examples/balance-tracker/src/strategy.rs | 41 ++--- modules/examples/echo-client/src/lib.rs | 19 +-- modules/examples/echo-keeper/src/lib.rs | 27 ++-- modules/examples/echo-venue/src/lib.rs | 22 +-- modules/examples/http-probe/src/lib.rs | 35 +--- modules/examples/http-probe/src/strategy.rs | 37 ++--- modules/examples/price-alert/src/lib.rs | 41 +---- modules/examples/price-alert/src/strategy.rs | 45 ++---- modules/fixtures/clock-reader/src/lib.rs | 14 +- modules/fixtures/flaky-bomb/src/lib.rs | 23 +-- modules/fixtures/flaky-venue/src/lib.rs | 12 +- modules/fixtures/fuel-bomb/src/lib.rs | 11 +- modules/fixtures/memory-bomb/src/lib.rs | 11 +- modules/fixtures/panic-bomb/src/lib.rs | 17 +- modules/fixtures/slow-host/src/lib.rs | 28 +--- modules/twap-monitor/src/keeper.rs | 153 +++++++----------- modules/twap-monitor/src/lib.rs | 16 +- tools/load-gen/src/main.rs | 49 ++---- tools/orderbook-mock/src/main.rs | 36 ++--- 23 files changed, 244 insertions(+), 587 deletions(-) diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 35b800a8..41077767 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -1,21 +1,11 @@ //! # ethflow-watcher (Shepherd module) //! //! Subscribes to `CoWSwapOnchainOrders.OrderPlacement` logs from the -//! canonical CoWSwap EthFlow contracts, computes each placement's -//! orderbook UID, and puts it under the host's status watch through the -//! typed cow venue client. The registry polls the cow adapter and fans -//! transitions back as `intent-status` events; the module journals -//! `observed:{uid}` on the first one. Observe-only: it never submits -//! (see `strategy.rs`). -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and unit tests against the -//! `nexum_sdk::host` and `videre_sdk` client seams. It does not know -//! `wit-bindgen` exists. -//! - `lib.rs` (this file) is the per-cdylib glue: the -//! `#[videre_sdk::keeper]` handler impl that binds the world derived -//! from `module.toml` and delegates each event to `strategy`. +//! canonical EthFlow contracts, computes each placement's orderbook UID, +//! and puts it under the host's status watch; the registry polls the cow +//! adapter and fans transitions back as `intent-status` events, journalled +//! as `observed:{uid}`. Observe-only, never submits. Pure logic lives in +//! `strategy`; `lib.rs` is the `#[videre_sdk::keeper]` glue. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index de3d3238..ec3fbb80 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -1,34 +1,13 @@ //! Pure strategy logic for the ethflow-watcher module. //! -//! Every interaction with the world flows through two seams: the -//! `nexum_sdk::host` traits for the `observed:` journal and the typed -//! [`CowClient`] over [`VenueTransport`] for the venue. The `lib.rs` -//! glue binds both to the module's own imports; tests drive the same -//! functions with `nexum_sdk_test::MockHost` and an in-memory spy -//! transport. -//! -//! ## Design (redesign) -//! -//! The original design POSTed each on-chain `OrderPlacement` -//! to `/api/v1/orders` with the EthFlow contract as the EIP-1271 owner. -//! Empirical evidence (2026-06-22 Sepolia soak) showed that path cannot -//! succeed: the orderbook backend indexes EthFlow `OrderPlacement` -//! events natively and writes server-only fields the public POST body -//! does not carry. -//! -//! This strategy therefore **observes + verifies** through the venue -//! registry: -//! -//! 1. Decode the `OrderPlacement` log against the canonical EthFlow -//! contract addresses. -//! 2. Compute the orderbook UID from the on-chain order shape: the -//! externally-obtained receipt at the cow venue. -//! 3. `observe` the receipt through the venue registry, which polls the -//! cow adapter's `status` and fans transitions back as -//! `intent-status` events. On the first one, record `observed:{uid}` -//! in the keeper idempotency journal so log re-delivery is a no-op. -//! A refused observe is logged and left unjournalled, so the next -//! re-delivery retries. +//! Observes EthFlow placements through the venue registry: decode the +//! `OrderPlacement` log, compute the orderbook UID (the receipt at the +//! cow venue), and `observe` it. The registry polls the cow adapter's +//! `status` and fans transitions back as `intent-status` events; the +//! first records `observed:{uid}` in the journal so log re-delivery +//! no-ops. A refused observe stays unjournalled, so re-delivery +//! retries. World access flows through the `nexum_sdk::host` traits and +//! the typed [`CowClient`] over [`VenueTransport`]. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolEvent; @@ -45,32 +24,24 @@ use videre_sdk::client::{Venue, VenueTransport}; use videre_sdk::status_body::StatusBody; /// Decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. -/// `GPv2OrderData` is ~300 bytes; box it so the struct stays -/// cache-friendly when threaded through the observe path. #[derive(Debug)] pub(crate) struct DecodedPlacement { - /// EthFlow contract that emitted the event - also the EIP-1271 - /// owner of the resulting orderbook entry, used as the UID - /// `owner` input. + /// EthFlow contract that emitted the event; the EIP-1271 owner and + /// the UID `owner` input. pub(crate) contract: Address, - /// Original native-token seller. Logged for operator diagnostics; - /// not the orderbook owner. + /// Original native-token seller; not the orderbook owner. pub(crate) sender: Address, pub(crate) order: Box, - /// Decoded signature. Recorded by the orderbook indexer itself; - /// not consumed by the observe path. + /// Decoded signature; not consumed by the observe path. #[allow(dead_code)] pub(crate) signature: OnchainSignature, - /// Refund pointer / opaque placer metadata embedded in the - /// `OrderPlacement` event. The orderbook indexer derives - /// `ethflowData.userValidTo` from this blob; we keep it on the - /// struct for parity with the decoder contract. + /// Opaque placer metadata from the event; kept for decoder parity. #[allow(dead_code)] pub(crate) data: Bytes, } -/// Entry point: decode every `OrderPlacement` chain-log in a dispatch batch -/// and put each decoded placement's UID under the host's status watch. +/// Decode every `OrderPlacement` log in a dispatch batch and put each +/// placement's UID under the host's status watch. pub async fn on_chain_logs( host: &H, venue: &CowClient, @@ -86,8 +57,7 @@ pub async fn on_chain_logs( } /// A registry status transition for a watched receipt. Foreign venues -/// are ignored; the first transition for a cow receipt records the -/// `observed:{uid}` marker (the orderbook indexed the placement), and +/// are ignored; the first cow transition records `observed:{uid}`, and /// every transition is logged. pub fn on_intent_status( host: &H, @@ -118,14 +88,9 @@ pub fn on_intent_status( // ---- decode ---- /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. -/// -/// Returns `None` when: -/// - the log's contract address is neither `ETH_FLOW_PRODUCTION` nor -/// `ETH_FLOW_STAGING` (defensive - the host's `[[subscription]]` -/// filter already pins the address, but a misconfigured engine could -/// still leak through); -/// - topic-0 does not match the `shepherd:cow/cow-events` pin; or -/// - the ABI body fails to decode. +/// `None` when the contract address is neither `ETH_FLOW_PRODUCTION` +/// nor `ETH_FLOW_STAGING`, topic-0 misses the `shepherd:cow/cow-events` +/// pin, or the ABI body fails to decode. pub(crate) fn decode_order_placement(log: &Log) -> Option { let contract = log.address(); if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { @@ -146,9 +111,8 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { // ---- observe + verify (venue registry) ---- -/// Compute the orderbook UID for the placement and put it under the -/// host's status watch. A refused observe (venue down, watch set full) -/// is logged and left unjournalled, so re-delivery retries. +/// Compute the orderbook UID and put it under the host's status watch. +/// A refused observe stays unjournalled, so re-delivery retries. async fn observe_placement( host: &H, venue: &CowClient, @@ -188,9 +152,8 @@ async fn observe_placement( Ok(()) } -/// Compute the canonical 56-byte orderbook UID for the placement. -/// The UID packs `digest || owner || valid_to`; the owner input is the -/// EthFlow contract (which signs via EIP-1271), not the native-token +/// Canonical 56-byte orderbook UID: `digest || owner || valid_to`, +/// where owner is the EthFlow contract (EIP-1271 signer), not the /// sender. fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option { let chain = Chain::try_from(chain_id).ok()?; @@ -219,8 +182,8 @@ mod tests { const SEPOLIA: u64 = 11_155_111; - /// One recorded transport call: which verb, and for `observe` the - /// routed venue and receipt. + /// One recorded transport call; `observe` also records venue and + /// receipt. #[derive(Clone, Debug, Eq, PartialEq)] enum Call { Quote, @@ -230,11 +193,9 @@ mod tests { Cancel, } - /// Records every call; `observe` pops a scripted response and - /// defaults to accepted once the script drains. The other verbs - /// refuse: the observe-only strategy must never reach them. - /// Cloneable over shared state so the test keeps a handle after - /// one moves into the client. + /// Records every call; `observe` pops a scripted response, + /// defaulting to accepted once the script drains. The other verbs + /// refuse. Cloneable over shared state so the test keeps a handle. #[derive(Clone, Default)] struct SpyVenues { calls: Rc>>, @@ -367,8 +328,7 @@ mod tests { (topics, data) } - /// Assemble the alloy log a placement decodes from, through the same - /// WIT-edge path the bind macro uses at runtime. + /// The alloy log a placement decodes from. fn make_log(address_bytes: &[u8], topics: &[Vec], data: &[u8]) -> Log { nexum_sdk::events::ChainLogParts { address: address_bytes, @@ -447,9 +407,8 @@ mod tests { // ---- observe via the venue registry (transport integration) ---- - /// A placement registers exactly one status watch at the cow venue - /// with the computed UID as the receipt, and journals nothing yet: - /// the marker waits for the first status transition. + /// A placement registers one cow status watch keyed on the computed + /// UID, and journals nothing until the first status transition. #[test] fn placement_log_registers_the_uid_watch() { let host = MockHost::new(); @@ -469,8 +428,8 @@ mod tests { ); } - /// A refused observe warns, journals nothing, and the next - /// re-delivery retries the watch. + /// A refused observe warns, journals nothing, and re-delivery + /// retries. #[test] fn watch_refusal_warns_and_redelivery_retries() { let host = MockHost::new(); @@ -488,8 +447,8 @@ mod tests { assert_eq!(spy.observe_count(), 2); } - /// Idempotency: a placement that already has `observed:{uid}` in - /// local store does NOT touch the venue on re-delivery. + /// A placement already carrying `observed:{uid}` does not touch the + /// venue on re-delivery. #[test] fn previously_observed_placement_is_skipped_on_redelivery() { let host = MockHost::new(); @@ -508,8 +467,8 @@ mod tests { ); } - /// Defensive: unsupported chain id surfaces a Warn but does not - /// panic and does not touch the venue. + /// An unsupported chain id warns without panicking or touching the + /// venue. #[test] fn unsupported_chain_logs_warn_without_venue_call() { let host = MockHost::new(); @@ -526,8 +485,8 @@ mod tests { }); } - /// The strategy is observer-only: no call path reaches quote, - /// submit, status, or cancel. Belt-and-suspenders regression guard. + /// Observer-only: no call path reaches quote, submit, status, or + /// cancel. #[test] fn strategy_never_submits() { let host = MockHost::new(); @@ -590,7 +549,7 @@ mod tests { assert!(host.store.snapshot().is_empty()); } - /// A cow receipt that is not a 56-byte UID warns without a marker. + /// A cow receipt that is not a 56-byte UID warns, no marker. #[test] fn non_uid_receipt_warns_without_marker() { let host = MockHost::new(); @@ -601,8 +560,7 @@ mod tests { logs.expect_one(|e| e.level == Level::WARN && e.message.contains("non-uid receipt")); } - /// A status body the SDK cannot decode is a typed fault, never a - /// silent marker. + /// An undecodable status body is a typed fault, never a marker. #[test] fn malformed_status_body_is_a_typed_fault() { let host = MockHost::new(); @@ -614,9 +572,9 @@ mod tests { // ---- package-of-record parity ---- - /// Guard: the `sol!` decoder's topic-0 matches the - /// `shepherd:cow/cow-events` package of record. A typo or ABI - /// drift would silently miss every EthFlow event. + /// The `sol!` decoder's topic-0 matches the + /// `shepherd:cow/cow-events` pin; a drift would silently miss every + /// EthFlow event. #[test] fn topic0_matches_the_cow_events_package_of_record() { let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); @@ -627,9 +585,8 @@ mod tests { ); } - /// Read the shipped `module.toml` and assert its pinned - /// `event_signature` equals the decoder topic-0 - catches a - /// manifest/code drift the wit assertion cannot see. + /// The shipped `module.toml` `event_signature` equals the decoder + /// topic-0; catches a manifest/code drift the wit assertion cannot. #[test] fn manifest_topic0_matches_order_placement_signature_hash() { let manifest = include_str!("../module.toml"); diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 5597f442..cc16f55d 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,12 +1,9 @@ //! # 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. +//! Minimal reference module: one handler per event, each logging a +//! one-line summary. The smallest demonstration of +//! `#[nexum_sdk::module]`, which supplies the wit-bindgen call, host +//! adapter, dispatch, and `export!`. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index ee3ae410..725856ac 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -1,28 +1,10 @@ //! # balance-tracker (example Shepherd module) //! -//! Subscribes to blocks, reads `eth_getBalance(addr)` for every -//! address in `[config].addresses` (comma-separated), persists the -//! last seen value under `balance:{addr}` in local-store, and emits -//! a Warn-level log line when the balance changes by more than -//! `[config].change_threshold` wei since the previous block. -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! the `nexum_sdk::host` trait seam. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Config -//! -//! ```toml -//! [config] -//! # Comma-separated list of 0x-prefixed 20-byte addresses. -//! addresses = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8,0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" -//! # Change threshold in wei; an alert fires when the delta exceeds it. -//! change_threshold = "100000000000000000" # 0.1 ETH -//! ``` +//! On each block reads `eth_getBalance` for every `[config].addresses` +//! entry, persists the last value under `balance:{addr}`, and warns +//! when a balance moves more than `[config].change_threshold` wei. +//! Pure logic lives in `strategy`; `lib.rs` is the +//! `#[nexum_sdk::module]` glue. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/strategy.rs index f1f5bf73..eb2f6016 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/strategy.rs @@ -1,42 +1,26 @@ //! Pure strategy logic for the balance-tracker module. //! -//! Every interaction with the world flows through the host trait -//! seam exposed by `nexum-sdk`, bounded to exactly the interfaces the -//! module declares ([`ChainHost`] + [`LocalStoreHost`]) - no direct -//! calls to wit-bindgen-generated free functions live here. The -//! `lib.rs` glue wraps a `WitBindgenHost` adapter around the module's -//! per-cdylib wit-bindgen imports and hands it to [`on_block`]; tests -//! under `#[cfg(test)]` hand the same function a +//! World access flows through the [`ChainHost`] + [`LocalStoreHost`] +//! trait seam; `lib.rs` hands [`on_block`] a `WitBindgenHost`, tests a //! `nexum_sdk_test::MockHost`. -//! -//! Aligns balance-tracker with the M3 "host trait + adapter" recipe -//! the other four modules already follow (PR #55 review). Previously -//! `on_event` here dispatched against wit-bindgen free functions -//! directly, which made `check_one` / `fetch_balance` only reachable -//! from a real WASM build and excluded MockHost coverage. use nexum_sdk::address::parse_address_list; use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::{ChainHost, Fault, LocalStoreHost}; use nexum_sdk::prelude::{Address, U256}; -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. +/// Settings parsed from `[config]` at `init`. #[derive(Clone, Debug)] pub struct Settings { - /// 0x-prefixed addresses to track. + /// Addresses to track. pub addresses: Vec
, - /// Change threshold in wei; an alert fires when the delta exceeds - /// it. + /// Alert fires when a balance moves more than this many wei. pub change_threshold: U256, } -/// Entry point: poll every tracked address on a new block, log on -/// threshold-crossing diffs, persist the latest reading. -/// -/// Each address is independent; a single flaky `eth_getBalance` does -/// not abort the loop - the failure is logged and the next address is -/// still polled. +/// Poll every tracked address on a new block, warn on threshold-crossing +/// diffs, persist the latest reading. A single flaky `eth_getBalance` is +/// logged and skipped, not fatal to the loop. pub fn on_block( host: &H, chain_id: u64, @@ -50,9 +34,8 @@ pub fn on_block( Ok(()) } -/// Poll one address: fetch latest balance, diff against the last -/// stored value, emit a log if the delta crosses `threshold`, then -/// persist the new value under `balance:{addr}`. +/// Fetch one address's balance, warn if the diff against the stored +/// value crosses `threshold`, then persist it under `balance:{addr}`. fn check_one( host: &H, chain_id: u64, @@ -92,8 +75,8 @@ fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result // ---- pure helpers (unit-testable, no host) ------------------------ -/// Parse the `"0x..."` JSON string `eth_getBalance` returns into a -/// `U256`. `None` on shape mismatch. +/// Parse the `"0x..."` JSON string `eth_getBalance` returns; `None` on +/// shape mismatch. fn parse_balance_hex(result_json: &str) -> Option { let trimmed = result_json.trim(); let body = trimmed diff --git a/modules/examples/echo-client/src/lib.rs b/modules/examples/echo-client/src/lib.rs index 33025943..e4e13fc6 100644 --- a/modules/examples/echo-client/src/lib.rs +++ b/modules/examples/echo-client/src/lib.rs @@ -1,15 +1,11 @@ //! # echo-client (reference Shepherd intent module) //! -//! The keeper half of the echo pair. On every chain-1 block it quotes and -//! submits an opaque body through `videre:venue/client` to the `echo-venue` -//! adapter and logs the receipt, and it logs each `intent-status` transition the -//! registry fans back from that venue. Paired with the echo-venue adapter it -//! is the smallest end-to-end demonstration of the intent core: module -> -//! host registry -> venue adapter, and the status event back. -//! -//! It declares two capabilities (`client`, `logging`), so the built -//! component imports `videre:venue/client` and `nexum:host/logging` and -//! nothing else: the per-module world matches the manifest by construction. +//! The keeper half of the echo pair. On every chain-1 block it quotes +//! and submits an opaque body through the raw `videre:venue/client` +//! import to the `echo-venue` adapter, logs the receipt, and logs each +//! `intent-status` transition the registry fans back. The smallest +//! demonstration of the intent core: module -> registry -> adapter and +//! the status event back. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. @@ -20,8 +16,7 @@ use nexum::host::{logging, types}; use videre::types::types::SubmitOutcome; use videre::venue::client; -/// Venue id the paired echo-venue adapter answers for; the module submits -/// to and observes exactly this venue. +/// Venue id the paired echo-venue adapter answers for. const ECHO_VENUE: &str = "echo-venue"; struct EchoClient; diff --git a/modules/examples/echo-keeper/src/lib.rs b/modules/examples/echo-keeper/src/lib.rs index b840f7c0..3978e5cd 100644 --- a/modules/examples/echo-keeper/src/lib.rs +++ b/modules/examples/echo-keeper/src/lib.rs @@ -1,18 +1,11 @@ //! # echo-keeper (reference videre keeper module) //! -//! The blessed keeper half of the echo pair: on every chain-1 block it -//! drives the echo-venue adapter through the typed -//! `VenueClient` - quote, submit, status, cancel, all with a -//! typed body - and logs each `intent-status` transition the registry -//! fans back. Where echo-client hand-writes byte marshalling over the -//! raw `videre:venue/client` import, this module is -//! `#[videre_sdk::keeper]`: the macro wires the world and the client -//! import, and the author never sees wire bytes. -//! -//! It declares two capabilities (`client`, `logging`), so the built -//! component imports `videre:venue/client` and `nexum:host/logging` and -//! nothing else: the per-module world matches the manifest by -//! construction. +//! The keeper half of the echo pair. On every chain-1 block it drives +//! the echo-venue adapter through the typed `VenueClient` +//! (quote, submit, status, cancel, all with a typed body) and logs each +//! `intent-status` transition the registry fans back. The +//! `#[videre_sdk::keeper]` counterpart to echo-client: the macro wires +//! the world and client import, so the author never sees wire bytes. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. @@ -22,8 +15,8 @@ use nexum::host::{logging, types}; use videre_sdk::{SubmitOutcome, Venue, VenueClient, VenueId}; -/// The echo venue as this keeper types it: the id the paired adapter -/// answers for and the body schema below. +/// The echo venue as this keeper types it: the adapter's id and the +/// body schema below. struct EchoVenue; impl Venue for EchoVenue { @@ -31,9 +24,7 @@ impl Venue for EchoVenue { type Body = EchoBody; } -/// The keeper's published body schema. The echo venue accepts any -/// bytes, so v1 is just the block number: enough to exercise the typed -/// codec end to end. +/// The keeper's published body schema; v1 is just the block number. #[derive(videre_sdk::IntentBody)] enum EchoBody { V1(u64), diff --git a/modules/examples/echo-venue/src/lib.rs b/modules/examples/echo-venue/src/lib.rs index 2a3d8d50..085a250c 100644 --- a/modules/examples/echo-venue/src/lib.rs +++ b/modules/examples/echo-venue/src/lib.rs @@ -1,17 +1,9 @@ //! # echo-venue (reference Shepherd venue adapter) //! -//! The minimal reference venue adapter: it accepts any body, echoes it back -//! as the receipt, and settles instantly (every receipt it issued reports -//! `fulfilled`). It carries no real venue protocol, so it doubles as the -//! smallest end-to-end demonstration of `#[videre_sdk::venue]` - the -//! attribute takes the `impl VenueAdapter` block and supplies the -//! per-cdylib wit-bindgen for a world derived from `module.toml` plus the -//! export glue - and as the `videre-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 -//! the manifest by construction. +//! Minimal reference venue adapter: accepts any body, echoes it back as +//! the receipt, and settles instantly (every receipt reports +//! `fulfilled`). The smallest demonstration of `#[videre_sdk::venue]` +//! and the `videre-test` conformance target (see the tests below). // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. @@ -107,10 +99,8 @@ fn minimal_be(value: u64) -> Vec { first.map_or(Vec::new(), |index| bytes[index..].to_vec()) } -/// echo-venue as the `videre-test` conformance target: the adapter's -/// pure header derivation is held to a hand-written golden. The macro -/// remaps the type interfaces onto the SDK bindings, so the derivation -/// feeds the kit directly through its `From` mirror. +/// echo-venue as the `videre-test` conformance target: the pure header +/// derivation is held to a hand-written golden. #[cfg(test)] mod conformance { use super::*; diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index 672f0001..07c3786d 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -1,34 +1,11 @@ //! # http-probe (example Shepherd module) //! -//! On every matching block, fetches an allowlisted URL over wasi:http -//! and logs the response status, then fetches an off-list URL and -//! verifies the host denies it before any connection is made. -//! Demonstrates the guest-side HTTP patterns of a Shepherd module: -//! -//! - `nexum_sdk::http::fetch` (wasi:http via the SDK helper) -//! - the `[capabilities.http].allow` allowlist and its denial path -//! - `[config]` driven behaviour parsed once in `init` -//! -//! ## Module layout -//! -//! - `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) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # URL fetched on every matching block; host must be allowlisted. -//! probe_url = "https://api.cow.fi/mainnet/api/v1/version" -//! # URL whose host is deliberately off-list; the module expects the -//! # denied error and treats any other outcome as a failure. -//! denied_url = "https://example.com/" -//! # Optional throttle: probe every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` +//! On each matching block fetches the allowlisted `[config].probe_url` +//! over wasi:http and logs its status, then fetches `[config].denied_url` +//! and asserts the `[capabilities.http].allow` gate denies it. +//! Demonstrates the SDK `http::Fetch` seam and the allowlist denial +//! path. Pure logic lives in `strategy`; `lib.rs` is the +//! `#[nexum_sdk::module]` glue. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/strategy.rs index 3d3d80b7..0463a14c 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/strategy.rs @@ -1,31 +1,25 @@ //! Pure strategy logic for the http-probe module. //! -//! All HTTP flows through the [`Fetch`] seam and logging goes through -//! the `tracing` facade, so the whole strategy is unit-testable -//! host-free: tests hand [`on_block`] a stub fetcher and capture the -//! `tracing` output; the `lib.rs` glue hands it `nexum_sdk::http::WasiFetch`. +//! HTTP flows through the [`Fetch`] seam; `lib.rs` hands [`on_block`] +//! `nexum_sdk::http::WasiFetch`, tests hand it a stub fetcher. use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::Fault; use nexum_sdk::http::{Fetch, FetchError}; -/// Resolved settings parsed from `[config]` at `init` and read on -/// every event. +/// Settings parsed from `[config]` at `init`. #[derive(Clone, Debug)] pub struct Settings { - /// URL fetched on every matching block; its host must be on the - /// module's allowlist. + /// Allowlisted URL fetched on every matching block. pub probe_url: String, - /// URL whose host is deliberately off-list; anything other than a - /// denial is a failure. + /// Off-list URL; anything other than a denial is a failure. pub denied_url: String, /// Only probe every Nth block. pub every_n_blocks: u64, } -/// Entry point: probe the allowlisted URL, then verify the off-list -/// URL is denied. Returns `Err` when either leg misbehaves so the -/// runtime records a fault for the dispatch. +/// Probe the allowlisted URL, then verify the off-list URL is denied. +/// `Err` when either leg misbehaves. pub fn on_block( fetcher: &F, settings: &Settings, @@ -38,8 +32,8 @@ pub fn on_block( probe_denied(fetcher, &settings.denied_url) } -/// Fetch the allowlisted URL and log its status; any fetch error is -/// surfaced as a fault for this dispatch. +/// Fetch the allowlisted URL and log its status; a fetch error is a +/// fault. fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { let response = fetcher .fetch(get_request(url)?) @@ -52,8 +46,8 @@ fn probe_allowlisted(fetcher: &F, url: &str) -> Result<(), Fault> { Ok(()) } -/// Fetch the off-list URL and demand [`FetchError::Denied`]; a -/// response or any other error means the allowlist gate did not hold. +/// Fetch the off-list URL and demand [`FetchError::Denied`]; any other +/// outcome means the allowlist gate did not hold. fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { match fetcher.fetch(get_request(url)?) { Err(FetchError::Denied) => { @@ -70,16 +64,14 @@ fn probe_denied(fetcher: &F, url: &str) -> Result<(), Fault> { } } -/// Build a body-less GET for `url`; a malformed URL is a config error -/// surfaced as an invalid-input fault. +/// Body-less GET for `url`; a malformed URL is an invalid-input fault. fn get_request(url: &str) -> Result>, Fault> { http::Request::get(url) .body(Vec::new()) .map_err(|e| invalid_input(format!("probe url {url}: {e}"))) } -/// Lift a [`FetchError`] into a [`Fault`], preserving the -/// policy/timeout/input/transport distinction in the case. +/// Lift a [`FetchError`] into a [`Fault`], preserving the case. fn fetch_err(url: &str, error: &FetchError) -> Fault { let detail = format!("fetch {url}: {error}"); match error { @@ -135,8 +127,7 @@ mod tests { use super::*; - /// Stub fetcher: replays canned outcomes in call order and records - /// the requested URLs. + /// Stub fetcher: replays canned outcomes in order, records URLs. struct StubFetch { outcomes: RefCell>, FetchError>>>, urls: RefCell>, diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index f8e3a825..7ca316ea 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -1,41 +1,10 @@ //! # price-alert (example Shepherd module) //! -//! Polls a Chainlink price oracle on every new block and emits a -//! Warn-level log when the price crosses a config-supplied -//! threshold. Demonstrates the three load-bearing patterns of a -//! Shepherd module: -//! -//! - `chain::request` + ABI decode via `alloy_sol_types` -//! - `nexum_sdk` helpers (`prelude`, `chain::eth_call_params`, -//! `chain::parse_eth_call_result`) -//! - `[config]` driven behaviour parsed once in `init` and read on -//! every subsequent event -//! -//! ## Module layout -//! -//! - `strategy.rs` holds the pure logic and tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) declares the handlers and defers the -//! per-cdylib glue to `#[nexum_sdk::module]`. -//! -//! ## Settings -//! -//! ```toml -//! [config] -//! # Chainlink AggregatorV3Interface address. -//! oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" # ETH/USD on Sepolia -//! # Oracle's decimals (Chainlink USD pairs are 8; ETH pairs 18). -//! decimals = "8" -//! # Threshold in the oracle's native units (decimal string). The -//! # module multiplies by 10**decimals at init. -//! threshold = "2500.00" -//! # Either "above" or "below". Fires when the answer crosses on -//! # the configured side. -//! direction = "below" -//! # Optional throttle: poll every N blocks. Default 1. -//! every_n_blocks = "1" -//! ``` +//! Polls a Chainlink oracle on each block and warns when the price +//! crosses a `[config]`-supplied threshold on the configured side. +//! Demonstrates `chain::request` with an `alloy_sol_types` ABI decode +//! and the `nexum_sdk::chain` helpers. Pure logic lives in `strategy`; +//! `lib.rs` is the `#[nexum_sdk::module]` glue. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. diff --git a/modules/examples/price-alert/src/strategy.rs b/modules/examples/price-alert/src/strategy.rs index a6c773e8..64a52126 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/modules/examples/price-alert/src/strategy.rs @@ -1,14 +1,8 @@ //! Pure strategy logic for the price-alert module. //! -//! Every interaction with the world flows through the host trait -//! seam exposed by `nexum-sdk` - no direct calls to wit-bindgen- -//! generated free functions live here. The `lib.rs` glue wraps a -//! `WitBindgenHost` adapter around the module's per-cdylib wit-bindgen -//! imports and hands it to [`on_block`]; tests under `#[cfg(test)]` -//! hand the same function a `nexum_sdk_test::MockHost`. 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. +//! World access flows through the [`ChainHost`] + [`LoggingHost`] trait +//! seam, the module's two declared capabilities; `lib.rs` hands +//! [`on_block`] a `WitBindgenHost`, tests a `nexum_sdk_test::MockHost`. use alloy_primitives::I256; use nexum_sdk::chain::chainlink::read_latest_answer; @@ -16,8 +10,7 @@ use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::{ChainHost, Fault, LoggingHost}; use nexum_sdk::prelude::Address; -/// Resolved configuration, parsed from `module.toml::[config]` at -/// `init` and read on every `on_event`. +/// Configuration parsed from `[config]` at `init`. #[derive(Debug)] pub struct Settings { /// Chainlink AggregatorV3Interface address. @@ -40,13 +33,9 @@ pub enum Direction { Below, } -/// React to a new block. -/// -/// Returns `Ok(())` on success and on recoverable upstream failures -/// (oracle RPC error, decode failure) - the strategy logs a Warn and -/// 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. +/// Poll the oracle and warn on a threshold crossing. Recoverable +/// upstream failures (oracle RPC error, decode failure) log a Warn and +/// return `Ok` so the next block re-polls. pub fn on_block( host: &H, chain_id: u64, @@ -80,7 +69,7 @@ pub fn on_block( } /// `true` when `answer` is on the firing side of `threshold` per -/// `direction`. Pure - exercised by the unit tests. +/// `direction`. pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { match direction { Direction::Above => answer >= threshold, @@ -88,11 +77,7 @@ pub fn classify(answer: I256, threshold: I256, direction: Direction) -> bool { } } -/// Parse `module.toml::[config]` into a typed [`Settings`]. -/// -/// One-shot config-parser style: returns `Result` so the -/// `Guest::init` adapter can lower the failure into the wit-bindgen -/// `fault` with no extra plumbing. +/// Parse `[config]` into a typed [`Settings`]. pub fn parse_config(entries: &[(String, String)]) -> Result { let oracle_address = config::get_required(entries, "oracle_address") .map_err(config_err)? @@ -139,16 +124,12 @@ pub fn parse_config(entries: &[(String, String)]) -> Result { }) } -/// Lift a free-text invalid-config detail into a [`Fault::InvalidInput`]. -/// Used when the SDK helper does not own the error (e.g. an -/// `Address::from_str` failure). +/// Lift a free-text detail into a [`Fault::InvalidInput`]. fn invalid(message: impl Into) -> Fault { Fault::InvalidInput(message.into()) } -/// Project a `nexum_sdk::config::ConfigError` into a -/// [`Fault::InvalidInput`] via `Display`, preserving the detail at the -/// WIT boundary. +/// Project a [`ConfigError`] into a [`Fault::InvalidInput`]. fn config_err(e: ConfigError) -> Fault { invalid(e.to_string()) } @@ -175,8 +156,8 @@ mod tests { } } - /// Encode a `latestRoundData` return into the `"0x..."` JSON string - /// the host's `chain::request` would yield. + /// Encode a `latestRoundData` return as the `"0x..."` JSON string + /// `chain::request` yields. fn oracle_response_json(answer_scaled: i128) -> String { use alloy_primitives::aliases::U80; let returns = AggregatorV3::latestRoundDataReturn { diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs index 2f0cea8b..21ee7b01 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -1,14 +1,10 @@ //! # clock-reader (test fixture) //! -//! On every event reads `std::time::SystemTime::now()` and logs the wall -//! time as whole seconds since the Unix epoch. Under `wasm32-wasip2` that -//! read routes to `wasi:clocks/wall-clock`, which the supervisor -//! virtualizes per store, so a test that boots this fixture under a pinned -//! clock override can assert from the log line that the guest observed the -//! overridden time rather than the ambient host clock. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. +//! Logs `SystemTime::now()` as whole seconds since the epoch. Under +//! `wasm32-wasip2` the read routes to `wasi:clocks/wall-clock`, which +//! the supervisor virtualizes per store, so a test under a pinned clock +//! override can assert the guest observed the overridden time. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/modules/fixtures/flaky-bomb/src/lib.rs index d1b9598a..49df3817 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/modules/fixtures/flaky-bomb/src/lib.rs @@ -1,21 +1,9 @@ //! # flaky-bomb (test fixture) //! -//! Traps deterministically on the first N events and succeeds on -//! every subsequent event. Drives the supervisor's exponential- -//! backoff restart policy through its full lifecycle: -//! -//! 1. Dispatch 1: trap (failure_count = 1, next_attempt = +1s). -//! 2. (engine waits the backoff window) -//! 3. Dispatch 2 (eligible after 1s): trap again, failure_count = 2. -//! 4. ... -//! 5. Dispatch N+1: succeeds, failure_count resets to 0. -//! -//! N is config-supplied via `[config].fail_first_n`. The fixture -//! reads the value once during `init` into a `OnceLock` and keeps -//! a static `AtomicU32` counter across calls. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! Traps on the first `[config].fail_first_n` events and succeeds +//! after, driving the supervisor's exponential-backoff restart policy. +//! The attempt counter rides local-store so it survives restarts. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] @@ -32,8 +20,7 @@ use std::sync::OnceLock; use nexum::host::{local_store, logging, types}; -/// Number of consecutive events to trap on. Set from `[config].fail_first_n` -/// at init; defaults to `1` (trap once, recover on second event). +/// Consecutive events to trap on, from `[config].fail_first_n`; default 1. static FAIL_FIRST_N: OnceLock = OnceLock::new(); const ATTEMPTS_KEY: &str = "attempts"; diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/modules/fixtures/flaky-venue/src/lib.rs index 10716274..dee87818 100644 --- a/modules/fixtures/flaky-venue/src/lib.rs +++ b/modules/fixtures/flaky-venue/src/lib.rs @@ -1,13 +1,9 @@ //! # flaky-venue (test fixture) //! -//! A venue adapter whose `submit` panics (traps the store) while the chain -//! head reads as the poison sentinel `0xdead`, and accepts once the head -//! moves on. The controlling test programs the mock chain backend, so the -//! trap is deterministic and the recovery is host-driven: the supervisor's -//! sweep must reinstantiate the adapter before a submit succeeds again. -//! -//! Not a production adapter. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! Venue adapter whose `submit` panics (traps the store) while the chain +//! head reads the poison sentinel `0xdead`, and accepts once it moves +//! on. The test drives recovery: the supervisor's sweep must +//! reinstantiate the adapter before a submit succeeds. Test-only. // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/modules/fixtures/fuel-bomb/src/lib.rs index 3e6b6b3b..6ab9e741 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/modules/fixtures/fuel-bomb/src/lib.rs @@ -1,13 +1,8 @@ //! # fuel-bomb (test fixture) //! -//! Deliberately exhausts the wasmtime fuel budget on every `on_event` -//! by running an unbounded counter loop. The wasmtime engine must -//! trap with `OutOfFuel`; the supervisor must catch the trap, mark -//! the module dead, and continue dispatching to other modules. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the M2 / M3 testnet -//! configs. +//! Exhausts the fuel budget on every `on_event` via an unbounded loop. +//! The engine traps with `OutOfFuel`; the supervisor must catch it, +//! mark the module dead, and keep dispatching others. Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/modules/fixtures/memory-bomb/src/lib.rs index 948b65e1..02426ab8 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/modules/fixtures/memory-bomb/src/lib.rs @@ -1,12 +1,9 @@ //! # memory-bomb (test fixture) //! -//! Deliberately allocates past the default 64 MiB per-module memory -//! cap on every `on_event`. The wasmtime `StoreLimits` reject the -//! linear-memory grow, the host traps the module, the supervisor -//! marks it dead, and other modules keep dispatching. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! Allocates past the default 64 MiB per-module memory cap on every +//! `on_event`. The `StoreLimits` reject the grow, the module traps, the +//! supervisor marks it dead, and other modules keep dispatching. +//! Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/modules/fixtures/panic-bomb/src/lib.rs index 56eaa348..8d9e823c 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/modules/fixtures/panic-bomb/src/lib.rs @@ -1,13 +1,10 @@ //! # panic-bomb (test fixture) //! //! Installs the nexum-sdk tracing facade (subscriber + panic hook) in -//! `init` and panics on every `on_event`. The hook writes the panic to -//! stderr and forwards it over the host logging call before the trap -//! reaches the supervisor, so one death leaves Stderr, HostInterface, -//! and Panic records on the run. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only. +//! `init` and panics on every `on_event`. The hook forwards the panic +//! to stderr and the host logging call before the trap reaches the +//! supervisor, so one death leaves Stderr, HostInterface, and Panic +//! records. Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] @@ -23,12 +20,6 @@ wit_bindgen::generate!({ use nexum::host::{logging, types}; /// Routes facade lines to the bound host logging import. -/// -/// Hand-rolled rather than generated by `bind_host_via_wit_bindgen!`: -/// this fixture binds only the minimal nexum world, so the generic -/// adapter would pull in unused chain/local-store impls and an unused -/// error converter (dead code under the `-D warnings` wasm build) for -/// the sole benefit of one log line. The sink below is the whole cost. struct HostLogSink; impl nexum_sdk::tracing::LogSink for HostLogSink { diff --git a/modules/fixtures/slow-host/src/lib.rs b/modules/fixtures/slow-host/src/lib.rs index 97b420be..7d3f405a 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/modules/fixtures/slow-host/src/lib.rs @@ -1,26 +1,12 @@ //! # slow-host (test fixture) //! -//! On every event issues a single `chain::request` host call and returns -//! `Ok`. The handler does no guest-side work of note; the point is the -//! host call itself. -//! -//! Fuel meters only guest wasm instructions and epoch interruption fires -//! only at wasm instruction boundaries, so neither can see, let alone -//! bound, time the guest spends suspended inside a host call. This fixture -//! makes that gap observable: the integration test wires the `chain` -//! capability to a mock provider that parks the first `request` far past a -//! short `event_deadline_secs` override. The guest suspends inside the host -//! call, the per-dispatch wall-clock deadline fires, and the supervisor -//! must drop the suspended call, mark the module dead, and reinstantiate it -//! on a fresh store. On the next dispatch the mock answers promptly, so the -//! same guest recovers and returns `Ok`. -//! -//! The result of the call is deliberately ignored: whether the request -//! resolves, errors, or is cut off, the handler returns `Ok(())`, so the -//! only thing that can end a dispatch early is the deadline under test. -//! -//! Not a production module. Lives under `modules/fixtures/` so it is -//! obviously test-only and never gets loaded by the testnet configs. +//! Issues one `chain::request` per event and returns `Ok` regardless of +//! its result. Fuel and epoch interruption only meter wasm instructions, +//! not time suspended inside a host call, so the test parks the first +//! `request` past a short `event_deadline_secs`: the wall-clock deadline +//! must fire, the supervisor drop the suspended call, mark the module +//! dead, and reinstantiate it. The next dispatch answers promptly and +//! recovers. Test-only. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] diff --git a/modules/twap-monitor/src/keeper.rs b/modules/twap-monitor/src/keeper.rs index bd79b94f..c718a2da 100644 --- a/modules/twap-monitor/src/keeper.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -1,20 +1,10 @@ -//! Pure logic for the twap-monitor keeper module. -//! -//! Every interaction with the world flows through a trait seam: the -//! `nexum_sdk::host` traits for chain and store access and the videre -//! [`VenueTransport`] under the typed [`CowClient`] for submission - -//! no direct calls to wit-bindgen-generated free functions live here. -//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the -//! `WitBindgenHost` adapter and the client over the module's own -//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the -//! same functions a `nexum_sdk_test::MockHost` and a scripted -//! transport. -//! -//! The module owns decode and evaluate only: log decoding into the -//! keeper watch set, and the `getTradeableOrderWithSignature` poll -//! behind [`Poller`]. Gate discipline, the `submitted:` -//! journal, submission through the venue registry, and retry dispatch live in -//! the shared composition (`composable_cow::run`). +//! Pure logic for the twap-monitor keeper: decode ComposableCoW +//! registration and removal logs into the watch set, and poll +//! `getTradeableOrderWithSignature` behind [`Poller`]. World access +//! flows through the `nexum_sdk::host` traits and the typed +//! [`CowClient`] over [`VenueTransport`]. Gate discipline, the +//! `submitted:` journal, and retry dispatch live in +//! `composable_cow::run`. use alloy_primitives::{Address, B256, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; @@ -42,19 +32,16 @@ mod abi { use alloy_sol_types::sol; sol! { - /// Wire-format mirror of `cowprotocol::ConditionalOrderParams`. sol! - /// cannot reference Rust types declared in another sol! block, but - /// the ABI is identical (same field types in the same order) so - /// the generated call selector matches the real contract. + /// Wire-format mirror of `cowprotocol::ConditionalOrderParams`; the + /// ABI matches so the generated selector matches the contract. struct Params { address handler; bytes32 salt; bytes staticInput; } - /// Selector source for `eth_call`. The successful return path - /// decodes into the canonical `cowprotocol::GPv2OrderData` - /// instead of duplicating the 12-field struct here. + /// Selector source for `eth_call`; the return decodes into + /// `cowprotocol::GPv2OrderData`. function getTradeableOrderWithSignature( address owner, Params params, @@ -64,13 +51,12 @@ mod abi { } } -/// Indexer entry: decode every ComposableCoW registration and removal -/// chain-log in a dispatch batch. A `ConditionalOrderCreated` persists -/// its watch stamped with the log's chain position; a v2 -/// `ConditionalOrderRemoved` drops the watch (with its gates) only -/// when it postdates that stamp. The two subscription streams merge in -/// arrival order, not chain order, so the stamp is what keeps a stale -/// removal from dropping a re-registered watch. +/// Decode every ComposableCoW registration and removal log in a +/// dispatch batch. A create persists a watch stamped with the log's +/// chain position; a removal drops the watch and its gates only when it +/// postdates that stamp. Streams merge in arrival order, not chain +/// order, so the stamp keeps a stale removal from dropping a +/// re-registered watch. pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { @@ -82,10 +68,9 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fa Ok(()) } -/// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition, submitting through the typed client onto the -/// venue seam. The block timestamp arrives in milliseconds; the tick carries -/// Unix seconds. +/// Run the keeper over every gate-ready watch through the shared +/// composition, submitting through the typed client. Block timestamp is +/// milliseconds; the tick carries Unix seconds. pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> where H: ChainHost + LocalStoreHost, @@ -122,8 +107,7 @@ impl LogPosition { const ROW_HEADER_LEN: usize = 17; /// Watch row payload: a position header ahead of the ABI-encoded -/// `ConditionalOrderParams`. The header pins where the indexed -/// `ConditionalOrderCreated` sits on chain. +/// `ConditionalOrderParams`, pinning where the create sits on chain. fn encode_row(indexed_at: Option, params: &[u8]) -> Vec { let mut row = Vec::with_capacity(ROW_HEADER_LEN + params.len()); match indexed_at { @@ -155,8 +139,8 @@ fn decode_row(row: &[u8]) -> Option<(Option, &[u8])> { Some((indexed_at, params)) } -/// Topic-0 gates before the ABI decode; the pin is parity-tested -/// against the `shepherd:cow/cow-events` package of record. +/// Topic-0 gates before the ABI decode; pin parity-tested against +/// `shepherd:cow/cow-events`. fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOrderParams)> { if log.topics().first() != Some(&ConditionalOrderCreated::SIGNATURE_HASH) { return None; @@ -165,9 +149,8 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// The watch set overwrites in place and the stamp keeps the latest -/// indexed position, so re-indexing (re-org replay, overlapping -/// subscription windows, cursor rewind) never ages the row. +/// Overwrites in place, keeping the latest indexed stamp, so +/// re-indexing (re-org replay, cursor rewind) never ages the row. fn persist_watch( host: &H, owner: Address, @@ -188,8 +171,8 @@ fn persist_watch( Ok(()) } -/// Topic-0 gates before the ABI decode; the pin is parity-tested -/// against the `shepherd:cow/cow-events` package of record. +/// Topic-0 gates before the ABI decode; pin parity-tested against +/// `shepherd:cow/cow-events`. fn decode_conditional_order_removed(log: &Log) -> Option<(Address, B256)> { if log.topics().first() != Some(&ConditionalOrderRemoved::SIGNATURE_HASH) { return None; @@ -198,15 +181,9 @@ fn decode_conditional_order_removed(log: &Log) -> Option<(Address, B256)> { Some((decoded.data.owner, decoded.data.singleOrderHash)) } -/// `singleOrderHash` is `keccak256(abi.encode(params))`, exactly the -/// hash the watch key carries. The removal lands only when it provably -/// postdates the watch's stamp: `remove(hash)` + `create(same params)` -/// in one call re-registers the same hash, and the earlier remove can -/// arrive after the later create, so an unprovable ordering keeps the -/// watch (a wrongly-kept watch self-heals through the poll's drop -/// path; a wrongly-dropped one is never polled again). A removal for -/// an order this keeper never indexed (or already dropped) replays -/// cleanly as a no-op. +/// Drops the watch only when the removal provably postdates its create +/// stamp; an unprovable ordering keeps the watch (self-heals via the +/// poll drop path). An unknown or already-dropped order is a no-op. fn remove_watch( host: &H, owner: Address, @@ -234,10 +211,9 @@ fn remove_watch( // ---- poll path ---- -/// TWAP conditional source: decode the stored row's -/// `ConditionalOrderParams` and evaluate -/// `getTradeableOrderWithSignature` on chain. A row this source cannot -/// decode polls again next block rather than tearing down the run. +/// TWAP conditional source: decode the stored row and evaluate +/// `getTradeableOrderWithSignature` on chain. An undecodable row polls +/// again next block rather than tearing down the run. struct TwapSource; impl Poller for TwapSource { @@ -324,10 +300,8 @@ fn poll_one( } /// Decode a successful `getTradeableOrderWithSignature` return into -/// `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 `None`. +/// `Verdict::Post`. The 1.x contract carries no next-poll hint, so +/// `next_poll_timestamp` is `None`. fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; Some(Verdict::Post { @@ -349,9 +323,6 @@ fn outcome_label(o: &Verdict) -> &'static str { } // ---- test-only seam mirrors ---- -// -// Thin views over the keeper / venue canon so the dispatch tests can -// seed and inspect the store in the exact shapes production writes. #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { @@ -400,9 +371,8 @@ mod tests { const SEPOLIA: u64 = 11_155_111; - /// Scripted [`VenueTransport`]: one submit outcome per queued entry, - /// every submit recorded. Quote, status, and cancel are off the - /// module's poll path. + /// Scripted [`VenueTransport`]: one submit outcome per queued entry. + /// Quote, status, and cancel are off the poll path. #[derive(Default)] struct MockVenue { outcomes: RefCell>>, @@ -456,14 +426,12 @@ mod tests { } } - /// Dispatch one block through `on_block` with the typed client over - /// the scripted transport. + /// Dispatch one block through `on_block` over the scripted transport. fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { on_block(host, &CowClient::with_transport(venue), block) } - /// `validTo` a given number of seconds from the wall clock, the - /// same saturating `now + seconds` the builder's `valid_for` applies. + /// `validTo` `seconds` from the wall clock, saturating. fn valid_to_in(seconds: u32) -> u32 { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -592,8 +560,7 @@ mod tests { LogPosition { block, index } } - /// Assemble a mined ComposableCoW log at `position` through the - /// same WIT-edge path the bind macro uses at runtime. + /// A mined ComposableCoW log at `position`. fn make_event_log(owner: Address, topic0: B256, data: &[u8], position: LogPosition) -> Log { let mut owner_topic = vec![0u8; 12]; owner_topic.extend_from_slice(owner.as_slice()); @@ -644,15 +611,14 @@ mod tests { eth_call_params(&COMPOSABLE_COW, &call.abi_encode()) } - /// JSON-encode a hex blob as the raw `result` field a JSON-RPC - /// response carries (a quoted hex string). + /// JSON-encode a hex blob as a JSON-RPC `result` field. fn quoted_hex(bytes: &[u8]) -> String { let hex = alloy_primitives::hex::encode_prefixed(bytes); serde_json::to_string(&hex).unwrap() } - /// Pre-seed a `watch:` row identical to what the indexer would - /// write for a create mined at block 1, index 0. + /// Pre-seed a `watch:` row as the indexer would for a create at + /// block 1, index 0. fn seed_watch(host: &MockHost, owner: Address, params: &ConditionalOrderParams) -> String { let encoded = params.abi_encode(); let key = watch_key(&owner, &keccak256(&encoded)); @@ -956,14 +922,9 @@ mod tests { ); } - /// Regression guard: when `getTradeableOrderWithSignature` - /// returns the same Ready tuple in consecutive poll-ticks (the - /// on-chain conditional order does not know shepherd already - /// posted it), the second tick must NOT submit again. Without the - /// guard the venue refuses the duplicate and a Warn fires for what - /// is in fact correct, finished work. The guard is the - /// `submitted:{intent_id}` short-circuit at the top of - /// `submit_ready`. + /// Guard: a repeated Ready tuple in consecutive ticks must not + /// re-submit; the `submitted:{intent_id}` short-circuit in + /// `submit_ready` prevents it. #[test] fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); @@ -1004,10 +965,8 @@ mod tests { ); } - /// A Ready order with a non-empty `appData` digest rides the intent - /// body verbatim: assembly into the orderbook wire shape is the - /// adapter's, so the keeper ships exactly the digest the chain - /// returned - watch-tower parity. + /// A Ready order's non-empty `appData` digest rides the intent body + /// verbatim; assembly into the orderbook wire shape is the adapter's. #[test] fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { let host = MockHost::new(); @@ -1104,9 +1063,8 @@ mod tests { }); } - /// The venue's throttle hint survives the venue seam: a rate-limited - /// refusal backs the watch off on the epoch clock instead of - /// hot-looping the submit every block. + /// A rate-limited refusal backs the watch off on the epoch clock + /// instead of hot-looping the submit. #[test] fn submit_rate_limited_backs_off_on_the_epoch_gate() { let host = MockHost::new(); @@ -1247,13 +1205,10 @@ mod tests { }); } - /// The supervisor builds its log filters from this manifest's - /// chain-log `event_signature` pins - /// (`nexum-runtime::supervisor` -> `build_alloy_filter`), so a - /// drift from a decoder topic-0 subscribes to one topic and decodes - /// another, and the module silently sees nothing. Compares the two - /// sets rather than testing membership, so a missing pin and a pin - /// the decoders cannot handle both fail too. + /// The supervisor builds log filters from this manifest's chain-log + /// `event_signature` pins, so a drift from a decoder topic-0 + /// subscribes to one topic and decodes another. Compares the two + /// sets, so a missing or unhandled pin fails too. #[test] fn manifest_topics_match_the_decoder_signature_hashes() { let manifest: toml::Value = diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 2360715e..3f24ebbe 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -2,19 +2,9 @@ //! //! Indexes `ComposableCoW.ConditionalOrderCreated` and v2 //! `ConditionalOrderRemoved` logs and polls each watched conditional -//! order on every block, submitting tranches to the CoW venue through -//! the venue registry as they go live. -//! -//! ## Module layout -//! -//! - `keeper.rs` holds the pure logic and unit tests against the -//! `nexum_sdk::host` trait seams and the videre `VenueTransport` -//! seam. It does not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the -//! macro derives the component world from `module.toml`, emits the -//! `WitBindgenHost` adapter, and dispatches each event variant to -//! `keeper` with the typed [`CowClient`] over the module's own -//! `videre:venue/client` import. +//! order on every block, submitting tranches to the CoW venue as they +//! go live. Pure logic lives in `keeper`; `lib.rs` is the +//! `#[videre_sdk::keeper]` glue. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 60d20588..da58399d 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -1,20 +1,11 @@ //! Anvil-side load generator for the runtime load test. //! //! Connects to an Anvil fork of Sepolia, impersonates the pinned test -//! EOA (no signer required: `anvil_impersonateAccount` skips -//! signature verification), and submits N `ComposableCoW.create(...)` -//! plus M `CoWSwapEthFlow.createOrder(...)` calls per new block. The -//! resulting `ConditionalOrderCreated` and `OrderPlacement` events are -//! what the twap-monitor and ethflow-watcher modules dispatch on. -//! -//! Knobs (`--help` for the full list): -//! - `--anvil ` WebSocket URL of the Anvil fork -//! - `--twap-per-block N` calls to ComposableCoW.create per block -//! - `--ethflow-per-block M` calls to CoWSwapEthFlow.createOrder per block -//! - `--duration ` wall-clock window the loop runs for -//! -//! Pinned identities: EOA, ComposableCoW, TWAP handler, CoWSwapEthFlow, -//! WETH9, COW token, Safe. These are constant across the Sepolia fork. +//! EOA, and submits N `ComposableCoW.create` plus M +//! `CoWSwapEthFlow.createOrder` calls per block; the resulting +//! `ConditionalOrderCreated` and `OrderPlacement` events are what +//! twap-monitor and ethflow-watcher dispatch on. `--help` lists the +//! knobs. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -97,20 +88,14 @@ struct Cli { #[arg(long, default_value_t = 5)] duration_min: u64, - /// Address whose state Anvil should impersonate when sending the - /// load-gen transactions. Defaults to the pinned Sepolia test EOA. - /// Ignored when `--parallel > 1` - synthetic per-worker EOAs are - /// used instead so the per-EOA nonce serialisation does not gate - /// throughput (the bottleneck the saturation 50x50 report - /// surfaced). + /// Address Anvil impersonates. Defaults to the pinned Sepolia test + /// EOA; ignored when `--parallel > 1`, which uses synthetic + /// per-worker EOAs so nonce serialisation does not gate throughput. #[arg(long, default_value_t = EOA)] eoa: Address, - /// Number of parallel workers. Each worker impersonates its own - /// synthetic EOA (`Address::from([i; 20])` where `i` is the - /// 1-based worker index), gets its own WS connection, runs its - /// own per-block submission loop. Total events per block = - /// `parallel * (twap_per_block + ethflow_per_block)`. + /// Parallel workers, each with its own synthetic EOA and WS + /// connection. Events per block = `parallel * (twap + ethflow)`. #[arg(long, default_value_t = 1)] parallel: u32, } @@ -363,10 +348,8 @@ fn salt_from_counter(n: u128) -> B256 { B256::from(bytes) } -/// Encode `ComposableCoW.create((handler, salt, staticInput), true)`. -/// The static input is the TWAP tuple from -/// `docs/operations/e2e-prep.md` §4.2 with `t0 = block_ts - 60` -/// so part 0 is Ready immediately. +/// Encode `ComposableCoW.create((handler, salt, staticInput), true)` +/// with `t0 = block_ts - 60` so part 0 is Ready immediately. fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { let static_input = ( WETH, @@ -393,12 +376,8 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { } /// Encode `CoWSwapEthFlow.createOrder(EthFlowOrder.Data)` with a sell -/// amount matched to the tx `value`. `appData` is the empty hash - a -/// digest every orderbook already knows, so hash-only submission -/// 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). +/// amount matched to the tx `value`. `appData` is the empty hash; +/// `validTo` is `u32::MAX` per the canonical EthFlow shape. fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 8e7450d5..897e1b46 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,23 +1,10 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the one endpoint the cow venue adapter hits on every order -//! submission: -//! -//! - `POST /api/v1/orders` - accepts any body, returns a synthetic -//! 56-byte OrderUid as a JSON-encoded hex string. Counts a request -//! for the operator report. -//! -//! Operator knobs (CLI): -//! - `--port` (default 9999) -//! - `--latency-ms` artificial latency injected into every response -//! - `--error-rate` fraction of `POST /api/v1/orders` responses that -//! return a recognised `ApiError` envelope; lets the load test -//! exercise the strategy's `Drop` / `TryNextBlock` paths. -//! -//! Not a faithful orderbook simulator - the load test cares about -//! shepherd's throughput when the orderbook responds quickly, not -//! about the orderbook's own behaviour. Real-orderbook fidelity comes -//! from running against the live testnet orderbook. +//! Serves `POST /api/v1/orders`: accepts any body, returns a synthetic +//! 56-byte OrderUid as a JSON hex string. CLI knobs `--port`, +//! `--latency-ms`, and `--error-rate` (fraction of responses returning +//! a recognised `ApiError` envelope, exercising the strategy's `Drop` / +//! `TryNextBlock` paths). Not a faithful simulator. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -51,11 +38,9 @@ struct Cli { #[arg(long, default_value_t = 0)] latency_ms: u64, - /// Fraction of POST /api/v1/orders responses that return a - /// recognised error envelope instead of a 201 success. 0.0 = all - /// success; 1.0 = all error. Errors cycle between - /// `InsufficientFee` (transient -> TryNextBlock) and - /// `InvalidSignature` (permanent -> Drop). + /// Fraction of `POST /api/v1/orders` responses returning an error + /// envelope (0.0 all success, 1.0 all error). Errors cycle + /// `InsufficientFee` (transient) and `InvalidSignature` (permanent). #[arg(long, default_value_t = 0.0)] error_rate: f64, } @@ -183,10 +168,7 @@ async fn post_orders(State(state): State>, body: String) -> impl I (StatusCode::CREATED, uid_hex).into_response() } -/// Tiny inline hex encoder - the mock does not depend on `alloy` to -/// keep its dependency surface minimal. (The engine uses -/// `alloy_primitives::hex::encode_prefixed` instead; that rule -/// applies to the engine, not to one-off test tooling.) +/// Inline hex encoder; keeps the mock's dependency surface minimal. fn hex_encode_inline(bytes: &[u8]) -> String { use std::fmt::Write as _; let mut s = String::with_capacity(bytes.len() * 2); From 9fa70266101a591bc963891ac0f46bbd884d9010 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:14:04 +0000 Subject: [PATCH 126/139] docs: tersen rustdoc in runtime-core (#618) --- crates/nexum-runtime/examples/embed.rs | 10 - crates/nexum-runtime/src/addons.rs | 41 ++-- crates/nexum-runtime/src/bindings.rs | 22 +-- crates/nexum-runtime/src/bootstrap.rs | 22 +-- crates/nexum-runtime/src/builder.rs | 145 +++++---------- crates/nexum-runtime/src/lib.rs | 12 +- crates/nexum-runtime/src/preset.rs | 36 ++-- .../src/runtime/dispatch_rate.rs | 7 +- .../nexum-runtime/src/runtime/event_loop.rs | 176 +++++------------- crates/nexum-runtime/src/runtime/limits.rs | 7 +- .../src/runtime/poison_policy.rs | 44 +---- .../src/runtime/restart_policy.rs | 29 +-- 12 files changed, 164 insertions(+), 387 deletions(-) diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index feba0440..75c26dff 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -1,20 +1,10 @@ //! Embed the runtime without the CLI: point the builder at a loaded config //! and a [`Runtime`] preset, then launch and run until shutdown. //! -//! [`CoreRuntime`] is the domain-free preset: it bundles the reference core -//! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability is added by -//! writing a preset that names its extension builder in the `Ext` slot and -//! returns its linker extensions, or by dropping to the explicit -//! `with_components` builder path. The returned [`RuntimeHandle`] carries the -//! in-process log read side; clone it to keep reading after `wait` consumes -//! the handle. -//! //! Build the example module first (`just build-module`), then run //! `cargo run -p nexum-runtime --example embed` from the repo root. //! //! [`Runtime`]: nexum_runtime::preset::Runtime -//! [`RuntimeHandle`]: nexum_runtime::builder::RuntimeHandle use nexum_runtime::builder::RuntimeBuilder; use nexum_runtime::engine_config::{EngineConfig, ModuleEntry}; diff --git a/crates/nexum-runtime/src/addons.rs b/crates/nexum-runtime/src/addons.rs index 114d2357..165ae019 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/crates/nexum-runtime/src/addons.rs @@ -1,30 +1,19 @@ -//! Cross-cutting runtime add-ons: process-wide facilities that attach to -//! the launch path without the core knowing their concrete type. -//! -//! An add-on installs a facility from the resolved config (a metrics -//! recorder today) and hands back a handle the launcher keeps alive for the -//! run. The composition root chooses the set, so an embedder omits or -//! replaces any of them instead of inheriting a fixed install. -//! -//! A future control-surface add-on (an admin or RPC socket) slots in beside -//! [`PrometheusAddOn`]: implement [`RuntimeAddOn`], read its own section -//! from [`AddOnsContext`], and add it to the launcher's list at the -//! composition root. +//! Cross-cutting runtime add-ons: process-wide facilities that attach to the +//! launch path without the core knowing their concrete type. An add-on +//! installs a facility from the resolved config and returns a handle the +//! launcher keeps alive for the run. use tracing::info; use crate::engine_config::MetricsSection; -/// Inputs an add-on reads at install time. Grows as add-ons are added: a -/// future control-surface add-on carries its own resolved section here. +/// Inputs an add-on reads at install time. pub struct AddOnsContext<'a> { /// Resolved `[engine.metrics]` config. pub metrics: &'a MetricsSection, } -/// A live add-on installation, retained by the launcher for the length of -/// the run. Names the add-on for diagnostics; an add-on that needs RAII -/// teardown grows a resource slot here when one arrives. +/// A live add-on installation, retained by the launcher for the run. pub struct AddOnHandle { /// The add-on's name, for diagnostics. pub name: &'static str, @@ -37,23 +26,18 @@ impl AddOnHandle { } } -/// A process-wide facility attached to the launch path. `install` reads the -/// resolved config from `ctx` and returns a handle the launcher retains. +/// A process-wide facility attached to the launch path. pub trait RuntimeAddOn { /// Install the facility, returning its live handle. fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result; } -/// An owned, ordered add-on set gathered behind one value. A preset or -/// composition root returns this so a heterogeneous set travels together; -/// the launcher borrows each element to install it. +/// An owned, ordered add-on set. pub type AddOns = Vec>; -/// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` -/// it binds an HTTP listener serving `/metrics`; otherwise it installs the -/// recorder alone so `metrics::counter!` call sites stay live but no port -/// opens. The same binary thus runs in CI without binding a port and in -/// production with observability by flipping one config flag. +/// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` it +/// binds an HTTP listener serving `/metrics`; otherwise it installs the +/// recorder alone so `metrics::counter!` call sites stay live but no port opens. pub struct PrometheusAddOn; impl RuntimeAddOn for PrometheusAddOn { @@ -86,8 +70,7 @@ mod tests { use super::*; use crate::engine_config::MetricsSection; - /// An enabled exporter with an unparseable bind address surfaces the - /// wrapped error at install, before any recorder is touched. + /// An enabled exporter with an unparseable bind address fails at install. #[test] fn prometheus_add_on_rejects_an_invalid_bind_addr() { let metrics = MetricsSection { diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 46fb2424..5789985f 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -1,20 +1,12 @@ //! WIT bindings generated by `wasmtime::component::bindgen!`. //! -//! The core host binds the `nexum:host/event-module` world: the six core -//! primitives. Outbound HTTP is not a `nexum:host` interface: it is -//! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions bind their own world and wire themselves in at the -//! composition root; they are not part of this core surface. -//! -//! Every `Host` trait impl in `crate::host::impls` consumes types -//! generated here. -//! -//! `nexum:host` is a leaf package: the host `event` variant carries a -//! status transition as opaque bytes, so the core world resolves against -//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared -//! interfaces here with `with`, so the `Host` impls and the `fault` type -//! its components see are the very ones the core host constructs. -//! `PartialEq` is derived so extension services can compare event payloads. +//! Binds the `nexum:host/event-module` world (the six core primitives). +//! Outbound HTTP is wasi:http, linked separately; clocks are ambient +//! wasi:clocks. `nexum:host` is a leaf package: the host `event` variant +//! carries a status transition as opaque bytes. An extension remaps onto these +//! shared interfaces with `with`, so the `Host` impls and `fault` type its +//! components see are the ones the core host constructs. `PartialEq` is derived +//! so extension services can compare event payloads. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 9ae57a94..88d70785 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -1,11 +1,8 @@ -//! Generic launch entry point: assemble the [`AssembledRuntime`] from -//! pre-built backends and run it until shutdown. +//! Generic launch entry point: assemble an [`AssembledRuntime`] from pre-built +//! backends and run it until shutdown. //! -//! Parameterised over the [`RuntimeTypes`] lattice. The composition root -//! builds the concrete [`Components`] and the extension list (including any -//! domain extension) and hands them here; this thin wrapper -//! forwards to the [`builder`](crate::builder) launcher and blocks until the -//! event loop returns. A launcher that wants the +//! Forwards to the [`builder`](crate::builder) launcher and blocks until the +//! event loop returns. A launcher wanting the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives //! [`LaunchRuntime`] directly. @@ -22,14 +19,9 @@ use crate::host::extension::Extension; /// Launch the runtime from a loaded config and run until shutdown. /// -/// `components` carries the shared backends threaded into every module -/// store; `extensions` carries the linker hooks and capability namespaces -/// assembled at the composition root. Both must agree: a module importing -/// an extension interface boots only if that extension is present in both. -/// -/// `add_ons` carries the cross-cutting facilities (the Prometheus exporter -/// today) installed before the engine boots; the composition root picks the -/// set so an embedder omits or replaces any of them. +/// `components` and `extensions` must agree: a module importing an extension +/// interface boots only if that extension is present in both. `add_ons` are the +/// cross-cutting facilities installed before the engine boots. pub async fn run( engine_cfg: &EngineConfig, wasm: Option<&Path>, diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 0d551d00..d01b1674 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -1,21 +1,12 @@ //! Type-state runtime builder and the imperative launcher it drives. //! -//! [`RuntimeBuilder`] accumulates the assembly (config, the [`RuntimeTypes`] -//! lattice, extensions, the component builders, add-ons) through a type-state -//! chain; [`ReadyBuilder::launch`] opens the backends and hands off to -//! [`LaunchRuntime::launch`]. The launcher runs one imperative sequence - -//! install add-ons, build the engine and linker, boot the supervisor, open -//! subscriptions through the [`TaskManager`]'s executor, spawn the event -//! loop - and returns a [`RuntimeHandle`] owning the manager and the -//! running tasks. -//! -//! The engine binaries reach this through the `nexum-launch` preset run; -//! an embedder holding pre-built backends constructs an [`AssembledRuntime`] -//! and calls [`LaunchRuntime::launch`] directly. For the common case, -//! [`RuntimeBuilder::runtime`] binds a [`Runtime`] preset that bundles the -//! lattice, component builders, extensions, and add-ons in one call; -//! [`RuntimeBuilder::with_runtime`] binds a preset value carrying pre-built -//! backends. +//! [`RuntimeBuilder`] accumulates the assembly (config, lattice, extensions, +//! component builders, add-ons) through a type-state chain; +//! [`ReadyBuilder::launch`] opens the backends and hands off to +//! [`LaunchRuntime::launch`], which installs add-ons, builds the engine and +//! linker, boots the supervisor, opens subscriptions, spawns the event loop, +//! and returns a [`RuntimeHandle`]. [`RuntimeBuilder::runtime`] binds a +//! [`Runtime`] preset for the common case. use std::future::{Future, IntoFuture}; use std::marker::PhantomData; @@ -39,8 +30,7 @@ use crate::runtime::event_loop; pub use crate::supervisor::WasiClockOverride; use crate::supervisor::{self, Supervisor}; -/// Ambient inputs the imperative launcher reads: the task manager every -/// runtime task spawns through, and the loaded config. +/// Ambient inputs the launcher reads. pub struct LaunchContext<'a> { /// Owns task spawning and graceful shutdown for the run. pub tasks: TaskManager, @@ -48,12 +38,10 @@ pub struct LaunchContext<'a> { pub config: &'a EngineConfig, } -/// Upper bound on how long the top level blocks for the event loop's final -/// durable flush after shutdown is signalled before forcing exit. +/// Upper bound on the final durable-flush drain before shutdown forces exit. const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(10); -/// A running runtime: the event-loop task, the task manager, and add-on -/// handles. [`shutdown`](Self::shutdown) or dropping fires shutdown; +/// A running runtime. [`shutdown`](Self::shutdown) or dropping fires shutdown; /// [`wait`](Self::wait) blocks on the bounded drain. pub struct RuntimeHandle { event_loop: TaskHandle, @@ -76,9 +64,8 @@ impl RuntimeHandle { } /// Block until the loop stops (on its own, on shutdown, or on a critical - /// task ending), bounding the final durable flush; a drain past the - /// timeout forces exit. A `None` join reason means the task panicked or - /// was aborted. + /// task ending), bounding the final flush; a drain past the timeout forces + /// exit. pub async fn wait(self) -> anyhow::Result<()> { let RuntimeHandle { event_loop, @@ -374,15 +361,14 @@ impl<'a> RuntimeBuilder<'a> { } } - /// Bind a [`Runtime`] preset by marker. Sugar over - /// [`with_runtime`](Self::with_runtime) for a `Default` preset: an - /// embedder writes `RuntimeBuilder::new(cfg).runtime::().launch()`. + /// Bind a `Default` [`Runtime`] preset by marker; sugar over + /// [`with_runtime`](Self::with_runtime). pub fn runtime(self) -> PresetBuilder<'a, R> { self.with_runtime(R::default()) } - /// Bind a [`Runtime`] preset by value, so a preset can carry pre-built - /// backends and extensions into the launch. + /// Bind a [`Runtime`] preset by value, carrying pre-built backends into + /// the launch. pub fn with_runtime(self, preset: R) -> PresetBuilder<'a, R> { PresetBuilder { config: self.config, @@ -395,10 +381,8 @@ impl<'a> RuntimeBuilder<'a> { } } -/// Terminal stage of the preset shortcut: the [`Runtime`] preset supplies the -/// lattice, the component builders, its extensions, and the add-on set, -/// leaving only the optional extension hooks and module source before -/// [`launch`](Self::launch). +/// Terminal stage of the preset shortcut, leaving only optional extension +/// hooks and the module source before [`launch`](Self::launch). pub struct PresetBuilder<'a, R: Runtime> { config: &'a EngineConfig, preset: R, @@ -426,19 +410,16 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. + /// Override the per-store WASI wall and monotonic clocks, including stores + /// rebuilt on restart. Omitting it leaves the ambient host clocks. pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { self.clocks = Some(clocks); self } - /// Override the preset's component builders before launch. `map` receives - /// the preset's [`ComponentsBuilder`] and returns the builders to open, so - /// an embedder swaps one seam (e.g. logs or store) while keeping the rest; - /// the preset's extensions and add-ons carry through unchanged. The preset - /// path's mirror of [`TypedBuilder::with_components`]. + /// Override the preset's component builders before launch; `map` swaps one + /// seam while the preset's extensions and add-ons carry through. Mirror of + /// [`TypedBuilder::with_components`]. pub fn with_components( self, map: impl FnOnce( @@ -462,10 +443,8 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } } - /// Open the preset's backends and launch. Builds the [`Components`] bundle - /// from the preset's component builders, gathers the preset's extensions - /// (appended ones after), installs the preset's add-ons, then drives - /// [`LaunchRuntime::launch`] with a fresh [`TaskManager`]. + /// Open the preset's backends and launch, driving [`LaunchRuntime::launch`] + /// with a fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -504,8 +483,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { } /// A preset with its component builders overridden through -/// [`PresetBuilder::with_components`]: the preset's extensions and add-ons are -/// already gathered, leaving only [`launch`](Self::launch). +/// [`PresetBuilder::with_components`], leaving only [`launch`](Self::launch). pub struct PresetComponentsBuilder<'a, T: RuntimeTypes, C, S, E, L> { config: &'a EngineConfig, extensions: Vec>>, @@ -585,9 +563,8 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { self } - /// Override the per-store WASI wall and monotonic clocks. Every module - /// store, including the ones rebuilt on restart, reads these instead of - /// the ambient host clocks. Omitting it is behaviour-neutral. + /// Override the per-store WASI wall and monotonic clocks, including stores + /// rebuilt on restart. Omitting it leaves the ambient host clocks. pub fn with_wasi_clocks(mut self, clocks: WasiClockOverride) -> Self { self.clocks = Some(clocks); self @@ -659,9 +636,8 @@ where E: ComponentBuilder, L: ComponentBuilder, { - /// Open the backends and launch. Builds the [`Components`] bundle from the - /// bound builders, then drives [`LaunchRuntime::launch`] with a fresh - /// [`TaskManager`]. + /// Open the backends and launch, driving [`LaunchRuntime::launch`] with a + /// fresh [`TaskManager`]. pub async fn launch(self) -> anyhow::Result { let tasks = TaskManager::new(); let executor = tasks.executor(); @@ -704,11 +680,8 @@ mod tests { use crate::test_utils::Prebuilt; use wasmtime::component::Linker; - /// The preset shortcut is exercised at runtime, not just compiled: the - /// component builders open the backends, the add-ons install, and the - /// launch reaches the supervisor boot, which bails because the default - /// config declares no modules. Locks the sugar path so a builder-chain - /// refactor cannot silently break it. + /// The preset shortcut reaches the supervisor boot, which bails on the + /// default config's empty module set. #[tokio::test] async fn preset_launch_runs_the_build_path_then_bails_without_modules() { let dir = tempfile::tempdir().expect("tempdir"); @@ -726,8 +699,7 @@ mod tests { assert!(err.to_string().contains("no modules to run"), "{err}"); } - /// Counts linker hook runs, so a test observes an extension reaching the - /// launch's linker build. + /// Counts linker hook runs. struct CountingExt { namespace: &'static str, prefix: &'static str, @@ -781,9 +753,7 @@ mod tests { } } - /// The preset's own extensions and the appended ones both reach the - /// launch's linker build, each linked exactly once, before the boot - /// bails on the empty module set. + /// Preset extensions and appended extensions each link exactly once. #[tokio::test] async fn preset_extensions_and_appended_extensions_both_link() { let dir = tempfile::tempdir().expect("tempdir"); @@ -845,8 +815,7 @@ mod tests { } } - /// `components(self)` hands a pre-built instance through the preset seam: - /// the built bundle carries the exact pipeline the preset owned. + /// A preset hands a pre-built pipeline through into the built bundle. #[tokio::test] async fn preset_hands_over_a_prebuilt_backend() { let dir = tempfile::tempdir().expect("tempdir"); @@ -874,9 +843,8 @@ mod tests { ); } - /// A core-lattice preset with no add-ons, so the `with_components` tests - /// avoid the process-global Prometheus recorder the `CoreRuntime` preset - /// installs (only one such install succeeds per process). + /// A core-lattice preset with no add-ons, avoiding the process-global + /// Prometheus recorder (only one install succeeds per process). struct NoAddOnCore; impl crate::sealed::SealedRuntime for NoAddOnCore {} @@ -897,8 +865,7 @@ mod tests { } } - /// Counts builds, so a test observes an overridden seam reaching the - /// launch's component build. + /// Counts builds. struct CountingLogsBuilder(Arc); impl ComponentBuilder for CountingLogsBuilder { @@ -909,9 +876,8 @@ mod tests { } } - /// `with_components` overrides a seam on the preset path: the substituted - /// logs builder drives the launch's component build (once), which then - /// bails on the empty module set. Locks the preset escape hatch. + /// `with_components` overrides a seam: the substituted logs builder runs + /// once, then the launch bails on the empty module set. #[tokio::test] async fn preset_with_components_overrides_a_seam() { let dir = tempfile::tempdir().expect("tempdir"); @@ -937,10 +903,8 @@ mod tests { ); } - /// Full preset-path launch with the logs seam overridden through - /// `with_components`: the run reads the substituted pipeline and the - /// trigger-to-wait handshake stops it. Skips when the module fixture is - /// not built (`just build-module`). + /// Full preset-path launch with an overridden logs seam; skips when the + /// module fixture is not built (`just build-module`). #[tokio::test] async fn e2e_preset_with_components_launches_through_overridden_logs() { let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -980,9 +944,7 @@ mod tests { handle.wait().await.expect("clean shutdown"); } - /// when every configured module fails `init`, launch must - /// abort with an operator-facing error instead of idling behind an - /// empty event loop. + /// Every module failing `init` aborts launch instead of idling. #[tokio::test] async fn launch_bails_when_all_modules_fail_init() { let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -1046,9 +1008,7 @@ every_n_blocks = "1" assert!(err.to_string().contains("failed initialisation"), "{err}"); } - /// The add-on set installs before the supervisor boots: a stub add-on's - /// `install` runs exactly once even though the launch bails on the - /// no-modules boot that follows. + /// Add-ons install before the supervisor boots, exactly once. #[tokio::test] async fn assembled_runtime_installs_add_ons_before_boot() { struct CountingAddOn(Arc); @@ -1104,10 +1064,8 @@ every_n_blocks = "1" ); } - /// Full builder-path launch against the pre-built example module: the - /// handle exposes the shared log pipeline and the trigger-to-wait - /// handshake stops the run. Skips when the module fixture is not built - /// (`just build-module`). + /// Full builder-path launch against the example module; skips when the + /// fixture is not built (`just build-module`). #[tokio::test] async fn e2e_builder_launch_exposes_logs_and_stops_on_shutdown() { let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -1178,8 +1136,8 @@ every_n_blocks = "1" .expect("clean completion resolves Ok"); } - /// Firing the shutdown trigger drives the event-loop task to completion - /// and `wait` returns once the graceful guard releases. + /// Firing the shutdown trigger drives the loop to completion and `wait` + /// returns. #[tokio::test] async fn runtime_handle_shutdown_trigger_drives_wait_to_return() { let tasks = TaskManager::new(); @@ -1192,9 +1150,7 @@ every_n_blocks = "1" handle.wait().await.expect("wait returns after the trigger"); } - /// An event-loop task that stops abnormally (here: aborted, the same - /// join outcome a panic produces) surfaces the wrapped error from - /// `wait` instead of masking it as a clean stop. + /// An abnormally-stopped event loop surfaces an error from `wait`. #[tokio::test] async fn runtime_handle_wait_is_err_on_abnormal_stop() { let tasks = TaskManager::new(); @@ -1210,8 +1166,7 @@ every_n_blocks = "1" assert!(err.to_string().contains("terminated abnormally"), "{err}"); } - /// dropping the handle without `wait` fires the shutdown signal, - /// so the detached event loop winds down and drains rather than leaking. + /// Dropping the handle without `wait` still drains the event loop. #[tokio::test] async fn dropping_handle_without_wait_drains_the_event_loop() { let tasks = TaskManager::new(); diff --git a/crates/nexum-runtime/src/lib.rs b/crates/nexum-runtime/src/lib.rs index 4a29dd47..22fef9e7 100644 --- a/crates/nexum-runtime/src/lib.rs +++ b/crates/nexum-runtime/src/lib.rs @@ -1,11 +1,9 @@ -//! Nexum runtime: a wasmtime-based host for WASM Component Model -//! modules, usable as an embeddable library. The bundled binary is a -//! thin consumer of the same public surface. +//! Wasmtime-based host for WASM Component Model modules, embeddable as a +//! library; the bundled binary is a thin consumer of the same surface. //! -//! Zero-leak charter: this crate is settlement-domain-agnostic. It -//! carries no domain symbol or WIT reference, `nexum:host` stays a -//! leaf WIT package, and no crate edge reaches a domain crate. The -//! zero-leak script under `scripts/` enforces this in CI. +//! Settlement-domain-agnostic: no domain symbol or WIT reference, `nexum:host` +//! stays a leaf WIT package, no crate edge reaches a domain crate. Enforced in +//! CI by the zero-leak script under `scripts/`. #![cfg_attr(not(test), warn(unused_crate_dependencies))] diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 5a0b811c..1296d3d4 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -1,13 +1,10 @@ -//! Runtime presets: a preset names a lattice, its component builders, its -//! extensions, and its add-on set as one bundle, so an embedder launches with -//! `RuntimeBuilder::new(cfg).runtime::().launch()` instead of naming -//! each seam. A preset carrying pre-built backends or non-static extensions -//! binds by value through +//! Runtime presets: one preset bundles a lattice, its component builders, +//! extensions, and add-ons, so an embedder launches with +//! `RuntimeBuilder::new(cfg).runtime::().launch()`. A preset carrying +//! pre-built backends or non-static extensions binds by value through //! [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime). -//! [`CoreRuntime`] is the domain-free default: the reference core backends -//! (a chain provider pool and a local redb store, no extension payload) with -//! the Prometheus add-on. A domain assembly ships its own preset naming its -//! extension builder in the `Ext` slot and returning its linker extensions. +//! [`CoreRuntime`] is the domain-free default: a chain provider pool and a +//! local redb store, no extension payload, with the Prometheus add-on. use std::sync::Arc; @@ -22,14 +19,8 @@ use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the -/// component builders, extensions, and add-ons the launcher needs, gathered -/// behind one name. -/// [`RuntimeBuilder::runtime`](crate::builder::RuntimeBuilder::runtime) binds -/// a `Default` marker; -/// [`RuntimeBuilder::with_runtime`](crate::builder::RuntimeBuilder::with_runtime) -/// binds a value, so a preset can hand back already-built backends through a -/// pass-through builder such as `Prebuilt`. +/// A bundled runtime assembly: the [`RuntimeTypes`] lattice plus the component +/// builders, extensions, and add-ons the launcher needs. /// /// Sealed: a preset opts in by also implementing the sealing marker. pub trait Runtime: crate::sealed::SealedRuntime { @@ -44,7 +35,7 @@ pub trait Runtime: crate::sealed::SealedRuntime { /// Builds the shared [`LogPipeline`]. type LogsBuilder: ComponentBuilder; - /// The component builders that open the backends at launch. Consumes the + /// Component builders that open the backends at launch; consumes the /// preset, so a value-bound preset hands over owned, pre-built backends. fn components( self, @@ -58,8 +49,7 @@ pub trait Runtime: crate::sealed::SealedRuntime { /// The cross-cutting add-ons installed before the engine boots. fn add_ons(&self) -> AddOns; - /// The extensions the preset launches with, derived from the loaded - /// config so an extension can carry config-resolved policy. None by + /// Extensions the preset launches with, derived from config. Empty by /// default; /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) /// appends on top. @@ -69,9 +59,9 @@ pub trait Runtime: crate::sealed::SealedRuntime { } } -/// The domain-free default preset: the reference core backends (a chain -/// provider pool and a local redb store, no extension payload) with the -/// Prometheus add-on. Doubles as its own [`RuntimeTypes`] lattice. +/// The domain-free default preset: a chain provider pool and a local redb +/// store, no extension payload, with the Prometheus add-on. Doubles as its own +/// [`RuntimeTypes`] lattice. #[derive(Debug, Clone, Copy, Default)] pub struct CoreRuntime; diff --git a/crates/nexum-runtime/src/runtime/dispatch_rate.rs b/crates/nexum-runtime/src/runtime/dispatch_rate.rs index 23a9853d..d7b1a862 100644 --- a/crates/nexum-runtime/src/runtime/dispatch_rate.rs +++ b/crates/nexum-runtime/src/runtime/dispatch_rate.rs @@ -55,8 +55,8 @@ impl TokenBucket { } } - /// Refill for elapsed time, then consume one token. `true` = allowed, - /// `false` = over-rate. `now` is injected to stay pure and testable. + /// Refill for elapsed time, then consume one token; `true` allowed, + /// `false` over-rate. `now` is injected. pub fn try_acquire(&mut self, now: Instant) -> bool { let capacity = f64::from(self.policy.capacity); let elapsed = now @@ -129,8 +129,7 @@ mod tests { ); } - /// The acceptance criterion at the policy layer: a flooding source is - /// throttled while a second, independent source keeps being served. + /// A flooding source is throttled while an independent source is served. #[test] fn one_flooding_bucket_does_not_starve_another() { let now = Instant::now(); diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index 265e2885..ef884bbe 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -1,32 +1,15 @@ -//! Open live chain event sources and dispatch their events to the -//! supervisor until a shutdown signal arrives. Blocks come from -//! `eth_subscribe(newHeads)` (WS); chain-logs come from alloy's -//! canonical `eth_getLogs` block-range poller (HTTP or WS), which -//! recovers events across a reconnect by re-querying the gap instead -//! of dropping them. +//! Open live chain event sources and dispatch their events to the supervisor +//! until shutdown. Blocks come from `eth_subscribe(newHeads)` (WS); chain-logs +//! from an `eth_getLogs` block-range poller (HTTP or WS) that recovers events +//! across a reconnect by re-querying the gap rather than dropping them. //! -//! ## Per-stream reconnect with exponential backoff -//! -//! `open_block_streams` / `open_chain_log_streams` no longer return a -//! `Vec` that ends on the first drop. They each spawn one -//! reconnect-aware task per `(chain_id)` or `(module, chain_id, -//! filter)` tuple. The task: -//! -//! 1. Opens the block subscription / log poller via the provider pool. -//! 2. Pumps items to an mpsc channel until the underlying stream ends -//! (a WebSocket drop for blocks, or a terminal poller error for -//! logs - a hard RPC failure or a reorg past retained history). -//! 3. Logs the end + waits `restart_policy::backoff_for(attempt)` -//! (1s -> 2s -> ... cap 5min). -//! 4. Reopens. On the first event after a reopen, attempt resets -//! if the stream has been healthy for `HEALTHY_WINDOW`. -//! -//! The event loop reads the receiver as a regular `Stream`. The -//! reconnect tasks live for the lifetime of the engine; they exit -//! cleanly with [`TaskExit::ReceiverGone`] when their channel receiver -//! is dropped (which happens when `run` returns). They are spawned via -//! a [`TaskExecutor`] and their handles collected into a [`TaskSet`] -//! the loop drains on shutdown. +//! `open_block_streams` and `open_chain_log_streams` each spawn one +//! reconnect-aware task per subscription: it opens the stream, pumps items to +//! an mpsc channel, and on drop waits `restart_policy::backoff_for` before +//! reopening, resetting the backoff once the stream has been healthy for +//! `HEALTHY_WINDOW`. The tasks exit with [`TaskExit::ReceiverGone`] when `run` +//! drops the receivers; their handles collect into a [`TaskSet`] the loop +//! drains on shutdown. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -46,53 +29,31 @@ use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; -/// Errors carried by the tagged block / chain-log streams that the -/// supervisor consumes. Library-side code keeps `anyhow::Error` out -/// of long-lived stream item types per the rust idiomatic rubric. +/// Errors carried by the tagged block and chain-log streams. #[derive(Debug, Error)] #[non_exhaustive] pub enum StreamError { - /// Underlying provider / transport failure while opening or - /// pumping the subscription. + /// Provider or transport failure opening or pumping the subscription. #[error(transparent)] Provider(#[from] ProviderError), } -/// Time the wrapper stream must observe uninterrupted events before -/// the backoff counter resets to 0. Long enough that a brief but -/// real connection blip does not silently undo the doubling, short -/// enough that a healthy node reverts to fast retries on the next -/// drop. +/// Uninterrupted-event duration before the backoff counter resets to 0. const HEALTHY_WINDOW: Duration = Duration::from_secs(60); -/// Time without any block event that we treat as a gap worth a -/// positive recovery log line. Sepolia and Ethereum -/// mainnet both produce blocks reliably every ~12 s, so a silence -/// longer than this is either a transport-layer reconnect that alloy -/// handled internally (no `stream ended` reached the engine, hence -/// no `subscription reopened` log fires) or an upstream RPC stall. -/// Either way, the soak operator wants a positive log line when -/// blocks resume - otherwise an `alloy_transport_ws::native` ERROR -/// followed by silence looks identical to a hung engine. +/// Silence between block events beyond which the next event logs a gap-closed +/// line, surfacing an alloy-internal transport reconnect that produced no +/// `stream ended` event. const BLOCK_GAP_LOG_THRESHOLD: Duration = Duration::from_secs(60); -/// Channel buffer for the reconnect tasks. Each chain / module -/// subscription gets its own task -> channel pair; buffer is small -/// because the event loop drains in real time. +/// Channel buffer for each reconnect task. const RECONNECT_CHANNEL_BUF: usize = 64; -/// Gap size (blocks) at or above which a re-open logs a large-backfill -/// notice. Purely informational - nothing is ever skipped. +/// Block-gap size at or above which a re-open logs a large-backfill notice. const LARGE_GAP_LOG_THRESHOLD: u64 = 1_000; -/// Per-chain block subscriptions, one reconnect-aware task per -/// chain id. Tasks are spawned via `executor` and their handles pushed -/// into `tasks` so the caller can drive graceful shutdown (the engine -/// drains the set after closing its receivers - the tasks exit cleanly -/// when the receiver drops). -/// -/// Not `async`: the openers only spawn, they never await, so the caller -/// gets the tagged streams synchronously. +/// Open one reconnect-aware block-subscription task per chain, spawned via +/// `executor` with handles pushed into `tasks` for graceful shutdown. pub fn open_block_streams( pool: &C, chains: &[Chain], @@ -115,10 +76,8 @@ where streams } -/// Per-module chain-log subscriptions. Each entry gets its own reconnect- -/// aware task tagged with the owning module name + chain id. Tasks -/// are spawned via `executor` and pushed into `tasks` (see -/// [`open_block_streams`]). +/// Open one reconnect-aware chain-log task per subscription; see +/// [`open_block_streams`]. pub fn open_chain_log_streams( pool: &C, subs: Vec, @@ -148,9 +107,7 @@ where streams } -/// Wrap an `mpsc::Receiver` as a `Stream` using -/// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just -/// for `ReceiverStream`. +/// Wrap an `mpsc::Receiver` as a `Stream`. fn receiver_stream( rx: mpsc::Receiver, ) -> impl futures::Stream + Send { @@ -159,10 +116,8 @@ fn receiver_stream( }) } -/// Reconnect-aware loop for a single chain's block subscription. -/// Holds `(pool, chain_id)` and re-opens the underlying alloy -/// `eth_subscribe` stream with exponential backoff after every drop -/// or transport error. +/// Reconnect-aware loop for one chain's block subscription: re-opens the +/// `eth_subscribe` stream with exponential backoff after every drop or error. async fn reconnecting_block_task( pool: C, chain: Chain, @@ -247,28 +202,19 @@ where /// Per-subscription resume and backfill knobs for a chain-log task. struct ChainLogResume { - /// Durable cursor key, `Some` for a `resume` subscription; the block - /// under it seeds `initial_cursor`. + /// Durable cursor key; `Some` for a `resume` subscription. cursor_key: Option>, /// Persisted resume block read at boot; the first open starts here. initial_cursor: Option, - /// Opt-in cap (in blocks) on how far back the poller backfills; `None` - /// backfills the whole gap. + /// Opt-in cap in blocks on backfill depth; `None` backfills the whole gap. max_lookback: Option, } -/// Poller-backed loop for a single (module, chain) chain-log -/// subscription. Instead of `eth_subscribe(logs)` - which silently -/// drops events emitted during a WebSocket reconnect - it drives -/// alloy's canonical `eth_getLogs` block-range poller. The poller -/// reconciles reorgs and re-queries any gap internally, so no manual -/// backfill or dedup is needed here. A hard RPC error (after the -/// transport's own retries), or a reorg deeper than the poller's -/// retained history, ends the poller stream; this loop then re-opens -/// from the block after the last one it delivered and backfills the -/// entire missed range (nothing skipped) with exponential backoff - -/// unless the subscription set `max_lookback`, which bounds how far back -/// the backfill reaches. +/// Poller-backed loop for one (module, chain) chain-log subscription. Drives +/// the `eth_getLogs` block-range poller, which reconciles reorgs and re-queries +/// gaps internally. On a terminal poller error it re-opens from the block after +/// the last delivered one and backfills the whole missed range, bounded only by +/// `max_lookback` if set. async fn reconnecting_chain_log_task( pool: C, module: String, @@ -451,28 +397,19 @@ pub type TaggedBlockStream = std::pin::Pin< + Send, >, >; -/// One item on a tagged chain-log stream: `(module, chain, log, -/// cursor_key)` or a stream error. `cursor_key` is `Some` for a `resume` -/// subscription (constant per subscription; `Arc` for a cheap per-log -/// clone) and threads the durable cursor key through to the dispatch site. +/// One tagged chain-log item: `(module, chain, log, cursor_key)` or a stream +/// error. `cursor_key` is `Some` for a `resume` subscription and threads the +/// durable cursor key to the dispatch site. pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; /// Drive the supervisor with events until `shutdown` resolves. /// -/// Graceful shutdown: the dispatch path is structured so -/// that `shutdown` is only observed *between* dispatches, never -/// mid-`call_on_event`. Each select fork either yields a fresh event -/// to dispatch or signals shutdown - the in-flight wasmtime call -/// finishes naturally before the loop exits. Whatever `shutdown` -/// yields (the launcher passes the graceful-drain guard) is held -/// until the loop returns, so the drain covers the final dispatch -/// and cursor commit. -/// -/// Returns the `(blocks, chain_logs)` tally of events drained from the -/// streams - the same numbers the shutdown log line reports. Tests -/// assert on the tally; the launch path ignores it. +/// `shutdown` is observed only between dispatches, never mid-`call_on_event`, +/// so an in-flight wasmtime call finishes before the loop exits; the guard it +/// yields is held until return, so the drain covers the final dispatch and +/// cursor commit. Returns the `(blocks, chain_logs)` dispatch tally. pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, @@ -614,13 +551,8 @@ pub async fn run( } } -/// The block a re-opened log poller should start from. `None` (the -/// first open) starts at the head, so no history is replayed on boot. -/// Otherwise resume just after the last delivered block and backfill -/// the whole gap - there is no lookback cap, so nothing is ever -/// skipped. This is reorg-safe: the old blocks are final, and the -/// poller fetches one `eth_getLogs` per block (immune to a provider's -/// block-range limit). +/// Start block for a re-opened log poller: `None` (first open) starts at head; +/// otherwise just after the last delivered block, backfilling the whole gap. fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { match last_seen_block { None => head, @@ -628,11 +560,8 @@ fn poller_resume_block(last_seen_block: Option, head: u64) -> u64 { } } -/// Returns `Some(gap)` when the time between the last observed event -/// and `now` meets or exceeds `threshold` - the caller should emit a -/// positive-recovery log line at this point. `None` covers -/// both the first-event case (no `last_event` yet) and the normal -/// "events are arriving at expected cadence" case. +/// `Some(gap)` when `now` is at least `threshold` past the last event; `None` +/// on the first event or when events arrive within `threshold`. fn block_stream_gap_to_log( now: Instant, last_event: Option, @@ -669,9 +598,6 @@ mod tests { // ── Structural tests: per-stream task allocation (#56) ────────────────── /// `open_block_streams` spawns one independent reconnect task per chain. - /// Per-chain task isolation means a slow or reconnecting chain does not - /// delay events from other chains: each chain has its own mpsc channel - /// and backoff timer. #[tokio::test] async fn open_block_streams_opens_one_task_per_chain() { use crate::test_utils::MockChainProvider; @@ -690,9 +616,7 @@ mod tests { tasks.shutdown().await; } - /// `open_chain_log_streams` spawns one independent reconnect task per - /// (module, chain, filter) subscription. Two subscriptions from different - /// modules on the same chain each get their own task. + /// `open_chain_log_streams` spawns one reconnect task per subscription. #[tokio::test] async fn open_chain_log_streams_opens_one_task_per_subscription() { use crate::test_utils::MockChainProvider; @@ -725,11 +649,8 @@ mod tests { tasks.shutdown().await; } - /// Issue #58's task-exit contract, asserted directly: a reconnect - /// task whose downstream receiver drops exits on its own with - /// [`TaskExit::ReceiverGone`] - it is not aborted. This cannot be - /// observed through `TaskSet::shutdown`, which aborts every handle - /// before joining, so the bare handle is joined here. + /// A reconnect task whose receiver drops exits on its own with + /// [`TaskExit::ReceiverGone`], not via abort. #[tokio::test] async fn reconnect_task_exits_receiver_gone_when_receiver_drops() { use crate::test_utils::MockChainProvider; @@ -763,8 +684,7 @@ mod tests { // ── block_stream_gap_to_log unit tests ────────────────────────────────── - /// The helper that decides whether to emit a - /// "stream gap closed" line on the next block event. + /// No prior event yields `None`. #[test] fn block_stream_gap_to_log_returns_none_when_no_prior_event() { let now = Instant::now(); diff --git a/crates/nexum-runtime/src/runtime/limits.rs b/crates/nexum-runtime/src/runtime/limits.rs index b7778f11..ab046cdd 100644 --- a/crates/nexum-runtime/src/runtime/limits.rs +++ b/crates/nexum-runtime/src/runtime/limits.rs @@ -1,6 +1,5 @@ -//! Re-exports for the configurable per-module wasmtime fuel + memory -//! limits. The canonical source is [`crate::engine_config::ModuleLimits`]. +//! Re-exports the per-module fuel and memory limits; canonical source +//! [`crate::engine_config::ModuleLimits`]. //! -//! Fuel meters only guest instructions; host-call time is unmetered, so a +//! Fuel meters only guest instructions; host-call time is unmetered, so the //! per-dispatch wall-clock deadline in [`crate::supervisor`] is the backstop. -//! diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index bfc1a427..b080e19a 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -1,45 +1,18 @@ //! Supervisor poison-pill policy. //! -//! Modules that reach `max_failures` traps within a sliding -//! `window` are marked **poisoned**: the supervisor stops dispatching -//! events to them entirely (no further restart attempts), bumps a -//! `shepherd_module_poisoned{module}` gauge to 1, and logs the -//! quarantine event so an operator can investigate. Recovery -//! requires an operator-driven full engine restart (today): remove -//! the entry from `engine.toml::[[modules]]`, kill the process, fix -//! the module, restart. -//! -//! ## Difference from the restart policy -//! -//! `restart_policy::backoff_for` schedules retries for transient -//! traps; the failure counter resets on a successful dispatch. The -//! poison policy is the *sustained-failure* escalation: if a module -//! is still trapping after `max_failures` retries inside `window`, -//! it stops being a transient and becomes a permanent failure that -//! exhausts an operator's restart budget without ever recovering. -//! Stop retrying. -//! -//! The two policies share `LoadedModule.failure_count` for the -//! consecutive-failure semantic; poison adds a `failure_timestamps` -//! ring so the window check is independent of how the failures are -//! spaced (one second apart vs nine minutes apart both count toward -//! the same window). +//! A module reaching `max_failures` traps within a sliding `window` is +//! poisoned: the supervisor stops dispatching to it (no further restarts), +//! sets the `shepherd_module_poisoned{module}` gauge to 1, and logs the +//! quarantine. Recovery needs an operator-driven full engine restart. use std::time::Duration; -/// Production defaults: 5 traps within 10 minutes -> quarantine. -/// Aggressive enough to catch a deterministically broken module -/// without waiting out the full exponential backoff (the 5th trap -/// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real extension submit does -/// not get a module quarantined. +/// Production defaults: 5 traps within 10 minutes quarantines a module. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); -/// Configurable poison-pill thresholds. Resolved from `[limits.poison]` -/// on the supervisor boot paths (`ModuleLimits::poison`), falling back -/// to [`PoisonPolicy::default`] for production; operators shorten both -/// values to catch a deterministically broken module sooner. +/// Configurable poison-pill thresholds from `[limits.poison]`, else +/// [`PoisonPolicy::default`]. #[derive(Debug, Clone, Copy)] pub struct PoisonPolicy { /// Maximum traps within `window` before the module is poisoned. @@ -63,8 +36,7 @@ impl Default for PoisonPolicy { } } -/// Return `true` when `failure_count` failures inside `window` -/// crosses the configured threshold. +/// `true` when the recent-failure count crosses the configured threshold. pub fn should_poison(policy: PoisonPolicy, recent_failures: u32) -> bool { recent_failures >= policy.max_failures } diff --git a/crates/nexum-runtime/src/runtime/restart_policy.rs b/crates/nexum-runtime/src/runtime/restart_policy.rs index 26be7eb7..502cb6c5 100644 --- a/crates/nexum-runtime/src/runtime/restart_policy.rs +++ b/crates/nexum-runtime/src/runtime/restart_policy.rs @@ -1,12 +1,8 @@ //! Supervisor module restart policy. //! -//! When a module traps in `on_event`, the supervisor flips `alive = -//! false` and schedules a restart attempt with exponential backoff. -//! The next dispatch eligible for that module retries the call; on -//! success the failure counter resets so a module that recovers -//! lands back in the steady-state schedule with no further delay. -//! -//! Policy: +//! On a trap in `on_event` the supervisor marks the module dead and schedules +//! a restart with exponential backoff; the next eligible dispatch retries, and +//! a successful call resets the failure counter. //! //! | failure_count | next_attempt delay | //! |---|---| @@ -16,25 +12,16 @@ //! | ... | doubles | //! | 9+ | capped at 5 minutes | //! -//! State is in-memory per supervisor process; it does not persist -//! across engine restarts. +//! State is in-memory per process; it does not persist across restarts. use std::time::Duration; -/// Hard cap on the restart backoff. After ~8 doublings we plateau -/// here. +/// Hard cap on the restart backoff. pub const RESTART_MAX_BACKOFF: Duration = Duration::from_secs(300); -/// Compute the wait window the supervisor honours before the next -/// restart attempt of a module that has trapped `failure_count` times -/// in a row. -/// -/// `failure_count = 0` is the steady-state value (no failures yet); -/// it returns `Duration::ZERO` so the supervisor can call this -/// unconditionally without a branch at the call site. -/// -/// `failure_count >= 1` is "the module just trapped"; the first -/// retry is 1 s, doubling on each subsequent trap, capped at 5 min. +/// Backoff before the next restart of a module that trapped `failure_count` +/// times in a row. `0` returns `Duration::ZERO` (steady state, callable +/// unconditionally); `>= 1` is 1 s doubling per trap, capped at 5 min. pub fn backoff_for(failure_count: u32) -> Duration { if failure_count == 0 { return Duration::ZERO; From a034107f89d10460f4537e65d1ec178dcec66386 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:26:53 +0000 Subject: [PATCH 127/139] docs: tersen rustdoc in cow-layer (#619) --- crates/composable-cow/src/body.rs | 21 +--- crates/composable-cow/src/lib.rs | 2 +- crates/composable-cow/src/poll.rs | 126 +++++++------------- crates/composable-cow/src/run.rs | 60 ++++------ crates/composable-cow/tests/run.rs | 64 ++++------ crates/cow-venue/src/adapter.rs | 90 ++++++-------- crates/cow-venue/src/assembly.rs | 103 ++++------------ crates/cow-venue/src/body.rs | 21 ++-- crates/cow-venue/src/classification.rs | 90 +++++--------- crates/cow-venue/src/classification_data.rs | 10 +- crates/cow-venue/src/client.rs | 33 ++--- crates/cow-venue/src/lib.rs | 40 ++----- crates/cow-venue/src/order.rs | 56 ++++----- 13 files changed, 239 insertions(+), 477 deletions(-) diff --git a/crates/composable-cow/src/body.rs b/crates/composable-cow/src/body.rs index 1ca8466c..d05d1727 100644 --- a/crates/composable-cow/src/body.rs +++ b/crates/composable-cow/src/body.rs @@ -1,17 +1,9 @@ //! The composable (conditional) order body. //! -//! ComposableCoW expresses a conditional order as the -//! `ConditionalOrderParams` tuple: the handler contract that mints the -//! tradeable order, a salt that distinguishes otherwise-identical -//! conditional orders, and the opaque handler-specific static input. -//! This body type is that tuple in wire form. The one non-obvious -//! invariant: `static_input` is opaque; only the named handler parses -//! it, so this crate never inspects its bytes. -//! -//! Borsh comes from `alloy-primitives`' own `borsh` feature, so no -//! adapter is needed: `Address` and `B256` encode as their bare bytes -//! and `Bytes` length-prefixed, leaving the wire byte-identical to the -//! `[u8; 20]`/`[u8; 32]`/`Vec` these fields replaced. +//! ComposableCoW's `ConditionalOrderParams` tuple in wire form: the +//! handler that mints the tradeable order, a salt, and the opaque +//! handler-specific static input. `static_input` is opaque; only the +//! named handler parses it. use alloy_primitives::{Address, B256, Bytes}; use borsh::{BorshDeserialize, BorshSerialize}; @@ -61,9 +53,8 @@ mod tests { ); } - /// The alloy types must encode exactly as the raw arrays did: 20 - /// bare handler bytes, 32 bare salt bytes, then a `u32`-length- - /// prefixed static input. + /// Wire layout: 20 bare handler bytes, 32 bare salt bytes, then a + /// `u32`-length-prefixed static input. #[test] fn wire_matches_the_raw_array_layout() { let mut expected = Vec::new(); diff --git a/crates/composable-cow/src/lib.rs b/crates/composable-cow/src/lib.rs index d0eb35bb..1eb93fc5 100644 --- a/crates/composable-cow/src/lib.rs +++ b/crates/composable-cow/src/lib.rs @@ -4,7 +4,7 @@ //! conditional-order body ([`ComposableBody`]) and the structured poll //! seam ([`Verdict`]), with the deployed 1.x reverting wire quarantined //! behind [`LegacyRevertAdapter`]. The `run` slice adds the shared -//! poll-loop composition ([`run`](run::run)) over the typed CoW venue client. +//! poll-loop composition (`run`) over the typed CoW venue client. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/composable-cow/src/poll.rs b/crates/composable-cow/src/poll.rs index f68eb5c7..f909cfa3 100644 --- a/crates/composable-cow/src/poll.rs +++ b/crates/composable-cow/src/poll.rs @@ -1,24 +1,12 @@ //! ComposableCoW poll seam: the structured [`Verdict`] and the //! quarantined [`LegacyRevertAdapter`]. //! -//! Every strategy poll resolves to a [`Verdict`] - the structured -//! outcome mirroring the composable-cow fork's structured generator. -//! The keeper 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`. +//! Every strategy poll resolves to a [`Verdict`]; the keeper run and +//! strategy modules dispatch on its variants alone. The deployed +//! ComposableCoW 1.x contract instead reverts with one of five custom +//! errors; [`LegacyRevertAdapter`] decodes that wire onto a [`Verdict`] +//! and is the single seam that retires when the structured generator +//! ships. use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolError, sol}; @@ -26,86 +14,66 @@ use cowprotocol::GPv2OrderData; use nexum_sdk::host::ChainError; sol! { - /// 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. + /// Deployed ComposableCoW 1.x custom error surface; selector source + /// for [`LegacyRevertAdapter::decode`]. #[derive(Debug)] interface IConditionalOrder { - /// `OrderNotValid(string)` - the order condition is permanently - /// not met. Watch towers drop. + /// Order condition permanently unmet; drop. error OrderNotValid(string reason); - /// `PollTryNextBlock(string)` - try again on the next block. + /// Retry on the next block. error PollTryNextBlock(string reason); - /// `PollTryAtBlock(uint256, string)` - try at or after the - /// given block number. + /// Retry at or after `blockNumber`. error PollTryAtBlock(uint256 blockNumber, string reason); - /// `PollTryAtEpoch(uint256, string)` - try at or after the - /// given Unix timestamp (seconds). + /// Retry at or after `timestamp` (Unix seconds). error PollTryAtEpoch(uint256 timestamp, string reason); - /// `PollNever(string)` - the conditional order is dead. + /// Conditional order is dead. error PollNever(string reason); } } -/// Structured outcome of a single watch poll, mirroring the -/// composable-cow fork's structured generator. +/// Structured outcome of a single watch poll. /// -/// 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. +/// Every variant but `Post` carries `reason`, the source 4-byte +/// selector for logging only; `[0; 4]` when synthetic. `Post` is the +/// only variant [`LegacyRevertAdapter`] never produces. #[derive(Debug)] 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. a few for the other - /// variants). + /// Tradeable now; submit `order` with its EIP-1271 `signature`. Post { - /// The 12-field order ready to submit. + /// Order ready to submit. order: Box, - /// EIP-1271 wire-form signature (raw verifier bytes; the - /// orderbook prepends `from` before settlement). + /// EIP-1271 signature blob (raw verifier bytes; the orderbook + /// prepends `from` before settlement). signature: Bytes, - /// Advisory Unix timestamp (seconds) the fork's generator hints - /// the next poll at. `None` when synthetic - the legacy adapter - /// has no such hint. + /// Advisory next-poll hint (Unix seconds); `None` when synthetic. next_poll_timestamp: Option, }, - /// Retry once the wall clock (Unix seconds, UTC) reaches - /// `wait_until`. + /// Retry once the wall clock (Unix seconds) reaches `wait_until`. WaitTimestamp { - /// Unix timestamp (seconds) to re-poll at or after. + /// Re-poll at or after this Unix timestamp (seconds). 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. + /// Re-poll at or after this block number. 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. + /// Retry on the next block. 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. + /// Order is dead; drop the watch. 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 - /// keeper run parks the watch untouched. + /// Generator needs off-chain input; the keeper parks the watch. + /// Never produced by [`LegacyRevertAdapter`]. NeedsInput { /// Source selector, log only. reason: [u8; 4], @@ -113,21 +81,14 @@ pub enum Verdict { } /// 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. +/// wire; maps each revert onto a [`Verdict`]. #[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. + /// Decode a revert payload into a [`Verdict`], or `None` when the + /// selector is not one of the five [`IConditionalOrder`] errors. + /// [`classify`](Self::classify) is the lifecycle policy on top. #[must_use] pub fn decode(data: &[u8]) -> Option { if data.len() < 4 { @@ -161,16 +122,13 @@ impl LegacyRevertAdapter { } } - /// Classify a failed poll `eth_call` into a [`Verdict`] - the one + /// 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`. + /// A recognised revert decodes; an unrecognised selector maps to + /// `Invalid` (a permanent contract-level rejection that would + /// otherwise loop every block); payload-free failures (transport + /// faults, sub-selector data) stay `TryNextBlock`. #[must_use] pub fn classify(err: &ChainError) -> Verdict { match err { @@ -317,9 +275,8 @@ mod tests { )); } - /// A handler-specific selector outside the `IConditionalOrder` - /// vocabulary is a permanent contract-level rejection: it must map - /// to `Invalid`, not re-poll every block forever. + /// A selector outside the `IConditionalOrder` vocabulary maps to + /// `Invalid`, not re-poll forever. #[test] fn classify_unrecognised_selector_is_invalid() { let mut data = vec![0x7a, 0x93, 0x32, 0x34]; @@ -359,8 +316,7 @@ mod tests { use proptest::prelude::*; proptest! { - /// `decode` on arbitrary revert bytes never panics and returns - /// `None` for inputs shorter than the 4-byte EVM selector. + /// `decode` never panics; `None` below the 4-byte selector length. #[test] fn decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { let outcome = LegacyRevertAdapter::decode(&bytes); diff --git a/crates/composable-cow/src/run.rs b/crates/composable-cow/src/run.rs index 97342acd..279a02da 100644 --- a/crates/composable-cow/src/run.rs +++ b/crates/composable-cow/src/run.rs @@ -1,28 +1,18 @@ -//! Keeper run: the poll-loop composition conditional- -//! commitment modules share. +//! Keeper run: the poll-loop composition conditional-commitment modules +//! share. //! -//! [`run`] first drives the shared -//! [`reconcile`](videre_sdk::reconcile) pass over the `submitted:` -//! reserve/commit journal, then walks the keeper watch set, polls each -//! gate-ready watch through a [`Poller`], and runs the -//! [`Verdict`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Post` reserves the encoded body on the venue-and-body -//! submission key and drives one submission through the typed -//! [`CowClient`] onto the `videre:venue/client` seam, committing on -//! acceptance, with the keeper [`Retrier`] as the failure dispatch. A -//! reservation whose submit outcome is lost is resubmitted by the next -//! tick's reconcile pass, never dropped. +//! [`run`] first drives the shared [`reconcile`](videre_sdk::reconcile) +//! pass over the `submitted:` reserve/commit journal, then polls each +//! gate-ready watch through a [`Poller`] and applies its [`Verdict`]: +//! lifecycle outcomes update the gate and watch stores; `Post` reserves +//! the encoded body, submits once through the typed `CowClient`, and +//! commits on acceptance. A reservation whose outcome is lost is +//! resubmitted by the next tick's reconcile pass, never dropped. //! -//! Store faults abort the run (the next tick replays it); -//! submission failures never do - they fold into a -//! [`RetryAction`] through the videre -//! [`retry_action`] table, a `denied` refusal re-entering the CoW -//! classification through its errorType prefix -//! ([`classify_denied`]) so a one-shot row survives the coarse -//! collapse, the ledger applies the effect, and the -//! run 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. +//! Store faults abort the run (the next tick replays it); a submission +//! failure folds into a [`RetryAction`], a `denied` refusal re-entering +//! the CoW classification by its errorType prefix ([`classify_denied`]). +//! Diagnostics go through the guest `tracing` facade. use alloy_primitives::{Address, Bytes, hex}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; @@ -42,11 +32,9 @@ use videre_sdk::{ use crate::Verdict; -/// Poll every gate-ready watch once at `tick` and run each outcome's -/// effect. The top-of-sweep [`reconcile`](videre_sdk::reconcile) pass -/// resolves stranded reservations first, then one source poll per ready -/// watch; a `Post` outcome makes at most one venue submit through -/// `venue`. +/// Poll every gate-ready watch once at `tick` and apply each outcome. +/// The top-of-sweep [`reconcile`](videre_sdk::reconcile) pass resolves +/// stranded reservations first; a `Post` makes at most one submit. pub fn run(host: &H, venue: &CowClient, source: &S, tick: &Tick) -> Result<(), Fault> where H: LocalStoreHost, @@ -110,16 +98,14 @@ where Ok(()) } -/// Submit one freshly-polled `Ready` order through the typed client on -/// the `submitted:` reserve/commit journal, dispatching any venue -/// refusal through the retry ledger. +/// Submit one polled `Post` order through the typed client on the +/// `submitted:` reserve/commit journal, dispatching a refusal through +/// the retry ledger. /// -/// The journal keys on the deterministic venue-and-body submission key. -/// A `COMMITTED` marker is an idempotent skip; a `RESERVED` marker is -/// owned by this tick's reconcile pass and never re-submitted here. A -/// fresh order reserves its encoded body before the submit and commits -/// on acceptance; release runs only on a known synchronous non-accept, -/// never on a pending or accepted path. +/// Keyed on the venue-and-body submission key: `COMMITTED` is an +/// idempotent skip, `RESERVED` is owned by reconcile. A fresh order +/// reserves before the submit and commits on acceptance; release runs +/// only on a known synchronous non-accept. fn submit_ready( host: &H, venue: &CowClient, diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index 7466a7cd..3c7462a6 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -88,8 +88,8 @@ where } } -/// Pin the closure to the higher-ranked source signature at the -/// construction site so inference never guesses a too-narrow lifetime. +/// Pin the closure to the source signature so inference keeps the +/// higher-ranked lifetime. fn src(f: F) -> FnSource where F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, @@ -156,8 +156,7 @@ fn intent_bytes(order: &GPv2OrderData) -> Vec { .expect("body encodes") } -/// The intent-id the run journals for `order`: the venue-and-body -/// key over the same signed body `run` derives pre-submit. +/// The intent-id the run journals for `order`. fn intent_id(order: &GPv2OrderData) -> String { submission_key(&CowVenue::ID, &intent_bytes(order)) } @@ -406,8 +405,7 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); } -/// A run cannot sign: a `requires-signing` outcome is surfaced, not -/// journalled, so the next tick re-poses the same ask. +/// A run cannot sign: `requires-signing` is surfaced, not journalled. #[test] fn requires_signing_is_surfaced_and_not_journalled() { let host = MockHost::new(); @@ -480,8 +478,7 @@ fn denied_fault_drops_the_watch_through_the_ledger() { assert!(logs.any(|e| e.message.contains("submit dropped watch"))); } -/// A rate-limit fault with server guidance backs the watch off on the -/// epoch clock - `RetryAction::Backoff` reached through the ledger. +/// A rate-limit fault backs the watch off on the epoch clock. #[test] fn rate_limited_submit_backs_off_through_the_epoch_gate() { let host = MockHost::new(); @@ -512,18 +509,16 @@ fn rate_limited_submit_backs_off_through_the_epoch_gate() { assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); } -/// The adapter's projection of the same-block wiring+create race: a -/// first-time user's Safe wiring and `create()` land in one block, so -/// the orderbook rejects the first submission against its own head. +/// The same-block wiring+create race: the orderbook rejects the first +/// submission against its own head. fn eip1271_rejection() -> Result { Err(VenueFault::Denied( "InvalidEip1271Signature: signature for computed order hash 0x7ee5 is not valid".into(), )) } -/// Same-block wiring+create race: the first rejection gates the watch -/// to the next block instead of dropping it, re-polls within the block -/// stay gated, and the retried submission one block later lands. +/// First EIP-1271 rejection gates to the next block; the retry one +/// block later lands. #[test] fn first_eip1271_rejection_retries_on_the_next_block() { let host = MockHost::new(); @@ -572,8 +567,7 @@ fn first_eip1271_rejection_retries_on_the_next_block() { ); } -/// A rejection that repeats on a later block is a genuinely broken -/// signature: the watch and every derived key go. +/// A rejection repeating on a later block drops the watch and its keys. #[test] fn repeated_eip1271_rejection_on_a_later_block_drops_the_watch() { let host = MockHost::new(); @@ -600,9 +594,8 @@ fn repeated_eip1271_rejection_on_a_later_block_drops_the_watch() { ); } -/// An accepted submission ends the refusal episode: a later tranche's -/// own first rejection earns a fresh one-block grace instead of an -/// immediate drop on the stale marker from an earlier tranche. +/// An acceptance ends the refusal episode; a later tranche's first +/// rejection earns a fresh one-block grace. #[test] fn acceptance_resets_the_one_block_grace_for_later_tranches() { let host = MockHost::new(); @@ -662,9 +655,8 @@ fn acceptance_resets_the_one_block_grace_for_later_tranches() { ); } -/// Restart regression: a keeper that posted, journalled, and then -/// restarted over the same persistent local store must not post the -/// same order again - one venue submit across both lives. +/// Restart regression: a journalled intent is not re-posted after +/// restart, one venue submit across both lives. #[test] fn restart_with_a_journalled_intent_does_not_repost() { let host = MockHost::new(); @@ -704,9 +696,8 @@ fn restart_with_a_journalled_intent_does_not_repost() { // ---- the generic seam ---- -/// The seam proof: a `Post` verdict reaches the venue transport as the -/// encoded `CowIntentBody` under the CoW venue id, and the journal -/// keys on the generic submission key. +/// The seam proof: a `Post` reaches the transport as the encoded +/// `CowIntentBody`, keyed on the generic submission key. #[test] fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { let host = MockHost::new(); @@ -736,11 +727,9 @@ fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { // ---- #573 reserve/commit + top-of-sweep reconcile ---- -/// Venue that models the CoW re-POST floor: a body it already holds -/// re-accepts (the `DuplicatedOrder` -> AlreadyHeld -> Accepted fold), -/// so a reconcile resubmit is always safe. A fresh body gets the -/// programmed outcome; an accepted body joins the held set. Every POST -/// is recorded. +/// Models the CoW re-POST floor: a held body re-accepts, so a reconcile +/// resubmit is always safe. A fresh body gets the programmed outcome, an +/// accepted body joins the held set. Every POST is recorded. struct HoldingVenue { outcome: RefCell>, posts: RefCell>>, @@ -811,9 +800,8 @@ fn holding_client(venue: &HoldingVenue) -> CowClient<&HoldingVenue> { CowClient::with_transport(venue) } -/// Wraps a store, faulting the first `COMMITTED` write to `submitted:` -/// once, then delegating. Models an accepted submit whose commit write -/// faults: the `RESERVED` marker persists and no release runs. +/// Faults the first `COMMITTED` write to `submitted:` once: models a +/// commit write that faults, leaving the marker RESERVED with no release. struct FlakyCommit { inner: MockLocalStore, arm: Cell, @@ -885,8 +873,8 @@ impl Poller for PostOnce { } } -/// Seed a stranded `RESERVED` marker on the encoded body, as a prior -/// tick's reserve whose submit outcome never landed. +/// Seed a stranded `RESERVED` marker, as a prior tick's reserve whose +/// outcome never landed. fn seed_reserved(host: &impl LocalStoreHost, order: &GPv2OrderData) { Journal::submitted(host) .reserve(&intent_id(order), &intent_bytes(order)) @@ -986,10 +974,8 @@ fn w3_abandoned_after_the_post_reconciles_to_one_held() { // The venue-never-saw-it sub-case is W1 above. } -/// Anti-#572: a RESERVED marker MUST drive a reconcile POST routed -/// THROUGH `venue.submit`, where the AlreadyHeld -> Accepted backstop -/// catches the duplicate. A pre-venue skip (the #572 shape) would defeat -/// the backstop and drop the order. +/// Anti-#572: a RESERVED marker drives a reconcile POST through +/// `venue.submit`, where the AlreadyHeld backstop catches the duplicate. #[test] fn anti_572_reserved_marker_drives_a_reconcile_post_through_the_venue() { let host = MockLocalStore::default(); diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 018f7717..9133b70d 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -1,22 +1,17 @@ //! The CoW venue adapter: the `venue-adapter` component slice. //! -//! [`CowAdapter`] decodes [`CowIntentBody`], assembles the orderbook -//! wire bodies through [`crate::assembly`], and speaks the -//! orderbook REST API over the scoped wasi:http transport, every -//! request bounded by the configured per-request timeout -//! ([`BoundedFetch`](videre_sdk::transport::BoundedFetch)). Orderbook -//! `errorType` rejections project onto -//! `venue-error` through the shipped classification table so the retry -//! hint survives the collapse: transient rows are `unavailable`, -//! throttles are `rate-limited`, permanent rows are `denied` (never -//! retried). An unsigned order submits as pre-sign: success is -//! `requires-signing` carrying the `setPreSignature` call the host -//! signs and sends. +//! `CowAdapter` decodes [`CowIntentBody`], assembles the orderbook +//! wire bodies through [`crate::assembly`], and speaks the orderbook +//! REST API over the scoped wasi:http transport bounded by the +//! configured per-request timeout. Orderbook `errorType` rejections +//! project onto `venue-error` through the shipped classification table; +//! an unsigned order submits as pre-sign, success carrying the +//! `setPreSignature` call the host signs. //! -//! `[config]` keys: `chain` (required, decimal chain id), and optional -//! `orderbook-url` (base URL override), `owner` (hex address enabling -//! the pre-sign path), `http-timeout-ms` (per-request bound, -//! defaulting to the SDK's per-phase timeout). +//! `[config]` keys: `chain` (required, decimal chain id), optional +//! `orderbook-url`, `owner` (hex address enabling the pre-sign path), +//! `http-timeout-ms` (per-request bound, default the SDK per-phase +//! timeout). use core::time::Duration; use std::sync::{PoisonError, RwLock}; @@ -41,8 +36,8 @@ use crate::body::{CowIntent, CowIntentBody}; use crate::classification; use crate::order::OrderUid; -/// The CoW venue's protocol speaker: the `venue-adapter` export type. -/// The component face is supplied by `#[videre_sdk::venue]` on the +/// The CoW venue's `venue-adapter` export type; the component face +/// comes from `#[videre_sdk::venue]` on the /// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. pub struct CowAdapter; @@ -112,17 +107,14 @@ impl AdapterConfig { } } -/// The configured adapter state; `init` replaces it whole, so a -/// supervisor restart re-configures cleanly. +/// Configured adapter state; `init` replaces it whole. static CONFIG: RwLock> = RwLock::new(None); pub(crate) fn store_config(config: AdapterConfig) { *CONFIG.write().unwrap_or_else(PoisonError::into_inner) = Some(config); } -/// The stored config, or the typed refusal for an uninitialised call. -/// The world contract calls `init` before any submission, so this is -/// only reachable on a host driving the exports out of order. +/// The stored config, or a typed refusal when `init` has not run. pub(crate) fn config() -> Result { CONFIG .read() @@ -139,10 +131,8 @@ fn decode(body: &[u8]) -> Result { Ok(intent) } -/// Pure header derivation for `chain`: gives the sell side, wants the -/// buy side, authorisation by intent kind (a signed order carries its -/// EIP-1271 signature; an unsigned order is authorised by host-held -/// keys through the pre-sign flow). +/// Derive the intent header for `chain`: the sell side gives, the buy +/// side wants, authorisation by intent kind. pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result { let intent = decode(body)?; let (order, authorisation) = match &intent { @@ -157,15 +147,11 @@ pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result QuoteRequest { let mut request = match order.kind { OrderKind::Sell => QuoteRequest::sell_before_fee( @@ -352,8 +336,7 @@ fn join(config: &AdapterConfig, path: &str) -> Result { .map_err(|e| VenueError::Unavailable(format!("orderbook url: {e}"))) } -/// One bounded request; every transport failure arrives as a typed -/// [`VenueError`] through the fetch conversion. +/// One bounded request; transport failures arrive as a typed [`VenueError`]. fn call( fetch: &impl Fetch, method: http::Method, @@ -370,14 +353,12 @@ fn call( Ok(fetch.fetch(request)?) } -/// A non-2xx orderbook reply on the submit path, where already-held is -/// a success shape. Reads take [`refusal_for_read`] instead, so a read -/// caller cannot hold an unresolved already-held. +/// A non-2xx submit reply; already-held is a success shape here. Reads +/// use [`refusal_for_read`] instead. enum Refusal { - /// The structured already-held rejection: success wearing an error - /// status. + /// Already-held: success wearing an error status. AlreadyHeld, - /// Everything else, as the `venue-error` the caller reports. + /// Everything else, as a reported `venue-error`. Error(VenueError), } @@ -405,9 +386,8 @@ fn refusal_for_submit(response: &http::Response>) -> Refusal { } } -/// Project a non-2xx read reply: already-held has no success meaning on -/// a read (the orderbook only emits it on submission), so it collapses -/// to an error rather than a success shape. +/// Project a non-2xx read reply; already-held has no read meaning and +/// collapses to an error. fn refusal_for_read(response: &http::Response>) -> VenueError { match refusal_for_submit(response) { Refusal::AlreadyHeld => VenueError::Unavailable("order already held".to_owned()), @@ -460,9 +440,8 @@ fn classified(api: &ApiError) -> VenueError { use wit_bindgen as _; /// The component face: `#[videre_sdk::venue]` derives the world from -/// `module.toml` and wires the trait through it. The real transport is -/// wasi:http behind the configured -/// [`BoundedFetch`](videre_sdk::transport::BoundedFetch) bound. +/// `module.toml`; the transport is wasi:http behind the configured +/// [`BoundedFetch`](videre_sdk::transport::BoundedFetch). mod export { use videre_sdk::VenueAdapter; use videre_sdk::transport::BoundedFetch; @@ -967,8 +946,7 @@ mod tests { // ── reconcile contract ─────────────────────────────────────────── /// The shared compliance fixture: one owner-configured config drives - /// both auth paths (a signed order is authorised by its own owner, so - /// the config owner only enables the pre-sign path). + /// both auth paths. struct CowReconcile; impl CowReconcile { diff --git a/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs index 7bbf213b..35f64226 100644 --- a/crates/cow-venue/src/assembly.rs +++ b/crates/cow-venue/src/assembly.rs @@ -1,12 +1,6 @@ -//! Chain-edge order assembly: the projections between the on-chain +//! Chain-edge order assembly: projections between the on-chain //! `GPv2OrderData` tuple, the typed `OrderData`, and the venue wire //! [`OrderBody`], plus the orderbook submission bodies built from them. -//! -//! This is the venue's protocol knowledge: `kind` / -//! `sellTokenBalance` / `buyTokenBalance` ride the chain as 32-byte -//! keccak markers and the orderbook signs against the typed shape, so -//! the adapter, not the keeper, owns every projection across that -//! edge. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolCall; @@ -17,49 +11,10 @@ use cowprotocol::{ use crate::order::OrderBody; -/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::new` expects. -/// -/// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride -/// the wire as `bytes32` markers (the `keccak256` of the lowercase -/// variant name). This helper hands them off to cowprotocol's -/// `from_contract_bytes` classifiers and returns `None` when the on- -/// 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::new` does the same downstream, but doing it here -/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// constructor later. -/// -/// # Example -/// -/// ``` -/// use cow_venue::assembly::gpv2_to_order_data; -/// use cowprotocol::{ -/// BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource, -/// }; -/// use alloy_primitives::{Address, U256}; -/// -/// let gpv2 = GPv2OrderData { -/// sellToken: Address::repeat_byte(1), -/// buyToken: Address::repeat_byte(2), -/// receiver: Address::ZERO, // normalised to None -/// sellAmount: U256::from(1_000u64), -/// buyAmount: U256::from(999u64), -/// validTo: u32::MAX, -/// appData: cowprotocol::EMPTY_APP_DATA_HASH, -/// feeAmount: U256::ZERO, -/// kind: OrderKind::SELL, -/// partiallyFillable: false, -/// sellTokenBalance: SellTokenSource::ERC20, -/// buyTokenBalance: BuyTokenDestination::ERC20, -/// }; -/// -/// let order = gpv2_to_order_data(&gpv2).expect("known markers"); -/// assert_eq!(order.sell_amount, U256::from(1_000u64)); -/// assert_eq!(order.receiver, None); -/// ``` +/// Project a polled or placed [`GPv2OrderData`] into the typed +/// [`OrderData`]. `None` when a `bytes32` enum marker (`kind`, +/// `sellTokenBalance`, `buyTokenBalance`) is unrecognised; the caller +/// skips the order. `receiver = Address::ZERO` normalises to `None`. #[must_use] pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { Some(OrderData { @@ -78,18 +33,14 @@ 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. +/// Orderbook UID hex (`0x` + 112 hex chars) for the on-chain (order, +/// owner, chain) tuple, matching the server-side value so a client can +/// key idempotency before any network work. /// -/// `None` when the chain id has no settlement domain or the order -/// 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. +/// `None` on an unsupported chain id or an unknown enum marker. Only +/// the unknown marker also stops the submit path; on an unsupported +/// chain a caller keying idempotency here alone re-submits until +/// `validTo`. #[must_use] pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { let chain = Chain::try_from(chain_id).ok()?; @@ -97,15 +48,14 @@ pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Op Some(format!("{}", order_uid(chain, &order_data, owner))) } -/// The canonical 56-byte orderbook UID for `order` under `chain`'s -/// settlement domain: what an accepted submit's receipt carries. +/// Canonical 56-byte orderbook UID for `order` under `chain`'s +/// settlement domain. #[must_use] pub fn order_uid(chain: Chain, order: &OrderData, owner: Address) -> cowprotocol::OrderUid { order.uid(&chain.settlement_domain(), owner) } -/// Project a typed [`OrderData`] into the venue wire [`OrderBody`] a -/// keeper emits. Total: every typed field has exactly one wire form. +/// Project a typed [`OrderData`] into the venue wire [`OrderBody`]. Total. #[must_use] pub fn order_data_to_body(order: &OrderData) -> OrderBody { OrderBody { @@ -134,8 +84,7 @@ pub fn order_data_to_body(order: &OrderData) -> OrderBody { } } -/// Project a venue wire [`OrderBody`] back onto the typed [`OrderData`] -/// the orderbook signs against: [`order_data_to_body`]'s total inverse. +/// [`order_data_to_body`]'s total inverse. #[must_use] pub fn body_to_order_data(body: &OrderBody) -> OrderData { OrderData { @@ -164,14 +113,10 @@ pub fn body_to_order_data(body: &OrderBody) -> OrderData { } } -/// Assemble the `OrderCreation` body the orderbook expects from a -/// polled conditional order. The signed `appData` digest goes out -/// verbatim in the hash-only wire shape (watch-tower parity), and the -/// signature is EIP-1271 - the conditional-order contract is the -/// verifier. -/// -/// An `Err` is a client-side precondition failure that would recur on -/// every retry of the same payload; the caller drops the watch. +/// Assemble the orderbook `OrderCreation` for a polled order: hash-only +/// `appData` wire shape, EIP-1271 signature (the conditional-order +/// contract is the verifier). `Err` is a client-side precondition +/// failure that recurs on retry; the caller drops the watch. pub fn build_order_creation( order_data: &OrderData, signature: &[u8], @@ -181,9 +126,8 @@ pub fn build_order_creation( OrderCreation::new_app_data_hash_only(order_data, signature, from, None) } -/// Assemble the pre-sign `OrderCreation` for an unsigned order: the -/// orderbook holds it as signature-pending until `from` settles the -/// authorisation on chain via [`set_pre_signature_calldata`]. +/// Assemble the pre-sign `OrderCreation`: held signature-pending until +/// `from` settles authorisation via [`set_pre_signature_calldata`]. pub fn build_presign_creation( order_data: &OrderData, from: Address, @@ -191,8 +135,7 @@ pub fn build_presign_creation( OrderCreation::new_app_data_hash_only(order_data, Signature::PreSign, from, None) } -/// ABI-encoded `GPv2Settlement::setPreSignature(uid, true)` calldata: -/// the call the order's owner must sign and send to activate a +/// ABI-encoded `setPreSignature(uid, true)` calldata to activate a /// pre-sign order. #[must_use] pub fn set_pre_signature_calldata(uid: &cowprotocol::OrderUid) -> Vec { diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index 4d0f450f..1b860ab4 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,14 +1,10 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is an order for the orderbook; [`CowIntent`] is -//! that sum, open for future intent kinds. [`CowIntentBody`] is the -//! outer per-venue version enum the venue publishes, and -//! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version -//! tag plus the borsh payload, with an unknown tag failing as a typed -//! [`BodyError`](videre_sdk::BodyError) rather than a stringly borsh -//! error. The one non-obvious invariant: the tag order is the schema, so -//! new versions append at the end and no variant is ever reordered or -//! removed. +//! [`CowIntent`] is the intent sum; [`CowIntentBody`] is the outer +//! per-venue version enum, and `#[derive(IntentBody)]` gives it the +//! borsh codec (one-byte version tag plus payload, an unknown tag +//! failing as a typed [`BodyError`](videre_sdk::BodyError)). Tag order +//! is the schema: append new versions, never reorder or remove. use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; @@ -20,8 +16,7 @@ use crate::order::{OrderBody, SignedOrder}; pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), - /// An owner-signed order with its EIP-1271 signature: what a - /// conditional-order keeper emits after a poll. + /// An owner-signed order with its EIP-1271 signature. Signed(SignedOrder), } @@ -54,8 +49,8 @@ mod tests { .build() } - /// The codec conformance set: the v1 intent as a round-trip vector - /// plus the typed failure contract, in the kit's published form. + /// The codec conformance set: round-trip vectors plus the typed + /// failure contract. fn vectors() -> CodecVectors { let mut vectors = CodecVectors::new("cow-venue/cow-intent-body"); vectors diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index ab2f4f23..18565302 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -1,34 +1,23 @@ //! 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 `errorType -> {try-next-block, backoff, drop}` policy ships as +//! data in `data/classification.toml`; `build.rs` validates it and +//! emits the static lookup table [`classify`] and +//! [`is_already_submitted`] read, so no TOML parser reaches the guest. //! -//! The one non-obvious invariant: an `errorType` absent from the table -//! (including [`OrderbookApiErrorType::Unknown`]) 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. +//! Invariant: an `errorType` absent from the table (including +//! [`OrderbookApiErrorType::Unknown`]) classifies as +//! [`RetryAction::Drop`], a permanent refusal never retried every block. use cowprotocol::OrderbookApiErrorType; 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. +/// The shipped classification data, embedded verbatim for the parity test. 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. +/// The retry action a generated row selects; mapped to a keeper +/// [`RetryAction`] by [`GeneratedRow`]. Which variants appear is a +/// property of the shipped data, hence `allow(dead_code)`. #[allow(dead_code)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum GenAction { @@ -73,9 +62,7 @@ pub struct ClassificationTable { } impl ClassificationTable { - /// [`OrderbookApiErrorType::Unknown`] is by definition unlisted: - /// the parity tests pin every row to a known variant's exact wire - /// spelling, so only known variants can match a row. + /// Find the row for `error_type`; `Unknown` is unlisted by definition. fn row(&self, error_type: &OrderbookApiErrorType) -> Option<&GeneratedRow> { match error_type { OrderbookApiErrorType::Unknown(_) => None, @@ -91,14 +78,12 @@ impl ClassificationTable { .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. + /// Whether `error_type` means the orderbook already holds this order. pub fn is_already_submitted(&self, error_type: &OrderbookApiErrorType) -> bool { self.row(error_type).is_some_and(|r| r.already_submitted) } - /// Number of classified `errorType`s, for tests and diagnostics. + /// Number of classified `errorType`s. pub fn len(&self) -> usize { self.rows.len() } @@ -116,10 +101,8 @@ pub fn table() -> ClassificationTable { } } -/// Classify an orderbook `errorType` into a keeper [`RetryAction`] via -/// the shipped table. Unlisted types (including -/// [`OrderbookApiErrorType::Unknown`]) are permanent -/// ([`RetryAction::Drop`]). +/// Classify an orderbook `errorType` via the shipped table; unlisted +/// types are permanent ([`RetryAction::Drop`]). pub fn classify(error_type: OrderbookApiErrorType) -> RetryAction { table().classify(&error_type) } @@ -129,11 +112,9 @@ pub fn is_already_submitted(error_type: OrderbookApiErrorType) -> bool { table().is_already_submitted(&error_type) } -/// Retry action for a coarse `denied` refusal. The adapter spells a -/// classified rejection as `{errorType}: {description}`; the prefix -/// re-enters the table so a [`RetryAction::DropOnRepeat`] row survives -/// the collapse to the wire `venue-error`. Every other denial is -/// permanent. +/// Retry action for a coarse `denied` refusal: the `{errorType}:` +/// prefix re-enters the table so a [`RetryAction::DropOnRepeat`] row +/// survives the collapse; every other denial is permanent. pub fn classify_denied(detail: &str) -> RetryAction { let error_type = detail.split_once(':').map_or(detail, |(prefix, _)| prefix); match classify(OrderbookApiErrorType::from(error_type)) { @@ -158,10 +139,8 @@ mod tests { 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. + /// Data-vs-code parity: the generated table agrees with an + /// independent re-parse of the shipped file on every entry. #[test] fn data_matches_code_contract() { let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); @@ -190,9 +169,7 @@ mod tests { } } - /// 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. + /// Spot check: the exemplar rows the table must carry. #[test] fn known_rows_classify_as_documented() { assert_eq!(classify(kind("InsufficientFee")), RetryAction::TryNextBlock); @@ -209,8 +186,7 @@ mod tests { assert!(is_already_submitted(kind("DuplicateOrder"))); } - /// Unlisted (including newly minted) types are permanent, so a - /// contract-level rejection is never retried every block forever. + /// Unlisted types are permanent. #[test] fn unlisted_type_drops() { let unknown = kind("NewlyMintedErrorType"); @@ -234,9 +210,8 @@ mod tests { assert_eq!(classify(kind("InvalidSignature")), RetryAction::Drop); } - /// A denied detail re-enters the table by its `errorType` prefix: - /// only a drop-on-repeat row escapes the permanent default, and a - /// transient row cannot be smuggled through a denial. + /// A denied detail re-enters the table by its `errorType` prefix; + /// only a drop-on-repeat row escapes the permanent default. #[test] fn denied_detail_refines_by_error_type_prefix() { assert_eq!( @@ -298,9 +273,8 @@ mod tests { ); } - /// Every listed `error-type` names a member of the upstream - /// orderbook errorType enum, in its exact wire spelling: no phantom - /// rows. + /// Every listed `error-type` names a real `errorType` in its exact + /// wire spelling. #[test] fn every_row_names_a_real_error_type() { let entries = parse_and_validate(CLASSIFICATION_TOML).expect("shipped data is valid"); @@ -315,9 +289,8 @@ mod tests { } } - /// The table's divergence from upstream `retry_hint()` is exactly - /// the ratified set in the data header. A data edit or an upstream - /// policy change lands here and forces re-ratification. + /// The table's divergence from `retry_hint()` is exactly the + /// ratified set; a change forces re-ratification. #[test] fn divergence_from_upstream_is_exactly_the_ratified_set() { const RATIFIED: [&str; 5] = [ @@ -358,9 +331,8 @@ mod tests { assert_eq!(divergent, RATIFIED); } - /// A non-Rust reader sees the same file as plain data: parsing it - /// with the untyped TOML value model (no Rust schema) exposes the - /// entries and their fields, proving any TOML library reads it. + /// The shipped file parses with an untyped TOML model, so any TOML + /// library reads it. #[test] fn non_rust_reader_sees_plain_toml() { let value: toml::Table = diff --git a/crates/cow-venue/src/classification_data.rs b/crates/cow-venue/src/classification_data.rs index bb96cc3f..b02f67b8 100644 --- a/crates/cow-venue/src/classification_data.rs +++ b/crates/cow-venue/src/classification_data.rs @@ -1,12 +1,8 @@ //! 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. +//! The single source of the TOML schema and table invariants, compiled +//! by `build.rs` (to generate the lookup table) and by the tests (to +//! re-parse and check parity), never into a guest. use serde::Deserialize; diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 92696153..5fe0e19e 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -1,12 +1,10 @@ //! The CoW venue as a keeper types it. //! -//! [`CowVenue`] names the venue once - the id its adapter registers -//! under and the [`CowIntentBody`] schema it decodes - so keeper code -//! drives it through [`VenueClient`] with typed bodies, never wire -//! bytes. The classification API -//! ([`classify`](crate::classification::classify)) travels in the same -//! slice so the client that submits an order and the table that -//! classifies its rejection version together. +//! [`CowVenue`] names the venue: the id its adapter registers under and +//! the [`CowIntentBody`] schema, so keeper code drives it through +//! [`VenueClient`] with typed bodies. The retry +//! [`classify`](crate::classification::classify) API ships in the same +//! slice so client and classification version together. use videre_sdk::client::{HostVenues, Venue, VenueClient}; use videre_sdk::keeper::submission_key; @@ -14,9 +12,9 @@ use videre_sdk::{BodyError, IntentBody as _}; use crate::body::CowIntentBody; -/// The CoW venue marker: every [`CowClient`] call routes to -/// [`Venue::ID`] and encodes a [`CowIntentBody`]. An accepted submit's -/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. +/// The CoW venue marker: `CowClient` calls route to [`Venue::ID`] and +/// encode a [`CowIntentBody`]; a receipt is the canonical +/// [`OrderUid`](crate::OrderUid) in wire form. #[derive(Clone, Copy, Debug)] pub struct CowVenue; @@ -24,19 +22,14 @@ pub struct CowVenue; #[videre_sdk::venue(id = "cow", body = CowIntentBody)] impl Venue for CowVenue {} -/// A typed client pre-bound to the CoW venue: callers cannot mis-route -/// or submit a foreign body. +/// A typed client pre-bound to the CoW venue. pub type CowClient = VenueClient; -/// Deterministic intent-id for `body`: the run's -/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any -/// network work, so a keeper journals the same key whether it submits -/// through the run or directly. -/// -/// The key covers the encoded body, so a signed payload +/// Deterministic intent-id for `body`: [`submission_key`] bound to +/// [`CowVenue::ID`], derivable before any network work. It covers the +/// encoded body, so a signed payload /// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its -/// signature: it scopes to that exact payload, not to the economic -/// order, which dedups only through the venue's duplicate response. +/// signature, not the economic order. pub fn intent_id(body: &CowIntentBody) -> Result { Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index e7852a36..03f6a63f 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -1,36 +1,14 @@ //! # cow-venue //! -//! The CoW venue, staged as a crate of feature slices: the orderbook -//! and nothing else. The default [`body`] slice carries the -//! venue-neutral order intent body types and the borsh `IntentBody` -//! codec over them; conditional-order keeper machinery lives in its -//! own crate and never here. -//! -//! The body slice is dependency-light on purpose. It links only the -//! venue SDK (for the [`IntentBody`](videre_sdk::IntentBody) derive), -//! borsh, and the alloy primitives the body fields carry, so a venue -//! adapter component or a strategy module can carry the body types and -//! codec without dragging in the host-side CoW machinery. -//! -//! With `--no-default-features` the slice drops out entirely and the -//! crate compiles empty, so a consumer can depend on a single slice -//! without pulling the codec transitively. -//! -//! The `client` slice layers on top: a typed [`CowClient`] bound to the -//! CoW venue, the deterministic [`intent_id`] journal key, and the -//! table-driven retry [`classification`] generated at -//! build time from the shipped `data/classification.toml` (the TOML -//! parser stays a build-time dependency, off the guest). It links the -//! strategy keeper (for the retry action type) and is off by default, -//! so an adapter or a module that wants only the body types stays -//! dependency-light. -//! -//! The `assembly` slice carries the chain-edge order projections and -//! orderbook submission bodies; the `adapter` slice on top of it is -//! the venue-adapter component itself (`CowAdapter` under -//! `#[videre_sdk::venue]`), built as the cdylib for wasm32-wasip2 and -//! never linked by a keeper module (linking it would export the -//! adapter face). +//! The CoW venue, staged as feature slices. `body` (default) carries the +//! venue-neutral order body types and their borsh +//! [`IntentBody`](videre_sdk::IntentBody) codec. `client` adds the typed +//! `CowClient`, the deterministic `intent_id` journal key, and the +//! table-driven retry `classification` generated at build time from +//! `data/classification.toml`. `assembly` carries the chain-edge order +//! projections; `adapter` is the `venue-adapter` component +//! (`CowAdapter`) built for wasm32-wasip2, never linked by a keeper +//! module. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index 69e75c17..081a9728 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -1,20 +1,16 @@ //! The venue-neutral CoW order body. //! -//! On the wire a CoW order is the 12-field `GPv2Order` tuple. This body -//! type is the same shape over the alloy primitives (`Address`/`U256`, -//! small enums for the balance and kind markers) so it borsh-encodes -//! and links without the on-chain stack. The one non-obvious invariant: -//! 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. +//! The 12-field `GPv2Order` tuple over the alloy primitives, so it +//! borsh-encodes without the on-chain stack. The marker enums are +//! canonical wire forms, not on-chain keccak markers; the adapter owns +//! the projection to and from chain. use core::fmt; use alloy_primitives::{Address, U256}; use borsh::{BorshDeserialize, BorshSerialize}; -/// The token an order sells, typed so a builder call cannot swap -/// sides with the buy token. +/// The token an order sells, typed against side swaps. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SellToken(pub Address); @@ -24,8 +20,7 @@ impl From
for SellToken { } } -/// The token an order buys, typed so a builder call cannot swap sides -/// with the sell token. +/// The token an order buys, typed against side swaps. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct BuyToken(pub Address); @@ -65,10 +60,6 @@ pub enum BuyTokenDestination { } /// The venue-neutral order body: the `GPv2Order` fields in wire form. -/// -/// `receiver` is `None` for the self-receive default the orderbook -/// 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. @@ -98,8 +89,8 @@ pub struct OrderBody { } impl OrderBody { - /// A sell order: `sell_amount` of `sell` is the fixed side, at least - /// `buy_amount` of `buy` in return, expiring at `valid_to`. + /// A sell order: `sell_amount` fixed, at least `buy_amount` in + /// return, expiring at `valid_to`. #[must_use] pub const fn sell( sell: SellToken, @@ -118,8 +109,8 @@ impl OrderBody { ) } - /// A buy order: `buy_amount` of `buy` is the fixed side, spending at - /// most `sell_amount` of `sell`, expiring at `valid_to`. + /// A buy order: `buy_amount` fixed, spending at most `sell_amount`, + /// expiring at `valid_to`. #[must_use] pub const fn buy( buy: BuyToken, @@ -139,11 +130,10 @@ impl OrderBody { } } -/// Builder for [`OrderBody`]: the required fields (both sides and the -/// expiry) are constructor args, so completeness is compile-time -/// guaranteed without state markers; the optionals default (self-receive, -/// zero `app_data` and `fee_amount`, fill-or-kill, ERC-20 balances). -/// Use [`OrderBody::sell`] or [`OrderBody::buy`] to fix the kind. +/// Builder for [`OrderBody`]: required fields are constructor args, the +/// optionals default (self-receive, zero `app_data`/`fee_amount`, +/// fill-or-kill, ERC-20 balances). Start from [`OrderBody::sell`] or +/// [`OrderBody::buy`]. #[derive(Clone, Debug)] pub struct OrderBuilder { body: OrderBody, @@ -176,8 +166,8 @@ impl OrderBuilder { } } - /// Set the absolute `validTo` unix-seconds expiry, overriding the - /// constructor argument. + /// Set the absolute `validTo` (Unix seconds), overriding the + /// constructor. #[must_use] pub const fn valid_to(mut self, secs: u32) -> Self { self.body.valid_to = secs; @@ -185,9 +175,9 @@ impl OrderBuilder { } /// Expire `duration` seconds after `now`, saturating at `u32::MAX`. - /// `now` is the tick's block timestamp, not a wall clock: `valid_to` - /// is hashed into the submission dedup key, so a wall clock would - /// break reconcile-replay idempotency. + /// `now` is the block timestamp, not a wall clock: `valid_to` feeds + /// the submission dedup key, so a wall clock would break replay + /// idempotency. #[must_use] pub const fn valid_for(mut self, now: u32, duration: u32) -> Self { self.body.valid_to = now.saturating_add(duration); @@ -243,8 +233,7 @@ impl OrderBuilder { } } -/// An owner-signed order ready for the orderbook: what a -/// conditional-order keeper emits after a poll. +/// An owner-signed order ready for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub struct SignedOrder { /// The order to place. @@ -257,9 +246,8 @@ pub struct SignedOrder { pub signature: Vec, } -/// Canonical 56-byte orderbook order UID (order digest, owner, -/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit -/// carries. +/// Canonical 56-byte orderbook UID (order digest, owner, `valid_to`) +/// in wire form. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct OrderUid(pub [u8; 56]); From e11a24f0715e1e4892e4981bac8b10dec9ed809f Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:28:08 +0000 Subject: [PATCH 128/139] docs: tersen rustdoc in manifest and test_utils (#620) --- .../src/manifest/capabilities.rs | 102 ++++-------- crates/nexum-runtime/src/manifest/error.rs | 24 +-- crates/nexum-runtime/src/manifest/load.rs | 39 ++--- crates/nexum-runtime/src/manifest/mod.rs | 28 +--- crates/nexum-runtime/src/manifest/types.rs | 112 +++++-------- .../nexum-runtime/src/test_utils/builders.rs | 8 +- crates/nexum-runtime/src/test_utils/chain.rs | 53 ++---- crates/nexum-runtime/src/test_utils/clock.rs | 27 ++- .../nexum-runtime/src/test_utils/harness.rs | 156 ++++++------------ crates/nexum-runtime/src/test_utils/mod.rs | 45 ++--- crates/nexum-runtime/src/test_utils/store.rs | 5 +- crates/nexum-runtime/src/test_utils/types.rs | 9 +- 12 files changed, 205 insertions(+), 403 deletions(-) diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 12b3a53a..ccea7c18 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -1,30 +1,19 @@ -//! Capability enforcement: cross-checks the component's WIT imports -//! against the `[capabilities]` block declared in `module.toml`. +//! Capability enforcement: cross-checks a component's WIT imports against +//! its `[capabilities]` declarations. //! -//! The set of recognised capabilities is not fixed: the core namespace is -//! 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. -//! -//! The WASI surface is gated the same way: io/clocks/random and all of -//! `wasi:cli` are ambient, `wasi:sockets` and `wasi:filesystem` are opt-in -//! via the `wasi-*` capabilities, and any other `wasi:` interface is -//! refused fail-closed. +//! The core `nexum:host` namespace is built in; each extension registers +//! its own via [`CapabilityRegistry::register`]. `wasi:http` is gated by +//! the `http` capability, `wasi:sockets` and `wasi:filesystem` by `wasi-*`; +//! io/clocks/random and `wasi:cli` are ambient; any other `wasi:` interface +//! is refused fail-closed. use std::collections::HashSet; use super::error::{CapabilityError, CapabilityViolation}; use super::types::{CORE_CAPABILITIES, LoadedManifest}; -/// One WIT namespace prefix plus the interface names under it that count as -/// capabilities. Core registers `nexum:host/`; an extension registers its -/// own. +/// A WIT namespace prefix plus the interface names under it that are +/// capabilities. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -39,38 +28,31 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// The interfaces a provider world links: the scoped transport only. A -/// provider has no local-store, remote-store, identity, or logging - it -/// moves bytes to and from its counterparty and nothing else. `http` is -/// 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. +/// Interfaces a provider world links: the scoped transport only. `http` is +/// gated by the registry, as in the core set. pub const PROVIDER_CAPABILITIES: &[&str] = &[ nexum_world::Cap::Chain.as_str(), nexum_world::Cap::Messaging.as_str(), ]; -/// The provider namespace: the same `nexum:host/` prefix as core but only -/// the scoped-transport interfaces. Validating a provider manifest against -/// a registry built from this namespace rejects a declaration of any core -/// interface a provider must not reach (e.g. `local-store`) as unknown. +/// The provider namespace: `nexum:host/` scoped to the transport +/// interfaces, so a provider declaring a core-only interface (e.g. +/// `local-store`) is rejected as unknown. pub const PROVIDER_NAMESPACE: NamespaceCaps = NamespaceCaps { prefix: "nexum:host/", ifaces: PROVIDER_CAPABILITIES, }; -/// Import prefix of the wasi:http package. Every interface under it -/// (outgoing-handler, types, ...) is gated by the single -/// [`HTTP_CAPABILITY`] declaration. +/// Import prefix of the `wasi:http` package; every interface under it is +/// gated by [`HTTP_CAPABILITY`]. const WASI_HTTP_PREFIX: &str = "wasi:http/"; /// Capability name a module declares to import any `wasi:http/*` /// interface; the per-module `[capabilities.http].allow` list scopes it. const HTTP_CAPABILITY: &str = nexum_world::Cap::Http.as_str(); -/// Gated WASI capability names. Declaring one grants the matching `wasi:` -/// interface group; see [`classify_wasi`]. `wasi:io`, `wasi:clocks`, -/// `wasi:random` and all of `wasi:cli` (environment included; the host -/// populates it empty) are ambient and need no declaration. +/// Gated WASI capability names; declaring one grants the matching `wasi:` +/// interface group. See [`classify_wasi`]. const WASI_CAPABILITIES: &[&str] = &["wasi-sockets", "wasi-filesystem"]; /// A `wasi:` import (other than `wasi:http`) classified against the gate. @@ -102,8 +84,8 @@ fn classify_wasi(import_name: &str) -> WasiGate { } } -/// Registry of capability namespaces recognised by enforcement. Built from -/// the core namespace plus every registered extension. +/// Capability namespaces recognised by enforcement: the core namespace plus +/// every registered extension. #[derive(Clone)] pub struct CapabilityRegistry { namespaces: Vec, @@ -123,11 +105,9 @@ impl CapabilityRegistry { } } - /// The registry a provider validates against: only the scoped - /// transport interfaces plus `http`. A provider manifest that declares - /// a core-only capability (e.g. `local-store`) fails as unknown here, - /// and the provider linker withholds the same interfaces so the - /// component cannot instantiate against them either. + /// The registry a provider validates against: the scoped transport plus + /// `http`. A provider manifest declaring a core-only capability (e.g. + /// `local-store`) fails as unknown. pub fn provider() -> Self { Self { namespaces: vec![PROVIDER_NAMESPACE], @@ -140,7 +120,6 @@ impl CapabilityRegistry { } /// Whether `name` is a capability under any registered namespace. - /// Used to validate declared capability names in a manifest. pub fn is_known(&self, name: &str) -> bool { name == HTTP_CAPABILITY || WASI_CAPABILITIES.contains(&name) @@ -158,22 +137,10 @@ impl CapabilityRegistry { .join(", ") } - /// Map a WIT import name to a capability name, or `None` for - /// non-capability imports. - /// - /// Returns `Some(iface)` only for interfaces under a registered - /// namespace, plus `Some("http")` for anything under `wasi:http/`; - /// type-only packages like `nexum:host/types` and the remaining - /// `wasi:*` namespaces fall through to `None` so they do not need a - /// manifest declaration. - /// - /// Examples: - /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` - /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that - /// namespace is registered - /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` - /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) - /// - `"wasi:io/streams@0.2.0"` -> `None` + /// Map a WIT import name to a capability name. `Some(iface)` for an + /// interface under a registered namespace, `Some("http")` for anything + /// under `wasi:http/`, and `None` for a non-capability import (type-only + /// packages, ungated `wasi:*`). pub fn wit_import_to_cap<'a>(&self, import_name: &'a str) -> Option<&'a str> { let without_version = import_name.split('@').next().unwrap_or(import_name); if without_version.starts_with(WASI_HTTP_PREFIX) { @@ -190,16 +157,11 @@ impl CapabilityRegistry { } } -/// Check that every capability-bearing WIT import of the component is covered -/// by the module's manifest declarations. Call after loading the component, -/// before instantiation. -/// -/// The WASI surface is gated fail-closed. With `[capabilities]` absent -/// (0.1-fallback) the registry surface stays permissive and load warns. -/// -/// `component_imports` is the name part of each import from -/// `component.component_type().imports(&engine)`. `registry` carries the -/// core namespace plus any extension namespaces. +/// Check that every capability-bearing WIT import is covered by the +/// manifest's declarations; call after loading the component, before +/// instantiation. The WASI surface is gated fail-closed even in the +/// 0.1-fallback (no `[capabilities]`), where the registry surface stays +/// permissive. `component_imports` are the import name parts. pub fn enforce_capabilities<'a>( loaded: &LoadedManifest, component_imports: impl Iterator, diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index ed5858c7..e961c8eb 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -3,11 +3,7 @@ use strum::IntoStaticStr; use thiserror::Error; -/// Errors returned while loading or validating a manifest. -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` for the manifest-loader's `tracing::warn!` / -/// `metrics::counter!` call sites. +/// Errors from loading or validating a manifest. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -18,18 +14,16 @@ pub enum ParseError { /// Manifest file was not valid TOML. #[error("manifest: parse: {0}")] Toml(#[from] toml::de::Error), - /// `[capabilities].required` or `.optional` listed a capability - /// the engine does not recognise. `known` is the comma-joined set of - /// core plus registered-extension capabilities at validation time. + /// A declared capability the engine does not recognise. #[error("manifest: unknown capability {name:?} in [capabilities] (known: {known})")] UnknownCapability { - /// The unrecognised capability name. + /// The unrecognised name. name: String, /// Comma-joined recognised capability names. known: String, }, - /// `[module].name` is not a single safe path component; it must not - /// contain `/`, `\`, or `..` so it cannot escape the state directory. + /// `[module].name` contains `/`, `\`, or `..`, so it could escape the + /// state directory. #[error("manifest: [module].name {0:?} must not contain '/', '\\', or '..'")] InvalidModuleName(String), } @@ -41,15 +35,13 @@ pub enum ParseError { [capabilities].required or [capabilities].optional" )] pub struct CapabilityViolation { - /// Capability name (e.g. `"remote-store"`). + /// Capability name. pub capability: String, - /// Full WIT import name as it appeared in the component (e.g. - /// `"nexum:host/remote-store@0.1.0"`). + /// Full WIT import name. pub wit_import: String, } -/// Error returned when a component's WIT imports exceed its declared -/// capabilities. +/// A component's WIT imports exceed its declared capabilities. #[derive(Debug, Error)] #[non_exhaustive] pub enum CapabilityError { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index a7a70e98..3f26ee68 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -1,9 +1,5 @@ -//! Parse `module.toml` from disk, validate, and emit operator-visible -//! warnings. -//! -//! Also exposes the host-matching helper the wasi:http gate uses to -//! enforce the manifest's `[capabilities.http].allow` list at request -//! time. +//! Parse and validate `module.toml`, plus the host-matching helper the +//! wasi:http gate uses to enforce `[capabilities.http].allow`. use std::path::Path; @@ -13,10 +9,9 @@ use super::capabilities::CapabilityRegistry; use super::error::ParseError; use super::types::{LoadedManifest, Manifest}; -/// Read `module.toml` from `path`, parse, validate, and emit a deprecation -/// warning if `[capabilities]` is absent (0.1-compat fallback). Declared -/// capability names are validated against `registry`, so extension -/// capabilities are recognised only once their namespace is registered. +/// Read, parse, and validate `module.toml`; declared capability names are +/// checked against `registry`. Warns if `[capabilities]` is absent +/// (0.1-compat fallback). pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result { let raw = std::fs::read_to_string(path)?; let manifest: Manifest = toml::from_str(&raw)?; @@ -75,8 +70,7 @@ pub fn load(path: &Path, registry: &CapabilityRegistry) -> Result LoadedManifest { warn!( target: "manifest", @@ -91,9 +85,9 @@ pub fn fallback_manifest() -> LoadedManifest { } } -/// Reject a `[module].name` that is not a single safe path component, so a -/// hostile name cannot escape the state directory wherever it is used as one. -/// An empty name is allowed; the runtime falls back to `module`. +/// Reject a `[module].name` that is not a single safe path component, so it +/// cannot escape the state directory. An empty name is allowed (the runtime +/// falls back to `module`). fn validate_module_name(name: &str) -> Result<(), ParseError> { if name.contains('/') || name.contains('\\') || name.contains("..") { return Err(ParseError::InvalidModuleName(name.to_owned())); @@ -101,11 +95,10 @@ fn validate_module_name(name: &str) -> Result<(), ParseError> { Ok(()) } -/// Check whether `host` matches any pattern in the allowlist. Patterns are -/// either exact (`api.example.com`) or `*.suffix` wildcards which match -/// any subdomain of `suffix` (but not `suffix` itself). Matching is -/// case-insensitive and host-only: no scheme, no port, and IPv6 literals -/// keep their brackets. +/// Whether `host` matches any allowlist pattern: exact, or a `*.suffix` +/// wildcard matching any subdomain of `suffix` but not `suffix` itself. +/// Case-insensitive and host-only (no scheme or port; IPv6 literals keep +/// their brackets). pub fn host_allowed(host: &str, allowlist: &[String]) -> bool { let host = host.to_ascii_lowercase(); allowlist.iter().any(|pat| { @@ -232,8 +225,7 @@ scope = 7 assert!(err.to_string().contains("must be a string"), "{err}"); } - /// A non-core top-level section parses into the opaque extension - /// map: the runtime carries it verbatim and ascribes it no meaning. + /// A non-core top-level section parses into the opaque extension map. #[test] fn load_parses_extension_sections_opaquely() { let toml = r#" @@ -373,8 +365,7 @@ kind = "acme-provider" ); } - /// An unknown spelling parses as a provider kind; boot refuses it - /// against the registered kinds, where the valid set is known. + /// An unknown spelling parses as a provider kind for boot to refuse. #[test] fn component_kind_keeps_an_unregistered_spelling_for_boot_to_refuse() { use crate::manifest::types::ComponentKind; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 17b49ab2..7a76828f 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -1,26 +1,10 @@ -//! `module.toml` parser and capability-enforcement helpers. +//! `module.toml` parser and capability enforcement. //! -//! - `[capabilities].required` is parsed and validated: names must be in -//! the known capability set, which the engine always provides. -//! - `[capabilities].optional` is parsed and logged. -//! - `[capabilities.http].allow` is parsed and consulted by the -//! wasi:http gate before any outbound call. -//! - `[config]` is flattened to `Vec<(String, String)>` and passed to the -//! module's `init`. -//! -//! When the manifest file is missing or has no `[capabilities]` section, -//! a deprecation warning is emitted and the engine falls back to treating -//! every linked capability as required. -//! -//! ## Layout -//! -//! - `types`: the serde `Manifest` shape + `LoadedManifest` the engine -//! actually consumes, plus the core-capability list. -//! - `load`: `module.toml` -> `LoadedManifest`, plus the host-matching -//! helper the wasi:http gate uses at request time. -//! - `capabilities`: WIT-import vs declared-capabilities cross-check, plus -//! the extension-extensible `CapabilityRegistry`. -//! - `error`: `ParseError`, `CapabilityViolation`, `CapabilityError`. +//! `load` parses and validates a manifest; `capabilities` cross-checks a +//! component's WIT imports against its declared `[capabilities]`; `types` +//! holds the serde shapes and `LoadedManifest`; `error` the error types. +//! A manifest with no `[capabilities]` section falls back to all-required, +//! with a deprecation warning. mod capabilities; mod error; diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index a90f4abd..ab5306a0 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -1,8 +1,4 @@ -//! Data structures: `Manifest`, sections, and `LoadedManifest`. -//! -//! Plain serde shapes plus the core-capability list. The parsing -//! and validation logic lives in [`mod@super::load`]; capability enforcement -//! in [`super::capabilities`]. +//! Serde shapes: `Manifest`, its sections, and `LoadedManifest`. use std::collections::BTreeMap; use std::fmt; @@ -10,13 +6,9 @@ use std::fmt; use serde::Deserialize; use serde::de::Error as _; -/// Core capability names: the `nexum:host` interfaces the `event-module` -/// world links into every module linker, emitted from the -/// `nexum-world` capability table. The `http` capability is not a -/// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities are not -/// listed here; each extension contributes its own namespace to the -/// [`super::capabilities::CapabilityRegistry`] at the composition root. +/// Core capability names: the `nexum:host` interfaces linked into every +/// module. `http` is gated separately (it gates `wasi:http/*`), and +/// extensions register their own namespaces. pub const CORE_CAPABILITIES: &[&str] = &nexum_world::CORE_IFACES; #[derive(Debug, Deserialize, Default)] @@ -27,16 +19,13 @@ pub struct Manifest { pub capabilities: Option, #[serde(default)] pub config: toml::Table, - /// Event subscriptions the runtime wires before calling - /// `_init`. See `docs/02-modules-events-packaging.md` for the - /// schema. Implements `block` and `chain-log` kinds; `cron` is - /// parsed and ignored. + /// Event subscriptions wired before `_init`. `block` and `chain-log` + /// are dispatched; `cron` is parsed and ignored. #[serde(default, rename = "subscription")] pub subscriptions: Vec, - /// Extension-owned sections: every non-core top-level key, parsed - /// opaquely. The runtime ascribes them no meaning; it routes them - /// to the wired extensions' install predicates, and a section no - /// extension claims is refused at boot. + /// Extension-owned sections (every non-core top-level key), parsed + /// opaquely and routed to the wired extensions; a section no extension + /// claims is refused at boot. #[serde(flatten)] pub extensions: ExtensionSections, } @@ -45,60 +34,45 @@ pub struct Manifest { /// to the runtime; each claiming extension parses its own. pub type ExtensionSections = BTreeMap; -/// One `[[subscription]]` table in `module.toml`. -/// -/// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. A kind outside the core set -/// parses as [`Subscription::Extension`] and is validated at boot -/// against the kinds the wired extensions declare, so a typo still -/// fails loudly rather than silently disabling an event source. +/// One `[[subscription]]` table. The `kind` field discriminates; an +/// unknown kind parses as [`Subscription::Extension`] and is validated at +/// boot against the wired extensions' declared kinds. #[derive(Debug, Clone)] pub enum Subscription { - /// New-block events. Fan-out is shared per chain: the - /// supervisor opens one subscription per chain id and routes to - /// every module that asked for blocks on that chain. + /// New-block events; one subscription per chain id, fanned out to every + /// module watching that chain. Block { /// EVM chain id. chain_id: u64, }, - /// Chain-log events matching `address` + topic-0. Fan-out is - /// per-module: the supervisor opens one subscription per - /// `[[subscription]]` entry and tags emitted events with the - /// owning module. + /// Chain-log events matching `address` + topic-0; one subscription per + /// entry, tagged with the owning module. ChainLog { /// EVM chain id. chain_id: u64, /// Contract address as `0x`-prefixed 20-byte hex. Optional. address: Option, - /// Topic-0 of the event the module wants to consume. `0x`- - /// prefixed 32-byte hex. Optional: when absent the - /// subscription matches every event from the address(es). + /// Topic-0 filter as `0x`-prefixed 32-byte hex; absent matches + /// every event from the address(es). event_signature: Option, - /// Resume across engine restarts. When `true` the host persists a - /// 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 keeper idempotency journal already - /// dedups it). + /// Persist a durable per-subscription cursor and re-open from just + /// after the last dispatched block instead of head. Delivery is + /// then at-least-once; the module must tolerate redelivery. resume: bool, - /// Optional cap on how far back a `resume` subscription will - /// backfill, in blocks. `None` (the default) backfills the entire - /// gap with no loss; set it only for a consumer that explicitly + /// Backfill cap for a `resume` subscription, in blocks. `None` + /// backfills the whole gap; set it only for a consumer that /// tolerates dropping the oldest missed blocks. max_lookback: Option, }, - /// Cron-scheduled tick. Parsed but not dispatched; the - /// supervisor emits a warning so the operator knows the - /// declaration is currently inert. `schedule` is preserved verbatim. + /// Cron-scheduled tick; parsed but not dispatched (the supervisor + /// warns). Cron { /// Standard 5-field cron expression. #[allow(dead_code)] schedule: String, }, - /// An extension-owned event kind. Every non-`kind` key is a string - /// filter matched against the event's routing attributes: an event - /// is delivered when its kind matches and every filter pair is - /// present in the event's attributes. + /// An extension-owned event kind. Delivered when the kind matches and + /// every filter pair is present in the event's attributes. Extension { /// The extension-declared subscription kind. kind: String, @@ -107,8 +81,8 @@ pub enum Subscription { }, } -/// The core subscription kinds, parsed by shape. Any other kind falls -/// through to [`Subscription::Extension`]. +/// Core subscription kinds parsed by shape; others fall through to +/// [`Subscription::Extension`]. #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] enum CoreSubscription { @@ -194,10 +168,8 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, - /// Which component kind this manifest describes. Defaults to the - /// worker kind (`event-module`) so every existing `module.toml` keeps - /// its meaning; a provider names its registered kind. The supervisor - /// resolves the boot path from this discriminator. + /// Component kind; defaults to the worker (`event-module`), a provider + /// names its registered kind. #[serde(default)] pub kind: ComponentKind, /// Per-module resource overrides; each unset field inherits the engine @@ -209,19 +181,16 @@ pub struct ModuleSection { /// The worker kind's manifest spelling. pub const WORKER_KIND: &str = "event-module"; -/// The component kind a manifest declares: the core worker kind, or the -/// manifest spelling of a provider kind an extension registers. Defaults -/// to the worker so every manifest written before providers existed keeps -/// its meaning; an unregistered provider spelling is refused at boot, -/// where the registered kinds are known. +/// Component kind a manifest declares: the worker, or a provider spelling +/// an extension registers. Defaults to the worker; an unregistered spelling +/// is refused at boot. #[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] #[serde(from = "String")] pub enum ComponentKind { - /// Event-driven worker over the six core primitives (`event-module`). + /// Event-driven worker (`event-module`). #[default] Worker, - /// A provider the host holds behind a serialised actor, named by its - /// manifest spelling. + /// A provider, named by its manifest spelling. Provider(String), } @@ -243,8 +212,8 @@ impl fmt::Display for ComponentKind { } } } -/// `[module.resources]` overrides layered over the engine `[limits]` -/// defaults. Every field is optional; an unset field keeps the default. +/// `[module.resources]` overrides; each unset field keeps the engine +/// `[limits]` default. #[derive(Debug, Deserialize, Default)] pub struct ResourceSection { /// Linear-memory cap, in bytes. @@ -282,9 +251,8 @@ pub struct LoadedManifest { /// Hosts wasi:http outgoing requests may target. Each entry is /// either an exact hostname or a `*.suffix` wildcard. pub http_allowlist: Vec, - /// `[config]` flattened to `(key, stringified-value)` pairs ready to - /// hand to a module's `init`. TOML scalars (string, integer, float, - /// boolean) become their text form. Arrays and tables are rendered as + /// `[config]` flattened to `(key, stringified-value)` pairs for a + /// module's `init`. Scalars become their text form; arrays and tables /// their TOML representation. pub config: Vec<(String, String)>, } diff --git a/crates/nexum-runtime/src/test_utils/builders.rs b/crates/nexum-runtime/src/test_utils/builders.rs index 529f229a..d173ce4c 100644 --- a/crates/nexum-runtime/src/test_utils/builders.rs +++ b/crates/nexum-runtime/src/test_utils/builders.rs @@ -1,13 +1,9 @@ -//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend, so a test -//! hands a programmed mock straight into the public builder path. +//! Pass-through [`ComponentBuilder`] wrapping a pre-built backend. use crate::host::component::{BuilderContext, ComponentBuilder}; /// A [`ComponentBuilder`] that yields a pre-built backend, ignoring the build -/// context. Wrap any mock instance (chain, store, or extension payload) to -/// compose it through [`RuntimeBuilder::with_components`]. -/// -/// [`RuntimeBuilder::with_components`]: crate::builder::TypedBuilder::with_components +/// context. Wrap any mock instance to compose it through the public builder. pub struct Prebuilt(pub T); impl ComponentBuilder for Prebuilt { diff --git a/crates/nexum-runtime/src/test_utils/chain.rs b/crates/nexum-runtime/src/test_utils/chain.rs index a1deeeae..78a94445 100644 --- a/crates/nexum-runtime/src/test_utils/chain.rs +++ b/crates/nexum-runtime/src/test_utils/chain.rs @@ -28,18 +28,10 @@ pub struct RecordedRequest { type BlockItem = Result; type LogItem = Result; -/// One subscription kind's channel pair. The receiver is taken by the first -/// subscribe call. -/// -/// A concurrent second subscribe (with no close in between) finds the -/// receiver already taken and parks on a pending stream, so a reconnect loop -/// does not busy-spin against a live subscriber. -/// -/// A subscribe after [`close`](Self::close) is the reconnect path and is -/// distinct: close ends the open stream and re-arms the slot with a fresh -/// channel, so the next subscribe (the event loop's reconnect after backoff) -/// gets a real stream and resumes delivery of subsequently sent items, -/// mirroring a provider that reconnects after a dropped connection. +/// One subscription kind's channel pair; the receiver is taken by the first +/// subscribe. A second subscribe before any [`close`](Self::close) parks on +/// a pending stream. [`close`](Self::close) ends the open stream and re-arms +/// the slot, so the next subscribe (the reconnect path) resumes delivery. struct StreamSlot { tx: UnboundedSender, rx: Option>, @@ -93,21 +85,14 @@ struct Inner { /// Mock chain backend. Program `request` responses with [`on_method`] / /// [`on_request`], drive subscriptions with [`push_block`] / -/// [`push_chain_log`], script transport failures with [`push_block_err`] / -/// [`push_chain_log_err`], end a stream with [`close_block_stream`] / -/// [`close_chain_log_stream`], and read back dispatched calls with -/// [`recorded_requests`]. Cheap `Arc` clone shares one backing state, so a -/// test keeps a clone to program and assert while another clone lives inside -/// the runtime assembly. +/// [`push_chain_log`] (and the `_err` / `close_*` variants), and read +/// dispatched calls with [`recorded_requests`]. Cheap `Arc` clone shares one +/// backing state, so a test keeps a clone to program and assert. /// /// [`on_method`]: MockChainProvider::on_method /// [`on_request`]: MockChainProvider::on_request /// [`push_block`]: MockChainProvider::push_block /// [`push_chain_log`]: MockChainProvider::push_chain_log -/// [`push_block_err`]: MockChainProvider::push_block_err -/// [`push_chain_log_err`]: MockChainProvider::push_chain_log_err -/// [`close_block_stream`]: MockChainProvider::close_block_stream -/// [`close_chain_log_stream`]: MockChainProvider::close_chain_log_stream /// [`recorded_requests`]: MockChainProvider::recorded_requests #[derive(Clone)] pub struct MockChainProvider { @@ -158,8 +143,8 @@ impl MockChainProvider { self } - /// Deliver a block header to the open block subscription. Items sent - /// while no subscription is open buffer and drain into the next one. + /// Deliver a block header to the open block subscription; items sent with + /// no open subscription buffer and drain into the next. pub fn push_block(&self, header: Header) { self.lock().blocks.send(Ok(header)); } @@ -169,9 +154,7 @@ impl MockChainProvider { self.lock().logs.send(Ok(log)); } - /// Deliver an error item to the open block subscription, so a - /// reconnect-and-backoff loop on the [`BlockStream`] contract can be - /// exercised against the fake. + /// Deliver an error item to the open block subscription. pub fn push_block_err(&self, err: ProviderError) { self.lock().blocks.send(Err(err)); } @@ -181,11 +164,9 @@ impl MockChainProvider { self.lock().logs.send(Err(err)); } - /// End the block subscription, modelling a dropped upstream connection: - /// buffered items drain, then the stream terminates (yields `None`). The - /// slot re-arms, so a later `subscribe_blocks` (the event loop's - /// reconnect after backoff) resumes delivery of subsequently pushed - /// items, as a real provider does once its connection is back. + /// End the block subscription (modelling a dropped connection): buffered + /// items drain, the stream terminates, and the slot re-arms so a later + /// `subscribe_blocks` resumes delivery of subsequently pushed items. pub fn close_block_stream(&self) { self.lock().blocks.close(); } @@ -196,11 +177,9 @@ impl MockChainProvider { self.lock().logs.close(); } - /// Park the next [`ChainProvider::request`] for `delay` before it - /// resolves, modelling a hung node or a server that never answers. - /// One-shot: the delay is consumed when that request begins, so a - /// caller that drops the request future mid-park (e.g. a dispatch that - /// hits its wall-clock deadline) leaves the following request prompt. + /// Park the next [`ChainProvider::request`] for `delay`. One-shot, + /// consumed when the request begins, so a caller that drops the request + /// future mid-park leaves the following request prompt. pub fn delay_next_request(&self, delay: Duration) -> &Self { self.lock().next_request_delay = Some(delay); self diff --git a/crates/nexum-runtime/src/test_utils/clock.rs b/crates/nexum-runtime/src/test_utils/clock.rs index fcda4919..440efb1a 100644 --- a/crates/nexum-runtime/src/test_utils/clock.rs +++ b/crates/nexum-runtime/src/test_utils/clock.rs @@ -7,13 +7,11 @@ use wasmtime_wasi::{HostMonotonicClock, HostWallClock}; use crate::supervisor::WasiClockOverride; -/// A shared, manually-advanced clock source. -/// -/// Cloning yields another handle onto the same instant: install one clone as -/// the store's [`WasiClockOverride`] and drive the other from the test. -/// [`set`](Self::set) pins wall time; [`advance`](Self::advance) moves both the -/// wall and monotonic sources forward. Guest-visible only; it does not touch -/// the host wall-clock a `RunId` stamps its start with. +/// A shared, manually-advanced clock source. Cloning yields another handle +/// onto the same instant: install one clone as a store's +/// [`WasiClockOverride`], drive the other from the test. [`set`](Self::set) +/// pins wall time; [`advance`](Self::advance) moves wall and monotonic +/// together. Guest-visible only. #[derive(Clone)] pub struct ManualClock { inner: Arc>, @@ -44,10 +42,8 @@ impl ManualClock { } } - /// Pin wall time to `time`, leaving the monotonic reading untouched. - /// Times before the Unix epoch clamp to it. Because it does not move - /// monotonic, a `set` after an `advance` can put wall time behind the - /// monotonic source; the two only stay in step under `advance`. + /// Pin wall time to `time` (clamped to the Unix epoch), leaving monotonic + /// untouched; a `set` after an `advance` can put wall behind monotonic. pub fn set(&self, time: SystemTime) { let wall = time.duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO); self.locked().wall = wall; @@ -61,12 +57,9 @@ impl ManualClock { state.monotonic = state.monotonic.saturating_add(nanos); } - /// Build a [`WasiClockOverride`] backed by this clock for both the wall and - /// monotonic sources. The two `Arc`s wrap separate `clone`s of the same - /// `ManualClock`, which share one inner `Arc>`, so both handles - /// read and drive the same time. Swapping a `clone` for a fresh - /// `ManualClock::new()` would split that state and silently break the - /// override. + /// A [`WasiClockOverride`] over this clock for both sources. Both `Arc`s + /// share this clock's inner state; a fresh `ManualClock::new()` would + /// split it and break the override. pub fn as_override(&self) -> WasiClockOverride { WasiClockOverride::new(Arc::new(self.clone()), Arc::new(self.clone())) } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 7dc21eef..b53355a6 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -1,25 +1,14 @@ //! In-process test harness: launch one module over the mock assembly and -//! drive it from a test, with no supervisor ceremony. +//! drive it from a test. //! -//! [`TestRuntime`] wraps the public builder path over [`MockTypes`]: it opens -//! a module from a wasm path plus a manifest (a path, or inline TOML written -//! to a temp file), installs a manually-driven [`ManualClock`], and returns a -//! handle bundling the running [`RuntimeHandle`] with the retained mock -//! handles. A test programs chain responses and injects block headers or chain -//! logs through [`chain`](TestRuntime::chain), advances guest time through -//! [`clock`](TestRuntime::clock), reads what a module wrote through -//! [`store`](TestRuntime::store), and reads runs and log pages through -//! [`logs`](TestRuntime::logs), then [`shutdown`](TestRuntime::shutdown) and -//! [`wait`](TestRuntime::wait). -//! -//! Events dispatch on the spawned event-loop task, so a test injects an event -//! and then awaits an observable effect; -//! [`wait_for_log`](TestRuntime::wait_for_log) polls the log pipeline for one. -//! -//! The extension slot is the lattice type parameter: pass an `Ext` payload -//! and any [`Extension`]s through -//! [`builder_with_ext`](TestRuntime::builder_with_ext) to drive an extension -//! crate's backend through the same harness. +//! [`TestRuntime`] wraps the public builder path over [`MockTypes`] with a +//! manually-driven [`ManualClock`]. Program the mocks and read effects +//! through [`chain`](TestRuntime::chain), [`clock`](TestRuntime::clock), +//! [`store`](TestRuntime::store) and [`logs`](TestRuntime::logs). Events +//! dispatch on the spawned event-loop task, so +//! [`wait_for_log`](TestRuntime::wait_for_log) polls for an observable +//! effect. Bind an extension payload through +//! [`builder_with_ext`](TestRuntime::builder_with_ext). use std::path::PathBuf; use std::sync::Arc; @@ -45,9 +34,7 @@ enum ManifestSource { Inline(String), } -/// Builder for a [`TestRuntime`]. Program the mock backends through -/// [`chain`](Self::chain) / [`store`](Self::store) / [`clock`](Self::clock) -/// before [`launch`](Self::launch); the launched handle shares the same +/// Builder for a [`TestRuntime`]; the launched handle shares the same mock /// backends. pub struct TestRuntimeBuilder where @@ -71,9 +58,9 @@ impl TestRuntime<()> { } impl TestRuntime { - /// Start a harness whose lattice binds `ext` as the extension payload. - /// Pair with [`extension`](TestRuntimeBuilder::extension) to register the - /// extension's linker hook and capability namespace. + /// Start a harness binding `ext` as the extension payload; pair with + /// [`extension`](TestRuntimeBuilder::extension) to register its linker + /// hook and capability namespace. pub fn builder_with_ext(wasm: impl Into, ext: E) -> TestRuntimeBuilder { TestRuntimeBuilder { wasm: wasm.into(), @@ -116,21 +103,19 @@ impl TestRuntimeBuilder { self } - /// Replace the `[limits]` the launch resolves: fuel, memory, outbound - /// HTTP, log retention, and the poison-pill thresholds. Defaults to the + /// Replace the `[limits]` the launch resolves; defaults to the /// production defaults. pub fn limits(mut self, limits: ModuleLimits) -> Self { self.limits = limits; self } - /// The mock chain backend, for programming request responses before - /// launch. The launched handle shares this instance. + /// The mock chain backend; the launched handle shares this instance. pub fn chain(&self) -> &MockChainProvider { &self.chain } - /// The mock state store. The launched handle shares this instance. + /// The mock state store; the launched handle shares this instance. pub fn store(&self) -> &MockStateStore { &self.store } @@ -140,8 +125,7 @@ impl TestRuntimeBuilder { &self.clock } - /// Open the module and start the runtime. Composes entirely through the - /// public builder path over [`MockTypes`]. + /// Open the module and start the runtime through the public builder path. pub async fn launch(self) -> anyhow::Result> { // A temp directory roots any inline manifest and stands in as the // (unused, in-memory backends) state directory. @@ -186,9 +170,8 @@ impl TestRuntimeBuilder { } } -/// A launched in-process runtime over the mock assembly. Holds the running -/// [`RuntimeHandle`] and the retained mock handles; dropping it fires the -/// shutdown trigger. +/// A launched in-process runtime over the mock assembly; dropping it fires +/// the shutdown trigger. pub struct TestRuntime { handle: RuntimeHandle, chain: MockChainProvider, @@ -201,9 +184,7 @@ pub struct TestRuntime { } impl TestRuntime { - /// The mock chain backend: program request responses, inject block - /// headers with [`push_block`](Self::push_block), and inject logs with - /// [`push_chain_log`](Self::push_chain_log). + /// The mock chain backend. pub fn chain(&self) -> &MockChainProvider { &self.chain } @@ -223,7 +204,7 @@ impl TestRuntime { &self.ext } - /// The shared log pipeline: read runs and log pages here. + /// The shared log pipeline. pub fn logs(&self) -> &LogPipeline { self.handle.logs() } @@ -238,12 +219,9 @@ impl TestRuntime { self.chain.push_chain_log(log); } - /// Await a `module` log record whose message contains `needle`, returning - /// it. Driven by log-append notifications rather than a timer, so it - /// resolves as soon as the dispatched event's record lands; the 5s bound - /// is a failure backstop, not the cadence. Use it to await an injected - /// event's effect: the record only lands once the event-loop task has - /// dispatched the event. + /// Await a `module` log record whose message contains `needle`. + /// Notification-driven, so it resolves as soon as the dispatched event's + /// record lands; the 5s bound is a failure backstop. pub async fn wait_for_log(&self, module: &str, needle: &str) -> anyhow::Result { let logs = self.logs(); let appended = logs.appended(); @@ -291,8 +269,7 @@ mod tests { use crate::host::extension::Extension; use crate::manifest::NamespaceCaps; - /// The pre-built module wasm named `file`, or `None` (with a skip note) - /// when the fixture is not built. + /// The pre-built module wasm named `file`, or `None` with a skip note. fn module_wasm_or_skip(file: &str) -> Option { let wasm = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -311,8 +288,7 @@ mod tests { } } - /// The pre-built example module, or `None` (with a skip note) when the - /// wasm fixture is not built. + /// The pre-built example module, or `None` with a skip note. fn example_wasm_or_skip() -> Option { module_wasm_or_skip("example.wasm") } @@ -352,18 +328,15 @@ chain_id = {chain_id} ) } - /// A header carrying just the block number the event loop projects onto - /// the dispatched block. + /// A header carrying just the block number. fn header_numbered(number: u64) -> Header { let mut header: Header = Header::default(); header.inner.number = number; header } - /// End-to-end through the harness: launch the example module from an - /// inline manifest, inject a block header, and read the module's log line - /// back off the pipeline. Locks the harness ergonomics against the boot - /// plus dispatch plus log-read path. + /// End-to-end: launch the example module from an inline manifest, inject + /// a block header, and read the module's log line back. #[tokio::test] async fn harness_launches_dispatches_and_reads_logs() { let Some(wasm) = example_wasm_or_skip() else { @@ -391,10 +364,8 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// End-to-end through the harness on the chain-log leg: launch the - /// example module with a `chain-log` subscription, inject a log, and read - /// the module's log line back. Locks the log-stream path the way - /// [`harness_launches_dispatches_and_reads_logs`] locks the block path. + /// End-to-end on the chain-log leg: launch with a `chain-log` + /// subscription, inject a log, and read the module's log line back. #[tokio::test] async fn harness_dispatches_chain_logs() { let Some(wasm) = example_wasm_or_skip() else { @@ -416,10 +387,9 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// The extension slot threads through the same harness: a trivial - /// extension (no-op linker hook, empty capability namespace) and an ext - /// payload compose through the mock lattice, the module boots and - /// dispatches, and the harness hands the payload back. + /// The extension slot threads through the harness: a trivial extension + /// and an ext payload compose, the module dispatches, and the harness + /// hands the payload back. #[tokio::test] async fn harness_threads_an_extension_and_ext_payload() { let Some(wasm) = example_wasm_or_skip() else { @@ -476,9 +446,8 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// [`TestRuntimeBuilder::limits`] reaches the launch: with a one-byte - /// log ring the run keeps only its newest record, so the init line is - /// evicted once the block line lands. + /// [`TestRuntimeBuilder::limits`] reaches the launch: a one-byte log ring + /// keeps only the newest record, evicting the init line. #[tokio::test] async fn harness_threads_module_limits() { use crate::engine_config::LogLimitsSection; @@ -519,11 +488,9 @@ chain_id = {chain_id} rt.wait().await.expect("clean shutdown"); } - /// The module-author flow end to end on the chain-request leg: program - /// the mock chain's `eth_call` response, launch the price-alert module, - /// inject a block, and read the module's alert line back. The programmed - /// oracle answer sits above the configured threshold, so the module logs - /// its TRIGGERED line. + /// End to end on the chain-request leg: program the mock `eth_call`, + /// launch price-alert, inject a block, and read its alert line back; the + /// programmed answer is above threshold, so the module logs TRIGGERED. #[tokio::test] async fn harness_serves_chain_requests_to_the_module() { use crate::host::component::ChainMethod; @@ -593,9 +560,8 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// Both block and chain-log events are dispatched to a live module in the - /// same session: the `biased` select in `run()` delivers both event kinds - /// without starvation. Addresses the ordering guarantee from issue #56. + /// Both block and chain-log events dispatch in one session: the `biased` + /// select in `run()` delivers both kinds without starvation. #[tokio::test] async fn harness_delivers_block_and_chain_log_events_without_starvation() { let Some(wasm) = example_wasm_or_skip() else { @@ -641,10 +607,9 @@ chain_id = 1 rt.wait().await.expect("clean shutdown"); } - /// Blocks pushed in order arrive at the module in the same order: - /// the per-chain stream, the select, and the dispatch path preserve - /// delivery order. Issue #56's ordering guarantee, asserted on the - /// module's own log records rather than inferred from termination. + /// Blocks pushed in order arrive in the same order; the stream, select, + /// and dispatch path preserve delivery order, asserted on the module's + /// own log records. #[tokio::test] async fn harness_delivers_blocks_in_push_order() { let Some(wasm) = example_wasm_or_skip() else { @@ -688,13 +653,9 @@ chain_id = 1 rt.wait().await.expect("clean shutdown"); } - /// Shutdown signalled while a dispatch is pending never destroys - /// completed work: the dispatch path sits outside the shutdown select, - /// so a block that was picked up finishes its wasmtime call and its - /// log record survives `wait()`. The test first proves the dispatch - /// completed (log line present), then shuts down and re-reads the same - /// record after the engine is fully torn down: if teardown dropped or - /// truncated completed work, the second read fails. Issue #58. + /// Shutdown never destroys completed work: a picked-up block finishes its + /// wasmtime call and its log record survives `wait()`. Proven by + /// re-reading the record after full teardown. #[tokio::test] async fn harness_shutdown_preserves_completed_dispatch() { let Some(wasm) = example_wasm_or_skip() else { @@ -729,11 +690,8 @@ chain_id = 1 } /// `[limits.chain].response_body_max_bytes` is enforced on the real - /// `chain::request` path, end to end: the configured cap reaches - /// `HostState`, an over-cap node response is rejected before the guest - /// copy, and the module observes the typed `invalid-input` fault - /// instead of the body. Guards the wiring the unit tests on - /// `check_response_cap` cannot see (issue #154). + /// `chain::request` path: an over-cap response is rejected before the + /// guest copy, and the module observes the typed `invalid-input` fault. #[tokio::test] async fn harness_enforces_chain_response_cap_on_the_request_path() { use crate::engine_config::ChainLimitsSection; @@ -816,10 +774,9 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// A dropped block stream is not the end of dispatch: the event loop's - /// reconnect task reopens the subscription after backoff and the - /// re-armed mock resumes delivery, matching a real provider that comes - /// back after a connection drop. + /// A dropped block stream is not the end of dispatch: the reconnect task + /// reopens the subscription after backoff and the re-armed mock resumes + /// delivery. #[tokio::test] async fn harness_resumes_dispatch_after_a_dropped_block_stream() { let Some(wasm) = example_wasm_or_skip() else { @@ -847,12 +804,9 @@ direction = "above" rt.wait().await.expect("clean shutdown"); } - /// The guest observes the `WasiClockOverride` end to end: pin the harness - /// clock to a known instant, boot the clock-reader fixture under it, and - /// dispatch a block. The fixture reads `wasi:clocks/wall-clock` through - /// `std` and logs the wall time as whole seconds, so the logged value - /// equalling the pinned instant (and not the ambient host clock) proves - /// the override reaches the guest, not just the host boot path. + /// The guest observes the `WasiClockOverride`: pin the harness clock, + /// dispatch a block, and check the clock-reader fixture logs the pinned + /// wall time, not the ambient host clock. #[tokio::test] async fn harness_guest_observes_the_clock_override() { use std::time::{Duration, UNIX_EPOCH}; diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/crates/nexum-runtime/src/test_utils/mod.rs index dc2ffeea..073a6145 100644 --- a/crates/nexum-runtime/src/test_utils/mod.rs +++ b/crates/nexum-runtime/src/test_utils/mod.rs @@ -1,11 +1,10 @@ -//! Engine-side mock backends for launching an in-process runtime entirely on -//! fakes. +//! Engine-side mock backends for an in-process runtime on fakes. //! //! [`MockChainProvider`] and [`MockStateStore`] implement the component seam -//! traits with no network and no disk; [`Prebuilt`] wraps a pre-built instance -//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); and -//! [`MockTypes`] is the domain-free lattice that ties them together. The -//! assembly composes through the public builder path: +//! traits with no network or disk; [`Prebuilt`] wraps a pre-built instance +//! as a [`ComponentBuilder`](crate::host::component::ComponentBuilder); +//! [`MockTypes`] is the lattice tying them together. Compose through the +//! public builder path: //! //! ```no_run //! # use nexum_runtime::builder::RuntimeBuilder; @@ -28,9 +27,6 @@ //! # Ok(()) //! # } //! ``` -//! -//! The caller keeps its own clones of `chain` / `store` to program responses -//! and assert on what a module wrote. mod builders; mod chain; @@ -49,8 +45,7 @@ use crate::engine_config::ModuleLimits; use crate::host::component::Components; use crate::host::logs::LogPipeline; -/// A fresh in-memory [`LogPipeline`] at the default retention limits, the -/// one fake test bundles share so the construction lives in a single place. +/// A fresh in-memory [`LogPipeline`] at default retention limits. pub(crate) fn in_memory_logs() -> LogPipeline { LogPipeline::in_memory(ModuleLimits::default().logs()) } @@ -62,8 +57,7 @@ pub fn mock_components() -> Components { } /// A [`Components`] bundle over the given mock backends, with an empty -/// extension slot and an in-memory log pipeline. Pass clones the caller -/// retains for programming and assertion. +/// extension slot and an in-memory log pipeline. pub fn mock_components_from( chain: MockChainProvider, store: MockStateStore, @@ -89,11 +83,9 @@ mod tests { }; use crate::host::provider_pool::ProviderError; - /// M0 acceptance: a custom component set launched entirely through the - /// public builder, on fakes, with no CLI, no disk, and no network. The - /// launch reaches supervisor boot and bails only because the default - /// config declares no modules, which proves the mock backends composed - /// and the build path ran end to end. + /// A custom component set launches through the public builder on fakes; + /// it bails at boot only because the default config declares no modules, + /// proving the mock backends composed and the build path ran. #[tokio::test] async fn m0_custom_component_set_launches_through_the_public_builder() { let chain = MockChainProvider::new(); @@ -181,8 +173,7 @@ mod tests { assert!(item.is_ok(), "pushed header arrives as Ok"); } - /// A scripted error surfaces as an `Err` item on the block stream, and - /// closing the stream terminates it so a reconnect loop sees the end. + /// A scripted error surfaces as `Err`, and closing terminates the stream. #[tokio::test] async fn block_stream_scripts_errors_and_end() { let chain = MockChainProvider::new(); @@ -206,9 +197,8 @@ mod tests { ); } - /// After a close, a fresh `subscribe_blocks` (the event loop's reconnect - /// path) yields the items pushed since, so the fake keeps the real - /// provider's drop-then-reconnect delivery contract. + /// After a close, a fresh `subscribe_blocks` (the reconnect path) yields + /// the items pushed since, keeping the drop-then-reconnect contract. #[tokio::test] async fn closed_block_stream_rearms_for_the_reconnect_subscribe() { let chain = MockChainProvider::new(); @@ -226,9 +216,8 @@ mod tests { assert!(item.is_ok(), "pushed header arrives on the reopened stream"); } - /// The chain-log poller stream carries scripted errors and terminates on - /// close, mirroring the block leg. Each pushed log arrives as a one-log - /// canonical batch. + /// The chain-log poller carries scripted errors and terminates on close; + /// each pushed log arrives as a one-log canonical batch. #[tokio::test] async fn chain_log_stream_scripts_errors_and_end() { let chain = MockChainProvider::new(); @@ -266,8 +255,8 @@ mod tests { ); } - /// The store round-trips values, isolates namespaces, lists by prefix, and - /// rejects the empty namespace. + /// The store round-trips values, isolates namespaces, lists by prefix, + /// and rejects the empty namespace. #[test] fn store_roundtrips_and_isolates_namespaces() { let store = MockStateStore::new(); diff --git a/crates/nexum-runtime/src/test_utils/store.rs b/crates/nexum-runtime/src/test_utils/store.rs index edd3aa0b..56c7837b 100644 --- a/crates/nexum-runtime/src/test_utils/store.rs +++ b/crates/nexum-runtime/src/test_utils/store.rs @@ -11,9 +11,8 @@ use crate::host::local_store_redb::{MAX_APPLY_OPS, MAX_APPLY_VALUE_BYTES, Storag type Namespaces = HashMap>>; -/// Process-lifetime in-memory store keyed by namespace then key. Cheap `Arc` -/// clone shares one backing map, so a test keeps a clone to assert on what a -/// module wrote. +/// In-memory store keyed by namespace then key; cheap `Arc` clone shares one +/// backing map, so a test keeps a clone to assert on what a module wrote. #[derive(Clone, Default)] pub struct MockStateStore { namespaces: Arc>, diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/crates/nexum-runtime/src/test_utils/types.rs index 4180b328..16b20b55 100644 --- a/crates/nexum-runtime/src/test_utils/types.rs +++ b/crates/nexum-runtime/src/test_utils/types.rs @@ -6,13 +6,8 @@ use crate::host::component::RuntimeTypes; use crate::test_utils::{MockChainProvider, MockStateStore}; /// Lattice binding the mock backends. The extension slot is the type -/// parameter `E`, defaulting to the empty payload (`()`) so an assembly with -/// no extensions composes exactly as a domain-free lattice. An extension -/// crate binds its own `Ext` payload through the same mocks by naming -/// `MockTypes`. -/// -/// This is a type-level marker: it is only ever named, never constructed, so -/// it derives no traits and is zero-sized at runtime. +/// parameter `E` (default `()`); an extension crate binds its own payload as +/// `MockTypes`. A type-level marker, only ever named. pub struct MockTypes(PhantomData E>); impl crate::sealed::SealedRuntimeTypes for MockTypes {} From f5dcf3b955be213bb90265dd74b608ff833a97cb Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:29:35 +0000 Subject: [PATCH 129/139] docs: tersen rustdoc in videre-host-macros (#621) --- crates/videre-host/src/bindings.rs | 62 ++---- crates/videre-host/src/client.rs | 11 +- crates/videre-host/src/handshake.rs | 37 ++-- crates/videre-host/src/lib.rs | 40 ++-- crates/videre-host/src/registry.rs | 253 ++++++++---------------- crates/videre-host/tests/platform.rs | 136 +++++-------- crates/videre-macros/src/intent_body.rs | 27 +-- crates/videre-macros/src/keeper.rs | 26 +-- crates/videre-macros/src/lib.rs | 132 ++++--------- crates/videre-macros/src/world.rs | 20 +- 10 files changed, 256 insertions(+), 488 deletions(-) diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs index 3eea4883..4ab7f121 100644 --- a/crates/videre-host/src/bindings.rs +++ b/crates/videre-host/src/bindings.rs @@ -1,19 +1,12 @@ //! WIT bindings for the venue platform, generated by -//! `wasmtime::component::bindgen!`. -//! -//! The shared `nexum:host` interfaces are reused from the runtime's -//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` -//! impls and the `fault` type an adapter sees are the very ones the core -//! host constructs. The `videre:types` and `videre:value-flow` types -//! generate in the adapter bindgen and the client bindgen remaps onto -//! them, so one Rust type serves the registry and the adapter face alike. -//! `PartialEq` is derived so the registry can compare a polled status -//! against the last delivered one. +//! `wasmtime::component::bindgen!`. The shared `nexum:host` interfaces are +//! reused from the runtime bindings via `with`, and the client bindgen +//! remaps the `videre` types onto the adapter bindgen, so one Rust type +//! serves both faces. `PartialEq` is derived for polled-status comparison. -/// The provider face: the `videre:venue/venue-adapter` world. An adapter -/// imports only the scoped transport it needs (chain and messaging; -/// outbound HTTP is wasi:http, linked and allowlisted separately) and -/// exports the `videre:venue/adapter` face plus `init`. +/// The provider face: the `videre:venue/venue-adapter` world. Imports the +/// scoped transport (chain, messaging; HTTP is wasi:http) and exports the +/// `videre:venue/adapter` face plus `init`. mod venue_adapter { wasmtime::component::bindgen!({ path: [ @@ -36,13 +29,10 @@ mod venue_adapter { pub use venue_adapter::VenueAdapter; -/// The keeper-facing `videre:venue/client` import bound host-side. The -/// world imports the interface a module calls; the videre types it uses -/// are reused from the adapter bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the registry hands back to a module -/// are the very ones an adapter's `submit` produced. Async, because the -/// `Host` impl awaits the per-adapter mutex and the adapter's own async -/// guest calls. +/// The keeper-facing `videre:venue/client` import bound host-side. Reuses +/// the adapter bindings' videre types via `with`, so a module and an adapter +/// speak one `SubmitOutcome`/`VenueError`. Async: the `Host` impl awaits the +/// per-adapter mutex and the adapter's guest calls. mod client_host { wasmtime::component::bindgen!({ inline: " @@ -65,11 +55,9 @@ mod client_host { }); } -/// The host-bound client interface: the `Host` trait the registry implements -/// and the `add_to_linker` the videre extension's linker hook calls. +/// The host-bound client interface: the `Host` trait and `add_to_linker`. pub use client_host::videre::venue::client; -/// The shared intent ontology, re-exported at the plain spellings the -/// registry and the `client::Host` impl name. +/// The shared intent ontology at its plain spellings. pub use venue_adapter::videre::types::types::{ AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, UnsignedTx, VenueError, @@ -77,9 +65,8 @@ pub use venue_adapter::videre::types::types::{ /// The value-flow vocabulary the header is expressed in. pub use venue_adapter::videre::value_flow::types as value_flow; -/// Operator-log rendering of the wire `venue-error`: the bindgen -/// `Display` is the `{0:?}` debug form, so logs render through this -/// instead and the rate-limit `retry-after-ms` hint survives. +/// Operator-log rendering of a wire `venue-error`, preserving the +/// rate-limit `retry-after-ms` hint. pub(crate) fn venue_error_message(err: &VenueError) -> std::borrow::Cow<'_, str> { use std::borrow::Cow; match err { @@ -118,12 +105,9 @@ mod display_smoke { } } -/// Bindgen smoke for the `videre:value-flow` types package, compiled under -/// test through a throwaway world that imports the interface. Its value is -/// the identifier-hygiene gate: the test names every generated type, -/// variant, and field by its plain Rust spelling, so a WIT id that collided -/// with a Rust keyword would surface as an `r#` escape and fail to compile -/// here rather than in a downstream binding. +/// Identifier-hygiene smoke for `videre:value-flow`: naming every generated +/// type by its plain Rust spelling fails the build on a keyword collision +/// here rather than downstream. #[cfg(test)] mod value_flow_smoke { wasmtime::component::bindgen!({ @@ -154,13 +138,9 @@ mod value_flow_smoke { } } -/// Bindgen smoke for the `videre:types` and `videre:venue` packages, -/// mirroring the value-flow smoke above: a throwaway world imports the -/// client interface and, transitively, the types interface and its -/// value-flow dependency. The test names every generated type, case, and -/// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the five function signatures, so a keyword collision or an accidental -/// signature change fails this build rather than a downstream binding. +/// Identifier-hygiene and signature smoke for `videre:types`/`videre:venue`: +/// naming every generated type and pinning the five `client` signatures +/// fails the build on a keyword collision or signature change. #[cfg(test)] mod client_smoke { wasmtime::component::bindgen!({ diff --git a/crates/videre-host/src/client.rs b/crates/videre-host/src/client.rs index cbe27e70..7fa09d31 100644 --- a/crates/videre-host/src/client.rs +++ b/crates/videre-host/src/client.rs @@ -1,12 +1,7 @@ //! `videre:venue/client`: the keeper-facing venue import. Every method -//! resolves the shared [`VenueRegistry`] from the store's service map under -//! the videre namespace and delegates; the registry owns the venue -//! resolution, per-adapter serialisation, guard seam (advisory-only for -//! now), and quota. The caller identity the registry meters against is this -//! store's module namespace. No registry service means no venues, so every -//! call resolves to `unknown-venue`. The `Host` trait is local to this -//! crate's bindgen, so implementing it for the runtime's `HostState` is -//! orphan-legal. +//! resolves the shared [`VenueRegistry`] from the store's service map and +//! delegates, metering against this store's module namespace. No registry +//! service resolves every call to `unknown-venue`. use std::sync::Arc; diff --git a/crates/videre-host/src/handshake.rs b/crates/videre-host/src/handshake.rs index 2544bd49..7ef2d49a 100644 --- a/crates/videre-host/src/handshake.rs +++ b/crates/videre-host/src/handshake.rs @@ -1,8 +1,7 @@ //! Install-time body-version handshake over the `[venue]` manifest -//! section: a keeper declares the one body-schema version it encodes, -//! an adapter the set it decodes, and a keeper boots only when every -//! installed venue adapter decodes its version, since any of them is a -//! legal runtime submit target. +//! section: a keeper declares the one version it encodes, an adapter the +//! set it decodes, and a keeper boots only when every installed adapter +//! decodes its version. use std::collections::BTreeSet; @@ -42,11 +41,9 @@ fn parse Deserialize<'de>>(owner: &str, value: &toml::Value) -> anyh .map_err(|e| anyhow!("{owner} [venue]: {e}")) } -/// Admit one provider: a `[venue]` section, when present, must be the -/// adapter shape with a non-empty version set. -/// -/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an -/// intentional silent opt-out for non-venue keepers. +/// Admit one provider: a present `[venue]` section must be the adapter +/// shape with a non-empty version set. An absent section is admitted +/// (opt-out). pub(crate) fn admit_provider(provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { let Some(value) = sections.get(SECTION) else { return Ok(()); @@ -71,9 +68,8 @@ pub(crate) fn declared_versions( Ok(section.body_versions) } -/// Assert an adapter's `body-versions()` export equals its manifest -/// claim, refusing the install on divergence so the two sources of the -/// decode set cannot drift. +/// Assert an adapter's `body-versions()` export equals its manifest claim, +/// refusing the install on divergence. pub(crate) fn verify_exported_versions( provider: &str, declared: &BTreeSet, @@ -89,14 +85,10 @@ pub(crate) fn verify_exported_versions( Ok(()) } -/// The membership install predicate: a worker declaring `[venue] -/// body_version` is admitted only when every installed venue adapter's -/// `[venue] body_versions` set contains that version. Every installed -/// venue is a legal runtime submit target, so one non-decoding adapter -/// refuses the keeper. -/// -/// Opt-in: a manifest omitting `[venue]` is admitted unconditionally, an -/// intentional silent opt-out for non-venue keepers. +/// Membership predicate: a worker declaring `[venue] body_version` is +/// admitted only when every installed adapter's `body_versions` contains +/// it, so one non-decoding adapter refuses the keeper. An absent section is +/// admitted (opt-out). pub(crate) fn admit_worker( worker: &str, sections: &ExtensionSections, @@ -188,9 +180,8 @@ mod tests { assert!(msg.contains("cow decodes {1}"), "{msg}"); } - /// Membership is a conjunction over the installed adapters: one - /// non-decoding adapter refuses the keeper even when another decodes - /// its version, since either is a legal runtime submit target. + /// One non-decoding adapter refuses the keeper even when another decodes + /// its version. #[test] fn one_non_decoding_adapter_refuses_the_keeper() { let keeper = sections("[venue]\nbody_version = 2"); diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs index 82784126..2ff39410 100644 --- a/crates/videre-host/src/lib.rs +++ b/crates/videre-host/src/lib.rs @@ -1,8 +1,8 @@ -//! The videre venue platform, packaged as one [`nexum_runtime`] -//! extension: the venue-adapter provider kind, the [`VenueRegistry`] -//! service, the advisory [`EgressGuard`] seam, and the keeper-facing -//! `videre:venue/client` interface. A composition root registers it all -//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. +//! The videre venue platform as one [`nexum_runtime`] extension: the +//! venue-adapter provider kind, the [`VenueRegistry`] service, the advisory +//! [`EgressGuard`] seam, and the keeper-facing `videre:venue/client` +//! interface. A composition root wires it via +//! `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -33,12 +33,11 @@ pub use registry::{ VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, }; -/// Buffer for the status poll channel; small because the event loop -/// drains in real time. +/// Status-poll channel buffer. const STATUS_CHANNEL_BUF: usize = 64; /// The venue platform over the config-resolved quota and watch policy, -/// with the unit guard. The single registration entrypoint. +/// with the unit guard. pub fn platform(config: &EngineConfig) -> Videre { Videre::from_registry( VenueRegistryBuilder::new(config.limits.quota()) @@ -47,17 +46,14 @@ pub fn platform(config: &EngineConfig) -> Videre { ) } -/// The videre platform as one runtime extension. Registers the -/// `videre:venue/client` interface and its capability namespace on every -/// worker linker, publishes the [`VenueRegistry`] service, installs the -/// venue-adapter provider kind, and opens the status-poll event source. +/// The videre platform as one runtime extension. pub struct Videre { registry: Arc, } impl Videre { - /// Assemble over a pre-built registry, for a custom [`EgressGuard`] - /// or policy; [`platform`] covers the config-resolved default. + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] or + /// policy. pub fn from_registry(registry: VenueRegistry) -> Self { Self { registry: Arc::new(registry), @@ -70,9 +66,8 @@ impl Extension for Videre { VenueRegistry::NAMESPACE } - /// Only the keeper-facing `client` interface is a capability; the - /// `videre:types` and `videre:value-flow` packages are type-only and - /// need no declaration. + /// Only `client` is a capability; the type-only packages need no + /// declaration. fn capabilities(&self) -> NamespaceCaps { NamespaceCaps { prefix: "videre:venue/", @@ -121,10 +116,8 @@ impl Extension for Videre { &[INTENT_STATUS_KIND] } - /// The status poll source: on every cadence tick, poll each installed - /// adapter's status export through the shared registry and forward the - /// observed transitions. Opened only when a module subscribes and at - /// least one venue is installed. + /// The status-poll event source, opened only when a module subscribes + /// and a venue is installed. fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { if !sources.subscribed.contains(INTENT_STATUS_KIND) { return Ok(Vec::new()); @@ -143,9 +136,8 @@ impl Extension for Videre { } } -/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so -/// the engine's boot dispatch settles before the first poll; ends when -/// the event loop drops its receiver. +/// Poll loop behind [`Extension::events`]. Sleeps the cadence before each +/// poll; ends when the receiver drops. async fn status_poll_task( registry: VenueRegistry, cadence: Duration, diff --git a/crates/videre-host/src/registry.rs b/crates/videre-host/src/registry.rs index c33a70db..3f0efa46 100644 --- a/crates/videre-host/src/registry.rs +++ b/crates/videre-host/src/registry.rs @@ -1,29 +1,12 @@ -//! The venue registry: the keeper-facing `videre:venue/client` import -//! resolved to installed venue adapters. +//! The venue registry behind the keeper-facing `videre:venue/client` +//! import: resolves a venue id to its installed adapter and drives the +//! submit sequence (derive header, advisory [`EgressGuard`] seam, submit). +//! Status, cancel, and observe skip the header, guard, and quota. //! -//! A module's `client::submit(venue, body)` reaches the host here. The -//! registry resolves the venue id to the one installed adapter that answers -//! for it, then drives a fixed sequence against that adapter: derive the -//! header, run the guard interposition seam on it (advisory-only for now: -//! see [`EgressGuard`]), and only then submit. -//! Status and cancel are pass-throughs; they are not submissions, so they -//! skip the header, the guard, and the quota. Observe puts an -//! externally-obtained receipt under status watch without touching the -//! adapter at all. -//! -//! Invocation is serialised per adapter through the supervised-actor -//! primitive: each adapter sits behind its own [`ActorSlot`], so concurrent -//! client calls to the same venue queue while calls to different venues run -//! in parallel. The lock is held across the guest await, which is the whole -//! point - it is the actor boundary that keeps one adapter store -//! single-threaded. -//! -//! 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 quota gates every quote and submit before the adapter is -//! touched, and a decode failure (the adapter's `invalid-body`) is charged -//! to the calling module's quota, so a caller feeding garbage exhausts its -//! own budget rather than the adapter's. +//! Each adapter sits behind its own [`ActorSlot`], so calls to one venue +//! serialise while calls to different venues run in parallel. A per-caller +//! quota gates every quote and submit; a decode failure is charged to the +//! calling module, so a caller feeding garbage exhausts its own budget. use std::collections::{HashMap, VecDeque}; use std::fmt; @@ -47,17 +30,14 @@ use videre_status_body::StatusBody; use wasmtime::Store; use wasmtime::component::HasSelf; -/// The registry-observed status transition, carried in the `custom` -/// event's opaque payload, re-exported at the spelling the registry -/// names. +/// Status transition carried in the `custom` event payload. pub use videre_status_body::IntentStatusUpdate; use crate::bindings::{ IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, }; -/// Venue identifier: the id an adapter registers under and a submission -/// names. Opaque beyond equality. +/// Venue identifier an adapter registers under. Opaque beyond equality. #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct VenueId(String); @@ -86,14 +66,8 @@ impl fmt::Display for VenueId { } } -/// The guard interposition seam. The registry runs this on the -/// adapter-derived header after `derive-header` and before `submit`. -/// -/// Advisory-only: the checkpoint is not yet enforcing. A `Deny` verdict is -/// logged as a would-deny and the submission proceeds. The shipped policy is -/// the unit guard, which allows every egress; the egress-guard epic installs -/// the real facts-plus-analysers pipeline and turns the verdict enforcing, -/// without the registry changing shape. +/// Egress interposition run on the derived header between `derive-header` +/// and `submit`. Advisory-only: a `Deny` is logged, the submission proceeds. pub trait EgressGuard: Send + Sync { /// Decide whether the derived header may proceed to the adapter's submit. fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; @@ -106,9 +80,8 @@ impl EgressGuard for () { } } -/// 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. +/// What the guard sees. The raw body never reaches it, only the derived +/// header. pub struct GuardContext<'a> { /// Namespace of the calling module. pub caller: &'a str, @@ -122,20 +95,12 @@ pub struct GuardContext<'a> { pub enum GuardVerdict { /// Forward the submission to the adapter. Allow, - /// Refuse the egress with an operator-facing reason. Logged, not - /// enforced, while the seam is advisory-only. + /// Refuse with an operator-facing reason. Logged, not enforced. Deny(String), } -/// The per-adapter invocation seam. One installed adapter answers for exactly -/// one venue; the registry owns the adapter's `Store` behind an async mutex -/// and reaches it only through this trait, so the registry's sequencing and -/// quota logic is testable against a stub that never spins up a wasmtime -/// store. -/// -/// The futures are boxed so the registry can hold heterogeneous adapters -/// behind one `dyn` slot without the whole registry turning generic over an -/// adapter type it never names. +/// Per-adapter invocation seam, reached behind an async mutex. Boxed +/// futures so heterogeneous adapters share one `dyn` slot. pub trait VenueInvoker: Send { /// Project the opaque body onto the stable header the guard runs on. fn derive_header<'a>( @@ -150,19 +115,15 @@ pub trait VenueInvoker: Send { 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. + /// Report an intent's lifecycle state. 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 [`SupervisedStore`] plus the `venue-adapter` -/// bindings. Each guest call is refuelled by the primitive; a trap is -/// projected onto `unavailable` rather than propagated, because a -/// misbehaving adapter must not be the caller's fault and must not unwind -/// through the registry into the calling module's store. +/// Live adapter: a [`SupervisedStore`] plus its `venue-adapter` bindings. +/// A guest trap is projected onto `unavailable`, never propagated. pub struct VenueActor { actor: SupervisedStore, bindings: VenueAdapter, @@ -184,9 +145,7 @@ impl VenueActor { } } -/// Project an actor fault into the venue-error space. The fault carries -/// the root cause only, so an operator sees why the adapter died without -/// the wasm frame list leaking to the calling module. +/// Project an actor fault onto `unavailable`, carrying the root cause only. fn venue_fault(fault: ActorFault) -> VenueError { VenueError::Unavailable(format!("adapter {fault}")) } @@ -252,10 +211,9 @@ impl VenueInvoker for VenueActor { /// One installed adapter behind its serialising slot. type AdapterSlot = ActorSlot; -/// One installed venue: the adapter slot plus the liveness the supervisor's -/// sweep shares with the actor. A dead entry stays installed, so the venue -/// resolves to `unavailable` (temporarily dead) rather than `unknown-venue` -/// (never installed) until the sweep restarts it. +/// One installed venue: adapter slot plus shared liveness. A dead entry +/// stays installed, resolving to `unavailable` (not `unknown-venue`) until +/// the sweep restarts it. struct InstalledVenue { slot: AdapterSlot, liveness: Liveness, @@ -267,13 +225,10 @@ struct QuotaLedger { per_caller: HashMap>, } -/// One receipt the registry polls for status transitions. `last` starts -/// `None` so the first successful poll always reports, giving a -/// subscriber the intent's current state without waiting for a change. -/// `expires_at` is the give-up deadline, pushed a full `grace` window out -/// whenever the venue is reachable (it answered the poll, even if the body -/// then failed to encode); an unreachable venue rides out against it until -/// `grace` elapses. `None` (deadline arithmetic overflowed) never expires. +/// One receipt polled for status transitions. `last` starts `None` so the +/// first successful poll always reports. `expires_at` is the give-up +/// deadline, refreshed a `grace` window out whenever the venue is reachable; +/// `None` (arithmetic overflow) never expires. struct WatchedIntent { venue: VenueId, receipt: Vec, @@ -281,8 +236,8 @@ struct WatchedIntent { expires_at: Option, } -/// A polled status is terminal when the intent can never change again: -/// the registry stops watching the receipt after reporting it. +/// Whether a status is terminal, after which the receipt is dropped from +/// the watch. fn is_terminal(status: IntentStatus) -> bool { matches!( status, @@ -290,9 +245,8 @@ fn is_terminal(status: IntentStatus) -> bool { ) } -/// Lower a polled status onto the opaque status body the host `event` -/// stream carries. The registry attests the lifecycle state alone; proof -/// and failure reason ride the body only when the venue supplies them. +/// Lower a polled status onto the opaque status body. The registry attests +/// the lifecycle state only; proof and reason stay `None`. fn status_body(status: IntentStatus) -> StatusBody { use videre_status_body::IntentStatus as Lifecycle; @@ -310,25 +264,20 @@ fn status_body(status: IntentStatus) -> StatusBody { } } -/// The shared registry state. Cloning a [`VenueRegistry`] is an `Arc` bump; -/// every module store carries the same handle, so a submission from any -/// module reaches the same adapters and the same quota ledger. Adapters -/// install through the shared handle at provider boot, before any client -/// call routes. +/// Shared registry state behind the `Arc`. Every module store carries the +/// same handle, so all reach the same adapters and quota ledger. struct VenueRegistryInner { adapters: Mutex>, guard: Arc, quota: SubmitQuota, ledger: Mutex, watch_limit: WatchLimit, - /// Receipts under status watch, appended by accepted submissions and - /// explicit observes, pruned as they reach a terminal status, expire, - /// or overflow [`WatchLimit`]. + /// Receipts under status watch; pruned on terminal status, expiry, or + /// [`WatchLimit`] overflow. watched: Mutex>, } -/// The keeper-facing venue registry, cheap to clone and shared across every -/// module store. +/// The keeper-facing venue registry, cheap to clone. #[derive(Clone)] pub struct VenueRegistry { inner: Arc, @@ -338,18 +287,12 @@ pub struct VenueRegistry { impl HostService for VenueRegistry {} impl VenueRegistry { - /// Service namespace the registry publishes under: the videre - /// extension's. + /// Service namespace: the videre extension's. pub const NAMESPACE: &'static str = "videre"; - /// Install an adapter under its venue id, sharing `liveness` with its - /// invoker. Rejects a duplicate id while the incumbent is alive: two - /// adapters answering the same venue would silently shadow one another, - /// which is a config error worth failing boot over. A dead incumbent is - /// replaced: that is the sweep restarting a trapped adapter. - /// - /// Crate-internal: adapters install at provider boot, never post-boot - /// through a shared handle clone. + /// Install an adapter under its venue id, sharing `liveness`. Rejects a + /// duplicate id while the incumbent is alive; replaces a dead incumbent + /// (the sweep restarting a trapped adapter). pub(crate) fn install( &self, venue: VenueId, @@ -383,10 +326,8 @@ impl VenueRegistry { self.install(venue, liveness, invoker) } - /// Resolve a venue id to its installed adapter slot. An uninstalled - /// venue is `unknown-venue`; an installed but dead one is `unavailable` - /// pending the supervisor's restart sweep, without touching its - /// poisoned store. + /// Resolve a venue id to its slot: uninstalled is `unknown-venue`, + /// installed-but-dead is `unavailable` pending the restart sweep. fn resolve(&self, venue: &VenueId) -> Result { let adapters = self.inner.adapters.lock().expect("adapter map poisoned"); let installed = adapters.get(venue).ok_or(VenueError::UnknownVenue)?; @@ -398,8 +339,8 @@ impl VenueRegistry { Ok(Arc::clone(&installed.slot)) } - /// Whether `caller` has budget left in the current window. Read-only: it - /// prunes aged charges but does not record one. + /// Whether `caller` has budget left in the window. Prunes aged charges + /// but records none. 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(); @@ -415,18 +356,11 @@ impl VenueRegistry { 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 (advisory-only: a deny logs and the submission proceeds), then - /// forward to the adapter. A decode failure is charged to the - /// caller before returning, so a caller feeding garbage exhausts its own - /// budget and is stopped at the gate on the next call rather than - /// re-invoking the adapter. - /// - /// Charged once the header derives, ahead of the guard and adapter, so - /// a deny (when enforcing) or a venue outage is never a free retry. - /// Derive-stage venue errors other than a decode failure are left - /// uncharged and retryable. + /// Submit an opaque body to `venue` for `caller`: resolve, quota-gate, + /// derive the header, run the advisory guard, forward to the adapter. + /// Charged once the header derives (ahead of guard and adapter), plus on + /// a decode failure; other derive-stage errors stay uncharged and + /// retryable. pub async fn submit( &self, caller: &str, @@ -479,11 +413,8 @@ impl VenueRegistry { Ok(outcome) } - /// Price an opaque body at `venue` on behalf of `caller`. Not a - /// submission, so the header and guard are skipped (a quotation moves - /// no value), but it is adapter work on a caller-supplied body: the - /// caller's quota gates it and every quote spends one unit, so a - /// quote spammer exhausts its own budget, not the adapter's. + /// Price an opaque body at `venue` for `caller`. No header or guard, but + /// quota-gated: each quote spends one unit. pub async fn quote( &self, caller: &str, @@ -501,11 +432,9 @@ impl VenueRegistry { adapter.quote(&body).await } - /// Put an externally-obtained `(venue, receipt)` pair under status - /// watch: the `observe` half of the client face, for receipts the - /// registry never submitted (an accepted submit is watched implicitly). - /// Not a submission: no header, no guard, no quota; the watch cap - /// bounds it. Idempotent. + /// Put an externally-obtained `(venue, receipt)` under status watch, for + /// receipts the registry never submitted. No header, guard, or quota; + /// watch-cap bounded. Idempotent. pub fn observe(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { let _ = self.resolve(venue)?; if self.watch(venue, receipt) { @@ -515,11 +444,9 @@ impl VenueRegistry { } } - /// Put a `(venue, receipt)` pair under status watch, reporting - /// whether it is watched. Idempotent: a re-submitted receipt keeps - /// its existing watch entry. Bounded: expired entries evict first, - /// and at the cap the new watch is refused and logged rather than - /// an existing live watch dropped. + /// Put a `(venue, receipt)` under watch, reporting whether admitted. + /// Idempotent. Bounded: expired entries evict first; at the cap the new + /// watch is refused, not an existing one dropped. fn watch(&self, venue: &VenueId, receipt: Vec) -> bool { let (evicted, admitted) = { let mut watched = self.inner.watched.lock().expect("watch list poisoned"); @@ -562,14 +489,11 @@ impl VenueRegistry { .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. An unreachable venue (resolve failure or an - /// errored poll) leaves the entry to ride out against `grace` rather - /// than refreshing it; an entry whose `grace` has elapsed is evicted - /// unpolled and unreported. + /// Poll every watched receipt and return the transitions (statuses + /// differing from the last reported; the first successful poll always + /// reports). A terminal status is reported once, then dropped. An + /// unreachable venue rides out against `grace` rather than refreshing it; + /// an entry past `grace` is evicted unpolled. pub async fn poll_status_transitions(&self) -> Vec { // Snapshot so the std mutex is never held across the guest await. let (evicted, snapshot): (usize, Vec<(VenueId, Vec)>) = { @@ -619,14 +543,11 @@ impl VenueRegistry { updates } - /// Fold one polled status into the watch entry: `Some(update)` when it - /// differs from the last reported status. The venue answered, so it is - /// reachable: every path here refreshes the give-up deadline (or drops - /// the entry on a terminal status reported cleanly), so a reachable - /// venue never expires this cadence. An encode failure therefore costs - /// the update, not the watch: the deadline is still refreshed and the - /// entry retried next cadence. `None` also covers an entry that - /// disappeared while the poll was in flight. + /// Fold one polled status into the watch entry: `Some(update)` on a + /// change. The venue answered, so every path refreshes the deadline (or + /// drops the entry on a clean terminal); an encode failure costs the + /// update, not the watch. `None` also covers an entry gone while the poll + /// was in flight. fn record_polled_status( &self, venue: &VenueId, @@ -672,8 +593,7 @@ impl VenueRegistry { } } - /// Report where a previously submitted intent is in its life. Not a - /// submission: no header, no guard, no quota, just the serialised call. + /// Report an intent's lifecycle state. No header, guard, or quota. pub async fn status( &self, venue: &VenueId, @@ -684,8 +604,7 @@ impl VenueRegistry { adapter.status(receipt).await } - /// Ask the venue to withdraw an intent. Not a submission, so it skips the - /// header, guard, and quota like `status`. + /// Ask the venue to withdraw an intent. No header, guard, or quota. pub async fn cancel(&self, venue: &VenueId, receipt: Vec) -> Result<(), VenueError> { let slot = self.resolve(venue)?; let mut adapter = slot.lock().await; @@ -702,9 +621,8 @@ impl VenueRegistry { } } -/// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` -/// component and installs its actor in the venue registry. Registered -/// through the videre extension's provider slot. +/// Provider kind that boots a `videre:venue/venue-adapter` component and +/// installs its actor in the registry. pub struct VenueAdapterKind; impl VenueAdapterKind { @@ -821,10 +739,9 @@ fn prune(history: &mut VecDeque, window: Duration) { } } -/// Assembles a [`VenueRegistry`]'s policy: guard, quota, and watch bounds -/// freeze at build; adapters install afterwards through the shared handle -/// at provider boot. The guard defaults to the unit guard; the egress-guard -/// epic overrides it here. +/// Assembles a [`VenueRegistry`]'s policy: guard, quota, watch bounds. +/// Adapters install afterwards at provider boot. Guard defaults to the unit +/// guard. pub struct VenueRegistryBuilder { guard: Arc, quota: SubmitQuota, @@ -832,8 +749,8 @@ pub struct VenueRegistryBuilder { } impl VenueRegistryBuilder { - /// Start an empty builder with the given quota, the unit guard, and - /// the default watch limit. + /// Builder with the given quota, the unit guard, and the default watch + /// limit. pub fn new(quota: SubmitQuota) -> Self { Self { guard: Arc::new(()), @@ -842,9 +759,7 @@ impl VenueRegistryBuilder { } } - /// Override the guard policy. The egress-guard epic wires the real - /// pipeline through here; tests inject a denying policy to prove the - /// advisory seam. + /// Override the guard policy. pub fn with_guard(mut self, guard: Arc) -> Self { self.guard = guard; self @@ -924,9 +839,8 @@ mod tests { } } - /// A programmable adapter that records call counts and returns canned - /// outcomes, so the registry's sequencing, guard seam, and quota are - /// tested without a wasmtime store. + /// Programmable adapter recording call counts, so routing is tested + /// without a wasmtime store. #[derive(Default)] struct StubCalls { derive: AtomicUsize, @@ -934,8 +848,8 @@ mod tests { submit: AtomicUsize, status: AtomicUsize, cancel: AtomicUsize, - /// Highest number of overlapping invocations observed; proves the - /// per-adapter mutex serialises access. + /// Highest overlapping invocation count observed; proves the mutex + /// serialises. max_concurrency: AtomicUsize, live: AtomicUsize, } @@ -944,8 +858,7 @@ mod tests { calls: Arc, derive: Result, submit: Result, - /// Accept each submission with its body as the receipt, so one - /// stub can mint distinct receipts. + /// Accept each submission with its body as the receipt. echo_receipt: bool, /// Statuses served front-first by consecutive `status` calls; /// once drained, every further call reports `open`. diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index 2dc39e33..e8e87411 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -1,8 +1,7 @@ -//! E2E coverage for the videre platform over the generic runtime seam: -//! the venue-adapter provider boot, the client -> registry -> adapter -//! round trip, the status-poll event source, and the trap-to-recovery -//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when -//! an artefact is absent. +//! E2E coverage for the videre platform over the generic runtime seam: the +//! venue-adapter provider boot, the client -> registry -> adapter round +//! trip, the status-poll event source, and the trap-to-recovery sweeps. +//! Skips gracefully when a wasm artefact is absent. use std::collections::VecDeque; use std::path::{Path, PathBuf}; @@ -67,8 +66,8 @@ fn make_wasmtime_engine() -> wasmtime::Engine { wasmtime::Engine::new(&config).expect("wasmtime engine") } -/// The platform under test plus the extension slice the boot paths take. -/// The concrete handle stays available for the event-source calls. +/// The platform's extension slice, keeping the concrete handle for +/// event-source calls. fn videre_assembly(videre: &Arc) -> Vec>> { vec![Arc::clone(videre) as Arc>] } @@ -80,8 +79,7 @@ fn make_linker( build_linker::(engine, extensions).expect("build_linker") } -/// The registry the booted supervisor publishes under the videre -/// namespace. +/// The registry the booted supervisor publishes. fn registry_of(supervisor: &Supervisor) -> Arc { supervisor .services() @@ -99,9 +97,7 @@ fn block(chain_id: u64) -> nexum::host::types::Block { } } -/// Wrap a polled transition as the extension event the platform emits: -/// the transition rides the generic `custom` channel, its tagged borsh -/// envelope the opaque payload. +/// Wrap a polled transition as the extension event the platform emits. fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { let attrs = vec![("venue", update.venue.clone())]; let payload = update.encode().expect("encode intent-status envelope"); @@ -117,11 +113,8 @@ fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { // ── world contract ──────────────────────────────────────────────────── -/// The per-component venue-adapter world contract: an adapter built -/// through `#[videre_sdk::venue]` imports exactly the scoped -/// transport its manifest declares (`chain`), by construction of the -/// emitted world. The venue side never depended on toolchain elision; -/// this pins that it does not regress to it. +/// An adapter built through `#[videre_sdk::venue]` imports exactly the +/// scoped transport its manifest declares (`chain`). #[test] fn e2e_echo_venue_component_imports_equal_declared_capabilities() { let Some(wasm) = module_wasm_or_skip("echo-venue") else { @@ -159,9 +152,8 @@ fn e2e_echo_venue_component_imports_equal_declared_capabilities() { ); } -/// The shipped cow adapter honours the same contract: outbound HTTP is -/// its only capability, so the component structurally cannot reach -/// chain, messaging, host key material, or persistence. +/// The shipped cow adapter's only capability is outbound HTTP, so it cannot +/// reach chain, messaging, key material, or persistence. #[test] fn e2e_cow_venue_component_imports_equal_declared_capabilities() { let wasm = workspace_path("target/wasm32-wasip2/release/cow_venue.wasm"); @@ -200,10 +192,8 @@ fn e2e_cow_venue_component_imports_equal_declared_capabilities() { ); } -/// The venue-adapter provider linker binds only the scoped transport -/// (chain, messaging, wasi base, allowlisted http) and withholds the -/// core-only interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. +/// The venue-adapter provider linker binds only the scoped transport and +/// withholds the core-only interfaces, without a duplicate-definition clash. #[tokio::test] async fn provider_linker_assembles_with_scoped_transport() { let engine = make_wasmtime_engine(); @@ -213,9 +203,8 @@ async fn provider_linker_assembles_with_scoped_transport() { // ── intent-status subscription E2E ──────────────────────────────────── -/// A scripted venue adapter for the registry: accepts every submission -/// with a fixed receipt and serves statuses front-first from a script; -/// once drained, every further call reports `open`. +/// Scripted registry adapter: accepts every submission with a fixed receipt +/// and serves statuses front-first; once drained, reports `open`. struct ScriptedAdapter { statuses: VecDeque, } @@ -353,10 +342,8 @@ async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Sup .expect("boot_single") } -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the registry observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. +/// A module subscribed to `intent-status` receives the polled transitions; +/// a transition outside its venue filter is not delivered. #[tokio::test] async fn e2e_intent_status_subscription_receives_polled_transitions() { let Some(wasm) = module_wasm_or_skip("example") else { @@ -415,9 +402,8 @@ async fn e2e_intent_status_subscription_receives_polled_transitions() { ); } -/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots on the venue -/// platform with its shipped manifest and handles a delivered cow status -/// transition without trapping. +/// ethflow-watcher (built by `#[videre_sdk::keeper]`) boots with its shipped +/// manifest and handles a delivered cow status transition without trapping. #[tokio::test] async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { @@ -453,10 +439,9 @@ async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { assert_eq!(supervisor.alive_count(), 1); } -/// The event-loop wiring, through the real seam: the platform's `events` -/// source opens against the booted service map, its poll task drives the -/// supervisor, and the module's handler observably ran (its log line is -/// retained). +/// The event-loop wiring through the real seam: the platform's `events` +/// source opens, its poll task drives the supervisor, and the module's +/// handler observably ran. #[tokio::test] async fn e2e_intent_status_flows_through_the_event_loop() { use nexum_tasks::{TaskManager, TaskSet}; @@ -540,8 +525,8 @@ async fn e2e_intent_status_flows_through_the_event_loop() { ); } -/// With no subscriber (or no installed venue) the platform opens no -/// event source. +/// With no subscriber or no installed venue, the platform opens no event +/// source. #[tokio::test] async fn event_source_stays_closed_without_subscribers_or_venues() { use nexum_tasks::{TaskManager, TaskSet}; @@ -573,12 +558,10 @@ async fn event_source_stays_closed_without_subscribers_or_venues() { // ── echo round trip ─────────────────────────────────────────────────── -/// The acceptance path, end to end over two real components: the -/// echo-client module submits through `videre:venue/client`, the host -/// registry forwards to the installed echo-venue adapter, and the module -/// receives the fulfilled `intent-status` the registry polls back. Proves -/// the intent core round-trips module -> host registry -> venue adapter -/// with no scripted stand-ins on either side. +/// End to end over two real components: the echo-client module submits +/// through `videre:venue/client`, the host registry forwards to the +/// echo-venue adapter, and the module receives the fulfilled +/// `intent-status` polled back. #[tokio::test] async fn e2e_echo_module_registry_adapter_round_trip() { let (Some(adapter_wasm), Some(module_wasm)) = ( @@ -686,13 +669,10 @@ async fn e2e_echo_module_registry_adapter_round_trip() { ); } -/// The blessed keeper path over the same two real components: the -/// echo-keeper module (built by `#[videre_sdk::keeper]`) drives the -/// echo-venue adapter through the typed `VenueClient` - -/// quote, submit, status, cancel, all with a typed body - and receives -/// the fulfilled `intent-status` the registry polls back. Proves the -/// macro-emitted worker and the typed client end to end, with no -/// hand-written byte marshalling on the keeper side. +/// The keeper path over two real components: the echo-keeper module (built +/// by `#[videre_sdk::keeper]`) drives the echo-venue adapter through the +/// typed `VenueClient` (quote, submit, status, cancel) and +/// receives the fulfilled `intent-status` polled back. #[tokio::test] async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { let (Some(adapter_wasm), Some(module_wasm)) = ( @@ -774,11 +754,9 @@ async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { } } -/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` -/// worker) boots against the installed cow adapter - the body-version -/// handshake admits the pair - and a Sepolia block dispatch reaches it -/// and keeps it alive. The chainless poll surfaces a fault the strategy -/// absorbs, so no orderbook traffic occurs. +/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` worker) +/// boots against the cow adapter (the body-version handshake admits the +/// pair) and a Sepolia block dispatch reaches it and keeps it alive. #[tokio::test] async fn e2e_twap_monitor_boots_against_the_cow_adapter() { let (Some(adapter_wasm), Some(module_wasm)) = ( @@ -823,9 +801,8 @@ async fn e2e_twap_monitor_boots_against_the_cow_adapter() { assert_eq!(supervisor.alive_count(), 1); } -/// The body-version handshake refuses a mismatched pair: an adapter -/// decoding only v1 against a keeper encoding v2 fails the boot at the -/// keeper's install, before instantiation, naming both sides' versions. +/// The body-version handshake refuses a mismatched pair: an adapter decoding +/// only v1 against a keeper encoding v2 fails the boot before instantiation. #[tokio::test] async fn e2e_mismatched_body_versions_refuse_the_pair_at_boot() { let (Some(adapter_wasm), Some(module_wasm)) = ( @@ -897,9 +874,8 @@ body_version = 2 assert!(chain.contains("echo-venue decodes {1}"), "{chain}"); } -/// An adapter whose manifest claims versions its code does not decode -/// fails its own install: the `body-versions()` export must equal the -/// manifest `[venue] body_versions` set. +/// An adapter whose `body-versions()` export diverges from its manifest +/// `[venue] body_versions` fails its own install. #[tokio::test] async fn e2e_manifest_export_divergence_refuses_the_adapter_at_boot() { let Some(adapter_wasm) = module_wasm_or_skip("echo-venue") else { @@ -951,9 +927,8 @@ body_versions = [1, 2] // ── venue-adapter trap recovery ─────────────────────────────────────── -/// Boot one flaky-venue adapter over the mock chain, whose head starts at -/// the fixture's poison sentinel. Returns the chain handle so the test can -/// let the venue recover. +/// Boot one flaky-venue adapter over the mock chain, its head at the +/// fixture's poison sentinel. Returns the chain handle for recovery. async fn boot_flaky_venue( adapter_wasm: PathBuf, limits: ModuleLimits, @@ -982,10 +957,9 @@ async fn boot_flaky_venue( (supervisor, chain) } -/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped -/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the -/// provider restart sweep reinstantiates it after backoff, after which a -/// submit succeeds again. +/// The trap-to-recovery lifecycle over a real wasm adapter: a trapped venue +/// is `unavailable` (not `unknown-venue`), the restart sweep reinstantiates +/// it after backoff, and a submit then succeeds again. #[tokio::test] async fn e2e_trapped_adapter_is_swept_and_restarts() { let Some(wasm) = module_wasm_or_skip("flaky-venue") else { @@ -1035,9 +1009,8 @@ async fn e2e_trapped_adapter_is_swept_and_restarts() { assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); } -/// A crash-looping adapter is quarantined by the provider poison sweep: -/// at the threshold the restarts stop, and the venue stays dead past every -/// backoff until an operator intervenes. +/// A crash-looping adapter is quarantined by the poison sweep: at the +/// threshold the restarts stop and the venue stays dead. #[tokio::test] async fn e2e_crash_looping_adapter_is_poisoned() { let Some(wasm) = module_wasm_or_skip("flaky-venue") else { @@ -1084,11 +1057,9 @@ async fn e2e_crash_looping_adapter_is_poisoned() { // ── service-missing unknown-venue ───────────────────────────────────── -/// The videre platform with its registry service withheld: forwards -/// every face `boot_single` consults to [`Videre`] save `service`, which +/// The videre platform with its registry service withheld: `service` /// returns `None`, so `HostServices::from_extensions` seeds no venue -/// registry. The provider and event faces default (unused under -/// `boot_single`). +/// registry. struct ClientWithoutRegistry(Videre); impl Extension for ClientWithoutRegistry { @@ -1122,12 +1093,9 @@ impl Extension for ClientWithoutRegistry { } } -/// The service-lookup miss, end to end: a keeper importing -/// `videre:venue/client` boots through `boot_single` with no registry -/// service seeded, so `client.rs` resolves every venue call to -/// `unknown-venue` before any adapter is consulted. Distinct from the -/// adapter-map miss, where the registry is present and the venue id is -/// merely unlisted. +/// The service-lookup miss: with no registry service seeded, `client.rs` +/// resolves every venue call to `unknown-venue`, distinct from the +/// adapter-map miss where the registry is present but the venue id unlisted. #[tokio::test] async fn client_without_registry_service_resolves_every_venue_to_unknown() { let Some(wasm) = module_wasm_or_skip("echo-client") else { diff --git a/crates/videre-macros/src/intent_body.rs b/crates/videre-macros/src/intent_body.rs index 8ebbc3d6..bb0dc82a 100644 --- a/crates/videre-macros/src/intent_body.rs +++ b/crates/videre-macros/src/intent_body.rs @@ -1,27 +1,16 @@ -//! 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 -//! (`::videre_sdk`), so the derive is only usable through that -//! crate's re-export. The expansion names only `::core` and the SDK's -//! `__private` re-exports (borsh, `alloc`), so a `#![no_std]` consumer -//! needs no `extern crate alloc`. +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a per-venue +//! version enum. Enforces the outer-enum shape (newtype variants, one body +//! version each) and emits `to_bytes`/`from_bytes` over the borsh enum +//! layout (a one-byte version tag at the variant index). An unknown version +//! surfaces as `BodyError::UnknownVersion`. Names only `::core` and the +//! SDK's `__private` re-exports, so a `#![no_std]` consumer needs no +//! `extern crate alloc`. 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. +/// Expand the derive input into the `IntentBody` impl. pub(crate) fn expand(input: &DeriveInput) -> syn::Result { let name = &input.ident; diff --git a/crates/videre-macros/src/keeper.rs b/crates/videre-macros/src/keeper.rs index 465f701d..a6a3fa74 100644 --- a/crates/videre-macros/src/keeper.rs +++ b/crates/videre-macros/src/keeper.rs @@ -1,18 +1,14 @@ -//! Expansion for `#[keeper]`: the keeper-worker mirror of `#[module]`. -//! -//! Same world synthesis and event dispatch as the plain module macro, -//! with the keeper deltas: the `client` capability is required, the -//! videre interfaces remap onto the SDK bindings (one shim set, one -//! type identity for the typed client), async handlers complete via -//! `videre_sdk::client::poll_once`, and `ClientError` folds into the wire -//! fault so `?` works in handlers. +//! Expansion for `#[keeper]`: the worker mirror of `#[module]`. Same world +//! synthesis and event dispatch, with the keeper deltas: the `client` +//! capability is required, the videre interfaces remap onto the SDK +//! bindings, async handlers complete via `videre_sdk::client::poll_once`, +//! and `ClientError` folds into the wire fault so `?` works in handlers. use proc_macro2::TokenStream; use quote::quote; use syn::{ImplItem, ItemImpl}; -/// The handler names recognised on a `#[keeper]` impl; the `#[module]` -/// set, since a keeper is a plain worker. +/// The handler names recognised on a `#[keeper]` impl. const HANDLERS: [&str; 6] = [ "init", "on_block", @@ -35,8 +31,7 @@ const CLIENT_PACKAGES: [&str; 3] = ["videre-value-flow", "videre-types", "videre /// The fault detail for a handler future that suspended. const SUSPENDED: &str = "keeper handler suspended: guest futures complete in one poll"; -/// Expand the handler impl into the keeper module glue, or a compile -/// error naming the rule the input broke. +/// Expand the handler impl into the keeper module glue. pub(crate) fn expand(input: &ItemImpl) -> syn::Result { let self_ty = &input.self_ty; if !nexum_world::is_plain_type(self_ty) { @@ -258,10 +253,9 @@ fn client_row() -> nexum_world::ExtensionRow { } } -/// Read the consuming crate's `module.toml`, require the worker shape -/// (no `[module] kind`) and the `client` capability, and synthesize the -/// per-module world with the client extension row guaranteed. Returns -/// the rebuild anchor paths alongside the world. +/// Read `module.toml`, require the worker shape (no `[module] kind`) and +/// the `client` capability, and synthesize the module world with the client +/// extension row. Returns the rebuild anchor paths and the world. fn derive_keeper_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { let crate_dir = nexum_world::manifest_dir()?; let manifest_path = crate_dir.join("module.toml"); diff --git a/crates/videre-macros/src/lib.rs b/crates/videre-macros/src/lib.rs index 4c21451a..1c78a137 100644 --- a/crates/videre-macros/src/lib.rs +++ b/crates/videre-macros/src/lib.rs @@ -1,24 +1,12 @@ -//! Proc-macro glue for the two videre personas. +//! Proc-macro glue for the videre personas, reached through the SDK +//! re-exports (`videre_sdk::venue`/`keeper`/`IntentBody`), not directly. //! -//! [`venue`] is the single blessed venue authoring path: applied to an -//! `impl VenueAdapter` block it emits the per-cdylib wit-bindgen for a -//! manifest-derived world exporting `videre:venue/adapter`, asserts the -//! manifest kind, and expands to the SDK's internal export codegen. -//! -//! [`keeper`] is its worker mirror: applied to a handler impl it emits -//! the per-cdylib wit-bindgen for a manifest-derived module world, -//! wires the `videre:venue/client` import onto the SDK's shared shims, -//! and dispatches events to the handlers, completing async ones on the -//! synchronous guest boundary. -//! -//! [`derive@IntentBody`] implements the venue SDK's versioned body codec -//! over a per-venue version enum. -//! -//! The plain module macro (`#[module]`) lives in `nexum-module-macros`. -//! -//! Consumers reach these through the SDK re-exports -//! (`videre_sdk::venue`, `videre_sdk::keeper`, `videre_sdk::IntentBody`) -//! rather than depending on this crate directly. +//! - [`venue`]: on an `impl VenueAdapter` block, emits the per-cdylib +//! wit-bindgen for a manifest-derived venue-adapter world plus the SDK +//! export codegen. +//! - [`keeper`]: the worker mirror, wiring the `videre:venue/client` import +//! onto the SDK shims and dispatching events to the handler impl. +//! - [`derive@IntentBody`]: the versioned body codec over a per-venue enum. mod intent_body; mod keeper; @@ -29,18 +17,11 @@ use proc_macro::TokenStream; use quote::quote; use syn::{DeriveInput, ItemImpl}; -/// Derive the venue SDK's `IntentBody` codec on the outer per-venue -/// version enum: one newtype variant per published body version, each -/// payload a borsh type. -/// -/// The wire form is the borsh enum layout (a one-byte tag, the variant's -/// declaration index, then the borsh payload), so the tag order is the -/// schema: append new versions, never reorder. Decoding an unknown tag -/// fails typedly as `BodyError::UnknownVersion`. -/// -/// Generated code resolves the SDK by crate path, so use the -/// `videre_sdk::IntentBody` re-export with `videre-sdk` as a -/// direct dependency. +/// Derive the `IntentBody` codec on a per-venue version enum: one newtype +/// variant per body version. The wire form is the borsh enum layout (a +/// one-byte tag at the variant's declaration index), so append versions, +/// never reorder; an unknown tag fails as `BodyError::UnknownVersion`. Use +/// the `videre_sdk::IntentBody` re-export. #[proc_macro_derive(IntentBody)] pub fn derive_intent_body(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); @@ -49,47 +30,28 @@ pub fn derive_intent_body(input: TokenStream) -> TokenStream { .into() } -/// The manifest `kind` a venue adapter must declare. Mirrors the -/// venue-adapter provider kind's manifest spelling. +/// The manifest `kind` a venue adapter must declare. const VENUE_KIND: &str = "venue-adapter"; /// Generate the per-cdylib glue for a venue adapter. /// -/// Apply to the adapter's `impl VenueAdapter for MyVenue` block: the -/// macro reads the crate's `module.toml`, asserts its `[module] kind` -/// is `venue-adapter`, synthesizes a per-component world exporting the -/// `videre:venue/adapter` face and importing exactly the manifest's -/// declared scoped transport, then emits `wit_bindgen::generate!`, the -/// untouched trait impl, and the SDK's internal export codegen wiring -/// the world's `Guest` faces through the trait. So the built component -/// imports what the manifest declares and nothing else, by construction -/// of the emitted world. -/// -/// The generated world remaps `videre:types/types`, -/// `videre:value-flow/types`, and `nexum:host/types` onto the SDK's -/// bindings, so the impl speaks `videre_sdk` types directly and shares -/// type identity with the conformance kit and the client core. -/// -/// A venue's capabilities are scoped transport only: an undeclared -/// capability's bindings do not exist (using one is a compile error), -/// and a capability outside the venue-permitted set (`chain`, -/// `messaging`, `http`) is rejected at expansion. -/// -/// The same crate-root resolution invariants as `#[module]` apply: the -/// wit-bindgen output lands at the module crate root (so the export -/// codegen resolves `Guest`, `exports`, and `export!` there), and the -/// consuming crate must declare `wit-bindgen` and `videre-sdk` as -/// direct dependencies. +/// Apply to an `impl VenueAdapter for MyVenue` block: reads `module.toml`, +/// asserts `[module] kind = venue-adapter`, synthesizes a world exporting +/// `videre:venue/adapter` and importing exactly the manifest's declared +/// scoped transport, then emits `wit_bindgen::generate!`, the trait impl, +/// and the SDK export codegen. The world remaps the `videre` and +/// `nexum:host` types onto the SDK bindings, so the impl speaks `videre_sdk` +/// types directly. A capability outside the venue-permitted set (`chain`, +/// `messaging`, `http`) is rejected at expansion. The consuming crate must +/// declare `wit-bindgen` and `videre-sdk` as direct dependencies. /// /// # Client marker /// -/// Given arguments (`#[videre_sdk::venue(id = "cow", body = CowBody)]`) -/// the attribute instead fills a client-side `impl Venue for Marker {}`: -/// it emits the `const ID`/`type Body` from the args and, at expansion, -/// asserts the id equals the crate manifest's `[module] name`. No -/// component world is generated, so a keeper linking the client slice -/// never pulls adapter bindgen. This form expands on host and wasm alike -/// and is opt-in: hand-written `Venue` impls keep compiling. +/// With arguments (`#[videre_sdk::venue(id = "cow", body = CowBody)]`) it +/// instead fills a client-side `impl Venue for Marker {}`, emitting the +/// `const ID`/`type Body` and asserting the id equals `[module] name`. No +/// component world, so a keeper linking the client slice never pulls adapter +/// bindgen. #[proc_macro_attribute] pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(item as ItemImpl); @@ -181,28 +143,19 @@ pub fn venue(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Generate the per-cdylib glue for a keeper: a worker that drives -/// venues through the typed client. +/// Generate the per-cdylib glue for a keeper: a worker driving venues +/// through the typed client. /// -/// The keeper-author mirror of `#[module]`. Apply to an `impl` block -/// whose associated functions are the event handlers (`init`, +/// Apply to an `impl` block whose functions are the event handlers (`init`, /// `on_block`, `on_chain_logs`, `on_tick`, `on_message`, -/// `on_intent_status`); handlers may be `async` and are completed on -/// the synchronous guest boundary (`videre_sdk::client::poll_once`), so a -/// handler can await the typed `VenueClient` directly. -/// -/// The macro reads the crate's `module.toml`, requires the `client` -/// capability (the `videre:venue/client` import is what makes a keeper -/// a keeper), synthesizes the per-module world exactly as `#[module]` -/// does, and remaps the videre interfaces onto the SDK bindings: the -/// module's client import resolves to the SDK's shared shims, so the -/// `VenueClient` a handler drives and the wire speak one set of types. -/// A `From` impl onto the wire fault is emitted, so `?` -/// applies to client calls inside handlers. -/// -/// The same crate-root resolution invariants as `#[module]` apply, and -/// the consuming crate must declare `wit-bindgen`, `videre-sdk`, and -/// `nexum-sdk` as direct dependencies. +/// `on_intent_status`); handlers may be `async`, completed on the guest +/// boundary, so one can await the typed `VenueClient` directly. Reads +/// `module.toml`, requires the `client` capability, synthesizes the module +/// world as `#[module]` does, and remaps the videre interfaces onto the SDK +/// bindings so the client and wire share one type set. Emits a +/// `From` onto the wire fault, so `?` works in handlers. The +/// consuming crate must declare `wit-bindgen`, `videre-sdk`, and `nexum-sdk` +/// as direct dependencies. #[proc_macro_attribute] pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { @@ -219,10 +172,9 @@ pub fn keeper(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Read the consuming crate's `module.toml`, assert it declares the -/// venue-adapter kind, and synthesize the per-component venue-adapter -/// world from its `[capabilities]` declarations. Returns the manifest -/// path (for the rebuild anchor) alongside the world. +/// Read `module.toml`, assert the venue-adapter kind, and synthesize the +/// venue-adapter world from its `[capabilities]`. Returns the manifest path +/// (rebuild anchor) and the world. fn derive_venue_world() -> Result<(String, nexum_world::ModuleWorld), String> { let manifest_path = nexum_world::manifest_dir()?.join("module.toml"); let text = std::fs::read_to_string(&manifest_path).map_err(|e| { diff --git a/crates/videre-macros/src/world.rs b/crates/videre-macros/src/world.rs index 081a4b3f..3f6fd283 100644 --- a/crates/videre-macros/src/world.rs +++ b/crates/videre-macros/src/world.rs @@ -4,21 +4,15 @@ pub use nexum_world::ModuleWorld; -/// Capabilities a venue adapter may import. A venue speaks one venue's -/// protocol over scoped transport and nothing else: chain RPC, -/// 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. +/// Capabilities a venue adapter may import: scoped transport only (chain, +/// messaging, and HTTP via the SDK's wasi:http client). `local-store`, +/// `remote-store`, `identity`, and `logging` are refused. 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 -/// `videre:venue/adapter` face and imports exactly the declared scoped -/// transport, so a macro-built adapter's imports equal its declarations -/// by construction. A capability outside the venue-permitted set is a -/// compile error: an adapter that reaches for host key material or -/// persistent state is rejected at expansion, not at boot. +/// Build the venue-adapter world from the declared capability names: exports +/// `init` and the `videre:venue/adapter` face, imports exactly the declared +/// scoped transport. A capability outside the venue-permitted set is a +/// compile error. pub fn synthesize_venue(declared: &[String]) -> Result { for name in declared { if !VENUE_CAPABILITIES.contains(&name.as_str()) { From 8c0eb96691d79a5fc4f224c9d132358db6490d85 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:30:39 +0000 Subject: [PATCH 130/139] docs: tersen rustdoc in runtime supervisor (#622) Compress engine_config.rs, supervisor.rs, and supervisor/tests.rs doc comments to the terse house style: one-line item docs, config fields cut to their contract (units, defaults, saturation), restart and poison invariants kept but compressed, and the config-schema, history, and rationale essays dropped. Part of #613. --- crates/nexum-runtime/src/engine_config.rs | 398 +++++---------- crates/nexum-runtime/src/supervisor.rs | 499 +++++++------------ crates/nexum-runtime/src/supervisor/tests.rs | 156 ++---- 3 files changed, 342 insertions(+), 711 deletions(-) diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index c1b821d7..cea6c719 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -1,20 +1,9 @@ -//! Engine-side runtime configuration. +//! Engine-side runtime configuration (`engine.toml`): chain RPC +//! endpoints, local-store location, and per-module resource limits. +//! Distinct from a module's `module.toml` manifest. //! -//! Distinct from `module.toml` (module manifest): this file describes -//! the *engine*'s I/O wiring - chain RPC endpoints and the on-disk -//! location of the `local-store` database. Both are required for the -//! 0.2 reference engine to do anything other than print stubs. -//! -//! Lookup order: -//! -//! 1. `--engine-config ` CLI flag (future), or third positional -//! argument today; -//! 2. `engine.toml` in the current working directory; -//! 3. defaults - no chains configured, `state_dir = ./data`. -//! -//! A missing config is OK for the example module (it only logs); for -//! the chain-backed capabilities it surfaces as a `fault.unsupported` -//! so guests learn early. +//! Load order: `--engine-config` path, else `engine.toml` in the cwd, +//! else defaults (no chains, `state_dir = ./data`). use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -37,18 +26,15 @@ pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); /// Default cap on receipts under status watch at once. pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default base window: the poll cadence a healthy provider refreshes -/// within. The give-up deadline is the derived `grace`, not this directly. +/// Default base window a healthy provider refreshes within; the give-up +/// deadline is the derived `grace`, not this directly. pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); -/// Derived grace defaults to this many `expiry` windows, so a watch rides -/// out a provider outage of a couple of poll cadences before giving up. +/// Derived grace defaults to this many `expiry` windows. pub const WATCH_GRACE_MULTIPLIER: u64 = 2; -/// Ceiling on the derived grace window, so a long `expiry` cannot stretch -/// the give-up deadline past a day. +/// Ceiling on the derived grace window. pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); -/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. -/// An explicit `grace_secs` in config overrides this. +/// Give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. const fn derive_grace(expiry: Duration) -> Duration { let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); let capped = if scaled < WATCH_GRACE_MAX.as_secs() { @@ -59,11 +45,9 @@ const fn derive_grace(expiry: Duration) -> Duration { Duration::from_secs(capped) } -/// Per-caller submission quota toward installed providers. Both a -/// forwarded submission and a charged decode failure consume one unit; -/// the window slides so a caller's budget refills as old charges age out. -/// Resolved from `[limits.quota]`; the extension service that meters -/// callers consumes it. +/// Per-caller submission quota toward providers. A submission and a +/// charged decode failure each consume one unit; the window slides. +/// Resolved from `[limits.quota]`. #[derive(Debug, Clone, Copy)] pub struct SubmitQuota { /// Maximum charges a single caller may accrue within `window`. @@ -73,7 +57,7 @@ pub struct SubmitQuota { } impl SubmitQuota { - /// Pair a budget with the window it is counted over. + /// Budget paired with the window it is counted over. pub const fn new(max_charges: u32, window: Duration) -> Self { Self { max_charges, @@ -88,10 +72,9 @@ impl Default for SubmitQuota { } } -/// Bounds on a provider status-watch set. The cap bounds the per-cadence -/// poll fan-out; `grace` is the give-up deadline: how long a watch rides -/// out an unreachable provider before it is evicted unreported. `expiry` -/// is the base window `grace` derives from. Resolved from `[limits.watch]`. +/// Bounds on a provider status-watch set: `max_entries` caps the +/// per-cadence poll fan-out, `grace` is the give-up deadline, `expiry` +/// the base window it derives from. Resolved from `[limits.watch]`. #[derive(Debug, Clone, Copy)] pub struct WatchLimit { /// Maximum receipts under status watch at once. @@ -99,9 +82,8 @@ pub struct WatchLimit { /// Base window a healthy provider refreshes the deadline within. pub expiry: Duration, /// Give-up deadline: how long a watch survives an unreachable provider - /// before it is evicted unreported. A reachable poll (the provider - /// answered) resets it; a resolve failure or an errored poll rides out - /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. + /// before unreported eviction. A reachable poll resets it; a resolve + /// failure or errored poll rides out against it. Derived unless set. pub grace: Duration, } @@ -111,8 +93,7 @@ impl WatchLimit { Self::with_grace(max_entries, expiry, derive_grace(expiry)) } - /// As [`new`](Self::new) but with an explicit `grace` window, the path - /// a configured `grace_secs` takes. + /// As [`new`](Self::new) but with an explicit `grace`. pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { Self { max_entries, @@ -129,15 +110,6 @@ impl Default for WatchLimit { } /// Errors surfaced by [`load_or_default`]. -/// -/// Library-side modules must not propagate `anyhow::Error`; the rust -/// idiomatic rubric reserves `anyhow` for `main.rs` and -/// `supervisor.rs` top-level dispatch. The variants carry the -/// upstream error via `#[from]` so the caller in `main.rs` (which -/// uses `anyhow`) gets a free conversion through `?`. -/// -/// `IntoStaticStr` exposes the snake_case variant name for metric -/// labels and structured-log `error_kind` fields. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -158,42 +130,30 @@ pub enum EngineConfigError { pub struct EngineConfig { #[serde(default)] pub engine: EngineSection, - /// Per-module wasmtime resource limits. Applies uniformly to every - /// module. + /// Per-module wasmtime resource limits, applied uniformly. #[serde(default)] pub limits: ModuleLimits, - /// Per-chain RPC URLs keyed by EIP-155 chain id. Numeric TOML keys - /// (`[chains.11155111]`) stay canonical; named keys - /// (`[chains.sepolia]`) also parse, since the key string is handed - /// to `Chain`'s `FromStr`. `Chain` is not `Ord`, so this is a - /// `HashMap`; call sites that need deterministic output sort by - /// `Chain::id()`. + /// Per-chain RPC config keyed by EIP-155 chain id. Numeric + /// (`[chains.11155111]`) and named (`[chains.sepolia]`) keys both + /// parse via `Chain`'s `FromStr`. #[serde(default)] pub chains: HashMap, - /// Opaque `[extensions.]` tables. The engine never - /// interprets these; each extension parses its own table at the - /// composition root. + /// Opaque `[extensions.]` tables; the engine never interprets + /// these, each extension parses its own at the composition root. #[serde(default)] pub extensions: HashMap, - /// Modules the supervisor should boot. Each entry resolves a - /// `(component.wasm, module.toml)` pair on the local filesystem. + /// Modules the supervisor boots; each resolves a + /// `(component.wasm, module.toml)` pair. #[serde(default)] pub modules: Vec, - /// Provider components the supervisor should boot alongside the - /// modules. Each entry resolves a `(component.wasm, module.toml)` pair - /// like a module, but the operator scopes its transport here rather - /// than in the provider's own manifest: the installer of a provider, - /// not its author, decides which hosts and messaging topics it may - /// reach. + /// Provider components the supervisor boots alongside modules. Like a + /// module, but the operator, not the author, scopes its transport here. #[serde(default)] pub adapters: Vec, } -/// One `[[modules]]` table from `engine.toml`. -/// -/// Both fields are filesystem paths in 0.2. `manifest` defaults to -/// `module.toml` next to `path` if omitted, matching the bundle layout -/// in `docs/02-modules-events-packaging.md`. +/// One `[[modules]]` table. `manifest` defaults to a sibling +/// `module.toml`. #[derive(Debug, Deserialize)] pub struct ModuleEntry { /// Path to the compiled `.wasm` component. @@ -203,16 +163,10 @@ 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). +/// One `[[adapters]]` table. `path`/`manifest` mirror [`ModuleEntry`]. +/// `http_allow` and `messaging_topics` are the operator's transport +/// grant: an empty `http_allow` denies all outbound HTTP, an empty +/// `messaging_topics` leaves messaging unscoped. #[derive(Debug, Deserialize)] pub struct AdapterEntry { /// Path to the compiled `.wasm` adapter component. @@ -220,9 +174,7 @@ pub struct AdapterEntry { /// 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`. + /// Outbound HTTP host allowlist: exact hostname or `*.suffix` wildcard. #[serde(default)] pub http_allow: Vec, /// Messaging content topics this adapter may reach. @@ -234,18 +186,16 @@ pub struct AdapterEntry { pub struct EngineSection { #[serde(default = "default_state_dir")] pub state_dir: PathBuf, - /// `tracing_subscriber::EnvFilter`-compatible directive. Defaults to - /// `info` when absent; `RUST_LOG` overrides at process start. + /// `EnvFilter` directive; defaults to `info`, `RUST_LOG` overrides at + /// process start. #[serde(default = "default_log_level")] pub log_level: String, - /// Prometheus metrics exporter wiring. Absent table = - /// disabled (the engine still installs the recorder so call sites - /// stay live but no HTTP listener binds). + /// Prometheus exporter wiring. Absent = disabled (the recorder is still + /// installed so call sites stay live, but no HTTP listener binds). #[serde(default)] pub metrics: MetricsSection, - /// Concurrency for the chain-log poller's per-block `eth_getLogs` - /// during backfill; higher catches up faster at more node load. - /// `0` is treated as `1` by alloy. + /// Per-block `eth_getLogs` concurrency during chain-log backfill. `0` is + /// treated as `1`. #[serde(default = "default_log_backfill_concurrency")] pub log_backfill_concurrency: usize, } @@ -265,11 +215,8 @@ fn default_log_backfill_concurrency() -> usize { 16 } -/// `[engine.metrics]` config. When `enabled = true` the engine starts -/// a Prometheus HTTP exporter on `bind_addr` and serves `/metrics`. -/// -/// Default: disabled. Operators opt in explicitly so a run does not -/// bind a port unintentionally. +/// `[engine.metrics]`. When `enabled`, serves `/metrics` on `bind_addr` +/// via a Prometheus HTTP exporter. Default disabled. #[derive(Debug, Deserialize)] pub struct MetricsSection { #[serde(default)] @@ -294,14 +241,12 @@ fn default_metrics_bind() -> String { #[derive(Debug, Deserialize)] pub struct ChainConfig { - /// JSON-RPC endpoint. `ws://` and `wss://` engage alloy's pubsub - /// transport (required for `eth_subscribe`); `http://` and `https://` - /// engage the HTTP transport (request/response only). + /// JSON-RPC endpoint. `ws(s)://` engages pubsub (needed for + /// `eth_subscribe`); `http(s)://` is request/response only. pub rpc_url: String, - /// Per-request timeout for `chain::request` JSON-RPC calls, in - /// seconds. Does not apply to `eth_subscribe` streams or the log - /// poller (both long-lived by design). Default: 30 s. `0` is - /// rejected at boot - every call would time out immediately. + /// Per-request timeout for `chain::request` calls, seconds. Excludes + /// `eth_subscribe` streams and the log poller. Default 30. `0` is + /// rejected at boot. #[serde(default = "default_chain_request_timeout_secs")] pub request_timeout_secs: u64, } @@ -310,12 +255,10 @@ fn default_chain_request_timeout_secs() -> u64 { 30 } -/// Default fuel budget per `on_event` invocation (~1 billion WASM -/// instructions). +/// Default fuel budget per `on_event` invocation (~1e9 WASM instructions). const DEFAULT_FUEL_PER_EVENT: u64 = 1_000_000_000; -/// Default per-dispatch wall-clock deadline: the coarse backstop for a -/// dispatch parked in an unmetered host call. +/// Default per-dispatch wall-clock deadline. const DEFAULT_EVENT_DEADLINE: Duration = Duration::from_secs(120); /// Floor for the resolved dispatch deadline. @@ -327,100 +270,45 @@ const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; /// Default per-module local-store byte quota (50 MiB). const DEFAULT_STATE_BYTES: u64 = 50 * 1024 * 1024; -/// Default ceiling on the guest-settable connect timeout. A TCP + TLS -/// connect that has not completed in 10 s is dead; anything longer just -/// parks a host task. +/// Default ceiling on the guest-settable connect timeout. const DEFAULT_HTTP_CONNECT_TIMEOUT_MAX: Duration = Duration::from_secs(10); -/// Default ceiling on the guest-settable first-byte timeout. Generous -/// enough for slow API endpoints without letting one request hold a -/// connection for minutes. +/// Default ceiling on the guest-settable first-byte timeout. const DEFAULT_HTTP_FIRST_BYTE_TIMEOUT_MAX: Duration = Duration::from_secs(30); /// Default ceiling on the guest-settable between-bytes timeout. const DEFAULT_HTTP_BETWEEN_BYTES_TIMEOUT_MAX: Duration = Duration::from_secs(30); -/// Default total deadline on one outgoing exchange, connect through -/// body streaming. Event-driven modules should never hold a request -/// across minutes; the per-phase timeouts above cannot bound a server -/// that trickles bytes forever, this does. +/// Default total deadline on one outgoing exchange, connect through body +/// streaming. const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); -/// Default cap on one incoming response body (16 MiB): a quarter of the -/// default module memory, so a single response cannot dominate the -/// guest heap that has to buffer it. +/// Default cap on one incoming response body (16 MiB). const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; -/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough -/// for typical read responses (receipts, log batches, contract state), -/// while preventing a misbehaving or adversarial node from filling the -/// guest heap via a single large reply. +/// Default cap on one chain JSON-RPC response body (1 MiB). const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024; /// Ceiling for the `[limits.http]` millisecond knobs (24 h). const HTTP_LIMIT_MS_MAX: u64 = 86_400_000; -/// Default per-run log ring budget (256 KiB). Large enough to hold a -/// substantial tail of a run's output for post-mortem, small enough that -/// memory stays bounded at roughly `bytes_per_run * runs_retained * -/// modules`. Each record is charged its message bytes plus a fixed -/// per-record overhead, so a flood of empty lines cannot outgrow the -/// budget. The per-run ceiling is really `max(bytes_per_run, -/// MAX_LINE_BYTES)`: the ring never evicts its sole record, and the stdio -/// writer force-flushes an unterminated line at 1 MiB, so a newline-less -/// flood transiently holds one record up to that size (evicted as soon as -/// a newer record arrives). +/// Default per-run log ring budget (256 KiB). const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; -/// Default number of past runs retained per module (16). A crash-looping -/// module restarts repeatedly; keeping the last several runs gives -/// history for diagnosis without unbounded growth. +/// Default past runs retained per module (16). const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for provider status polling (5 s). Fast enough that a -/// settling submission is observed within a block time or two, slow -/// enough that per-receipt provider calls stay negligible. +/// Default provider status polling cadence (5 s). 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. +/// Saturate a millisecond knob into [1 ms, 24 h]. fn clamp_http_ms(ms: u64) -> Duration { Duration::from_millis(ms.clamp(1, HTTP_LIMIT_MS_MAX)) } -/// Per-module wasmtime resource limits. Every field is optional; -/// omitted values resolve to built-in defaults. -/// -/// ```toml -/// [limits] -/// fuel_per_event = 1_000_000_000 -/// event_deadline_secs = 120 -/// memory_bytes = 67_108_864 -/// state_bytes = 52_428_800 -/// -/// [limits.http] -/// connect_timeout_max_ms = 10_000 -/// first_byte_timeout_max_ms = 30_000 -/// between_bytes_timeout_max_ms = 30_000 -/// total_deadline_ms = 60_000 -/// response_body_max_bytes = 16_777_216 -/// -/// [limits.chain] -/// response_body_max_bytes = 1_048_576 -/// -/// [limits.logs] -/// bytes_per_run = 262_144 -/// runs_retained = 16 -/// -/// [limits.poison] -/// max_failures = 5 -/// window_secs = 600 -/// -/// [limits.dispatch] -/// burst = 256 -/// refill_per_sec = 128 -/// ``` +/// Per-module wasmtime resource limits. Every field is optional; omitted +/// values resolve to built-in defaults. Sections are documented on their +/// own types. #[derive(Debug, Default, Deserialize)] pub struct ModuleLimits { /// Fuel budget granted per `on_event` invocation. @@ -429,8 +317,7 @@ pub struct ModuleLimits { pub event_deadline_secs: Option, /// Linear-memory cap in bytes per module store. pub memory_bytes: Option, - /// Local-store on-disk byte quota (prefix + key + value + per-entry - /// overhead) per module. + /// Local-store on-disk byte quota per module. pub state_bytes: Option, /// Outbound wasi:http limits. #[serde(default)] @@ -469,10 +356,7 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } - /// Resolved chain response size cap (override or default). A - /// degenerate `0` saturates to 1 byte, matching the `logs` / - /// `poison` sections' zero handling, so resolution never yields a - /// cap that rejects even an empty body. + /// Resolved chain response size cap; a degenerate `0` saturates to 1 byte. pub fn chain_response_max_bytes(&self) -> usize { self.chain .response_body_max_bytes @@ -485,15 +369,14 @@ impl ModuleLimits { self.state_bytes.unwrap_or(DEFAULT_STATE_BYTES) } - /// Resolved per-dispatch wall-clock deadline; an override saturates - /// up to a 1 s floor. + /// Resolved per-dispatch deadline; an override saturates up to a 1 s floor. pub fn event_deadline(&self) -> Duration { self.event_deadline_secs .map(|secs| Duration::from_secs(secs).max(MIN_EVENT_DEADLINE)) .unwrap_or(DEFAULT_EVENT_DEADLINE) } - /// Resolved outbound HTTP limits (overrides or defaults). + /// Resolved outbound HTTP limits. pub fn http(&self) -> OutboundHttpLimits { OutboundHttpLimits { connect_timeout_max: self @@ -523,9 +406,7 @@ impl ModuleLimits { } } - /// Resolved log retention limits (overrides or defaults). Degenerate - /// zeroes saturate up to 1 so at least the newest record and run stay - /// retained; resolution never fails. + /// Resolved log retention limits; degenerate zeroes saturate up to 1. pub fn logs(&self) -> LogRetentionLimits { LogRetentionLimits { bytes_per_run: self @@ -541,10 +422,7 @@ impl ModuleLimits { } } - /// Resolved poison-pill thresholds (overrides or production - /// defaults). Degenerate zeroes saturate up to 1: a zero - /// `max_failures` would quarantine on the first trap, and a zero - /// `window` would prune every recorded failure before the check. + /// Resolved poison-pill thresholds; degenerate zeroes saturate up to 1. pub fn poison(&self) -> PoisonPolicy { PoisonPolicy::new( self.poison @@ -573,9 +451,7 @@ 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. + /// Resolved status-poll cadence; a zero interval saturates up to 1 ms. pub fn status_poll_interval(&self) -> Duration { self.status_poll .interval_ms @@ -583,10 +459,8 @@ impl ModuleLimits { .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 consuming service, so a - /// misconfigured budget still admits one submission rather than - /// bricking every provider. + /// Resolved per-caller submission quota; a zero `max_charges` is + /// saturated up to 1 by the consuming service. pub fn quota(&self) -> SubmitQuota { SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), @@ -597,11 +471,9 @@ impl ModuleLimits { ) } - /// Resolved status-watch bounds (overrides or defaults). A zero - /// `max_entries` saturates up to 1 and a zero `expiry_secs` up to 1 s, - /// so a misconfigured bound still watches one receipt briefly rather - /// than nothing at all. `grace_secs` overrides the give-up deadline; - /// omitted, it derives from `expiry` via [`WatchLimit::new`]. + /// Resolved status-watch bounds; zero `max_entries`/`expiry_secs` + /// saturate up to a usable minimum. `grace_secs` overrides the give-up + /// deadline, else it derives from `expiry` via [`WatchLimit::new`]. pub fn watch(&self) -> WatchLimit { let max_entries = self .watch @@ -622,14 +494,10 @@ impl ModuleLimits { } } -/// `[limits.http]` outbound wasi:http limits. Every field is optional; -/// omitted values resolve to built-in defaults, and millisecond values -/// saturate into [1 ms, 24 h]; degenerate values are clamped at resolve time. -/// -/// The three `*_timeout_max_ms` fields are ceilings on the matching -/// guest-settable `request-options` timeouts, not the timeouts -/// themselves: a guest value above the ceiling is clamped down, and an -/// unset guest value inherits the ceiling. +/// `[limits.http]` outbound limits. All optional; millisecond values +/// saturate into [1 ms, 24 h]. The `*_timeout_max_ms` fields are ceilings +/// on the matching guest-settable `request-options` timeouts: a higher +/// guest value is clamped down, an unset one inherits the ceiling. #[derive(Debug, Default, Deserialize)] pub struct HttpLimitsSection { /// Ceiling on the guest-settable connect timeout, in milliseconds. @@ -645,22 +513,15 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } -/// `[limits.chain]` chain JSON-RPC response size limit. Optional; -/// omitted values resolve to the built-in 1 MiB default. -/// -/// ```toml -/// [limits.chain] -/// response_body_max_bytes = 1_048_576 -/// ``` +/// `[limits.chain]` chain JSON-RPC response size limit. Optional; defaults +/// to 1 MiB. #[derive(Debug, Default, Deserialize)] pub struct ChainLimitsSection { - /// Cap on one chain JSON-RPC response body, in bytes. Named for - /// symmetry with `[limits.http].response_body_max_bytes`. + /// Cap on one chain JSON-RPC response body, in bytes. pub response_body_max_bytes: Option, } -/// Resolved outbound HTTP limits the wasi:http gate enforces per -/// request. Built by [`ModuleLimits::http`]. +/// Resolved outbound HTTP limits the wasi:http gate enforces per request. #[derive(Debug, Clone, Copy)] pub struct OutboundHttpLimits { /// Ceiling on the guest-settable connect timeout. @@ -675,14 +536,8 @@ pub struct OutboundHttpLimits { pub response_body_max_bytes: u64, } -/// `[limits.logs]` per-run log retention knobs. Both optional; omitted -/// values resolve to built-in defaults and degenerate zeroes saturate up -/// to 1 at resolve time. -/// -/// Captured-line levels are fixed, not configurable: guest stdout is -/// recorded at info, stderr at warn, and a supervisor-synthesized panic -/// record at error. A guest panic's stderr copy therefore records at -/// warn while its host-interface and supervisor copies carry error. +/// `[limits.logs]` per-run retention knobs. Both optional; degenerate +/// zeroes saturate up to 1. #[derive(Debug, Default, Deserialize)] pub struct LogLimitsSection { /// Byte budget for one run's in-memory ring. @@ -691,14 +546,10 @@ pub struct LogLimitsSection { pub runs_retained: Option, } -/// `[limits.poison]` quarantine thresholds. Both optional; omitted -/// values resolve to the production defaults and degenerate zeroes -/// saturate up to 1 at resolve time via [`ModuleLimits::poison`]. -/// -/// A module that reaches `max_failures` traps within a sliding -/// `window_secs` is quarantined: the check fires at the threshold, not one -/// past it. The supervisor then stops dispatching to the module until an -/// operator-driven engine restart clears the state. +/// `[limits.poison]` quarantine thresholds. Both optional; degenerate +/// zeroes saturate up to 1. A module reaching `max_failures` traps within +/// a sliding `window_secs` is quarantined and no longer dispatched until +/// an operator-driven engine restart. #[derive(Debug, Default, Deserialize)] pub struct PoisonLimitsSection { /// Maximum traps within the window before a module is poisoned. @@ -707,13 +558,9 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller provider submission budget. Both optional; -/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. -/// -/// A caller (a strategy module, keyed by its namespace) may accrue at most -/// `max_charges` submissions within a sliding `window_secs`; a decode failure -/// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the provider's fuel. +/// `[limits.quota]` per-caller submission budget. Both optional. A caller +/// (keyed by its namespace) may accrue at most `max_charges` within a +/// sliding `window_secs`; a charged decode failure counts the same. #[derive(Debug, Default, Deserialize)] pub struct QuotaLimitsSection { /// Maximum submissions (plus charged decode failures) per caller in the @@ -723,27 +570,19 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` provider status polling cadence. Optional; an -/// omitted value resolves to the built-in default and a degenerate zero -/// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. -/// -/// The cadence is how often the consuming service polls each installed -/// provider's `status` export for the receipts it watches; only observed -/// transitions fan out as events. +/// `[limits.status_poll]` provider status polling cadence: how often the +/// consuming service polls each provider's `status` export for the +/// receipts it watches. Optional; a zero saturates up to 1 ms. #[derive(Debug, Default, Deserialize)] pub struct StatusPollSection { /// Milliseconds between status poll sweeps. pub interval_ms: Option, } -/// `[limits.watch]` status-watch set bounds. Both optional; omitted -/// values resolve to the defaults via [`ModuleLimits::watch`] and -/// degenerate zeroes saturate up to a usable minimum. -/// -/// The consuming service watches each accepted receipt until a terminal -/// status: the cap bounds the per-cadence poll fan-out, and the give-up -/// deadline evicts a watch whose provider stays unreachable. At the cap a -/// new watch is refused and logged; live watches are never dropped. +/// `[limits.watch]` status-watch set bounds. All optional; degenerate +/// zeroes saturate up to a usable minimum. The cap bounds the per-cadence +/// poll fan-out; at the cap a new watch is refused and logged, live +/// watches are never dropped. #[derive(Debug, Default, Deserialize)] pub struct WatchLimitsSection { /// Maximum receipts under status watch at once. @@ -766,12 +605,11 @@ pub struct DispatchLimitsSection { pub refill_per_sec: Option, } -/// Resolved log retention limits the in-memory store enforces. Built by -/// [`ModuleLimits::logs`]. +/// Resolved log retention limits the in-memory store enforces. #[derive(Debug, Clone, Copy)] pub struct LogRetentionLimits { - /// Byte budget for one run's ring; the oldest records evict first, - /// but the newest record is never evicted to nothing. + /// Byte budget for one run's ring; the newest record is never evicted + /// to nothing. pub bytes_per_run: usize, /// Runs retained per module; the oldest run evicts first. pub runs_retained: usize, @@ -820,18 +658,9 @@ pub fn load_or_default(path: Option<&Path>) -> Result Result { let mut out = String::with_capacity(raw.len()); let bytes = raw.as_bytes(); @@ -883,10 +712,7 @@ fn is_valid_env_name(s: &str) -> bool { chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') } -/// `IntoStaticStr` exposes the snake_case variant name for the -/// `tracing::error!` / `metrics::counter!` call sites in `main.rs` -/// when an `engine.toml` substitution fails at boot, matching the -/// pattern used on every other engine-side error enum. +/// Errors from `${VAR}` substitution in `engine.toml`. #[derive(Debug, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -908,11 +734,9 @@ pub enum EnvVarError { Unclosed { offset: usize }, } -/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, and -/// long API-key path segments) so it is safe to log. Parsing with [`url::Url`] -/// rather than string-splitting is what makes bare query flags (`?token`) and -/// fragments redact; an unparseable url yields a placeholder. Shared by every -/// call site that logs an RPC url. +/// Blank the credential-bearing parts of a URL (userinfo, query, fragment, +/// long API-key path segments) so it is safe to log. Unparseable input +/// yields a placeholder. pub fn redact_url(url: &str) -> String { let Ok(mut parsed) = url::Url::parse(url) else { return "".to_owned(); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index ebddddd8..3472cb9c 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -1,35 +1,17 @@ //! Multi-module supervisor. //! -//! Loads every `[[modules]]` entry from `engine.toml`, instantiates -//! each as an `EventModule` binding against a dedicated wasmtime -//! `Store`, and routes the event types declared in each manifest's -//! `[[subscription]]` table. +//! Loads every `[[modules]]` and `[[adapters]]` entry from `engine.toml`, +//! instantiates each against a dedicated wasmtime `Store`, and routes +//! subscribed events. //! -//! Trap handling: a wasmtime trap in `on_event` -//! marks the module `alive = false`, increments `failure_count`, and -//! schedules a `next_attempt` instant via `runtime::restart_policy:: -//! backoff_for`. The next dispatch eligible after that instant -//! re-instantiates the component (fresh `Store` + bindings; the -//! wasm instance left by a trap is poisoned with "cannot enter -//! component instance") and re-calls `init`. On a successful -//! `on_event` the failure counter resets to 0. -//! -//! Modules whose `init` returned `Err(fault)` are dead with -//! `next_attempt = None` and never get scheduled - the init failure -//! is treated as a manifest / config bug, not a transient. -//! -//! Providers ride the same sweeps: a trap inside a routed call flips -//! the [`Liveness`] their actor shares with the supervisor, the owning -//! service reports the instance unavailable while dead, and the sweep -//! reinstalls the provider after the same backoff and poison policies. -//! -//! Multi-chain isolation: `dispatch_block(block)` walks -//! every module but only enters those whose subscriptions match -//! `block.chain_id`. Per-module restart / poison / fuel limits are -//! independent across chains, so a poisoned module on chain A -//! cannot starve modules on chain B. The upstream WS reconnect -//! tasks own one per-chain backoff timer each, so a -//! chain-A connection drop does not block chain-B events. +//! On a trap in `on_event` a module is marked dead, its failure count +//! bumps, and a backoff `next_attempt` is scheduled; the next eligible +//! dispatch re-instantiates it on a fresh `Store` (the trapped instance is +//! poisoned) and re-runs `init`. A successful dispatch resets the count. A +//! module whose `init` returned `Err` is permanently dead +//! (`next_attempt = None`). Providers ride the same sweeps via a shared +//! [`Liveness`]. Per-module restart, poison, and fuel state are +//! independent across chains. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; @@ -66,42 +48,34 @@ use crate::manifest::{ self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; -/// Owns every loaded module and exposes the dispatch surface the -/// event loop needs. Generic over the [`RuntimeTypes`] lattice binding -/// the component seam backends. +/// Owns every loaded module and provider and exposes the dispatch surface. +/// Generic over the [`RuntimeTypes`] backend lattice. pub struct Supervisor { modules: Vec>, - /// Providers loaded at boot, whether or not `init` succeeded. Swept - /// for restart and poison alongside the modules. + /// Providers loaded at boot; swept for restart and poison alongside + /// the modules. providers: Vec, - /// Registered provider kinds paired with their services, kept for the - /// provider restart sweep to reinstall through. + /// Registered provider kinds paired with their services, for the + /// restart sweep to reinstall through. kinds: ProviderKinds, - /// Cached for module restart: re-instantiating a trapped module - /// requires a fresh wasmtime `Store` + `Linker`, which in turn need - /// the shared backends. The `Components` bundle is cheaply cloned - /// (Arc-backed members) so the supervisor takes an owned copy at boot. + /// Cached for restart: rebuilding a trapped module needs a fresh + /// `Store` + `Linker`, hence the shared backends held here. engine: Engine, components: Components, - /// Extensions wired at boot. Cached so the module-restart path can - /// rebuild an identical linker (core interfaces plus every extension - /// hook) without re-consulting the composition root. + /// Extensions wired at boot, cached to rebuild an identical linker on + /// restart. extensions: Vec>>, - /// Extension-owned host services, built once at boot from the same - /// extension set and carried by every store. + /// Extension-owned host services, built once at boot and carried by + /// every store. services: HostServices, - /// Poison-pill thresholds resolved from `[limits.poison]` at boot - /// (production defaults: 5 failures / 10 min). + /// Poison-pill thresholds resolved from `[limits.poison]` at boot. poison_policy: crate::runtime::poison_policy::PoisonPolicy, - /// Optional WASI clock override applied to every module store, - /// including the ones rebuilt on restart. `None` leaves the ambient - /// host clocks. + /// Optional WASI clock override applied to every module store. `None` + /// leaves the ambient host clocks. clocks: Option, } -/// Core-only lattice for the runtime's own tests: the reference core -/// backends with an empty extension slot (`Ext = ()`). Domain-extension -/// boot coverage lives in the extension crate that owns the backend. +/// Core-only lattice for the runtime's own tests (`Ext = ()`). #[cfg(test)] #[derive(Clone, Copy, Default)] pub(crate) struct TestTypes; @@ -116,22 +90,17 @@ impl RuntimeTypes for TestTypes { type Ext = (); } -/// The supervisor the runtime's own tests drive. The launch path infers -/// its lattice from the composition root instead. +/// The supervisor the runtime's own tests drive. #[cfg(test)] pub(crate) type DefaultSupervisor = Supervisor; -/// A wasmtime `Store` holding the lattice `HostState`. Named so the -/// module and helper signatures stay legible. +/// A wasmtime `Store` holding the lattice `HostState`. type HostStore = Store>; -/// Per-store WASI clock override applied to every module store. -/// -/// Threaded from the assembly through the boot paths onto each store's -/// `WasiCtxBuilder`. The shared wall and monotonic sources let a test handle -/// drive guest-visible time; leaving it `None` keeps the ambient host clocks, -/// so the default is behaviour-neutral. `RunId.started_at` is host wall-clock -/// and is unaffected. +/// Per-store WASI clock override applied to every module store; shared +/// wall and monotonic sources let a test drive guest-visible time. `None` +/// keeps the ambient host clocks. `RunId.started_at` is host wall-clock +/// and unaffected. #[derive(Clone)] pub struct WasiClockOverride { wall: Arc, @@ -148,8 +117,7 @@ impl WasiClockOverride { } } -/// Adapts a shared wall clock into the by-value `HostWallClock` the -/// `WasiCtxBuilder` takes ownership of per store. +/// Adapts a shared wall clock into the by-value `HostWallClock` a store owns. struct SharedWallClock(Arc); impl HostWallClock for SharedWallClock { @@ -162,8 +130,8 @@ impl HostWallClock for SharedWallClock { } } -/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` the -/// `WasiCtxBuilder` takes ownership of per store. +/// Adapts a shared monotonic clock into the by-value `HostMonotonicClock` a +/// store owns. struct SharedMonotonicClock(Arc); impl HostMonotonicClock for SharedMonotonicClock { @@ -176,16 +144,16 @@ impl HostMonotonicClock for SharedMonotonicClock { } } -/// A module's resource budget after layering its `[module.resources]` -/// overrides onto the engine `[limits]` defaults. +/// A module's resource budget: `[module.resources]` layered over engine +/// `[limits]`. struct ResolvedLimits { fuel: u64, memory: usize, state_bytes: u64, } -/// Layer a manifest's `[module.resources]` over the engine `[limits]` -/// defaults: each unset override field keeps the engine default. +/// Layer `[module.resources]` over engine `[limits]`; unset fields keep the +/// default. fn resolve_module_limits(res: &ResourceSection, cfg: &ModuleLimits) -> ResolvedLimits { ResolvedLimits { fuel: res.max_fuel_per_event.unwrap_or(cfg.fuel()), @@ -198,83 +166,62 @@ struct LoadedModule { name: String, bindings: EventModule, store: HostStore, - /// The run this store instantiates. Restarts mint a fresh `RunId` - /// with an incremented sequence; the supervisor's death path stamps - /// the synthesized panic record with it. + /// The run this store instantiates; restarts mint a fresh `RunId` with + /// an incremented sequence. run: RunId, - /// Subscriptions copied from `module.toml`. The supervisor reads - /// these on every event to decide whether to dispatch. + /// Subscriptions copied from `module.toml`, read on every event to + /// decide dispatch. subscriptions: Vec, /// Fuel budget refilled before each `on_event` invocation. fuel_per_event: u64, - /// Wall-clock deadline for a whole dispatch, guest plus every host - /// call it awaits. Fuel bounds only guest instructions, so this is - /// the backstop against a dispatch parked in a slow or blocked host - /// call (see [`crate::runtime::limits`]). + /// Wall-clock deadline for a whole dispatch (guest plus every host + /// call). Fuel bounds only guest instructions; this is the backstop + /// for a dispatch parked in a host call (see [`crate::runtime::limits`]). event_deadline: Duration, - /// Memory cap applied to the wasmtime store on reinstantiation. + /// Memory cap applied to the store on reinstantiation. memory_limit: usize, - /// Local-store byte quota applied to the module store on reinstantiation. + /// Local-store byte quota applied on reinstantiation. local_store_bytes: u64, - /// Cached for restart: re-instantiating from the original - /// wasm bytes avoids re-reading the file on every restart. The - /// `Component` itself is internally `Arc`-backed by wasmtime. + /// Cached for restart; `Component` is internally `Arc`-backed. component: Component, - /// Cached for restart: the manifest's `[config]` we pass - /// to `Guest::init`. Cloning a `Vec<(String, String)>` is cheap. + /// Cached for restart: the manifest `[config]` passed to `init`. init_config: Config, - /// Cached for restart: HTTP allowlist baked into the - /// `HostState` we rebuild on each re-instantiation. + /// Cached for restart: HTTP allowlist baked into the rebuilt `HostState`. http_allowlist: Vec, - /// Cached for restart: outbound HTTP limits baked into the - /// `HostState` we rebuild on each re-instantiation. + /// Cached for restart: outbound HTTP limits. http_limits: OutboundHttpLimits, - /// Cached for restart: chain response size cap baked into the - /// `HostState` we rebuild on each re-instantiation. + /// Cached for restart: chain response size cap. chain_response_max_bytes: usize, - /// Set to `false` when `on_event` traps. Dead modules are - /// excluded from dispatch until `next_attempt` is in the past. - /// Modules whose `init` failed have `alive = false` - /// + `next_attempt = None`, so they never come back. + /// Set `false` when `on_event` traps; excluded from dispatch until + /// `next_attempt` passes. An init-failed module has `alive = false` + + /// `next_attempt = None`, so it never returns. alive: bool, - /// Number of consecutive trap-style failures since the last - /// successful dispatch. Resets to 0 on success. Drives the - /// exponential backoff via `restart_policy::backoff_for`. + /// Consecutive trap failures since the last success; resets to 0 on + /// success. Drives the backoff via `restart_policy::backoff_for`. failure_count: u32, - /// Earliest instant at which the supervisor may retry this - /// module after a trap. `None` for healthy modules + for modules - /// whose `init` failed (the latter never get scheduled because - /// the dispatch fast-path checks `next_attempt` *and* requires - /// `alive = false` before flipping back). + /// Earliest instant the supervisor may retry after a trap. `None` for + /// healthy modules and for init-failed modules (never rescheduled). next_attempt: Option, - /// Sliding-window record of recent trap timestamps for the - /// poison-pill check. Entries older than the - /// `PoisonPolicy.window` are dropped on each push. + /// Sliding-window trap timestamps for the poison-pill check; entries + /// older than `PoisonPolicy.window` drop on push. failure_timestamps: std::collections::VecDeque, - /// Once `true` the module is permanently quarantined: no restart - /// attempts, no dispatches, no metric churn. Recovery requires - /// an operator-driven full engine restart with the module - /// removed from `engine.toml::[[modules]]`. + /// Once `true` the module is permanently quarantined: no restarts, no + /// dispatches. Recovery requires removing it from `[[modules]]` and + /// restarting the engine. poisoned: bool, - /// Per-module dispatch rate limiter. Checked in `dispatch_to` - /// before the guest runs, so an event flood on this module's - /// source is throttled at the dispatch boundary without touching - /// any other module's bucket. Over-rate events are dropped and - /// counted. + /// Per-module dispatch rate limiter, checked in `dispatch_to` before + /// the guest runs; over-rate events are dropped and counted. dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison -/// bookkeeping; liveness is shared with the installed actor, which marks -/// it dead on a trap, and read back by the sweep. +/// One loaded provider; mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping. Liveness is shared with the installed actor. struct LoadedProvider { - /// The provider's namespace: its manifest name, and the id its kind - /// installs it under. + /// The provider's namespace: its manifest name. name: String, - /// Registered kind spelling the restart sweep reinstalls through. + /// Registered kind the restart sweep reinstalls through. kind: &'static str, - /// Extension-owned manifest sections, as the worker install - /// predicates see them. + /// Extension-owned manifest sections. sections: manifest::ExtensionSections, /// Cached for restart, like a module's. component: Component, @@ -283,7 +230,7 @@ struct LoadedProvider { /// Cached for restart: the operator's transport grants. http_allow: Vec, messaging_topics: Vec, - /// Cached for restart: the engine `[limits]` applied at boot. + /// Cached for restart. http_limits: OutboundHttpLimits, fuel_per_call: u64, memory_limit: usize, @@ -293,9 +240,9 @@ struct LoadedProvider { liveness: Liveness, /// Sequence of the run currently installed; restarts increment it. run_seq: u64, - /// The sweep's view of `liveness`: a `true` here against a dead - /// liveness is an unrecorded trap. Boot init failure leaves it `false` - /// with `next_attempt = None`, permanent like a module's. + /// The sweep's view of `liveness`: `true` against a dead liveness is + /// an unrecorded trap. Init failure leaves it `false` with + /// `next_attempt = None`, permanent. alive: bool, failure_count: u32, next_attempt: Option, @@ -333,8 +280,7 @@ fn provider_kinds( Ok(kinds) } -/// The union of subscription kinds the wired extensions declare; a -/// manifest subscription of any other non-core kind fails the load. +/// Union of subscription kinds the wired extensions declare. fn extension_subscription_vocabulary( extensions: &[Arc>], ) -> BTreeSet<&'static str> { @@ -344,9 +290,7 @@ fn extension_subscription_vocabulary( .collect() } -/// Refuse a manifest section no wired extension claims, so a typo'd -/// section fails loudly instead of silently skipping its extension's -/// install predicate. +/// Refuse a manifest section no wired extension claims. fn enforce_extension_sections( owner: &str, sections: &manifest::ExtensionSections, @@ -365,11 +309,8 @@ fn enforce_extension_sections( Ok(()) } -/// Refuse a string two wired extensions both claim, fail-fast at boot, in -/// any of three classes: service namespace, subscription kind, manifest -/// section. Each class dedupes silently downstream, so an unchecked -/// collision routes to whichever extension the map or `.any()` scan hits -/// first. +/// Refuse a name two wired extensions both claim (service namespace, +/// subscription kind, or manifest section), fail-fast at boot. fn enforce_extension_uniqueness( extensions: &[Arc>], ) -> Result<()> { @@ -414,9 +355,8 @@ fn registered_kinds(kinds: &ProviderKinds) -> String { } impl Supervisor { - /// Compile + instantiate every module declared in - /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are - /// passed in so `main.rs` can build them once. + /// Compile and instantiate every module and provider in `engine_cfg`. + /// The `Engine` and `Linker` are passed in. pub async fn boot( engine: &Engine, linker: &Linker>, @@ -503,10 +443,8 @@ impl Supervisor { }) } - /// One-shot construction from a single ad-hoc `(component, manifest)` - /// pair. Used by the CLI-positional invocation so `just run` - /// against the example module keeps working without an - /// `engine.toml`. + /// Construct from a single `(component, manifest)` pair, for `just run` + /// without an `engine.toml`. // One flat argument per shared backend and resource knob, plus the // optional clock override; bundling would obscure the call site. #[allow(clippy::too_many_arguments)] @@ -558,9 +496,8 @@ impl Supervisor { } /// Build a fresh wasmtime `Store` wired to the shared backends, with - /// the per-run namespace, allowlist, memory cap, and fuel applied. - /// Shared by `load_one` and `reinstantiate_one`; each call takes a - /// freshly minted [`RunId`] so a restart's store is a distinct run. + /// the per-run namespace, allowlist, memory cap, and fuel applied. Each + /// call takes a freshly minted [`RunId`]. // One flat argument per resource knob threaded onto the store, plus the // optional clock override. #[allow(clippy::too_many_arguments)] @@ -831,12 +768,11 @@ impl Supervisor { }) } - /// Load one `[[adapters]]` entry: resolve its manifest, resolve the - /// declared kind against the registered provider kinds, enforce the - /// scoped-transport capability set, build a supervised store carrying - /// the operator's HTTP and messaging grants, and hand the instance to - /// its kind to instantiate and install. A failed guest `init` loads the - /// provider dead and unroutable, permanently like a module's. + /// Load one `[[adapters]]` entry: resolve its manifest and kind, + /// enforce the scoped-transport capabilities, build a supervised store + /// with the operator's grants, and hand the instance to its kind to + /// install. A failed `init` loads the provider dead and unroutable, + /// permanently. // One flat argument per shared input threaded onto the store, matching // the module load path. #[allow(clippy::too_many_arguments)] @@ -1012,8 +948,7 @@ impl Supervisor { self.providers.len() } - /// Number of adapters currently alive and routable. Live: a trap drops - /// it, the restart sweep raises it again. + /// Number of adapters currently alive and routable. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { self.providers @@ -1022,12 +957,9 @@ impl Supervisor { .count() } - /// Chains any **alive** module asked for block events on. Dead modules - /// (init-failed or currently in trap-backoff) are excluded so the - /// caller does not open live RPC subscriptions for chains with no - /// reachable module. The caller opens one shared block subscription - /// per chain and routes through `dispatch_block`. Sorted by numeric id - /// and deduped (`Chain` is not `Ord`, so this is not a `BTreeSet`). + /// Chains any alive module subscribes to block events on. Dead modules + /// are excluded so no live subscription opens for an unreachable chain. + /// Sorted by numeric id and deduped. pub fn block_chains(&self) -> Vec { let mut out: Vec = Vec::new(); for module in self.modules.iter().filter(|m| m.alive) { @@ -1042,14 +974,9 @@ impl Supervisor { out } - /// Per-module chain-log subscriptions for **alive** modules only. - /// Called once at launch, when a dead module can only mean init - /// failure (trap-backoff cannot exist yet); excluding them keeps the - /// caller from opening live log subscriptions no reachable module - /// consumes. Each entry names the module, chain, and filter the event - /// loop opens against the matching alloy provider; the resulting - /// stream tags every log with `module_name` so `dispatch_chain_log` - /// routes correctly. + /// Per-module chain-log subscriptions for alive modules only. Each + /// entry names the module, chain, and filter the event loop opens; the + /// stream tags every log with `module_name` for routing. pub fn chain_log_subscriptions(&self) -> Vec { let mut out = Vec::new(); for module in self.modules.iter().filter(|m| m.alive) { @@ -1101,8 +1028,8 @@ impl Supervisor { out } - /// Read the persisted resume cursor for a chain-log subscription, or - /// `None` when absent / unreadable - both treated as "start at head". + /// Read the persisted resume cursor, or `None` when absent or + /// unreadable (both start at head). fn read_chain_log_cursor(&self, module: &str, key: &str) -> Option { let handle = self.components.store.module(module).ok()?; let bytes = handle.get(key).ok()??; @@ -1110,20 +1037,11 @@ impl Supervisor { Some(u64::from_le_bytes(arr)) } - /// Dispatch a block event to every module subscribed to - /// `block.chain_id`. Returns the number of modules invoked. - /// Modules that trap are marked dead and excluded from future dispatch. - /// Rebuild a module from its cached `Component` + `init_config` - /// after a wasmtime trap. A trap leaves the original - /// `Store` + component instance in a poisoned state ("cannot - /// enter component instance" on the next call); the only way to - /// recover is to create a fresh `Store` + re-instantiate. The - /// `LoadedModule.subscriptions` and `LoadedModule.name` are - /// preserved so the dispatch routing keeps working. - /// - /// On success the module's `alive` flag is left for the caller - /// to flip; on failure (e.g. `init` returns Err again) the - /// module stays dead and the failure_count keeps climbing. + /// Rebuild a trapped module from its cached `Component` and + /// `init_config` on a fresh `Store` (the trapped instance is poisoned) + /// and re-run `init`, preserving name and subscriptions. On success the + /// caller flips `alive`; on failure the module stays dead and its + /// failure count keeps climbing. async fn reinstantiate_one(&mut self, idx: usize) -> Result<()> { // Re-build the linker: core interfaces plus every extension hook, // identical to the boot-time linker. Cheap `add_to_linker` calls @@ -1262,10 +1180,9 @@ impl Supervisor { dispatched } - /// Dispatch a chain-log event to the specific module that opened the - /// subscription. Returns `true` when the module accepted the dispatch; - /// `false` when the module is dead, not found, or its callback failed. - /// A trapping module is marked dead and excluded from future dispatch. + /// Dispatch a chain-log event to the module that opened the + /// subscription. Returns `true` when accepted; `false` when the module + /// is dead, missing, or its callback failed. A trap marks it dead. pub async fn dispatch_chain_log( &mut self, module_name: &str, @@ -1345,11 +1262,9 @@ impl Supervisor { ok } - /// Dispatch one extension-observed event to every module holding a - /// subscription of its kind whose filters all match the event's - /// attributes. Returns the number of modules invoked. Mirrors - /// `dispatch_block`: dead modules past their backoff are restarted - /// first, poisoned modules are skipped. + /// Dispatch one extension event to every module whose subscription kind + /// and filters match. Returns the number invoked. Like `dispatch_block`: + /// dead modules past backoff restart first, poisoned modules skip. pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { let now = std::time::Instant::now(); let restart_candidates: Vec = (0..self.modules.len()) @@ -1394,10 +1309,8 @@ impl Supervisor { dispatched } - /// The extension subscription kinds at least one loaded module - /// declares. An extension opens an event source only when its kind - /// appears here: with no subscriber every event would be dropped on - /// arrival. + /// Extension subscription kinds at least one loaded module declares. An + /// extension opens an event source only when its kind appears here. pub fn extension_subscription_kinds(&self) -> BTreeSet { self.modules .iter() @@ -1409,18 +1322,15 @@ impl Supervisor { .collect() } - /// The extension-owned services, as booted. Shared by every module - /// store through the service map. + /// The extension-owned services, shared by every module store. pub fn services(&self) -> &HostServices { &self.services } - /// 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. + /// Shared per-module dispatch: refuel, call `on_event`, handle the + /// three outcomes (ok / fault / trap) with the same telemetry and + /// lifecycle bookkeeping. Returns whether the guest call succeeded. + /// `chain_id` is telemetry only; chain-less kinds pass 0. async fn dispatch_to( &mut self, idx: usize, @@ -1571,10 +1481,8 @@ impl Supervisor { } } - /// Attempt to re-instantiate a dead module in place. On success - /// the module is marked `alive`; on failure the failure counter - /// is bumped and `next_attempt` slides further out per the - /// restart-policy backoff. Used by both dispatch paths. + /// Re-instantiate a dead module in place. On success mark it `alive`; + /// on failure bump the counter and slide `next_attempt` per the backoff. async fn try_restart(&mut self, idx: usize) { let name = self.modules[idx].name.clone(); let failure_count = self.modules[idx].failure_count; @@ -1607,10 +1515,9 @@ impl Supervisor { } } - /// Fold providers into the recovery path: record any trap the shared - /// liveness reports (backoff plus poison bookkeeping), then reinstall - /// dead, unpoisoned providers whose backoff has elapsed. Runs at the - /// head of every dispatch, beside the module restart sweep. + /// Fold providers into recovery: record any trap the shared liveness + /// reports (backoff plus poison), then reinstall dead, unpoisoned + /// providers past their backoff. Runs at the head of every dispatch. async fn sweep_providers(&mut self) { let now = std::time::Instant::now(); let policy = self.poison_policy; @@ -1664,10 +1571,9 @@ impl Supervisor { } } - /// Attempt to reinstall a dead provider in place: fresh store, fresh - /// instance, `init`, and a re-install replacing the dead slot. On - /// success the shared liveness is revived; on failure the backoff - /// slides further out, like a module restart. + /// Reinstall a dead provider in place (fresh store, instance, `init`, + /// re-install). On success revive the shared liveness; on failure slide + /// the backoff. async fn try_restart_provider(&mut self, idx: usize) { let name = self.providers[idx].name.clone(); let failure_count = self.providers[idx].failure_count; @@ -1695,8 +1601,8 @@ impl Supervisor { } } - /// Rebuild a provider from its cached component and grants, then hand - /// it back to its kind to instantiate and install over the dead slot. + /// Rebuild a provider from its cached component and grants and reinstall + /// it over the dead slot. async fn reinstall_provider(&mut self, idx: usize) -> Result { let provider = &self.providers[idx]; let (kind, service) = self @@ -1735,25 +1641,22 @@ impl Supervisor { .await } - /// Count of modules currently alive. A module is not alive when its - /// `init` returned `Err` (permanent, never retried) or when `on_event` - /// trapped and its restart backoff has not yet elapsed. + /// Modules currently alive. Not alive when `init` returned `Err` + /// (permanent) or a trap's backoff has not elapsed. pub fn alive_count(&self) -> usize { self.modules.iter().filter(|m| m.alive).count() } - /// True when at least one init-failed module declared subscriptions. - /// Lets the launch path distinguish "no manifest declares any - /// `[[subscription]]`" (benign: exit cleanly) from "every declared - /// subscription belongs to a dead module" (operator error: abort). + /// True when an init-failed module declared subscriptions. Lets the + /// launch path tell "no subscriptions declared" (benign) from "every + /// declared subscription belongs to a dead module" (operator error). pub fn dead_modules_hold_subscriptions(&self) -> bool { self.modules .iter() .any(|m| !m.alive && !m.subscriptions.is_empty()) } - /// Also expose a per-module poisoned state for - /// metrics + integration tests. + /// Modules currently poisoned. #[cfg_attr(not(test), allow(dead_code))] pub fn poisoned_count(&self) -> usize { self.modules.iter().filter(|m| m.poisoned).count() @@ -1761,13 +1664,10 @@ impl Supervisor { } /// Build a `Linker` binding the core `event-module` interfaces plus every -/// extension's own interfaces for `HostState`. Shared by the supervisor -/// restart path and the bootstrap launch path. -/// -/// Extension hooks run after the core interfaces. A module that imports an -/// extension interface instantiates only if that extension's hook is -/// present here, so the same `extensions` slice must drive both this linker -/// and capability enforcement via the crate-internal `capability_registry`. +/// extension's interfaces. Shared by the restart and launch paths. A module +/// importing an extension interface instantiates only if that extension's +/// hook is present, so the same `extensions` slice must drive this and +/// capability enforcement. pub fn build_linker( engine: &Engine, extensions: &[Arc>], @@ -1784,14 +1684,11 @@ pub fn build_linker( Ok(linker) } -/// Build a `Linker` for one provider kind: the kind's own scoped imports -/// plus the ambient WASI base and the allowlisted `wasi:http`. The core -/// `nexum:host` interfaces a provider must not touch (local-store, -/// remote-store, identity, logging) are deliberately withheld, so a -/// provider that imports one of them fails to instantiate rather than -/// silently gaining reach. Extensions are not linked into providers: a -/// provider speaks its protocol over the standard transport, not a domain -/// extension surface. +/// Build a `Linker` for one provider kind: the kind's scoped imports plus +/// the WASI base and allowlisted `wasi:http`. Core `nexum:host` interfaces +/// (local-store, remote-store, identity, logging) are withheld, so a +/// provider importing one fails to instantiate. Extensions are not linked +/// into providers. pub fn build_provider_linker( engine: &Engine, kind: &dyn ProviderKind, @@ -1803,10 +1700,9 @@ pub fn build_provider_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. +/// Resolve a component's manifest: explicit override, else sibling +/// `module.toml`, else the deprecated `nexum.toml` with a rename warning. +/// `None` when neither exists. fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { if let Some(path) = explicit { return Some(path.to_path_buf()); @@ -1832,9 +1728,7 @@ fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option( extensions: &[Arc>], ) -> CapabilityRegistry { @@ -1845,10 +1739,9 @@ pub(crate) fn capability_registry( registry } -/// A guest dispatch, guest execution plus every host call it awaited, -/// outlived its wall-clock deadline and was cancelled. Distinct from a -/// fuel trap: fuel bounds guest instructions, this bounds time spent in -/// host calls (chain RPC, redb, HTTP), which fuel does not meter. +/// A dispatch (guest plus every host call it awaited) outlived its +/// wall-clock deadline and was cancelled. Distinct from a fuel trap, which +/// bounds guest instructions. #[derive(Debug, thiserror::Error)] #[error( "dispatch exceeded its {0:?} wall-clock deadline \ @@ -1856,17 +1749,11 @@ pub(crate) fn capability_registry( )] struct DeadlineExceeded(Duration); -/// Run a guest dispatch future under a wall-clock `deadline`. -/// -/// Fuel and epoch metering bound only *guest* instructions; time spent -/// inside a host call is unmetered (see [`crate::runtime::limits`]), so -/// without this a module could park the dispatch indefinitely behind a -/// cheap-in-fuel host call. Returns `Err(DeadlineExceeded)` once the -/// future, guest plus every host call it awaited, outlives `deadline`; -/// dropping the future on timeout cancels the in-flight host call at its -/// next await point. Pure guest CPU spinning stays fuel's job: a future -/// that never yields cannot be interrupted here, which is exactly why -/// fuel and this deadline are complementary rather than redundant. +/// Run a guest dispatch future under a wall-clock `deadline`. Fuel bounds +/// only guest instructions, so this bounds time in host calls (see +/// [`crate::runtime::limits`]). Returns `Err(DeadlineExceeded)` once the +/// future outlives `deadline`; dropping it cancels the in-flight host call +/// at its next await point. Pure guest spinning stays fuel's job. async fn with_dispatch_deadline( deadline: Duration, fut: F, @@ -1876,35 +1763,28 @@ async fn with_dispatch_deadline( .map_err(|_elapsed| DeadlineExceeded(deadline)) } -/// Outcome of [`Supervisor::dispatch_to`] for a single module. -/// -/// Returned to the caller so path-specific follow-ups (e.g. the -/// progress marker on the block path) can branch on whether -/// the guest actually ran cleanly. Kept private; only the two -/// `dispatch_*` entry points consume it. +/// Outcome of [`Supervisor::dispatch_to`] for one module. Private; only +/// the `dispatch_*` entry points consume it. #[derive(Debug, Eq, PartialEq)] enum DispatchOutcome { /// Guest returned `Ok(())`. Ok, /// Guest returned a typed `fault` via WIT. Fault, - /// Guest trapped (panic / OOM / fuel exhaustion / etc.). Module - /// has been marked dead and may be quarantined per the - /// poison-policy. + /// Guest trapped (panic / OOM / fuel / etc). Marked dead, maybe + /// quarantined per the poison policy. Trapped, - /// `set_fuel` failed before the call. Module is left alive but - /// this event is skipped. + /// `set_fuel` failed before the call; the module stays alive, this + /// event is skipped. Skipped, - /// The per-module dispatch rate limit was exceeded. The event is - /// dropped before the guest runs; the module stays alive and its - /// failure / poison state is untouched. + /// Per-module dispatch rate limit exceeded; the event is dropped before + /// the guest runs, liveness untouched. RateLimited, } -/// Push the current trap timestamp into a component's failure-window -/// ring, drop entries older than the policy window, and report the -/// recent-failure count once it crosses `policy.max_failures`. Shared by -/// the module and provider poison sweeps. +/// Push the current trap timestamp into a component's failure-window ring, +/// drop entries older than the window, and report the recent count once it +/// crosses `policy.max_failures`. fn poison_crossed( failure_timestamps: &mut std::collections::VecDeque, policy: crate::runtime::poison_policy::PoisonPolicy, @@ -1922,9 +1802,8 @@ fn poison_crossed( crate::runtime::poison_policy::should_poison(policy, recent).then_some(recent) } -/// Flip `poisoned = true` once the module's failure window crosses the -/// policy threshold. The first transition emits the -/// `shepherd_module_poisoned` gauge + a structured WARN. +/// Flip `poisoned` once the module's failure window crosses the threshold; +/// the first transition emits the gauge and a WARN. fn record_failure_and_maybe_poison( module: &mut LoadedModule, policy: crate::runtime::poison_policy::PoisonPolicy, @@ -1968,10 +1847,9 @@ fn progress_key(chain: Chain) -> String { format!("last_dispatched_block:{}", chain.id()) } -/// A resolved chain-log subscription for the event loop: the owning -/// module, the chain + alloy `Filter`, and - when the subscription opted -/// into `resume` - the durable cursor key plus the block to resume from -/// (read from the store at boot). +/// A resolved chain-log subscription for the event loop: owning module, +/// chain, alloy `Filter`, and, when `resume` is set, the durable cursor key +/// and resume block. pub struct ChainLogSub { /// Module that declared the subscription; also its store namespace. pub module: String, @@ -1979,23 +1857,21 @@ pub struct ChainLogSub { pub chain: Chain, /// Alloy filter the poller opens with. pub filter: alloy_rpc_types_eth::Filter, - /// `Some` iff `resume = true`: the store key the resume cursor is read - /// and written under. + /// `Some` iff `resume = true`: the store key the resume cursor lives + /// under. pub cursor_key: Option, /// The persisted resume block, read at boot for a `resume` - /// subscription; `None` on first run or when `resume` is off. + /// subscription; `None` otherwise. pub initial_cursor: Option, - /// Opt-in cap on how far back the poller backfills, in blocks. `None` - /// backfills the whole gap; `Some(cap)` bounds the start to - /// `head - cap`, dropping the oldest missed blocks. + /// Opt-in cap on backfill depth, in blocks. `None` backfills the whole + /// gap; `Some(cap)` bounds the start to `head - cap`. pub max_lookback: Option, } /// Durable resume-cursor key for a chain-log subscription. Derived from -/// the normalized manifest inputs - NOT the alloy `Filter`, whose hash -/// uses a process-randomized `HashSet` and is not reproducible across -/// restarts. Stable and independent of `[[subscription]]` ordering. The -/// module name is the store namespace, so it is not part of the digest. +/// normalized manifest inputs, not the alloy `Filter` (whose hash is +/// process-randomized), so it is stable across restarts and subscription +/// ordering. fn chainlog_cursor_key( chain: Chain, address: Option<&str>, @@ -2014,10 +1890,8 @@ fn chainlog_cursor_key( } impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { - /// Project an alloy `Log` onto the WIT `chain-log` record, preserving every - /// RPC field so the guest reconstructs the alloy log without loss. The chain - /// id is not on the alloy log; the subscription context supplies it at the - /// `chain-logs` batch level. + /// Project an alloy `Log` onto the WIT `chain-log` record without loss. + /// The chain id is not on the alloy log; the batch level supplies it. fn from(log: &alloy_rpc_types_eth::Log) -> Self { Self { address: log.address().as_slice().to_vec(), @@ -2035,15 +1909,6 @@ impl From<&alloy_rpc_types_eth::Log> for nexum::host::types::ChainLog { } /// Errors surfaced by [`build_alloy_filter`]. -/// -/// Variants thread the underlying alloy parse error via `#[source]` -/// instead of `to_string()`-ing it - keeps the typed chain intact for -/// the supervisor's `tracing::warn!(error = %err, ...)` log line at -/// the call site (where the `Display` chain prints the parse detail). -/// -/// `IntoStaticStr` exposes the snake_case variant name as a -/// `&'static str` so the warn log can carry -/// `error_kind = address | topic` without a match-ladder. #[derive(Debug, thiserror::Error, strum::IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 96bbb443..174bc4a4 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -135,27 +135,16 @@ async fn empty_supervisor_returns_no_subscriptions() { } /// Data-compat guard: the persisted progress marker keys on the numeric -/// chain id, never the `Chain` `Display` name. A named chain must still -/// yield `last_dispatched_block:11155111`, not `...:sepolia`, so existing -/// redb entries keep resolving after this refactor. +/// chain id, so a named chain still yields `last_dispatched_block:11155111`. #[test] fn progress_marker_key_uses_numeric_chain_id() { let chain = Chain::from_id(11_155_111); assert_eq!(progress_key(chain), "last_dispatched_block:11155111"); } -/// Regression guard: engines whose modules only declare -/// `[[subscription]] kind = "block"` (or only `kind = "chain-log"`) must not -/// bail at boot. Previously `select_all` on an empty `Vec` yielded -/// `None` immediately and the "stream ended -> shut down" arm fired -/// before any event flowed. The fix in `runtime/event_loop.rs` -/// substitutes `stream::pending()` when the Vec is empty so the -/// corresponding select arm is never selected. -/// -/// Surfaced when wiring up `engine.m3.toml` for the M3 testnet runbook: -/// the M3 example modules (price-alert, balance-tracker) -/// all subscribe to blocks only, no logs. The engine bailed within -/// ~50 ms of `supervisor ready` until this fix landed. +/// An engine whose modules declare only `kind = "block"` (or only +/// `kind = "chain-log"`) must not bail at boot when the other stream set +/// is empty. #[tokio::test] async fn run_does_not_bail_when_both_stream_kinds_are_empty() { use std::time::{Duration, Instant}; @@ -191,12 +180,9 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { // Verify the stream-open + run() + shutdown lifecycle end to end at the // supervisor boundary, without loading a real wasm module. -/// Block and chain-log streams are both consumed within the same `run()` -/// session: the `biased` select does not starve either event kind. One -/// item of each kind is queued before the loop starts; `run()`'s returned -/// tally must show both were drained. A regression that breaks either -/// select arm (or reorders the `biased` polling so one side never fires) -/// leaves its count at 0 and fails the assertion. Issue #56. +/// The `biased` select drains both block and chain-log streams within one +/// `run()` session without starving either; the returned tally shows both +/// were consumed. #[tokio::test] async fn run_delivers_block_and_chain_log_events_without_starvation() { use std::time::Duration; @@ -256,14 +242,8 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { ); } -/// After `run()` returns on the shutdown path, all reconnect tasks are -/// drained: the Shutdown arm calls `tasks.shutdown()`, which aborts every -/// handle and then joins each one, so no task detaches and outlives the -/// engine. (The companion contract, a task parked on a dropped receiver -/// exiting with `ReceiverGone` on its own, is asserted directly in -/// `event_loop::tests::reconnect_task_exits_receiver_gone_when_receiver_drops`; -/// it cannot be observed here because `TaskSet::shutdown` aborts first.) -/// Issue #58. +/// On the shutdown path `run()` aborts and joins every reconnect task, so +/// none detaches and outlives the engine. #[tokio::test] async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { use std::time::Duration; @@ -310,8 +290,7 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { // ── E2E helpers ─────────────────────────────────────────────────────── -/// Path to the pre-built example WASM component. Tests that need it -/// call `example_wasm_or_skip()` which skips gracefully if absent. +/// Path to the pre-built example WASM component. fn example_wasm() -> PathBuf { // CARGO_MANIFEST_DIR → crates/nexum-runtime Path::new(env!("CARGO_MANIFEST_DIR")) @@ -352,8 +331,7 @@ fn make_wasmtime_engine() -> wasmtime::Engine { wasmtime::Engine::new(&config).expect("wasmtime engine") } -/// The core-only extension set: no domain extensions. Domain-extension -/// boot coverage lives in the extension crate that owns the backend. +/// The core-only extension set: no domain extensions. fn core_extensions() -> Vec>> { Vec::new() } @@ -373,10 +351,8 @@ fn test_components(store: crate::host::local_store_redb::LocalStore) -> Componen } } -/// Return `(dir, store)` so the test holds the `TempDir` for the -/// duration of the test scope and cleans it up on drop. Forgetting -/// the dir (the old `ManuallyDrop` approach) leaks it for the -/// entire process lifetime. +/// Return `(dir, store)` so the test holds the `TempDir` and cleans it up +/// on drop. fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::LocalStore) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("ls.redb"); @@ -385,8 +361,7 @@ fn temp_local_store() -> (tempfile::TempDir, crate::host::local_store_redb::Loca } /// Boot a zero-module supervisor over the in-process mock backends via the -/// real `boot` path. The default config declares no modules, so `boot` -/// returns with an empty module set, touching neither disk nor network. +/// real `boot` path. async fn boot_mock_supervisor( engine: &wasmtime::Engine, ) -> Supervisor { @@ -430,10 +405,8 @@ 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. +/// The example component's capability-bearing imports are exactly what its +/// manifest declares (`logging`). #[test] fn e2e_example_component_imports_equal_declared_capabilities() { let Some(wasm) = example_wasm_or_skip() else { @@ -525,9 +498,8 @@ chain_id = 1 } /// 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 -/// the seam keeps reaching the store on the boot path. +/// store and is behaviour-neutral: the module boots, dispatches a block, +/// and stays alive as on the ambient clock. #[cfg(feature = "test-utils")] #[tokio::test] async fn e2e_manual_clock_override_boots_and_dispatches() { @@ -608,9 +580,8 @@ chain_id = 1 const SEPOLIA: u64 = 11_155_111; -/// Path to a production module's .wasm artefact under the workspace -/// target dir. `Cargo` writes the artefact as `.wasm` with -/// hyphens replaced by underscores, so the helper mirrors that. +/// Path to a production module's `.wasm` artefact under the workspace +/// target dir, with hyphens in the name replaced by underscores. fn module_wasm(module_name: &str) -> PathBuf { let artifact = module_name.replace('-', "_"); Path::new(env!("CARGO_MANIFEST_DIR")) @@ -642,10 +613,7 @@ fn module_wasm_or_skip(module_name: &str) -> Option { } } -/// Resolve a real `module.toml` for one of the production modules. -/// Looking up the real manifest (rather than synthesising one) keeps -/// the integration test honest about the capability set + subscription -/// shape each module actually ships. +/// Resolve the real `module.toml` for one of the production modules. fn production_module_toml(relative_path: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -665,7 +633,7 @@ fn synthetic_sepolia_block() -> nexum::host::types::Block { } /// Boot a single module from `(wasm, manifest)` and return the live -/// supervisor. Shared body across the 5 integration tests. +/// supervisor. async fn boot_production_module( engine: &wasmtime::Engine, linker: &Linker>, @@ -721,13 +689,10 @@ async fn e2e_balance_tracker_block_dispatch() { assert_eq!(supervisor.alive_count(), 1); } -/// End-to-end wasi:http path: http-probe fetches a loopback server -/// admitted by its allowlist, then fetches an off-list host and -/// requires the HTTP-request-denied outcome inside the guest. The -/// module returns `Ok` from `on_event` only when both legs hold, so -/// `dispatched == 1` asserts the success AND denied paths together. -/// The off-list host is never resolved or dialled (the gate denies -/// before any connection), so the test needs no external network. +/// End-to-end wasi:http path: http-probe fetches a loopback server on its +/// allowlist, then an off-list host that the gate denies before any +/// connection. The guest returns `Ok` only when both legs hold, so +/// `dispatched == 1` asserts the allow and deny paths together. #[tokio::test] async fn e2e_http_probe_allowlisted_fetch_and_denied_path() { let Some(wasm) = module_wasm_or_skip("http-probe") else { @@ -789,15 +754,9 @@ denied_url = "http://denied.invalid/" // ── Init-failed modules must be marked dead ──────────────── -/// Drive `Supervisor::boot_single` with a module whose `[config]` -/// carries a malformed `threshold` value (`"not-a-number"`). The -/// module's `init` returns `Err(fault.invalid-input)`. -/// Previously the supervisor still marked the module -/// `alive = true`, so it received block dispatches forever. The fix -/// flips `alive = false` when `init` fails. -/// -/// Surfaced live on Sepolia in -/// `docs/operations/m3-edge-case-validation.md` scenario 1.4. +/// A module whose `[config]` carries a malformed `threshold` fails `init` +/// with `fault.invalid-input`; the supervisor marks it `alive = false` so +/// it receives no dispatches. #[tokio::test] async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -856,11 +815,8 @@ every_n_blocks = "1" ); } -/// Dead modules (here: init-failed, `alive = false`) must not contribute -/// their chain to `block_chains()` or `chain_log_subscriptions()`. Without -/// the alive filter the builder opens live RPC subscriptions against chains -/// that will never dispatch to any module, wasting connections and emitting -/// zero-dispatch events until shutdown. +/// An init-failed (dead) module must not contribute its chain to +/// `block_chains()` or `chain_log_subscriptions()`. #[tokio::test] async fn dead_modules_excluded_from_subscription_lists() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -922,10 +878,7 @@ every_n_blocks = "1" } /// Positive control for the alive filter: with one dead and one alive -/// module, the alive module's subscriptions must survive the filter. -/// Guards against a regression where the filter (or a manifest-schema -/// change) empties the lists for everyone, which the all-dead test -/// above cannot distinguish from correct filtering. +/// module, the alive module's subscriptions survive the filter. #[tokio::test] async fn alive_module_subscriptions_survive_alongside_dead_module() { let Some(price_alert_wasm) = module_wasm_or_skip("price-alert") else { @@ -1035,8 +988,7 @@ chain_id = 1 // host-call time fuel cannot meter. /// `with_dispatch_deadline` cancels rather than awaits an over-long future: -/// a sleep far past the deadline is dropped, not run. The end-to-end case is -/// `dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers`. +/// a sleep far past the deadline is dropped, not run. #[tokio::test] async fn dispatch_deadline_interrupts_a_sleeping_host_call() { use std::sync::Arc; @@ -1102,10 +1054,10 @@ fn event_deadline_resolves_override_default_and_floor() { } /// A guest suspended inside a host call is cut off by the wall-clock -/// deadline, the poisoned store torn down and the module marked dead, then a -/// later dispatch reinstantiates it on a fresh store. The `slow-host` fixture -/// parks its first `chain::request` an hour past a 1s deadline override; the -/// park is one-shot, so the module recovers after the restart backoff. +/// deadline and the module marked dead, then a later dispatch reinstantiates +/// it on a fresh store. The `slow-host` fixture parks its first +/// `chain::request` an hour past a 1s deadline, one-shot, so it recovers +/// after the backoff. #[tokio::test] async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { use std::time::Instant; @@ -1220,8 +1172,7 @@ fn fixture_module_toml(relative_path: &str) -> PathBuf { .join(relative_path) } -/// Boot a single fixture (.wasm + module.toml) under the supervisor. -/// Shared body across the two resource-limit tests. +/// Boot a single fixture (`.wasm` + `module.toml`) under the supervisor. async fn boot_fixture(wasm: &Path, manifest_relative: &str) -> DefaultSupervisor { let engine = make_wasmtime_engine(); let linker = make_linker(&engine); @@ -1632,12 +1583,9 @@ fn components_with_logs( (components, logs) } -/// Ported to the [`TestRuntime`] harness: it replaces the hand-built -/// `boot_single` plus manual `dispatch_block` ceremony with an inline-manifest -/// launch, an injected header, and a polled log read, while holding the same -/// coverage. The example module logs via the host logging glue at init and on -/// the block, so its run holds retrievable HostInterface records after one -/// dispatch. +/// The example module logs via the host logging glue at init and on the +/// block, so its run holds retrievable HostInterface records after one +/// dispatch. Driven through the [`TestRuntime`] harness. #[tokio::test] async fn host_interface_records_are_retrievable_after_a_run() { let Some(wasm) = example_wasm_or_skip() else { @@ -1929,12 +1877,10 @@ chain_id = 100 assert_eq!(supervisor.alive_count(), 2); } -/// Acceptance criterion for the per-handler dispatch rate limit: a -/// source flooding one module is throttled at the dispatch boundary -/// (over-rate events dropped) while a second module on another chain -/// still gets every dispatch. Two healthy example modules; a tiny -/// `[limits.dispatch]` (burst = 2, refill = 1/s) so the flood drains -/// the first module's bucket almost immediately. +/// Per-module dispatch rate limit: a source flooding one module is +/// throttled (over-rate events dropped) while a second module on another +/// chain still gets every dispatch. A tiny `[limits.dispatch]` (burst = 2, +/// refill = 1/s) drains the first bucket almost immediately. #[tokio::test] async fn dispatch_rate_limit_throttles_a_flood_without_starving_others() { let Some(wasm) = example_wasm_or_skip() else { @@ -2359,8 +2305,7 @@ fn chainlog_cursor_key_differs_by_each_input() { // ── provider boot gating ────────────────────────────────────────────── /// A stub extension registering the `acme-adapter` provider kind behind a -/// unit service, so the boot-gate tests exercise the generic kind loop -/// without a real provider component. +/// unit service, for the boot-gate tests. struct AcmeService; impl crate::host::extension::HostService for AcmeService {} @@ -2423,9 +2368,8 @@ fn acme_extensions() -> Vec>> { vec![Arc::new(AcmeExtension)] } -/// The module-kind discriminator gates the provider load path: an -/// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the registered +/// An `[[adapters]]` entry whose manifest is (or defaults to) an +/// event-module is rejected before instantiation, naming the registered /// kinds. #[tokio::test] async fn boot_rejects_provider_whose_manifest_is_an_event_module() { @@ -2505,9 +2449,7 @@ async fn boot_rejects_an_unregistered_provider_kind() { } /// A registered kind clears the discriminator; boot then reaches the -/// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the provider load path -/// rather than rejecting it on kind. +/// compile step and fails only because the referenced wasm is absent. #[tokio::test] async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { let engine = make_wasmtime_engine(); From db97bd376e78bfb27bda9de692b957eba0c3a182 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:31:57 +0000 Subject: [PATCH 131/139] docs: tersen rustdoc in runtime host (#623) --- crates/nexum-runtime/src/host/actor.rs | 38 +++--- .../src/host/component/builder.rs | 43 ++----- .../nexum-runtime/src/host/component/chain.rs | 14 +- .../nexum-runtime/src/host/component/mod.rs | 21 ++- .../src/host/component/runtime_types.rs | 22 +--- .../nexum-runtime/src/host/component/state.rs | 13 +- crates/nexum-runtime/src/host/error.rs | 44 ++----- crates/nexum-runtime/src/host/extension.rs | 108 ++++++---------- crates/nexum-runtime/src/host/http.rs | 68 +++------- crates/nexum-runtime/src/host/impls/chain.rs | 32 ++--- .../nexum-runtime/src/host/impls/logging.rs | 5 +- .../nexum-runtime/src/host/impls/messaging.rs | 17 +-- crates/nexum-runtime/src/host/impls/mod.rs | 9 +- .../src/host/local_store_redb.rs | 74 ++++------- crates/nexum-runtime/src/host/logs/mod.rs | 72 ++++------- crates/nexum-runtime/src/host/logs/stdio.rs | 37 ++---- crates/nexum-runtime/src/host/logs/store.rs | 23 ++-- crates/nexum-runtime/src/host/mod.rs | 34 +---- .../nexum-runtime/src/host/provider_pool.rs | 120 ++++++------------ crates/nexum-runtime/src/host/state.rs | 48 +++---- 20 files changed, 272 insertions(+), 570 deletions(-) diff --git a/crates/nexum-runtime/src/host/actor.rs b/crates/nexum-runtime/src/host/actor.rs index c10f8e0a..62cb956c 100644 --- a/crates/nexum-runtime/src/host/actor.rs +++ b/crates/nexum-runtime/src/host/actor.rs @@ -1,8 +1,7 @@ -//! The supervised host-actor primitive: one component instance the host -//! holds and others call. The store is refuelled before each guest call, -//! a trap is projected onto a typed fault instead of unwinding into the -//! caller, and each instance sits behind an [`ActorSlot`] async mutex held -//! across the guest await, so one store never runs two guest calls at once. +//! Supervised host-actor primitive: one component instance the host holds and +//! others call. The store is refuelled before each guest call, traps project +//! onto a typed fault, and an [`ActorSlot`] mutex serialises calls so one +//! store never runs two at once. use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; @@ -13,14 +12,13 @@ use wasmtime::Store; use super::component::RuntimeTypes; use super::state::HostState; -/// One supervised actor behind its serialising mutex. A wasmtime `Store` -/// is not `Sync`; concurrent callers queue here. +/// One supervised actor behind its serialising mutex; concurrent callers +/// queue. pub type ActorSlot = Arc>; -/// Shared liveness of one supervised component. The store marks it dead on -/// a trap, recording when, so the supervisor's restart sweep can count the -/// backoff from the death rather than from the sweep that observed it. -/// Cloning shares the flag. Starts alive. +/// Shared liveness of one supervised component; a trap marks it dead and +/// records when, for backoff from the death instant. Clone shares the flag, +/// starts alive. #[derive(Clone, Debug, Default)] pub struct Liveness(Arc>>); @@ -35,8 +33,7 @@ impl Liveness { *self.lock() } - /// Mark the component dead: its store trapped and is unusable. Keeps - /// the first death instant when already dead. + /// Mark dead, keeping the first death instant if already dead. pub fn mark_dead(&self) { let mut died_at = self.lock(); if died_at.is_none() { @@ -49,8 +46,7 @@ impl Liveness { *self.lock() = None; } - /// The flag, recovered from a poisoned lock: the state is a bare - /// `Option`, valid under any interleaving. + /// The flag, recovered from a poisoned lock. fn lock(&self) -> MutexGuard<'_, Option> { self.0 .lock() @@ -65,15 +61,13 @@ pub enum ActorFault { /// The pre-call refuel failed; the guest was never entered. #[error("refuel failed: {0}")] Refuel(wasmtime::Error), - /// The guest trapped. Carries the root cause only; the wasm frame - /// list stays out of the caller-facing message. + /// The guest trapped; carries the root cause only. #[error("trapped: {}", .0.root_cause())] Trap(wasmtime::Error), } -/// A supervised component store: refuelled before each guest call so every -/// invocation starts from a full budget, with traps projected onto -/// [`ActorFault`] and recorded on the shared [`Liveness`]. +/// A supervised component store: refuelled before each guest call, with traps +/// projected onto [`ActorFault`] and recorded on [`Liveness`]. pub struct SupervisedStore { store: Store>, fuel_per_call: u64, @@ -91,8 +85,8 @@ impl SupervisedStore { } } - /// Refuel, then run one guest call against the store. A trap marks the - /// shared liveness dead: the store is poisoned until reinstantiated. + /// Refuel, then run one guest call; a trap marks liveness dead until + /// reinstantiated. pub async fn call( &mut self, call: impl AsyncFnOnce(&mut Store>) -> wasmtime::Result, diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 6b4111cc..02222c98 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -1,11 +1,6 @@ -//! Per-component builders: one seam for turning the loaded config plus a -//! resolved data directory into a runtime backend. -//! -//! Each core backend is wrapped as a [`ComponentBuilder`], and -//! [`ComponentsBuilder`] assembles the core seams (plus the lattice `Ext` -//! payload and the log pipeline) into a [`Components`] bundle. The -//! composition root names the concrete builders once; boot drives them -//! through this trait. +//! Per-component builders. Each core backend is a [`ComponentBuilder`]; +//! [`ComponentsBuilder`] assembles the core seams, the lattice `Ext` payload, +//! and the log pipeline into a [`Components`] bundle. use std::future::Future; use std::path::Path; @@ -17,9 +12,7 @@ use crate::host::local_store_redb::LocalStore; use crate::host::logs::LogPipeline; use crate::host::provider_pool::ProviderPool; -/// Shared inputs every component builder reads: the loaded engine config, -/// the resolved data directory backends open their files under, and the -/// executor blocking opens run on. +/// Shared inputs every component builder reads. pub struct BuilderContext<'a> { /// The loaded engine config. pub config: &'a crate::engine_config::EngineConfig, @@ -29,9 +22,7 @@ pub struct BuilderContext<'a> { pub executor: &'a TaskExecutor, } -/// Builds one runtime backend from the shared [`BuilderContext`]. The -/// `impl Future + Send` form lets a builder connect over the network -/// (the chain provider does) while staying usable from a spawned task. +/// Builds one runtime backend from the shared [`BuilderContext`]. pub trait ComponentBuilder { /// The backend this builder produces. type Output; @@ -94,9 +85,7 @@ impl ComponentBuilder for LogPipelineBuilder { } } -/// Names the component slot whose build failed. The leaf cause stays an -/// `anyhow::Error` because the backends fail for heterogeneous reasons -/// (I/O for the store, network for the chain). +/// Names the component slot whose build failed. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum BuildError { @@ -124,10 +113,8 @@ impl ComponentBuilder for () { } } -/// Assembles the core backend builders, the lattice `Ext` builder, and the -/// log pipeline builder into a [`Components`] bundle. The logs slot defaults -/// to [`LogPipelineBuilder`]; the embedder retains the read handle by -/// cloning [`Components::logs`] after the build. +/// Assembles the core, `Ext`, and log-pipeline builders into a [`Components`] +/// bundle; the logs slot defaults to [`LogPipelineBuilder`]. pub struct ComponentsBuilder { /// Builds the chain backend ([`RuntimeTypes::Chain`]). pub chain: C, @@ -162,12 +149,8 @@ impl ComponentsBuilder { } } - /// Drive each builder against `ctx` and bundle the backends. The - /// builder outputs must match the lattice seams: chain to - /// [`RuntimeTypes::Chain`], store to [`RuntimeTypes::Store`], ext to - /// [`RuntimeTypes::Ext`]; logs always yields a [`LogPipeline`]. A - /// failing sub-build returns the [`BuildError`] variant naming that - /// slot. + /// Drive each builder against `ctx` and bundle the backends; a failing + /// sub-build returns the [`BuildError`] naming that slot. pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, @@ -195,11 +178,7 @@ mod tests { use crate::engine_config::EngineConfig; use crate::preset::CoreRuntime; - /// Drives the core component builders end-to-end against a real (empty) - /// config and a fresh data directory: chain pool, redb store, and the - /// log pipeline are opened at runtime, not just typechecked. Proves the - /// store builder creates the data directory and the assembly bundles a - /// live pipeline. + /// Opens the core backends end-to-end against a fresh data directory. #[tokio::test] async fn components_builder_opens_the_core_backends() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/crates/nexum-runtime/src/host/component/chain.rs index 294eba71..490736d7 100644 --- a/crates/nexum-runtime/src/host/component/chain.rs +++ b/crates/nexum-runtime/src/host/component/chain.rs @@ -8,14 +8,10 @@ use alloy_rpc_types_eth::Filter; use crate::host::provider_pool::{BlockStream, CanonicalLogStream, ProviderError, ProviderPool}; -/// The read surface is defined once in `nexum-world`; host and guest -/// re-export the same type, so the dispatch table and the guest -/// allowlist cannot drift. +/// Permitted read surface, re-exported from `nexum-world`. pub use nexum_world::ChainMethod; -/// Async chain backend. Methods mirror [`ProviderPool`] one-to-one; -/// the `impl Future + Send` form bakes in the Send bound generic -/// consumers need across `.await` in tokio tasks (not dyn-compatible). +/// Async chain backend; methods mirror [`ProviderPool`]. pub trait ChainProvider { /// Open a `newHeads` block subscription on `chain`. fn subscribe_blocks( @@ -23,8 +19,7 @@ pub trait ChainProvider { chain: Chain, ) -> impl Future> + Send; - /// Current head block number (`eth_blockNumber`), used as the - /// canonical log poller's start block. + /// Current head block number (`eth_blockNumber`). fn block_number(&self, chain: Chain) -> impl Future> + Send; @@ -37,8 +32,7 @@ pub trait ChainProvider { start_block: u64, ) -> Result; - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` is the JSON params array. + /// Raw JSON-RPC dispatch; `params_json` is the JSON params array. fn request( &self, chain: Chain, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index fd9d24e1..80054506 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -1,8 +1,6 @@ -//! Backend component traits: the seam between the WIT host impls and -//! the concrete capability backends. Implemented here for the existing -//! pools; the runtime-generic `HostState` consumes them via generic -//! bounds (the async traits are not dyn-compatible by design). The -//! [`RuntimeTypes`] lattice ties the seams into one parameter. +//! Backend component traits: the seam between the WIT host impls and the +//! concrete capability backends, tied together by the [`RuntimeTypes`] +//! lattice. mod builder; mod chain; @@ -17,16 +15,14 @@ pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; pub use state::{StateHandle, StateStore}; -/// Owned bundle of the shared backends the supervisor threads into -/// every module store. All members are cheap Arc-backed clones. +/// Owned bundle of shared backends threaded into every module store; cheap to +/// clone. pub struct Components { pub chain: T::Chain, pub store: T::Store, - /// Extension backends (the lattice `Ext` payload), threaded into - /// `HostState.ext` and reached by extensions through `ExtState`. + /// Extension backends (the lattice `Ext` payload). pub ext: T::Ext, - /// Shared log pipeline: capture points route through its router, and - /// the embedder reads runs and logs back off the same handle. + /// Shared log pipeline. pub logs: crate::host::logs::LogPipeline, } @@ -47,8 +43,7 @@ mod tests { use crate::host::local_store_redb::{LocalStore, ModuleStore}; use crate::host::provider_pool::ProviderPool; - /// Core-only lattice (no extension payload) so the trait bounds are - /// exercised without depending on any domain extension crate. + /// Core-only lattice (no extension payload). #[derive(Clone, Copy, Default)] struct CoreTypes; diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index 33741499..70af46af 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -1,29 +1,17 @@ -//! The RuntimeTypes lattice: one trait naming the core backend seams plus -//! the pluggable extension slot, so every generic signature takes a single +//! The RuntimeTypes lattice: one trait naming the core backend seams plus the +//! pluggable [`RuntimeTypes::Ext`] slot, so every generic signature takes one //! parameter. -//! -//! Time, randomness, and outbound HTTP are deliberately not members: all -//! are WASI concerns serviced per store (WasiCtxBuilder for clocks and -//! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends are not core seams: they live behind -//! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; -/// Names the core backend seams a runtime assembly provides, plus the -/// extension slot ([`Ext`](RuntimeTypes::Ext)) that carries any non-core -/// backend an extension needs. -/// -/// Sealed: a lattice opts in by also implementing the sealing marker. +/// Core backend seams a runtime assembly provides, plus the extension slot +/// ([`Ext`](RuntimeTypes::Ext)). Sealed. pub trait RuntimeTypes: crate::sealed::SealedRuntimeTypes + 'static { /// JSON-RPC dispatch and subscriptions. type Chain: ChainProvider + Clone + Send + Sync + 'static; /// Process-wide store vending per-module handles. type Store: StateStore + Clone + Send + Sync + 'static; - /// Extension state slot. Backends that are not core capabilities live - /// here; an extension reaches its payload through the `ExtState` - /// accessor without naming the concrete lattice. `()` for an assembly - /// with no extensions. + /// Extension state slot; `()` for an assembly with no extensions. type Ext: Clone + Send + Sync + 'static; } diff --git a/crates/nexum-runtime/src/host/component/state.rs b/crates/nexum-runtime/src/host/component/state.rs index edcb1098..587f0bce 100644 --- a/crates/nexum-runtime/src/host/component/state.rs +++ b/crates/nexum-runtime/src/host/component/state.rs @@ -29,24 +29,19 @@ pub trait StateHandle { fn delete(&self, key: &str) -> Result<(), StorageError>; /// Enumerate module-visible keys starting with `prefix`. fn list_keys(&self, prefix: &str) -> Result, StorageError>; - /// Whether `key` exists. Default fetches the value; a backend - /// overrides when it can answer without. + /// Whether `key` exists. fn contains(&self, key: &str) -> Result { Ok(self.get(key)?.is_some()) } - /// Value byte length, `Ok(None)` when absent. Default fetches the - /// value; on some backends this may be a scan. + /// Value byte length, `Ok(None)` when absent. fn len(&self, key: &str) -> Result, StorageError> { Ok(self.get(key)?.map(|v| v.len() as u64)) } - /// Number of keys starting with `prefix`. Default materialises the - /// key list; on some backends this may be a scan. + /// Number of keys starting with `prefix`. fn count(&self, prefix: &str) -> Result { Ok(self.list_keys(prefix)?.len() as u64) } - /// Apply `ops` as one atomic batch: every op lands or none does. - /// Quota is charged on the net whole-batch footprint; the backend - /// caps op count and total value bytes per batch. + /// Apply `ops` as one atomic batch; caps op count and total value bytes. fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError>; } diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index bb67b619..22951a7b 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -1,23 +1,18 @@ -//! Small constructors and From conversions that build the WIT error -//! shapes: the chain interface's `chain-error` and the per-interface -//! `Fault` the store interfaces report. `fault_label` / `fault_message` -//! project a reported `Fault` into stable metric and log fields. +//! Constructors and `From` conversions building the WIT error shapes +//! (`chain-error`, `Fault`); `fault_label` / `fault_message` project a +//! `Fault` into metric and log fields. use crate::bindings::nexum::host::chain::{ChainError, RpcError}; use crate::bindings::nexum::host::types::{Fault, RateLimit}; use crate::host::local_store_redb::StorageError; use crate::host::provider_pool::ProviderError; -/// `Denied` chain fault for a request the host policy refused to -/// forward, such as a method outside the permitted read surface. +/// `Denied` chain fault for a request the host policy refused. pub(crate) fn chain_denied(detail: impl Into) -> ChainError { ChainError::Fault(Fault::Denied(detail.into())) } -/// Stable snake_case label for a [`Fault`], used as a metric label and -/// structured-log `kind` field. Emitted from the single-source -/// [`nexum_world::FaultLabel`] vocabulary the SDK `HostFault::label` -/// mirrors. +/// Stable snake_case label for a [`Fault`], for metric and log `kind` fields. pub fn fault_label(fault: &Fault) -> &'static str { use nexum_world::FaultLabel as Label; match fault { @@ -32,11 +27,7 @@ pub fn fault_label(fault: &Fault) -> &'static str { .into() } -/// Human-readable detail carried by a [`Fault`], for the log `message` -/// field. The bindgen `Display` is the `{0:?}` debug form, so operator -/// logs render through this instead. The payload-bearing cases carry -/// their own detail; a rate limit keeps its `retry-after-ms` hint; -/// `timeout` renders a fixed phrase. +/// Human-readable detail carried by a [`Fault`], for the log `message` field. pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { match fault { Fault::Unsupported(m) @@ -52,14 +43,9 @@ pub fn fault_message(fault: &Fault) -> std::borrow::Cow<'_, str> { } } -/// Project a [`ProviderError`] into the chain `chain-error`. -/// -/// A structured JSON-RPC `ErrorResp` (the node returned a `code`, -/// typically `-32000` for an `eth_call` revert) becomes a -/// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so an SDK revert classifier can dispatch the revert -/// envelopes. Everything else - transport failures, an unknown chain, -/// bad params - becomes a shared [`Fault`]. +/// Project a [`ProviderError`] into `chain-error`: a structured JSON-RPC +/// `ErrorResp` becomes [`ChainError::Rpc`] with its code and revert bytes, +/// everything else a shared [`Fault`]. impl From for ChainError { fn from(err: ProviderError) -> Self { match err { @@ -105,10 +91,8 @@ impl From for ChainError { } } -/// Classify a transport-level RPC failure into a [`Fault`]. HTTP 429 -/// maps to `rate-limited`, 503 / a dropped backend to `unavailable`, -/// and a timed-out request to `timeout`; anything else defaults to -/// `unavailable`. +/// Classify a transport RPC failure: 429 to `rate-limited`, 503 or a dropped +/// backend to `unavailable`, a timeout to `timeout`, else `unavailable`. fn transport_fault(source: &alloy_transport::TransportError) -> Fault { use alloy_transport::TransportErrorKind; if let Some(kind) = source.as_transport_err() { @@ -136,10 +120,8 @@ fn transport_fault(source: &alloy_transport::TransportError) -> Fault { } } -/// The `local-store` interface is the failure domain, so the fault omits -/// the redundant subsystem tag. A quota breach is a policy `denied`, an -/// apply batch past a per-batch cap is the caller's `invalid-input`; -/// anything else is an `internal` backend failure. +/// Project a [`StorageError`]: quota breach to `denied`, a per-batch cap to +/// `invalid-input`, else `internal`. impl From for Fault { fn from(err: StorageError) -> Self { match err { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 9d097fb0..06761a08 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,9 +1,6 @@ -//! The extension seam: what one extension contributes to the host - a -//! namespace, a capability namespace, a linker hook, an optional host -//! service, an optional provider kind, optional event sources, and -//! optional install predicates over the manifest sections it claims. -//! Assembled at the composition root and threaded into every module -//! linker. +//! Extension seam: what one extension contributes to the host (namespace, +//! capabilities, linker hook, optional service, provider kind, event sources, +//! and manifest-section install predicates). use std::any::Any; use std::collections::{BTreeMap, BTreeSet}; @@ -23,25 +20,20 @@ use crate::host::component::RuntimeTypes; use crate::host::state::HostState; use crate::manifest::{ExtensionSections, NamespaceCaps}; -/// One runtime extension. A module that imports an extension interface -/// boots only if the linker entry AND the capability namespace are both -/// registered before instantiation. +/// One runtime extension; a module importing its interface boots only if both +/// the linker entry and the capability namespace are registered. pub trait Extension: Send + Sync + 'static { /// Namespace this extension owns; keys its service in [`HostServices`]. fn namespace(&self) -> &'static str; - /// Capability namespace merged into enforcement so a module importing - /// the extension's interfaces still validates. + /// Capability namespace merged into enforcement. fn capabilities(&self) -> NamespaceCaps; - /// Adds the extension's imports to a worker linker. Runs after the - /// core interfaces and before instantiation. Takes only `&mut Linker`, - /// so the seam stays compatible with a future per-extension router - /// that serializes access to the non-`Sync` wasmtime `Store`. + /// Add the extension's imports to a worker linker, after core interfaces + /// and before instantiation. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Host service this extension owns, published under its namespace on - /// [`HostServices`]. + /// Host service this extension owns, published under its namespace. fn service(&self) -> Option> { None } @@ -51,22 +43,21 @@ pub trait Extension: Send + Sync + 'static { None } - /// Manifest section names this extension claims. A non-core section - /// no wired extension claims is refused at boot. + /// Manifest section names this extension claims; an unclaimed non-core + /// section is refused at boot. fn manifest_sections(&self) -> &'static [&'static str] { &[] } - /// Admit one provider at install, over its opaque manifest sections. - /// Runs before compilation; an `Err` refuses the install fail-fast. + /// Admit one provider at install over its manifest sections; `Err` + /// refuses fail-fast. fn admit_provider(&self, provider: &str, sections: &ExtensionSections) -> anyhow::Result<()> { let _ = (provider, sections); Ok(()) } - /// Admit one worker at install, over its own and the loaded - /// providers' opaque manifest sections. Runs before compilation; an - /// `Err` refuses the install fail-fast. + /// Admit one worker at install over its and the loaded providers' + /// sections; `Err` refuses fail-fast. fn admit_worker( &self, worker: &str, @@ -77,24 +68,22 @@ pub trait Extension: Send + Sync + 'static { Ok(()) } - /// Manifest subscription kinds this extension's event sources emit. - /// A `[[subscription]]` entry of any other non-core kind is refused - /// at boot. + /// Subscription kinds this extension's event sources emit; an unknown + /// non-core kind is refused at boot. fn subscriptions(&self) -> &'static [&'static str] { &[] } - /// Open the extension's event sources once the engine is booted. The - /// event loop merges the returned streams and dispatches each item to - /// the modules its kind and attributes admit. + /// Open the extension's event sources after boot; the event loop merges + /// and dispatches them. fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { let _ = sources; Ok(Vec::new()) } } -/// One extension-observed event: dispatched to every module holding a -/// `[[subscription]]` of `kind` whose filters all match `attrs`. +/// Event dispatched to every module with a `[[subscription]]` of `kind` whose +/// filters match `attrs`. pub struct ExtensionEvent { /// Manifest subscription kind that routes this event. pub kind: &'static str, @@ -107,9 +96,7 @@ pub struct ExtensionEvent { /// A stream of extension events the event loop merges and drives. pub type ExtensionEventStream = Pin + Send>>; -/// Ambient launch inputs for [`Extension::events`]: the loaded config, the -/// booted service map, the subscription kinds at least one module declares, -/// and the spawn surface for source tasks. +/// Launch inputs for [`Extension::events`]. pub struct EventSources<'a> { /// The loaded engine config. pub config: &'a EngineConfig, @@ -139,9 +126,8 @@ impl<'a> EventSources<'a> { } } - /// Spawn one event-source task through the engine's executor. The task - /// must end when its stream's receiver drops; the engine drains it on - /// shutdown. + /// Spawn an event-source task; it must end when its stream's receiver + /// drops. pub fn spawn(&mut self, task: impl Future + Send + 'static) { self.tasks.push(self.executor.spawn(async move { task.await; @@ -150,14 +136,11 @@ impl<'a> EventSources<'a> { } } -/// A type-erased host service an extension owns. Held per namespace on -/// `HostState::services` and downcast at the call site. Kept synchronous -/// so it stays `dyn`-compatible. +/// Type-erased host service an extension owns, downcast at the call site. pub trait HostService: Any + Send + Sync + 'static {} -/// A provider component kind: the host holds an instance behind the owning -/// extension's serialized service; others call it. `async_trait` carries -/// the one cold `dyn` boot path until `async_fn_in_dyn_trait` stabilizes. +/// A provider component kind; the host holds an instance behind the owning +/// extension's serialized service. #[async_trait] pub trait ProviderKind: Send + Sync + 'static { /// Manifest kind this provider answers for. @@ -166,9 +149,8 @@ pub trait ProviderKind: Send + Sync + 'static { /// Adds the provider's imports to a provider linker. fn link(&self, linker: &mut Linker>) -> anyhow::Result<()>; - /// Instantiate one provider and install it behind the owning service. - /// [`Installed::Dead`] reports a failed guest `init`; an `Err` is a - /// boot error. + /// Instantiate and install one provider; [`Installed::Dead`] is a failed + /// guest `init`, `Err` a boot error. async fn install( &self, instance: ProviderInstance<'_, T>, @@ -176,10 +158,7 @@ pub trait ProviderKind: Send + Sync + 'static { ) -> anyhow::Result; } -/// One provider instance ready to install: the compiled component, the -/// linker the kind's [`ProviderKind::link`] populated, the supervised -/// store, the manifest `[config]` and extension sections, and the -/// per-call fuel budget. +/// One provider instance ready to install. pub struct ProviderInstance<'a, T: RuntimeTypes> { /// Compiled provider component. pub component: &'a Component, @@ -189,22 +168,18 @@ pub struct ProviderInstance<'a, T: RuntimeTypes> { pub store: Store>, /// Manifest `[config]` handed to the guest `init`. pub config: Vec<(String, String)>, - /// The provider's extension-owned manifest sections, so a kind can - /// hold the instance to its manifest claims at install. + /// The provider's extension-owned manifest sections. pub sections: &'a ExtensionSections, /// Fuel budget applied before each routed guest call. pub fuel_per_call: u64, - /// Shared liveness the installed instance reports traps on and the - /// supervisor's restart sweep reads. + /// Shared liveness the instance reports traps on. pub liveness: Liveness, } -/// One loaded provider as [`Extension::admit_worker`] sees it: its -/// namespace, registered kind, and opaque manifest sections. Manifest -/// data only, so the predicate is static and liveness-independent. +/// One loaded provider as [`Extension::admit_worker`] sees it. #[derive(Clone, Debug)] pub struct ProviderManifest { - /// The provider's namespace: its manifest name. + /// The provider's namespace (its manifest name). pub name: String, /// Registered kind spelling. pub kind: &'static str, @@ -228,9 +203,7 @@ pub fn downcast_service(service: &Arc) -> Optio erased.downcast().ok() } -/// Immutable per-namespace service map: each extension's [`HostService`] -/// under its [`Extension::namespace`], built once at boot and shared by -/// every module store. +/// Immutable per-namespace service map, built once at boot. #[derive(Clone, Default)] pub struct HostServices(Arc>>); @@ -241,8 +214,8 @@ impl std::fmt::Debug for HostServices { } impl HostServices { - /// Collect each extension's service under its namespace. Refuses a - /// duplicate namespace. + /// Collect each extension's service under its namespace; refuses a + /// duplicate. pub fn from_extensions( extensions: &[Arc>], ) -> anyhow::Result { @@ -259,8 +232,8 @@ impl HostServices { Ok(Self(Arc::new(map))) } - /// The service under `namespace`, downcast to its concrete type. - /// `None` when the namespace is absent or the type does not match. + /// Service under `namespace` downcast to `S`; `None` if absent or + /// mismatched. pub fn get(&self, namespace: &str) -> Option> { downcast_service(self.0.get(namespace)?) } @@ -270,8 +243,7 @@ impl HostServices { self.0.get(namespace) } - /// Publish `service` under `namespace`, refusing a duplicate. The boot - /// path seeds a service no extension registers yet. + /// Publish `service` under `namespace`, refusing a duplicate. pub fn with_service( self, namespace: &'static str, diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index 2624457e..c156418a 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -1,10 +1,7 @@ -//! wasi:http outgoing gate: every guest request funnels through -//! [`HttpGate::send_request`], which enforces the per-module -//! `[capabilities.http].allow` list, clamps the guest-settable timeouts -//! to the engine's `[limits.http]` maxima, and bounds the exchange with -//! a total deadline plus a response-body cap before handing the request -//! to the backend. The host does not follow redirects, so each hop is a -//! fresh guest request that re-enters this gate. +//! wasi:http outgoing gate. [`HttpGate::send_request`] enforces the +//! per-module `[capabilities.http].allow` list, clamps guest timeouts to the +//! `[limits.http]` maxima, and bounds the exchange with a total deadline and +//! response-body cap. Redirects are not followed; each hop re-enters the gate. use std::future::Future; use std::pin::Pin; @@ -26,8 +23,7 @@ use super::state::HostState; use crate::engine_config::OutboundHttpLimits; use crate::manifest::host_allowed; -/// Per-module outbound HTTP policy: the manifest allowlist, the -/// engine's outbound limits, and the module name for log attribution. +/// Per-module outbound HTTP policy. pub struct HttpGate { module: String, allowlist: Vec, @@ -35,8 +31,7 @@ pub struct HttpGate { } impl HttpGate { - /// Gate for `module` with its `[capabilities.http].allow` entries - /// and the engine's `[limits.http]` outbound limits. + /// Gate for `module` with its allowlist and outbound limits. pub fn new( module: impl Into, allowlist: Vec, @@ -74,11 +69,8 @@ impl WasiHttpHooks for HttpGate { } } -/// Clamp the guest-settable timeouts to the engine maxima. Guest values -/// above a maximum are lowered, never rejected. The linked handler -/// substitutes its own fixed default for unset request-options before -/// this hook runs, so an unset timeout also clamps down: each maximum -/// doubles as the effective default. +/// Clamp guest timeouts to the engine maxima, lowering never rejecting; each +/// maximum doubles as the effective default for an unset timeout. fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> OutgoingRequestConfig { config.connect_timeout = config.connect_timeout.min(limits.connect_timeout_max); config.first_byte_timeout = config.first_byte_timeout.min(limits.first_byte_timeout_max); @@ -88,15 +80,10 @@ fn clamp(mut config: OutgoingRequestConfig, limits: &OutboundHttpLimits) -> Outg config } -/// Dispatch through the default backend, bounded by the engine's total -/// deadline and response-body cap. The `timeout_at` covers connect, -/// TLS, request write, and response headers; the same deadline instant -/// is armed inside the [`CappedBody`] wrapping the response body, so a -/// consuming guest gets `ConnectionReadTimeout` mid-body. The deadline -/// is unconditional: the connection driver is raced against it in its -/// own task and aborted when it fires, so a guest that parks the -/// response without ever reading the body cannot hold the socket past -/// the deadline. +/// Dispatch through the default backend under the total deadline and body +/// cap. The deadline is unconditional: it covers headers and, via +/// [`CappedBody`], the body, and the raced connection driver is aborted when +/// it fires even if the guest never reads the response. fn send_with_limits( request: http::Request, config: OutgoingRequestConfig, @@ -132,12 +119,9 @@ fn send_with_limits( HostFutureIncomingResponse::pending(handle) } -/// Response-body wrapper enforcing the size cap and the total deadline -/// while the guest streams the body. -/// -/// Exceeding the cap yields `HttpResponseBodySize(cap)`; the deadline -/// firing mid-body yields `ConnectionReadTimeout`, the code the backend -/// uses for its own read-phase timeouts. +/// Response-body wrapper enforcing the size cap and total deadline. Over-cap +/// yields `HttpResponseBodySize(cap)`; the deadline firing yields +/// `ConnectionReadTimeout`. struct CappedBody { inner: HyperIncomingBody, /// Bytes still admissible under the cap. @@ -197,18 +181,10 @@ impl Body for CappedBody { } } -/// Allowlist decision for one outgoing request URI. -/// -/// Matching is host-only: ports and scheme are ignored (the handler -/// admits only http/https before this point), and comparison is -/// case-insensitive with exact or `*.suffix` wildcard semantics per -/// [`host_allowed`]. IPv6 literals keep their brackets, so allowlist -/// entries use the bracketed form. -/// -/// The check is name-based and precedes resolution: the connection -/// re-resolves the name, so there is no IP pinning and no defence -/// against DNS rebinding or names resolving to internal addresses. -/// The operator vouches for the names they allowlist. +/// Allowlist decision for one request URI. Host-only, case-insensitive, exact +/// or `*.suffix` per [`host_allowed`]; IPv6 literals stay bracketed. +/// Name-based and pre-resolution, so there is no IP pinning or DNS-rebinding +/// defence. fn admit(uri: &http::Uri, allowlist: &[String]) -> Result<(), ErrorCode> { let Some(host) = uri.host() else { return Err(ErrorCode::HttpRequestUriInvalid); @@ -600,10 +576,8 @@ mod tests { nexum_tasks::TaskManager::new().executor() } - /// One-connection loopback server: reads the request, writes - /// `response`, then either closes or holds the socket open so the - /// client sees a stall instead of EOF. Panic-free: any IO failure - /// just ends the task and the client side times out. + /// One-connection loopback server; `hold_open` stalls instead of sending + /// EOF. async fn spawn_server(response: Vec, hold_open: bool) -> std::net::SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/crates/nexum-runtime/src/host/impls/chain.rs index f9afad2a..59aa8133 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/crates/nexum-runtime/src/host/impls/chain.rs @@ -1,4 +1,4 @@ -//! `nexum:host/chain`: raw JSON-RPC dispatch over alloy. +//! `nexum:host/chain`: raw JSON-RPC dispatch. use std::time::Instant; @@ -10,12 +10,8 @@ use crate::host::component::{ChainMethod, ChainProvider, RuntimeTypes}; use crate::host::error::chain_denied; use crate::host::state::HostState; -/// Resolve a guest method string into the permitted read surface. -/// -/// Signing-adjacent and mutating methods have no [`ChainMethod`] -/// variant, so they are rejected here structurally rather than by an -/// ad-hoc name check; the result is a `Denied` chain fault. Every entry -/// of a batch request routes through this same resolver. +/// Resolve a guest method string into the permitted read surface; an unknown +/// or mutating method is a `Denied` fault. fn resolve_method(method: &str) -> Result { ChainMethod::try_from(method).map_err(|_| { chain_denied(format!( @@ -24,9 +20,8 @@ fn resolve_method(method: &str) -> Result { }) } -/// Return an error if `body` exceeds `cap` bytes. The check is applied -/// host-side before the response is copied into the guest, so an -/// oversized node response cannot saturate the guest heap. +/// Error if `body` exceeds `cap` bytes, checked before the copy into the +/// guest. fn check_response_cap( body: &str, cap: usize, @@ -108,16 +103,8 @@ impl nexum::host::chain::Host for HostState { result } - /// Dispatch a batch of requests, one `RpcResult` per entry in order. - /// - /// The outer `ChainError` is reserved for a failure that stops the - /// host producing any results at all; this host has no such path, so - /// it always returns `Ok`. A per-entry failure (a denied - /// method, a node revert, a transport fault) surfaces as that entry's - /// `RpcResult::Err`. This impl folds each entry independently, so a - /// failure leaves its neighbours intact; a different host could instead - /// short-circuit the batch, so SDK consumers match on each entry, not - /// on the batch call. + /// Dispatch a batch, one `RpcResult` per entry in order. Per-entry + /// failures are independent; the outer `ChainError` is never returned. async fn request_batch( &mut self, chain_id: u64, @@ -182,10 +169,7 @@ mod tests { use crate::host::provider_pool::ProviderError; use alloy_transport::TransportErrorKind; - /// Helper: build a synthetic transport-level [`TransportError`]. - /// Transport-level errors carry no structured JSON-RPC `ErrorResp`, - /// so they project to a [`ChainError::Fault`] rather than a - /// [`ChainError::Rpc`]. + /// Build a synthetic transport-level [`TransportError`]. fn transport_err(msg: &str) -> alloy_transport::TransportError { TransportErrorKind::custom_str(msg) } diff --git a/crates/nexum-runtime/src/host/impls/logging.rs b/crates/nexum-runtime/src/host/impls/logging.rs index 37be16c8..12197013 100644 --- a/crates/nexum-runtime/src/host/impls/logging.rs +++ b/crates/nexum-runtime/src/host/impls/logging.rs @@ -1,6 +1,5 @@ -//! `nexum:host/logging`: constructs a `HostInterface` [`LogRecord`] from -//! the guest's `log` call and hands it to the shared router, which tags -//! it with the run and fans it to the tracing consumer and the store. +//! `nexum:host/logging`: builds a [`LogRecord`] from the guest's `log` call +//! and routes it. use tracing_core::Level; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index 459994ab..3851c710 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,20 +1,15 @@ -//! `nexum:host/messaging`: unimplemented stub. `publish` reports -//! `unsupported` and `query` returns empty. The per-store topic scope is -//! enforced ahead of that stub: a provider carrying a -//! `[[adapters]].messaging_topics` grant may only publish within it, so -//! the egress boundary is live even though delivery is not. +//! `nexum:host/messaging`: stub. `publish` reports `unsupported`, `query` +//! returns empty; the per-store topic scope is still enforced ahead of the +//! stub. 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/acme` does not admit `/nexum/1/acme-orders/...`). +/// Whether `topic` falls within `scope`. Empty scope admits everything; +/// otherwise a topic matches a scope entry exactly or as a `/`-bounded path +/// prefix, so a grant never leaks into a longer sibling segment. fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 2247ed60..b86cf5e8 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -1,10 +1,5 @@ -//! `Host` trait impls for [`crate::host::state::HostState`], one -//! file per WIT interface. -//! -//! The interfaces themselves (and their generated trait shapes) live -//! in [`crate::bindings`]; this module only contains the dispatch -//! glue between the WIT signature and the corresponding backend in -//! [`crate::host`]. +//! `Host` trait impls for [`crate::host::state::HostState`], one file per WIT +//! interface: dispatch glue to the backends in [`crate::host`]. mod chain; mod identity; diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index cc3929fa..a6c2516c 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -1,10 +1,9 @@ -//! `nexum:host/local-store` backend. +//! `nexum:host/local-store` backend: a single redb file under +//! `EngineConfig.engine.state_dir`. //! -//! Single redb file under `EngineConfig.engine.state_dir`. Each module is -//! namespaced host-side by a fixed 32-byte prefix `keccak256(module_name)` -//! prepended to every key, so modules sharing a key string see disjoint -//! data and cannot forge a key into another's range. keccak256 matches ENS -//! node derivation (ADR-0003). +//! Every key is prefixed host-side by `keccak256(module_name)`, so modules +//! sharing a key string see disjoint data and cannot forge into another's +//! range. #![allow(clippy::result_large_err)] @@ -20,8 +19,8 @@ const TABLE: TableDefinition<'static, &[u8], &[u8]> = TableDefinition::new("nexu #[cfg(test)] const PREFIX_LEN: usize = 32; -/// Fixed per-entry redb page/B-tree overhead charged on top of prefix + key -/// + value so the quota bounds on-disk bytes, not logical payload. +/// Fixed per-entry overhead charged with prefix+key+value so the quota bounds +/// on-disk bytes, not logical payload. const ENTRY_OVERHEAD: u64 = 32; /// Cap on ops per [`ModuleStore::apply`] batch. @@ -47,33 +46,26 @@ pub enum WriteOp { }, } -/// Process-wide handle to the local-store redb database. Cheap to -/// clone. Use [`LocalStore::module`] to obtain a [`ModuleStore`] -/// handle with a pre-computed namespace prefix. +/// Process-wide handle to the local-store redb database; cheap to clone. #[derive(Debug, Clone)] pub struct LocalStore { db: Arc, - /// Per-namespace live-byte counter, shared across every handle of a - /// namespace, so [`ModuleStore::set`] is O(1) rather than re-scanning - /// the namespace on each write. Lazily seeded by one range scan. + /// Per-namespace live-byte counter, lazily seeded, keeping writes O(1). counters: Arc, u64>>>, } -/// Per-module handle carrying the pre-computed 32-byte keccak256 -/// namespace prefix. +/// Per-module handle carrying the pre-computed keccak256 namespace prefix. #[derive(Debug, Clone)] pub struct ModuleStore { db: Arc, prefix: Vec, counters: Arc, u64>>>, - /// On-disk byte quota for this namespace, enforced in - /// [`ModuleStore::set`]. `None` is unlimited. + /// On-disk byte quota for this namespace; `None` is unlimited. quota_bytes: Option, } impl LocalStore { - /// Open (or create) the redb file at `path`. Initialises the shared - /// table so subsequent read transactions never hit `TableDoesNotExist`. + /// Open or create the redb file at `path`, initialising the shared table. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; { @@ -87,9 +79,7 @@ impl LocalStore { }) } - /// Return a [`ModuleStore`] with the keccak256 prefix pre-computed. - /// Rejects the empty string so callers can rely on a non-trivial - /// prefix. + /// [`ModuleStore`] for `namespace`; rejects the empty string. pub fn module(&self, namespace: &str) -> Result { if namespace.is_empty() { return Err(StorageError::InvalidNamespace( @@ -107,16 +97,14 @@ impl LocalStore { } impl ModuleStore { - /// Cap this handle's namespace at `quota_bytes` of on-disk footprint - /// (prefix + key + value + fixed overhead, summed across its keys). - /// Writes past the cap are rejected with [`StorageError::QuotaExceeded`]. + /// Cap this handle's namespace at `quota_bytes` on-disk; over-cap writes + /// return [`StorageError::QuotaExceeded`]. pub fn with_quota(mut self, quota_bytes: u64) -> Self { self.quota_bytes = Some(quota_bytes); self } - /// Fetch a value for `key`. Returns `Ok(None)` when no entry - /// exists; the module never observes the prefix. + /// Value for `key`, `Ok(None)` when absent. pub fn get(&self, key: &str) -> Result>, StorageError> { let full = self.build_key(key); let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -139,8 +127,7 @@ impl ModuleStore { .is_some()) } - /// Value byte length for `key`, `Ok(None)` when absent. Reads the - /// entry's length in place; the value bytes are never copied out. + /// Value byte length for `key`, `Ok(None)` when absent. pub fn len(&self, key: &str) -> Result, StorageError> { let full = self.build_key(key); let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -151,8 +138,7 @@ impl ModuleStore { .map(|v| v.value().len() as u64)) } - /// Number of module-visible keys starting with `prefix`. A bounded - /// B-tree range scan: no key strings are materialised. + /// Number of module-visible keys starting with `prefix`. pub fn count(&self, prefix: &str) -> Result { let full_prefix = self.build_key(prefix); let txn = self.db.begin_read().map_err(StorageError::Txn)?; @@ -171,9 +157,8 @@ impl ModuleStore { Ok(count) } - /// Insert or overwrite. Under a quota, charges on-disk cost (prefix, key, - /// value, overhead) and rejects an over-quota write untouched. The commit - /// is fsync-durable. + /// Insert or overwrite; fsync-durable. An over-quota write is rejected + /// untouched. pub fn set(&self, key: &str, value: &[u8]) -> Result<(), StorageError> { let full = self.build_key(key); let txn = self.db.begin_write().map_err(StorageError::Txn)?; @@ -218,12 +203,9 @@ impl ModuleStore { Ok(()) } - /// Apply `ops` in one write transaction: every op lands or none does. - /// Later ops on a key supersede earlier ones. Under a quota, the whole - /// batch's net footprint is projected and checked once; an over-quota - /// batch is rejected before commit so nothing lands. Batches past - /// [`MAX_APPLY_OPS`] or [`MAX_APPLY_VALUE_BYTES`] are rejected upfront. - /// The commit is fsync-durable. + /// Apply `ops` atomically, fsync-durable; later ops on a key win. An + /// over-quota batch or one past [`MAX_APPLY_OPS`] / + /// [`MAX_APPLY_VALUE_BYTES`] is rejected before commit. pub fn apply(&self, ops: &[WriteOp]) -> Result<(), StorageError> { if ops.len() > MAX_APPLY_OPS { return Err(StorageError::ApplyOpsExceeded { @@ -312,14 +294,12 @@ impl ModuleStore { Ok(()) } - /// On-disk footprint charged for one entry: prefix + key + value + a - /// fixed per-entry overhead. + /// On-disk footprint of one entry: prefix + key + value + overhead. fn entry_cost(&self, key_len: usize, value_len: usize) -> u64 { self.prefix.len() as u64 + ENTRY_OVERHEAD + key_len as u64 + value_len as u64 } - /// Seed the namespace footprint by summing [`Self::entry_cost`] over its - /// prefix range. Run once per namespace; the counter is then incremental. + /// Seed the namespace footprint by scanning its prefix range once. fn used_bytes( &self, table: &impl ReadableTable<&'static [u8], &'static [u8]>, @@ -364,9 +344,7 @@ impl ModuleStore { Ok(()) } - /// Enumerate keys whose raw key (post-prefix) starts with - /// `prefix`. Returns only the module-visible key strings; the - /// host strips the namespace prefix. + /// Module-visible keys whose post-prefix key starts with `prefix`. pub fn list_keys(&self, prefix: &str) -> Result, StorageError> { let full_prefix = self.build_key(prefix); let txn = self.db.begin_read().map_err(StorageError::Txn)?; diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/crates/nexum-runtime/src/host/logs/mod.rs index b619bf3e..ddb68535 100644 --- a/crates/nexum-runtime/src/host/logs/mod.rs +++ b/crates/nexum-runtime/src/host/logs/mod.rs @@ -1,25 +1,14 @@ //! Typed module-log pipeline. //! -//! Three capture points construct [`LogRecord`]s and hand them to one -//! [`LogRouter`]: the `nexum:host/logging` glue (`HostInterface`), the -//! per-store stdout/stderr pipes ([`StdioStream`], `Stdout`/`Stderr`), -//! and the supervisor's death path (`Panic`). The router fans each -//! record to exactly two consumers: a host `tracing` event (so the -//! operator console and OTLP stacks stay live) and the retention store. -//! `tracing` is a consumer of the pipeline, not its transport. +//! Three capture points build [`LogRecord`]s for one [`LogRouter`]: the +//! `nexum:host/logging` glue, the per-store stdout/stderr pipes, and the +//! supervisor death path. The router fans each record to a host `tracing` +//! event and the retention store. [`LogPipeline`] is the shared handle, +//! carrying the write side and the store's read side. //! -//! [`LogPipeline`] is the shared handle: it rides the host state so -//! every capture point reaches the router, and it exposes the store's -//! read side to the embedding surface for run listing and log paging. -//! -//! One guest panic deliberately yields three records, distinguishable -//! by source: the guest panic hook writes to stderr (`Stderr`, warn) -//! and then reports over the host logging call (`HostInterface`, -//! error), and the supervisor synthesizes a death record (`Panic`, -//! error) once the trap surfaces. The redundancy is kept because the -//! channels survive different failure modes: stderr capture works even -//! if the sink's host call traps, and the supervisor record covers a -//! guest with no hook installed at all. +//! One guest panic yields three records distinguished by [`LogSource`] +//! (stderr, host logging call, supervisor death), redundancy covering +//! channels that survive different failure modes. mod stdio; mod store; @@ -33,8 +22,7 @@ use tracing_core::Level; pub use stdio::StdioStream; pub use store::{InMemoryRunLogStore, LogPage, RunLogStore, RunMeta}; -/// Identity of one module run. Minted at every instantiation; a restart -/// increments `seq`, so each run is a distinct retention key. +/// Identity of one module run; a restart increments `seq`, keying retention. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RunId { /// Module namespace this run belongs to. @@ -46,8 +34,7 @@ pub struct RunId { } impl RunId { - /// Mint a run for `module` at sequence `seq`, stamping the current - /// wall-clock instant. + /// Mint a run for `module` at sequence `seq`. pub fn new(module: impl Into>, seq: u64) -> Self { Self { module: module.into(), @@ -57,8 +44,8 @@ impl RunId { } } -/// Which capture point produced a record. The snake_case name is the -/// `source` field on the host tracing event. +/// Which capture point produced a record; the snake_case name is the tracing +/// `source` field. #[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -89,7 +76,7 @@ pub struct LogRecord { } impl LogRecord { - /// Build a record stamped at the current wall-clock instant. + /// Record stamped at the current instant. pub fn now(run: RunId, source: LogSource, level: Level, message: String) -> Self { Self { run, @@ -106,19 +93,16 @@ impl LogRecord { } } -/// Fixed per-record charge on top of the message bytes, approximating -/// the host memory a retained record occupies (run key, timestamps, -/// `String` header, ring slot). Without it a flood of empty messages -/// would grow the ring far past the `[limits.logs]` byte budget. +/// Fixed per-record charge added to message bytes so empty messages still +/// count against the `[limits.logs]` byte budget. const RECORD_OVERHEAD: usize = 128; /// Fans every captured record to a host `tracing` event and the /// retention store. pub struct LogRouter { store: Arc, - /// Woken after each append so a consumer can await new output instead - /// of polling. `notify_waiters` wakes only armed waiters, so a reader - /// must arm before it reads (see [`LogPipeline::appended`]). + /// Woken after each append; a reader must arm before reading (see + /// [`LogPipeline::appended`]). appended: Arc, } @@ -143,11 +127,7 @@ impl LogRouter { } } -/// Emit one record as a host tracing event at its own level, carrying -/// the module, run sequence, and source. `tracing`'s macros require a -/// static level per call site, and `Level` is a set of associated -/// consts rather than a matchable enum, so dispatch through an equality -/// ladder over the five tiers. +/// Emit one record as a host tracing event at its own level. fn emit_tracing(record: &LogRecord) { let module = &*record.run.module; let run = record.run.seq; @@ -166,9 +146,7 @@ fn emit_tracing(record: &LogRecord) { } } -/// Shared log pipeline threaded into every module store. Cheap to clone -/// (one `Arc`); the write side is [`router`](Self::router) and the read -/// side is [`list_runs`](Self::list_runs) / [`read`](Self::read). +/// Shared log pipeline threaded into every module store; cheap to clone. #[derive(Clone)] pub struct LogPipeline { router: Arc, @@ -182,8 +160,8 @@ impl LogPipeline { } } - /// Pipeline over the default byte-bounded in-memory backend, sized by - /// the resolved `[limits.logs]` knobs. + /// Pipeline over the byte-bounded in-memory backend, sized by + /// `[limits.logs]`. pub fn in_memory(limits: crate::engine_config::LogRetentionLimits) -> Self { Self::new(Arc::new(InMemoryRunLogStore::new(limits))) } @@ -193,9 +171,8 @@ impl LogPipeline { self.router.clone() } - /// A notify woken after each append, for awaiting new output without - /// polling. Arm a `notified()` future (and `enable()` it) before reading - /// so an append between the read and the await is not lost. + /// Notify woken after each append; arm a `notified()` future before + /// reading so an append is not lost. pub fn appended(&self) -> Arc { self.router.appended.clone() } @@ -217,8 +194,7 @@ mod tests { use super::*; - /// Store that both counts appends and forwards to an inner ring, so - /// the fan-out test can prove the retention consumer saw the record. + /// Store that records appends so the fan-out test can inspect them. struct CountingStore { appended: Mutex>, } diff --git a/crates/nexum-runtime/src/host/logs/stdio.rs b/crates/nexum-runtime/src/host/logs/stdio.rs index 251e6753..9e91a9e8 100644 --- a/crates/nexum-runtime/src/host/logs/stdio.rs +++ b/crates/nexum-runtime/src/host/logs/stdio.rs @@ -1,7 +1,6 @@ -//! Per-store stdout/stderr capture: a [`StdoutStream`] that line-buffers -//! the guest's byte stream and routes each complete line as a -//! [`LogRecord`]. Installed in place of `inherit_stdio`, so guest output -//! is tagged with its run and source rather than merged onto host stdio. +//! Per-store stdout/stderr capture: a [`StdoutStream`] line-buffering guest +//! output and routing each line as a [`LogRecord`] tagged with its run and +//! source. use std::io; use std::pin::Pin; @@ -15,15 +14,12 @@ use tracing_core::Level; use super::{LogRecord, LogRouter, LogSource, RunId}; -/// Upper bound on an in-flight line held without a newline. A guest that -/// floods a stream without ever terminating a line cannot grow host -/// memory without limit: the buffer is force-flushed as one record once -/// it crosses this size. +/// Cap on an unterminated in-flight line; crossing it force-flushes the +/// buffer as one record. const MAX_LINE_BYTES: usize = 1 << 20; -/// Per-store stdout or stderr sink handed to `WasiCtxBuilder`. Each call -/// to [`StdoutStream::async_stream`] yields a fresh line-splitting writer -/// bound to the same run and source. +/// Per-store stdout or stderr sink; each [`StdoutStream::async_stream`] yields +/// a line-splitting writer bound to the run and source. pub struct StdioStream { router: Arc, run: RunId, @@ -58,10 +54,8 @@ impl StdoutStream for StdioStream { } } -/// Line-splitting writer: buffers raw bytes and emits one record per -/// newline. Cutting only at `\n` (never a UTF-8 continuation byte) means -/// a multi-byte code point split across writes is always reassembled in -/// the buffer before the line is decoded. +/// Line-splitting writer: one record per newline. Cutting only at `\n` +/// reassembles multi-byte code points split across writes. struct LineWriter { router: Arc, run: RunId, @@ -88,8 +82,8 @@ impl LineWriter { } } - /// Emit any buffered partial line. Idempotent: the buffer is taken, - /// so a shutdown flush and the drop guard never double-emit. + /// Emit any buffered partial line; idempotent, so shutdown and drop never + /// double-emit. fn flush_remainder(&mut self) { if self.buf.is_empty() { return; @@ -99,8 +93,7 @@ impl LineWriter { } } -/// Level a captured line carries: stdout is informational, stderr is a -/// warning. Documented alongside the `[limits.logs]` knobs. +/// Level for a captured line: stdout INFO, stderr WARN. fn level_for(source: LogSource) -> Level { match source { LogSource::Stderr => Level::WARN, @@ -108,8 +101,7 @@ fn level_for(source: LogSource) -> Level { } } -/// Decode one line's bytes and route it, dropping a trailing `\r` (so -/// CRLF output is clean) and skipping empties. +/// Decode and route one line, dropping a trailing `\r` and skipping empties. fn route_line(router: &LogRouter, run: &RunId, source: LogSource, bytes: &[u8]) { let bytes = bytes.strip_suffix(b"\r").unwrap_or(bytes); if bytes.is_empty() { @@ -163,8 +155,7 @@ mod tests { use super::*; use crate::host::logs::{LogPipeline, LogRecord, LogSource, RunId, RunLogStore}; - /// Capturing store that records every appended message so a test can - /// assert the exact line boundaries the writer produced. + /// Store recording every appended message for assertions. #[derive(Default)] struct CaptureStore { records: Mutex>, diff --git a/crates/nexum-runtime/src/host/logs/store.rs b/crates/nexum-runtime/src/host/logs/store.rs index e953d01e..a17c72e4 100644 --- a/crates/nexum-runtime/src/host/logs/store.rs +++ b/crates/nexum-runtime/src/host/logs/store.rs @@ -1,6 +1,5 @@ -//! Retention store for captured log records: the trait the pipeline -//! writes through and reads back, plus the default byte-bounded -//! in-memory backend (one ring per run, a retained-runs cap per module). +//! Retention store for captured log records: the [`RunLogStore`] trait and +//! the default byte-bounded in-memory backend. use std::collections::HashMap; use std::collections::VecDeque; @@ -14,12 +13,10 @@ use crate::engine_config::LogRetentionLimits; /// A page of a run's retained records plus the cursor to resume from. #[derive(Debug, Clone, Default)] pub struct LogPage { - /// Records with sequence at or after the requested cursor, oldest - /// first. May be shorter than the caller expects when older records - /// have been evicted from the ring. + /// Records at or after the cursor, oldest first; may be short after + /// eviction. pub records: Vec, - /// Cursor to pass on the next [`RunLogStore::read`] to continue after - /// the last returned record. + /// Cursor for the next [`RunLogStore::read`]. pub next_cursor: u64, } @@ -40,17 +37,15 @@ pub struct RunMeta { pub last_ts: Option, } -/// Retention backend the [`LogRouter`](super::LogRouter) appends to and -/// the embedding surface reads from. Appends are best-effort and -/// infallible: a full ring evicts rather than erroring. +/// Retention backend for captured records; appends are infallible, a full +/// ring evicts. pub trait RunLogStore: Send + Sync { /// Retain one record, registering its run on first sighting. fn append(&self, record: LogRecord); /// Runs recorded for `module`, oldest retained first. fn list_runs(&self, module: &str) -> Vec; - /// Page a run's retained records from `cursor` (0 for the start). - /// An unknown or evicted run yields an empty page with - /// `next_cursor` 0, silently resetting a poller to the start. + /// Page a run's records from `cursor`; an unknown or evicted run yields an + /// empty page with `next_cursor` 0. fn read(&self, run: &RunId, cursor: u64) -> LogPage; } diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index a2842435..a6c06933 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -1,31 +1,11 @@ -//! Host-side backends for the `nexum:host` interfaces, plus the -//! per-module `HostState` and the WIT `Host` trait impls. +//! Host-side backends for the `nexum:host` interfaces, plus the per-module +//! [`state::HostState`] and the WIT `Host` trait impls. //! -//! Layout: -//! - [`state`]: the `HostState` struct + `WasiView` impl, the receiver -//! every WIT `Host` trait is implemented for. `HostState` is generic -//! over the `RuntimeTypes` lattice; the composition root supplies the -//! concrete assembly. -//! - [`error`]: From conversions that project backend errors into the -//! WIT `chain-error` / `Fault` shapes, plus the `Fault` label and -//! message projections the supervisor records. -//! - [`provider_pool`], [`local_store_redb`]: capability backends. Pure -//! code with no bindgen types, so each can be unit-tested without -//! spinning up a wasmtime store. -//! - `impls` (private): the bindgen-side trait impls, one file per core -//! WIT interface, that dispatch to the backends above. -//! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook, capability -//! namespace, service, provider kind, event sources) an extension is -//! wired in through at the composition root. Domain extensions live in -//! their own crates and plug in through this seam rather than being -//! hard-linked into the core host. -//! - [`actor`]: the supervised host-actor primitive provider instances -//! run behind (refuel, trap projection, serialising slot). -//! - [`http`]: the wasi:http outgoing gate enforcing the per-module -//! `[capabilities.http].allow` list. -//! - [`logs`]: the typed module-log pipeline (capture points -> router -> -//! tracing event + retention store) and its embedder read surface. +//! [`provider_pool`] and [`local_store_redb`] are the capability backends; +//! [`component`] is the backend-trait seam; [`extension`] wires in domain +//! extensions; [`actor`] supervises provider instances; [`http`] gates +//! outgoing wasi:http; [`logs`] is the module-log pipeline; [`error`] projects +//! backend errors into the WIT `chain-error` / `Fault` shapes. pub mod actor; pub mod component; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index b30c7a40..0540c6af 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -1,16 +1,10 @@ -//! `nexum:host/chain` backend. +//! `nexum:host/chain` backend: per-chain provider opened from the engine +//! config at boot. //! -//! Per-chain alloy provider, opened from the engine config at boot. -//! `request` is a raw JSON-RPC dispatch: the host hands `(method, -//! params)` straight to alloy's transport and returns the result body -//! verbatim. The method is a typed [`ChainMethod`], so only the -//! permitted read surface can reach the transport; params are passed -//! through without re-encoding. -//! -//! Transports: -//! - `ws://` / `wss://` - `WsConnect`; block following pushes `newHeads`. -//! - `http://` / `https://` - alloy's HTTP transport; block following polls -//! `eth_getBlockByNumber`, mirroring the `eth_getLogs` log poller. +//! `request` is a raw JSON-RPC dispatch over a typed [`ChainMethod`], so only +//! the permitted read surface reaches the transport; params pass through +//! unencoded and the result body returns verbatim. WS/WSS push `newHeads`; +//! HTTP polls `eth_getBlockByNumber`. use std::borrow::Cow; use std::collections::HashMap; @@ -34,26 +28,19 @@ use tracing::info; use crate::engine_config::EngineConfig; use crate::host::component::ChainMethod; -/// Fallback head re-poll cadence for chains alloy has no block-time hint -/// for (custom / dev nets). Known chains derive the interval from -/// [`Chain::average_blocktime_hint`] so the block and log pollers track the -/// chain's block time rather than a one-size-fits-all constant: polling much -/// faster than the block time just burns RPC calls on empty ranges, polling -/// much slower adds latency. +/// Head re-poll cadence for chains without a block-time hint; known chains +/// derive it from [`Chain::average_blocktime_hint`]. const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2); -/// Transport retry-layer parameters. `watch_canonical_logs_from` surfaces -/// RPC errors to the caller and ends the stream on the first one unless -/// the transport retries it. This layer heals transient blips below the -/// poller, so a momentary node hiccup does not force a re-open, which is -/// exactly where a gap could reappear. +/// Transport retry-layer parameters; heal transient RPC blips below the +/// poller so a node hiccup does not force a re-open. const RPC_MAX_RETRIES: u32 = 10; const RPC_RETRY_BACKOFF_MS: u64 = 300; -/// Compute-units-per-second budget the retry layer paces rate-limited -/// nodes against; generous because this pool is read-only and low-QPS. +/// Compute-units-per-second budget for rate-limited nodes; generous, this +/// pool is read-only and low-QPS. const RPC_RETRY_CUPS: u64 = 100; -/// The transport retry layer applied to every provider in the pool. +/// Transport retry layer applied to every provider in the pool. fn retry_layer() -> RetryBackoffLayer { RetryBackoffLayer::new(RPC_MAX_RETRIES, RPC_RETRY_BACKOFF_MS, RPC_RETRY_CUPS) } @@ -63,26 +50,21 @@ fn retry_layer() -> RetryBackoffLayer { struct ChainEndpoint { provider: DynProvider, timeout: Duration, - /// WS/IPC transport: `subscribe_blocks` pushes `newHeads`. HTTP has no - /// pubsub, so block following polls `eth_getBlockByNumber` instead. + /// WS/IPC drives block following by pubsub; HTTP polls. supports_pubsub: bool, } -/// Pool of alloy providers keyed by chain. +/// Providers keyed by chain. #[derive(Debug, Clone)] pub struct ProviderPool { providers: Arc>, - /// In-flight `eth_getLogs` request groups the canonical log poller - /// runs while backfilling a gap. Paces catch-up throughput against - /// node load; `0` is clamped to `1` by alloy. + /// In-flight `eth_getLogs` groups during gap backfill; `0` clamps to `1`. log_backfill_concurrency: usize, } impl ProviderPool { - /// Open one provider per chain in `cfg.chains`. WebSocket URLs - /// engage alloy's pubsub transport; HTTP URLs use the HTTP - /// transport. Connection failures propagate to the caller; the - /// engine treats them as fatal at boot. + /// Open one provider per chain in `cfg.chains`; connection failures + /// propagate and are fatal at boot. pub async fn from_config(cfg: &EngineConfig) -> Result { let mut providers: HashMap = HashMap::new(); // Sort by numeric id so the boot logs are deterministic @@ -139,8 +121,7 @@ impl ProviderPool { }) } - /// Empty pool - used by tests. Every `request` call returns - /// `UnknownChain`. + /// Empty pool; every `request` returns `UnknownChain`. #[cfg(test)] pub fn empty() -> Self { Self { @@ -149,9 +130,8 @@ impl ProviderPool { } } - /// Follow new canonical block headers on `chain`. WS pushes them via - /// `eth_subscribe(newHeads)`; HTTP polls `eth_getBlockByNumber` at the - /// chain's block time, yielding the same [`BlockStream`] either way. + /// Follow canonical block headers on `chain`: WS via + /// `eth_subscribe(newHeads)`, HTTP by polling at the chain's block time. pub async fn subscribe_blocks(&self, chain: Chain) -> Result { let ep = self .providers @@ -208,9 +188,7 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Current head block number (`eth_blockNumber`). Used as the - /// canonical log poller's `start_block` so a fresh subscription - /// begins at the tip instead of replaying history. + /// Current head block number (`eth_blockNumber`). pub async fn block_number(&self, chain: Chain) -> Result { let ep = self .providers @@ -227,14 +205,9 @@ impl ProviderPool { }) } - /// Open a canonical (reorg-aware) log stream on `chain` from - /// `start_block`. Backed by alloy's `eth_getLogs` block-range poller - /// rather than `eth_subscribe(logs)`, so it works over HTTP as well - /// as WS and recovers events by re-querying the gap rather than - /// silently dropping them across a reconnect. Each yielded item is - /// one canonical block's matching logs (a possibly-empty batch); - /// reorg rollbacks surface as a batch whose logs carry - /// `removed == true`. + /// Canonical (reorg-aware) log stream on `chain` from `start_block`. Each + /// item is one block's matching logs (possibly empty); reorg rollbacks + /// carry `removed == true`. pub fn watch_chain_logs( &self, chain: Chain, @@ -284,10 +257,7 @@ impl ProviderPool { Ok(Box::pin(stream)) } - /// Raw JSON-RPC dispatch. `method` is a permitted read-surface - /// method; `params_json` must be the JSON encoding of the params - /// array (e.g. `"[\"0x...\",\"latest\"]"`), as produced by the - /// SDK's `chain::request` glue. + /// Raw JSON-RPC dispatch; `params_json` is the JSON-encoded params array. pub async fn request( &self, chain: Chain, @@ -352,16 +322,12 @@ impl ProviderPool { /// Boxed stream of `newHeads`-style block headers. pub type BlockStream = Pin> + Send>>; -/// Boxed stream of canonical per-block log batches from -/// [`ProviderPool::watch_chain_logs`]. Each item is one canonical -/// block's matching logs; reorg rollbacks carry `removed == true`. +/// Boxed canonical per-block log stream; reorg rollbacks carry +/// `removed == true`. pub type CanonicalLogStream = Pin, ProviderError>> + Send>>; -/// Errors surfaced by [`ProviderPool`]. -/// -/// `IntoStaticStr` produces the snake_case variant name as -/// `&'static str` for metric labels and structured-log fields; the -/// per-variant Display still carries the detail via `thiserror`. +/// Errors surfaced by [`ProviderPool`]. Variant names serialize snake_case as +/// `&'static str` for metric labels. #[derive(Debug, Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -387,7 +353,7 @@ pub enum ProviderError { #[source] source: url::ParseError, }, - /// The guest-supplied JSON params did not parse. + /// Guest-supplied JSON params did not parse. #[error("invalid params JSON for `{method}`: {source}")] InvalidParams { /// RPC method name. @@ -396,39 +362,27 @@ pub enum ProviderError { #[source] source: serde_json::Error, }, - /// `request_timeout_secs = 0` in the engine config: every call would - /// time out before it even starts. Rejected at boot. + /// `request_timeout_secs = 0`; rejected at boot. #[error("chain {chain}: request_timeout_secs must not be 0")] ZeroTimeout { /// Chain with the misconfigured timeout. chain: Chain, }, - /// The RPC node did not respond within the configured per-request - /// timeout. Surfaces to the guest as a `timeout` fault; the module - /// decides whether to retry. + /// RPC node did not respond within the per-request timeout. #[error("rpc `{method}` timed out")] Timeout { /// RPC method name. method: String, }, - /// The node returned an error for the dispatched call. - /// - /// When the underlying alloy `RpcError` carries a JSON-RPC - /// `ErrorResp` payload (the normal shape for `eth_call` reverts) - /// the structured `code` and `data` fields are propagated; for - /// transport-side failures both are `None`. + /// Node returned an error for the dispatched call. JSON-RPC `ErrorResp` + /// payloads propagate `code`/`data`; transport failures leave both `None`. #[error("rpc `{method}` failed: {source}")] Rpc { /// RPC method name. method: String, - /// JSON-RPC error code from `ErrorResp.code`. `None` when - /// the failure was transport-level (no structured response). + /// `ErrorResp.code`, `None` for transport-level failures. code: Option, - /// Decoded `ErrorResp.data` payload - for `eth_call` reverts - /// this is the abi-encoded revert body, hex-decoded from the - /// upstream JSON string once here (consumed directly by - /// an SDK revert decoder). `None` when the failure - /// was transport-level or the payload was not a hex string. + /// Decoded `ErrorResp.data` (abi-encoded revert body), else `None`. data: Option>, /// Transport-side typed error. #[source] diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 2023baf4..b6128f6e 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -1,8 +1,5 @@ -//! Per-instance host state and its WASI view. -//! -//! One [`HostState`] is created per module, lives inside the wasmtime -//! `Store`, and is the receiver every `Host` trait impl in -//! `super::impls` is implemented for. +//! Per-module host state, held in the wasmtime `Store` and the receiver for +//! every `Host` impl in `super::impls`. use std::sync::Arc; @@ -15,9 +12,7 @@ use super::extension::HostServices; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; -/// Per-module host state, generic over the [`RuntimeTypes`] lattice -/// binding the backend seams. The composition root supplies the -/// concrete assembly. +/// Per-module host state, generic over the [`RuntimeTypes`] lattice. pub struct HostState { pub wasi: WasiCtx, pub table: ResourceTable, @@ -28,32 +23,24 @@ 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 - /// provider carries its `[[adapters]].messaging_topics` grant here, - /// so an out-of-scope publish is refused before it reaches the - /// backend. + /// Content topics this store may publish to; empty is unscoped. An + /// out-of-scope publish is refused before 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. + /// Identity of this store's run; tags every captured log record. pub run: RunId, /// Shared log pipeline the `nexum:host/logging` glue routes through. pub log_router: Arc, - /// Extension backends (the lattice `Ext` payload). Reached generically - /// by an extension's `Host` impl through [`ExtState`]. + /// Extension backends (the lattice `Ext` payload), reached via + /// [`ExtState`]. pub ext: T::Ext, - /// `chain` backend - per-chain alloy `DynProvider` pool. + /// `chain` backend: per-chain provider pool. pub chain: T::Chain, - /// Host-enforced cap on a single chain JSON-RPC response body. - /// Responses larger than this are rejected before they reach the guest. + /// Cap on a chain JSON-RPC response body; larger responses are rejected. pub chain_response_max_bytes: usize, - /// `local-store` backend - per-module handle with pre-computed - /// keccak256 namespace prefix. + /// `local-store` backend: per-module handle with keccak256 prefix. pub store: Handle, - /// Extension-owned host services, keyed by extension namespace and - /// downcast at the call site. One shared map across every module store; - /// a provider store carries an empty map. + /// Extension-owned host services, keyed by namespace; a provider store + /// carries an empty map. pub services: HostServices, } @@ -68,13 +55,8 @@ impl WasiView for HostState { } } -/// Generic access to the extension state slot of a host state. -/// -/// An extension crate implements its bindgen-local `Host` trait for the -/// foreign `HostState` (orphan-legal: the trait is local to the -/// extension) and reaches its own payload through this accessor, without -/// naming the concrete lattice `T`. The extension then bounds the payload -/// on its own trait to extract its backend. +/// Generic access to the extension payload of a host state, without naming +/// the concrete lattice `T`. pub trait ExtState { /// The extension payload type (the lattice `Ext` member). type Ext; From c2c955a803bb1263a916ce007e1383fcdc157543 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 07:33:27 +0000 Subject: [PATCH 132/139] docs: tersen rustdoc in sdk-periphery-l1 (#624) --- crates/nexum-launch/src/cli.rs | 6 +- crates/nexum-launch/src/lib.rs | 6 +- crates/nexum-module-macros/src/lib.rs | 69 ++++-------- crates/nexum-sdk/src/address.rs | 64 ++++------- crates/nexum-sdk/src/chain/chainlink.rs | 33 ++---- crates/nexum-sdk/src/chain/eth_call.rs | 47 +------- crates/nexum-sdk/src/chain/provider.rs | 21 +--- crates/nexum-sdk/src/chain/transport.rs | 13 +-- crates/nexum-sdk/src/config.rs | 43 ++------ crates/nexum-sdk/src/events.rs | 19 ++-- crates/nexum-sdk/src/http.rs | 79 +++++--------- crates/nexum-sdk/src/proptests.rs | 38 ++----- crates/nexum-sdk/src/tracing.rs | 40 +++---- crates/nexum-sdk/src/wit_bindgen_macro.rs | 107 ++++++------------ crates/nexum-tasks/src/lib.rs | 6 +- crates/nexum-world/src/lib.rs | 127 +++++++++------------- crates/no-std-probe/src/lib.rs | 11 +- crates/shepherd/src/main.rs | 4 +- 18 files changed, 223 insertions(+), 510 deletions(-) diff --git a/crates/nexum-launch/src/cli.rs b/crates/nexum-launch/src/cli.rs index b9af9baa..c2de94b0 100644 --- a/crates/nexum-launch/src/cli.rs +++ b/crates/nexum-launch/src/cli.rs @@ -40,10 +40,8 @@ pub struct Cli { #[arg(long = "pretty-logs")] pub pretty_logs: bool, - /// Override the chain-log poller's per-block `eth_getLogs` - /// concurrency during backfill. Higher catches up faster at more - /// node load. Overrides `[engine] log_backfill_concurrency` when - /// set. + /// Override `[engine] log_backfill_concurrency`, the chain-log + /// poller's per-block `eth_getLogs` concurrency during backfill. #[arg(long = "log-backfill-concurrency")] pub log_backfill_concurrency: Option, } diff --git a/crates/nexum-launch/src/lib.rs b/crates/nexum-launch/src/lib.rs index 6994ca29..0e472374 100644 --- a/crates/nexum-launch/src/lib.rs +++ b/crates/nexum-launch/src/lib.rs @@ -42,9 +42,9 @@ pub async fn launch(name: &str, preset: R, cli: Cli) -> anyhow::Resu .await } -/// Install the global tracing subscriber: JSON by default (machine-readable -/// for production), the human-readable formatter behind `--pretty-logs`. -/// The same [`EnvFilter`] applies to both, so `RUST_LOG` works identically. +/// Install the global tracing subscriber: JSON by default, the +/// human-readable formatter behind `--pretty-logs`. The same +/// [`EnvFilter`] (`RUST_LOG`, else the config level) applies to both. fn init_tracing(pretty: bool, engine_cfg: &EngineConfig) { let env_filter = EnvFilter::try_from_default_env() .or_else(|_| EnvFilter::try_new(&engine_cfg.engine.log_level)) diff --git a/crates/nexum-module-macros/src/lib.rs b/crates/nexum-module-macros/src/lib.rs index 9aa28bb0..d021d6bd 100644 --- a/crates/nexum-module-macros/src/lib.rs +++ b/crates/nexum-module-macros/src/lib.rs @@ -1,27 +1,16 @@ //! 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 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!`. -//! -//! The venue-side macros (`#[venue]`, `derive(IntentBody)`) live in -//! `videre-macros`. -//! -//! Consumers reach this through the SDK re-export (`nexum_sdk::module`) -//! rather than depending on this crate directly. +//! per-cdylib module. Reach it through `nexum_sdk::module`, not this +//! crate directly; the venue-side macros live in `videre-macros`. use proc_macro::TokenStream; use quote::quote; use syn::{ImplItem, ItemImpl}; -/// The handler names recognised on a `#[module]` impl. Any method not in -/// 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. +/// The handler names recognised on a `#[module]` impl. An `on_`-prefixed +/// method outside this set is a compile error; an absent handler +/// dispatches as a no-op. const HANDLERS: [&str; 6] = [ "init", "on_block", @@ -35,34 +24,20 @@ const HANDLERS: [&str; 6] = [ /// /// Apply to an `impl` block whose associated functions are the event /// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, -/// `on_message`, `on_custom`). 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 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. +/// `on_message`, `on_custom`); each takes its event's wit-bindgen +/// payload and returns `Result<(), Fault>`, and `init` takes the config +/// table. Undefined handlers dispatch as no-ops. Emits +/// `wit_bindgen::generate!`, the host adapter, the `Guest` impl, and +/// `export!` around the untouched impl. /// -/// 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 resolve against the crate's own -/// `wit/` and `wit/deps/`, then the nearest ancestor carrying the -/// package. Two corollaries: the consuming crate must -/// declare `wit-bindgen` as a direct dependency (the emitted -/// `wit_bindgen::generate!` call resolves against the consumer's -/// namespace), and the crate root must not shadow std prelude names -/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` +/// The world is per module: the macro reads the crate's `module.toml` +/// and synthesizes a world importing exactly the +/// `[capabilities].required` and `optional` declarations, so the +/// load-time capability check passes by construction. An undeclared +/// capability's bindings do not exist. Requirements: the manifest sits +/// at the crate root with a `[capabilities]` section; the crate depends +/// on `wit-bindgen` directly; and the crate root must not shadow the +/// std prelude names `Result`, `Vec`, or `Ok` (the generated `Guest` /// trait refers to them unqualified). #[proc_macro_attribute] pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { @@ -242,11 +217,9 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .into() } -/// Read the consuming crate's `module.toml` and synthesize the -/// per-module world from its `[capabilities]` declarations plus the -/// extension rows registered in the nearest ancestor `extensions.toml`. -/// Returns the rebuild anchor paths (the manifest, then the registry -/// when one exists) alongside the world. +/// Synthesize the per-module world from the crate's `module.toml` +/// `[capabilities]` plus the nearest ancestor `extensions.toml`. +/// Returns the rebuild anchor paths alongside the world. fn derive_module_world() -> Result<(Vec, nexum_world::ModuleWorld), String> { let crate_dir = nexum_world::manifest_dir()?; let manifest_path = crate_dir.join("module.toml"); diff --git a/crates/nexum-sdk/src/address.rs b/crates/nexum-sdk/src/address.rs index e1c8cb3a..2e8e8e42 100644 --- a/crates/nexum-sdk/src/address.rs +++ b/crates/nexum-sdk/src/address.rs @@ -1,63 +1,38 @@ //! EVM address parsing helpers. //! -//! Multiple Shepherd modules need to read a `[config]` value such as -//! `addresses = "0xabc..., 0xdef..."` and surface a typed error when -//! one of the entries is malformed, and native tooling parses single -//! `0x...` strings out of JSON. Each module -//! previously rolled its own `AddressListParseError` / -//! `AddressParseError`. The shapes were near-identical; the audit -//! pass consolidates them here so future modules pick up the same -//! `Display` wording (operator-facing log strings stay stable) and -//! the same `#[non_exhaustive]` evolution guarantee. -//! -//! The list parser stays deliberately permissive about whitespace + -//! empty trailing segments to match the wording operators have grown -//! used to (a literal trailing comma in `engine.toml` should not -//! error). +//! Parses `[config]` values such as `addresses = "0xabc..., 0xdef..."` +//! and single `0x...` strings into typed [`Address`] values. The list +//! parser is permissive about whitespace and empty segments, so a +//! trailing comma is not an error. use alloy_primitives::Address; -/// Typed errors returned by [`parse_address_list`] and -/// [`parse_address`]. Replaces the `Result<_, String>` and -/// per-module `AddressListParseError` / `AddressParseError` shapes -/// that previously lived in each strategy crate (rubric prohibits -/// stringly-typed errors). -/// -/// The Display impls preserve the exact wording the previous -/// formatters produced so any operator-facing log strings remain -/// stable across the JC5 consolidation. +/// Typed errors from [`parse_address_list`] and [`parse_address`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum AddressParse { - /// One of the comma-separated entries failed to parse as an - /// EVM address, or a single-address input failed to parse. For - /// the single-address case the `index` is always `0`. + /// An entry failed to parse as an EVM address; `index` is `0` for a + /// single-address parse. #[error("address #{index} ({raw:?}): {message}")] InvalidAddress { - /// Zero-based position of the offending entry in the - /// comma-separated list (`0` for single-address parses). + /// Zero-based position in the list (counts skipped empties); + /// `0` for single-address parses. index: usize, /// The trimmed source string that failed to parse. raw: String, - /// Human-readable parse-error detail from - /// `
::Err`. + /// Parse-error detail. message: String, }, - /// The whole list was empty (or contained only whitespace + - /// empty segments). Only emitted by [`parse_address_list`]. + /// The list held no non-whitespace segment. Only from + /// [`parse_address_list`]. #[error("expected at least one address")] Empty, } -/// Parse a comma-separated address list, stripping whitespace and -/// skipping empty segments (so a trailing `,` is not an error). -/// -/// Returns [`AddressParse::Empty`] if the input contains no -/// non-whitespace segment and [`AddressParse::InvalidAddress`] on -/// the first entry that does not parse as an EVM address. The -/// `index` reflects the zero-based position in the original -/// comma-separated list (i.e. it counts skipped empties), which -/// matches the wording the per-module errors used to surface. +/// Parse a comma-separated address list, trimming whitespace and +/// skipping empty segments. [`AddressParse::Empty`] on no segment, +/// [`AddressParse::InvalidAddress`] on the first bad entry (`index` +/// counts skipped empties). pub fn parse_address_list(raw: &str) -> Result, AddressParse> { let mut out = Vec::new(); for (i, part) in raw.split(',').enumerate() { @@ -80,10 +55,9 @@ pub fn parse_address_list(raw: &str) -> Result, AddressParse> { Ok(out) } -/// Parse a single `0x...` (or bare-hex) address string into a -/// typed [`Address`]. Trims surrounding whitespace before -/// delegating to `
`; failures surface as -/// [`AddressParse::InvalidAddress`] with `index = 0`. +/// Parse a single `0x...` (or bare-hex) address string, trimming +/// whitespace. Failures surface as [`AddressParse::InvalidAddress`] +/// with `index = 0`. pub fn parse_address(raw: &str) -> Result { let trimmed = raw.trim(); trimmed diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/crates/nexum-sdk/src/chain/chainlink.rs index 74e04051..9ee53a34 100644 --- a/crates/nexum-sdk/src/chain/chainlink.rs +++ b/crates/nexum-sdk/src/chain/chainlink.rs @@ -1,17 +1,8 @@ //! Chainlink Aggregator V3 reader. //! -//! [`read_latest_answer`] performs the full `eth_call → decode → -//! latestRoundData.answer` flow against a Chainlink AggregatorV3 -//! oracle. Returns `Some(answer)` on success or `None` on any host / -//! decode failure (logging the failure at Warn). Used by oracle-driven -//! example modules (price-alert) so they consume the SDK -//! instead of redefining the `AggregatorV3` ABI + read loop locally. -//! -//! The shape is deliberately `Option` rather than -//! `Result`: every observed caller treats a fetch -//! failure as "skip this block, try next one", and `Option` makes -//! that the only path without forcing a discard pattern at the call -//! site. +//! [`read_latest_answer`] runs `eth_call` against a Chainlink +//! AggregatorV3 oracle and decodes `latestRoundData.answer`, returning +//! `None` (logging at Warn) on any host or decode failure. use alloy_primitives::{Address, I256}; use alloy_sol_types::{SolCall, sol}; @@ -35,16 +26,8 @@ sol! { } /// Fetch the oracle's latest answer via `eth_call(latestRoundData)`. -/// -/// Returns `Some(answer)` on success. Logs a Warn (prefixed with -/// `domain`) and returns `None` on any of: -/// -/// - `host.request("eth_call", …)` returning `Err(ChainError)`; -/// - the JSON-RPC result not parsing as `0x`-prefixed hex bytes; -/// - the ABI decode failing. -/// -/// `domain` is embedded in the log line so a single host log stream -/// can disambiguate which module's oracle failed. +/// `None`, with a Warn line prefixed by `domain`, on an `Err` +/// response, a result that is not `0x`-hex, or an ABI decode failure. // 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. @@ -91,10 +74,8 @@ pub fn read_latest_answer( #[cfg(test)] mod tests { - //! `MockHost`-driven coverage of the read path. Encodes a synthetic - //! `latestRoundData` return into the `"0x..."` JSON the - //! `chain::request` mock returns, then asserts the helper - //! extracts the `answer` field. + //! Coverage of the read path over a stub host returning a synthetic + //! `latestRoundData` result. use super::*; use crate::host::{ChainError, Fault}; diff --git a/crates/nexum-sdk/src/chain/eth_call.rs b/crates/nexum-sdk/src/chain/eth_call.rs index 36dd41c1..51b7e1e8 100644 --- a/crates/nexum-sdk/src/chain/eth_call.rs +++ b/crates/nexum-sdk/src/chain/eth_call.rs @@ -2,28 +2,8 @@ use alloy_primitives::Address; -/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`. -/// -/// Returned as a `String` rather than `serde_json::Value` so the caller -/// can hand it straight to `chain::request(chain_id, "eth_call", &p)` -/// without re-serialising. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::eth_call_params; -/// use nexum_sdk::prelude::Address; -/// -/// let to: Address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" -/// .parse() -/// .unwrap(); -/// let selector = [0xaa, 0xbb, 0xcc, 0xdd]; // 4-byte function selector -/// let params = eth_call_params(&to, &selector); -/// -/// assert!(params.contains("\"to\":\"0xfdafc9d1902f4e0b84f65f49f244b32b31013b74\"")); -/// assert!(params.contains("\"data\":\"0xaabbccdd\"")); -/// assert!(params.contains("\"latest\"")); -/// ``` +/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`, +/// ready to pass to `chain::request` without re-serialising. pub fn eth_call_params(to: &Address, data: &[u8]) -> String { // Both fields are hex, which never needs JSON escaping, so the // array is written directly instead of via a serde_json DOM. @@ -31,27 +11,8 @@ pub fn eth_call_params(to: &Address, data: &[u8]) -> String { format!(r#"[{{"to":"{to:#x}","data":"{data_hex}"}},"latest"]"#) } -/// Parse the raw JSON-RPC `result` field a host's `chain::request` -/// returns for an `eth_call`. The value is a JSON string holding hex -/// like `"0x1234..."`; strip the JSON quotes, strip the `0x` prefix, -/// and hex-decode. Returns `None` on shape mismatch. -/// -/// # Example -/// -/// ``` -/// use nexum_sdk::chain::parse_eth_call_result; -/// -/// // What the host typically returns for an eth_call result: a JSON -/// // string holding 0x-prefixed hex. -/// let raw = r#""0xdeadbeef""#; -/// assert_eq!( -/// parse_eth_call_result(raw), -/// Some(vec![0xde, 0xad, 0xbe, 0xef]), -/// ); -/// -/// // Shape mismatch (not JSON-quoted) -> None. -/// assert_eq!(parse_eth_call_result("not json"), None); -/// ``` +/// Decode the JSON-RPC `result` of an `eth_call`, a JSON string holding +/// `0x`-prefixed hex, to bytes. `None` on shape mismatch. #[must_use] pub fn parse_eth_call_result(result_json: &str) -> Option> { // Borrowed deserialization: valid hex payloads never contain JSON diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs index 426b4fd5..21258381 100644 --- a/crates/nexum-sdk/src/chain/provider.rs +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -15,24 +15,6 @@ use crate::host::ChainHost; /// instead of hand-building JSON-RPC. Blanket-implemented for every /// cloneable [`ChainHost`]; drive the returned futures with /// [`block_on`]. -/// -/// ``` -/// use alloy_provider::Provider; -/// use nexum_sdk::chain::{Chain, ProviderHost, block_on}; -/// use nexum_sdk::host::{ChainError, ChainHost}; -/// -/// #[derive(Clone)] -/// struct StubHost; -/// impl ChainHost for StubHost { -/// fn request(&self, _: u64, _: &str, _: &str) -> Result { -/// Ok("\"0x2a\"".into()) -/// } -/// } -/// -/// let provider = StubHost.provider(Chain::mainnet()); -/// let block = block_on(provider.get_block_number()).unwrap(); -/// assert_eq!(block, 42); -/// ``` pub trait ProviderHost: ChainHost + Clone + Send + Sync + Sized + 'static { /// Provider for `chain`, routed through the host's RPC stack. fn provider(&self, chain: Chain) -> RootProvider { @@ -47,8 +29,7 @@ impl ProviderHost for H {} /// Drive a host-backed provider future to completion. The host /// transport is a synchronous WIT import, so the future resolves on the -/// first poll; a `Pending` means an async alloy layer crept in and the -/// chain SDK must move to a host-driven surface, not a poll loop. +/// first poll; a `Pending` panics. pub fn block_on(future: F) -> F::Output { let mut future = pin!(future.into_future()); let mut cx = Context::from_waker(Waker::noop()); diff --git a/crates/nexum-sdk/src/chain/transport.rs b/crates/nexum-sdk/src/chain/transport.rs index 5ae36dcc..ee2ef28d 100644 --- a/crates/nexum-sdk/src/chain/transport.rs +++ b/crates/nexum-sdk/src/chain/transport.rs @@ -14,15 +14,14 @@ use super::{Chain, ChainMethod}; use crate::host::{ChainError, ChainHost}; /// An alloy `Transport` routing JSON-RPC through the host's chain -/// interface. Dispatch is synchronous: the host blocks the guest until -/// the response is available, so every returned future is ready on its -/// first poll and [`block_on`](super::block_on) drives it for free. +/// interface. Dispatch is synchronous, so every returned future is +/// ready on its first poll and [`block_on`](super::block_on) drives it. /// /// Methods outside the typed [`ChainMethod`] surface never reach the -/// host; they fail as a JSON-RPC `-32601` error response. A structured -/// node error comes back as the error payload (code, message, revert -/// bytes as `0x` hex); a host [`Fault`](crate::host::Fault) surfaces as -/// a custom transport error carrying the typed fault. +/// host and fail as a JSON-RPC `-32601`. A structured node error comes +/// back as the error payload (code, message, revert bytes as `0x` hex); +/// a host [`Fault`](crate::host::Fault) surfaces as a custom transport +/// error carrying the typed fault. #[derive(Clone, Copy, Debug)] pub struct HostTransport { host: H, diff --git a/crates/nexum-sdk/src/config.rs b/crates/nexum-sdk/src/config.rs index f1526293..b1dba85e 100644 --- a/crates/nexum-sdk/src/config.rs +++ b/crates/nexum-sdk/src/config.rs @@ -1,21 +1,12 @@ -//! Helpers for parsing the `Vec<(String, String)>` config entries a -//! module's `on_event` receives. -//! -//! Each entry is a `(key, value)` pair the runtime read from the -//! module's `[config]` table. Modules need three operations -//! repeatedly: required-key lookup, optional-key lookup, and decimal -//! parsing for thresholds / amounts. Hoisting these here keeps the -//! example modules consuming the SDK rather than re-implementing the -//! same loops around it. +//! Helpers over the `Vec<(String, String)>` `[config]` entries a +//! module's `on_event` receives: required and optional key lookup, and +//! fixed-point decimal parsing. use alloy_primitives::{I256, U256}; use thiserror::Error; -/// Why a config lookup or parse failed. -/// -/// Modules wrap this into a [`Fault::InvalidInput`] at the boundary. -/// The SDK type stays host-neutral so the same parser can be -/// unit-tested without `wasm32-wasip2`. +/// Why a config lookup or parse failed. Modules wrap it into a +/// [`Fault::InvalidInput`] at the boundary. /// /// [`Fault::InvalidInput`]: crate::host::Fault::InvalidInput #[derive(Debug, Error)] @@ -45,10 +36,7 @@ pub enum ConfigError { }, } -/// Look up a required `(key, value)` entry in a config table. -/// -/// Returns `Err(MissingKey)` if the key is absent. The returned -/// `&str` borrows from `entries`. +/// Look up a required entry; `Err(MissingKey)` if absent. pub fn get_required<'a>( entries: &'a [(String, String)], key: &str, @@ -62,8 +50,7 @@ pub fn get_required<'a>( }) } -/// Look up an optional `(key, value)` entry. Returns `None` when -/// absent; never errors. +/// Look up an optional entry; `None` when absent. pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&'a str> { entries .iter() @@ -72,18 +59,10 @@ pub fn get_optional<'a>(entries: &'a [(String, String)], key: &str) -> Option<&' } /// Parse a signed fixed-point decimal string into an `I256` scaled by -/// `10**decimals`. -/// -/// - Short fractional parts are right-padded with zeros. -/// - Long fractional parts are truncated. -/// - A leading `-` is honoured. -/// - Empty input is rejected as a parse error. -/// - Non-digit characters (other than the leading sign and a single -/// `.`) are rejected. -/// -/// `key` is the config-table key the value came from; it is embedded -/// in the returned error so the caller can surface a useful message -/// without re-passing context. +/// `10**decimals`. Short fractions are right-padded, long fractions +/// truncated, a leading `-` honoured; empty input and non-digit +/// characters (beyond the sign and one `.`) are rejected. `key` is +/// embedded in the error. pub fn scale_decimal(value: &str, decimals: u32, key: &str) -> Result { let (sign, body) = if let Some(rest) = value.strip_prefix('-') { (-1i32, rest) diff --git a/crates/nexum-sdk/src/events.rs b/crates/nexum-sdk/src/events.rs index fb9cd0bc..4342faa1 100644 --- a/crates/nexum-sdk/src/events.rs +++ b/crates/nexum-sdk/src/events.rs @@ -1,24 +1,19 @@ //! Chain-log delivery at the guest WIT edge. //! //! Modules receive on-chain logs as the native [`Log`] (alloy's -//! `eth_getLogs` shape), not an SDK-invented view. The host packs each log -//! into the WIT `chain-log` record; [`ChainLogParts`] borrows that record's -//! raw fields and `From` rebuilds the alloy value. The per-module bind macro -//! emits the `From` glue that routes through this, so a strategy -//! holds `&[Log]` and decodes `sol!` events against [`Log::inner`]. +//! `eth_getLogs` shape). The host packs each log into the WIT +//! `chain-log` record; [`ChainLogParts`] borrows its raw fields and +//! `From` rebuilds the alloy value. use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, LogData}; /// The alloy RPC log delivered to modules for chain-log events. pub use alloy_rpc_types_eth::Log; -/// Borrowed raw fields of a WIT `chain-log` record, assembled into an alloy -/// [`Log`] via `From`. -/// -/// Fixed-width byte fields are right-aligned into their EVM word (20 bytes for -/// the address, 32 for topics and hashes). The host is the sole runtime and -/// the frames it emits are well-formed by construction, so an out-of-width -/// field is a host bug that traps loudly rather than being silently reshaped. +/// Borrowed raw fields of a WIT `chain-log` record, assembled into an +/// alloy [`Log`] via `From`. Fixed-width byte fields are left-padded +/// into their EVM word (20 bytes for the address, 32 for topics and +/// hashes). #[derive(Default)] pub struct ChainLogParts<'a> { /// 20-byte contract address. diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index cac11bc3..d4f38419 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -1,40 +1,29 @@ //! Outbound HTTP over wasi:http for guest modules. //! //! `fetch` performs one synchronous request through the host's -//! wasi:http outgoing handler. The host admits or denies every request -//! against the module's `[capabilities.http].allow` list before any -//! connection is made; a denial surfaces as [`FetchError::Denied`], so -//! modules can tell policy refusals from transport failures. -//! -//! Requests and responses are the standard [`http`] crate's -//! `Request>` and `Response>`; the SDK owns only what -//! wasi:http adds on top: the allowlist-aware [`FetchError`], the -//! per-phase [`FetchOptions`] timeouts, and the [`Fetch`] seam. -//! -//! [`Fetch`], [`FetchError`], and [`FetchOptions`] compile on every -//! target so strategy logic can be unit-tested host-side against the -//! seam; the `fetch` implementation itself only exists on -//! `wasm32-wasip2`. +//! wasi:http outgoing handler. The host admits or denies each request +//! against `[capabilities.http].allow` before connecting; a denial +//! surfaces as [`FetchError::Denied`], distinct from a transport +//! failure. Requests and responses are the [`http`] crate's +//! `Request>` / `Response>`. The [`Fetch`] seam, +//! [`FetchError`], and [`FetchOptions`] compile on every target for +//! host-side tests; `fetch` itself exists only on `wasm32-wasip2`. use core::time::Duration; use strum::IntoStaticStr; -/// Per-phase timeout applied to each of connect, first byte, and -/// between bytes by [`FetchOptions::default`]. Keeps an event handler -/// from hanging on a stalled upstream. +/// Per-phase timeout [`FetchOptions::default`] applies to connect, +/// first byte, and between bytes. pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); /// Per-phase wasi:http timeouts that have no home on [`http::Request`]. -/// -/// `Default` applies [`DEFAULT_TIMEOUT`] to every phase; plain -/// [`Fetch::fetch`] uses it, [`Fetch::fetch_with`] takes an override. +/// `Default` applies [`DEFAULT_TIMEOUT`] to every phase. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct FetchOptions { /// Time allowed to establish the connection. pub connect_timeout: Duration, - /// Time allowed for the first response byte after the request is - /// sent. + /// Time allowed for the first response byte. pub first_byte_timeout: Duration, /// Time allowed between consecutive response body bytes. pub between_bytes_timeout: Duration, @@ -51,15 +40,12 @@ impl Default for FetchOptions { } /// Why a fetch failed, folded down from the wasi:http error codes. -/// -/// `IntoStaticStr` yields a snake_case label per variant for log and -/// metric fields. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] pub enum FetchError { - /// The host's `[capabilities.http].allow` list refused the request - /// before any connection was made. + /// The `[capabilities.http].allow` list refused the request before + /// any connection was made. #[error("denied by the module's http allowlist")] Denied, /// The request never left the guest: malformed URL, method, or @@ -69,18 +55,16 @@ pub enum FetchError { /// A configured timeout elapsed. #[error("timeout: {0}")] Timeout(String), - /// Connection or protocol failure after the allowlist admitted the - /// request. + /// Connection or protocol failure after the request was admitted. #[error("transport failure: {0}")] Transport(String), } -/// Seam between strategy logic and the wasi:http transport: -/// strategies take `&impl Fetch` and tests slot in a stub; module glue -/// passes [`WasiFetch`]. +/// Seam between strategy logic and the wasi:http transport; module glue +/// passes [`WasiFetch`], tests a stub. pub trait Fetch { - /// Perform one request with `options`, blocking until the response - /// body is fully buffered. + /// Perform one request, blocking until the response body is fully + /// buffered. fn fetch_with( &self, request: http::Request>, @@ -96,7 +80,7 @@ pub trait Fetch { } } -/// A shared reference forwards, so a wrapper can borrow its transport. +/// A shared reference forwards to its referent. impl Fetch for &F { fn fetch_with( &self, @@ -107,11 +91,9 @@ impl Fetch for &F { } } -/// [`Fetch`] adapter over the host's wasi:http outgoing handler. -/// -/// Guest-only glue: the type exists on every target so module -/// `lib.rs` glue compiles host-side for unit tests, but calling -/// [`Fetch::fetch_with`] off the wasm guest is unimplemented. +/// [`Fetch`] adapter over the host's wasi:http outgoing handler. Exists +/// on every target for host-side tests, but [`Fetch::fetch_with`] is +/// unimplemented off the wasm guest. #[derive(Clone, Copy, Debug, Default)] pub struct WasiFetch; @@ -146,10 +128,9 @@ mod wasi_impl { fetch_with(request, FetchOptions::default()) } - /// Perform `request` through the host's wasi:http outgoing - /// handler, blocking the (single-threaded) guest until the - /// response body is fully buffered. The buffered body is bounded - /// only by the module's memory limit. + /// Perform `request` through the host's wasi:http outgoing handler, + /// blocking until the response body is fully buffered. The body is + /// bounded only by the module's memory limit. pub fn fetch_with( request: http::Request>, options: FetchOptions, @@ -172,9 +153,9 @@ mod wasi_impl { Ok(http::Response::from_parts(parts, bytes.to_vec())) } - /// Fold the wasi:http error code carried inside the client error - /// into [`FetchError`]. Codes that do not identify a policy, - /// timeout, or request-shape failure are transport failures. + /// Fold the client error's wasi:http error code into [`FetchError`]; + /// anything not a policy, timeout, or request-shape failure is + /// transport. fn map_error(error: wstd::http::Error) -> FetchError { let Some(code) = error.downcast_ref::() else { return FetchError::Transport(format!("{error:#}")); @@ -219,9 +200,7 @@ mod tests { ); } - /// The default [`Fetch::fetch`] must delegate to `fetch_with` with - /// default options, so a stub can observe both the request and the - /// options it was handed. + /// [`Fetch::fetch`] delegates to `fetch_with` with default options. #[test] fn fetch_delegates_to_fetch_with_default_options() { use core::cell::Cell; diff --git a/crates/nexum-sdk/src/proptests.rs b/crates/nexum-sdk/src/proptests.rs index 2a4709e7..634d4df6 100644 --- a/crates/nexum-sdk/src/proptests.rs +++ b/crates/nexum-sdk/src/proptests.rs @@ -1,18 +1,6 @@ -//! Property-based regression tests for the SDK's codec round-trips -//! and validation functions. Lives behind `#[cfg(test)]` so neither -//! the wasm32-wasip2 builds nor downstream consumers pay the -//! proptest dep cost. -//! -//! Covered here: -//! -//! - `eth_call_params` / `parse_eth_call_result` round-trip. -//! - `config::scale_decimal` decimal scaling round-trip. -//! - `U256` little-endian byte round-trip (mirrored from -//! `balance-tracker`'s persistence path). -//! -//! The CoW-domain properties (`decode_revert`, the -//! `gpv2_to_order_data` marker guard) live in `composable-cow` and -//! `cow-venue`. +//! Property-based round-trip tests for the SDK codecs: `eth_call` +//! params/result, `config::scale_decimal`, and `U256` little-endian +//! bytes. #![cfg(test)] @@ -61,10 +49,8 @@ fn decimal_string() -> impl Strategy { // ---- properties --------------------------------------------------- proptest! { - /// `eth_call_params(to, data)` produces a JSON string that - /// alloy's transport will accept; `parse_eth_call_result` round- - /// trips through any 0x-prefixed hex blob the result field can - /// carry. + /// `eth_call_params` embeds the address; `parse_eth_call_result` + /// round-trips any `0x`-hex body. #[test] fn eth_call_round_trip_hex( addr in any_address(), @@ -84,9 +70,7 @@ proptest! { prop_assert_eq!(parsed, body); } - /// `parse_eth_call_result` returns `None` on a non-quoted or - /// non-hex shape. Catches accidental "string contains 0x" - /// false positives. + /// `parse_eth_call_result` returns `None` on a non-quoted shape. #[test] fn parse_eth_call_result_rejects_unquoted( s in "[a-zA-Z0-9]{0,32}", @@ -95,9 +79,8 @@ proptest! { prop_assert!(parse_eth_call_result(&s).is_none() || s.starts_with('"')); } - /// `config::scale_decimal` round-trips: scaling by 10^d then - /// reversing the integer division reproduces the unsigned - /// portion. The reverse uses I256 to U256 cast guarded by sign. + /// `config::scale_decimal` round-trips: dividing the scaled value + /// by `10^decimals` reproduces the integer part and the sign. #[test] fn scale_decimal_round_trip( (value, decimals) in decimal_string(), @@ -132,10 +115,7 @@ proptest! { } } - /// `U256` round-trips through little-endian 32-byte - /// serialisation. Mirrored from balance-tracker's persistence - /// path; the SDK does not own this function but the property - /// belongs here since the same shape is reused across modules. + /// `U256` round-trips through little-endian 32-byte bytes. #[test] fn u256_le_round_trip(v in any_u256()) { let bytes = v.to_le_bytes::<32>(); diff --git a/crates/nexum-sdk/src/tracing.rs b/crates/nexum-sdk/src/tracing.rs index 81d1bb43..3709f61b 100644 --- a/crates/nexum-sdk/src/tracing.rs +++ b/crates/nexum-sdk/src/tracing.rs @@ -1,16 +1,11 @@ -//! Guest-side `tracing` facade: routes `tracing` events to a host log -//! sink so module authors write `tracing::info!(...)` with no host -//! parameter to thread. +//! Guest-side `tracing` facade routing events to a host log sink, so +//! module authors write `tracing::info!(...)` with no host parameter. //! -//! The subscriber is events-only: it renders each event's fields into -//! one line and forwards it at the event's [`Level`]. Spans are inert -//! no-ops. It links `tracing-core` alone, not the subscriber registry, -//! so the wasm module stays small. -//! -//! The [`init`] call also installs a panic hook that writes the panic -//! to stderr and then reports it over the same sink. Stderr is written -//! first on purpose: host-side stderr capture still records the panic -//! even if the sink's host call traps before `panic = abort` fires. +//! Events-only: each event's fields render to one line, forwarded at +//! the event's [`Level`]; spans are inert. [`init`] also installs a +//! panic hook that writes the panic to stderr, then reports it over the +//! sink (stderr first, so the panic is captured even if the host call +//! traps before `panic = abort`). use core::fmt::{self, Write as _}; use core::sync::atomic::{AtomicU64, Ordering}; @@ -21,16 +16,16 @@ use tracing_core::field::{Field, Visit}; use tracing_core::span::{Attributes, Id, Record}; use tracing_core::{Event, Level, LevelFilter, Metadata, Subscriber}; -/// Sink the facade forwards rendered events to. Implementors carry the -/// bound host logging call; the host decides how each line is handled. +/// Sink the facade forwards rendered event lines to; implementors carry +/// the bound host logging call. pub trait LogSink: Send + Sync { /// Forward one rendered line at `level`. fn log(&self, level: Level, message: &str); } /// Install the facade as the global subscriber and register the panic -/// hook, both forwarding to `sink`. The subscriber is set once; a -/// second call leaves it in place and only re-registers the panic hook. +/// hook over `sink`. The subscriber is set once; a second call only +/// re-registers the panic hook. pub fn init(sink: impl LogSink + 'static) { let sink: Arc = Arc::new(sink); let dispatch = tracing_core::Dispatch::new(FacadeSubscriber::new(Arc::clone(&sink))); @@ -39,8 +34,8 @@ pub fn init(sink: impl LogSink + 'static) { set_panic_hook(sink); } -/// Build the events-only subscriber over `sink` without touching global -/// state. Test harnesses scope it with `tracing::subscriber::with_default`. +/// The events-only subscriber over `sink`, without touching global +/// state. pub fn subscriber(sink: impl LogSink + 'static) -> impl Subscriber { FacadeSubscriber::new(Arc::new(sink)) } @@ -68,8 +63,7 @@ fn panic_payload(info: &PanicHookInfo<'_>) -> String { } } -/// Render a panic into the reported line. Pure so it is unit-testable -/// apart from the abort path. +/// Render a panic into the reported line. fn format_panic(payload: &str, location: Option<(&str, u32)>) -> String { match location { Some((file, line)) => format!("panic: {payload} at {file}:{line}"), @@ -126,9 +120,7 @@ impl Subscriber for FacadeSubscriber { fn exit(&self, _span: &Id) {} } -/// Flattens an event into ` key=value ...`: the `message` -/// field becomes the line body, every other field is appended as a -/// space-separated `key=value` pair in record order. +/// Flattens an event into ` key=value ...` in record order. #[derive(Default)] struct LineVisitor { message: String, @@ -182,7 +174,7 @@ mod tests { use super::*; - /// Capturing sink for the with_default-scoped subscriber. + /// Capturing sink for the scoped subscriber. #[derive(Default)] struct Captured { lines: Mutex>, diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 003af051..b98ac690 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -1,56 +1,28 @@ -//! Declarative macro that generates the `WitBindgenHost` adapter -//! every module ships in `lib.rs`: the `struct WitBindgenHost;` plus -//! the core trait impls and the fault, chain-error, and level -//! conversions. +//! Declarative macro generating the `WitBindgenHost` adapter a module +//! ships in `lib.rs`: `struct WitBindgenHost;` plus the core trait +//! impls and the fault, chain-error, and level conversions. //! -//! 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 six-interface set (`chain, identity, local_store, -//! remote_store, messaging, logging`) for modules that compile -//! against a blanket world with every core import present. -//! Either way the call site must already have the wit-bindgen output -//! for its world in scope (`wit_bindgen::generate!({ ..., -//! generate_all })`): each selected piece resolves its -//! `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`. -//! -//! Usage in a module's `lib.rs`: +//! Capability-selected: `caps: [...]` emits only the pieces backed by +//! the listed capabilities (how `#[nexum_sdk::module]` invokes it); the +//! zero-argument form emits the full six-interface set. Either way the +//! wit-bindgen output for the world must already be in scope, so +//! selecting a capability the world does not import is a compile error. +//! A domain SDK layers its own interfaces on the same `WitBindgenHost`. //! //! ```ignore //! wit_bindgen::generate!({ /* ... */ }); //! nexum_sdk::bind_host_via_wit_bindgen!(); -//! // or, capability-selected: -//! // nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); -//! -//! // `WitBindgenHost` and the `Fault` `From` impls (both directions) -//! // are now in scope, plus per selected capability: `convert_chain_err` -//! // (chain), the `IdentityHost`, `LocalStoreHost`, `RemoteStoreHost`, -//! // and `MessagingHost` impls (identity / local_store / remote_store / -//! // messaging), and the `Level` `From` impl, `HostLogSink`, and -//! // `install_tracing` (logging), with the wit-bindgen and SDK types -//! // tied together through identifier resolution. Call -//! // `install_tracing()` once at the top of `Guest::init` to route -//! // `tracing::info!(...)` to the host. `From` impls for -//! // `nexum_sdk::events::Log` and `nexum_sdk::host::Message` are also -//! // emitted so `on_event` maps chain-log batches and messages -//! // straight to the SDK types. +//! // or capability-selected: +//! nexum_sdk::bind_host_via_wit_bindgen!(caps: [chain, logging]); +//! // Call `install_tracing()` once at the top of `Guest::init`. //! ``` -/// Generate `WitBindgenHost` + the `*Host` trait impls + the error / +/// Generate `WitBindgenHost`, the `*Host` trait impls, and the error / /// level `From` impls for the selected capabilities. See module docs. /// -/// The fault and level conversions are `From` impls: orphan-legal here -/// because the wit-bindgen types are local to the expanding cdylib. -/// -/// Macro hygiene note: `macro_rules!` is not hygienic for type names -/// or function items, so the names `WitBindgenHost`, `convert_chain_err`, -/// `HostLogSink`, and `install_tracing` are intentionally visible in the -/// caller's scope. +/// The generated names `WitBindgenHost`, `convert_chain_err`, +/// `HostLogSink`, and `install_tracing` are visible in the caller's +/// scope (`macro_rules!` is not hygienic for items). #[macro_export] macro_rules! bind_host_via_wit_bindgen { // Blanket-world form: every core interface is in scope, emit the @@ -64,15 +36,11 @@ macro_rules! bind_host_via_wit_bindgen { // 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!`. + /// Wraps the module's per-cdylib wit-bindgen imports so a + /// strategy can hold a `&impl Host`. struct WitBindgenHost; - /// 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. + /// Lift the wit-bindgen `types.fault` into the SDK's `Fault`. impl ::core::convert::From for $crate::host::Fault { fn from(f: nexum::host::types::Fault) -> Self { match f { @@ -91,15 +59,9 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Reverse direction: lower the SDK `Fault` back into the - /// per-cdylib wit-bindgen `Fault` so `Guest::init` / - /// `Guest::on_event` can return what the export signature - /// expects (`?` applies it). - /// - /// Carries a wildcard arm because `$crate::host::Fault` is - /// `#[non_exhaustive]`: a future SDK-side case must compile in - /// module crates without source changes. Falls back to - /// `internal` carrying the `Display` detail. + /// Lower the SDK `Fault` back into the wit-bindgen `Fault` for + /// the export signature. A future `#[non_exhaustive]` SDK case + /// falls back to `internal` carrying the `Display` detail. impl ::core::convert::From<$crate::host::Fault> for nexum::host::types::Fault { fn from(f: $crate::host::Fault) -> Self { match f { @@ -121,10 +83,8 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// 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 - /// wire record. Assembly logic lives in `nexum_sdk::events`. + /// Rebuild the native alloy log from the wit-bindgen `chain-log` + /// record; assembly lives in `nexum_sdk::events`. impl ::core::convert::From for $crate::events::Log { fn from(log: nexum::host::types::ChainLog) -> Self { $crate::events::ChainLogParts { @@ -143,9 +103,8 @@ macro_rules! bind_host_via_wit_bindgen { } } - /// Rebuild the SDK message from the per-cdylib wit-bindgen - /// `message` record, so `on_event` maps a delivery straight to - /// `nexum_sdk::host::Message`. + /// Rebuild the SDK `Message` from the wit-bindgen `message` + /// record. impl ::core::convert::From for $crate::host::Message { fn from(message: nexum::host::types::Message) -> Self { Self { @@ -178,9 +137,8 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } - /// 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. + /// Lift the wit-bindgen `chain.chain-error` into the SDK's + /// host-neutral `ChainError`. fn convert_chain_err(e: nexum::host::chain::ChainError) -> $crate::host::ChainError { match e { nexum::host::chain::ChainError::Fault(f) => { @@ -349,9 +307,7 @@ macro_rules! __bind_host_cap_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`. + /// `logging::Level` wire enum. impl ::core::convert::From<$crate::Level> for nexum::host::logging::Level { fn from(level: $crate::Level) -> Self { if level == $crate::Level::ERROR { @@ -377,9 +333,8 @@ macro_rules! __bind_host_cap_via_wit_bindgen { } } - /// Install the guest tracing facade and panic hook, forwarding - /// through the bound host logging call. Call once at the top of - /// `Guest::init` so `tracing::info!(...)` reaches the host. + /// Install the guest tracing facade and panic hook over the + /// bound host logging call. Call once at the top of `Guest::init`. fn install_tracing() { $crate::tracing::init(HostLogSink); } diff --git a/crates/nexum-tasks/src/lib.rs b/crates/nexum-tasks/src/lib.rs index 81e5b3d0..81eb72c2 100644 --- a/crates/nexum-tasks/src/lib.rs +++ b/crates/nexum-tasks/src/lib.rs @@ -1,9 +1,7 @@ //! Task lifecycle and graceful shutdown: every runtime task is spawned //! through a [`TaskExecutor`] minted by a [`TaskManager`], which owns the -//! shutdown signal and the bounded drain. -//! -//! This crate is the only place a raw `tokio` spawn appears; consumers -//! route every task through the executor so shutdown reaches all of them. +//! shutdown signal and the bounded drain. The only crate a raw `tokio` +//! spawn appears in, so shutdown reaches every task. mod manager; mod shutdown; diff --git a/crates/nexum-world/src/lib.rs b/crates/nexum-world/src/lib.rs index 12269b92..f8595f9b 100644 --- a/crates/nexum-world/src/lib.rs +++ b/crates/nexum-world/src/lib.rs @@ -2,24 +2,18 @@ //! declarations into an inline WIT world whose imports are exactly the //! declared capability interfaces. //! -//! The one non-obvious invariant: the capability rows here must agree -//! with the runtime's capability registry (`nexum-runtime`'s manifest -//! enforcement) on both the capability names and the WIT interfaces they -//! map to. The runtime cross-checks a component's imports against the -//! manifest at load time; because the imports are derived from the same -//! manifest, a macro-built component passes that check by construction -//! rather than by relying on the toolchain eliding unused imports. -//! -//! The table here carries only the core `nexum:host` rows. Per-namespace -//! rows come from the composition root's `extensions.toml` registry -//! ([`manifest_extensions`]): the caller passes them to [`synthesize`], -//! so this crate carries no downstream name. +//! Invariant: the capability rows must agree with the runtime's +//! capability registry on both names and WIT interfaces, since the +//! runtime cross-checks a component's imports against the manifest at +//! load time. [`CORE`] carries only the core `nexum:host` rows; +//! per-namespace rows come from a composition root's `extensions.toml` +//! ([`manifest_extensions`]) and are passed to [`synthesize`]. use std::path::{Path, PathBuf}; use strum::{EnumString, IntoStaticStr, VariantNames}; -/// A core capability name: the single source the [`CORE`] table and the -/// runtime's capability registry emit from. +/// A core capability name; the single source [`CORE`] and the runtime's +/// capability registry emit from. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, VariantNames)] #[strum(serialize_all = "kebab-case")] #[non_exhaustive] @@ -41,9 +35,8 @@ pub enum Cap { } impl Cap { - /// The declared name, as a manifest spells it. Hand-written rather - /// than derived: [`CORE`] and [`CORE_IFACES`] evaluate it in const - /// context, and strum's `IntoStaticStr` emits a non-const `From`. + /// The declared name, as a manifest spells it. Const so [`CORE`] and + /// [`CORE_IFACES`] can evaluate it. pub const fn as_str(self) -> &'static str { match self { Self::Chain => "chain", @@ -58,8 +51,7 @@ impl Cap { } /// A `nexum:host/types.fault` case as a stable snake_case label, in WIT -/// declaration order: the single source every label mirror emits from. -/// `IntoStaticStr` yields the label, `VARIANTS` the whole vocabulary. +/// declaration order; the single source every label mirror emits from. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr, VariantNames)] #[strum(serialize_all = "snake_case")] #[non_exhaustive] @@ -80,14 +72,11 @@ pub enum FaultLabel { Internal, } -/// The permitted JSON-RPC read surface as a closed type: the single -/// source both the guest allowlist (`nexum_sdk::chain::ChainMethod`) -/// and the host dispatch table (`nexum_runtime::host::component:: -/// ChainMethod`) emit from. Methods that sign or mutate node state have -/// no variant, so a guest-supplied signing method (for example -/// `eth_sign` or `eth_sendTransaction`) cannot be represented and never -/// crosses the WIT edge. This is the structural ceiling; an operator -/// allowlist narrows within it and never widens it. +/// The permitted JSON-RPC read surface as a closed type; the single +/// source the guest allowlist and host dispatch table emit from. +/// Signing and state-mutating methods have no variant, so cannot cross +/// the WIT edge. This is the structural ceiling; an operator allowlist +/// narrows within it, never widens it. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, EnumString, IntoStaticStr)] #[non_exhaustive] pub enum ChainMethod { @@ -151,9 +140,7 @@ pub enum ChainMethod { } impl ChainMethod { - /// The wire method name. `&'static` because the permitted set is - /// closed, so the name drops straight into alloy's - /// `Cow<'static, str>` method slot without allocating. + /// The wire method name. pub fn as_str(self) -> &'static str { self.into() } @@ -163,16 +150,14 @@ impl ChainMethod { pub struct Capability { /// The name declared under `[capabilities].required` / `optional`. pub name: Cap, - /// 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). + /// The WIT import the declaration turns into; `None` for a + /// capability with no world import (`http`). pub import: Option<&'static str>, /// WIT package directories the import needs on the resolve path, /// beyond `nexum-host`. pub packages: &'static [&'static str], - /// The `bind_host_via_wit_bindgen!` capability ident carrying this - /// capability's host-adapter pieces, if the SDK has a trait seam - /// for it. + /// The `bind_host_via_wit_bindgen!` capability ident for this + /// capability's host-adapter pieces, if it has a trait seam. pub adapter: Option<&'static str>, } @@ -236,9 +221,8 @@ const fn core_iface_count() -> usize { n } -/// Names of the import-bearing [`CORE`] rows, in emission order: the -/// `nexum:host` interface set the runtime's capability registry -/// enforces. `http` is absent (no world import). +/// Names of the import-bearing [`CORE`] rows, in emission order; the +/// `nexum:host` interface set the runtime enforces. `http` is absent. pub const CORE_IFACES: [&str; core_iface_count()] = { let mut out = [""; core_iface_count()]; let mut n = 0; @@ -253,10 +237,9 @@ pub const CORE_IFACES: [&str; core_iface_count()] = { out }; -/// One registered extension row: a per-namespace capability a -/// composition root declares in its `extensions.toml`. An extension -/// always has a WIT import and never a host-adapter ident (adapter -/// seams are core-only). +/// One registered extension row: a per-namespace capability declared in +/// a composition root's `extensions.toml`. Always has a WIT import, +/// never an adapter ident (adapter seams are core-only). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtensionRow { /// The name modules declare under `[capabilities]`. @@ -275,17 +258,15 @@ pub struct ModuleWorld { /// Inline WIT text defining `nexum:module-world/module`. pub wit: String, /// WIT package directories the resolve path must carry, in - /// dependency order (a package precedes its dependants). Always - /// starts with the base set the host `event` variant needs. + /// dependency order (a package precedes its dependants). pub packages: Vec, /// Capability idents to pass to `bind_host_via_wit_bindgen!`. pub adapters: Vec<&'static str>, } -/// Extract the declared capability names (`required` then `optional`) -/// from the manifest text. A missing or malformed `[capabilities]` -/// section is an error: the emitted world is derived from it, so the -/// synthesis has nothing to build from without one. +/// The declared capability names (`required` then `optional`) from the +/// manifest text. A missing or malformed `[capabilities]` section is an +/// error. pub fn manifest_capabilities(text: &str) -> Result, String> { let value: toml::Table = text .parse() @@ -316,8 +297,8 @@ pub fn manifest_capabilities(text: &str) -> Result, String> { Ok(names) } -/// Extract the declared `[module] name` from the manifest text, the id -/// the module registers under. Absent or non-string is an error. +/// The declared `[module] name` from the manifest text, the id the +/// module registers under. Absent or non-string is an error. pub fn manifest_name(text: &str) -> Result { let value: toml::Table = text .parse() @@ -331,8 +312,8 @@ pub fn manifest_name(text: &str) -> Result { .ok_or_else(|| "[module].name must be a string".to_string()) } -/// Extract the declared `[module] kind` from the manifest text, `None` -/// when absent (the runtime defaults an absent kind to the worker). +/// The declared `[module] kind` from the manifest text; `None` when +/// absent (the runtime defaults it to the worker). pub fn manifest_kind(text: &str) -> Result, String> { let value: toml::Table = text .parse() @@ -346,10 +327,10 @@ pub fn manifest_kind(text: &str) -> Result, String> { } } -/// Parse the registered extension rows from an `extensions.toml`. Each -/// `[extensions.]` table carries the WIT `import` the declaration -/// turns into and the extra `packages` its resolve path needs. A file -/// without an `[extensions]` section registers nothing. +/// The registered extension rows from an `extensions.toml`. Each +/// `[extensions.]` table carries a WIT `import` and the extra +/// `packages` its resolve path needs. No `[extensions]` section +/// registers nothing. pub fn manifest_extensions(text: &str) -> Result, String> { let value: toml::Table = text .parse() @@ -395,9 +376,8 @@ pub fn manifest_extensions(text: &str) -> Result, String> { .collect() } -/// Find the extension registry for a build rooted at `start`: the -/// nearest ancestor `extensions.toml`. `None` means no registered -/// extensions. +/// The extension registry for a build rooted at `start`: the nearest +/// ancestor `extensions.toml`, or `None`. pub fn find_extensions_manifest(start: &Path) -> Option { let mut dir = Some(start); while let Some(cur) = dir { @@ -410,14 +390,11 @@ pub fn find_extensions_manifest(start: &Path) -> Option { None } -/// Build the per-module world from the declared capability names -/// (required and optional alike: an optional capability must still be -/// importable, the host decides at load time whether to back or stub -/// it). `extensions` carries the per-namespace rows of the registered -/// extensions, emitted after the core rows. Unknown names are an error -/// so a typo cannot silently drop an import; a registered name that -/// shadows a core row or another registration is an error so a -/// colliding registry cannot emit a duplicate import. +/// The per-module world from the declared capability names (required +/// and optional alike; optional imports still resolve, the host stubs +/// them at load time). `extensions` rows emit after the core rows. An +/// unknown name is an error; an extension name that shadows a core row +/// or another registration is an error. pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result { for (idx, ext) in extensions.iter().enumerate() { if CORE.iter().any(|c| c.name.as_str() == ext.name) @@ -499,11 +476,9 @@ pub fn synthesize(declared: &[String], extensions: &[ExtensionRow]) -> Result` before own -/// `wit/`; a crate not carrying it falls back to the nearest -/// ancestor `wit/` that does (the transitional monorepo layout). +/// Resolve each WIT package directory for a build rooted at `start`. +/// Crate-local `wit/deps/` before own `wit/`, else the +/// nearest ancestor `wit/` that carries it. pub fn resolve_wit_packages>( start: &Path, packages: &[S], @@ -567,8 +542,7 @@ fn resolve_wit_package(start: &Path, package: &str) -> Option { mod tests { use super::*; - /// The base package set every module world resolves against: - /// `nexum:host` is a leaf package, so it stands alone. + /// The base package set every module world resolves against. const MODULE_PACKAGES: [&str; 1] = ["nexum-host"]; /// A stand-in extension row, as a registered extension would pass. @@ -638,8 +612,7 @@ mod tests { assert!(!CORE_IFACES.contains(&Cap::Http.as_str())); } - /// The const accessor is hand-written, so pin it to the derived - /// vocabulary in both directions. + /// Pin the hand-written const accessor to the derived vocabulary. #[test] fn cap_accessor_agrees_with_the_derived_vocabulary() { let names: Vec<&str> = CORE.iter().map(|c| c.name.as_str()).collect(); diff --git a/crates/no-std-probe/src/lib.rs b/crates/no-std-probe/src/lib.rs index 89603613..e5bc2289 100644 --- a/crates/no-std-probe/src/lib.rs +++ b/crates/no-std-probe/src/lib.rs @@ -1,10 +1,7 @@ -//! `no_std` derive-hygiene probe for `#[derive(IntentBody)]`. The claim -//! is scoped to the derive, not the whole SDK: the expansion names only -//! `::core` and the SDK's `__private` re-exports (borsh, `alloc`), so it -//! compiles without the consumer's std prelude or an `extern crate -//! alloc`. The crate is `#![no_std]`; the `tests` module (std-exempt) -//! exercises an actual encode/decode round-trip, so the generated codec -//! is verified correct, not merely expanded. +//! `no_std` derive-hygiene probe for `#[derive(IntentBody)]`: the +//! expansion names only `::core` and the SDK's `__private` re-exports, +//! so it compiles under `#![no_std]` without an `extern crate alloc`. +//! The `tests` module round-trips the generated codec. #![no_std] #![warn(missing_docs)] diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index b35ad0ee..0bf1be75 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,8 +1,6 @@ //! The `shepherd` binary: the cow composition root. Boots the //! reference backends, registers the videre venue platform, and hands -//! it all to the generic launcher; CoW enters only as the bundled -//! `cow-venue` adapter component, and the engine itself stays venue- -//! and cow-free. +//! both to the generic launcher. #![cfg_attr(not(test), warn(unused_crate_dependencies))] From 6c4f54e55467794a50b01b76064930af300b793c Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 10:14:00 +0000 Subject: [PATCH 133/139] chore: retire strategy vocabulary and banners in docs (#626) De-stale the word strategy to keeper (venue-submitting modules) or module (generic guests) across the docs tree, and repoint the renamed module logic files: ethflow-watcher uses keeper.rs and the read-only example modules use logic.rs. No banner comments were present in docs. No behaviour or code change. --- docs/00-overview.md | 4 ++-- docs/01-runtime-environment.md | 2 +- docs/05-sdk-design.md | 14 +++++++------- docs/07-rpc-namespace-design.md | 4 ++-- .../0007-upstream-protocol-logic-to-cow-rs.md | 2 +- docs/adr/0009-host-trait-surface.md | 10 +++++----- docs/diagrams/diagrams.md | 12 ++++++------ docs/operations/e2e-testnet-runbook.md | 2 +- docs/operations/m3-testnet-runbook.md | 6 +++--- docs/sdk.md | 4 ++-- docs/testing-runtime-harness.md | 6 +++--- docs/tutorial-first-module.md | 18 +++++++++--------- 12 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/00-overview.md b/docs/00-overview.md index 7bec0431..3268dbf5 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -212,7 +212,7 @@ stateDiagram-v2 ## SDK -The guest SDK ships as `nexum-sdk` (the generic strategy-module SDK: host-trait seam, chain/config/address helpers, `http::fetch`, tracing facade) with `nexum-sdk-test` for the mock-host surface, and `videre-sdk` (the venue and keeper SDK: the `venue-adapter` export trait and the typed venue client) with `videre-test`. Modules are built with `cargo build --target wasm32-wasip2 --release`. The operator CLI is the `nexum` binary itself. +The guest SDK ships as `nexum-sdk` (the generic module SDK: host-trait seam, chain/config/address helpers, `http::fetch`, tracing facade) with `nexum-sdk-test` for the mock-host surface, and `videre-sdk` (the venue and keeper SDK: the `venue-adapter` export trait and the typed venue client) with `videre-test`. Modules are built with `cargo build --target wasm32-wasip2 --release`. The operator CLI is the `nexum` binary itself. Multi-language support: authors can target the WIT world directly from Rust, C/C++, Go, JavaScript, or Python via `wit-bindgen`. The SDK is a Rust ergonomics layer. @@ -247,7 +247,7 @@ shepherd/ │ ├── nexum-runtime/ Core WASM host (server) library: event system, local store, bootstrap │ ├── nexum-cli/ The `nexum` binary: clap CLI over the runtime library │ ├── nexum-sdk/ Generic guest SDK: host-trait seam, chain/config/address helpers, wasi:http fetch -│ ├── nexum-sdk-test/ Generic mock host for strategy tests +│ ├── nexum-sdk-test/ Generic mock host for module tests │ ├── videre-sdk/ Venue + keeper SDK: venue-adapter export trait, typed venue client │ ├── videre-host/ Host-side venue registry + status watch │ ├── videre-test/ Venue/keeper test surface diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 6dff10d1..8d9ca094 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -426,7 +426,7 @@ See doc 07 for the full `chain` host implementation, method allowlisting, and th ## Guest-Side (Module Author) Experience -Modules ship using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `strategy.rs` of pure logic against `&impl Host`, plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. Build with `cargo build --target wasm32-wasip2 --release`. See [`sdk.md`](sdk.md), doc 05, and the example modules under `modules/examples/`. +Modules ship using the host-trait seam from [ADR-0009](adr/0009-host-trait-surface.md): a `logic.rs` (`keeper.rs` in a keeper) of pure logic against `&impl Host`, plus a `lib.rs` `WitBindgenHost` adapter that bridges to `wit-bindgen::generate!`. Build with `cargo build --target wasm32-wasip2 --release`. See [`sdk.md`](sdk.md), doc 05, and the example modules under `modules/examples/`. ## Multi-Language Guest Support diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 05ece59c..f12f02a3 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -14,7 +14,7 @@ For the architectural decision behind the host-trait seam both personas build on A keeper - a module that drives venues - sits between the two: it is a module by world, but it authors with `#[videre_sdk::keeper]` and calls venues through the typed `VenueClient`. The domain itself (CoW, a DEX) lives in the venue adapter, never in the host: see [doc 08](08-platform-generalisation.md#layer-3-domain-extensions-venue-adapters). -Each persona has its own proc-macro crate (`nexum-module-macros`, `videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so strategy logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. +Each persona has its own proc-macro crate (`nexum-module-macros`, `videre-macros`), reached through the SDK re-exports. Both share the same host-trait philosophy: guest code is written against small Rust traits that mirror the WIT interfaces one-for-one, so module logic can be unit-tested against an in-memory mock without a `wasm32-wasip2` toolchain or a running wasmtime instance. ## Crate layout @@ -84,7 +84,7 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -Strategy code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject `nexum_sdk_test::MockHost` while the compiled module injects the wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and [ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error model. +Module code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject `nexum_sdk_test::MockHost` while the compiled module injects the wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) for the full rationale and [ADR-0011](adr/0011-per-interface-typed-errors.md) for the typed error model. ### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` @@ -96,7 +96,7 @@ Every module keeps its own `wit_bindgen::generate!` call (the macro emits types ```rust // modules/examples/http-probe/src/lib.rs (shipped) -mod strategy; +mod logic; use nexum::host::types; @@ -106,13 +106,13 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config)?; + let cfg = logic::parse_config(&config)?; // ... Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { - strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + logic::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) .map_err(Into::into) } } @@ -125,7 +125,7 @@ against a per-module world whose imports are exactly the `[capabilities].require - **Handlers are synchronous.** `init` and the named handlers are plain `fn`, called directly with no `block_on` wrapper. Modules call `host.request(chain_id, method, params_json)` (or the `chain::eth_call_params` / `parse_eth_call_result` helpers) directly against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). (Keeper handlers are the exception: see below.) -The `Guest`/`export!` shape the macro emits follows the `strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, `Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic run. +The `Guest`/`export!` shape the macro emits follows the `logic.rs` (pure logic, tested against `&impl Host`) / `lib.rs` (handlers plus the macro attribute) split from ADR-0009. The keeper primitives in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, `Poller`, `Retrier` - give conditional-commitment modules a shared set of `LocalStoreHost` conventions instead of hand-rolled key schemes; `videre_sdk::keeper` assembles them into the generic run. ## Venue persona: `videre-sdk` @@ -170,7 +170,7 @@ impl Venue for CowVenue { ### Testing: `nexum-sdk-test` and `videre-test` -Keeper strategy logic tests exactly as module logic does: against the host traits with `nexum_sdk_test::MockHost`, plus an in-memory `VenueTransport` for the client seam. Tests run as plain native Rust - no `wasm32-wasip2` target, no wasmtime instance, no network. +Keeper logic tests exactly as module logic does: against the host traits with `nexum_sdk_test::MockHost`, plus an in-memory `VenueTransport` for the client seam. Tests run as plain native Rust - no `wasm32-wasip2` target, no wasmtime instance, no network. ```rust use nexum_sdk::host::*; diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index 117e636d..f89804b7 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -50,7 +50,7 @@ interface identity { ## Guest SDK: the alloy provider seam -`nexum_sdk::chain` fronts `chain.request` with an alloy `Provider`, so strategy code calls typed provider methods instead of hand-building JSON-RPC: +`nexum_sdk::chain` fronts `chain.request` with an alloy `Provider`, so module code calls typed provider methods instead of hand-building JSON-RPC: - `HostTransport` implements alloy's `Service` over any `ChainHost`, dispatching single requests through `chain.request` and batches through `chain.request-batch`. - `ProviderHost::provider(chain)` mints an alloy `RootProvider` over that transport, blanket-implemented for every cloneable `ChainHost`. @@ -75,4 +75,4 @@ Submitting an order or intent is not a chain namespace. It is the `videre:venue` ## Testing -`nexum_sdk_test::MockHost` implements the host traits (`ChainHost`, `IdentityHost`, `LocalStoreHost`, ...); strategy logic tests against `&impl Host` as plain native Rust, with no `wasm32-wasip2` target and no wasmtime instance. Provider-level tests wrap a stub `ChainHost` in `HostTransport` and drive it with `block_on`. +`nexum_sdk_test::MockHost` implements the host traits (`ChainHost`, `IdentityHost`, `LocalStoreHost`, ...); module logic tests against `&impl Host` as plain native Rust, with no `wasm32-wasip2` target and no wasmtime instance. Provider-level tests wrap a stub `ChainHost` in `HostTransport` and drive it with `block_on`. diff --git a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md index f750feac..afa1f771 100644 --- a/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md +++ b/docs/adr/0007-upstream-protocol-logic-to-cow-rs.md @@ -6,7 +6,7 @@ status: accepted ## Context -When the runtime or its modules need CoW Protocol logic the `cowprotocol` crate does not yet expose, the choice is to write it locally and tidy up upstream later, or add it upstream first and land the wiring afterwards. Duplicating logic an existing crate could own is the anti-pattern to avoid. Protocol primitives (order types, signing schemes, orderbook errors) belong in `cowprotocol`; strategy implementations (TWAP polling, EthFlow decoding) stay in guest modules per ADR-0006. +When the runtime or its modules need CoW Protocol logic the `cowprotocol` crate does not yet expose, the choice is to write it locally and tidy up upstream later, or add it upstream first and land the wiring afterwards. Duplicating logic an existing crate could own is the anti-pattern to avoid. Protocol primitives (order types, signing schemes, orderbook errors) belong in `cowprotocol`; keeper implementations (TWAP polling, EthFlow decoding) stay in guest modules per ADR-0006. ## Decision diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index a205f3dd..47ba0618 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -2,9 +2,9 @@ status: superseded-in-part --- -# Host trait surface: per-capability traits plus a supertrait, with a `strategy.rs` / `lib.rs` split +# Host trait surface: per-capability traits plus a supertrait, with a `logic.rs` / `lib.rs` split -> **The error envelope is superseded by [ADR-0011](0011-per-interface-typed-errors.md):** the single `HostError` / `HostErrorKind` record is replaced by per-interface typed errors over a shared `fault` vocabulary. The trait-surface and `strategy.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. +> **The error envelope is superseded by [ADR-0011](0011-per-interface-typed-errors.md):** the single `HostError` / `HostErrorKind` record is replaced by per-interface typed errors over a shared `fault` vocabulary. The trait-surface and `logic.rs` / `lib.rs` decisions below still hold; read `HostError` as its successor types. ## Context @@ -14,12 +14,12 @@ The runtime needs a testable host abstraction so module logic compiles against a Three coupled choices: -1. **Four per-capability traits (`ChainHost`, `LocalStoreHost`, `CowApiHost`, `LoggingHost`) with a blanket-implemented supertrait `Host`.** Strategy code takes `&impl Host` and calls any interface uniformly; tests inject `nexum_sdk_test::MockHost`, production injects `WitBindgenHost`. The four-trait split lets a test mock only the calls its strategy makes. +1. **Four per-capability traits (`ChainHost`, `LocalStoreHost`, `CowApiHost`, `LoggingHost`) with a blanket-implemented supertrait `Host`.** Module code takes `&impl Host` and calls any interface uniformly; tests inject `nexum_sdk_test::MockHost`, production injects `WitBindgenHost`. The four-trait split lets a test mock only the calls its module makes. 2. **An SDK-side error type mirroring the WIT struct field-for-field**, its own type (the WIT-generated one is per-cdylib), bridged by a one-line `From` in each module's `lib.rs`. This keeps the traits world-neutral so `nexum-sdk-test` needs no wasm toolchain. Superseded by ADR-0011's typed errors. -3. **Per-module `strategy.rs` (pure, wit-independent logic, unit tests against `MockHost`) plus `lib.rs` (per-cdylib `wit_bindgen::generate!` glue, the `WitBindgenHost` impl, and the `Guest` dispatch).** Colocating hundreds of lines of strategy with the mechanical adapter obscures both. +3. **Per-module `logic.rs` (pure, wit-independent logic, unit tests against `MockHost`) plus `lib.rs` (per-cdylib `wit_bindgen::generate!` glue, the `WitBindgenHost` impl, and the `Guest` dispatch).** Colocating hundreds of lines of logic with the mechanical adapter obscures both. ## Consequences -- Strategy code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. +- Module code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. - New capabilities add a new trait plus a `MockX` in `nexum-sdk-test`; modules that do not use a capability bound only on the subset they touch. - The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index 5bd8ba1e..9e7d4914 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -47,7 +47,7 @@ graph TD | **EventLoop** | The main async loop. Runs all block-header and log-event streams concurrently via `futures::stream::select_all`. When a stream fires, it routes the event to every module that subscribed to it in their `module.toml`. | | **WASM Modules** | The guest programs. Each module exports `init(config)` (called once at boot) and `on_event(event)` (called on every relevant block or log). They contain the protocol logic themselves: TWAP polling, EthFlow event decoding, OrderCreation construction. They call back into the host through universal WIT interfaces only - no CoW-specific helper interfaces (ADR-0006). | | **Blockchain** | The EVM chain being watched. Delivers new block headers and contract log events over a persistent WebSocket (`eth_subscribe`). Also handles `eth_call` for on-chain reads (e.g. checking whether a TWAP order is ready). | -| **bleu/cow-rs [patch.crates-io]** | The Rust crate containing CoW Protocol **primitives**: order types, signing schemes, the orderbook HTTP client, and the typed orderbook error model (`OrderPostError` + `retry_hint`). Pulled via `[patch.crates-io]` pointing at the head of upstream PR #5. Modules consume the types directly via the `wasm32` feature; the engine consumes the orderbook client via its `cow-api` host backend. No TWAP or EthFlow strategy logic lives here - that stays in module code (ADR-0007). | +| **bleu/cow-rs [patch.crates-io]** | The Rust crate containing CoW Protocol **primitives**: order types, signing schemes, the orderbook HTTP client, and the typed orderbook error model (`OrderPostError` + `retry_hint`). Pulled via `[patch.crates-io]` pointing at the head of upstream PR #5. Modules consume the types directly via the `wasm32` feature; the engine consumes the orderbook client via its `cow-api` host backend. No TWAP or EthFlow keeper logic lives here - that stays in module code (ADR-0007). | | **api.cow.fi (Orderbook REST)** | The CoW Protocol orderbook service. Accepts `POST /orders` to register new orders. Trader-uploaded app-data documents are PUT to `/app_data/{hash}` separately by whoever signed the order (not by the relayer module). | --- @@ -315,7 +315,7 @@ sequenceDiagram | **ComposableCoW Contract** | The on-chain conditional order registry. Accepts TWAP parameters via `create()` and emits `ConditionalOrderCreated`. Also exposes `getTradeableOrderWithSignature()`, which the engine polls to check whether the current TWAP part is ready to trade. | | **RPC Node** | The WebSocket connection to the chain. Delivers log events (subscriptions) and handles `eth_call` (synchronous reads). Must be `wss://` for this flow since it uses subscriptions. | | **EventLoop** | Receives raw events from the RPC node and routes them to the module that subscribed to them. Opaque to the flow - it just calls `on_event`. | -| **twap module (WASM guest)** | Contains the entire TWAP strategy: decoding registrations, deciding when to poll (using stored hints), reacting to revert reasons, building orders, interpreting orderbook errors. Calls into the host only through universal WIT primitives. | +| **twap module (WASM guest)** | Contains the entire TWAP keeper: decoding registrations, deciding when to poll (using stored hints), reacting to revert reasons, building orders, interpreting orderbook errors. Calls into the host only through universal WIT primitives. | | **alloy_sol_types (in module)** | The ABI-aware decoder. Compiled into the module's WASM. Decodes `ConditionalOrderCreated` from raw log bytes; decodes the `getTradeableOrderWithSignature` return; interprets revert reasons. No host involvement for decoding. | | **cowprotocol types (in module)** | The protocol-level types from `bleu/cow-rs`, consumed by the module via the wasm32 feature (ADR-0007 item 3). Used to build `OrderCreation`, manipulate `OrderUid`, and pattern-match `OrderPostError`. The crate's HTTP client (`OrderBookApi`) is **not** used directly by the module - orderbook submission goes through the host's `cow-api`. | | **HostState (Rust)** | Provides only the universal primitives (`chain.request`, `local-store.*`, `cow-api.submit-order`). Knows nothing about TWAP semantics. | @@ -425,7 +425,7 @@ flowchart TD | **wasmtime Linker** | `Linker` built once at startup. `wasmtime::component::bindgen!` generates a `Shepherd` world struct and one trait per WIT interface (e.g. `shepherd::cow::cow_api::Host`, `nexum::host::local_store::Host`). `Shepherd::add_to_linker(&mut linker, \|state\| state)` registers every trait method as a host function. After that, calls from WASM resolve with zero dynamic dispatch overhead - the vtable is built at link time, not per-call. | | **HostState - manifest.required check** | In 0.2, capability enforcement is **link-time**, not per-call. At boot, `manifest::enforce_capabilities` (in `crates/nexum-runtime/src/manifest/capabilities.rs`) cross-checks every capability-bearing WIT import of the component against the `[capabilities].required` ∪ `[capabilities].optional` set in `module.toml`, with names validated against `KNOWN_CAPABILITIES`. A module that imports a capability it did not declare fails instantiation - it never reaches dispatch. This is structurally equivalent to per-call gating for the 0.2 capability set: an undeclared capability cannot link the relevant WIT, so unauthorised calls fail at component link rather than per-call dispatch. Per-call gating in `HostState` (returning `fault.denied` per invocation, useful for finer-grained policies or capability revocation at runtime) remains a future direction for 0.3+ if a richer threat model demands it; it is not in 0.2 scope. | | **tracing::info!** | Every host call emits a structured trace event (capability name, chain id, etc.). Operators use `RUST_LOG=shepherd=debug` to see every call a module makes. | -| **host backend Rust function** | `HostState` implements one generated trait per WIT interface. Each `async fn` in the trait receives `&mut self` (giving access to all host resources) and returns the WIT-mapped Rust type. There are no CoW-strategy-specific backends - only the universal ones plus `cow-api` (ADR-0006). | +| **host backend Rust function** | `HostState` implements one generated trait per WIT interface. Each `async fn` in the trait receives `&mut self` (giving access to all host resources) and returns the WIT-mapped Rust type. There are no CoW-specific backends - only the universal ones plus `cow-api` (ADR-0006). | | **OrderBookPool** | Looks up the `OrderBookApi` client for the requested chain and calls `post_order`. Returns a 56-byte `OrderUid` on success or an `OrderPostError`-bearing host error on failure. | | **ProviderPool (chain.request)** | Looks up the alloy provider for the requested chain and dispatches the JSON-RPC call (`eth_call`, `eth_getLogs`, etc.). | | **engine-managed streams (chain.subscribe-*)** | Subscriptions are not exposed as runtime-callable host functions in 0.2. They are opened by the engine at boot from each module's declared `[[subscription]]` entries; events flow into the module via `on_event`. Dynamic `register-address` for factory patterns is deferred (ADR-0008). | @@ -465,9 +465,9 @@ graph TD |---|---| | **cowdao-grants/cow-rs** | The upstream CoW Protocol Rust SDK, maintained by the DAO. Version `alpha.3` is published to crates.io but predates 18 follow-up commits Bleu has been pushing through PR #5. This is the PR base - changes land here eventually. | | **bleu/cow-rs** | Bleu's repository, which is simultaneously the head branch of the DAO's open PR #5. Every commit Bleu pushes here also advances PR #5 for upstream review. This is not a long-lived parallel fork - it is the active PR branch (ADR-0004). | -| **Protocol primitives added to PR #5** | The three additions Bleu is pushing into PR #5: `OrderPostError` rich variants + `retry_hint()` (critical for module error handling), `OrderBookApi::with_base_url` (barn / staging / forked deployments), and `wasm32` feature-gating (critical so guest modules can consume `cowprotocol` types). All three are protocol primitives - they describe what CoW Protocol *is*, not how a particular strategy uses it. TWAP polling and EthFlow event decoding are explicitly *not* added here; they stay in module code (ADR-0007). | +| **Protocol primitives added to PR #5** | The three additions Bleu is pushing into PR #5: `OrderPostError` rich variants + `retry_hint()` (critical for module error handling), `OrderBookApi::with_base_url` (barn / staging / forked deployments), and `wasm32` feature-gating (critical so guest modules can consume `cowprotocol` types). All three are protocol primitives - they describe what CoW Protocol *is*, not how a particular module uses it. TWAP polling and EthFlow event decoding are explicitly *not* added here; they stay in module code (ADR-0007). | | **Already in PR #5** | The types and orderbook client Bleu's modules consume but did not add: `Order`, `OrderCreation`, `OrderUid`, signing-scheme enums, and `OrderBookApi`. These existed in PR #5 before the M2 work. | | **[patch.crates-io]** | A single line in the workspace `Cargo.toml` that tells Cargo to use `bleu/cow-rs` at a specific git rev instead of the `alpha.3` release on crates.io. Bumping the rev is the only change needed to pick up a new primitive after it is pushed to `bleu/cow-rs` (ADR-0004). | -| **nexum** | The engine binary. Contains the WIT host implementations, Supervisor, EventLoop, config loaders, and alloy/redb integration. Contains no CoW Protocol logic - protocol primitives live in `bleu/cow-rs`; strategy logic lives in guest modules. | +| **nexum** | The engine binary. Contains the WIT host implementations, Supervisor, EventLoop, config loaders, and alloy/redb integration. Contains no CoW Protocol logic - protocol primitives live in `bleu/cow-rs`; module logic lives in guest modules. | | **shepherd:cow/cow-api WIT** | The only CoW-specific WIT interface in 0.2. The engine implements it (host side); WASM modules import it (guest side). Backed by `OrderBookPool` (and through that, `OrderBookApi` from `cow-rs`). | -| **WASM modules (twap · eth-flow)** | The grant deliverables. Compiled to `.wasm` Component Model binaries. Import only universal WIT interfaces (`chain`, `local-store`, `logging`) plus `shepherd:cow/cow-api`. Consume `cowprotocol` types directly through the wasm32 feature for building `OrderCreation` and pattern-matching on `OrderPostError`. Contain all TWAP and EthFlow strategy logic themselves (ADR-0006). | +| **WASM modules (twap · eth-flow)** | The grant deliverables. Compiled to `.wasm` Component Model binaries. Import only universal WIT interfaces (`chain`, `local-store`, `logging`) plus `shepherd:cow/cow-api`. Consume `cowprotocol` types directly through the wasm32 feature for building `OrderCreation` and pattern-matching on `OrderPostError`. Contain all TWAP and EthFlow keeper logic themselves (ADR-0006). | diff --git a/docs/operations/e2e-testnet-runbook.md b/docs/operations/e2e-testnet-runbook.md index 047334fc..a78b6b53 100644 --- a/docs/operations/e2e-testnet-runbook.md +++ b/docs/operations/e2e-testnet-runbook.md @@ -128,7 +128,7 @@ Every acceptance box in the template's section 7 must be `[x]` for the run to pa ## 5. Known Sepolia constraint: EthFlow `validTo = u32::MAX` -EthFlow on-chain orders carry `validTo = type(uint32).max` by design (cancellation is operator-controlled via the EthFlow contract). The Sepolia orderbook's max-validTo cap rejects this shape with `errorType = "ExcessiveValidTo"`, so every EthFlow placement on Sepolia terminates as `Drop`. The strategy recognises this and degrades gracefully: +EthFlow on-chain orders carry `validTo = type(uint32).max` by design (cancellation is operator-controlled via the EthFlow contract). The Sepolia orderbook's max-validTo cap rejects this shape with `errorType = "ExcessiveValidTo"`, so every EthFlow placement on Sepolia terminates as `Drop`. The keeper recognises this and degrades gracefully: - `ethflow dropped (400): orderbook error (ExcessiveValidTo)...` at Info level. - `dropped:{uid}` written once per placement. diff --git a/docs/operations/m3-testnet-runbook.md b/docs/operations/m3-testnet-runbook.md index 6e0b4a17..0c16cd27 100644 --- a/docs/operations/m3-testnet-runbook.md +++ b/docs/operations/m3-testnet-runbook.md @@ -6,7 +6,7 @@ Exercises the example modules price-alert, balance-tracker, and stop-loss on Sep - balance-tracker: SDK `chain::request` (raw RPC) + `local-store` per-key diff persistence. Read-only. - stop-loss: `chain::request` + `local-store` dedup + `cow-api::submit-order` with `Signature::PreSign`. Submits a real CoW order to the Sepolia orderbook when the oracle price crosses the trigger. -All three subscribe to blocks only and start working immediately; a single Sepolia block (~12 s) drives each through its full strategy. +All three subscribe to blocks only and start working immediately; a single Sepolia block (~12 s) drives each through its full logic. ## 0. Prerequisites @@ -55,7 +55,7 @@ DEBUG cow-api::submit-order bytes=561 WARN stop-loss retry on next block (0): orderbook error (TransferSimulationFailed): sell token cannot be transferred ``` -That block proves the M3 strategy surface end-to-end: oracle read + ABI decode + multi-key local-store + cow-api submit + typed retry classification. +That block proves the M3 module surface end-to-end: oracle read + ABI decode + multi-key local-store + cow-api submit + typed retry classification. Why TRIGGERED fires immediately: the default `trigger_price` in `module.toml` is above the Sepolia Chainlink ETH/USD feed (a stale or mocked value), and `direction = below`, so the first poll trips. Raise `trigger_price` to test the silent path. @@ -67,7 +67,7 @@ To see stop-loss submit and persist `submitted:{uid}`: 1. Set `owner` in `modules/examples/stop-loss/module.toml` to a Sepolia EOA you control. 2. Choose a `sell_token` / `buy_token` pair the EOA holds. -3. Compute the OrderUid (see `build_creation` in `strategy.rs`). +3. Compute the OrderUid (see `build_creation` in `keeper.rs`). 4. Call `GPv2Settlement.setPreSignature(uid, true)` from that EOA. 5. Approve `sell_token` to the GPv2VaultRelayer for the sell amount. 6. Lower `trigger_price` so the next poll fires. diff --git a/docs/sdk.md b/docs/sdk.md index 5dc7fa39..8f21fc8e 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -54,11 +54,11 @@ JSON plumbing (in `nexum-sdk`): seam plus the SDK's host-neutral `Fault` vocabulary (same cases as wit-bindgen's, bridged via one-liner converters per module), in `nexum-sdk`. `config` and `address` parsing helpers sit alongside it. - [`http`](../target/doc/nexum_sdk/http/index.html) - outbound -HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free strategy tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `modules/examples/http-probe` for a complete module. +HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free module tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `modules/examples/http-probe` for a complete module. ## Companions: nexum-sdk-test and videre-test -Add `nexum-sdk-test` as a dev-dep on the module crate to write strategy tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), [videre-test](../crates/videre-test/src/lib.rs)) for the usage pattern. +Add `nexum-sdk-test` as a dev-dep on the module crate to write module tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), [videre-test](../crates/videre-test/src/lib.rs)) for the usage pattern. ## Versioning diff --git a/docs/testing-runtime-harness.md b/docs/testing-runtime-harness.md index d825d07a..b61a8c02 100644 --- a/docs/testing-runtime-harness.md +++ b/docs/testing-runtime-harness.md @@ -5,11 +5,11 @@ Two separate mock surfaces exist, for testing two separate things: module logic ## The guardrail - **Module business logic is tested in plain Rust, no wasm.** A module's -decision logic lives in a host-generic `strategy.rs` (`fn on_block(host: &H, ...)`), and its tests drive it against `nexum-sdk-test::MockHost`; venue-facing logic adds `videre-test`'s transport mocks. No wasmtime, no component boundary, no engine crate at all. This is the dominant pattern across every shipped module (twap-monitor, ethflow-watcher, price-alert, balance-tracker); see [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-videre-test). **New module-logic tests belong here.** +decision logic lives in a host-generic `logic.rs` (`keeper.rs` in a keeper) as `fn on_block(host: &H, ...)`, and its tests drive it against `nexum-sdk-test::MockHost`; venue-facing logic adds `videre-test`'s transport mocks. No wasmtime, no component boundary, no engine crate at all. This is the dominant pattern across every shipped module (twap-monitor, ethflow-watcher, price-alert, balance-tracker); see [docs/sdk.md](sdk.md#companions-nexum-sdk-test-and-videre-test). **New module-logic tests belong here.** - **The engine harness (this page) is reserved for engine, host, and boundary correctness**: supervision (poison, restart, resource traps), dispatch isolation across chains and modules, fault and log capture, the WASI clock override a real guest observes, capability wiring, stream reconnect. These need a real compiled `.wasm` component over async mock backends and genuinely cannot be faked in-process. - **Do not test module business logic through the wasm harness.** If a -test only needs "given input X the strategy does Y", it belongs in `strategy.rs` against `MockHost`, not in a booted fixture here. A harness test that could be rewritten as a plain-Rust `MockHost` test without losing coverage is in the wrong place. +test only needs "given input X the module does Y", it belongs in the module's pure-logic file against `MockHost`, not in a booted fixture here. A harness test that could be rewritten as a plain-Rust `MockHost` test without losing coverage is in the wrong place. ## What `test-utils` provides @@ -101,4 +101,4 @@ module's WASI clock override; advance it to test time-dependent logic without a ## If you landed here looking for module tests -You want `nexum-sdk-test::MockHost` instead (plus `videre-test`'s transport mocks for venue-facing logic): no wasm build, no engine crate, runs in milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's `strategy.rs` test module for the pattern. +You want `nexum-sdk-test::MockHost` instead (plus `videre-test`'s transport mocks for venue-facing logic): no wasm build, no engine crate, runs in milliseconds. See [docs/sdk.md](sdk.md) and any shipped module's pure-logic test module for the pattern. diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index 1329878b..ad7b108a 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -89,9 +89,9 @@ every_n_blocks = "1" - `[capabilities.http].allow` is empty because `price-alert` makes no outbound HTTP. A module that needs it declares the `http` capability, lists the hosts it may contact, and calls `nexum_sdk::http::fetch`; an off-list host returns the matchable `FetchError::Denied`. See [`modules/examples/http-probe`](../modules/examples/http-probe). - `[config]` values are strings. `init` parses them into a typed `Settings`. -## The pure strategy +## The pure logic -Decision logic lives in `strategy.rs` as a host-generic function. It never names `wit-bindgen` or `wasmtime`, so tests drive it directly: +Decision logic lives in `logic.rs` as a host-generic function. It never names `wit-bindgen` or `wasmtime`, so tests drive it directly: ```rust use nexum_sdk::chain::chainlink::read_latest_answer; @@ -125,7 +125,7 @@ The shape to internalise: - Every interaction with the world goes through `host`, bounded by the traits the module actually imports (`ChainHost + LoggingHost` here, matching the two declared capabilities). - The function recovers from transient upstream failure by logging and returning `Ok`, so one bad event does not poison the supervisor. -Config parsing follows the same one-shot style: `parse_config(&[(String, String)]) -> Result`, using the `nexum_sdk::config` helpers (`get_required`, `scale_decimal`). See the full source in `strategy.rs`. +Config parsing follows the same one-shot style: `parse_config(&[(String, String)]) -> Result`, using the `nexum_sdk::config` helpers (`get_required`, `scale_decimal`). See the full source in `logic.rs`. ## The glue @@ -134,12 +134,12 @@ Config parsing follows the same one-shot style: `parse_config(&[(String, String) ```rust #![allow(clippy::too_many_arguments)] -mod strategy; +mod logic; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); struct PriceAlert; @@ -147,14 +147,14 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config)?; + let cfg = logic::parse_config(&config)?; let _ = SETTINGS.set(cfg); Ok(()) } fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()) }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) .map_err(Into::into) } } @@ -164,7 +164,7 @@ Apply the attribute to an inherent `impl` whose functions are the named handlers ## Tests against `MockHost` -Because the strategy is host-generic, tests run in plain Rust with no wasm toolchain, driving it against `nexum_sdk_test::MockHost` and capturing the log output: +Because the module logic is host-generic, tests run in plain Rust with no wasm toolchain, driving it against `nexum_sdk_test::MockHost` and capturing the log output: ```rust #[cfg(test)] @@ -187,7 +187,7 @@ mod tests { } ``` -Run with `cargo test -p price-alert`. See `strategy.rs` for the full test module, including the `MockHost` chain programming (`host.chain.respond_to`) and the throttle and error-path cases. +Run with `cargo test -p price-alert`. See `logic.rs` for the full test module, including the `MockHost` chain programming (`host.chain.respond_to`) and the throttle and error-path cases. Any behaviour expressible as "given this host state, do that" belongs here, not in the engine harness. See [testing-runtime-harness.md](testing-runtime-harness.md) for the guardrail between the two. From b49baa1fca6d2f72a9cb8fa9d8940372e0e0c583 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 10:15:04 +0000 Subject: [PATCH 134/139] chore: retire strategy vocabulary and banners in crates (#627) De-stale the word "strategy" in crate comments and doc comments to "keeper" (a keeper submits to venues) or "module" (the generic guest) as fits each context, leaving the proptest crate's own `Strategy` trait in nexum-sdk/src/proptests.rs untouched. Kill banner/section-divider line comments throughout crates/**, including test modules, per the umbrella hygiene sweep. Part of #625. --- crates/composable-cow/src/poll.rs | 6 ++---- crates/composable-cow/tests/run.rs | 12 ------------ crates/cow-venue/Cargo.toml | 2 +- crates/cow-venue/src/assembly.rs | 6 ------ crates/nexum-runtime/src/engine_config.rs | 1 - crates/nexum-runtime/src/host/http.rs | 5 ----- .../src/host/local_store_redb/tests.rs | 4 ---- crates/nexum-runtime/src/supervisor.rs | 2 +- crates/nexum-runtime/src/supervisor/tests.rs | 4 ++-- crates/nexum-sdk-test/src/lib.rs | 18 +----------------- crates/nexum-sdk/src/chain/mod.rs | 2 +- crates/nexum-sdk/src/chain/provider.rs | 2 +- crates/nexum-sdk/src/host.rs | 6 +++--- crates/nexum-sdk/src/http.rs | 2 +- crates/nexum-sdk/src/keeper.rs | 4 ++-- crates/nexum-sdk/src/lib.rs | 2 +- crates/nexum-sdk/src/wit_bindgen_macro.rs | 2 +- crates/nexum-sdk/tests/keeper.rs | 16 ---------------- crates/videre-sdk/src/keeper.rs | 3 --- crates/videre-test/src/transport.rs | 2 -- 20 files changed, 17 insertions(+), 84 deletions(-) diff --git a/crates/composable-cow/src/poll.rs b/crates/composable-cow/src/poll.rs index f909cfa3..68343772 100644 --- a/crates/composable-cow/src/poll.rs +++ b/crates/composable-cow/src/poll.rs @@ -1,8 +1,8 @@ //! ComposableCoW poll seam: the structured [`Verdict`] and the //! quarantined [`LegacyRevertAdapter`]. //! -//! Every strategy poll resolves to a [`Verdict`]; the keeper run and -//! strategy modules dispatch on its variants alone. The deployed +//! Every module poll resolves to a [`Verdict`]; the keeper run and +//! modules dispatch on its variants alone. The deployed //! ComposableCoW 1.x contract instead reverts with one of five custom //! errors; [`LegacyRevertAdapter`] decodes that wire onto a [`Verdict`] //! and is the single seam that retires when the structured generator @@ -247,8 +247,6 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } - // ---- LegacyRevertAdapter::classify ---- - use nexum_sdk::host::{Fault, RpcError}; fn rpc(data: Option>) -> ChainError { diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index 3c7462a6..ad2df22d 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -165,8 +165,6 @@ fn accepted() -> Result { Ok(SubmitOutcome::Accepted(vec![0xAA])) } -// ---- lifecycle outcomes ---- - #[test] fn try_next_block_leaves_the_store_untouched() { let host = MockHost::new(); @@ -253,8 +251,6 @@ fn invalid_removes_the_watch_and_its_gates() { assert!(host.store.is_empty(), "watch and gates must go"); } -// ---- gating and skipping ---- - #[test] fn gated_watch_is_not_polled() { let host = MockHost::new(); @@ -300,8 +296,6 @@ fn malformed_watch_rows_are_skipped() { assert_eq!(polls.get(), 0); } -// ---- ready -> submission ---- - #[test] fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); @@ -430,8 +424,6 @@ fn requires_signing_is_surfaced_and_not_journalled() { assert!(logs.any(|e| e.message.contains("requires signing"))); } -// ---- submission failure dispatch ---- - #[test] fn transient_fault_keeps_the_watch_ungated() { let host = MockHost::new(); @@ -694,8 +686,6 @@ fn restart_with_a_journalled_intent_does_not_repost() { ); } -// ---- the generic seam ---- - /// The seam proof: a `Post` reaches the transport as the encoded /// `CowIntentBody`, keyed on the generic submission key. #[test] @@ -725,8 +715,6 @@ fn ready_submits_the_encoded_intent_body_through_the_venue_seam() { ); } -// ---- #573 reserve/commit + top-of-sweep reconcile ---- - /// Models the CoW re-POST floor: a held body re-accepts, so a reconcile /// resubmit is always safe. A fresh body gets the programmed outcome, an /// accepted body joins the held set. Every POST is recorded. diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 9d635463..9a110ea4 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -8,7 +8,7 @@ description = "CoW venue slices, orderbook-only. The default `body` slice carrie [lib] # The default `body` slice is dependency-light so a venue adapter -# component or a strategy module can link the intent body types and +# component or a keeper module can link the intent body types and # codec without the host-side CoW machinery. The cdylib is the # `adapter` slice's component build (wasm32-wasip2). crate-type = ["lib", "cdylib"] diff --git a/crates/cow-venue/src/assembly.rs b/crates/cow-venue/src/assembly.rs index 35f64226..7f6745c4 100644 --- a/crates/cow-venue/src/assembly.rs +++ b/crates/cow-venue/src/assembly.rs @@ -215,8 +215,6 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } - // ---- order_data_to_body / body_to_order_data ---- - #[test] fn order_data_to_body_projects_every_field() { let g = submittable_gpv2(); @@ -268,8 +266,6 @@ mod tests { } } - // ---- order_uid_hex ---- - const SEPOLIA: u64 = 11_155_111; #[test] @@ -298,8 +294,6 @@ mod tests { assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); } - // ---- submission bodies ---- - #[test] fn presign_creation_carries_the_presign_scheme() { let order = gpv2_to_order_data(&submittable_gpv2()).expect("known markers"); diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index cea6c719..956f8f73 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -1236,7 +1236,6 @@ key = "value" assert_eq!(redact_url("not a url"), ""); } - // ----------------- env var substitution ----------------------- // // These tests stash + restore process env vars under unique names // so parallel `cargo test` runs don't trip on each other. diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index c156418a..57e00595 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -339,7 +339,6 @@ mod tests { )); } - // ----------------- SSRF-style bypass regressions (#57) --------- // // `http::Uri` resolves the authority per RFC 3986 before `admit` // ever sees a host string, so these are regression guards on the @@ -521,8 +520,6 @@ mod tests { ); } - // ----------------- timeout clamping ---------------------------- - fn config_with(timeout: Duration) -> OutgoingRequestConfig { OutgoingRequestConfig { use_tls: false, @@ -569,8 +566,6 @@ mod tests { assert_eq!(clamped.between_bytes_timeout, Duration::from_secs(5)); } - // ----------------- deadline + body cap ------------------------- - /// A detached executor for test-server tasks. fn test_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/crates/nexum-runtime/src/host/local_store_redb/tests.rs index 886228fb..cbc37c2a 100644 --- a/crates/nexum-runtime/src/host/local_store_redb/tests.rs +++ b/crates/nexum-runtime/src/host/local_store_redb/tests.rs @@ -248,9 +248,7 @@ fn quota_counts_across_short_lived_handles_of_one_namespace() { assert!(matches!(err, StorageError::QuotaExceeded { .. })); } -// --------------------------------------------------------------------------- // Atomic apply batches (#609). -// --------------------------------------------------------------------------- fn set_op(key: &str, value: &[u8]) -> WriteOp { WriteOp::Set { @@ -376,9 +374,7 @@ fn apply_quota_projects_the_last_op_per_key() { assert_eq!(ms.get("k").unwrap().as_deref(), Some(&b"ok"[..])); } -// --------------------------------------------------------------------------- // Concurrent access tests: real parallelism via the blocking pool. -// --------------------------------------------------------------------------- fn blocking_executor() -> nexum_tasks::TaskExecutor { nexum_tasks::TaskManager::new().executor() diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 3472cb9c..c7978d57 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -681,7 +681,7 @@ impl Supervisor { loaded_manifest.config.clone() }; // Whether `init` returned `Ok(())`. When `init` returns - // `Err(fault)` the module's strategy state (e.g. an + // `Err(fault)` the module's state (e.g. an // `OnceLock`) is left uninitialised. Existing M3 // example modules short-circuit on the missing state via // `SETTINGS.get().is_none() -> return Ok(())`, but future diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 174bc4a4..80b4dd58 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -573,7 +573,7 @@ chain_id = 1 // // One test per module that goes through the real wit-bindgen + // WitBindgenHost adapter + supervisor dispatch path, not just the -// strategy-level MockHost coverage. Mirrors the example-module e2e +// module-level MockHost coverage. Mirrors the example-module e2e // shape above; each test is guarded by `module_wasm_or_skip()` so // local runs without a fresh `--target wasm32-wasip2 --release` // build are skipped rather than failing. @@ -764,7 +764,7 @@ async fn init_failure_marks_module_dead_and_excludes_from_dispatch() { }; // Synthesise a manifest with the same shape as the real - // price-alert module but with a `threshold` that the strategy + // price-alert module but with a `threshold` that the module // rejects in `parse_config`. let dir = tempfile::tempdir().unwrap(); let manifest = dir.path().join("module.toml"); diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 81439b5a..7c637c70 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -1,5 +1,5 @@ //! In-memory [`nexum_sdk::host`] trait implementations plus assertion -//! helpers, so a module can test its strategy logic without wit-bindgen, +//! helpers, so a module can test its logic without wit-bindgen, //! wasmtime, or a network round-trip. //! //! [`MockHost`] composes the six per-seam mocks ([`MockChain`], @@ -132,8 +132,6 @@ impl LoggingHost for MockHost { } } -// ---------------------------------------------------------------- chain - /// In-memory [`ChainHost`] over a `(method, params)` response map; /// records every call. #[derive(Default)] @@ -201,8 +199,6 @@ impl ChainHost for MockChain { } } -// ---------------------------------------------------------------- identity - /// One recorded [`MockIdentity`] signing invocation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SignCall { @@ -287,8 +283,6 @@ impl IdentityHost for MockIdentity { } } -// ---------------------------------------------------------------- messaging - /// One recorded [`MessagingHost::publish`] invocation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PublishRecord { @@ -435,8 +429,6 @@ impl MessagingHost for MockMessaging { } } -// ---------------------------------------------------------------- remote-store - /// In-memory [`RemoteStoreHost`]: `keccak256`-addressed blobs plus /// mutable `(owner, topic)` feeds. Feed writes land under the mock's own /// owner ([`set_owner`](Self::set_owner), zero by default). @@ -515,8 +507,6 @@ impl RemoteStoreHost for MockRemoteStore { } } -// ---------------------------------------------------------------- local-store - /// In-memory [`LocalStoreHost`]: namespaced views over one shared row /// map, with store-wide entry and byte limits. /// [`namespaced`](Self::namespaced) derives a sibling view over the same @@ -715,8 +705,6 @@ impl LocalStoreHost for MockLocalStore { } } -// ---------------------------------------------------------------- trap store - /// Trap-injection wrapper over a [`LocalStoreHost`]: counts `set` and /// `delete` calls and, once armed, simulates a guest trap mid-flow. /// [`arm_after`](Self::arm_after)`(n)` lets the next `n` writes land; @@ -833,8 +821,6 @@ impl LocalStoreHost for TrapStore { } } -// ---------------------------------------------------------------- logging - /// One recorded log line. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LogLine { @@ -883,8 +869,6 @@ impl LoggingHost for MockLogging { } } -// ---------------------------------------------------------------- tracing capture - /// One tracing event captured pre-flattening. #[derive(Clone, Debug, PartialEq)] pub struct CapturedEvent { diff --git a/crates/nexum-sdk/src/chain/mod.rs b/crates/nexum-sdk/src/chain/mod.rs index dc8ff363..0534b8e7 100644 --- a/crates/nexum-sdk/src/chain/mod.rs +++ b/crates/nexum-sdk/src/chain/mod.rs @@ -1,4 +1,4 @@ -//! Chain access for guest strategies. +//! Chain access for guest modules. //! //! Chain identity (alloy [`Chain`]), the closed JSON-RPC read //! surface ([`ChainMethod`]), and the alloy provider seam: a diff --git a/crates/nexum-sdk/src/chain/provider.rs b/crates/nexum-sdk/src/chain/provider.rs index 21258381..27bba090 100644 --- a/crates/nexum-sdk/src/chain/provider.rs +++ b/crates/nexum-sdk/src/chain/provider.rs @@ -11,7 +11,7 @@ use super::{Chain, HostTransport}; use crate::host::ChainHost; /// Mints an alloy [`Provider`](alloy_provider::Provider) over -/// [`ChainHost::request`], so a strategy calls typed provider methods +/// [`ChainHost::request`], so a module calls typed provider methods /// instead of hand-building JSON-RPC. Blanket-implemented for every /// cloneable [`ChainHost`]; drive the returned futures with /// [`block_on`]. diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index 90913a4a..ff83751c 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -1,10 +1,10 @@ -//! Host traits, the seam between strategy logic and the wit-bindgen +//! Host traits, the seam between module logic and the wit-bindgen //! shims a module generates per-cdylib. Each trait mirrors one nexum //! host interface ([`ChainHost`], [`IdentityHost`], [`LocalStoreHost`], //! [`RemoteStoreHost`], [`MessagingHost`], [`LoggingHost`]); [`Host`] //! bundles all six. //! -//! Strategy logic written against these traits runs host-free against +//! Module logic written against these traits runs host-free against //! the `nexum-sdk-test` mocks. The traits are world-neutral over this //! module's [`Fault`], mirroring the per-module `Fault` that //! `wit_bindgen::generate!` emits, so modules wire a one-line converter @@ -279,7 +279,7 @@ pub fn reference_from_wire(raw: &[u8]) -> Result { }) } -/// Supertrait bundling all six core host interfaces. Strategy functions +/// Supertrait bundling all six core host interfaces. Module functions /// take `` (or bound exactly the interfaces they exercise) and /// run against `nexum_sdk_test::MockHost` in tests. Blanket-implemented /// for any type carrying all six; sealed, so that impl is the only one. diff --git a/crates/nexum-sdk/src/http.rs b/crates/nexum-sdk/src/http.rs index d4f38419..7aebce85 100644 --- a/crates/nexum-sdk/src/http.rs +++ b/crates/nexum-sdk/src/http.rs @@ -60,7 +60,7 @@ pub enum FetchError { Transport(String), } -/// Seam between strategy logic and the wasi:http transport; module glue +/// Seam between module logic and the wasi:http transport; module glue /// passes [`WasiFetch`], tests a stub. pub trait Fetch { /// Perform one request, blocking until the response body is fully diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 594ea550..8f663655 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -1,4 +1,4 @@ -//! Strategy-keeper stores: persistent-state conventions shared by +//! Keeper stores: persistent-state conventions shared by //! conditional-commitment modules, expressed over [`LocalStoreHost`] so //! they compile for any world and test against the in-memory mocks. //! @@ -435,7 +435,7 @@ pub trait Poller { /// watch value, passed verbatim for the source to decode. fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; - /// Short strategy name for log lines (e.g. `"twap"`). Diagnostic + /// Short keeper name for log lines (e.g. `"twap"`). Diagnostic /// only. fn label(&self) -> &'static str { "poller" diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 4a862764..cefaea94 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -10,7 +10,7 @@ //! Modules: //! - [`prelude`] - alloy primitive re-exports. //! - [`host`] - the [`Host`](host::Host) seam over the six core host interfaces, plus the [`Fault`](host::Fault) vocabulary. -//! - [`keeper`] - strategy-keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). +//! - [`keeper`] - keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). //! - [`chain`] - typed chain access and the alloy provider seam. //! - [`events`] - chain-log delivery. //! - [`config`] - config-table lookups and decimal scaling. diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index b98ac690..773425ca 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -37,7 +37,7 @@ macro_rules! bind_host_via_wit_bindgen { // capability. (caps: [$($cap:ident),* $(,)?]) => { /// Wraps the module's per-cdylib wit-bindgen imports so a - /// strategy can hold a `&impl Host`. + /// module can hold a `&impl Host`. struct WitBindgenHost; /// Lift the wit-bindgen `types.fault` into the SDK's `Fault`. diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 6388fc6c..1c85cc4e 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -18,8 +18,6 @@ fn sample_hash() -> B256 { b256!("0202020202020202020202020202020202020202020202020202020202020202") } -// ---- watch keys ---- - #[test] fn watch_key_is_lowercase_prefixed_hex() { let key = watch_key(&sample_owner(), &sample_hash()); @@ -74,8 +72,6 @@ fn parse_preserves_key_substrings_verbatim() { assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); } -// ---- watch-set registry ---- - #[test] fn put_get_list_round_trip() { let host = MockHost::new(); @@ -128,8 +124,6 @@ fn list_scans_only_the_watch_prefix() { assert_eq!(watches.list().unwrap(), vec![key]); } -// ---- atomic delete ---- - #[test] fn remove_drops_watch_and_all_gate_keys() { let host = MockHost::new(); @@ -212,8 +206,6 @@ fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { ); } -// ---- gates ---- - #[test] fn ready_with_no_gates_set() { let host = MockHost::new(); @@ -309,8 +301,6 @@ fn gate_fault_propagates_from_is_ready() { gates.is_ready(watch, 0, 0).unwrap_err(); } -// ---- journal ---- - #[test] fn journal_round_trips_a_receipt() { let host = MockHost::new(); @@ -351,8 +341,6 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(!snapshot.contains_key("observed:0xuid")); } -// ---- durable-effect journal ---- - #[test] fn mark_distinguishes_reserved_committed_absent_and_legacy() { let host = MockHost::new(); @@ -507,8 +495,6 @@ fn release_of_absent_is_a_no_op() { assert!(host.store.is_empty()); } -// ---- retry ledger ---- - fn seeded_watch(host: &MockHost) -> String { WatchSet::new(host) .put(&sample_owner(), &sample_hash(), b"params") @@ -706,8 +692,6 @@ fn retry_action_labels_are_stable_snake_case() { } } -// ---- conditional source ---- - /// The keeper passes stored params and the judged tick verbatim. #[test] fn poller_sees_params_and_tick_verbatim() { diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 73d15bd7..50b1bb25 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -676,8 +676,6 @@ mod tests { assert_eq!(report, RunReport::default()); } - // ---- #573 reserve/commit + top-of-sweep reconcile ---- - const STUB: &str = "stub"; /// The submit key the run reserves for `body` at the stub venue. @@ -1051,7 +1049,6 @@ mod tests { assert_ne!(mark(&host, b"order"), None); } - // ---- #609 trap-injection: convergence from every torn prefix ---- // // Review rule the sweep enforces: no in-store invariant may span // two `set` calls unless the intermediate state is self-healing or diff --git a/crates/videre-test/src/transport.rs b/crates/videre-test/src/transport.rs index 506ed073..56902e7b 100644 --- a/crates/videre-test/src/transport.rs +++ b/crates/videre-test/src/transport.rs @@ -66,8 +66,6 @@ impl Fetch for MockTransport { } } -// ------------------------------------------------------------ http - /// One recorded [`Fetch::fetch_with`] invocation. #[derive(Clone, Debug)] pub struct RecordedRequest { From 1f2ab8225b72e13eb2df350bb7e68eb9342fbd27 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 10:15:52 +0000 Subject: [PATCH 135/139] chore: retire strategy vocabulary and banners in modules (#628) Rename the modules' pure-logic files off the stale strategy name: ethflow-watcher submits observations to venues so its logic is keeper.rs, while the read-only balance-tracker, http-probe and price-alert examples become logic.rs. Update every mod declaration, path reference, module.toml and Cargo.toml comment to match. De-stale the word strategy in module comments and doc comments to keeper (ethflow-watcher, twap-monitor) or module (generic), and kill the banner divider comments throughout modules. --- modules/ethflow-watcher/module.toml | 2 +- .../src/{strategy.rs => keeper.rs} | 22 ++++--------------- modules/ethflow-watcher/src/lib.rs | 12 +++++----- modules/examples/balance-tracker/src/lib.rs | 10 ++++----- .../src/{strategy.rs => logic.rs} | 8 +------ modules/examples/http-probe/src/lib.rs | 10 ++++----- .../http-probe/src/{strategy.rs => logic.rs} | 2 +- modules/examples/price-alert/Cargo.toml | 2 +- modules/examples/price-alert/src/lib.rs | 10 ++++----- .../price-alert/src/{strategy.rs => logic.rs} | 10 +++------ modules/twap-monitor/src/keeper.rs | 10 --------- 11 files changed, 32 insertions(+), 66 deletions(-) rename modules/ethflow-watcher/src/{strategy.rs => keeper.rs} (97%) rename modules/examples/balance-tracker/src/{strategy.rs => logic.rs} (98%) rename modules/examples/http-probe/src/{strategy.rs => logic.rs} (99%) rename modules/examples/price-alert/src/{strategy.rs => logic.rs} (97%) diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index cb99e038..98b88cae 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -25,7 +25,7 @@ allow = [] # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, # bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"), # pinned in wit/shepherd-cow/cow-events.wit and parity-tested in -# strategy.rs. +# keeper.rs. # `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from # `cowprotocol/ethflowcontract/networks.prod.json`. Unlike # ComposableCoW's CREATE2 address, EthFlow has had multiple per-network diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/keeper.rs similarity index 97% rename from modules/ethflow-watcher/src/strategy.rs rename to modules/ethflow-watcher/src/keeper.rs index ec3fbb80..56bca16f 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/keeper.rs @@ -1,4 +1,4 @@ -//! Pure strategy logic for the ethflow-watcher module. +//! Pure keeper logic for the ethflow-watcher module. //! //! Observes EthFlow placements through the venue registry: decode the //! `OrderPlacement` log, compute the orderbook UID (the receipt at the @@ -85,8 +85,6 @@ pub fn on_intent_status( Ok(()) } -// ---- decode ---- - /// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. /// `None` when the contract address is neither `ETH_FLOW_PRODUCTION` /// nor `ETH_FLOW_STAGING`, topic-0 misses the `shepherd:cow/cow-events` @@ -109,8 +107,6 @@ pub(crate) fn decode_order_placement(log: &Log) -> Option { }) } -// ---- observe + verify (venue registry) ---- - /// Compute the orderbook UID and put it under the host's status watch. /// A refused observe stays unjournalled, so re-delivery retries. async fn observe_placement( @@ -262,7 +258,7 @@ mod tests { } } - /// Drive the async strategy on the synchronous test boundary. + /// Drive the async keeper on the synchronous test boundary. fn run_logs( host: &MockHost, spy: &SpyVenues, @@ -349,8 +345,6 @@ mod tests { compute_uid(SEPOLIA, &placement).expect("sepolia + canonical markers") } - // ---- decode (invariants preserved) ---- - #[test] fn decodes_well_formed_placement() { let event = sample_event(); @@ -383,8 +377,6 @@ mod tests { assert!(decode_order_placement(&log).is_none()); } - // ---- UID computation ---- - #[test] fn compute_uid_pins_owner_to_ethflow_contract_and_validto() { let event = sample_event(); @@ -405,8 +397,6 @@ mod tests { assert!(compute_uid(9999, &decoded).is_none()); } - // ---- observe via the venue registry (transport integration) ---- - /// A placement registers one cow status watch keyed on the computed /// UID, and journals nothing until the first status transition. #[test] @@ -488,7 +478,7 @@ mod tests { /// Observer-only: no call path reaches quote, submit, status, or /// cancel. #[test] - fn strategy_never_submits() { + fn keeper_never_submits() { let host = MockHost::new(); let spy = SpyVenues::default(); @@ -496,12 +486,10 @@ mod tests { assert!( spy.calls().iter().all(|c| matches!(c, Call::Observe(..))), - "observe is the only verb the strategy may use", + "observe is the only verb the keeper may use", ); } - // ---- intent-status transitions ---- - /// The first cow transition journals `observed:{uid}` and logs it. #[test] fn status_update_journals_the_observed_marker() { @@ -570,8 +558,6 @@ mod tests { assert!(host.store.snapshot().is_empty()); } - // ---- package-of-record parity ---- - /// The `sol!` decoder's topic-0 matches the /// `shepherd:cow/cow-events` pin; a drift would silently miss every /// EthFlow event. diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 41077767..5f4864ae 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -5,7 +5,7 @@ //! and puts it under the host's status watch; the registry polls the cow //! adapter and fans transitions back as `intent-status` events, journalled //! as `observed:{uid}`. Observe-only, never submits. Pure logic lives in -//! `strategy`; `lib.rs` is the `#[videre_sdk::keeper]` glue. +//! `keeper`; `lib.rs` is the `#[videre_sdk::keeper]` glue. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -15,19 +15,19 @@ // The keeper glue only resolves against the engine's wasm component // host. Cfg-gate it so a native build of this crate carries just the -// strategy code without dangling `extern "C"` imports; the +// keeper code without dangling `extern "C"` imports; the // `use wit_bindgen as _` line silences the unused-crate lint on native // targets where the macro never expands. #[cfg(not(target_arch = "wasm32"))] use wit_bindgen as _; -pub mod strategy; +pub mod keeper; #[cfg(target_arch = "wasm32")] mod glue { use cow_venue::client::CowClient; - use crate::strategy; + use crate::keeper; struct EthFlowWatcher; @@ -42,13 +42,13 @@ mod glue { async fn on_chain_logs(batch: nexum::host::types::ChainLogs) -> Result<(), Fault> { let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) + keeper::on_chain_logs(&WitBindgenHost, &CowClient::new(), batch.chain_id, &logs) .await?; Ok(()) } fn on_intent_status(update: videre_sdk::IntentStatusUpdate) -> Result<(), Fault> { - strategy::on_intent_status( + keeper::on_intent_status( &WitBindgenHost, &update.venue, &update.receipt, diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 725856ac..766d52fa 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -3,19 +3,19 @@ //! On each block reads `eth_getBalance` for every `[config].addresses` //! entry, persists the last value under `balance:{addr}`, and warns //! when a balance moves more than `[config].change_threshold` wei. -//! Pure logic lives in `strategy`; `lib.rs` is the +//! Pure logic lives in `logic`; `lib.rs` is the //! `#[nexum_sdk::module]` glue. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -mod strategy; +mod logic; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); // `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the @@ -26,7 +26,7 @@ struct BalanceTracker; impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config)?; + let cfg = logic::parse_config(&config)?; tracing::info!( "balance-tracker init: {} addresses, threshold={} wei", cfg.addresses.len(), @@ -40,6 +40,6 @@ impl BalanceTracker { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) + logic::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(Into::into) } } diff --git a/modules/examples/balance-tracker/src/strategy.rs b/modules/examples/balance-tracker/src/logic.rs similarity index 98% rename from modules/examples/balance-tracker/src/strategy.rs rename to modules/examples/balance-tracker/src/logic.rs index eb2f6016..c475eec2 100644 --- a/modules/examples/balance-tracker/src/strategy.rs +++ b/modules/examples/balance-tracker/src/logic.rs @@ -1,4 +1,4 @@ -//! Pure strategy logic for the balance-tracker module. +//! Pure logic for the balance-tracker module. //! //! World access flows through the [`ChainHost`] + [`LocalStoreHost`] //! trait seam; `lib.rs` hands [`on_block`] a `WitBindgenHost`, tests a @@ -73,8 +73,6 @@ fn fetch_balance(host: &H, chain_id: u64, addr: Address) -> Result }) } -// ---- pure helpers (unit-testable, no host) ------------------------ - /// Parse the `"0x..."` JSON string `eth_getBalance` returns; `None` on /// shape mismatch. fn parse_balance_hex(result_json: &str) -> Option { @@ -144,8 +142,6 @@ mod tests { const SEPOLIA: u64 = 11_155_111; - // ---- pure helpers ---- - #[test] fn parse_balance_hex_decodes_canonical_response() { // 0x16345785d8a0000 = 100_000_000_000_000_000 = 0.1 ETH. @@ -229,8 +225,6 @@ mod tests { assert!(message.contains("change_threshold")); } - // ---- MockHost-driven coverage of check_one / fetch_balance ---- - fn one_addr_settings(threshold_wei: u128) -> Settings { Settings { addresses: vec![address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")], diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index 07c3786d..7290114b 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -4,7 +4,7 @@ //! over wasi:http and logs its status, then fetches `[config].denied_url` //! and asserts the `[capabilities.http].allow` gate denies it. //! Demonstrates the SDK `http::Fetch` seam and the allowlist denial -//! path. Pure logic lives in `strategy`; `lib.rs` is the +//! path. Pure logic lives in `logic`; `lib.rs` is the //! `#[nexum_sdk::module]` glue. // wit_bindgen::generate! expands to host-import shims whose arity matches @@ -12,13 +12,13 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -mod strategy; +mod logic; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); // The fault `From` impls and `install_tracing` (used by the handlers) are // generated by the attribute alongside the wit-bindgen call and the @@ -29,7 +29,7 @@ struct HttpProbe; impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config)?; + let cfg = logic::parse_config(&config)?; tracing::info!( "http-probe init: probe_url={} denied_url={} every_n_blocks={}", cfg.probe_url, @@ -44,6 +44,6 @@ impl HttpProbe { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) + logic::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/http-probe/src/strategy.rs b/modules/examples/http-probe/src/logic.rs similarity index 99% rename from modules/examples/http-probe/src/strategy.rs rename to modules/examples/http-probe/src/logic.rs index 0463a14c..374c0178 100644 --- a/modules/examples/http-probe/src/strategy.rs +++ b/modules/examples/http-probe/src/logic.rs @@ -1,4 +1,4 @@ -//! Pure strategy logic for the http-probe module. +//! Pure logic for the http-probe module. //! //! HTTP flows through the [`Fetch`] seam; `lib.rs` hands [`on_block`] //! `nexum_sdk::http::WasiFetch`, tests hand it a stub fetcher. diff --git a/modules/examples/price-alert/Cargo.toml b/modules/examples/price-alert/Cargo.toml index 30bb5280..c31f9385 100644 --- a/modules/examples/price-alert/Cargo.toml +++ b/modules/examples/price-alert/Cargo.toml @@ -17,6 +17,6 @@ wit-bindgen = { version = "0.59", default-features = false, features = ["macros" [dev-dependencies] nexum-sdk-test = { path = "../../../crates/nexum-sdk-test" } -# Only used by tests in `strategy.rs` to encode a synthetic oracle +# Only used by tests in `logic.rs` to encode a synthetic oracle # return body; the production code uses `nexum_sdk::chain::chainlink`. alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index 7ca316ea..a9d16b35 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -3,7 +3,7 @@ //! Polls a Chainlink oracle on each block and warns when the price //! crosses a `[config]`-supplied threshold on the configured side. //! Demonstrates `chain::request` with an `alloy_sol_types` ABI decode -//! and the `nexum_sdk::chain` helpers. Pure logic lives in `strategy`; +//! and the `nexum_sdk::chain` helpers. Pure logic lives in `logic`; //! `lib.rs` is the `#[nexum_sdk::module]` glue. // wit_bindgen::generate! expands to host-import shims whose arity matches @@ -11,13 +11,13 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -mod strategy; +mod logic; use std::sync::OnceLock; use nexum::host::types; -static SETTINGS: OnceLock = OnceLock::new(); +static SETTINGS: OnceLock = OnceLock::new(); // `WitBindgenHost`, the fault `From` impls, and `install_tracing` (used // by the handlers) are generated by the attribute alongside the @@ -28,7 +28,7 @@ struct PriceAlert; impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); - let cfg = strategy::parse_config(&config)?; + let cfg = logic::parse_config(&config)?; tracing::info!( "price-alert init: oracle={:#x} threshold={} direction={:?} every_n_blocks={}", cfg.oracle_address, @@ -44,6 +44,6 @@ impl PriceAlert { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) + logic::on_block(&WitBindgenHost, block.chain_id, cfg, block.number).map_err(Into::into) } } diff --git a/modules/examples/price-alert/src/strategy.rs b/modules/examples/price-alert/src/logic.rs similarity index 97% rename from modules/examples/price-alert/src/strategy.rs rename to modules/examples/price-alert/src/logic.rs index 64a52126..3c5c0bcb 100644 --- a/modules/examples/price-alert/src/strategy.rs +++ b/modules/examples/price-alert/src/logic.rs @@ -1,4 +1,4 @@ -//! Pure strategy logic for the price-alert module. +//! Pure logic for the price-alert module. //! //! World access flows through the [`ChainHost`] + [`LoggingHost`] trait //! seam, the module's two declared capabilities; `lib.rs` hands @@ -178,8 +178,6 @@ mod tests { host.chain.respond_to("eth_call", ¶ms, response); } - // ---- pure helpers ---- - #[test] fn classify_below_fires_at_or_under_threshold() { let t = I256::try_from(100_i32).unwrap(); @@ -276,8 +274,6 @@ mod tests { assert!(message.contains("oracle_address")); } - // ---- strategy behaviour against MockHost ---- - #[test] fn on_block_idle_when_price_above_below_trigger() { let host = MockHost::new(); @@ -346,13 +342,13 @@ mod tests { Err(ChainError::Fault(Fault::Timeout)), ); - // Strategy returns Ok so the supervisor moves on. + // The module returns Ok so the supervisor moves on. let (result, logs) = capture_tracing(|| on_block(&host, 11_155_111, &settings, 100)); result.unwrap(); // The oracle-read failure is logged by the SDK chainlink helper // through the host logging call, so it lands on `host.logging`. assert!(host.logging.contains("eth_call failed")); - // No facade event at all: the strategy returns before emitting + // No facade event at all: the module returns before emitting // either the ok or TRIGGERED line. assert!(logs.is_empty()); } diff --git a/modules/twap-monitor/src/keeper.rs b/modules/twap-monitor/src/keeper.rs index c718a2da..c56b7979 100644 --- a/modules/twap-monitor/src/keeper.rs +++ b/modules/twap-monitor/src/keeper.rs @@ -84,8 +84,6 @@ where run(host, venue, &TwapSource, &tick) } -// ---- indexing path ---- - /// Chain position of a mined log, ordered as the chain orders logs. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] struct LogPosition { @@ -209,8 +207,6 @@ fn remove_watch( Ok(()) } -// ---- poll path ---- - /// TWAP conditional source: decode the stored row and evaluate /// `getTradeableOrderWithSignature` on chain. An undecodable row polls /// again next block rather than tearing down the run. @@ -322,8 +318,6 @@ fn outcome_label(o: &Verdict) -> &'static str { } } -// ---- test-only seam mirrors ---- - #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { let watch = WatchRef::parse(key)?; @@ -484,8 +478,6 @@ mod tests { } } - // ---- existing pure tests ---- - #[test] fn decodes_well_formed_log() { let owner = address!("00112233445566778899aabbccddeeff00112233"); @@ -553,8 +545,6 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - // ---- MockHost + MockVenue dispatch tests ---- - /// Chain position shorthand for the log builders. fn at(block: u64, index: u64) -> LogPosition { LogPosition { block, index } From 37b82c6f8a53f919bbee8afed19fe02663bc08a8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 12:28:21 +0000 Subject: [PATCH 136/139] sdk: journal guard combinator enforces reserve-before-effect (#629) Add Journal::guard to the nexum-sdk keeper: it reserves the submission key with the encoded body, runs the async submit closure, then disposes of the reservation as the closure's Disposition directs (Commit on accept, Release on a known non-accept, Park on a rate-limit hint, Retain for reconcile). The reserve-effect-commit order is fixed by the API shape, so a caller cannot run the effect unreserved or leave the marker unsettled; an existing marker short-circuits without running the closure. The combinator factors out the pattern the videre-sdk keeper run and the composable-cow run already inline, without changing either; markers stay on plain set. Tests cover the combinator semantics over the mock store and drive it through the reserve/commit/reconcile path with the FlakyCommit and counting-venue fixtures, asserting exactly-once and no stranded reservation. --- crates/nexum-sdk/src/keeper.rs | 82 +++++++++++++- crates/nexum-sdk/tests/keeper.rs | 138 ++++++++++++++++++++++- crates/videre-sdk/src/keeper.rs | 182 ++++++++++++++++++++++++++++++- 3 files changed, 397 insertions(+), 5 deletions(-) diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 8f663655..9135489b 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -7,7 +7,8 @@ //! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a u64 //! little-endian threshold, with an [`is_ready`](Gates::is_ready) //! predicate. -//! - [`Journal`] - receipt-keyed idempotency journal. +//! - [`Journal`] - receipt-keyed idempotency journal, with the +//! [`guard`](Journal::guard) reserve-effect-commit combinator. //! - [`Poller`] - world-neutral poll seam: one watch in, one outcome //! out at a [`Tick`]. //! - [`Retrier`] - runs a [`RetryAction`] through the stores after a @@ -67,6 +68,8 @@ //! # Ok::<(), Fault>(()) //! ``` +use std::future::Future; + use alloy_primitives::{Address, B256}; use strum::IntoStaticStr; @@ -407,6 +410,83 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { } Ok(out) } + + /// Guard one durable effect: reserve `key` with `body`, run + /// `submit`, then dispose of the reservation as the closure's + /// [`Disposition`] directs. The reserve-effect-commit order is + /// fixed by this shape, so a caller cannot run the effect + /// unreserved or leave the marker unsettled. + /// + /// An existing marker short-circuits without running the closure: + /// COMMITTED is an idempotent duplicate, RESERVED is owned by the + /// reconcile pass. A commit-write fault is tolerated (the effect + /// landed; the marker stays RESERVED for reconcile); release and + /// park faults propagate. + /// + /// The caller chooses the `Disposition` per outcome, so `guard` + /// fixes the ordering, not the retry policy. `Keeper::run`'s + /// fresh-submit arm releases on every venue error, so wiring `guard` + /// there must supply that policy, not the reconcile pass's park. + pub async fn guard( + &self, + key: &str, + body: &[u8], + submit: F, + ) -> Result, Fault> + where + F: FnOnce() -> Fut, + Fut: Future, + { + if let Some(mark) = self.mark(key)? { + return Ok(Guarded::Skipped(mark)); + } + self.reserve(key, body)?; + let (disposition, outcome) = submit().await; + match disposition { + Disposition::Commit => { + // Best-effort: the effect is durable upstream, so a + // commit fault leaves the marker RESERVED for the next + // reconcile pass, never an abort. + if let Err(fault) = self.commit(key) { + tracing::error!(%key, %fault, "commit write failed; reconcile owns the marker"); + } + } + Disposition::Release => self.release(key)?, + Disposition::Park { until } => self.park(key, body, until)?, + Disposition::Retain => {} + } + Ok(Guarded::Ran(outcome)) + } +} + +/// How a guarded submit's outcome disposes of its reservation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Disposition { + /// Accepted: the effect is durable, commit the marker. + Commit, + /// Known synchronous non-accept (requires-signing or a terminal + /// refusal): release the marker. + Release, + /// Retryable refusal with a window: re-park the marker. + Park { + /// Earliest Unix-seconds a retry is eligible. + until: u64, + }, + /// Outcome unknown: leave the marker RESERVED for reconcile. + Retain, +} + +/// What [`Journal::guard`] did with a submission. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Guarded { + /// The submit closure ran; the reservation was disposed as the + /// closure directed. + Ran(T), + /// An existing marker skipped the closure: [`Mark::Committed`] is + /// an idempotent duplicate, [`Mark::Reserved`] is owned by the + /// reconcile pass. + Skipped(Mark), } /// One poll dispatch's world view: chain, block height, block clock. diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 1c85cc4e..a67ab99e 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -5,8 +5,9 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - Gates, Journal, Mark, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, REFUSED_PREFIX, - Reservation, Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, + Disposition, Gates, Guarded, Journal, Mark, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Poller, + REFUSED_PREFIX, Reservation, Retrier, RetryAction, Tick, WATCH_PREFIX, WatchRef, WatchSet, + watch_key, }; use nexum_sdk_test::MockHost; @@ -731,3 +732,136 @@ fn poller_sees_params_and_tick_verbatim() { assert_eq!(epoch_s, 1_700_000_000); assert_eq!(echoed, key); } + +/// Drive a guard future on the test's synchronous boundary. +fn drive(future: F) -> F::Output { + let mut future = std::pin::pin!(future); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + match future.as_mut().poll(&mut cx) { + std::task::Poll::Ready(output) => output, + std::task::Poll::Pending => panic!("guard futures complete in one poll"), + } +} + +#[test] +fn guard_reserves_before_the_effect_and_commits_after() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { + // The reservation is durable before the effect runs. + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); + (Disposition::Commit, 7u32) + })) + .unwrap(); + + assert_eq!(out, Guarded::Ran(7)); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Committed)); + assert!(journal.pending().unwrap().is_empty()); +} + +#[test] +fn guard_skips_a_committed_marker_without_running_the_closure() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.commit("uid").unwrap(); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Skipped(Mark::Committed)); + assert!(!ran.get(), "a COMMITTED marker must not re-run the effect"); +} + +#[test] +fn guard_skips_a_reserved_marker_for_reconcile() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + journal.reserve("uid", b"body").unwrap(); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Skipped(Mark::Reserved)); + assert!(!ran.get(), "a RESERVED marker is owned by reconcile"); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); +} + +#[test] +fn guard_releases_on_a_known_non_accept() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = + drive(journal.guard("uid", b"body", || async { (Disposition::Release, ()) })).unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!(journal.mark("uid").unwrap(), None); + assert!(journal.pending().unwrap().is_empty()); +} + +#[test] +fn guard_parks_a_retryable_outcome_with_its_body() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { + (Disposition::Park { until: 1_234 }, ()) + })) + .unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!( + journal.pending().unwrap(), + vec![Reservation { + key: "uid".into(), + next_eligible: 1_234, + body: b"body".to_vec(), + }], + ); +} + +#[test] +fn guard_retains_an_unknown_outcome_reserved() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + let out = drive(journal.guard("uid", b"body", || async { (Disposition::Retain, ()) })).unwrap(); + + assert_eq!(out, Guarded::Ran(())); + assert_eq!(journal.mark("uid").unwrap(), Some(Mark::Reserved)); + assert_eq!( + journal.pending().unwrap(), + vec![Reservation { + key: "uid".into(), + next_eligible: 0, + body: b"body".to_vec(), + }], + "the body must stay enumerable for reconcile", + ); +} + +#[test] +fn guard_propagates_a_reserve_fault_without_running_the_closure() { + let host = MockHost::new(); + host.store + .fail_on("submitted:", Fault::Unavailable("store down".into())); + let journal = Journal::submitted(&host); + + let ran = std::cell::Cell::new(false); + let out = drive(journal.guard("uid", b"body", || async { + ran.set(true); + (Disposition::Commit, ()) + })); + + assert!(out.is_err(), "a reserve fault must abort the guard"); + assert!(!ran.get(), "no effect may run unreserved"); +} diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 50b1bb25..4cbf8385 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -343,11 +343,14 @@ mod tests { use std::collections::HashSet; use nexum_sdk::host::{Fault, LocalStoreHost as _}; - use nexum_sdk::keeper::{Gates, Journal, Mark, Tick, WatchRef, WatchSet}; + use nexum_sdk::keeper::{Disposition, Gates, Guarded, Journal, Mark, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::{MockLocalStore, TrapStore}; - use super::{Keeper, Outcome, RunReport, submission_key}; + use super::{ + DEFAULT_RECONCILE_BUDGET, Keeper, Outcome, RunReport, is_terminal, reconcile, + submission_key, + }; use crate::client::{VenueId, VenueTransport}; use crate::{IntentStatus, Quotation, SubmitOutcome, UnsignedTx, VenueFault}; @@ -1128,4 +1131,179 @@ mod tests { assert_eq!(venue.held_count(), 1, "prefix {n}: still one held order"); } } + + /// One guarded submit of `body` through `venue`, mapping the venue + /// outcome onto the disposition the run inlines: accept commits, + /// requires-signing and terminal refusals release, a rate-limit + /// hint parks, anything else stays RESERVED for reconcile. + async fn guarded_submit( + journal: &Journal<'_, H>, + venue: &CountingVenue, + body: &[u8], + tick: &Tick, + ) -> Result>, Fault> { + let key = stub_key(body); + journal + .guard(&key, body, move || async move { + let outcome = venue + .submit(&VenueId::from_static(STUB), body.to_vec()) + .await; + let disposition = match &outcome { + Ok(SubmitOutcome::Accepted(_)) => Disposition::Commit, + Ok(SubmitOutcome::RequiresSigning(_)) => Disposition::Release, + Err(VenueFault::RateLimited { + retry_after_ms: Some(ms), + }) => Disposition::Park { + until: tick.epoch_s.saturating_add(ms.div_ceil(1000)), + }, + Err(fault) if is_terminal(fault) => Disposition::Release, + Err(_) => Disposition::Retain, + }; + (disposition, outcome) + }) + .await + } + + #[test] + fn guard_reserve_commit_round_trip_is_exactly_once() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::accepting(); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(Ok(SubmitOutcome::Accepted(_))))); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + assert!(journal.pending().expect("journal reads").is_empty()); + + // A repeat is an idempotent skip: no second POST, one held order. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Skipped(Mark::Committed))); + assert_eq!(venue.post_count(), 1); + assert_eq!(venue.held_count(), 1); + } + + #[test] + fn guard_commit_fault_strands_reserved_and_reconcile_heals_exactly_once() { + let host = FlakyCommit::new(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::accepting(); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)) + .expect("a commit fault must not abort the guard"); + assert!(matches!(out, Guarded::Ran(Ok(SubmitOutcome::Accepted(_))))); + // The commit write faulted: RESERVED stays, never released. + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // The next tick's reconcile pass re-posts, the venue dedups, + // the commit lands: one held order, nothing stranded. + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &TICK, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(venue.held_count(), 1, "one held order despite two POSTs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + assert!(journal.pending().expect("journal reads").is_empty()); + } + + #[test] + fn guard_requires_signing_releases_the_reservation() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let tx = UnsignedTx { + chain: 1, + to: vec![0x11; 20], + value: Vec::new(), + data: vec![0xFE], + }; + let venue = CountingVenue::new(Ok(SubmitOutcome::RequiresSigning(tx))); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!( + out, + Guarded::Ran(Ok(SubmitOutcome::RequiresSigning(_))) + )); + assert_eq!(mark(&host, b"order"), None); + assert!(journal.pending().expect("journal reads").is_empty()); + + // Nothing journalled: a later guard re-poses the same submit. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(_))); + assert_eq!(venue.post_count(), 2); + } + + #[test] + fn guard_terminal_refusal_releases_the_reservation() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::Denied("blocked".into()))); + + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Ran(Err(_)))); + assert_eq!(mark(&host, b"order"), None); + assert!(journal.pending().expect("journal reads").is_empty()); + } + + #[test] + fn guard_rate_limit_parks_until_the_hint_then_reconcile_reposts() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_000), + })); + + run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + assert_eq!(venue.post_count(), 1); + + // Inside the window the reconcile pass gates the reservation. + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &TICK, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.gated, 1); + assert_eq!(venue.post_count(), 1); + + // Past the window it re-posts; the venue accepts, commit lands. + *venue.outcome.borrow_mut() = Ok(SubmitOutcome::Accepted(vec![0xAB])); + let at_2 = Tick { + epoch_s: TICK.epoch_s + 2, + ..TICK + }; + let report = run(reconcile( + &VenueId::from_static(STUB), + &&venue, + &journal, + &at_2, + DEFAULT_RECONCILE_BUDGET, + )) + .expect("reconcile runs"); + assert_eq!(report.committed, 1); + assert_eq!(venue.post_count(), 2); + assert_eq!(mark(&host, b"order"), Some(Mark::Committed)); + } + + #[test] + fn guard_unknown_outcome_stays_reserved_for_reconcile() { + let host = MockLocalStore::default(); + let journal = Journal::submitted(&host); + let venue = CountingVenue::new(Err(VenueFault::Unavailable("down".into()))); + + run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert_eq!(mark(&host, b"order"), Some(Mark::Reserved)); + + // A second guard never double-posts: reconcile owns the marker. + let out = run(guarded_submit(&journal, &venue, b"order", &TICK)).expect("guard runs"); + assert!(matches!(out, Guarded::Skipped(Mark::Reserved))); + assert_eq!(venue.post_count(), 1); + } } From d2a5cc0b8ccef3de21a45296477bfa5e69d281d6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sat, 25 Jul 2026 12:29:29 +0000 Subject: [PATCH 137/139] sdk: typed local-store helpers over the apply seam (#630) Expose the atomic batch verb on the seam: LocalStoreHost gains WriteOp and apply, whose default is an explicit per-op set/delete fallback for arbitrary impls such as mocks, and the WitBindgenHost adapter overrides it with the host's apply verb so a flushed batch is all-or-nothing on the real host. Add nexum_sdk::store with the #609 Wave 2 helpers: a WriteBatch builder flushed in one apply call, clear_prefix, borsh-typed TypedCell and TypedMap, and a u64 Counter, so module code hand-rolls neither serialization nor batching. --- Cargo.lock | 1 + crates/nexum-sdk/Cargo.toml | 2 + crates/nexum-sdk/src/host.rs | 79 ++++++++ crates/nexum-sdk/src/lib.rs | 2 + crates/nexum-sdk/src/store.rs | 228 ++++++++++++++++++++++ crates/nexum-sdk/src/wit_bindgen_macro.rs | 26 +++ crates/nexum-sdk/tests/store.rs | 135 +++++++++++++ 7 files changed, 473 insertions(+) create mode 100644 crates/nexum-sdk/src/store.rs create mode 100644 crates/nexum-sdk/tests/store.rs diff --git a/Cargo.lock b/Cargo.lock index 9a1416af..f686c938 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3685,6 +3685,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "alloy-transport", + "borsh", "http", "nexum-module-macros", "nexum-sdk-test", diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 844b50c6..4e3ddd46 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -34,6 +34,8 @@ alloy-chains.workspace = true # assembled from the WIT record at the binding edge (see `events`). alloy-rpc-types-eth.workspace = true alloy-sol-types.workspace = true +# The `store` helpers' value codec (`TypedCell`, `TypedMap`, `Counter`). +borsh.workspace = true # The provider seam: `HostTransport` speaks alloy's JSON-RPC packet # vocabulary over `ChainHost::request`, and `provider()` fronts it with # a `RootProvider`. Featureless, so no ws/ipc/reqwest transport reaches diff --git a/crates/nexum-sdk/src/host.rs b/crates/nexum-sdk/src/host.rs index ff83751c..2621d5ef 100644 --- a/crates/nexum-sdk/src/host.rs +++ b/crates/nexum-sdk/src/host.rs @@ -160,6 +160,24 @@ pub trait ChainHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } +/// One write in a [`LocalStoreHost::apply`] batch, mirrored from +/// `nexum:host/local-store.write-op`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum WriteOp { + /// Insert or overwrite `key` with `value`. + Set { + /// Key to write. + key: String, + /// Value bytes. + value: Vec, + }, + /// Delete `key`; a no-op if absent. + Delete { + /// Key to delete. + key: String, + }, +} + /// `nexum:host/local-store` - per-module key-value persistence. pub trait LocalStoreHost { /// Fetch a value. `Ok(None)` when the key is absent. @@ -170,6 +188,21 @@ pub trait LocalStoreHost { fn delete(&self, key: &str) -> Result<(), Fault>; /// Enumerate keys whose raw form starts with `prefix`. fn list_keys(&self, prefix: &str) -> Result, Fault>; + /// Apply a batch of writes; later ops on a key supersede earlier + /// ones. Atomic (every op lands or none does) only on the real + /// host adapter, which overrides this with the host's `apply` + /// verb; the default is a per-op `set`/`delete` fallback for + /// arbitrary impls such as mocks, so a mid-batch failure leaves + /// the earlier ops applied. + fn apply(&self, ops: &[WriteOp]) -> Result<(), Fault> { + for op in ops { + match op { + WriteOp::Set { key, value } => self.set(key, value)?, + WriteOp::Delete { key } => self.delete(key)?, + } + } + Ok(()) + } /// Whether `key` exists. fn contains(&self, key: &str) -> Result { Ok(self.get(key)?.is_some()) @@ -346,6 +379,52 @@ mod tests { assert_eq!(TwoRows.count("z").unwrap(), 0); } + #[test] + fn local_store_default_apply_falls_back_to_one_call_per_op() { + use std::cell::RefCell; + + use super::{LocalStoreHost, WriteOp}; + + /// Records each call; only the four required methods are written. + #[derive(Default)] + struct Recorder(RefCell>); + impl LocalStoreHost for Recorder { + fn get(&self, _: &str) -> Result>, Fault> { + Ok(None) + } + fn set(&self, key: &str, _: &[u8]) -> Result<(), Fault> { + self.0.borrow_mut().push(format!("set {key}")); + Ok(()) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.borrow_mut().push(format!("delete {key}")); + Ok(()) + } + fn list_keys(&self, _: &str) -> Result, Fault> { + Ok(Vec::new()) + } + } + + let recorder = Recorder::default(); + recorder + .apply(&[ + WriteOp::Set { + key: "a".into(), + value: b"1".to_vec(), + }, + WriteOp::Delete { key: "b".into() }, + WriteOp::Set { + key: "c".into(), + value: b"2".to_vec(), + }, + ]) + .unwrap(); + assert_eq!( + recorder.0.into_inner(), + ["set a", "delete b", "set c"].map(str::to_owned) + ); + } + #[test] fn wire_lifts_accept_exact_lengths() { let account = account_from_wire(&[0x11; 20]).unwrap(); diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index cefaea94..9023a599 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -13,6 +13,7 @@ //! - [`keeper`] - keeper stores ([`WatchSet`](keeper::WatchSet), [`Gates`](keeper::Gates), [`Journal`](keeper::Journal)), the [`Poller`](keeper::Poller) seam, and the [`Retrier`](keeper::Retrier). //! - [`chain`] - typed chain access and the alloy provider seam. //! - [`events`] - chain-log delivery. +//! - [`store`] - typed local-store helpers ([`WriteBatch`](store::WriteBatch), [`TypedCell`](store::TypedCell), [`TypedMap`](store::TypedMap), [`Counter`](store::Counter)). //! - [`config`] - config-table lookups and decimal scaling. //! - [`address`] - EVM address parsing. //! - [`http`] - outbound HTTP over wasi:http. @@ -35,6 +36,7 @@ pub mod host; pub mod http; pub mod keeper; pub mod prelude; +pub mod store; pub mod tracing; pub mod wit_bindgen_macro; diff --git a/crates/nexum-sdk/src/store.rs b/crates/nexum-sdk/src/store.rs new file mode 100644 index 00000000..bd115345 --- /dev/null +++ b/crates/nexum-sdk/src/store.rs @@ -0,0 +1,228 @@ +//! Typed local-store helpers over the [`LocalStoreHost`] seam: +//! [`WriteBatch`], [`clear_prefix`], [`TypedCell`], [`TypedMap`], and +//! [`Counter`], so module code hand-rolls neither serialization nor +//! batching. Typed values cross the store as borsh bytes. +//! +//! Batch atomicity follows [`LocalStoreHost::apply`]: all-or-nothing on +//! the real host adapter, per-op on the trait's fallback. + +use core::marker::PhantomData; + +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::host::{Fault, LocalStoreHost, WriteOp}; + +/// Encode as borsh bytes; a failure folds to [`Fault::Internal`]. +fn encode(value: &T) -> Result, Fault> { + borsh::to_vec(value).map_err(|e| Fault::Internal(format!("borsh encode failed: {e}"))) +} + +/// Decode borsh bytes (trailing bytes are malformed); a failure folds +/// to [`Fault::Internal`] naming the key. +fn decode(key: &str, bytes: &[u8]) -> Result { + borsh::from_slice(bytes) + .map_err(|e| Fault::Internal(format!("stored value at `{key}` failed to decode: {e}"))) +} + +/// Stages set/delete ops, flushed in one [`LocalStoreHost::apply`] +/// call. Dropping an unflushed batch discards it. The host caps a +/// batch at 1024 ops and 4 MiB; a larger batch fails whole. +pub struct WriteBatch<'h, H> { + host: &'h H, + ops: Vec, +} + +impl<'h, H: LocalStoreHost> WriteBatch<'h, H> { + /// Empty batch over the given host. + pub fn new(host: &'h H) -> Self { + Self { + host, + ops: Vec::new(), + } + } + + /// Stage an insert-or-overwrite. + pub fn set(&mut self, key: impl Into, value: impl Into>) -> &mut Self { + self.ops.push(WriteOp::Set { + key: key.into(), + value: value.into(), + }); + self + } + + /// Stage a delete. + pub fn delete(&mut self, key: impl Into) -> &mut Self { + self.ops.push(WriteOp::Delete { key: key.into() }); + self + } + + /// Staged op count. + pub fn len(&self) -> usize { + self.ops.len() + } + + /// Whether nothing is staged. + pub fn is_empty(&self) -> bool { + self.ops.is_empty() + } + + /// Flush every staged op in one [`LocalStoreHost::apply`] call; a + /// no-op when nothing is staged. + pub fn flush(self) -> Result<(), Fault> { + if self.ops.is_empty() { + return Ok(()); + } + self.host.apply(&self.ops) + } +} + +/// Delete every key under `prefix` in one [`LocalStoreHost::apply`] +/// call; returns the number of keys deleted. Fails whole past the +/// host's 1024-key batch cap; chunk manually for larger prefixes. +pub fn clear_prefix(host: &impl LocalStoreHost, prefix: &str) -> Result { + let ops: Vec = host + .list_keys(prefix)? + .into_iter() + .map(|key| WriteOp::Delete { key }) + .collect(); + if ops.is_empty() { + return Ok(0); + } + let count = ops.len() as u64; + host.apply(&ops)?; + Ok(count) +} + +/// One borsh-typed value under one key. +pub struct TypedCell<'h, H, T> { + host: &'h H, + key: String, + _value: PhantomData T>, +} + +impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedCell<'h, H, T> { + /// Cell over the given key. + pub fn new(host: &'h H, key: impl Into) -> Self { + Self { + host, + key: key.into(), + _value: PhantomData, + } + } + + /// Decoded value, `Ok(None)` when absent. + pub fn get(&self) -> Result, Fault> { + self.host + .get(&self.key)? + .map(|bytes| decode(&self.key, &bytes)) + .transpose() + } + + /// Encode and store `value`. + pub fn set(&self, value: &T) -> Result<(), Fault> { + self.host.set(&self.key, &encode(value)?) + } + + /// Delete the value; a no-op if absent. + pub fn clear(&self) -> Result<(), Fault> { + self.host.delete(&self.key) + } +} + +/// Borsh-typed values keyed under a prefix: a keyed collection for +/// sets of things such as watches or orders. +pub struct TypedMap<'h, H, T> { + host: &'h H, + prefix: String, + _value: PhantomData T>, +} + +impl<'h, H: LocalStoreHost, T: BorshSerialize + BorshDeserialize> TypedMap<'h, H, T> { + /// Collection under the given key prefix. + pub fn new(host: &'h H, prefix: impl Into) -> Self { + Self { + host, + prefix: prefix.into(), + _value: PhantomData, + } + } + + fn full_key(&self, key: &str) -> String { + format!("{}{key}", self.prefix) + } + + /// Encode and store `value` under `key`. + pub fn insert(&self, key: &str, value: &T) -> Result<(), Fault> { + self.host.set(&self.full_key(key), &encode(value)?) + } + + /// Decoded value at `key`, `Ok(None)` when absent. + pub fn get(&self, key: &str) -> Result, Fault> { + let full = self.full_key(key); + self.host + .get(&full)? + .map(|bytes| decode(&full, &bytes)) + .transpose() + } + + /// Delete `key`; a no-op if absent. + pub fn remove(&self, key: &str) -> Result<(), Fault> { + self.host.delete(&self.full_key(key)) + } + + /// Keys in the collection, prefix stripped. + pub fn keys(&self) -> Result, Fault> { + Ok(self + .host + .list_keys(&self.prefix)? + .into_iter() + .map(|key| match key.strip_prefix(&self.prefix) { + Some(stripped) => stripped.to_owned(), + None => key, + }) + .collect()) + } + + /// Delete every entry in one [`LocalStoreHost::apply`] call; + /// returns the number deleted. Fails whole past the 1024-entry cap. + pub fn clear(&self) -> Result { + clear_prefix(self.host, &self.prefix) + } +} + +/// A `u64` under one key. Read-modify-write, so safe under the +/// runtime's single-actor serialized dispatch; there is no cross-call +/// lock. +pub struct Counter<'h, H> { + host: &'h H, + key: String, +} + +impl<'h, H: LocalStoreHost> Counter<'h, H> { + /// Counter over the given key. + pub fn new(host: &'h H, key: impl Into) -> Self { + Self { + host, + key: key.into(), + } + } + + /// Current value; 0 when absent. + pub fn get(&self) -> Result { + self.host + .get(&self.key)? + .map_or(Ok(0), |bytes| decode(&self.key, &bytes)) + } + + /// Add `delta` (saturating) and store; returns the new value. + pub fn add(&self, delta: u64) -> Result { + let next = self.get()?.saturating_add(delta); + self.set(next)?; + Ok(next) + } + + /// Store `value`. + pub fn set(&self, value: u64) -> Result<(), Fault> { + self.host.set(&self.key, &encode(&value)?) + } +} diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/crates/nexum-sdk/src/wit_bindgen_macro.rs index 773425ca..39558dea 100644 --- a/crates/nexum-sdk/src/wit_bindgen_macro.rs +++ b/crates/nexum-sdk/src/wit_bindgen_macro.rs @@ -209,6 +209,32 @@ macro_rules! __bind_host_cap_via_wit_bindgen { fn delete(&self, key: &str) -> ::core::result::Result<(), $crate::host::Fault> { nexum::host::local_store::delete(key).map_err($crate::host::Fault::from) } + // Overrides the trait's per-op fallback with the host's + // atomic batch verb. + fn apply( + &self, + ops: &[$crate::host::WriteOp], + ) -> ::core::result::Result<(), $crate::host::Fault> { + let ops: ::std::vec::Vec = ops + .iter() + .map(|op| match op { + $crate::host::WriteOp::Set { key, value } => { + nexum::host::local_store::WriteOp::Set( + nexum::host::local_store::KeyValue { + key: ::std::clone::Clone::clone(key), + value: ::std::clone::Clone::clone(value), + }, + ) + } + $crate::host::WriteOp::Delete { key } => { + nexum::host::local_store::WriteOp::Delete(::std::clone::Clone::clone( + key, + )) + } + }) + .collect(); + nexum::host::local_store::apply(&ops).map_err($crate::host::Fault::from) + } fn list_keys( &self, prefix: &str, diff --git a/crates/nexum-sdk/tests/store.rs b/crates/nexum-sdk/tests/store.rs new file mode 100644 index 00000000..851b3b2f --- /dev/null +++ b/crates/nexum-sdk/tests/store.rs @@ -0,0 +1,135 @@ +//! Store-helper acceptance tests over the world-neutral mock local +//! store, which exercises the trait's default per-op `apply` fallback. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nexum_sdk::host::{Fault, LocalStoreHost}; +use nexum_sdk::store::{Counter, TypedCell, TypedMap, WriteBatch, clear_prefix}; +use nexum_sdk_test::MockLocalStore; + +#[derive(BorshSerialize, BorshDeserialize, Debug, Eq, PartialEq)] +struct Row { + label: String, + size: u64, +} + +fn row(label: &str, size: u64) -> Row { + Row { + label: label.to_owned(), + size, + } +} + +#[test] +fn write_batch_flushes_staged_sets_and_deletes() { + let store = MockLocalStore::default(); + store.set("stale", b"x").unwrap(); + + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()).set("b", b"2".to_vec()); + batch.delete("stale"); + assert_eq!(batch.len(), 3); + assert!(!batch.is_empty()); + batch.flush().unwrap(); + + assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); + assert_eq!(store.get("b").unwrap(), Some(b"2".to_vec())); + assert_eq!(store.get("stale").unwrap(), None); +} + +#[test] +fn write_batch_dropped_before_flush_writes_nothing() { + let store = MockLocalStore::default(); + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()); + drop(batch); + assert!(store.is_empty()); +} + +#[test] +fn write_batch_empty_flush_is_a_no_op() { + let store = MockLocalStore::default(); + WriteBatch::new(&store).flush().unwrap(); + assert!(store.is_empty()); +} + +#[test] +fn default_apply_fallback_is_per_op_so_a_mid_batch_fault_leaves_earlier_ops() { + let store = MockLocalStore::default(); + store.fail_on("poison", Fault::Internal("injected".into())); + + let mut batch = WriteBatch::new(&store); + batch.set("a", b"1".to_vec()); + batch.set("poison", b"2".to_vec()); + batch.set("z", b"3".to_vec()); + batch.flush().unwrap_err(); + + // The mock exercises the trait default, so the pre-fault op landed + // and the post-fault op did not. + assert_eq!(store.get("a").unwrap(), Some(b"1".to_vec())); + assert_eq!(store.get("z").unwrap(), None); +} + +#[test] +fn clear_prefix_deletes_only_the_prefix_and_counts() { + let store = MockLocalStore::default(); + store.set("watch:a", b"1").unwrap(); + store.set("watch:b", b"2").unwrap(); + store.set("gate:a", b"3").unwrap(); + + assert_eq!(clear_prefix(&store, "watch:").unwrap(), 2); + assert_eq!(store.len(), 1); + assert_eq!(store.get("gate:a").unwrap(), Some(b"3".to_vec())); + assert_eq!(clear_prefix(&store, "watch:").unwrap(), 0); +} + +#[test] +fn typed_cell_round_trips_and_clears() { + let store = MockLocalStore::default(); + let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); + + assert_eq!(cell.get().unwrap(), None); + cell.set(&row("head", 7)).unwrap(); + assert_eq!(cell.get().unwrap(), Some(row("head", 7))); + cell.clear().unwrap(); + assert_eq!(cell.get().unwrap(), None); +} + +#[test] +fn typed_cell_folds_a_corrupt_value_to_internal() { + let store = MockLocalStore::default(); + store.set("cursor", b"not borsh").unwrap(); + let cell: TypedCell<'_, _, Row> = TypedCell::new(&store, "cursor"); + assert!(matches!(cell.get().unwrap_err(), Fault::Internal(_))); +} + +#[test] +fn typed_map_inserts_gets_removes_and_strips_prefix_from_keys() { + let store = MockLocalStore::default(); + store.set("other", b"x").unwrap(); + let map: TypedMap<'_, _, Row> = TypedMap::new(&store, "order:"); + + assert_eq!(map.keys().unwrap(), Vec::::new()); + map.insert("a", &row("first", 1)).unwrap(); + map.insert("b", &row("second", 2)).unwrap(); + assert_eq!(map.get("a").unwrap(), Some(row("first", 1))); + assert_eq!(map.get("missing").unwrap(), None); + assert_eq!(map.keys().unwrap(), vec!["a".to_owned(), "b".to_owned()]); + + map.remove("a").unwrap(); + assert_eq!(map.get("a").unwrap(), None); + assert_eq!(map.clear().unwrap(), 1); + assert!(map.keys().unwrap().is_empty()); + assert_eq!(store.get("other").unwrap(), Some(b"x".to_vec())); +} + +#[test] +fn counter_defaults_to_zero_adds_and_saturates() { + let store = MockLocalStore::default(); + let counter = Counter::new(&store, "submitted"); + + assert_eq!(counter.get().unwrap(), 0); + assert_eq!(counter.add(2).unwrap(), 2); + assert_eq!(counter.add(3).unwrap(), 5); + counter.set(u64::MAX).unwrap(); + assert_eq!(counter.add(1).unwrap(), u64::MAX); +} From e86a3d884874d0479928d294591d3971c38353b5 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Sun, 26 Jul 2026 05:04:38 +0000 Subject: [PATCH 138/139] cow: adopt the Journal guard in the composable-cow keeper submit (#631) Rewire submit_ready onto Journal::guard: the six inline journal.mark/reserve/commit/release calls that carry the #572 reserve-before-effect ordering fold into one guard call, with the venue outcome mapped to a Disposition (Accepted commits, requires-signing and known refusals release, an unknown outcome retains for reconcile). Behaviour is identical: all 23 composable-cow run tests including w1/w2/w3 and anti_572 pass unchanged. The remaining code is the CoW-specific outcome handling (refusal clear, retry ledger, tracing) that no primitive absorbs; the value is that the durable-effect ordering is no longer inlined, so a second venue's keeper reuses it rather than re-deriving it. Part of #609. Stacks on the Journal::guard combinator (#629). --- crates/composable-cow/src/run.rs | 96 ++++++++++++-------------------- 1 file changed, 37 insertions(+), 59 deletions(-) diff --git a/crates/composable-cow/src/run.rs b/crates/composable-cow/src/run.rs index 279a02da..591759e5 100644 --- a/crates/composable-cow/src/run.rs +++ b/crates/composable-cow/src/run.rs @@ -20,7 +20,8 @@ use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder, clas use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ - Gates, Journal, Mark, Poller, Retrier, RetryAction, Tick, WatchRef, WatchSet, + Disposition, Gates, Guarded, Journal, Mark, Poller, Retrier, RetryAction, Tick, WatchRef, + WatchSet, }; use std::task::Poll; @@ -98,14 +99,8 @@ where Ok(()) } -/// Submit one polled `Post` order through the typed client on the -/// `submitted:` reserve/commit journal, dispatching a refusal through -/// the retry ledger. -/// -/// Keyed on the venue-and-body submission key: `COMMITTED` is an -/// idempotent skip, `RESERVED` is owned by reconcile. A fresh order -/// reserves before the submit and commits on acceptance; release runs -/// only on a known synchronous non-accept. +/// Submit one polled `Post` order through the guard, folding a refusal +/// into the retry ledger. fn submit_ready( host: &H, venue: &CowClient, @@ -128,9 +123,7 @@ where }; 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. + // Unknown enum marker: skip, not drop, so an SDK upgrade picks it up. tracing::warn!( "{label} submit skipped for {owner:#x}: GPv2OrderData carried an unknown enum marker" ); @@ -142,8 +135,7 @@ where owner, signature: signature.to_vec(), })); - // Reserve the exact wire bytes the venue submit and the reconcile - // resubmit both carry, so the id and the reservation agree. + // Key on the exact bytes the submit and reconcile both carry. let encoded = match intent.to_bytes() { Ok(bytes) => bytes, Err(err) => { @@ -153,42 +145,40 @@ where }; let intent_id = submission_key(&CowVenue::ID, &encoded); let journal = Journal::submitted(host); - match journal.mark(&intent_id)? { - Some(Mark::Committed) => { - tracing::info!("{label} {intent_id} already committed; skipping re-submit"); - return Ok(()); - } - Some(Mark::Reserved) => { - // Owned by this tick's reconcile pass; never a second submit. - tracing::info!("{label} {intent_id} reserved; reconcile owns it"); - return Ok(()); + // The guard owns the reserve/commit/reconcile ordering (#572). + let guarded = poll_once(journal.guard(&intent_id, &encoded, || async { + let outcome = venue.submit(&intent).await; + let disposition = match &outcome { + Ok(SubmitOutcome::Accepted(_)) => Disposition::Commit, + Ok(SubmitOutcome::RequiresSigning(_)) + | Err(ClientError::Body(_)) + | Err(ClientError::Venue(_)) => Disposition::Release, + // Unknown outcome: hold for reconcile. + Err(_) => Disposition::Retain, + }; + (disposition, outcome) + })); + let outcome = match guarded { + Poll::Ready(res) => match res? { + Guarded::Skipped(Mark::Committed) => { + tracing::info!("{label} {intent_id} already committed; skipping re-submit"); + return Ok(()); + } + Guarded::Skipped(Mark::Reserved) => { + tracing::info!("{label} {intent_id} reserved; reconcile owns it"); + return Ok(()); + } + Guarded::Ran(outcome) => outcome, + }, + Poll::Pending => { + // Guest transports never suspend; retry next block, reconcile owns the marker. + tracing::error!("{label} submit future suspended; retrying next block"); + return Retrier::new(host).apply(watch, RetryAction::TryNextBlock, tick); } - None => {} - } - // Reserve the real body before any network work: a crash or lost - // outcome now strands a RESERVED marker the next tick's reconcile - // resolves, never a silent drop (#572). - journal.reserve(&intent_id, &encoded)?; - - let Poll::Ready(outcome) = poll_once(venue.submit(&intent)) else { - // Guest transports never suspend; a pending future means a - // foreign transport misbehaved. Leave the marker RESERVED for the - // next tick's reconcile and retry the watch next block; never - // release on a pending path. - tracing::error!("{label} submit future suspended; retrying next block"); - return Retrier::new(host).apply(watch, RetryAction::TryNextBlock, tick); }; match outcome { Ok(SubmitOutcome::Accepted(receipt)) => { - // The submit landed; commit the reservation best-effort. A - // commit fault leaves the marker RESERVED for reconcile, never - // released or aborted. - if let Err(fault) = journal.commit(&intent_id) { - tracing::error!("submitted {intent_id} but commit write failed: {fault}"); - } - // An acceptance ends any refusal episode: clear the - // first-refusal marker so a later independent refusal earns a - // fresh one-block grace. + // Acceptance ends the refusal episode: clear the first-refusal marker. if let Err(fault) = Retrier::new(host).clear_refusal(watch) { tracing::error!("submitted {intent_id} but refusal-marker clear failed: {fault}"); } @@ -198,20 +188,12 @@ where ); } Ok(SubmitOutcome::RequiresSigning(_)) => { - // A known non-accept: a run cannot sign, so release the reserve - // and re-pose the ask next tick. - journal.release(&intent_id)?; tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); } Err(ClientError::Body(err)) => { - // A known non-accept before any order is placed: release. - journal.release(&intent_id)?; tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { - // A known venue refusal: release the reserve, then fold it - // through the ledger. - journal.release(&intent_id)?; let action = match &fault { VenueFault::Denied(detail) => classify_denied(detail), other => retry_action(other), @@ -224,17 +206,13 @@ where } RetryAction::DropOnRepeat => tracing::warn!("submit drop-on-repeat: {fault}"), RetryAction::Drop => tracing::warn!("submit dropped watch: {fault}"), - // `RetryAction` is non-exhaustive; the ledger already - // ran the effect, so the log needs only the name. + // Non-exhaustive fallthrough: log the action name. other => { let action_label: &'static str = other.into(); tracing::warn!("submit retry action {action_label}: {fault}"); } } } - // `ClientError` is non-exhaustive; an unknown outcome is neither a - // known accept nor a known refusal, so leave the marker RESERVED - // for reconcile rather than releasing. Err(err) => tracing::error!("submit failed: {err}"), } Ok(()) From 2436c50769ad1ea645d5cec99f76a673c5ddca22 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 27 Jul 2026 02:06:33 +0000 Subject: [PATCH 139/139] refactor(packaging): three-grouping path-dep workspace (#403) (#636) refactor(packaging): reorganize into three-grouping path-dep workspace Move all workspace members into nexum/ (L1), videre/ (L2) and shepherd/ (L3) repo-root dirs as a single cargo workspace with intra-grouping path-deps, so the tree keeps building as one unit with a shared Cargo.lock right up to the physical carve (#407). This is the refactor-now-cut-later step of the gated three-repo split (M5). Add the carve-groups dep-sync gate (scripts/check-carve-groups.sh, CI job, justfile recipe) enforcing the acyclic nexum <- videre <- shepherd invariant from the physical layout, so no upward edge can become a circular repo dependency at the carve. Document the transitional cross-repo dependency medium (path-deps -> git-tag pins -> crates.io) in docs/design/carve-workspace.md. WIT stays in a single root wit/, resolved via the manifest-dir ancestor walk; the per-group wit/ + wit/deps split is deferred to the wit-deps flip (#404/#405). The hardcoded wit_bindgen::generate! path-lists were re-based one level deeper by the move. Align the justfile with the already-optimized CI: test/test-e2e/ci use cargo nextest run, ci runs doctests separately and appends -D warnings to RUSTFLAGS/RUSTDOCFLAGS instead of clobbering the devshell mold flags. sccache is already wired via the flake devshell and the CI workflow env. Fix the test workspace-root locators the move broke: replace every hardcoded CARGO_MANIFEST_DIR.parent().parent() (valid only at the old crates/ depth) with a workspace_root() that walks to the topmost ancestor carrying a Cargo.toml, and group-prefix the module manifests those e2e tests load. These had silently skipped locally (no CI env) into a hollow green; CI's fail-loud path caught them. Re-validated with CI=1. Closes #403. --- .github/workflows/ci.yml | 12 ++++ Cargo.toml | 70 +++++++++--------- Dockerfile | 12 ++-- README.md | 14 ++-- docs/00-overview.md | 4 +- docs/01-runtime-environment.md | 2 +- docs/02-modules-events-packaging.md | 4 +- docs/03-module-discovery.md | 2 +- docs/05-sdk-design.md | 12 ++-- docs/06-production-hardening.md | 4 +- docs/07-rpc-namespace-design.md | 2 +- docs/08-platform-generalisation.md | 2 +- ...01-engine-toml-separate-from-nexum-toml.md | 2 +- docs/adr/0009-host-trait-surface.md | 2 +- .../0013-composable-cow-structured-poll.md | 2 +- docs/deployment.md | 6 +- docs/deployment/multi-chain.md | 4 +- docs/design/carve-workspace.md | 47 ++++++++++++ docs/diagrams/diagrams.md | 2 +- docs/operations/load-testnet-runbook.md | 12 ++-- docs/operations/m2-testnet-runbook.md | 4 +- docs/operations/m3-testnet-runbook.md | 2 +- docs/sdk.md | 6 +- docs/testing-runtime-harness.md | 4 +- docs/tutorial-first-module.md | 16 ++--- engine.e2e.toml | 18 ++--- engine.example.toml | 2 +- engine.load.toml | 6 +- engine.m2.toml | 6 +- engine.m3.toml | 6 +- engine.soak.toml | 10 +-- justfile | 28 ++++++-- {crates => nexum/crates}/nexum-cli/Cargo.toml | 0 .../crates}/nexum-cli/src/main.rs | 0 .../crates}/nexum-launch/Cargo.toml | 0 .../crates}/nexum-launch/src/cli.rs | 0 .../crates}/nexum-launch/src/lib.rs | 0 .../crates}/nexum-module-macros/Cargo.toml | 0 .../crates}/nexum-module-macros/src/lib.rs | 0 .../crates}/nexum-runtime/Cargo.toml | 0 .../crates}/nexum-runtime/examples/embed.rs | 0 .../crates}/nexum-runtime/src/addons.rs | 0 .../crates}/nexum-runtime/src/bindings.rs | 2 +- .../crates}/nexum-runtime/src/bootstrap.rs | 0 .../crates}/nexum-runtime/src/builder.rs | 39 +++++----- .../nexum-runtime/src/engine_config.rs | 0 .../crates}/nexum-runtime/src/host/actor.rs | 0 .../src/host/component/builder.rs | 0 .../nexum-runtime/src/host/component/chain.rs | 0 .../nexum-runtime/src/host/component/mod.rs | 0 .../src/host/component/runtime_types.rs | 0 .../nexum-runtime/src/host/component/state.rs | 0 .../crates}/nexum-runtime/src/host/error.rs | 0 .../nexum-runtime/src/host/extension.rs | 0 .../crates}/nexum-runtime/src/host/http.rs | 0 .../nexum-runtime/src/host/impls/chain.rs | 0 .../nexum-runtime/src/host/impls/identity.rs | 0 .../src/host/impls/local_store.rs | 0 .../nexum-runtime/src/host/impls/logging.rs | 0 .../nexum-runtime/src/host/impls/messaging.rs | 0 .../nexum-runtime/src/host/impls/mod.rs | 0 .../src/host/impls/remote_store.rs | 0 .../nexum-runtime/src/host/impls/types.rs | 0 .../src/host/local_store_redb.rs | 0 .../src/host/local_store_redb/tests.rs | 0 .../nexum-runtime/src/host/logs/mod.rs | 0 .../nexum-runtime/src/host/logs/stdio.rs | 0 .../nexum-runtime/src/host/logs/store.rs | 0 .../crates}/nexum-runtime/src/host/mod.rs | 0 .../nexum-runtime/src/host/provider_pool.rs | 0 .../crates}/nexum-runtime/src/host/state.rs | 0 .../crates}/nexum-runtime/src/lib.rs | 0 .../src/manifest/capabilities.rs | 0 .../nexum-runtime/src/manifest/error.rs | 0 .../nexum-runtime/src/manifest/load.rs | 0 .../crates}/nexum-runtime/src/manifest/mod.rs | 0 .../nexum-runtime/src/manifest/types.rs | 0 .../crates}/nexum-runtime/src/preset.rs | 0 .../src/runtime/dispatch_rate.rs | 0 .../nexum-runtime/src/runtime/event_loop.rs | 0 .../nexum-runtime/src/runtime/limits.rs | 0 .../crates}/nexum-runtime/src/runtime/mod.rs | 0 .../src/runtime/poison_policy.rs | 0 .../src/runtime/restart_policy.rs | 0 .../crates}/nexum-runtime/src/supervisor.rs | 0 .../nexum-runtime/src/supervisor/tests.rs | 71 ++++++++----------- .../nexum-runtime/src/test_utils/builders.rs | 0 .../nexum-runtime/src/test_utils/chain.rs | 0 .../nexum-runtime/src/test_utils/clock.rs | 0 .../nexum-runtime/src/test_utils/harness.rs | 14 ++-- .../nexum-runtime/src/test_utils/mod.rs | 0 .../nexum-runtime/src/test_utils/store.rs | 0 .../nexum-runtime/src/test_utils/types.rs | 0 .../crates}/nexum-sdk-test/Cargo.toml | 0 .../crates}/nexum-sdk-test/src/lib.rs | 0 {crates => nexum/crates}/nexum-sdk/Cargo.toml | 0 .../crates}/nexum-sdk/src/address.rs | 0 .../crates}/nexum-sdk/src/chain/chainlink.rs | 0 .../crates}/nexum-sdk/src/chain/eth_call.rs | 0 .../crates}/nexum-sdk/src/chain/mod.rs | 0 .../crates}/nexum-sdk/src/chain/provider.rs | 0 .../crates}/nexum-sdk/src/chain/transport.rs | 0 .../crates}/nexum-sdk/src/config.rs | 0 .../crates}/nexum-sdk/src/events.rs | 0 .../crates}/nexum-sdk/src/host.rs | 0 .../crates}/nexum-sdk/src/http.rs | 0 .../crates}/nexum-sdk/src/keeper.rs | 0 {crates => nexum/crates}/nexum-sdk/src/lib.rs | 0 .../crates}/nexum-sdk/src/prelude.rs | 0 .../crates}/nexum-sdk/src/proptests.rs | 0 .../crates}/nexum-sdk/src/store.rs | 0 .../crates}/nexum-sdk/src/tracing.rs | 0 .../nexum-sdk/src/wit_bindgen_macro.rs | 0 .../crates}/nexum-sdk/tests/keeper.rs | 0 .../crates}/nexum-sdk/tests/store.rs | 0 .../crates}/nexum-tasks/Cargo.toml | 0 .../crates}/nexum-tasks/src/lib.rs | 0 .../crates}/nexum-tasks/src/manager.rs | 0 .../crates}/nexum-tasks/src/shutdown.rs | 0 .../crates}/nexum-tasks/src/task.rs | 0 .../crates}/nexum-world/Cargo.toml | 0 .../crates}/nexum-world/src/lib.rs | 0 {modules => nexum/modules}/example/Cargo.toml | 0 .../modules}/example/module.toml | 0 {modules => nexum/modules}/example/src/lib.rs | 0 .../examples/balance-tracker/Cargo.toml | 0 .../examples/balance-tracker/module.toml | 0 .../examples/balance-tracker/src/lib.rs | 0 .../examples/balance-tracker/src/logic.rs | 0 .../modules}/examples/http-probe/Cargo.toml | 0 .../modules}/examples/http-probe/module.toml | 0 .../modules}/examples/http-probe/src/lib.rs | 0 .../modules}/examples/http-probe/src/logic.rs | 0 .../modules}/examples/price-alert/Cargo.toml | 0 .../modules}/examples/price-alert/module.toml | 0 .../modules}/examples/price-alert/src/lib.rs | 0 .../examples/price-alert/src/logic.rs | 0 .../modules}/fixtures/clock-reader/Cargo.toml | 0 .../fixtures/clock-reader/module.toml | 0 .../modules}/fixtures/clock-reader/src/lib.rs | 2 +- .../modules}/fixtures/flaky-bomb/Cargo.toml | 0 .../modules}/fixtures/flaky-bomb/module.toml | 0 .../modules}/fixtures/flaky-bomb/src/lib.rs | 2 +- .../modules}/fixtures/fuel-bomb/Cargo.toml | 0 .../modules}/fixtures/fuel-bomb/module.toml | 0 .../modules}/fixtures/fuel-bomb/src/lib.rs | 2 +- .../modules}/fixtures/memory-bomb/Cargo.toml | 0 .../modules}/fixtures/memory-bomb/module.toml | 0 .../modules}/fixtures/memory-bomb/src/lib.rs | 2 +- .../modules}/fixtures/panic-bomb/Cargo.toml | 0 .../modules}/fixtures/panic-bomb/module.toml | 0 .../modules}/fixtures/panic-bomb/src/lib.rs | 2 +- .../modules}/fixtures/slow-host/Cargo.toml | 0 .../modules}/fixtures/slow-host/module.toml | 0 .../modules}/fixtures/slow-host/src/lib.rs | 2 +- {tools => nexum/tools}/load-gen/Cargo.toml | 0 {tools => nexum/tools}/load-gen/src/main.rs | 0 scripts/check-carve-groups.sh | 42 +++++++++++ scripts/check-cow-orderbook-only.sh | 8 +-- scripts/check-venue-agnostic.sh | 12 ++-- .../crates}/composable-cow/Cargo.toml | 6 +- .../crates}/composable-cow/src/body.rs | 0 .../crates}/composable-cow/src/lib.rs | 0 .../crates}/composable-cow/src/poll.rs | 0 .../crates}/composable-cow/src/run.rs | 0 .../crates}/composable-cow/tests/run.rs | 0 .../crates}/cow-venue/Cargo.toml | 6 +- .../crates}/cow-venue/build.rs | 0 .../cow-venue/data/classification.toml | 0 .../crates}/cow-venue/module.load.toml | 0 .../crates}/cow-venue/module.sepolia.toml | 0 .../crates}/cow-venue/module.toml | 0 .../crates}/cow-venue/src/adapter.rs | 0 .../crates}/cow-venue/src/assembly.rs | 0 .../crates}/cow-venue/src/body.rs | 0 .../crates}/cow-venue/src/classification.rs | 0 .../cow-venue/src/classification_data.rs | 0 .../crates}/cow-venue/src/client.rs | 0 .../crates}/cow-venue/src/lib.rs | 0 .../crates}/cow-venue/src/order.rs | 0 .../crates}/cow-venue/tests/conformance.rs | 0 .../tests/vectors/cow-header-goldens.json | 0 .../tests/vectors/cow-intent-body.json | 0 .../crates}/cow-venue/tests/wit_layering.rs | 2 +- .../crates}/shepherd/Cargo.toml | 6 +- .../crates}/shepherd/src/main.rs | 0 .../modules}/ethflow-watcher/Cargo.toml | 6 +- .../modules}/ethflow-watcher/module.toml | 0 .../modules}/ethflow-watcher/src/keeper.rs | 2 +- .../modules}/ethflow-watcher/src/lib.rs | 0 .../modules}/twap-monitor/Cargo.toml | 6 +- .../modules}/twap-monitor/module.toml | 0 .../modules}/twap-monitor/src/keeper.rs | 0 .../modules}/twap-monitor/src/lib.rs | 0 .../tools}/baseline-latency/.gitignore | 0 .../baseline-latency/baseline_latency.py | 0 .../baseline-latency/data/arbitrum_one.json | 0 .../tools}/baseline-latency/data/base.json | 0 .../tools}/baseline-latency/data/gnosis.json | 0 .../tools}/baseline-latency/data/mainnet.json | 0 .../tools}/baseline-latency/data/sepolia.json | 0 .../tools}/orderbook-mock/Cargo.toml | 0 .../tools}/orderbook-mock/src/main.rs | 0 .../crates}/no-std-probe/Cargo.toml | 0 .../crates}/no-std-probe/src/lib.rs | 0 .../crates}/videre-host/Cargo.toml | 6 +- .../crates}/videre-host/src/bindings.rs | 26 +++---- .../crates}/videre-host/src/client.rs | 0 .../crates}/videre-host/src/handshake.rs | 0 .../crates}/videre-host/src/lib.rs | 0 .../crates}/videre-host/src/registry.rs | 0 .../crates}/videre-host/tests/platform.rs | 42 +++++++---- .../crates}/videre-host/tests/zero_leak.rs | 22 +++--- .../crates}/videre-macros/Cargo.toml | 2 +- .../crates}/videre-macros/src/intent_body.rs | 0 .../crates}/videre-macros/src/keeper.rs | 0 .../crates}/videre-macros/src/lib.rs | 0 .../crates}/videre-macros/src/venue_marker.rs | 0 .../crates}/videre-macros/src/world.rs | 0 .../crates}/videre-sdk/Cargo.toml | 4 +- .../crates}/videre-sdk/src/adapter.rs | 0 .../crates}/videre-sdk/src/bindings.rs | 8 +-- .../crates}/videre-sdk/src/body.rs | 0 .../crates}/videre-sdk/src/client.rs | 0 .../crates}/videre-sdk/src/event.rs | 0 .../crates}/videre-sdk/src/faults.rs | 0 .../crates}/videre-sdk/src/keeper.rs | 0 .../crates}/videre-sdk/src/lib.rs | 0 .../crates}/videre-sdk/src/transport.rs | 0 .../crates}/videre-sdk/src/value_flow.rs | 0 .../crates}/videre-sdk/tests/adapter.rs | 0 .../crates}/videre-status-body/Cargo.toml | 0 .../crates}/videre-status-body/src/lib.rs | 0 .../crates}/videre-test/Cargo.toml | 4 +- .../videre-test/goldens/reference-header.json | 0 .../crates}/videre-test/src/codec.rs | 0 .../crates}/videre-test/src/fixture.rs | 0 .../crates}/videre-test/src/header.rs | 0 .../crates}/videre-test/src/lib.rs | 0 .../crates}/videre-test/src/reconcile.rs | 0 .../crates}/videre-test/src/reference.rs | 0 .../crates}/videre-test/src/report.rs | 0 .../crates}/videre-test/src/transport.rs | 0 .../crates}/videre-test/tests/conformance.rs | 0 .../videre-test/vectors/reference-body.json | 0 .../modules}/examples/echo-client/Cargo.toml | 2 +- .../modules}/examples/echo-client/module.toml | 0 .../modules}/examples/echo-client/src/lib.rs | 0 .../modules}/examples/echo-keeper/Cargo.toml | 2 +- .../modules}/examples/echo-keeper/module.toml | 0 .../modules}/examples/echo-keeper/src/lib.rs | 0 .../modules}/examples/echo-venue/Cargo.toml | 0 .../modules}/examples/echo-venue/module.toml | 0 .../modules}/examples/echo-venue/src/lib.rs | 0 .../modules}/fixtures/flaky-venue/Cargo.toml | 0 .../modules}/fixtures/flaky-venue/module.toml | 0 .../modules}/fixtures/flaky-venue/src/lib.rs | 0 257 files changed, 394 insertions(+), 283 deletions(-) create mode 100644 docs/design/carve-workspace.md rename {crates => nexum/crates}/nexum-cli/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-cli/src/main.rs (100%) rename {crates => nexum/crates}/nexum-launch/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-launch/src/cli.rs (100%) rename {crates => nexum/crates}/nexum-launch/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-module-macros/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-module-macros/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-runtime/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-runtime/examples/embed.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/addons.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/bindings.rs (95%) rename {crates => nexum/crates}/nexum-runtime/src/bootstrap.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/builder.rs (97%) rename {crates => nexum/crates}/nexum-runtime/src/engine_config.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/actor.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/component/builder.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/component/chain.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/component/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/component/runtime_types.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/component/state.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/error.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/extension.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/http.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/chain.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/identity.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/local_store.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/logging.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/messaging.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/remote_store.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/impls/types.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/local_store_redb.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/local_store_redb/tests.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/logs/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/logs/stdio.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/logs/store.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/provider_pool.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/host/state.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/manifest/capabilities.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/manifest/error.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/manifest/load.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/manifest/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/manifest/types.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/preset.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/dispatch_rate.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/event_loop.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/limits.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/poison_policy.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/runtime/restart_policy.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/supervisor.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/supervisor/tests.rs (97%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/builders.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/chain.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/clock.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/harness.rs (98%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/mod.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/store.rs (100%) rename {crates => nexum/crates}/nexum-runtime/src/test_utils/types.rs (100%) rename {crates => nexum/crates}/nexum-sdk-test/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-sdk-test/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-sdk/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-sdk/src/address.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/chain/chainlink.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/chain/eth_call.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/chain/mod.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/chain/provider.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/chain/transport.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/config.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/events.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/host.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/http.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/keeper.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/prelude.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/proptests.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/store.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/tracing.rs (100%) rename {crates => nexum/crates}/nexum-sdk/src/wit_bindgen_macro.rs (100%) rename {crates => nexum/crates}/nexum-sdk/tests/keeper.rs (100%) rename {crates => nexum/crates}/nexum-sdk/tests/store.rs (100%) rename {crates => nexum/crates}/nexum-tasks/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-tasks/src/lib.rs (100%) rename {crates => nexum/crates}/nexum-tasks/src/manager.rs (100%) rename {crates => nexum/crates}/nexum-tasks/src/shutdown.rs (100%) rename {crates => nexum/crates}/nexum-tasks/src/task.rs (100%) rename {crates => nexum/crates}/nexum-world/Cargo.toml (100%) rename {crates => nexum/crates}/nexum-world/src/lib.rs (100%) rename {modules => nexum/modules}/example/Cargo.toml (100%) rename {modules => nexum/modules}/example/module.toml (100%) rename {modules => nexum/modules}/example/src/lib.rs (100%) rename {modules => nexum/modules}/examples/balance-tracker/Cargo.toml (100%) rename {modules => nexum/modules}/examples/balance-tracker/module.toml (100%) rename {modules => nexum/modules}/examples/balance-tracker/src/lib.rs (100%) rename {modules => nexum/modules}/examples/balance-tracker/src/logic.rs (100%) rename {modules => nexum/modules}/examples/http-probe/Cargo.toml (100%) rename {modules => nexum/modules}/examples/http-probe/module.toml (100%) rename {modules => nexum/modules}/examples/http-probe/src/lib.rs (100%) rename {modules => nexum/modules}/examples/http-probe/src/logic.rs (100%) rename {modules => nexum/modules}/examples/price-alert/Cargo.toml (100%) rename {modules => nexum/modules}/examples/price-alert/module.toml (100%) rename {modules => nexum/modules}/examples/price-alert/src/lib.rs (100%) rename {modules => nexum/modules}/examples/price-alert/src/logic.rs (100%) rename {modules => nexum/modules}/fixtures/clock-reader/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/clock-reader/module.toml (100%) rename {modules => nexum/modules}/fixtures/clock-reader/src/lib.rs (97%) rename {modules => nexum/modules}/fixtures/flaky-bomb/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/flaky-bomb/module.toml (100%) rename {modules => nexum/modules}/fixtures/flaky-bomb/src/lib.rs (98%) rename {modules => nexum/modules}/fixtures/fuel-bomb/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/fuel-bomb/module.toml (100%) rename {modules => nexum/modules}/fixtures/fuel-bomb/src/lib.rs (97%) rename {modules => nexum/modules}/fixtures/memory-bomb/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/memory-bomb/module.toml (100%) rename {modules => nexum/modules}/fixtures/memory-bomb/src/lib.rs (97%) rename {modules => nexum/modules}/fixtures/panic-bomb/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/panic-bomb/module.toml (100%) rename {modules => nexum/modules}/fixtures/panic-bomb/src/lib.rs (97%) rename {modules => nexum/modules}/fixtures/slow-host/Cargo.toml (100%) rename {modules => nexum/modules}/fixtures/slow-host/module.toml (100%) rename {modules => nexum/modules}/fixtures/slow-host/src/lib.rs (97%) rename {tools => nexum/tools}/load-gen/Cargo.toml (100%) rename {tools => nexum/tools}/load-gen/src/main.rs (100%) create mode 100755 scripts/check-carve-groups.sh rename {crates => shepherd/crates}/composable-cow/Cargo.toml (86%) rename {crates => shepherd/crates}/composable-cow/src/body.rs (100%) rename {crates => shepherd/crates}/composable-cow/src/lib.rs (100%) rename {crates => shepherd/crates}/composable-cow/src/poll.rs (100%) rename {crates => shepherd/crates}/composable-cow/src/run.rs (100%) rename {crates => shepherd/crates}/composable-cow/tests/run.rs (100%) rename {crates => shepherd/crates}/cow-venue/Cargo.toml (94%) rename {crates => shepherd/crates}/cow-venue/build.rs (100%) rename {crates => shepherd/crates}/cow-venue/data/classification.toml (100%) rename {crates => shepherd/crates}/cow-venue/module.load.toml (100%) rename {crates => shepherd/crates}/cow-venue/module.sepolia.toml (100%) rename {crates => shepherd/crates}/cow-venue/module.toml (100%) rename {crates => shepherd/crates}/cow-venue/src/adapter.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/assembly.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/body.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/classification.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/classification_data.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/client.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/lib.rs (100%) rename {crates => shepherd/crates}/cow-venue/src/order.rs (100%) rename {crates => shepherd/crates}/cow-venue/tests/conformance.rs (100%) rename {crates => shepherd/crates}/cow-venue/tests/vectors/cow-header-goldens.json (100%) rename {crates => shepherd/crates}/cow-venue/tests/vectors/cow-intent-body.json (100%) rename {crates => shepherd/crates}/cow-venue/tests/wit_layering.rs (98%) rename {crates => shepherd/crates}/shepherd/Cargo.toml (57%) rename {crates => shepherd/crates}/shepherd/src/main.rs (100%) rename {modules => shepherd/modules}/ethflow-watcher/Cargo.toml (79%) rename {modules => shepherd/modules}/ethflow-watcher/module.toml (100%) rename {modules => shepherd/modules}/ethflow-watcher/src/keeper.rs (99%) rename {modules => shepherd/modules}/ethflow-watcher/src/lib.rs (100%) rename {modules => shepherd/modules}/twap-monitor/Cargo.toml (85%) rename {modules => shepherd/modules}/twap-monitor/module.toml (100%) rename {modules => shepherd/modules}/twap-monitor/src/keeper.rs (100%) rename {modules => shepherd/modules}/twap-monitor/src/lib.rs (100%) rename {tools => shepherd/tools}/baseline-latency/.gitignore (100%) rename {tools => shepherd/tools}/baseline-latency/baseline_latency.py (100%) rename {tools => shepherd/tools}/baseline-latency/data/arbitrum_one.json (100%) rename {tools => shepherd/tools}/baseline-latency/data/base.json (100%) rename {tools => shepherd/tools}/baseline-latency/data/gnosis.json (100%) rename {tools => shepherd/tools}/baseline-latency/data/mainnet.json (100%) rename {tools => shepherd/tools}/baseline-latency/data/sepolia.json (100%) rename {tools => shepherd/tools}/orderbook-mock/Cargo.toml (100%) rename {tools => shepherd/tools}/orderbook-mock/src/main.rs (100%) rename {crates => videre/crates}/no-std-probe/Cargo.toml (100%) rename {crates => videre/crates}/no-std-probe/src/lib.rs (100%) rename {crates => videre/crates}/videre-host/Cargo.toml (86%) rename {crates => videre/crates}/videre-host/src/bindings.rs (93%) rename {crates => videre/crates}/videre-host/src/client.rs (100%) rename {crates => videre/crates}/videre-host/src/handshake.rs (100%) rename {crates => videre/crates}/videre-host/src/lib.rs (100%) rename {crates => videre/crates}/videre-host/src/registry.rs (100%) rename {crates => videre/crates}/videre-host/tests/platform.rs (97%) rename {crates => videre/crates}/videre-host/tests/zero_leak.rs (90%) rename {crates => videre/crates}/videre-macros/Cargo.toml (86%) rename {crates => videre/crates}/videre-macros/src/intent_body.rs (100%) rename {crates => videre/crates}/videre-macros/src/keeper.rs (100%) rename {crates => videre/crates}/videre-macros/src/lib.rs (100%) rename {crates => videre/crates}/videre-macros/src/venue_marker.rs (100%) rename {crates => videre/crates}/videre-macros/src/world.rs (100%) rename {crates => videre/crates}/videre-sdk/Cargo.toml (94%) rename {crates => videre/crates}/videre-sdk/src/adapter.rs (100%) rename {crates => videre/crates}/videre-sdk/src/bindings.rs (88%) rename {crates => videre/crates}/videre-sdk/src/body.rs (100%) rename {crates => videre/crates}/videre-sdk/src/client.rs (100%) rename {crates => videre/crates}/videre-sdk/src/event.rs (100%) rename {crates => videre/crates}/videre-sdk/src/faults.rs (100%) rename {crates => videre/crates}/videre-sdk/src/keeper.rs (100%) rename {crates => videre/crates}/videre-sdk/src/lib.rs (100%) rename {crates => videre/crates}/videre-sdk/src/transport.rs (100%) rename {crates => videre/crates}/videre-sdk/src/value_flow.rs (100%) rename {crates => videre/crates}/videre-sdk/tests/adapter.rs (100%) rename {crates => videre/crates}/videre-status-body/Cargo.toml (100%) rename {crates => videre/crates}/videre-status-body/src/lib.rs (100%) rename {crates => videre/crates}/videre-test/Cargo.toml (91%) rename {crates => videre/crates}/videre-test/goldens/reference-header.json (100%) rename {crates => videre/crates}/videre-test/src/codec.rs (100%) rename {crates => videre/crates}/videre-test/src/fixture.rs (100%) rename {crates => videre/crates}/videre-test/src/header.rs (100%) rename {crates => videre/crates}/videre-test/src/lib.rs (100%) rename {crates => videre/crates}/videre-test/src/reconcile.rs (100%) rename {crates => videre/crates}/videre-test/src/reference.rs (100%) rename {crates => videre/crates}/videre-test/src/report.rs (100%) rename {crates => videre/crates}/videre-test/src/transport.rs (100%) rename {crates => videre/crates}/videre-test/tests/conformance.rs (100%) rename {crates => videre/crates}/videre-test/vectors/reference-body.json (100%) rename {modules => videre/modules}/examples/echo-client/Cargo.toml (92%) rename {modules => videre/modules}/examples/echo-client/module.toml (100%) rename {modules => videre/modules}/examples/echo-client/src/lib.rs (100%) rename {modules => videre/modules}/examples/echo-keeper/Cargo.toml (90%) rename {modules => videre/modules}/examples/echo-keeper/module.toml (100%) rename {modules => videre/modules}/examples/echo-keeper/src/lib.rs (100%) rename {modules => videre/modules}/examples/echo-venue/Cargo.toml (100%) rename {modules => videre/modules}/examples/echo-venue/module.toml (100%) rename {modules => videre/modules}/examples/echo-venue/src/lib.rs (100%) rename {modules => videre/modules}/fixtures/flaky-venue/Cargo.toml (100%) rename {modules => videre/modules}/fixtures/flaky-venue/module.toml (100%) rename {modules => videre/modules}/fixtures/flaky-venue/src/lib.rs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f28ace..41c17199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,3 +166,15 @@ jobs: with: tool: ripgrep - run: ./scripts/check-cow-orderbook-only.sh + + # Blocking dep-sync gate for the transitional three-grouping workspace: every + # crate is grouped under nexum/videre/shepherd and depends only within or below + # its tier, so no upward edge becomes a circular repo dependency at the carve + # (scripts/check-carve-groups.sh, M5 #403). + carve-groups: + name: carve-groups + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: ./.github/actions/rust-setup + - run: ./scripts/check-carve-groups.sh diff --git a/Cargo.toml b/Cargo.toml index 27461972..6f5a27d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,40 +1,40 @@ [workspace] members = [ - "crates/composable-cow", - "crates/cow-venue", - "crates/nexum-cli", - "crates/nexum-launch", - "crates/nexum-module-macros", - "crates/nexum-runtime", - "crates/nexum-sdk", - "crates/nexum-sdk-test", - "crates/nexum-tasks", - "crates/nexum-world", - "crates/no-std-probe", - "crates/shepherd", - "crates/videre-host", - "crates/videre-macros", - "crates/videre-sdk", - "crates/videre-status-body", - "crates/videre-test", - "modules/ethflow-watcher", - "modules/example", - "modules/examples/balance-tracker", - "modules/examples/echo-client", - "modules/examples/echo-keeper", - "modules/examples/echo-venue", - "modules/examples/http-probe", - "modules/examples/price-alert", - "modules/fixtures/clock-reader", - "modules/fixtures/flaky-bomb", - "modules/fixtures/flaky-venue", - "modules/fixtures/fuel-bomb", - "modules/fixtures/memory-bomb", - "modules/fixtures/panic-bomb", - "modules/fixtures/slow-host", - "modules/twap-monitor", - "tools/load-gen", - "tools/orderbook-mock", + "shepherd/crates/composable-cow", + "shepherd/crates/cow-venue", + "nexum/crates/nexum-cli", + "nexum/crates/nexum-launch", + "nexum/crates/nexum-module-macros", + "nexum/crates/nexum-runtime", + "nexum/crates/nexum-sdk", + "nexum/crates/nexum-sdk-test", + "nexum/crates/nexum-tasks", + "nexum/crates/nexum-world", + "videre/crates/no-std-probe", + "shepherd/crates/shepherd", + "videre/crates/videre-host", + "videre/crates/videre-macros", + "videre/crates/videre-sdk", + "videre/crates/videre-status-body", + "videre/crates/videre-test", + "shepherd/modules/ethflow-watcher", + "nexum/modules/example", + "nexum/modules/examples/balance-tracker", + "videre/modules/examples/echo-client", + "videre/modules/examples/echo-keeper", + "videre/modules/examples/echo-venue", + "nexum/modules/examples/http-probe", + "nexum/modules/examples/price-alert", + "nexum/modules/fixtures/clock-reader", + "nexum/modules/fixtures/flaky-bomb", + "videre/modules/fixtures/flaky-venue", + "nexum/modules/fixtures/fuel-bomb", + "nexum/modules/fixtures/memory-bomb", + "nexum/modules/fixtures/panic-bomb", + "nexum/modules/fixtures/slow-host", + "shepherd/modules/twap-monitor", + "nexum/tools/load-gen", + "shepherd/tools/orderbook-mock", ] resolver = "2" diff --git a/Dockerfile b/Dockerfile index 8cc4bf90..69a38621 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,17 +121,17 @@ COPY --from=build /src/target/wasm32-wasip2/release/*.wasm /opt/shepherd/modules # Module manifests (the `module.toml` next to each cdylib crate). The # engine resolves capability declarations + chain subscriptions from # these at supervisor boot. -COPY --from=build /src/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml -COPY --from=build /src/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml -COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml -COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml +COPY --from=build /src/shepherd/modules/twap-monitor/module.toml /opt/shepherd/manifests/twap-monitor.toml +COPY --from=build /src/shepherd/modules/ethflow-watcher/module.toml /opt/shepherd/manifests/ethflow-watcher.toml +COPY --from=build /src/nexum/modules/examples/price-alert/module.toml /opt/shepherd/manifests/price-alert.toml +COPY --from=build /src/nexum/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml # The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. # One manifest per chain: mainnet (cow-venue.toml) and Sepolia # (cow-venue.sepolia.toml); pick the one matching the run's chain. -COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml -COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml +COPY --from=build /src/shepherd/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/shepherd/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and diff --git a/README.md b/README.md index 95b7bdd4..f0b248cb 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ A module built against `nexum:host` runs on any Nexum-compatible host. CoW order | Path | Purpose | | --- | --- | -| `crates/nexum-runtime/` | The engine: a wasmtime host implementing the `nexum:host` contract. | -| `crates/nexum-launch/` | Launcher library: shared CLI, config load, tracing, preset launch. | -| `crates/nexum-cli/` | The bare `nexum` binary: the core lattice, no extension payload. | -| `crates/shepherd/` | The `shepherd` binary: the cow composition root registering the videre venue platform and the Prometheus add-on. | -| `crates/nexum-sdk/` | Guest SDK: host trait seam, bind macro, chain/config/address helpers, `wasi:http` fetch, tracing facade. | -| `crates/videre-sdk/` | Venue-platform SDK: the `videre:venue` client and adapter contracts. | -| `crates/cow-venue/` | The bundled CoW venue adapter component. | +| `nexum/crates/nexum-runtime/` | The engine: a wasmtime host implementing the `nexum:host` contract. | +| `nexum/crates/nexum-launch/` | Launcher library: shared CLI, config load, tracing, preset launch. | +| `nexum/crates/nexum-cli/` | The bare `nexum` binary: the core lattice, no extension payload. | +| `shepherd/crates/shepherd/` | The `shepherd` binary: the cow composition root registering the videre venue platform and the Prometheus add-on. | +| `nexum/crates/nexum-sdk/` | Guest SDK: host trait seam, bind macro, chain/config/address helpers, `wasi:http` fetch, tracing facade. | +| `videre/crates/videre-sdk/` | Venue-platform SDK: the `videre:venue` client and adapter contracts. | +| `shepherd/crates/cow-venue/` | The bundled CoW venue adapter component. | | `wit/nexum-host/` | The `nexum:host` WIT package: the host/guest contract. | | `wit/videre-venue/` | The `videre:venue` WIT package: the venue-adapter contract. | | `wit/shepherd-cow/` | `cow-events.wit`: the CoW event ABIs of record. | diff --git a/docs/00-overview.md b/docs/00-overview.md index 3268dbf5..aa7b76aa 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -8,10 +8,10 @@ Nexum is a WASM Component Model runtime that provides secure, sandboxed executio | Term | What it is | Where you find it | |---|---|---| -| **engine** (`nexum`) | A concrete implementation that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. | `crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | +| **engine** (`nexum`) | A concrete implementation that loads and runs WASM components. The 0.2 reference engine is a wasmtime-based server daemon. | `nexum/crates/nexum-runtime/`, the `nexum` binary, `cargo run -p nexum-cli` | | **host** (`nexum:host`) | The WIT contract: the host-imported interfaces (chain, identity, local-store, ...), types, and worlds that every engine implements and every module imports. | `wit/nexum-host/`, `package nexum:host@0.1.0`, Rust path `nexum::host::*` | -An engine implements `nexum:host` so that modules built against `nexum:host` can run on it. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `crates/nexum-cli`. A Rust embedder constructs an `EngineConfig` in code and calls `nexum_runtime::bootstrap::run_from_config`; see `crates/nexum-runtime/examples/embed.rs`. +An engine implements `nexum:host` so that modules built against `nexum:host` can run on it. The reference engine ships as two crates: the `nexum-runtime` library (embeddable, no CLI surface) and the `nexum` binary in `nexum/crates/nexum-cli`. A Rust embedder constructs an `EngineConfig` in code and calls `nexum_runtime::bootstrap::run_from_config`; see `nexum/crates/nexum-runtime/examples/embed.rs`. ## Architecture diff --git a/docs/01-runtime-environment.md b/docs/01-runtime-environment.md index 8d9ca094..250298f9 100755 --- a/docs/01-runtime-environment.md +++ b/docs/01-runtime-environment.md @@ -458,7 +458,7 @@ Both are needed: fuel for correctness, epochs for liveness. ## Resource Limits -A `ResourceLimiter` caps linear-memory growth per module store, enforced synchronously on every `memory.grow`. The cap is `[limits].memory_bytes` from `engine.toml` (default 64 MiB). Fuel, the per-dispatch wall-clock deadline, and the local-store byte quota are the other resolved caps (`fuel_per_event` 1B, `event_deadline_secs` 120, `state_bytes` 50 MiB); all live in `crates/nexum-runtime/src/engine_config.rs` and apply uniformly, per-module overrides being a 0.3 direction. +A `ResourceLimiter` caps linear-memory growth per module store, enforced synchronously on every `memory.grow`. The cap is `[limits].memory_bytes` from `engine.toml` (default 64 MiB). Fuel, the per-dispatch wall-clock deadline, and the local-store byte quota are the other resolved caps (`fuel_per_event` 1B, `event_deadline_secs` 120, `state_bytes` 50 MiB); all live in `nexum/crates/nexum-runtime/src/engine_config.rs` and apply uniformly, per-module overrides being a 0.3 direction. ## Async Integration diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index e9da3b92..23ad7621 100755 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -56,11 +56,11 @@ Key design points: - **`component` is a content hash**, not a filename. The runtime resolves it via the content store (see below). - **`[[subscription]]` blocks are declarative.** The module doesn't set up its own subscriptions imperatively - the runtime reads the manifest and wires up event sources before calling `init`. The 0.1 spelling was `[[subscribe]]` with `type = ...`; 0.2 uses `[[subscription]]` with `kind = ...` because `type` is a reserved word in several binding languages. -- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `modules/examples/http-probe` for a complete example. +- **`[capabilities]`** is new in 0.2 and now drives what the runtime links into the module's import space. A module that declares `http` imports the standard `wasi:http/outgoing-handler` interface - the SDK's `http::fetch` helper wraps it - and the host checks every outgoing request against the `[capabilities.http].allow` list; see `nexum/modules/examples/http-probe` for a complete example. - **Chain ids are declared per-subscription**, not in a top-level `[chains]` table - each `[[subscription]]` names its own `chain_id`. If `engine.toml` has no `[chains.]` entry for a chain a subscription names, the engine bails at boot, before any events dispatch (fast, clear error). - **`config`** is opaque to the runtime. 0.2 keeps 0.1's stringly-typed shape (`list>`); the host flattens TOML scalars (numbers, booleans) to their string form on the way through. A typed `config-value` variant is on the 0.3 roadmap, bundled with the manifest-parser work. -> Resource caps are engine-global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120), resolved in `crates/nexum-runtime/src/engine_config.rs`. Per-module `[module.resources]` overrides, per-module restart policy, and `optional`-import trap stubs are 0.3 directions. +> Resource caps are engine-global in 0.2, set in `engine.toml` `[limits]` (`fuel_per_event`, default 1B; `memory_bytes`, default 64 MiB; `state_bytes`, default 50 MiB; `event_deadline_secs`, default 120), resolved in `nexum/crates/nexum-runtime/src/engine_config.rs`. Per-module `[module.resources]` overrides, per-module restart policy, and `optional`-import trap stubs are 0.3 directions. ### Bundle Format diff --git a/docs/03-module-discovery.md b/docs/03-module-discovery.md index 21812b02..2e7cd042 100755 --- a/docs/03-module-discovery.md +++ b/docs/03-module-discovery.md @@ -12,7 +12,7 @@ path = "/var/nexum/twap-monitor/twap_monitor.wasm" manifest = "/var/nexum/twap-monitor/module.toml" ``` -This is the whole of discovery in 0.2. Content-addressed resolution (Swarm / IPFS / OCI) and `[[content.sources]]` are not wired: `EngineConfig::modules` resolves a `(component.wasm, module.toml)` pair on disk, nothing more (see `crates/nexum-runtime/src/engine_config.rs`). +This is the whole of discovery in 0.2. Content-addressed resolution (Swarm / IPFS / OCI) and `[[content.sources]]` are not wired: `EngineConfig::modules` resolves a `(component.wasm, module.toml)` pair on disk, nothing more (see `nexum/crates/nexum-runtime/src/engine_config.rs`). ## 0.3 direction: ENS and on-chain registry diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index f12f02a3..ad5ff4cb 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -2,7 +2,7 @@ This document describes the guest-side SDK crates. There are two personas, and both are shipped: the **module author**, served by `nexum-sdk`, and the **venue persona**, served by `videre-sdk` - which covers both sides of a venue: the adapter author who speaks one venue's protocol, and the keeper author who drives venues through the typed client. -For the architectural decision behind the host-trait seam both personas build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc under `crates/nexum-sdk/` and `crates/videre-sdk/`. +For the architectural decision behind the host-trait seam both personas build on, see [ADR-0009](adr/0009-host-trait-surface.md). For the rustdoc-level API reference, see [`sdk.md`](sdk.md) and the rustdoc under `nexum/crates/nexum-sdk/` and `videre/crates/videre-sdk/`. ## The two personas @@ -95,7 +95,7 @@ Every module keeps its own `wit_bindgen::generate!` call (the macro emits types `nexum-module-macros` ships one attribute macro, re-exported as `nexum_sdk::module`. Apply it to an inherent `impl` block whose methods are named event handlers - `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - and the macro reads the crate's `module.toml`, synthesizes the per-module world from its `[capabilities]`, and generates the `wit_bindgen::generate!` call for that world, the capability-selected `bind_host_via_wit_bindgen!` invocation, a `Guest` implementation whose `on_event` dispatches to whichever handlers are present (absent handlers become a no-op for that event), and `export!`: ```rust -// modules/examples/http-probe/src/lib.rs (shipped) +// nexum/modules/examples/http-probe/src/lib.rs (shipped) mod logic; use nexum::host::types; @@ -196,7 +196,7 @@ conforming `derive-header` projects from them. ## Walkthrough: authoring a venue on videre -The shipped reference pair is `modules/examples/echo-venue` (adapter) and `modules/examples/echo-keeper` (driver); the production instance of the same shape is `crates/cow-venue` driven by `modules/twap-monitor`. +The shipped reference pair is `videre/modules/examples/echo-venue` (adapter) and `videre/modules/examples/echo-keeper` (driver); the production instance of the same shape is `shepherd/crates/cow-venue` driven by `shepherd/modules/twap-monitor`. 1. **Declare the manifest.** A venue adapter is a component with a `module.toml` whose kind names it: @@ -249,7 +249,7 @@ goldens, and hold the adapter to them with `videre-test` in the crate's tests. N ```toml [[adapters]] path = "target/wasm32-wasip2/release/echo_venue.wasm" - manifest = "modules/examples/echo-venue/module.toml" + manifest = "videre/modules/examples/echo-venue/module.toml" http_allow = [] # the operator's outbound-HTTP grant ``` @@ -279,11 +279,11 @@ An accepted submit is watched implicitly: the registry polls the adapter's `stat CoW ships as the production instance of the persona, in two crates so the venue stays orderbook-only: - **`cow-venue`** - feature slices. `body` (default, `no_std`): the -order intent body types and codec, light enough for any keeper or adapter to carry. `client`: the typed `CowClient` bound to the CoW venue, the deterministic `intent_id` journal key, and the table-driven retry classification generated from the shipped `data/classification.toml`. `assembly`: the chain-edge order projections and orderbook submission bodies. `adapter`: the venue adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, manifest at `crates/cow-venue/module.toml`). +order intent body types and codec, light enough for any keeper or adapter to carry. `client`: the typed `CowClient` bound to the CoW venue, the deterministic `intent_id` journal key, and the table-driven retry classification generated from the shipped `data/classification.toml`. `assembly`: the chain-edge order projections and orderbook submission bodies. `adapter`: the venue adapter component itself (`CowAdapter` under `#[videre_sdk::venue]`, manifest at `shepherd/crates/cow-venue/module.toml`). - **`composable-cow`** - the ComposableCoW keeper machinery, kept out of the venue: the conditional-order `ComposableBody`, the structured poll seam (`Verdict`, with the deployed 1.x reverting wire quarantined behind `LegacyRevertAdapter`, per [ADR-0013](adr/0013-composable-cow-structured-poll.md)), and the `run` slice composing the poll loop over the typed `CowClient`. -The shipped CoW keepers - `modules/twap-monitor`, `modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` modules on this surface. +The shipped CoW keepers - `shepherd/modules/twap-monitor`, `shepherd/modules/ethflow-watcher` - are ordinary `#[videre_sdk::keeper]` modules on this surface. ## Non-Rust module and adapter authors diff --git a/docs/06-production-hardening.md b/docs/06-production-hardening.md index 1b66b4c9..d4299596 100755 --- a/docs/06-production-hardening.md +++ b/docs/06-production-hardening.md @@ -4,7 +4,7 @@ The design facts behind the runtime's resource enforcement, restart policy, RPC ## Resource enforcement -Four dimensions are capped per module. Caps come from `[limits]` in `engine.toml`, resolving to built-in defaults (`crates/nexum-runtime/src/engine_config.rs`). They apply uniformly to every module; per-module overrides land in 0.3. +Four dimensions are capped per module. Caps come from `[limits]` in `engine.toml`, resolving to built-in defaults (`nexum/crates/nexum-runtime/src/engine_config.rs`). They apply uniformly to every module; per-module overrides land in 0.3. ### Fuel @@ -37,7 +37,7 @@ A module that keeps trapping is a poison pill. After `max_failures` traps within ## RPC resilience -RPC I/O flows through one alloy provider per chain, opened from `engine.toml` at boot (`crates/nexum-runtime/src/host/provider_pool.rs`). Each chain has a single `rpc_url`; there is no secondary-endpoint failover. The `chain::request` host function forwards the typed method to the provider. +RPC I/O flows through one alloy provider per chain, opened from `engine.toml` at boot (`nexum/crates/nexum-runtime/src/host/provider_pool.rs`). Each chain has a single `rpc_url`; there is no secondary-endpoint failover. The `chain::request` host function forwards the typed method to the provider. Two layers harden it: diff --git a/docs/07-rpc-namespace-design.md b/docs/07-rpc-namespace-design.md index f89804b7..79e4ef33 100755 --- a/docs/07-rpc-namespace-design.md +++ b/docs/07-rpc-namespace-design.md @@ -71,7 +71,7 @@ A module that only needs raw JSON calls `host.request(chain_id, method, params)` ## Order submission -Submitting an order or intent is not a chain namespace. It is the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the installed venue adapter (for CoW, `crates/cow-venue`) speaks the orderbook wire. See [doc 08](08-platform-generalisation.md) for the layer model and [doc 05](05-sdk-design.md) for the venue SDK. +Submitting an order or intent is not a chain namespace. It is the `videre:venue` venue-adapter contract: a keeper calls `videre:venue/client`, and the installed venue adapter (for CoW, `shepherd/crates/cow-venue`) speaks the orderbook wire. See [doc 08](08-platform-generalisation.md) for the layer model and [doc 05](05-sdk-design.md) for the venue SDK. ## Testing diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index 20cb3867..2a42ee17 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -266,7 +266,7 @@ interface adapter { } ``` -The two faces meet in the host. The venue platform (`crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this path; typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). +The two faces meet in the host. The venue platform (`videre/crates/videre-host`, one `nexum-runtime` extension registered at the composition root) holds the `VenueRegistry`, links `videre:venue/client` into keeper worlds, and routes each call: resolve the venue id to its installed adapter, run the advisory egress guard over the adapter's pure `derive-header` projection, then invoke the adapter face. Accepted submits go under a status watch; the platform polls the adapter's `status` and fans transitions back to subscribed modules as `intent-status` events. Intent bodies are opaque on this path; typing is a guest-side agreement between keeper and adapter over the venue's published `IntentBody` schema ([doc 05](05-sdk-design.md#bodies-the-intentbody-derive)). A new domain adds a component and (usually) a body crate, never a WIT package or a host change: write the adapter with `#[videre_sdk::venue]`, publish its codec vectors and header goldens, and install it via the engine's `[[adapters]]` table. The `shepherd` binary is exactly this composition: the core lattice plus the videre platform, with CoW entering only as the bundled `cow-venue` adapter. diff --git a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md index 11f584b1..bb51999f 100644 --- a/docs/adr/0001-engine-toml-separate-from-nexum-toml.md +++ b/docs/adr/0001-engine-toml-separate-from-nexum-toml.md @@ -20,5 +20,5 @@ The engine config carries each module's manifest path; the two files never colla ## Consequences - A deployment needs both files. A missing `engine.toml` falls back to no chains and the default `state_dir` (`./data`); the example logging module still runs, chain-backed capabilities report `unsupported`. -- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required. +- A `module.toml` without a `[capabilities]` block triggers the 0.1-compat deprecation warning in `manifest::fallback_manifest` (`nexum/crates/nexum-runtime/src/manifest/load.rs`) and treats every linked capability as required. - Module-bundle redistribution carries `module.toml` with the artifact; engines ship no templates. diff --git a/docs/adr/0009-host-trait-surface.md b/docs/adr/0009-host-trait-surface.md index 47ba0618..ebe59818 100644 --- a/docs/adr/0009-host-trait-surface.md +++ b/docs/adr/0009-host-trait-surface.md @@ -22,4 +22,4 @@ Three coupled choices: - Module code is testable in native Rust without `wasm32-wasip2`; every module ships a `MockHost` unit-test suite. - New capabilities add a new trait plus a `MockX` in `nexum-sdk-test`; modules that do not use a capability bound only on the subset they touch. -- The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. +- The `#[nexum_sdk::module]` macro derives a per-module world from the manifest's `[capabilities]`, so a macro-built component's imports equal its declarations by construction and an undeclared capability is a compile-time error. `enforce_capabilities` (`nexum/crates/nexum-runtime/src/manifest/capabilities.rs`) is the boot-time backstop; a hand-rolled module compiled against the supertype world that imports an undeclared capability fails there rather than at compile time. diff --git a/docs/adr/0013-composable-cow-structured-poll.md b/docs/adr/0013-composable-cow-structured-poll.md index 49fa4ea9..ffb56ab3 100644 --- a/docs/adr/0013-composable-cow-structured-poll.md +++ b/docs/adr/0013-composable-cow-structured-poll.md @@ -24,7 +24,7 @@ The orderbook-API submit-error `errorType` table (the REST POST response) is a s ## Current state -The seam migration has shipped: `crates/composable-cow` exports the structured `Verdict`, the `run` sweep dispatches on it (`crates/composable-cow/src/run.rs`), and the deployed reverting wire is served by `LegacyRevertAdapter`. The fork is deployed on no target chain, so `LegacyRevertAdapter` is the live poll path; the structured non-reverting `PollResult` wire is not yet exercised in production. The wire-swap remains gated on the fork deploying. +The seam migration has shipped: `shepherd/crates/composable-cow` exports the structured `Verdict`, the `run` sweep dispatches on it (`shepherd/crates/composable-cow/src/run.rs`), and the deployed reverting wire is served by `LegacyRevertAdapter`. The fork is deployed on no target chain, so `LegacyRevertAdapter` is the live poll path; the structured non-reverting `PollResult` wire is not yet exercised in production. The wire-swap remains gated on the fork deploying. ## Consequences diff --git a/docs/deployment.md b/docs/deployment.md index c430ceaa..6cc400d8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -56,11 +56,11 @@ The full `[limits.*]` subtables (`http`, `chain`, `logs`, `poison`, `dispatch`, ```toml [[modules]] path = "modules/twap-monitor.wasm" -manifest = "modules/twap-monitor/module.toml" # defaults to module.toml beside `path` +manifest = "shepherd/modules/twap-monitor/module.toml" # defaults to module.toml beside `path` [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.toml" +manifest = "shepherd/crates/cow-venue/module.toml" http_allow = ["api.cow.fi"] # outbound wasi:http allowlist the operator grants ``` @@ -101,7 +101,7 @@ The single-module shortcut takes positional paths and synthesizes a one-module c ```sh cargo run -p shepherd -- \ target/wasm32-wasip2/release/twap_monitor.wasm \ - modules/twap-monitor/module.toml + shepherd/modules/twap-monitor/module.toml ``` At boot the engine creates `state_dir/local-store.redb`, opens RPC providers, loads components, calls `init`, and begins dispatching. Console output is `tracing` JSON, or pretty with `--pretty-logs`. For systemd runs see [`docs/production.md`](./production.md). diff --git a/docs/deployment/multi-chain.md b/docs/deployment/multi-chain.md index 36a1e138..bf1c4a47 100644 --- a/docs/deployment/multi-chain.md +++ b/docs/deployment/multi-chain.md @@ -53,7 +53,7 @@ environment: The `cow-venue` adapter resolves the orderbook base URL from its `[config] chain` using the canonical `https://api.cow.fi//` pattern. Override it per adapter in the adapter's `module.toml` to point at a barn (staging) instance or a local mock: ```toml -# crates/cow-venue/module.toml +# shepherd/crates/cow-venue/module.toml [config] chain = 11155111 orderbook-url = "https://barn.api.cow.fi/sepolia/" @@ -130,4 +130,4 @@ Each new chain adds one always-on block subscription, N log subscriptions (one p - [`docs/deployment.md`](../deployment.md): `engine.toml` reference and single-module quickstart. - [`docs/production.md`](../production.md): systemd, RPC selection, alerting. - [`docs/deployment/docker.md`](./docker.md): container image layout. -- `modules/twap-monitor/module.toml`, `modules/ethflow-watcher/module.toml`: canonical subscription examples. +- `shepherd/modules/twap-monitor/module.toml`, `shepherd/modules/ethflow-watcher/module.toml`: canonical subscription examples. diff --git a/docs/design/carve-workspace.md b/docs/design/carve-workspace.md new file mode 100644 index 00000000..39b93173 --- /dev/null +++ b/docs/design/carve-workspace.md @@ -0,0 +1,47 @@ +# Transitional three-grouping workspace (M5) + +The monorepo is reorganized into the three prospective repo roots as one cargo +workspace, so it keeps building as a single unit with a shared `Cargo.lock` up to +the physical carve (#407). This is the refactor-now-cut-later step (#403). + +## Groupings + +Each top-level dir is a future repo root. Tier order is `nexum <- videre <- shepherd`. + +| Dir | Tier | Repo | Contents | +|-------------|------|------------------|----------| +| `nexum/` | L1 | `nexum-runtime` | universal runtime, SDK, macros, launcher, `nexum-cli` bare bin; universal-package example modules and runtime fixtures | +| `videre/` | L2 | `videre` | intent/venue SDK, host, macros, status-body; the generic `echo` reference venue | +| `shepherd/` | L3 | `shepherd` | `cow-venue`, `composable-cow`, the `shepherd` composition-root bin, the cow reference modules | + +Layout within a group: `/crates/*`, `/modules/*`, `/tools/*`. + +## Dependency invariant + +A crate depends only within its own tier or a lower one. An upward edge (nexum on +videre, videre on shepherd) would become a circular repo dependency at the carve, +so it is rejected in CI by `scripts/check-carve-groups.sh` (job `carve-groups`). +The physical layout is the source of truth: a crate's group is its top-level dir, +and the check derives every edge's tier from cargo metadata. There is no separate +mapping to drift. + +## Cross-repo dependency medium + +Cross-group edges stay as intra-workspace path-deps in this transitional umbrella, +preserving the single hoisted dependency table and shared lockfile. The medium +converges in three steps: + +1. Path-deps (now) — one workspace, one `Cargo.lock`, atomic folds. +2. Git-tag pins (at carve, #407) — each repo pins its cross-repo deps to a tagged + release; the `carve-groups` gate is joined by a dep-sync check that the pins + resolve to the tagged versions. +3. crates.io (post-carve) — published semver releases replace the git-tag pins. + +## WIT resolution + +WIT stays in a single root `wit/` for this step. `resolve_wit_package` +(`nexum/crates/nexum-world`) walks manifest-dir ancestors to the nearest `wit/`, +so every crate resolves the shared tree regardless of depth. Splitting `wit/` into +per-group `wit/` + `wit/deps/` requires the crate-local wit-deps flip and is done +in #404/#405, not here. The hardcoded `wit_bindgen::generate!` path lists that +bypass the resolver were re-based one level deeper by the move. diff --git a/docs/diagrams/diagrams.md b/docs/diagrams/diagrams.md index 9e7d4914..2eec4e0b 100644 --- a/docs/diagrams/diagrams.md +++ b/docs/diagrams/diagrams.md @@ -423,7 +423,7 @@ flowchart TD |---|---| | **WASM Module** | The guest program. It calls imported WIT functions exactly like regular function calls - it has no visibility into the host machinery behind them. | | **wasmtime Linker** | `Linker` built once at startup. `wasmtime::component::bindgen!` generates a `Shepherd` world struct and one trait per WIT interface (e.g. `shepherd::cow::cow_api::Host`, `nexum::host::local_store::Host`). `Shepherd::add_to_linker(&mut linker, \|state\| state)` registers every trait method as a host function. After that, calls from WASM resolve with zero dynamic dispatch overhead - the vtable is built at link time, not per-call. | -| **HostState - manifest.required check** | In 0.2, capability enforcement is **link-time**, not per-call. At boot, `manifest::enforce_capabilities` (in `crates/nexum-runtime/src/manifest/capabilities.rs`) cross-checks every capability-bearing WIT import of the component against the `[capabilities].required` ∪ `[capabilities].optional` set in `module.toml`, with names validated against `KNOWN_CAPABILITIES`. A module that imports a capability it did not declare fails instantiation - it never reaches dispatch. This is structurally equivalent to per-call gating for the 0.2 capability set: an undeclared capability cannot link the relevant WIT, so unauthorised calls fail at component link rather than per-call dispatch. Per-call gating in `HostState` (returning `fault.denied` per invocation, useful for finer-grained policies or capability revocation at runtime) remains a future direction for 0.3+ if a richer threat model demands it; it is not in 0.2 scope. | +| **HostState - manifest.required check** | In 0.2, capability enforcement is **link-time**, not per-call. At boot, `manifest::enforce_capabilities` (in `nexum/crates/nexum-runtime/src/manifest/capabilities.rs`) cross-checks every capability-bearing WIT import of the component against the `[capabilities].required` ∪ `[capabilities].optional` set in `module.toml`, with names validated against `KNOWN_CAPABILITIES`. A module that imports a capability it did not declare fails instantiation - it never reaches dispatch. This is structurally equivalent to per-call gating for the 0.2 capability set: an undeclared capability cannot link the relevant WIT, so unauthorised calls fail at component link rather than per-call dispatch. Per-call gating in `HostState` (returning `fault.denied` per invocation, useful for finer-grained policies or capability revocation at runtime) remains a future direction for 0.3+ if a richer threat model demands it; it is not in 0.2 scope. | | **tracing::info!** | Every host call emits a structured trace event (capability name, chain id, etc.). Operators use `RUST_LOG=shepherd=debug` to see every call a module makes. | | **host backend Rust function** | `HostState` implements one generated trait per WIT interface. Each `async fn` in the trait receives `&mut self` (giving access to all host resources) and returns the WIT-mapped Rust type. There are no CoW-specific backends - only the universal ones plus `cow-api` (ADR-0006). | | **OrderBookPool** | Looks up the `OrderBookApi` client for the requested chain and calls `post_order`. Returns a 56-byte `OrderUid` on success or an `OrderPostError`-bearing host error on failure. | diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 09d94c48..8062ab99 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -43,11 +43,11 @@ RPC_URL_SEPOLIA_HTTP=https://eth-sepolia.g.alchemy.com/v2/ The script: -1. Sources `scripts/load-bootstrap.sh`: starts Anvil (port 8545) and `tools/orderbook-mock` (port 9999). -2. Builds the two module `.wasm`, the `shepherd` binary, and `tools/load-gen`. +1. Sources `scripts/load-bootstrap.sh`: starts Anvil (port 8545) and `shepherd/tools/orderbook-mock` (port 9999). +2. Builds the two module `.wasm`, the `shepherd` binary, and `nexum/tools/load-gen`. 3. Starts the engine on `engine.load.toml`. 4. Snapshots `/metrics`. -5. Runs `tools/load-gen` for the requested duration. +5. Runs `nexum/tools/load-gen` for the requested duration. 6. Snapshots `/metrics` again. 7. Tears everything down and drops a report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md`. @@ -56,9 +56,9 @@ Ctrl-C triggers `load_teardown`. If a child escapes, run `./scripts/load-teardow ## 2. Components - **Anvil (port 8545)**: `anvil --fork-url $RPC_URL_SEPOLIA_HTTP --port 8545 --block-time 1`. Forks Sepolia at the latest block, inheriting ComposableCoW, CoWSwapEthFlow, the TWAP handler, WETH9, and COW at their pinned addresses, so the test EOA calls real bytecode with no local deployment. -- **Mock orderbook (port 9999)**: `tools/orderbook-mock` serves `POST /api/v1/orders`, returning a synthetic 56-byte OrderUid. Knobs (env in `scripts/load-bootstrap.sh`): `--latency-ms` injects response latency; `--error-rate` returns a fraction as an `ApiError` envelope, alternating `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). Leave both 0 for the saturation probe to isolate the engine-side bottleneck. +- **Mock orderbook (port 9999)**: `shepherd/tools/orderbook-mock` serves `POST /api/v1/orders`, returning a synthetic 56-byte OrderUid. Knobs (env in `scripts/load-bootstrap.sh`): `--latency-ms` injects response latency; `--error-rate` returns a fraction as an `ApiError` envelope, alternating `InsufficientFee` (`TryNextBlock`) and `InvalidSignature` (`Drop`). Leave both 0 for the saturation probe to isolate the engine-side bottleneck. - **Engine (`engine.load.toml`)**: RPC `ws://localhost:8545`; cow orderbook URL `http://localhost:9999`; Prometheus on `127.0.0.1:9100`; `state_dir = ./data/load` (wiped each run); modules `twap-monitor` + `ethflow-watcher`. -- **Load generator (`tools/load-gen`)**: connects to the Anvil WS, calls `anvil_impersonateAccount` + `anvil_setBalance` on the pinned EOA, then each new block fires N `ComposableCoW.create(...)` + M `CoWSwapEthFlow.createOrder(...)` calls, each with a fresh counter-derived salt. +- **Load generator (`nexum/tools/load-gen`)**: connects to the Anvil WS, calls `anvil_impersonateAccount` + `anvil_setBalance` on the pinned EOA, then each new block fires N `ComposableCoW.create(...)` + M `CoWSwapEthFlow.createOrder(...)` calls, each with a fresh counter-derived salt. ## 3. Acceptance reading @@ -83,5 +83,5 @@ The report at `docs/operations/load-reports/load-NxM-YYYY-MM-DD.md` carries mock - Sister doc (live Sepolia E2E): `docs/operations/e2e-testnet-runbook.md` - Engine config: `engine.load.toml` -- Tools: `tools/orderbook-mock/`, `tools/load-gen/` +- Tools: `shepherd/tools/orderbook-mock/`, `nexum/tools/load-gen/` - Scripts: `scripts/load-bootstrap.sh`, `scripts/load-run.sh`, `scripts/load-teardown.sh` diff --git a/docs/operations/m2-testnet-runbook.md b/docs/operations/m2-testnet-runbook.md index 8891d17d..79ecf38c 100644 --- a/docs/operations/m2-testnet-runbook.md +++ b/docs/operations/m2-testnet-runbook.md @@ -108,6 +108,6 @@ The local store is a redb file with no standalone dump tool. Reboot the engine o ## 5. References -- Engine config schema: `crates/nexum-runtime/src/engine_config.rs` -- M2 modules: `modules/twap-monitor/`, `modules/ethflow-watcher/` +- Engine config schema: `nexum/crates/nexum-runtime/src/engine_config.rs` +- M2 modules: `shepherd/modules/twap-monitor/`, `shepherd/modules/ethflow-watcher/` - ADR-0005 (cow-api routing), ADR-0006 (twap + ethflow helpers), ADR-0009 (host trait surface) diff --git a/docs/operations/m3-testnet-runbook.md b/docs/operations/m3-testnet-runbook.md index 0c16cd27..ea655581 100644 --- a/docs/operations/m3-testnet-runbook.md +++ b/docs/operations/m3-testnet-runbook.md @@ -94,6 +94,6 @@ INFO stop-loss submitted submitted:0x ## 5. References - M3 modules: `modules/examples/{price-alert,balance-tracker,stop-loss}/` -- SDK chain helpers: `crates/nexum-sdk/src/chain/` +- SDK chain helpers: `nexum/crates/nexum-sdk/src/chain/` - ADR-0009 (host trait surface) - M2 runbook (sister doc): `docs/operations/m2-testnet-runbook.md` diff --git a/docs/sdk.md b/docs/sdk.md index 8f21fc8e..d3a5526e 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -54,14 +54,14 @@ JSON plumbing (in `nexum-sdk`): seam plus the SDK's host-neutral `Fault` vocabulary (same cases as wit-bindgen's, bridged via one-liner converters per module), in `nexum-sdk`. `config` and `address` parsing helpers sit alongside it. - [`http`](../target/doc/nexum_sdk/http/index.html) - outbound -HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free module tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `modules/examples/http-probe` for a complete module. +HTTP over wasi:http, in `nexum-sdk`. `http::fetch` performs one synchronous request from a `wasm32-wasip2` guest; requests and responses are the standard `http` crate's `Request` / `Response` types, and the SDK's `Fetch` trait seam, `FetchError` taxonomy, and per-phase `FetchOptions` timeouts compile on every target for host-free module tests. The module must declare the `http` capability and list the hosts it may contact in `[capabilities.http].allow` in its `module.toml`; an off-list host surfaces as the matchable `FetchError::Denied`, distinct from timeouts and transport failures. See `nexum/modules/examples/http-probe` for a complete module. ## Companions: nexum-sdk-test and videre-test -Add `nexum-sdk-test` as a dev-dep on the module crate to write module tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../crates/nexum-sdk-test/src/lib.rs), [videre-test](../crates/videre-test/src/lib.rs)) for the usage pattern. +Add `nexum-sdk-test` as a dev-dep on the module crate to write module tests against in-memory mocks; its `MockHost` covers the chain, local store and logging seams. `videre-test` is the venue-side kit: codec vectors and header goldens for conformance runs, plus transport mocks such as `MockFetch`. See the crate docs ([nexum-sdk-test](../nexum/crates/nexum-sdk-test/src/lib.rs), [videre-test](../videre/crates/videre-test/src/lib.rs)) for the usage pattern. ## Versioning -The SDK crates are currently `0.1.0` and live at `crates/nexum-sdk/` and `crates/videre-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. +The SDK crates are currently `0.1.0` and live at `nexum/crates/nexum-sdk/` and `videre/crates/videre-sdk/` in the shepherd monorepo. They are not yet published to crates.io; modules depend on them via workspace paths. The `cowprotocol` crate is published to crates.io; the workspace declares `cowprotocol = "0.2.0"` in `[workspace.dependencies]`, with a temporary `[patch.crates-io]` git override to `nullislabs/cow-rs` pending a release that ships the hash-only `OrderCreationAppData` constructor (see the comment above the patch block in the root `Cargo.toml`). Module Cargo.toml files that inherit from the workspace pick it up automatically. diff --git a/docs/testing-runtime-harness.md b/docs/testing-runtime-harness.md index b61a8c02..8c268ec1 100644 --- a/docs/testing-runtime-harness.md +++ b/docs/testing-runtime-harness.md @@ -13,7 +13,7 @@ test only needs "given input X the module does Y", it belongs in the module's pu ## What `test-utils` provides -`crates/nexum-runtime`'s `test_utils` module (gated behind the `test-utils` cargo feature) ships two layers: +`nexum/crates/nexum-runtime`'s `test_utils` module (gated behind the `test-utils` cargo feature) ships two layers: - **The bare mock backends** - `MockChainProvider`, `MockStateStore`, and `MockTypes` implement the engine's component-seam traits with no network and no disk. `mock_components` / `mock_components_from` bundle them into a `Components` ready for `Supervisor::boot`. Use these when a test needs to drive the supervisor directly - multi-module scenarios, custom extensions, or checking host-interface wiring the harness below doesn't expose. @@ -25,7 +25,7 @@ the same mocks: launch *one* module through the real public `RuntimeBuilder` pat `test_utils` only compiles under the `test-utils` feature (it pulls `tempfile`, needed for manifest staging): ```toml -# crates/nexum-runtime/Cargo.toml +# nexum/crates/nexum-runtime/Cargo.toml [features] test-utils = ["dep:tempfile"] diff --git a/docs/tutorial-first-module.md b/docs/tutorial-first-module.md index ad7b108a..2147b86a 100644 --- a/docs/tutorial-first-module.md +++ b/docs/tutorial-first-module.md @@ -1,6 +1,6 @@ # Build your first module -A walkthrough of the shipped `price-alert` example: a module that polls a Chainlink oracle on every block and logs when the price crosses a threshold. It exercises the three load-bearing patterns of a module: a block subscription, a `chain` read with ABI decode, and `[config]`-driven behaviour. Read the real files alongside this page under [`modules/examples/price-alert`](../modules/examples/price-alert). +A walkthrough of the shipped `price-alert` example: a module that polls a Chainlink oracle on every block and logs when the price crosses a threshold. It exercises the three load-bearing patterns of a module: a block subscription, a `chain` read with ABI decode, and `[config]`-driven behaviour. Read the real files alongside this page under [`nexum/modules/examples/price-alert`](../nexum/modules/examples/price-alert). Venue submission (signing and posting an order to a venue such as CoW) is a separate concern layered on `videre-sdk`; see [Where to go from here](#where-to-go-from-here). @@ -18,7 +18,7 @@ Build and run the minimal `example` module to confirm the engine works: cargo build --target wasm32-wasip2 --release -p example cargo run -p nexum-cli -- \ target/wasm32-wasip2/release/example.wasm \ - modules/example/module.toml + nexum/modules/example/module.toml ``` You should see the example module's `init` log line. Triage the build before continuing if it does not appear. @@ -86,7 +86,7 @@ every_n_blocks = "1" ``` - `required` lists the host capabilities the module imports. The engine enforces the list at instantiation: missing a used capability is a hard error. -- `[capabilities.http].allow` is empty because `price-alert` makes no outbound HTTP. A module that needs it declares the `http` capability, lists the hosts it may contact, and calls `nexum_sdk::http::fetch`; an off-list host returns the matchable `FetchError::Denied`. See [`modules/examples/http-probe`](../modules/examples/http-probe). +- `[capabilities.http].allow` is empty because `price-alert` makes no outbound HTTP. A module that needs it declares the `http` capability, lists the hosts it may contact, and calls `nexum_sdk::http::fetch`; an off-list host returns the matchable `FetchError::Denied`. See [`nexum/modules/examples/http-probe`](../nexum/modules/examples/http-probe). - `[config]` values are strings. `init` parses them into a typed `Settings`. ## The pure logic @@ -212,7 +212,7 @@ Run the engine over the module, supplying the chain config: ```sh cargo run -p nexum-cli -- \ target/wasm32-wasip2/release/price_alert.wasm \ - modules/examples/price-alert/module.toml \ + nexum/modules/examples/price-alert/module.toml \ --engine-config engine.toml ``` @@ -220,9 +220,9 @@ You should see the `init` line, then one `price-alert: ok` or `price-alert: TRIG ## Where to go from here -- Venue submission: a module that signs and posts orders to a venue depends on `videre-sdk` and a venue adapter crate (`cow-venue` for CoW), and uses `#[videre_sdk::keeper]` rather than `#[nexum_sdk::module]`. See [`modules/twap-monitor`](../modules/twap-monitor) and [docs/sdk.md](sdk.md). -- Local state: declare `local-store` and persist through `host.set`/`host.get`; see [`modules/examples/balance-tracker`](../modules/examples/balance-tracker). -- Outbound HTTP: [`modules/examples/http-probe`](../modules/examples/http-probe). +- Venue submission: a module that signs and posts orders to a venue depends on `videre-sdk` and a venue adapter crate (`cow-venue` for CoW), and uses `#[videre_sdk::keeper]` rather than `#[nexum_sdk::module]`. See [`shepherd/modules/twap-monitor`](../shepherd/modules/twap-monitor) and [docs/sdk.md](sdk.md). +- Local state: declare `local-store` and persist through `host.set`/`host.get`; see [`nexum/modules/examples/balance-tracker`](../nexum/modules/examples/balance-tracker). +- Outbound HTTP: [`nexum/modules/examples/http-probe`](../nexum/modules/examples/http-probe). - Resource limits: [docs/deployment.md](deployment.md). ## Reference @@ -231,4 +231,4 @@ You should see the `init` line, then one `price-alert: ok` or `price-alert: TRIG - Deployment: [docs/deployment.md](deployment.md) - ADR-0001 (`engine.toml` vs `module.toml` split) - ADR-0006 (TWAP and EthFlow as guest modules, no specialised WIT interfaces) -- Worked examples: [`price-alert`](../modules/examples/price-alert/), [`balance-tracker`](../modules/examples/balance-tracker/), [`twap-monitor`](../modules/twap-monitor/), [`ethflow-watcher`](../modules/ethflow-watcher/) +- Worked examples: [`price-alert`](../nexum/modules/examples/price-alert/), [`balance-tracker`](../nexum/modules/examples/balance-tracker/), [`twap-monitor`](../shepherd/modules/twap-monitor/), [`ethflow-watcher`](../shepherd/modules/ethflow-watcher/) diff --git a/engine.e2e.toml b/engine.e2e.toml index 3b664336..e3a7d26b 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -3,10 +3,10 @@ # Boots all 4 production + example modules on Sepolia simultaneously # for the 4-6 h E2E run: # -# - twap-monitor (modules/twap-monitor) -# - ethflow-watcher (modules/ethflow-watcher) -# - price-alert (modules/examples/price-alert) -# - balance-tracker (modules/examples/balance-tracker) +# - twap-monitor (shepherd/modules/twap-monitor) +# - ethflow-watcher (shepherd/modules/ethflow-watcher) +# - price-alert (nexum/modules/examples/price-alert) +# - balance-tracker (nexum/modules/examples/balance-tracker) # # This is the integration step between the M3 single-chain runbook # (`engine.m3.toml`, 2 modules) and the 7-day soak @@ -47,19 +47,19 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" # --- adapters --------------------------------------------------------- @@ -68,5 +68,5 @@ manifest = "modules/examples/balance-tracker/module.toml" # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.sepolia.toml" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 69184ca4..748fd86e 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -113,5 +113,5 @@ rpc_url = "${BASE_RPC_URL}" # [[adapters]] # path = "target/wasm32-wasip2/release/cow_venue.wasm" -# manifest = "crates/cow-venue/module.toml" +# manifest = "shepherd/crates/cow-venue/module.toml" # http_allow = ["api.cow.fi"] diff --git a/engine.load.toml b/engine.load.toml index 4f8072b2..4c837856 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -35,16 +35,16 @@ rpc_url = "ws://localhost:8545" [[modules]] path = "./target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "./modules/twap-monitor/module.toml" +manifest = "./shepherd/modules/twap-monitor/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Load-variant manifest: Sepolia chain id with the # orderbook re-pointed at tools/orderbook-mock. [[adapters]] path = "./target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "./crates/cow-venue/module.load.toml" +manifest = "./shepherd/crates/cow-venue/module.load.toml" http_allow = ["localhost"] [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "./modules/ethflow-watcher/module.toml" +manifest = "./shepherd/modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index 11f29389..5a45e4cd 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -28,16 +28,16 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" # The cow venue adapter twap-monitor submits through (`just # build-cow-venue`). Sepolia manifest: the adapter's orderbook must # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.sepolia.toml" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.m3.toml b/engine.m3.toml index 8889dd4b..5a1ee826 100644 --- a/engine.m3.toml +++ b/engine.m3.toml @@ -25,11 +25,11 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" # --- adapters --------------------------------------------------------- @@ -38,5 +38,5 @@ manifest = "modules/examples/balance-tracker/module.toml" # match the chain the oracle is read on. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.sepolia.toml" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 567be71b..07f337ea 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -51,19 +51,19 @@ rpc_url = "wss://ethereum-sepolia-rpc.publicnode.com" [[modules]] path = "target/wasm32-wasip2/release/twap_monitor.wasm" -manifest = "modules/twap-monitor/module.toml" +manifest = "shepherd/modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" -manifest = "modules/ethflow-watcher/module.toml" +manifest = "shepherd/modules/ethflow-watcher/module.toml" [[modules]] path = "target/wasm32-wasip2/release/price_alert.wasm" -manifest = "modules/examples/price-alert/module.toml" +manifest = "nexum/modules/examples/price-alert/module.toml" [[modules]] path = "target/wasm32-wasip2/release/balance_tracker.wasm" -manifest = "modules/examples/balance-tracker/module.toml" +manifest = "nexum/modules/examples/balance-tracker/module.toml" # --- adapters --------------------------------------------------------- @@ -72,5 +72,5 @@ manifest = "modules/examples/balance-tracker/module.toml" # match the chain twap indexes. [[adapters]] path = "target/wasm32-wasip2/release/cow_venue.wasm" -manifest = "crates/cow-venue/module.sepolia.toml" +manifest = "shepherd/crates/cow-venue/module.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 47d2d3d0..58c06824 100644 --- a/justfile +++ b/justfile @@ -24,15 +24,15 @@ build: build-engine build-module # module's module.toml — without it the engine prints the 0.1-compat # deprecation warning and proceeds with empty capabilities/config. run: build-module build-engine - cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm modules/example/module.toml + cargo run -p nexum-cli -- target/wasm32-wasip2/release/example.wasm nexum/modules/example/module.toml # Run host engine unit tests test: - cargo test -p nexum-runtime + cargo nextest run -p nexum-runtime # Build module + engine, then run E2E integration tests test-e2e: build-module build-engine - cargo test -p nexum-runtime supervisor::tests::e2e + cargo nextest run -p nexum-runtime supervisor::tests::e2e # Build the M2 modules (twap-monitor + ethflow-watcher) for wasm32-wasip2. build-m2: @@ -87,6 +87,12 @@ check-venue-agnostic: check-cow-orderbook-only: ./scripts/check-cow-orderbook-only.sh +# Dep-sync gate: every crate is grouped under nexum/videre/shepherd and +# depends only within or below its tier, so no upward edge becomes a +# circular repo dependency at the carve. Blocking in CI (M5 #403). +check-carve-groups: + ./scripts/check-carve-groups.sh + # Check the entire workspace check: cargo check --target wasm32-wasip2 -p example @@ -96,14 +102,19 @@ check: # Run the full CI series locally before pushing. Mirrors # .github/workflows/ci.yml one-to-one: rustfmt, clippy, rustdoc, the # module wasms the integration tests need, and the workspace test -# suite, all under the `-D warnings` the CI workflow sets globally. +# suite via nextest plus the doctests, all under the `-D warnings` the +# CI workflow sets globally. ci: #!/usr/bin/env bash set -euo pipefail - export RUSTFLAGS="-D warnings" - export RUSTDOCFLAGS="-D warnings" + # Append -D warnings without clobbering the devshell's flags (mold linker, + # set in flake.nix), so the local run keeps fast native linking. RUSTC_WRAPPER + # is already sccache from the devshell shellHook. + export RUSTFLAGS="${RUSTFLAGS:-} -D warnings" + export RUSTDOCFLAGS="${RUSTDOCFLAGS:-} -D warnings" cargo fmt --all --check cargo clippy --workspace --all-targets --all-features -- -D warnings + ./scripts/check-carve-groups.sh cargo doc --workspace --no-deps cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ @@ -114,4 +125,7 @@ ci: # twap-monitor's cow-venue dep; rebuild the adapter component over it # (as CI does) so the platform e2e tests load the adapter, not the stub. cargo build --release --target wasm32-wasip2 -p cow-venue --features adapter - cargo test --workspace --all-features --no-fail-fast + # nextest for the suite (as CI does); doctests run separately since nextest + # does not cover them. + cargo nextest run --workspace --all-features --no-fail-fast + cargo test --doc --workspace --all-features diff --git a/crates/nexum-cli/Cargo.toml b/nexum/crates/nexum-cli/Cargo.toml similarity index 100% rename from crates/nexum-cli/Cargo.toml rename to nexum/crates/nexum-cli/Cargo.toml diff --git a/crates/nexum-cli/src/main.rs b/nexum/crates/nexum-cli/src/main.rs similarity index 100% rename from crates/nexum-cli/src/main.rs rename to nexum/crates/nexum-cli/src/main.rs diff --git a/crates/nexum-launch/Cargo.toml b/nexum/crates/nexum-launch/Cargo.toml similarity index 100% rename from crates/nexum-launch/Cargo.toml rename to nexum/crates/nexum-launch/Cargo.toml diff --git a/crates/nexum-launch/src/cli.rs b/nexum/crates/nexum-launch/src/cli.rs similarity index 100% rename from crates/nexum-launch/src/cli.rs rename to nexum/crates/nexum-launch/src/cli.rs diff --git a/crates/nexum-launch/src/lib.rs b/nexum/crates/nexum-launch/src/lib.rs similarity index 100% rename from crates/nexum-launch/src/lib.rs rename to nexum/crates/nexum-launch/src/lib.rs diff --git a/crates/nexum-module-macros/Cargo.toml b/nexum/crates/nexum-module-macros/Cargo.toml similarity index 100% rename from crates/nexum-module-macros/Cargo.toml rename to nexum/crates/nexum-module-macros/Cargo.toml diff --git a/crates/nexum-module-macros/src/lib.rs b/nexum/crates/nexum-module-macros/src/lib.rs similarity index 100% rename from crates/nexum-module-macros/src/lib.rs rename to nexum/crates/nexum-module-macros/src/lib.rs diff --git a/crates/nexum-runtime/Cargo.toml b/nexum/crates/nexum-runtime/Cargo.toml similarity index 100% rename from crates/nexum-runtime/Cargo.toml rename to nexum/crates/nexum-runtime/Cargo.toml diff --git a/crates/nexum-runtime/examples/embed.rs b/nexum/crates/nexum-runtime/examples/embed.rs similarity index 100% rename from crates/nexum-runtime/examples/embed.rs rename to nexum/crates/nexum-runtime/examples/embed.rs diff --git a/crates/nexum-runtime/src/addons.rs b/nexum/crates/nexum-runtime/src/addons.rs similarity index 100% rename from crates/nexum-runtime/src/addons.rs rename to nexum/crates/nexum-runtime/src/addons.rs diff --git a/crates/nexum-runtime/src/bindings.rs b/nexum/crates/nexum-runtime/src/bindings.rs similarity index 95% rename from crates/nexum-runtime/src/bindings.rs rename to nexum/crates/nexum-runtime/src/bindings.rs index 5789985f..e79803af 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/nexum/crates/nexum-runtime/src/bindings.rs @@ -9,7 +9,7 @@ //! so extension services can compare event payloads. wasmtime::component::bindgen!({ - path: ["../../wit/nexum-host"], + path: ["../../../wit/nexum-host"], world: "nexum:host/event-module", imports: { default: async }, exports: { default: async }, diff --git a/crates/nexum-runtime/src/bootstrap.rs b/nexum/crates/nexum-runtime/src/bootstrap.rs similarity index 100% rename from crates/nexum-runtime/src/bootstrap.rs rename to nexum/crates/nexum-runtime/src/bootstrap.rs diff --git a/crates/nexum-runtime/src/builder.rs b/nexum/crates/nexum-runtime/src/builder.rs similarity index 97% rename from crates/nexum-runtime/src/builder.rs rename to nexum/crates/nexum-runtime/src/builder.rs index d01b1674..195cefdc 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/nexum/crates/nexum-runtime/src/builder.rs @@ -680,6 +680,17 @@ mod tests { use crate::test_utils::Prebuilt; use wasmtime::component::Linker; + /// Workspace root: the topmost ancestor with a `Cargo.toml`. + fn workspace_root() -> std::path::PathBuf { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .to_path_buf() + } + /// The preset shortcut reaches the supervisor boot, which bails on the /// default config's empty module set. #[tokio::test] @@ -907,11 +918,7 @@ mod tests { /// module fixture is not built (`just build-module`). #[tokio::test] async fn e2e_preset_with_components_launches_through_overridden_logs() { - let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("repo root"); + let repo_root = workspace_root(); let wasm = repo_root.join("target/wasm32-wasip2/release/example.wasm"); if !wasm.exists() { eprintln!( @@ -920,7 +927,7 @@ mod tests { ); return; } - let manifest = repo_root.join("modules/example/module.toml"); + let manifest = repo_root.join("nexum/modules/example/module.toml"); let dir = tempfile::tempdir().expect("tempdir"); let mut config = EngineConfig::default(); @@ -947,12 +954,7 @@ mod tests { /// Every module failing `init` aborts launch instead of idling. #[tokio::test] async fn launch_bails_when_all_modules_fail_init() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("repo root") - .join("target/wasm32-wasip2/release/price_alert.wasm"); + let wasm = workspace_root().join("target/wasm32-wasip2/release/price_alert.wasm"); if !wasm.exists() { eprintln!( "SKIP: {} not found - build with `cargo build -p price-alert --target wasm32-wasip2 --release`", @@ -1068,12 +1070,7 @@ every_n_blocks = "1" /// fixture is not built (`just build-module`). #[tokio::test] async fn e2e_builder_launch_exposes_logs_and_stops_on_shutdown() { - let wasm = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("repo root") - .join("target/wasm32-wasip2/release/example.wasm"); + let wasm = workspace_root().join("target/wasm32-wasip2/release/example.wasm"); if !wasm.exists() { eprintln!( "SKIP: {} not found - run `just build-module` to enable E2E tests", @@ -1081,11 +1078,7 @@ every_n_blocks = "1" ); return; } - let manifest = wasm - .ancestors() - .nth(3) - .expect("repo root") - .join("modules/example/module.toml"); + let manifest = workspace_root().join("nexum/modules/example/module.toml"); let dir = tempfile::tempdir().expect("tempdir"); let mut config = EngineConfig::default(); diff --git a/crates/nexum-runtime/src/engine_config.rs b/nexum/crates/nexum-runtime/src/engine_config.rs similarity index 100% rename from crates/nexum-runtime/src/engine_config.rs rename to nexum/crates/nexum-runtime/src/engine_config.rs diff --git a/crates/nexum-runtime/src/host/actor.rs b/nexum/crates/nexum-runtime/src/host/actor.rs similarity index 100% rename from crates/nexum-runtime/src/host/actor.rs rename to nexum/crates/nexum-runtime/src/host/actor.rs diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/nexum/crates/nexum-runtime/src/host/component/builder.rs similarity index 100% rename from crates/nexum-runtime/src/host/component/builder.rs rename to nexum/crates/nexum-runtime/src/host/component/builder.rs diff --git a/crates/nexum-runtime/src/host/component/chain.rs b/nexum/crates/nexum-runtime/src/host/component/chain.rs similarity index 100% rename from crates/nexum-runtime/src/host/component/chain.rs rename to nexum/crates/nexum-runtime/src/host/component/chain.rs diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/nexum/crates/nexum-runtime/src/host/component/mod.rs similarity index 100% rename from crates/nexum-runtime/src/host/component/mod.rs rename to nexum/crates/nexum-runtime/src/host/component/mod.rs diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/nexum/crates/nexum-runtime/src/host/component/runtime_types.rs similarity index 100% rename from crates/nexum-runtime/src/host/component/runtime_types.rs rename to nexum/crates/nexum-runtime/src/host/component/runtime_types.rs diff --git a/crates/nexum-runtime/src/host/component/state.rs b/nexum/crates/nexum-runtime/src/host/component/state.rs similarity index 100% rename from crates/nexum-runtime/src/host/component/state.rs rename to nexum/crates/nexum-runtime/src/host/component/state.rs diff --git a/crates/nexum-runtime/src/host/error.rs b/nexum/crates/nexum-runtime/src/host/error.rs similarity index 100% rename from crates/nexum-runtime/src/host/error.rs rename to nexum/crates/nexum-runtime/src/host/error.rs diff --git a/crates/nexum-runtime/src/host/extension.rs b/nexum/crates/nexum-runtime/src/host/extension.rs similarity index 100% rename from crates/nexum-runtime/src/host/extension.rs rename to nexum/crates/nexum-runtime/src/host/extension.rs diff --git a/crates/nexum-runtime/src/host/http.rs b/nexum/crates/nexum-runtime/src/host/http.rs similarity index 100% rename from crates/nexum-runtime/src/host/http.rs rename to nexum/crates/nexum-runtime/src/host/http.rs diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/nexum/crates/nexum-runtime/src/host/impls/chain.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/chain.rs rename to nexum/crates/nexum-runtime/src/host/impls/chain.rs diff --git a/crates/nexum-runtime/src/host/impls/identity.rs b/nexum/crates/nexum-runtime/src/host/impls/identity.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/identity.rs rename to nexum/crates/nexum-runtime/src/host/impls/identity.rs diff --git a/crates/nexum-runtime/src/host/impls/local_store.rs b/nexum/crates/nexum-runtime/src/host/impls/local_store.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/local_store.rs rename to nexum/crates/nexum-runtime/src/host/impls/local_store.rs diff --git a/crates/nexum-runtime/src/host/impls/logging.rs b/nexum/crates/nexum-runtime/src/host/impls/logging.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/logging.rs rename to nexum/crates/nexum-runtime/src/host/impls/logging.rs diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/nexum/crates/nexum-runtime/src/host/impls/messaging.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/messaging.rs rename to nexum/crates/nexum-runtime/src/host/impls/messaging.rs diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/nexum/crates/nexum-runtime/src/host/impls/mod.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/mod.rs rename to nexum/crates/nexum-runtime/src/host/impls/mod.rs diff --git a/crates/nexum-runtime/src/host/impls/remote_store.rs b/nexum/crates/nexum-runtime/src/host/impls/remote_store.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/remote_store.rs rename to nexum/crates/nexum-runtime/src/host/impls/remote_store.rs diff --git a/crates/nexum-runtime/src/host/impls/types.rs b/nexum/crates/nexum-runtime/src/host/impls/types.rs similarity index 100% rename from crates/nexum-runtime/src/host/impls/types.rs rename to nexum/crates/nexum-runtime/src/host/impls/types.rs diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb.rs similarity index 100% rename from crates/nexum-runtime/src/host/local_store_redb.rs rename to nexum/crates/nexum-runtime/src/host/local_store_redb.rs diff --git a/crates/nexum-runtime/src/host/local_store_redb/tests.rs b/nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs similarity index 100% rename from crates/nexum-runtime/src/host/local_store_redb/tests.rs rename to nexum/crates/nexum-runtime/src/host/local_store_redb/tests.rs diff --git a/crates/nexum-runtime/src/host/logs/mod.rs b/nexum/crates/nexum-runtime/src/host/logs/mod.rs similarity index 100% rename from crates/nexum-runtime/src/host/logs/mod.rs rename to nexum/crates/nexum-runtime/src/host/logs/mod.rs diff --git a/crates/nexum-runtime/src/host/logs/stdio.rs b/nexum/crates/nexum-runtime/src/host/logs/stdio.rs similarity index 100% rename from crates/nexum-runtime/src/host/logs/stdio.rs rename to nexum/crates/nexum-runtime/src/host/logs/stdio.rs diff --git a/crates/nexum-runtime/src/host/logs/store.rs b/nexum/crates/nexum-runtime/src/host/logs/store.rs similarity index 100% rename from crates/nexum-runtime/src/host/logs/store.rs rename to nexum/crates/nexum-runtime/src/host/logs/store.rs diff --git a/crates/nexum-runtime/src/host/mod.rs b/nexum/crates/nexum-runtime/src/host/mod.rs similarity index 100% rename from crates/nexum-runtime/src/host/mod.rs rename to nexum/crates/nexum-runtime/src/host/mod.rs diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/nexum/crates/nexum-runtime/src/host/provider_pool.rs similarity index 100% rename from crates/nexum-runtime/src/host/provider_pool.rs rename to nexum/crates/nexum-runtime/src/host/provider_pool.rs diff --git a/crates/nexum-runtime/src/host/state.rs b/nexum/crates/nexum-runtime/src/host/state.rs similarity index 100% rename from crates/nexum-runtime/src/host/state.rs rename to nexum/crates/nexum-runtime/src/host/state.rs diff --git a/crates/nexum-runtime/src/lib.rs b/nexum/crates/nexum-runtime/src/lib.rs similarity index 100% rename from crates/nexum-runtime/src/lib.rs rename to nexum/crates/nexum-runtime/src/lib.rs diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/nexum/crates/nexum-runtime/src/manifest/capabilities.rs similarity index 100% rename from crates/nexum-runtime/src/manifest/capabilities.rs rename to nexum/crates/nexum-runtime/src/manifest/capabilities.rs diff --git a/crates/nexum-runtime/src/manifest/error.rs b/nexum/crates/nexum-runtime/src/manifest/error.rs similarity index 100% rename from crates/nexum-runtime/src/manifest/error.rs rename to nexum/crates/nexum-runtime/src/manifest/error.rs diff --git a/crates/nexum-runtime/src/manifest/load.rs b/nexum/crates/nexum-runtime/src/manifest/load.rs similarity index 100% rename from crates/nexum-runtime/src/manifest/load.rs rename to nexum/crates/nexum-runtime/src/manifest/load.rs diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/nexum/crates/nexum-runtime/src/manifest/mod.rs similarity index 100% rename from crates/nexum-runtime/src/manifest/mod.rs rename to nexum/crates/nexum-runtime/src/manifest/mod.rs diff --git a/crates/nexum-runtime/src/manifest/types.rs b/nexum/crates/nexum-runtime/src/manifest/types.rs similarity index 100% rename from crates/nexum-runtime/src/manifest/types.rs rename to nexum/crates/nexum-runtime/src/manifest/types.rs diff --git a/crates/nexum-runtime/src/preset.rs b/nexum/crates/nexum-runtime/src/preset.rs similarity index 100% rename from crates/nexum-runtime/src/preset.rs rename to nexum/crates/nexum-runtime/src/preset.rs diff --git a/crates/nexum-runtime/src/runtime/dispatch_rate.rs b/nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/dispatch_rate.rs rename to nexum/crates/nexum-runtime/src/runtime/dispatch_rate.rs diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/nexum/crates/nexum-runtime/src/runtime/event_loop.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/event_loop.rs rename to nexum/crates/nexum-runtime/src/runtime/event_loop.rs diff --git a/crates/nexum-runtime/src/runtime/limits.rs b/nexum/crates/nexum-runtime/src/runtime/limits.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/limits.rs rename to nexum/crates/nexum-runtime/src/runtime/limits.rs diff --git a/crates/nexum-runtime/src/runtime/mod.rs b/nexum/crates/nexum-runtime/src/runtime/mod.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/mod.rs rename to nexum/crates/nexum-runtime/src/runtime/mod.rs diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/nexum/crates/nexum-runtime/src/runtime/poison_policy.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/poison_policy.rs rename to nexum/crates/nexum-runtime/src/runtime/poison_policy.rs diff --git a/crates/nexum-runtime/src/runtime/restart_policy.rs b/nexum/crates/nexum-runtime/src/runtime/restart_policy.rs similarity index 100% rename from crates/nexum-runtime/src/runtime/restart_policy.rs rename to nexum/crates/nexum-runtime/src/runtime/restart_policy.rs diff --git a/crates/nexum-runtime/src/supervisor.rs b/nexum/crates/nexum-runtime/src/supervisor.rs similarity index 100% rename from crates/nexum-runtime/src/supervisor.rs rename to nexum/crates/nexum-runtime/src/supervisor.rs diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/nexum/crates/nexum-runtime/src/supervisor/tests.rs similarity index 97% rename from crates/nexum-runtime/src/supervisor/tests.rs rename to nexum/crates/nexum-runtime/src/supervisor/tests.rs index 80b4dd58..a16ae712 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/nexum/crates/nexum-runtime/src/supervisor/tests.rs @@ -290,24 +290,24 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { // ── E2E helpers ─────────────────────────────────────────────────────── +/// Workspace root: the topmost ancestor with a `Cargo.toml`. +fn workspace_root() -> PathBuf { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) + .to_path_buf() +} + /// Path to the pre-built example WASM component. fn example_wasm() -> PathBuf { - // CARGO_MANIFEST_DIR → crates/nexum-runtime - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/example.wasm") + workspace_root().join("target/wasm32-wasip2/release/example.wasm") } fn example_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/example/module.toml") + workspace_root().join("nexum/modules/example/module.toml") } /// Returns `None` and prints a skip message if the fixture isn't built. @@ -580,16 +580,10 @@ chain_id = 1 const SEPOLIA: u64 = 11_155_111; -/// Path to a production module's `.wasm` artefact under the workspace -/// target dir, with hyphens in the name replaced by underscores. +/// A production module's built `.wasm`; hyphens in the name become underscores. fn module_wasm(module_name: &str) -> PathBuf { let artifact = module_name.replace('-', "_"); - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) + workspace_root().join(format!("target/wasm32-wasip2/release/{artifact}.wasm")) } fn module_wasm_or_skip(module_name: &str) -> Option { @@ -615,12 +609,7 @@ fn module_wasm_or_skip(module_name: &str) -> Option { /// Resolve the real `module.toml` for one of the production modules. fn production_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) + workspace_root().join(relative_path) } fn synthetic_sepolia_block() -> nexum::host::types::Block { @@ -662,7 +651,7 @@ async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { return; }; - let manifest = production_module_toml("modules/examples/price-alert/module.toml"); + let manifest = production_module_toml("nexum/modules/examples/price-alert/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -678,7 +667,7 @@ async fn e2e_balance_tracker_block_dispatch() { let Some(wasm) = module_wasm_or_skip("balance-tracker") else { return; }; - let manifest = production_module_toml("modules/examples/balance-tracker/module.toml"); + let manifest = production_module_toml("nexum/modules/examples/balance-tracker/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -1083,7 +1072,7 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { let components = crate::test_utils::mock_components_from(chain, crate::test_utils::MockStateStore::new()); - let manifest = fixture_module_toml("modules/fixtures/slow-host/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/slow-host/module.toml"); // 1s is the floor the resolver saturates up to; short enough to keep // the test quick, long enough to prove the call was cut off (the park // is an hour) rather than never started. @@ -1164,12 +1153,7 @@ async fn dispatch_deadline_cuts_off_a_blocked_host_call_and_recovers() { // changes to the supervisor cannot silently bypass the limits. fn fixture_module_toml(relative_path: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join(relative_path) + workspace_root().join(relative_path) } /// Boot a single fixture (`.wasm` + `module.toml`) under the supervisor. @@ -1199,7 +1183,7 @@ async fn resource_limit_fuel_bomb_traps_and_marks_module_dead() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/fuel-bomb/module.toml").await; + let mut supervisor = boot_fixture(&wasm, "nexum/modules/fixtures/fuel-bomb/module.toml").await; assert_eq!(supervisor.module_count(), 1); assert_eq!(supervisor.alive_count(), 1, "loads alive"); @@ -1287,7 +1271,7 @@ chain_id = 1 crate::engine_config::ModuleEntry { path: bomb_wasm.clone(), manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", + "nexum/modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { @@ -1340,7 +1324,8 @@ async fn resource_limit_memory_bomb_traps_and_marks_module_dead() { let Some(wasm) = module_wasm_or_skip("memory-bomb") else { return; }; - let mut supervisor = boot_fixture(&wasm, "modules/fixtures/memory-bomb/module.toml").await; + let mut supervisor = + boot_fixture(&wasm, "nexum/modules/fixtures/memory-bomb/module.toml").await; assert_eq!(supervisor.module_count(), 1); assert_eq!(supervisor.alive_count(), 1); @@ -1484,7 +1469,7 @@ async fn poison_pill_quarantines_module_after_threshold() { let Some(wasm) = module_wasm_or_skip("fuel-bomb") else { return; }; - let manifest = production_module_toml("modules/fixtures/fuel-bomb/module.toml"); + let manifest = production_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); let engine = make_wasmtime_engine(); let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); @@ -1652,7 +1637,7 @@ async fn dying_run_leaves_a_panic_record() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/fuel-bomb/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/fuel-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -1707,7 +1692,7 @@ async fn facade_panic_leaves_stderr_host_interface_and_panic_records() { let linker = make_linker(&engine); let (_dir, store) = temp_local_store(); let (components, logs) = components_with_logs(store); - let manifest = fixture_module_toml("modules/fixtures/panic-bomb/module.toml"); + let manifest = fixture_module_toml("nexum/modules/fixtures/panic-bomb/module.toml"); let limits = ModuleLimits::default(); let mut supervisor = Supervisor::boot_single( &engine, @@ -2075,7 +2060,7 @@ chain_id = 100 crate::engine_config::ModuleEntry { path: bomb_wasm, manifest: Some(fixture_module_toml( - "modules/fixtures/fuel-bomb/module.toml", + "nexum/modules/fixtures/fuel-bomb/module.toml", )), }, crate::engine_config::ModuleEntry { diff --git a/crates/nexum-runtime/src/test_utils/builders.rs b/nexum/crates/nexum-runtime/src/test_utils/builders.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/builders.rs rename to nexum/crates/nexum-runtime/src/test_utils/builders.rs diff --git a/crates/nexum-runtime/src/test_utils/chain.rs b/nexum/crates/nexum-runtime/src/test_utils/chain.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/chain.rs rename to nexum/crates/nexum-runtime/src/test_utils/chain.rs diff --git a/crates/nexum-runtime/src/test_utils/clock.rs b/nexum/crates/nexum-runtime/src/test_utils/clock.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/clock.rs rename to nexum/crates/nexum-runtime/src/test_utils/clock.rs diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/nexum/crates/nexum-runtime/src/test_utils/harness.rs similarity index 98% rename from crates/nexum-runtime/src/test_utils/harness.rs rename to nexum/crates/nexum-runtime/src/test_utils/harness.rs index b53355a6..3dc1d021 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/nexum/crates/nexum-runtime/src/test_utils/harness.rs @@ -271,12 +271,14 @@ mod tests { /// The pre-built module wasm named `file`, or `None` with a skip note. fn module_wasm_or_skip(file: &str) -> Option { - let wasm = Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(Path::parent) - .expect("repo root") - .join("target/wasm32-wasip2/release") - .join(file); + // Workspace root: the topmost ancestor with a `Cargo.toml`. + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest); + let wasm = root.join("target/wasm32-wasip2/release").join(file); if wasm.exists() { Some(wasm) } else { diff --git a/crates/nexum-runtime/src/test_utils/mod.rs b/nexum/crates/nexum-runtime/src/test_utils/mod.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/mod.rs rename to nexum/crates/nexum-runtime/src/test_utils/mod.rs diff --git a/crates/nexum-runtime/src/test_utils/store.rs b/nexum/crates/nexum-runtime/src/test_utils/store.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/store.rs rename to nexum/crates/nexum-runtime/src/test_utils/store.rs diff --git a/crates/nexum-runtime/src/test_utils/types.rs b/nexum/crates/nexum-runtime/src/test_utils/types.rs similarity index 100% rename from crates/nexum-runtime/src/test_utils/types.rs rename to nexum/crates/nexum-runtime/src/test_utils/types.rs diff --git a/crates/nexum-sdk-test/Cargo.toml b/nexum/crates/nexum-sdk-test/Cargo.toml similarity index 100% rename from crates/nexum-sdk-test/Cargo.toml rename to nexum/crates/nexum-sdk-test/Cargo.toml diff --git a/crates/nexum-sdk-test/src/lib.rs b/nexum/crates/nexum-sdk-test/src/lib.rs similarity index 100% rename from crates/nexum-sdk-test/src/lib.rs rename to nexum/crates/nexum-sdk-test/src/lib.rs diff --git a/crates/nexum-sdk/Cargo.toml b/nexum/crates/nexum-sdk/Cargo.toml similarity index 100% rename from crates/nexum-sdk/Cargo.toml rename to nexum/crates/nexum-sdk/Cargo.toml diff --git a/crates/nexum-sdk/src/address.rs b/nexum/crates/nexum-sdk/src/address.rs similarity index 100% rename from crates/nexum-sdk/src/address.rs rename to nexum/crates/nexum-sdk/src/address.rs diff --git a/crates/nexum-sdk/src/chain/chainlink.rs b/nexum/crates/nexum-sdk/src/chain/chainlink.rs similarity index 100% rename from crates/nexum-sdk/src/chain/chainlink.rs rename to nexum/crates/nexum-sdk/src/chain/chainlink.rs diff --git a/crates/nexum-sdk/src/chain/eth_call.rs b/nexum/crates/nexum-sdk/src/chain/eth_call.rs similarity index 100% rename from crates/nexum-sdk/src/chain/eth_call.rs rename to nexum/crates/nexum-sdk/src/chain/eth_call.rs diff --git a/crates/nexum-sdk/src/chain/mod.rs b/nexum/crates/nexum-sdk/src/chain/mod.rs similarity index 100% rename from crates/nexum-sdk/src/chain/mod.rs rename to nexum/crates/nexum-sdk/src/chain/mod.rs diff --git a/crates/nexum-sdk/src/chain/provider.rs b/nexum/crates/nexum-sdk/src/chain/provider.rs similarity index 100% rename from crates/nexum-sdk/src/chain/provider.rs rename to nexum/crates/nexum-sdk/src/chain/provider.rs diff --git a/crates/nexum-sdk/src/chain/transport.rs b/nexum/crates/nexum-sdk/src/chain/transport.rs similarity index 100% rename from crates/nexum-sdk/src/chain/transport.rs rename to nexum/crates/nexum-sdk/src/chain/transport.rs diff --git a/crates/nexum-sdk/src/config.rs b/nexum/crates/nexum-sdk/src/config.rs similarity index 100% rename from crates/nexum-sdk/src/config.rs rename to nexum/crates/nexum-sdk/src/config.rs diff --git a/crates/nexum-sdk/src/events.rs b/nexum/crates/nexum-sdk/src/events.rs similarity index 100% rename from crates/nexum-sdk/src/events.rs rename to nexum/crates/nexum-sdk/src/events.rs diff --git a/crates/nexum-sdk/src/host.rs b/nexum/crates/nexum-sdk/src/host.rs similarity index 100% rename from crates/nexum-sdk/src/host.rs rename to nexum/crates/nexum-sdk/src/host.rs diff --git a/crates/nexum-sdk/src/http.rs b/nexum/crates/nexum-sdk/src/http.rs similarity index 100% rename from crates/nexum-sdk/src/http.rs rename to nexum/crates/nexum-sdk/src/http.rs diff --git a/crates/nexum-sdk/src/keeper.rs b/nexum/crates/nexum-sdk/src/keeper.rs similarity index 100% rename from crates/nexum-sdk/src/keeper.rs rename to nexum/crates/nexum-sdk/src/keeper.rs diff --git a/crates/nexum-sdk/src/lib.rs b/nexum/crates/nexum-sdk/src/lib.rs similarity index 100% rename from crates/nexum-sdk/src/lib.rs rename to nexum/crates/nexum-sdk/src/lib.rs diff --git a/crates/nexum-sdk/src/prelude.rs b/nexum/crates/nexum-sdk/src/prelude.rs similarity index 100% rename from crates/nexum-sdk/src/prelude.rs rename to nexum/crates/nexum-sdk/src/prelude.rs diff --git a/crates/nexum-sdk/src/proptests.rs b/nexum/crates/nexum-sdk/src/proptests.rs similarity index 100% rename from crates/nexum-sdk/src/proptests.rs rename to nexum/crates/nexum-sdk/src/proptests.rs diff --git a/crates/nexum-sdk/src/store.rs b/nexum/crates/nexum-sdk/src/store.rs similarity index 100% rename from crates/nexum-sdk/src/store.rs rename to nexum/crates/nexum-sdk/src/store.rs diff --git a/crates/nexum-sdk/src/tracing.rs b/nexum/crates/nexum-sdk/src/tracing.rs similarity index 100% rename from crates/nexum-sdk/src/tracing.rs rename to nexum/crates/nexum-sdk/src/tracing.rs diff --git a/crates/nexum-sdk/src/wit_bindgen_macro.rs b/nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs similarity index 100% rename from crates/nexum-sdk/src/wit_bindgen_macro.rs rename to nexum/crates/nexum-sdk/src/wit_bindgen_macro.rs diff --git a/crates/nexum-sdk/tests/keeper.rs b/nexum/crates/nexum-sdk/tests/keeper.rs similarity index 100% rename from crates/nexum-sdk/tests/keeper.rs rename to nexum/crates/nexum-sdk/tests/keeper.rs diff --git a/crates/nexum-sdk/tests/store.rs b/nexum/crates/nexum-sdk/tests/store.rs similarity index 100% rename from crates/nexum-sdk/tests/store.rs rename to nexum/crates/nexum-sdk/tests/store.rs diff --git a/crates/nexum-tasks/Cargo.toml b/nexum/crates/nexum-tasks/Cargo.toml similarity index 100% rename from crates/nexum-tasks/Cargo.toml rename to nexum/crates/nexum-tasks/Cargo.toml diff --git a/crates/nexum-tasks/src/lib.rs b/nexum/crates/nexum-tasks/src/lib.rs similarity index 100% rename from crates/nexum-tasks/src/lib.rs rename to nexum/crates/nexum-tasks/src/lib.rs diff --git a/crates/nexum-tasks/src/manager.rs b/nexum/crates/nexum-tasks/src/manager.rs similarity index 100% rename from crates/nexum-tasks/src/manager.rs rename to nexum/crates/nexum-tasks/src/manager.rs diff --git a/crates/nexum-tasks/src/shutdown.rs b/nexum/crates/nexum-tasks/src/shutdown.rs similarity index 100% rename from crates/nexum-tasks/src/shutdown.rs rename to nexum/crates/nexum-tasks/src/shutdown.rs diff --git a/crates/nexum-tasks/src/task.rs b/nexum/crates/nexum-tasks/src/task.rs similarity index 100% rename from crates/nexum-tasks/src/task.rs rename to nexum/crates/nexum-tasks/src/task.rs diff --git a/crates/nexum-world/Cargo.toml b/nexum/crates/nexum-world/Cargo.toml similarity index 100% rename from crates/nexum-world/Cargo.toml rename to nexum/crates/nexum-world/Cargo.toml diff --git a/crates/nexum-world/src/lib.rs b/nexum/crates/nexum-world/src/lib.rs similarity index 100% rename from crates/nexum-world/src/lib.rs rename to nexum/crates/nexum-world/src/lib.rs diff --git a/modules/example/Cargo.toml b/nexum/modules/example/Cargo.toml similarity index 100% rename from modules/example/Cargo.toml rename to nexum/modules/example/Cargo.toml diff --git a/modules/example/module.toml b/nexum/modules/example/module.toml similarity index 100% rename from modules/example/module.toml rename to nexum/modules/example/module.toml diff --git a/modules/example/src/lib.rs b/nexum/modules/example/src/lib.rs similarity index 100% rename from modules/example/src/lib.rs rename to nexum/modules/example/src/lib.rs diff --git a/modules/examples/balance-tracker/Cargo.toml b/nexum/modules/examples/balance-tracker/Cargo.toml similarity index 100% rename from modules/examples/balance-tracker/Cargo.toml rename to nexum/modules/examples/balance-tracker/Cargo.toml diff --git a/modules/examples/balance-tracker/module.toml b/nexum/modules/examples/balance-tracker/module.toml similarity index 100% rename from modules/examples/balance-tracker/module.toml rename to nexum/modules/examples/balance-tracker/module.toml diff --git a/modules/examples/balance-tracker/src/lib.rs b/nexum/modules/examples/balance-tracker/src/lib.rs similarity index 100% rename from modules/examples/balance-tracker/src/lib.rs rename to nexum/modules/examples/balance-tracker/src/lib.rs diff --git a/modules/examples/balance-tracker/src/logic.rs b/nexum/modules/examples/balance-tracker/src/logic.rs similarity index 100% rename from modules/examples/balance-tracker/src/logic.rs rename to nexum/modules/examples/balance-tracker/src/logic.rs diff --git a/modules/examples/http-probe/Cargo.toml b/nexum/modules/examples/http-probe/Cargo.toml similarity index 100% rename from modules/examples/http-probe/Cargo.toml rename to nexum/modules/examples/http-probe/Cargo.toml diff --git a/modules/examples/http-probe/module.toml b/nexum/modules/examples/http-probe/module.toml similarity index 100% rename from modules/examples/http-probe/module.toml rename to nexum/modules/examples/http-probe/module.toml diff --git a/modules/examples/http-probe/src/lib.rs b/nexum/modules/examples/http-probe/src/lib.rs similarity index 100% rename from modules/examples/http-probe/src/lib.rs rename to nexum/modules/examples/http-probe/src/lib.rs diff --git a/modules/examples/http-probe/src/logic.rs b/nexum/modules/examples/http-probe/src/logic.rs similarity index 100% rename from modules/examples/http-probe/src/logic.rs rename to nexum/modules/examples/http-probe/src/logic.rs diff --git a/modules/examples/price-alert/Cargo.toml b/nexum/modules/examples/price-alert/Cargo.toml similarity index 100% rename from modules/examples/price-alert/Cargo.toml rename to nexum/modules/examples/price-alert/Cargo.toml diff --git a/modules/examples/price-alert/module.toml b/nexum/modules/examples/price-alert/module.toml similarity index 100% rename from modules/examples/price-alert/module.toml rename to nexum/modules/examples/price-alert/module.toml diff --git a/modules/examples/price-alert/src/lib.rs b/nexum/modules/examples/price-alert/src/lib.rs similarity index 100% rename from modules/examples/price-alert/src/lib.rs rename to nexum/modules/examples/price-alert/src/lib.rs diff --git a/modules/examples/price-alert/src/logic.rs b/nexum/modules/examples/price-alert/src/logic.rs similarity index 100% rename from modules/examples/price-alert/src/logic.rs rename to nexum/modules/examples/price-alert/src/logic.rs diff --git a/modules/fixtures/clock-reader/Cargo.toml b/nexum/modules/fixtures/clock-reader/Cargo.toml similarity index 100% rename from modules/fixtures/clock-reader/Cargo.toml rename to nexum/modules/fixtures/clock-reader/Cargo.toml diff --git a/modules/fixtures/clock-reader/module.toml b/nexum/modules/fixtures/clock-reader/module.toml similarity index 100% rename from modules/fixtures/clock-reader/module.toml rename to nexum/modules/fixtures/clock-reader/module.toml diff --git a/modules/fixtures/clock-reader/src/lib.rs b/nexum/modules/fixtures/clock-reader/src/lib.rs similarity index 97% rename from modules/fixtures/clock-reader/src/lib.rs rename to nexum/modules/fixtures/clock-reader/src/lib.rs index 21ee7b01..a79b3e1a 100644 --- a/modules/fixtures/clock-reader/src/lib.rs +++ b/nexum/modules/fixtures/clock-reader/src/lib.rs @@ -13,7 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/modules/fixtures/flaky-bomb/Cargo.toml b/nexum/modules/fixtures/flaky-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/flaky-bomb/Cargo.toml rename to nexum/modules/fixtures/flaky-bomb/Cargo.toml diff --git a/modules/fixtures/flaky-bomb/module.toml b/nexum/modules/fixtures/flaky-bomb/module.toml similarity index 100% rename from modules/fixtures/flaky-bomb/module.toml rename to nexum/modules/fixtures/flaky-bomb/module.toml diff --git a/modules/fixtures/flaky-bomb/src/lib.rs b/nexum/modules/fixtures/flaky-bomb/src/lib.rs similarity index 98% rename from modules/fixtures/flaky-bomb/src/lib.rs rename to nexum/modules/fixtures/flaky-bomb/src/lib.rs index 49df3817..91a82bbb 100644 --- a/modules/fixtures/flaky-bomb/src/lib.rs +++ b/nexum/modules/fixtures/flaky-bomb/src/lib.rs @@ -10,7 +10,7 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/modules/fixtures/fuel-bomb/Cargo.toml b/nexum/modules/fixtures/fuel-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/fuel-bomb/Cargo.toml rename to nexum/modules/fixtures/fuel-bomb/Cargo.toml diff --git a/modules/fixtures/fuel-bomb/module.toml b/nexum/modules/fixtures/fuel-bomb/module.toml similarity index 100% rename from modules/fixtures/fuel-bomb/module.toml rename to nexum/modules/fixtures/fuel-bomb/module.toml diff --git a/modules/fixtures/fuel-bomb/src/lib.rs b/nexum/modules/fixtures/fuel-bomb/src/lib.rs similarity index 97% rename from modules/fixtures/fuel-bomb/src/lib.rs rename to nexum/modules/fixtures/fuel-bomb/src/lib.rs index 6ab9e741..3fd1bfd8 100644 --- a/modules/fixtures/fuel-bomb/src/lib.rs +++ b/nexum/modules/fixtures/fuel-bomb/src/lib.rs @@ -9,7 +9,7 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/modules/fixtures/memory-bomb/Cargo.toml b/nexum/modules/fixtures/memory-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/memory-bomb/Cargo.toml rename to nexum/modules/fixtures/memory-bomb/Cargo.toml diff --git a/modules/fixtures/memory-bomb/module.toml b/nexum/modules/fixtures/memory-bomb/module.toml similarity index 100% rename from modules/fixtures/memory-bomb/module.toml rename to nexum/modules/fixtures/memory-bomb/module.toml diff --git a/modules/fixtures/memory-bomb/src/lib.rs b/nexum/modules/fixtures/memory-bomb/src/lib.rs similarity index 97% rename from modules/fixtures/memory-bomb/src/lib.rs rename to nexum/modules/fixtures/memory-bomb/src/lib.rs index 02426ab8..b09f54f0 100644 --- a/modules/fixtures/memory-bomb/src/lib.rs +++ b/nexum/modules/fixtures/memory-bomb/src/lib.rs @@ -10,7 +10,7 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/modules/fixtures/panic-bomb/Cargo.toml b/nexum/modules/fixtures/panic-bomb/Cargo.toml similarity index 100% rename from modules/fixtures/panic-bomb/Cargo.toml rename to nexum/modules/fixtures/panic-bomb/Cargo.toml diff --git a/modules/fixtures/panic-bomb/module.toml b/nexum/modules/fixtures/panic-bomb/module.toml similarity index 100% rename from modules/fixtures/panic-bomb/module.toml rename to nexum/modules/fixtures/panic-bomb/module.toml diff --git a/modules/fixtures/panic-bomb/src/lib.rs b/nexum/modules/fixtures/panic-bomb/src/lib.rs similarity index 97% rename from modules/fixtures/panic-bomb/src/lib.rs rename to nexum/modules/fixtures/panic-bomb/src/lib.rs index 8d9e823c..e0e711ac 100644 --- a/modules/fixtures/panic-bomb/src/lib.rs +++ b/nexum/modules/fixtures/panic-bomb/src/lib.rs @@ -11,7 +11,7 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/modules/fixtures/slow-host/Cargo.toml b/nexum/modules/fixtures/slow-host/Cargo.toml similarity index 100% rename from modules/fixtures/slow-host/Cargo.toml rename to nexum/modules/fixtures/slow-host/Cargo.toml diff --git a/modules/fixtures/slow-host/module.toml b/nexum/modules/fixtures/slow-host/module.toml similarity index 100% rename from modules/fixtures/slow-host/module.toml rename to nexum/modules/fixtures/slow-host/module.toml diff --git a/modules/fixtures/slow-host/src/lib.rs b/nexum/modules/fixtures/slow-host/src/lib.rs similarity index 97% rename from modules/fixtures/slow-host/src/lib.rs rename to nexum/modules/fixtures/slow-host/src/lib.rs index 7d3f405a..f5ce7b09 100644 --- a/modules/fixtures/slow-host/src/lib.rs +++ b/nexum/modules/fixtures/slow-host/src/lib.rs @@ -13,7 +13,7 @@ wit_bindgen::generate!({ path: [ - "../../../wit/nexum-host", + "../../../../wit/nexum-host", ], world: "nexum:host/event-module", generate_all, diff --git a/tools/load-gen/Cargo.toml b/nexum/tools/load-gen/Cargo.toml similarity index 100% rename from tools/load-gen/Cargo.toml rename to nexum/tools/load-gen/Cargo.toml diff --git a/tools/load-gen/src/main.rs b/nexum/tools/load-gen/src/main.rs similarity index 100% rename from tools/load-gen/src/main.rs rename to nexum/tools/load-gen/src/main.rs diff --git a/scripts/check-carve-groups.sh b/scripts/check-carve-groups.sh new file mode 100755 index 00000000..b5c35af7 --- /dev/null +++ b/scripts/check-carve-groups.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Dep-sync gate for the transitional three-grouping workspace (M5, #403). +# +# The physical layout is the source of truth: every workspace crate lives under +# exactly one group dir (nexum/ = L1, videre/ = L2, shepherd/ = L3). A crate may +# depend only within its own tier or a lower one (nexum <- videre <- shepherd). +# An upward edge (e.g. nexum depending on videre) would become a circular repo +# dependency the moment the groups are carved into separate repos, so it is +# rejected here rather than discovered at the cut. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +meta="$(cargo metadata --format-version 1 --no-deps)" + +# Emit "|" lines for every violation, then gate on the count. +violations="$(printf '%s' "$meta" | jq -r ' + .workspace_root as $root + | (["nexum","videre","shepherd"]) as $tiers + | def grp($p): ($p | ltrimstr($root + "/") | split("/")[0]); + def tier($p): ($tiers | index(grp($p))); + .packages[] + | select(.manifest_path | startswith($root + "/")) + | .name as $n + | (.manifest_path | rtrimstr("/Cargo.toml")) as $self + | if (tier($self) == null) + then "\($n)|not under a group dir (nexum/videre/shepherd): \(grp($self))" + else + (tier($self)) as $st + | .dependencies[] + | select(.path != null and (.path | startswith($root + "/"))) + | select((tier(.path)) != null and (tier(.path)) > $st) + | "\($n)|upward dep on \(.name) (\(grp($self)) -> \(grp(.path)))" + end +')" + +if [ -n "$violations" ]; then + echo "carve-groups: FAIL — grouping invariant violated:" >&2 + printf '%s\n' "$violations" | sed 's/^/ /; s/|/: /' >&2 + exit 1 +fi + +echo "carve-groups: OK — every workspace crate is grouped and depends only within or below its tier" diff --git a/scripts/check-cow-orderbook-only.sh b/scripts/check-cow-orderbook-only.sh index 52229ed2..e6fee9cb 100755 --- a/scripts/check-cow-orderbook-only.sh +++ b/scripts/check-cow-orderbook-only.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Orderbook-only check for the CoW venue crate: crates/cow-venue carries +# Orderbook-only check for the CoW venue crate: shepherd/crates/cow-venue carries # no composable symbol (Composable*, getTradeableOrder*, the # IConditionalOrder revert selectors, LegacyRevertAdapter) and no # dependency edge to the composable-cow keeper crate - the Cargo.toml @@ -19,11 +19,11 @@ command -v rg >/dev/null || { echo "ripgrep (rg) is required" >&2; exit 2; } status=0 symbols='composable|getTradeableOrder|IConditionalOrder|LegacyRevertAdapter|\bVerdict\b|OrderNotValid|PollTryNextBlock|PollTryAtBlock|PollTryAtEpoch|PollNever' -rg -in --no-heading -e "$symbols" crates/cow-venue +rg -in --no-heading -e "$symbols" shepherd/crates/cow-venue case $? in - 0) fail "composable symbols leak into crates/cow-venue" ;; + 0) fail "composable symbols leak into shepherd/crates/cow-venue" ;; 1) pass "cow-venue symbol scan empty" ;; - *) fail "symbol scan errored (crates/cow-venue missing?)" ;; + *) fail "symbol scan errored (shepherd/crates/cow-venue missing?)" ;; esac exit "$status" diff --git a/scripts/check-venue-agnostic.sh b/scripts/check-venue-agnostic.sh index fe52c029..4b317a8d 100755 --- a/scripts/check-venue-agnostic.sh +++ b/scripts/check-venue-agnostic.sh @@ -58,11 +58,11 @@ else fail "cargo tree failed for nexum-sdk" fi sdk_charter='intent-status|IntentStatusUpdate|INTENT_STATUS_KIND|[Ss]tatus[Bb]ody|status[-_]body' -rg -n --no-heading -e "$sdk_charter" crates/nexum-sdk/src +rg -n --no-heading -e "$sdk_charter" nexum/crates/nexum-sdk/src case $? in 0) fail "venue status-codec vocabulary leaks into nexum-sdk" ;; 1) pass "nexum-sdk carries no venue status-codec vocabulary" ;; - *) fail "nexum-sdk scan errored (crates/nexum-sdk/src missing?)" ;; + *) fail "nexum-sdk scan errored (nexum/crates/nexum-sdk/src missing?)" ;; esac # 2. Symbol scan: the charter set (the current venue vocabulary that would @@ -70,22 +70,22 @@ esac # guard). Section 1 guards dependency edges; this scan stays curated to # the live post-rename names so opaque extension payloads never false-flag. charter='videre:|videre_host|Venue[A-Z]|EgressGuard|synthesize_venue|value-flow' -rg -n --no-heading -e "$charter" crates/nexum-runtime/src +rg -n --no-heading -e "$charter" nexum/crates/nexum-runtime/src case $? in 0) fail "charter symbols leak into nexum-runtime" ;; 1) pass "symbol scan empty" ;; - *) fail "symbol scan errored (crates/nexum-runtime/src missing?)" ;; + *) fail "symbol scan errored (nexum/crates/nexum-runtime/src missing?)" ;; esac # 3. Privileged-field scan: the venue registry rides the extension # service map; no `VenueRegistry` router field may return to the # runtime. (The charter scan above also catches the type; this stays # as the named guard for that specific invariant.) -rg -n --no-heading -e 'VenueRegistry' crates/nexum-runtime/src +rg -n --no-heading -e 'VenueRegistry' nexum/crates/nexum-runtime/src case $? in 0) fail "a privileged router field returned to nexum-runtime" ;; 1) pass "no privileged router field" ;; - *) fail "field scan errored (crates/nexum-runtime/src missing?)" ;; + *) fail "field scan errored (nexum/crates/nexum-runtime/src missing?)" ;; esac # 4. WIT surface: nexum:host is a leaf. No foreign package named diff --git a/crates/composable-cow/Cargo.toml b/shepherd/crates/composable-cow/Cargo.toml similarity index 86% rename from crates/composable-cow/Cargo.toml rename to shepherd/crates/composable-cow/Cargo.toml index 6d7c29e5..e692c1a7 100644 --- a/crates/composable-cow/Cargo.toml +++ b/shepherd/crates/composable-cow/Cargo.toml @@ -20,11 +20,11 @@ alloy-primitives = { workspace = true, features = ["borsh"] } alloy-sol-types.workspace = true borsh.workspace = true cowprotocol = { version = "0.2.0", default-features = false } -nexum-sdk = { path = "../nexum-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } # `run` slice: the keeper run over the typed CoW client on the # `videre:venue/client` seam. cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } -videre-sdk = { path = "../videre-sdk", optional = true } +videre-sdk = { path = "../../../videre/crates/videre-sdk", optional = true } tracing = { workspace = true, optional = true } [features] @@ -34,7 +34,7 @@ run = ["dep:cow-venue", "dep:videre-sdk", "dep:tracing"] [dev-dependencies] proptest.workspace = true -nexum-sdk-test = { path = "../nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } [[test]] name = "run" diff --git a/crates/composable-cow/src/body.rs b/shepherd/crates/composable-cow/src/body.rs similarity index 100% rename from crates/composable-cow/src/body.rs rename to shepherd/crates/composable-cow/src/body.rs diff --git a/crates/composable-cow/src/lib.rs b/shepherd/crates/composable-cow/src/lib.rs similarity index 100% rename from crates/composable-cow/src/lib.rs rename to shepherd/crates/composable-cow/src/lib.rs diff --git a/crates/composable-cow/src/poll.rs b/shepherd/crates/composable-cow/src/poll.rs similarity index 100% rename from crates/composable-cow/src/poll.rs rename to shepherd/crates/composable-cow/src/poll.rs diff --git a/crates/composable-cow/src/run.rs b/shepherd/crates/composable-cow/src/run.rs similarity index 100% rename from crates/composable-cow/src/run.rs rename to shepherd/crates/composable-cow/src/run.rs diff --git a/crates/composable-cow/tests/run.rs b/shepherd/crates/composable-cow/tests/run.rs similarity index 100% rename from crates/composable-cow/tests/run.rs rename to shepherd/crates/composable-cow/tests/run.rs diff --git a/crates/cow-venue/Cargo.toml b/shepherd/crates/cow-venue/Cargo.toml similarity index 94% rename from crates/cow-venue/Cargo.toml rename to shepherd/crates/cow-venue/Cargo.toml index 9a110ea4..9c1bb291 100644 --- a/crates/cow-venue/Cargo.toml +++ b/shepherd/crates/cow-venue/Cargo.toml @@ -23,12 +23,12 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -videre-sdk = { path = "../videre-sdk", optional = true } +videre-sdk = { path = "../../../videre/crates/videre-sdk", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never # reach a guest that links this slice. -nexum-sdk = { path = "../nexum-sdk", optional = true } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk", optional = true } # `assembly` slice: the chain-edge order projections and orderbook # submission bodies. The `client` slice also enables it for the typed # `OrderbookApiErrorType` classifier boundary. Express-declared (not @@ -61,7 +61,7 @@ serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. -videre-test = { path = "../videre-test" } +videre-test = { path = "../../../videre/crates/videre-test" } # Parity tests: the upstream `retry_hint()` the shipped table is # reconciled against. cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/cow-venue/build.rs b/shepherd/crates/cow-venue/build.rs similarity index 100% rename from crates/cow-venue/build.rs rename to shepherd/crates/cow-venue/build.rs diff --git a/crates/cow-venue/data/classification.toml b/shepherd/crates/cow-venue/data/classification.toml similarity index 100% rename from crates/cow-venue/data/classification.toml rename to shepherd/crates/cow-venue/data/classification.toml diff --git a/crates/cow-venue/module.load.toml b/shepherd/crates/cow-venue/module.load.toml similarity index 100% rename from crates/cow-venue/module.load.toml rename to shepherd/crates/cow-venue/module.load.toml diff --git a/crates/cow-venue/module.sepolia.toml b/shepherd/crates/cow-venue/module.sepolia.toml similarity index 100% rename from crates/cow-venue/module.sepolia.toml rename to shepherd/crates/cow-venue/module.sepolia.toml diff --git a/crates/cow-venue/module.toml b/shepherd/crates/cow-venue/module.toml similarity index 100% rename from crates/cow-venue/module.toml rename to shepherd/crates/cow-venue/module.toml diff --git a/crates/cow-venue/src/adapter.rs b/shepherd/crates/cow-venue/src/adapter.rs similarity index 100% rename from crates/cow-venue/src/adapter.rs rename to shepherd/crates/cow-venue/src/adapter.rs diff --git a/crates/cow-venue/src/assembly.rs b/shepherd/crates/cow-venue/src/assembly.rs similarity index 100% rename from crates/cow-venue/src/assembly.rs rename to shepherd/crates/cow-venue/src/assembly.rs diff --git a/crates/cow-venue/src/body.rs b/shepherd/crates/cow-venue/src/body.rs similarity index 100% rename from crates/cow-venue/src/body.rs rename to shepherd/crates/cow-venue/src/body.rs diff --git a/crates/cow-venue/src/classification.rs b/shepherd/crates/cow-venue/src/classification.rs similarity index 100% rename from crates/cow-venue/src/classification.rs rename to shepherd/crates/cow-venue/src/classification.rs diff --git a/crates/cow-venue/src/classification_data.rs b/shepherd/crates/cow-venue/src/classification_data.rs similarity index 100% rename from crates/cow-venue/src/classification_data.rs rename to shepherd/crates/cow-venue/src/classification_data.rs diff --git a/crates/cow-venue/src/client.rs b/shepherd/crates/cow-venue/src/client.rs similarity index 100% rename from crates/cow-venue/src/client.rs rename to shepherd/crates/cow-venue/src/client.rs diff --git a/crates/cow-venue/src/lib.rs b/shepherd/crates/cow-venue/src/lib.rs similarity index 100% rename from crates/cow-venue/src/lib.rs rename to shepherd/crates/cow-venue/src/lib.rs diff --git a/crates/cow-venue/src/order.rs b/shepherd/crates/cow-venue/src/order.rs similarity index 100% rename from crates/cow-venue/src/order.rs rename to shepherd/crates/cow-venue/src/order.rs diff --git a/crates/cow-venue/tests/conformance.rs b/shepherd/crates/cow-venue/tests/conformance.rs similarity index 100% rename from crates/cow-venue/tests/conformance.rs rename to shepherd/crates/cow-venue/tests/conformance.rs diff --git a/crates/cow-venue/tests/vectors/cow-header-goldens.json b/shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json similarity index 100% rename from crates/cow-venue/tests/vectors/cow-header-goldens.json rename to shepherd/crates/cow-venue/tests/vectors/cow-header-goldens.json diff --git a/crates/cow-venue/tests/vectors/cow-intent-body.json b/shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json similarity index 100% rename from crates/cow-venue/tests/vectors/cow-intent-body.json rename to shepherd/crates/cow-venue/tests/vectors/cow-intent-body.json diff --git a/crates/cow-venue/tests/wit_layering.rs b/shepherd/crates/cow-venue/tests/wit_layering.rs similarity index 98% rename from crates/cow-venue/tests/wit_layering.rs rename to shepherd/crates/cow-venue/tests/wit_layering.rs index 4ccf59ed..6e26e1c7 100644 --- a/crates/cow-venue/tests/wit_layering.rs +++ b/shepherd/crates/cow-venue/tests/wit_layering.rs @@ -6,7 +6,7 @@ use std::path::Path; #[test] fn generic_wit_packages_never_reference_shepherd_cow() { - let wit_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../wit"); + let wit_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../wit"); for pkg in std::fs::read_dir(&wit_root).expect("wit dir") { let pkg = pkg.expect("wit dir entry").path(); if pkg.file_name().is_some_and(|n| n == "shepherd-cow") { diff --git a/crates/shepherd/Cargo.toml b/shepherd/crates/shepherd/Cargo.toml similarity index 57% rename from crates/shepherd/Cargo.toml rename to shepherd/crates/shepherd/Cargo.toml index 1de15410..c5ed1b75 100644 --- a/crates/shepherd/Cargo.toml +++ b/shepherd/crates/shepherd/Cargo.toml @@ -13,9 +13,9 @@ name = "shepherd" path = "src/main.rs" [dependencies] -nexum-launch = { path = "../nexum-launch" } -nexum-runtime = { path = "../nexum-runtime" } -videre-host = { path = "../videre-host" } +nexum-launch = { path = "../../../nexum/crates/nexum-launch" } +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime" } +videre-host = { path = "../../../videre/crates/videre-host" } anyhow.workspace = true tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/shepherd/crates/shepherd/src/main.rs similarity index 100% rename from crates/shepherd/src/main.rs rename to shepherd/crates/shepherd/src/main.rs diff --git a/modules/ethflow-watcher/Cargo.toml b/shepherd/modules/ethflow-watcher/Cargo.toml similarity index 79% rename from modules/ethflow-watcher/Cargo.toml rename to shepherd/modules/ethflow-watcher/Cargo.toml index 1902df84..790e747e 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/shepherd/modules/ethflow-watcher/Cargo.toml @@ -10,8 +10,8 @@ repository.workspace = true crate-type = ["cdylib"] [dependencies] -nexum-sdk = { path = "../../crates/nexum-sdk" } -videre-sdk = { path = "../../crates/videre-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +videre-sdk = { path = "../../../videre/crates/videre-sdk" } cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } @@ -20,4 +20,4 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] -nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/modules/ethflow-watcher/module.toml b/shepherd/modules/ethflow-watcher/module.toml similarity index 100% rename from modules/ethflow-watcher/module.toml rename to shepherd/modules/ethflow-watcher/module.toml diff --git a/modules/ethflow-watcher/src/keeper.rs b/shepherd/modules/ethflow-watcher/src/keeper.rs similarity index 99% rename from modules/ethflow-watcher/src/keeper.rs rename to shepherd/modules/ethflow-watcher/src/keeper.rs index 56bca16f..deed6689 100644 --- a/modules/ethflow-watcher/src/keeper.rs +++ b/shepherd/modules/ethflow-watcher/src/keeper.rs @@ -563,7 +563,7 @@ mod tests { /// EthFlow event. #[test] fn topic0_matches_the_cow_events_package_of_record() { - let wit = include_str!("../../../wit/shepherd-cow/cow-events.wit"); + let wit = include_str!("../../../../wit/shepherd-cow/cow-events.wit"); let expected = format!("{:#x}", OrderPlacement::SIGNATURE_HASH); assert!( wit.contains(&expected), diff --git a/modules/ethflow-watcher/src/lib.rs b/shepherd/modules/ethflow-watcher/src/lib.rs similarity index 100% rename from modules/ethflow-watcher/src/lib.rs rename to shepherd/modules/ethflow-watcher/src/lib.rs diff --git a/modules/twap-monitor/Cargo.toml b/shepherd/modules/twap-monitor/Cargo.toml similarity index 85% rename from modules/twap-monitor/Cargo.toml rename to shepherd/modules/twap-monitor/Cargo.toml index a9a91c9d..0b89463a 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/shepherd/modules/twap-monitor/Cargo.toml @@ -11,8 +11,8 @@ crate-type = ["cdylib"] [dependencies] composable-cow = { path = "../../crates/composable-cow", features = ["run"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } -nexum-sdk = { path = "../../crates/nexum-sdk" } -videre-sdk = { path = "../../crates/videre-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } +videre-sdk = { path = "../../../videre/crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -25,4 +25,4 @@ serde_json = { version = "1", default-features = false, features = ["alloc"] } # Parses the shipped `module.toml` so the subscription parity test reads the # real `event_signature` value rather than scanning the file text. toml.workspace = true -nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/shepherd/modules/twap-monitor/module.toml similarity index 100% rename from modules/twap-monitor/module.toml rename to shepherd/modules/twap-monitor/module.toml diff --git a/modules/twap-monitor/src/keeper.rs b/shepherd/modules/twap-monitor/src/keeper.rs similarity index 100% rename from modules/twap-monitor/src/keeper.rs rename to shepherd/modules/twap-monitor/src/keeper.rs diff --git a/modules/twap-monitor/src/lib.rs b/shepherd/modules/twap-monitor/src/lib.rs similarity index 100% rename from modules/twap-monitor/src/lib.rs rename to shepherd/modules/twap-monitor/src/lib.rs diff --git a/tools/baseline-latency/.gitignore b/shepherd/tools/baseline-latency/.gitignore similarity index 100% rename from tools/baseline-latency/.gitignore rename to shepherd/tools/baseline-latency/.gitignore diff --git a/tools/baseline-latency/baseline_latency.py b/shepherd/tools/baseline-latency/baseline_latency.py similarity index 100% rename from tools/baseline-latency/baseline_latency.py rename to shepherd/tools/baseline-latency/baseline_latency.py diff --git a/tools/baseline-latency/data/arbitrum_one.json b/shepherd/tools/baseline-latency/data/arbitrum_one.json similarity index 100% rename from tools/baseline-latency/data/arbitrum_one.json rename to shepherd/tools/baseline-latency/data/arbitrum_one.json diff --git a/tools/baseline-latency/data/base.json b/shepherd/tools/baseline-latency/data/base.json similarity index 100% rename from tools/baseline-latency/data/base.json rename to shepherd/tools/baseline-latency/data/base.json diff --git a/tools/baseline-latency/data/gnosis.json b/shepherd/tools/baseline-latency/data/gnosis.json similarity index 100% rename from tools/baseline-latency/data/gnosis.json rename to shepherd/tools/baseline-latency/data/gnosis.json diff --git a/tools/baseline-latency/data/mainnet.json b/shepherd/tools/baseline-latency/data/mainnet.json similarity index 100% rename from tools/baseline-latency/data/mainnet.json rename to shepherd/tools/baseline-latency/data/mainnet.json diff --git a/tools/baseline-latency/data/sepolia.json b/shepherd/tools/baseline-latency/data/sepolia.json similarity index 100% rename from tools/baseline-latency/data/sepolia.json rename to shepherd/tools/baseline-latency/data/sepolia.json diff --git a/tools/orderbook-mock/Cargo.toml b/shepherd/tools/orderbook-mock/Cargo.toml similarity index 100% rename from tools/orderbook-mock/Cargo.toml rename to shepherd/tools/orderbook-mock/Cargo.toml diff --git a/tools/orderbook-mock/src/main.rs b/shepherd/tools/orderbook-mock/src/main.rs similarity index 100% rename from tools/orderbook-mock/src/main.rs rename to shepherd/tools/orderbook-mock/src/main.rs diff --git a/crates/no-std-probe/Cargo.toml b/videre/crates/no-std-probe/Cargo.toml similarity index 100% rename from crates/no-std-probe/Cargo.toml rename to videre/crates/no-std-probe/Cargo.toml diff --git a/crates/no-std-probe/src/lib.rs b/videre/crates/no-std-probe/src/lib.rs similarity index 100% rename from crates/no-std-probe/src/lib.rs rename to videre/crates/no-std-probe/src/lib.rs diff --git a/crates/videre-host/Cargo.toml b/videre/crates/videre-host/Cargo.toml similarity index 86% rename from crates/videre-host/Cargo.toml rename to videre/crates/videre-host/Cargo.toml index e45d28d3..f2fd4a08 100644 --- a/crates/videre-host/Cargo.toml +++ b/videre/crates/videre-host/Cargo.toml @@ -11,7 +11,7 @@ workspace = true [dependencies] # The runtime seam this crate plugs into; the only nexum crate edge. -nexum-runtime = { path = "../nexum-runtime" } +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime" } # Encoder for the opaque status body the host event stream carries; the # registry lowers an adapter-reported status through it. videre-status-body = { path = "../videre-status-body" } @@ -38,6 +38,6 @@ test-utils = [] # so `cargo test -p videre-host` sees `install_for_test` without every # invocation passing `--features test-utils`. videre-host = { path = ".", features = ["test-utils"] } -nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } -nexum-tasks = { path = "../nexum-tasks" } +nexum-runtime = { path = "../../../nexum/crates/nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../../../nexum/crates/nexum-tasks" } tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/videre/crates/videre-host/src/bindings.rs similarity index 93% rename from crates/videre-host/src/bindings.rs rename to videre/crates/videre-host/src/bindings.rs index 4ab7f121..df0a3625 100644 --- a/crates/videre-host/src/bindings.rs +++ b/videre/crates/videre-host/src/bindings.rs @@ -10,10 +10,10 @@ mod venue_adapter { wasmtime::component::bindgen!({ path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", ], world: "videre:venue/venue-adapter", imports: { default: async }, @@ -42,10 +42,10 @@ mod client_host { } ", path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", ], imports: { default: async }, with: { @@ -117,7 +117,7 @@ mod value_flow_smoke { import videre:value-flow/types@0.1.0; } ", - path: ["../../wit/videre-value-flow"], + path: ["../../../wit/videre-value-flow"], }); #[test] @@ -151,10 +151,10 @@ mod client_smoke { } ", path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", ], }); diff --git a/crates/videre-host/src/client.rs b/videre/crates/videre-host/src/client.rs similarity index 100% rename from crates/videre-host/src/client.rs rename to videre/crates/videre-host/src/client.rs diff --git a/crates/videre-host/src/handshake.rs b/videre/crates/videre-host/src/handshake.rs similarity index 100% rename from crates/videre-host/src/handshake.rs rename to videre/crates/videre-host/src/handshake.rs diff --git a/crates/videre-host/src/lib.rs b/videre/crates/videre-host/src/lib.rs similarity index 100% rename from crates/videre-host/src/lib.rs rename to videre/crates/videre-host/src/lib.rs diff --git a/crates/videre-host/src/registry.rs b/videre/crates/videre-host/src/registry.rs similarity index 100% rename from crates/videre-host/src/registry.rs rename to videre/crates/videre-host/src/registry.rs diff --git a/crates/videre-host/tests/platform.rs b/videre/crates/videre-host/tests/platform.rs similarity index 97% rename from crates/videre-host/tests/platform.rs rename to videre/crates/videre-host/tests/platform.rs index e8e87411..883891b1 100644 --- a/crates/videre-host/tests/platform.rs +++ b/videre/crates/videre-host/tests/platform.rs @@ -32,14 +32,14 @@ const INTENT_STATUS: &str = "intent-status"; // ── fixtures + assembly ─────────────────────────────────────────────── -/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is -/// `crates/videre-host`; two parents up is the workspace root. +/// Path under the workspace root (the topmost ancestor with a `Cargo.toml`). fn workspace_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("workspace root") + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) .join(relative) } @@ -409,7 +409,7 @@ async fn e2e_ethflow_watcher_boots_and_handles_intent_status() { let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { return; }; - let manifest = workspace_path("modules/ethflow-watcher/module.toml"); + let manifest = workspace_path("shepherd/modules/ethflow-watcher/module.toml"); let videre = Arc::new(platform(&EngineConfig::default())); let mut supervisor = boot_example(&videre, &wasm, &manifest).await; assert_eq!(supervisor.alive_count(), 1); @@ -583,13 +583,17 @@ async fn e2e_echo_module_registry_adapter_round_trip() { let config = EngineConfig { adapters: vec![AdapterEntry { path: adapter_wasm, - manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), http_allow: Vec::new(), messaging_topics: Vec::new(), }], modules: vec![ModuleEntry { path: module_wasm, - manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-client/module.toml", + )), }], ..Default::default() }; @@ -691,13 +695,17 @@ async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { let config = EngineConfig { adapters: vec![AdapterEntry { path: adapter_wasm, - manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), http_allow: Vec::new(), messaging_topics: Vec::new(), }], modules: vec![ModuleEntry { path: module_wasm, - manifest: Some(workspace_path("modules/examples/echo-keeper/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-keeper/module.toml", + )), }], ..Default::default() }; @@ -774,13 +782,15 @@ async fn e2e_twap_monitor_boots_against_the_cow_adapter() { // Sepolia variant: twap-monitor pins chain 11155111, so the // adapter manifest must name the same chain for the pair to // submit to the right orderbook. - manifest: Some(workspace_path("crates/cow-venue/module.sepolia.toml")), + manifest: Some(workspace_path( + "shepherd/crates/cow-venue/module.sepolia.toml", + )), http_allow: Vec::new(), messaging_topics: Vec::new(), }], modules: vec![ModuleEntry { path: module_wasm, - manifest: Some(workspace_path("modules/twap-monitor/module.toml")), + manifest: Some(workspace_path("shepherd/modules/twap-monitor/module.toml")), }], ..Default::default() }; @@ -941,7 +951,9 @@ async fn boot_flaky_venue( let config = EngineConfig { adapters: vec![AdapterEntry { path: adapter_wasm, - manifest: Some(workspace_path("modules/fixtures/flaky-venue/module.toml")), + manifest: Some(workspace_path( + "videre/modules/fixtures/flaky-venue/module.toml", + )), http_allow: Vec::new(), messaging_topics: Vec::new(), }], diff --git a/crates/videre-host/tests/zero_leak.rs b/videre/crates/videre-host/tests/zero_leak.rs similarity index 90% rename from crates/videre-host/tests/zero_leak.rs rename to videre/crates/videre-host/tests/zero_leak.rs index 43f32ea5..f592a35b 100644 --- a/crates/videre-host/tests/zero_leak.rs +++ b/videre/crates/videre-host/tests/zero_leak.rs @@ -16,14 +16,14 @@ use nexum_runtime::test_utils::{ }; use videre_host::{VenueRegistry, platform}; -/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is -/// `crates/videre-host`; two parents up is the workspace root. +/// Path under the workspace root (the topmost ancestor with a `Cargo.toml`). fn workspace_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("crates dir") - .parent() - .expect("workspace root") + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + manifest + .ancestors() + .filter(|d| d.join("Cargo.toml").is_file()) + .last() + .unwrap_or(manifest) .join(relative) } @@ -73,13 +73,17 @@ async fn e2e_echo_venue_boots_and_submits_through_the_generic_seam() { let config = EngineConfig { adapters: vec![AdapterEntry { path: adapter_wasm, - manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-venue/module.toml", + )), http_allow: Vec::new(), messaging_topics: Vec::new(), }], modules: vec![ModuleEntry { path: module_wasm, - manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + manifest: Some(workspace_path( + "videre/modules/examples/echo-client/module.toml", + )), }], ..Default::default() }; diff --git a/crates/videre-macros/Cargo.toml b/videre/crates/videre-macros/Cargo.toml similarity index 86% rename from crates/videre-macros/Cargo.toml rename to videre/crates/videre-macros/Cargo.toml index 7cf8ec6f..b2edd840 100644 --- a/crates/videre-macros/Cargo.toml +++ b/videre/crates/videre-macros/Cargo.toml @@ -13,7 +13,7 @@ proc-macro = true workspace = true [dependencies] -nexum-world = { path = "../nexum-world", features = ["macros"] } +nexum-world = { path = "../../../nexum/crates/nexum-world", features = ["macros"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = ["full"] } diff --git a/crates/videre-macros/src/intent_body.rs b/videre/crates/videre-macros/src/intent_body.rs similarity index 100% rename from crates/videre-macros/src/intent_body.rs rename to videre/crates/videre-macros/src/intent_body.rs diff --git a/crates/videre-macros/src/keeper.rs b/videre/crates/videre-macros/src/keeper.rs similarity index 100% rename from crates/videre-macros/src/keeper.rs rename to videre/crates/videre-macros/src/keeper.rs diff --git a/crates/videre-macros/src/lib.rs b/videre/crates/videre-macros/src/lib.rs similarity index 100% rename from crates/videre-macros/src/lib.rs rename to videre/crates/videre-macros/src/lib.rs diff --git a/crates/videre-macros/src/venue_marker.rs b/videre/crates/videre-macros/src/venue_marker.rs similarity index 100% rename from crates/videre-macros/src/venue_marker.rs rename to videre/crates/videre-macros/src/venue_marker.rs diff --git a/crates/videre-macros/src/world.rs b/videre/crates/videre-macros/src/world.rs similarity index 100% rename from crates/videre-macros/src/world.rs rename to videre/crates/videre-macros/src/world.rs diff --git a/crates/videre-sdk/Cargo.toml b/videre/crates/videre-sdk/Cargo.toml similarity index 94% rename from crates/videre-sdk/Cargo.toml rename to videre/crates/videre-sdk/Cargo.toml index acc8a57e..16e5b4dc 100644 --- a/crates/videre-sdk/Cargo.toml +++ b/videre/crates/videre-sdk/Cargo.toml @@ -27,7 +27,7 @@ videre-macros = { path = "../videre-macros" } # Host-neutral SDK layer this crate builds on: the `ChainHost` seam the # chain wrapper implements, the shared `Fault` vocabulary, and the # wasi:http `fetch` surface re-exported as `transport::http`. -nexum-sdk = { path = "../nexum-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } # The venue status-body codec, re-exported as `videre_sdk::status_body`; # venue guests reach the `intent-status` decode path through here. videre-status-body = { path = "../videre-status-body" } @@ -42,4 +42,4 @@ wit-bindgen.workspace = true [dev-dependencies] # In-memory `LocalStoreHost` behind the keeper run tests. -nexum-sdk-test = { path = "../nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } diff --git a/crates/videre-sdk/src/adapter.rs b/videre/crates/videre-sdk/src/adapter.rs similarity index 100% rename from crates/videre-sdk/src/adapter.rs rename to videre/crates/videre-sdk/src/adapter.rs diff --git a/crates/videre-sdk/src/bindings.rs b/videre/crates/videre-sdk/src/bindings.rs similarity index 88% rename from crates/videre-sdk/src/bindings.rs rename to videre/crates/videre-sdk/src/bindings.rs index cf8cdd0b..71ec301e 100644 --- a/crates/videre-sdk/src/bindings.rs +++ b/videre/crates/videre-sdk/src/bindings.rs @@ -24,10 +24,10 @@ world sdk-imports { } ", path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", + "../../../wit/videre-value-flow", + "../../../wit/videre-types", + "../../../wit/nexum-host", + "../../../wit/videre-venue", ], world: "videre:sdk-shims/sdk-imports", generate_all, diff --git a/crates/videre-sdk/src/body.rs b/videre/crates/videre-sdk/src/body.rs similarity index 100% rename from crates/videre-sdk/src/body.rs rename to videre/crates/videre-sdk/src/body.rs diff --git a/crates/videre-sdk/src/client.rs b/videre/crates/videre-sdk/src/client.rs similarity index 100% rename from crates/videre-sdk/src/client.rs rename to videre/crates/videre-sdk/src/client.rs diff --git a/crates/videre-sdk/src/event.rs b/videre/crates/videre-sdk/src/event.rs similarity index 100% rename from crates/videre-sdk/src/event.rs rename to videre/crates/videre-sdk/src/event.rs diff --git a/crates/videre-sdk/src/faults.rs b/videre/crates/videre-sdk/src/faults.rs similarity index 100% rename from crates/videre-sdk/src/faults.rs rename to videre/crates/videre-sdk/src/faults.rs diff --git a/crates/videre-sdk/src/keeper.rs b/videre/crates/videre-sdk/src/keeper.rs similarity index 100% rename from crates/videre-sdk/src/keeper.rs rename to videre/crates/videre-sdk/src/keeper.rs diff --git a/crates/videre-sdk/src/lib.rs b/videre/crates/videre-sdk/src/lib.rs similarity index 100% rename from crates/videre-sdk/src/lib.rs rename to videre/crates/videre-sdk/src/lib.rs diff --git a/crates/videre-sdk/src/transport.rs b/videre/crates/videre-sdk/src/transport.rs similarity index 100% rename from crates/videre-sdk/src/transport.rs rename to videre/crates/videre-sdk/src/transport.rs diff --git a/crates/videre-sdk/src/value_flow.rs b/videre/crates/videre-sdk/src/value_flow.rs similarity index 100% rename from crates/videre-sdk/src/value_flow.rs rename to videre/crates/videre-sdk/src/value_flow.rs diff --git a/crates/videre-sdk/tests/adapter.rs b/videre/crates/videre-sdk/tests/adapter.rs similarity index 100% rename from crates/videre-sdk/tests/adapter.rs rename to videre/crates/videre-sdk/tests/adapter.rs diff --git a/crates/videre-status-body/Cargo.toml b/videre/crates/videre-status-body/Cargo.toml similarity index 100% rename from crates/videre-status-body/Cargo.toml rename to videre/crates/videre-status-body/Cargo.toml diff --git a/crates/videre-status-body/src/lib.rs b/videre/crates/videre-status-body/src/lib.rs similarity index 100% rename from crates/videre-status-body/src/lib.rs rename to videre/crates/videre-status-body/src/lib.rs diff --git a/crates/videre-test/Cargo.toml b/videre/crates/videre-test/Cargo.toml similarity index 91% rename from crates/videre-test/Cargo.toml rename to videre/crates/videre-test/Cargo.toml index 02198e5a..d53a82a6 100644 --- a/crates/videre-test/Cargo.toml +++ b/videre/crates/videre-test/Cargo.toml @@ -25,10 +25,10 @@ hex.workspace = true http.workspace = true # Transport seam vocabulary: `ChainHost`, `Fault`, and the `Fetch` seam # the mocks implement. -nexum-sdk = { path = "../nexum-sdk" } +nexum-sdk = { path = "../../../nexum/crates/nexum-sdk" } # `MockChain` is composed rather than reimplemented: one chain mock # serves both personas. -nexum-sdk-test = { path = "../nexum-sdk-test" } +nexum-sdk-test = { path = "../../../nexum/crates/nexum-sdk-test" } # The contract under test: `IntentBody`, `BodyError`, the intent # header types, and the `MessagingHost` seam. videre-sdk = { path = "../videre-sdk" } diff --git a/crates/videre-test/goldens/reference-header.json b/videre/crates/videre-test/goldens/reference-header.json similarity index 100% rename from crates/videre-test/goldens/reference-header.json rename to videre/crates/videre-test/goldens/reference-header.json diff --git a/crates/videre-test/src/codec.rs b/videre/crates/videre-test/src/codec.rs similarity index 100% rename from crates/videre-test/src/codec.rs rename to videre/crates/videre-test/src/codec.rs diff --git a/crates/videre-test/src/fixture.rs b/videre/crates/videre-test/src/fixture.rs similarity index 100% rename from crates/videre-test/src/fixture.rs rename to videre/crates/videre-test/src/fixture.rs diff --git a/crates/videre-test/src/header.rs b/videre/crates/videre-test/src/header.rs similarity index 100% rename from crates/videre-test/src/header.rs rename to videre/crates/videre-test/src/header.rs diff --git a/crates/videre-test/src/lib.rs b/videre/crates/videre-test/src/lib.rs similarity index 100% rename from crates/videre-test/src/lib.rs rename to videre/crates/videre-test/src/lib.rs diff --git a/crates/videre-test/src/reconcile.rs b/videre/crates/videre-test/src/reconcile.rs similarity index 100% rename from crates/videre-test/src/reconcile.rs rename to videre/crates/videre-test/src/reconcile.rs diff --git a/crates/videre-test/src/reference.rs b/videre/crates/videre-test/src/reference.rs similarity index 100% rename from crates/videre-test/src/reference.rs rename to videre/crates/videre-test/src/reference.rs diff --git a/crates/videre-test/src/report.rs b/videre/crates/videre-test/src/report.rs similarity index 100% rename from crates/videre-test/src/report.rs rename to videre/crates/videre-test/src/report.rs diff --git a/crates/videre-test/src/transport.rs b/videre/crates/videre-test/src/transport.rs similarity index 100% rename from crates/videre-test/src/transport.rs rename to videre/crates/videre-test/src/transport.rs diff --git a/crates/videre-test/tests/conformance.rs b/videre/crates/videre-test/tests/conformance.rs similarity index 100% rename from crates/videre-test/tests/conformance.rs rename to videre/crates/videre-test/tests/conformance.rs diff --git a/crates/videre-test/vectors/reference-body.json b/videre/crates/videre-test/vectors/reference-body.json similarity index 100% rename from crates/videre-test/vectors/reference-body.json rename to videre/crates/videre-test/vectors/reference-body.json diff --git a/modules/examples/echo-client/Cargo.toml b/videre/modules/examples/echo-client/Cargo.toml similarity index 92% rename from modules/examples/echo-client/Cargo.toml rename to videre/modules/examples/echo-client/Cargo.toml index c5c20896..c2288cc6 100644 --- a/modules/examples/echo-client/Cargo.toml +++ b/videre/modules/examples/echo-client/Cargo.toml @@ -13,7 +13,7 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-sdk = { path = "../../../crates/nexum-sdk" } +nexum-sdk = { path = "../../../../nexum/crates/nexum-sdk" } # The venue status-body codec the `on_custom` handler decodes an # intent-status transition through, reached via `videre_sdk::status_body`. videre-sdk = { path = "../../../crates/videre-sdk" } diff --git a/modules/examples/echo-client/module.toml b/videre/modules/examples/echo-client/module.toml similarity index 100% rename from modules/examples/echo-client/module.toml rename to videre/modules/examples/echo-client/module.toml diff --git a/modules/examples/echo-client/src/lib.rs b/videre/modules/examples/echo-client/src/lib.rs similarity index 100% rename from modules/examples/echo-client/src/lib.rs rename to videre/modules/examples/echo-client/src/lib.rs diff --git a/modules/examples/echo-keeper/Cargo.toml b/videre/modules/examples/echo-keeper/Cargo.toml similarity index 90% rename from modules/examples/echo-keeper/Cargo.toml rename to videre/modules/examples/echo-keeper/Cargo.toml index ff843e85..de13587b 100644 --- a/modules/examples/echo-keeper/Cargo.toml +++ b/videre/modules/examples/echo-keeper/Cargo.toml @@ -13,6 +13,6 @@ workspace = true crate-type = ["cdylib"] [dependencies] -nexum-sdk = { path = "../../../crates/nexum-sdk" } +nexum-sdk = { path = "../../../../nexum/crates/nexum-sdk" } videre-sdk = { path = "../../../crates/videre-sdk" } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/examples/echo-keeper/module.toml b/videre/modules/examples/echo-keeper/module.toml similarity index 100% rename from modules/examples/echo-keeper/module.toml rename to videre/modules/examples/echo-keeper/module.toml diff --git a/modules/examples/echo-keeper/src/lib.rs b/videre/modules/examples/echo-keeper/src/lib.rs similarity index 100% rename from modules/examples/echo-keeper/src/lib.rs rename to videre/modules/examples/echo-keeper/src/lib.rs diff --git a/modules/examples/echo-venue/Cargo.toml b/videre/modules/examples/echo-venue/Cargo.toml similarity index 100% rename from modules/examples/echo-venue/Cargo.toml rename to videre/modules/examples/echo-venue/Cargo.toml diff --git a/modules/examples/echo-venue/module.toml b/videre/modules/examples/echo-venue/module.toml similarity index 100% rename from modules/examples/echo-venue/module.toml rename to videre/modules/examples/echo-venue/module.toml diff --git a/modules/examples/echo-venue/src/lib.rs b/videre/modules/examples/echo-venue/src/lib.rs similarity index 100% rename from modules/examples/echo-venue/src/lib.rs rename to videre/modules/examples/echo-venue/src/lib.rs diff --git a/modules/fixtures/flaky-venue/Cargo.toml b/videre/modules/fixtures/flaky-venue/Cargo.toml similarity index 100% rename from modules/fixtures/flaky-venue/Cargo.toml rename to videre/modules/fixtures/flaky-venue/Cargo.toml diff --git a/modules/fixtures/flaky-venue/module.toml b/videre/modules/fixtures/flaky-venue/module.toml similarity index 100% rename from modules/fixtures/flaky-venue/module.toml rename to videre/modules/fixtures/flaky-venue/module.toml diff --git a/modules/fixtures/flaky-venue/src/lib.rs b/videre/modules/fixtures/flaky-venue/src/lib.rs similarity index 100% rename from modules/fixtures/flaky-venue/src/lib.rs rename to videre/modules/fixtures/flaky-venue/src/lib.rs