From 1bb26758eda30d14d0429b4ef28aed5494d95307 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:18:18 +0000 Subject: [PATCH 01/89] feat(sdk): add keeper conditional-source seam and CoW run loop (#239) * feat(sdk): add the keeper conditional-source seam, retry ledger, and run Introduce the world-neutral half of the poll loop in nexum_sdk::keeper: a ConditionalSource trait (one watch in, one outcome out, generic over the host, no venue-transport pre-abstraction), a Tick carrying the dispatch instant, and the RetryAction (try-next-block / backoff / drop) whose effects the Retrier runs through the gate and watch-set stores. RetryAction moves out of shepherd-sdk; the CoW crate re-exports it so no module rewires. shepherd-sdk keeps classify_api_error as the CoW errorType table but returns the keeper action, and encodes two fixes in the one classification path: classify_poll_error maps an unrecognised revert selector with a structured payload to DontTryAgain instead of re-polling every block forever, and DuplicatedOrder (both spellings) classifies as already-submitted - the run records the submitted: receipt and keeps the watch rather than dropping every future tranche. classify_submit_error widens the table to the whole CowApiError surface and gives Backoff its producer: a rate-limit fault with server guidance gates the watch on the epoch clock. cow::run composes the loop: watch-set scan -> gate check -> source poll -> PollOutcome dispatch, driving submission through the existing CowApiHost seam with the journal as the idempotency guard and the retry ledger as the failure dispatch. Acceptance tests run against the composed shepherd-sdk-test MockHost as integration tests; no module is rewired yet. * docs(sdk): disambiguate the run intra-doc link * fix(cow): build the classify_poll_error test RpcError data as Bytes RpcError.data is now Bytes; the test helper takes raw Vec and wraps it (Vec -> Bytes is O(1)). * fix(cow): keep the sweep alive when a post-submit journal write faults journal.record after a successful submit_order was propagating a store fault out of submit_ready, aborting the whole watch sweep with the receipt unwritten - the next tick then re-polled and re-posted the same order. Log the failure and carry on instead; the already-submitted arm keeps the re-post idempotent. Both post-submit writes are covered: the Ok arm and the already-submitted (DuplicatedOrder) arm. --- Cargo.lock | 2 + crates/nexum-sdk/src/keeper.rs | 95 +++++ crates/nexum-sdk/src/lib.rs | 6 +- crates/nexum-sdk/tests/keeper.rs | 138 ++++++- crates/shepherd-sdk/Cargo.toml | 6 + crates/shepherd-sdk/src/cow/composable.rs | 94 ++++- crates/shepherd-sdk/src/cow/error.rs | 155 +++++--- crates/shepherd-sdk/src/cow/mod.rs | 23 +- crates/shepherd-sdk/src/cow/order.rs | 51 ++- crates/shepherd-sdk/src/cow/run.rs | 198 ++++++++++ crates/shepherd-sdk/src/lib.rs | 7 +- crates/shepherd-sdk/tests/run.rs | 422 ++++++++++++++++++++++ 12 files changed, 1139 insertions(+), 58 deletions(-) create mode 100644 crates/shepherd-sdk/src/cow/run.rs create mode 100644 crates/shepherd-sdk/tests/run.rs diff --git a/Cargo.lock b/Cargo.lock index db6e15e7..fa9ae472 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5102,6 +5102,8 @@ dependencies = [ "cowprotocol", "nexum-sdk", "proptest", + "serde_json", + "shepherd-sdk-test", "strum", "thiserror 2.0.18", ] diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 06d1cd9e..88edc6de 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -13,6 +13,14 @@ //! - [`Journal`] - the receipt-keyed idempotency journal of //! `submitted:` / `observed:` presence markers. //! +//! Two pieces drive the stores from the poll loop: +//! +//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! one outcome out, at a given [`Tick`]. Implementations own the +//! transport and the outcome shape. +//! - [`Retrier`] - runs a [`RetryAction`]'s effect through the +//! stores after a failed run attempt. +//! //! [`WatchRef`] ties the first two together: gate keys are derived //! from the exact hex substrings of the stored watch key, and //! [`WatchSet::remove`] drops a watch together with all of its gate @@ -69,6 +77,7 @@ //! ``` use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; use crate::host::{Fault, LocalStoreHost}; @@ -292,3 +301,89 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { .is_some()) } } + +/// One poll dispatch's world view: chain, block height, and the block +/// clock in Unix seconds. Gate checks and backoff arithmetic read the +/// same instant a source is polled at, so a watch can never gate +/// itself against a clock it was not judged by. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Tick { + /// Chain the dispatch targets. + pub chain_id: u64, + /// Block height at the tick. + pub block: u64, + /// Block timestamp, Unix seconds. + pub epoch_s: u64, +} + +/// A source of conditional commitments: poll one watch, produce one +/// outcome. Generic over the host so implementations stay mock- +/// testable; deliberately no venue-transport abstraction - the source +/// owns its own wire (an `eth_call`, an HTTP probe, a stub). +/// +/// A transient failure should surface as a retry-flavoured outcome, +/// not tear down the caller's sweep: `poll` is infallible by contract. +pub trait ConditionalSource { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value (the encoded commitment parameters), passed + /// verbatim so the source owns the decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; +} + +/// What the retry ledger should do to a watch after a failed +/// run attempt. +/// +/// `IntoStaticStr` exposes each variant as a snake_case `&'static +/// str` for log and metric labels. `#[non_exhaustive]` so the +/// contract can grow a variant; downstream dispatch should treat an +/// unknown variant as "leave the watch in place" (the conservative +/// choice). +#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum RetryAction { + /// Leave the watch untouched; the next tick re-attempts. + TryNextBlock, + /// Gate the watch until `now + seconds` on the epoch clock. + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// Remove the watch and its gates; no retry can succeed. + Drop, +} + +/// Retry ledger: runs a [`RetryAction`]'s effect through the keeper +/// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; +/// `Drop` delegates to [`WatchSet::remove`], so gates go first and no +/// failure path can orphan one. +pub struct Retrier<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Retrier<'h, H> { + /// Ledger view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Apply `action` to the watch, with `now_epoch_s` as the backoff + /// origin. + pub fn apply( + &self, + watch: WatchRef<'_>, + action: RetryAction, + now_epoch_s: u64, + ) -> Result<(), Fault> { + match action { + RetryAction::TryNextBlock => Ok(()), + RetryAction::Backoff { seconds } => { + Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + } + RetryAction::Drop => WatchSet::new(self.host).remove(watch), + } + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index ac6531fe..b11cd21e 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -31,7 +31,8 @@ //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal -//! ([`Journal`]). +//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! [`Retrier`] dispatching a [`RetryAction`] through the stores. //! //! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader @@ -81,6 +82,9 @@ //! [`WatchSet`]: keeper::WatchSet //! [`Gates`]: keeper::Gates //! [`Journal`]: keeper::Journal +//! [`ConditionalSource`]: keeper::ConditionalSource +//! [`Retrier`]: keeper::Retrier +//! [`RetryAction`]: keeper::RetryAction //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index 5ac54c87..ff8f9746 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -7,7 +7,8 @@ use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ - Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, WATCH_PREFIX, WatchRef, WatchSet, + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, + Tick, WATCH_PREFIX, WatchRef, WatchSet, }; use shepherd_sdk_test::MockHost; @@ -351,3 +352,138 @@ fn submitted_and_observed_keyspaces_are_disjoint() { assert!(snapshot.contains_key("submitted:0xuid")); assert!(!snapshot.contains_key("observed:0xuid")); } + +// ---- retry ledger ---- + +fn seeded_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +#[test] +fn ledger_try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let before = host.store.snapshot(); + + Retrier::new(&host) + .apply( + WatchRef::parse(&key).unwrap(), + RetryAction::TryNextBlock, + 1_000, + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); +} + +#[test] +fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = Retrier::new(&host); + + ledger + .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .unwrap(); + + let gates = Gates::new(&host); + assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_030_u64.to_le_bytes().to_vec(), + ); + assert!( + host.store.snapshot().contains_key(&key), + "backoff must keep the watch", + ); +} + +#[test] +fn ledger_backoff_saturates_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &u64::MAX.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 500).unwrap(); + + Retrier::new(&host) + .apply(watch, RetryAction::Drop, 1_000) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn retry_action_labels_are_stable_snake_case() { + let cases: [(RetryAction, &str); 3] = [ + (RetryAction::TryNextBlock, "try_next_block"), + (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::Drop, "drop"), + ]; + for (action, label) in cases { + assert_eq!(<&'static str>::from(action), label); + } +} + +// ---- conditional source ---- + +/// A source is generic over the host and owns its outcome shape; the +/// keeper passes the stored params verbatim and the tick it judged +/// the gates by. +#[test] +fn conditional_source_sees_params_and_tick_verbatim() { + struct EchoSource; + impl ConditionalSource for EchoSource { + type Outcome = (usize, u64, u64, u64, String); + fn poll( + &self, + _host: &H, + watch: WatchRef<'_>, + params: &[u8], + tick: &Tick, + ) -> Self::Outcome { + ( + params.len(), + tick.chain_id, + tick.block, + tick.epoch_s, + watch.key(), + ) + } + } + + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tick = Tick { + chain_id: 1, + block: 42, + epoch_s: 1_700_000_000, + }; + + let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); + assert_eq!(len, b"params".len()); + assert_eq!(chain_id, 1); + assert_eq!(block, 42); + assert_eq!(epoch_s, 1_700_000_000); + assert_eq!(echoed, key); +} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index fe8e1af3..717d5c5d 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -16,8 +16,14 @@ nexum-sdk = { path = "../nexum-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true alloy-sol-types.workspace = true +serde_json.workspace = true strum.workspace = true thiserror.workspace = true [dev-dependencies] proptest.workspace = true +# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, +# and the run acceptance-tests against the composed MockHost +# as an integration test - the mock crate links shepherd-sdk +# externally, so the unit-test copy of the traits would not unify. +shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 212e1240..47aa22ee 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -12,6 +12,7 @@ use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolError, sol}; use cowprotocol::GPv2OrderData; +use nexum_sdk::host::ChainError; sol! { /// Five custom errors `IConditionalOrder.verify` reverts with. @@ -75,9 +76,9 @@ pub enum PollOutcome { /// /// Returns `None` when the selector is not one of the five /// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. Callers should treat that as `TryNextBlock` (the -/// safe default) so a transient RPC blip does not drop a still-valid -/// watch. +/// require-revert. [`classify_poll_error`] is the lifecycle policy on +/// top: it treats any such foreign selector as a permanent +/// contract-level rejection. #[must_use] pub fn decode_revert(data: &[u8]) -> Option { if data.len() < 4 { @@ -105,6 +106,29 @@ pub fn decode_revert(data: &[u8]) -> Option { } } +/// Classify a failed poll `eth_call` into a [`PollOutcome`] - the one +/// policy for what a poll failure means to the watch lifecycle. +/// +/// A revert payload big enough to carry a selector that +/// [`decode_revert`] does not recognise maps to `DontTryAgain`: it is +/// a contract-level rejection outside the `IConditionalOrder` +/// vocabulary (a handler-specific error, typically permanent), and +/// retrying it on every block loops forever. Only payload-free +/// failures - transport faults and reverts whose `data` is absent or +/// shorter than a selector - stay `TryNextBlock`. +#[must_use] +pub fn classify_poll_error(err: &ChainError) -> PollOutcome { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + decode_revert(data).unwrap_or(PollOutcome::DontTryAgain) + } + _ => PollOutcome::TryNextBlock, + }, + ChainError::Fault(_) => PollOutcome::TryNextBlock, + } +} + fn u256_to_u64_saturating(v: U256) -> u64 { u64::try_from(v).unwrap_or(u64::MAX) } @@ -187,4 +211,68 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } + + // ---- classify_poll_error ---- + + use nexum_sdk::host::{Fault, RpcError}; + + fn rpc(data: Option>) -> ChainError { + ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: data.map(Into::into), + }) + } + + #[test] + fn classify_dispatches_a_recognised_selector() { + let revert = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(777_u64), + reason: "wait".to_string(), + } + .abi_encode(); + assert!(matches!( + classify_poll_error(&rpc(Some(revert))), + PollOutcome::TryOnBlock(777) + )); + } + + /// A handler-specific selector outside the `IConditionalOrder` + /// vocabulary is a permanent contract-level rejection: it must + /// drop, not re-poll every block forever. + #[test] + fn classify_unrecognised_selector_drops() { + let mut data = vec![0x7a, 0x93, 0x32, 0x34]; + data.extend_from_slice(&[0u8; 32]); + assert!(matches!( + classify_poll_error(&rpc(Some(data))), + PollOutcome::DontTryAgain + )); + // A bare 4-byte selector with no body classifies the same way. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + PollOutcome::DontTryAgain + )); + } + + #[test] + fn classify_payload_free_failures_stay_try_next_block() { + assert!(matches!( + classify_poll_error(&rpc(None)), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&rpc(Some(Vec::new()))), + PollOutcome::TryNextBlock + )); + // Sub-selector payloads cannot name a contract error. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x01, 0x02]))), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&ChainError::Fault(Fault::Timeout)), + PollOutcome::TryNextBlock + )); + } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 23360273..4129cd71 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -7,12 +7,16 @@ //! envelope. The guest dispatches on the variant directly, so no //! second JSON decode of a failure body happens strategy-side. //! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into a -//! [`RetryAction`] the lifecycle layer dispatches on. +//! [`classify_api_error`] maps a decoded [`OrderRejection`] into the +//! keeper [`RetryAction`] the retry ledger dispatches on; +//! [`classify_submit_error`] widens the table to the whole +//! [`CowApiError`] surface. use nexum_sdk::host::{Fault, HostFault}; use strum::IntoStaticStr; +pub use nexum_sdk::keeper::RetryAction; + /// A non-2xx orderbook reply with no typed rejection envelope. `body` /// is the raw response text, foreign orderbook JSON kept verbatim: a /// caller matches on `status` and reads `body` only for diagnostics. @@ -79,49 +83,19 @@ impl HostFault for CowApiError { } } -/// What the lifecycle layer should do after a failed submission. -/// -/// Mirrors the retry contract: `TryNextBlock` / -/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer -/// today because the retry classifier is bool-only; the -/// variant is kept so dispatch can grow into it once a server -/// `Retry-After` hint shows up. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the dispatch layer can record -/// `shepherd_cow_api_retry_total{action=...}` and surface the action -/// in `tracing::info!(retry_action = ...)` without an ad-hoc match -/// ladder. -#[derive(Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch / placement in place; the next event will - /// re-attempt. - TryNextBlock, - /// Persist `next_attempt = now + seconds`. Reserved - no producer - /// today (kept so the dispatch contract is stable). - #[allow(dead_code)] - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Remove the watch / mark as terminally rejected. The orderbook - /// will not accept this body on a retry. - Drop, -} - -/// Classify a decoded orderbook [`OrderRejection`] into a -/// [`RetryAction`]. +/// Classify a decoded orderbook [`OrderRejection`] into the keeper +/// [`RetryAction`] - the CoW `errorType` classification table. /// /// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, /// `PriceExceedsMarketPrice`) -> `TryNextBlock`. +/// - Already-submitted rejections ([`is_already_submitted`]) -> +/// `TryNextBlock`: the order is live, so the watch must survive; the +/// run also records the `submitted:` receipt so the next +/// tick short-circuits instead of re-posting. /// - Every other (including unrecognised) kind -> `Drop`. /// -/// Non-`Rejected` failures (transport faults, raw HTTP errors) carry -/// no `error_type` and are not classified here; the caller treats them -/// as transient (leave the watch in place) so a flaky orderbook does -/// not poison a still-valid order. +/// Non-`Rejected` failures carry no `error_type`; classify those with +/// [`classify_submit_error`]. /// /// # Example /// @@ -147,13 +121,48 @@ pub enum RetryAction { /// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); /// ``` pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_retriable(&rejection.error_type) { + if is_already_submitted(rejection) || is_retriable(&rejection.error_type) { RetryAction::TryNextBlock } else { RetryAction::Drop } } +/// Whether the rejection says the orderbook already holds this exact +/// order: `DuplicatedOrder` (the orderbook's spelling) plus the +/// `DuplicateOrder` variant older deployments emit. Already-submitted +/// is success wearing an error status - dropping the watch on it would +/// kill every future tranche of a TWAP - so the caller records the +/// `submitted:` receipt and keeps the watch. +pub fn is_already_submitted(rejection: &OrderRejection) -> bool { + matches!( + rejection.error_type.as_str(), + "DuplicatedOrder" | "DuplicateOrder" + ) +} + +/// Classify a whole [`CowApiError`] from a submission into the keeper +/// [`RetryAction`]. +/// +/// A typed rejection dispatches through [`classify_api_error`]; a +/// rate-limit fault with server guidance becomes `Backoff` (hint +/// rounded up to whole seconds, minimum one). Everything else +/// (transport faults, raw HTTP errors, unguided rate limits) is +/// transient -> `TryNextBlock`, so a flaky orderbook never poisons a +/// still-valid order. +pub fn classify_submit_error(err: &CowApiError) -> RetryAction { + match err { + CowApiError::Rejected(rejection) => classify_api_error(rejection), + CowApiError::Fault(Fault::RateLimited(limit)) => match limit.retry_after_ms { + Some(ms) => RetryAction::Backoff { + seconds: ms.div_ceil(1000).max(1), + }, + None => RetryAction::TryNextBlock, + }, + _ => RetryAction::TryNextBlock, + } +} + /// Orderbook `errorType` values the protocol treats as transient: a /// fresh submission on a later block may succeed. Everything else /// (including unrecognised types) is permanent. Mirrors the upstream @@ -199,7 +208,6 @@ mod tests { for kind in [ "InvalidSignature", "WrongOwner", - "DuplicateOrder", "UnsupportedToken", "InvalidAppData", "InvalidErc1271Signature", @@ -220,6 +228,69 @@ mod tests { ); } + /// Both spellings pin: the orderbook emits `DuplicatedOrder`, the + /// older `DuplicateOrder` form must classify identically. Neither + /// may drop the watch - that would kill every future tranche. + #[test] + fn duplicated_order_is_already_submitted_and_never_drops() { + for kind in ["DuplicatedOrder", "DuplicateOrder"] { + assert!(is_already_submitted(&rejection(kind)), "{kind}"); + assert_eq!( + classify_api_error(&rejection(kind)), + RetryAction::TryNextBlock, + "{kind}", + ); + } + assert!(!is_already_submitted(&rejection("InsufficientFee"))); + assert!(!is_already_submitted(&rejection("InvalidSignature"))); + } + + #[test] + fn submit_error_rejection_routes_through_the_table() { + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InvalidSignature"))), + RetryAction::Drop, + ); + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InsufficientFee"))), + RetryAction::TryNextBlock, + ); + } + + #[test] + fn submit_error_rate_limit_hint_becomes_backoff_in_whole_seconds() { + let limited = |ms| CowApiError::Fault(Fault::RateLimited(RateLimit { retry_after_ms: ms })); + assert_eq!( + classify_submit_error(&limited(Some(2_500))), + RetryAction::Backoff { seconds: 3 }, + ); + // Sub-second hints round up to a full second, never to zero. + assert_eq!( + classify_submit_error(&limited(Some(1))), + RetryAction::Backoff { seconds: 1 }, + ); + // No guidance -> plain next-block retry. + assert_eq!( + classify_submit_error(&limited(None)), + RetryAction::TryNextBlock + ); + } + + #[test] + fn submit_error_transient_shapes_stay_try_next_block() { + assert_eq!( + classify_submit_error(&CowApiError::Fault(Fault::Timeout)), + RetryAction::TryNextBlock, + ); + assert_eq!( + classify_submit_error(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + RetryAction::TryNextBlock, + ); + } + #[test] fn fault_case_recovers_embedded_fault_and_label() { let err = CowApiError::Fault(Fault::Timeout); diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 36cbdcca..893a80e6 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,20 +3,27 @@ //! Type conversions and ABI decoding helpers that translate between //! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, //! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`). +//! `PollOutcome`, `RetryAction`), plus [`run()`] - the +//! poll/submit composition over the keeper stores. //! -//! Each submodule stays purely host-neutral: helpers take primitive -//! arguments (`&[u8]`, `Option<&str>`, slices) so they can be unit- -//! tested without wit-bindgen scaffolding and re-used unchanged by -//! TWAP, EthFlow, and future strategy modules. +//! The codec submodules stay purely host-neutral: helpers take +//! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can +//! be unit-tested without wit-bindgen scaffolding and re-used +//! unchanged by TWAP, EthFlow, and future strategy modules. The +//! run is generic over the host traits alone. pub mod composable; pub mod error; pub mod order; +pub mod run; -pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; -pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; -pub use order::gpv2_to_order_data; +pub use composable::{IConditionalOrder, PollOutcome, classify_poll_error, decode_revert}; +pub use error::{ + CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, + classify_submit_error, is_already_submitted, +}; +pub use order::{gpv2_to_order_data, order_uid_hex}; +pub use run::run; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d983d649..5bcef8fb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -7,7 +7,9 @@ //! into Rust enums. [`gpv2_to_order_data`] is the bridge. use alloy_primitives::Address; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource}; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, +}; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the /// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` @@ -71,6 +73,23 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { }) } +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker; both also stop the submit path +/// downstream, so callers fall through and let it surface the +/// diagnostic. +#[must_use] +pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let domain = chain.settlement_domain(); + let order_data = gpv2_to_order_data(order)?; + Some(format!("{}", order_data.uid(&domain, owner))) +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +156,34 @@ mod tests { g.buyTokenBalance = B256::repeat_byte(0x55); assert!(gpv2_to_order_data(&g).is_none()); } + + // ---- order_uid_hex ---- + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } } diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs new file mode 100644 index 00000000..f2e6bf32 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -0,0 +1,198 @@ +//! Watch run: the poll-loop composition conditional- +//! commitment modules share. +//! +//! [`run`] walks the keeper watch set, polls each gate-ready +//! watch through a [`ConditionalSource`], and runs the +//! [`PollOutcome`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Ready` drives one submission through the +//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` +//! journal as the idempotency guard and the keeper [`Retrier`] +//! as the failure dispatch. +//! +//! Store faults abort the sweep (the next tick replays it); +//! submission failures never do - they classify into a +//! [`RetryAction`](super::RetryAction), the ledger applies the +//! effect, and the sweep moves on. + +use alloy_primitives::{Address, Bytes}; +use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; +use nexum_sdk::Level; +use nexum_sdk::host::Fault; +use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Retrier, Tick, WatchRef, WatchSet}; + +use super::{ + CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, + is_already_submitted, order_uid_hex, +}; + +/// Poll every gate-ready watch once at `tick` and run each outcome's +/// effect. One source poll per ready watch; a `Ready` outcome makes at +/// most one `submit_order` call. +pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: CowHost, + S: ConditionalSource, +{ + let watches = WatchSet::new(host); + let gates = Gates::new(host); + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + continue; + } + let Some(params) = watches.get(watch)? else { + continue; + }; + match source.poll(host, watch, ¶ms, tick) { + PollOutcome::Ready { order, signature } => { + submit_ready(host, watch, &order, signature, tick)?; + } + PollOutcome::TryNextBlock => {} + PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, + PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, + PollOutcome::DontTryAgain => watches.remove(watch)?, + } + } + Ok(()) +} + +/// Submit one freshly-polled `Ready` order, guarding on the +/// `submitted:` journal and dispatching any failure through the retry +/// ledger. +/// +/// The UID is deterministic from on-chain inputs, so the idempotency +/// check runs before any network work; the same value keys the journal +/// marker after, so the read and write paths agree. +fn submit_ready( + host: &H, + watch: WatchRef<'_>, + order: &GPv2OrderData, + signature: Bytes, + tick: &Tick, +) -> Result<(), Fault> { + let Ok(owner) = watch.owner_hex().parse::
() else { + host.log( + Level::WARN, + &format!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), + ), + ); + return Ok(()); + }; + + let journal = Journal::submitted(host); + let client_uid = order_uid_hex(tick.chain_id, order, owner); + if let Some(uid) = client_uid.as_deref() + && journal.contains(uid)? + { + host.log( + Level::INFO, + &format!("{uid} already submitted; skipping re-submit"), + ); + return Ok(()); + } + + let creation = match build_order_creation(order, signature, owner) { + Ok(creation) => creation, + Err(message) => { + host.log( + Level::WARN, + &format!("submit skipped for {owner:#x}: {message}"), + ); + return Ok(()); + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(body) => body, + Err(e) => { + host.log( + Level::ERROR, + &format!("OrderCreation JSON encode failed: {e}"), + ); + return Ok(()); + } + }; + + match host.submit_order(tick.chain_id, &body) { + Ok(server_uid) => { + // Prefer the client-computed UID so the guard above reads + // what this writes; a divergence would be a protocol bug + // worth a warning, never a silently split keyspace. + let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + // The submit already succeeded; a journal-store fault here + // must not abort the sweep or unwind the accepted order. + // Log and carry on - the already-submitted arm keeps the + // next tick's re-post idempotent. + if let Err(fault) = journal.record(marker) { + host.log( + Level::ERROR, + &format!("submitted {marker} but journal write failed: {fault}"), + ); + } + if let Some(client) = client_uid.as_deref() + && client != server_uid + { + host.log( + Level::WARN, + &format!( + "UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" + ), + ); + } + host.log(Level::INFO, &format!("submitted {marker}")); + } + Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { + // Success wearing an error status: the orderbook already + // holds this order. Record the receipt and keep the watch + // so the next tick short-circuits instead of re-posting. + // As above, a journal fault post-submit only forfeits the + // short-circuit; it must not abort the sweep. + if let Some(uid) = client_uid.as_deref() + && let Err(fault) = journal.record(uid) + { + host.log( + Level::ERROR, + &format!("orderbook already holds {uid} but journal write failed: {fault}"), + ); + } + host.log( + Level::INFO, + &format!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, + ), + ); + } + Err(err) => { + let action = classify_submit_error(&err); + Retrier::new(host).apply(watch, action, tick.epoch_s)?; + let label: &'static str = action.into(); + host.log( + Level::WARN, + &format!("submit failed ({err}); retry action {label}"), + ); + } + } + Ok(()) +} + +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +fn build_order_creation( + order: &GPv2OrderData, + signature: Bytes, + from: Address, +) -> Result { + let order_data = gpv2_to_order_data(order) + .ok_or_else(|| "GPv2OrderData carried an unknown enum marker".to_string())?; + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(&order_data, signature, from, None) + .map_err(|e| e.to_string()) +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 86e00dea..70dacc1c 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -17,8 +17,10 @@ //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), //! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), and the [`RetryAction`] classifier driving -//! submit-failure dispatch. +//! [`decode_revert`]), the classifiers mapping submit failures into +//! the keeper [`RetryAction`], and [`run`] - the poll -> +//! outcome -> gate/journal/submit composition over the keeper +//! stores. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,6 +52,7 @@ //! [`PollOutcome`]: cow::PollOutcome //! [`decode_revert`]: cow::decode_revert //! [`RetryAction`]: cow::RetryAction +//! [`run`]: cow::run() #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs new file mode 100644 index 00000000..d3190991 --- /dev/null +++ b/crates/shepherd-sdk/tests/run.rs @@ -0,0 +1,422 @@ +//! Run acceptance tests against the composed +//! `shepherd_sdk_test::MockHost`. These live as an integration test +//! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` +//! externally, and the external and unit-test copies of the traits +//! are distinct types. + +use std::cell::Cell; + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowApiError, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk_test::MockHost; + +const SEPOLIA: u64 = 11_155_111; + +/// Closure-backed source so each test scripts its own outcome and +/// observes its own poll calls. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + keccak256(b"conditional order params") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +// ---- lifecycle outcomes ---- + +#[test] +fn try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + seed_watch(&host); + let before = host.store.snapshot(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryNextBlock), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); + assert_eq!(host.cow_api.call_count(), 0); +} + +#[test] +fn try_on_block_sets_the_block_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryOnBlock(2_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_block_key()).unwrap(), + &2_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn try_at_epoch_sets_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::TryAtEpoch(1_800_000_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_800_000_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn dont_try_again_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 1).unwrap(); + + run( + &host, + &src(|_, _, _, _| PollOutcome::DontTryAgain), + &sample_tick(), + ) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +// ---- gating and skipping ---- + +#[test] +fn gated_watch_is_not_polled() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0, "a gated watch must not reach the source"); +} + +#[test] +fn malformed_watch_rows_are_skipped() { + let host = MockHost::new(); + host.store.set("watch:no-separator", b"junk").unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0); +} + +// ---- ready -> submission ---- + +#[test] +fn ready_submits_once_and_journals_the_client_uid() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok(client_uid(&order))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + assert_eq!(host.cow_api.call_count(), 1); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "submitted:{{client_uid}} receipt must be recorded", + ); + assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); +} + +#[test] +fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xfeedface".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!( + !snapshot.contains_key("submitted:0xfeedface"), + "marker must key on the client UID, not the divergent server UID", + ); + assert!(host.logging.contains("UID divergence")); +} + +#[test] +fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + Journal::submitted(&host) + .record(&client_uid(&order)) + .unwrap(); + let polls = Cell::new(0_u32); + + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "the source is still consulted"); + assert_eq!( + host.cow_api.call_count(), + 0, + "the journal guard must short-circuit before any network work", + ); +} + +#[test] +fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.kind = B256::repeat_byte(0x42); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert!(host.store.snapshot().contains_key(&key)); +} + +// ---- submission failure dispatch ---- + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +#[test] +fn transient_rejection_keeps_the_watch_ungated() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InsufficientFee"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +#[test] +fn permanent_rejection_drops_the_watch_through_the_ledger() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 1) + .unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InvalidSignature"))); + + run( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert!( + host.store.is_empty(), + "a permanent rejection must drop the watch and its gates", + ); +} + +/// The orderbook already holds the order: the receipt is recorded, the +/// watch survives, and the next tick short-circuits on the journal +/// instead of re-posting. +#[test] +fn duplicated_order_records_the_receipt_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "already-submitted must record the receipt", + ); + + // The next tick must not touch the orderbook again. + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// A rate-limit fault with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. +#[test] +fn rate_limited_submit_backs_off_through_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .respond(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + + let tick = sample_tick(); + run(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} From f37f0d19424f68b46395b870c584130547979e86 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:32:09 +0000 Subject: [PATCH 02/89] refactor(twap-monitor): port poll loop onto keeper (#240) * feat(sdk): log run diagnostics through the tracing facade The run logged through the LoggingHost seam, which the guest tracing capture cannot observe - module tests proving behaviour identity for keeper ports assert on tracing events. Route the submit-path diagnostics through the tracing macros with the wording the legacy twap poll loop established, and give ConditionalSource a defaulted label so shared log lines attribute the strategy that produced them. * refactor(twap-monitor): port the poll loop onto the keeper composition Reduce strategy.rs to decode and evaluate: log decoding persists watches through the keeper watch set, and the getTradeableOrderWithSignature evaluation moves behind ConditionalSource. The hand-rolled gate keys, watch-update lifecycle, submitted: markers, and submit-retry dispatch are deleted in favour of cow::run over the keeper stores and retry ledger, still on the legacy CowApiHost seam. The MockHost dispatch tests are untouched - the behaviour-identity proof for the port. --- Cargo.lock | 4 +- crates/nexum-sdk/src/keeper.rs | 7 + crates/shepherd-sdk/Cargo.toml | 4 + crates/shepherd-sdk/src/cow/run.rs | 86 ++--- crates/shepherd-sdk/tests/run.rs | 6 +- modules/twap-monitor/Cargo.toml | 4 +- modules/twap-monitor/src/strategy.rs | 504 ++++----------------------- 7 files changed, 126 insertions(+), 489 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa9ae472..61076120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5101,11 +5101,13 @@ dependencies = [ "alloy-sol-types", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", "serde_json", "shepherd-sdk-test", "strum", "thiserror 2.0.18", + "tracing", ] [[package]] @@ -5781,8 +5783,6 @@ dependencies = [ "serde_json", "shepherd-sdk", "shepherd-sdk-test", - "strum", - "thiserror 2.0.18", "tracing", "wit-bindgen 0.59.0", ] diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 88edc6de..ff78e082 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -331,6 +331,13 @@ pub trait ConditionalSource { /// watch value (the encoded commitment parameters), passed /// verbatim so the source owns the decode. fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. + fn label(&self) -> &'static str { + "conditional" + } } /// What the retry ledger should do to a watch after a failed diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index 717d5c5d..2e4547f2 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -19,8 +19,12 @@ alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true +tracing.workspace = true [dev-dependencies] +# `capture_tracing` observes the run's diagnostics in the +# acceptance tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, # and the run acceptance-tests against the composed MockHost diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index f2e6bf32..cda6c6e0 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -11,14 +11,17 @@ //! //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they classify into a -//! [`RetryAction`](super::RetryAction), the ledger applies the -//! effect, and the sweep moves on. +//! [`RetryAction`], the ledger applies the effect, and the sweep +//! moves on. Diagnostics go through the guest `tracing` facade - +//! the same channel strategy code logs on - so module tests observe +//! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes}; use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; -use nexum_sdk::Level; use nexum_sdk::host::Fault; -use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Retrier, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ + ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, +}; use super::{ CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, @@ -47,7 +50,7 @@ where }; match source.poll(host, watch, ¶ms, tick) { PollOutcome::Ready { order, signature } => { - submit_ready(host, watch, &order, signature, tick)?; + submit_ready(host, watch, &order, signature, tick, source.label())?; } PollOutcome::TryNextBlock => {} PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, @@ -71,14 +74,12 @@ fn submit_ready( order: &GPv2OrderData, signature: Bytes, tick: &Tick, + label: &str, ) -> Result<(), Fault> { let Ok(owner) = watch.owner_hex().parse::
() else { - host.log( - Level::WARN, - &format!( - "watch {} carries an unparseable owner; skipping submit", - watch.key(), - ), + tracing::warn!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), ); return Ok(()); }; @@ -88,30 +89,21 @@ fn submit_ready( if let Some(uid) = client_uid.as_deref() && journal.contains(uid)? { - host.log( - Level::INFO, - &format!("{uid} already submitted; skipping re-submit"), - ); + tracing::info!("{label} {uid} already submitted; skipping re-submit"); return Ok(()); } let creation = match build_order_creation(order, signature, owner) { Ok(creation) => creation, Err(message) => { - host.log( - Level::WARN, - &format!("submit skipped for {owner:#x}: {message}"), - ); + tracing::warn!("{label} submit skipped for {owner:#x}: {message}"); return Ok(()); } }; let body = match serde_json::to_vec(&creation) { Ok(body) => body, Err(e) => { - host.log( - Level::ERROR, - &format!("OrderCreation JSON encode failed: {e}"), - ); + tracing::error!("OrderCreation JSON encode failed: {e}"); return Ok(()); } }; @@ -127,23 +119,17 @@ fn submit_ready( // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. if let Err(fault) = journal.record(marker) { - host.log( - Level::ERROR, - &format!("submitted {marker} but journal write failed: {fault}"), - ); + tracing::error!("submitted {marker} but journal write failed: {fault}"); } if let Some(client) = client_uid.as_deref() && client != server_uid { - host.log( - Level::WARN, - &format!( - "UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ), + tracing::warn!( + "{label} UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" ); } - host.log(Level::INFO, &format!("submitted {marker}")); + tracing::info!("submitted {marker}"); } Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { // Success wearing an error status: the orderbook already @@ -154,27 +140,29 @@ fn submit_ready( if let Some(uid) = client_uid.as_deref() && let Err(fault) = journal.record(uid) { - host.log( - Level::ERROR, - &format!("orderbook already holds {uid} but journal write failed: {fault}"), - ); + tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); } - host.log( - Level::INFO, - &format!( - "orderbook already holds this order ({}); receipt recorded", - rejection.error_type, - ), + tracing::info!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, ); } Err(err) => { let action = classify_submit_error(&err); Retrier::new(host).apply(watch, action, tick.epoch_s)?; - let label: &'static str = action.into(); - host.log( - Level::WARN, - &format!("submit failed ({err}); retry action {label}"), - ); + match action { + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::Backoff { seconds } => { + tracing::warn!("submit backoff {seconds}s: {err}"); + } + RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + // `RetryAction` is non-exhaustive; the ledger already + // ran the effect, so the log needs only the name. + other => { + let action_label: &'static str = other.into(); + tracing::warn!("submit retry action {action_label}: {err}"); + } + } } } Ok(()) diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index d3190991..d5088626 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -10,6 +10,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk_test::capture_tracing; use shepherd_sdk::cow::{CowApiError, OrderRejection, PollOutcome, order_uid_hex, run}; use shepherd_sdk_test::MockHost; @@ -253,7 +254,8 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - run(&host, &source, &sample_tick()).unwrap(); + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); let snapshot = host.store.snapshot(); assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); @@ -261,7 +263,7 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { !snapshot.contains_key("submitted:0xfeedface"), "marker must key on the client UID, not the divergent server UID", ); - assert!(host.logging.contains("UID divergence")); + assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 1910ae93..91a37a90 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -14,12 +14,10 @@ shepherd-sdk = { path = "../../crates/shepherd-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -thiserror = "2" tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +serde_json = { version = "1", default-features = false, features = ["alloc"] } shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a58a58bf..fe0c72c1 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -7,20 +7,23 @@ //! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under //! `#[cfg(test)]` hand the same functions a //! `shepherd_sdk_test::MockHost`. +//! +//! The module owns decode and evaluate only: log decoding into the +//! keeper watch set, and the `getTradeableOrderWithSignature` poll +//! behind [`ConditionalSource`]. Gate discipline, the `submitted:` +//! journal, submission, and retry dispatch live in the shared +//! composition (`shepherd_sdk::cow::run`). -use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use cowprotocol::{ - COMPOSABLE_COW, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, + COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; +use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowHost, PollOutcome, classify_poll_error, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -66,9 +69,16 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: scan every persisted watch and dispatch ready tranches. +/// Poll entry: run every gate-ready watch through the keeper +/// composition. The block timestamp arrives in milliseconds; the tick +/// carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { - poll_all_watches(host, &block) + let tick = Tick { + chain_id: block.chain_id, + block: block.number, + epoch_s: block.timestamp / 1000, + }; + run(host, &TwapSource, &tick) } // ---- indexing path ---- @@ -78,64 +88,51 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// `set` overwrites in place, so re-indexing the same log (re-org -/// replay, overlapping subscription windows) produces no observable -/// side effect. +/// The watch set overwrites in place, so re-indexing the same log +/// (re-org replay, overlapping subscription windows) produces no +/// observable side effect. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; + let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; tracing::info!("indexed {key}"); Ok(()) } // ---- poll path ---- -fn poll_all_watches(host: &H, block: &BlockInfo) -> Result<(), Fault> { - let now_epoch_s = block.timestamp / 1000; - let keys = host.list_keys("watch:")?; - for key in keys { - let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { - continue; - }; - if !is_ready(host, owner_hex, hash_hex, block.number, now_epoch_s)? { - continue; - } - let Some(value) = host.get(&key)? else { - continue; - }; - let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; +/// TWAP conditional source: decode the stored `ConditionalOrderParams` +/// and evaluate `getTradeableOrderWithSignature` on chain. A row this +/// source cannot decode polls again next block rather than tearing +/// down the sweep. +struct TwapSource; + +impl ConditionalSource for TwapSource { + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + let Ok(params) = ConditionalOrderParams::abi_decode(params) else { + tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); + return PollOutcome::TryNextBlock; }; - let Ok(owner) = owner_hex.parse::
() else { - continue; + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carried an unparseable owner; skipping", + watch.key() + ); + return PollOutcome::TryNextBlock; }; - let outcome = poll_one(host, block.chain_id, &owner, ¶ms); - tracing::info!("poll {key} -> {}", outcome_label(&outcome)); - match outcome { - PollOutcome::Ready { order, signature } => { - submit_ready( - host, - block.chain_id, - owner, - &order, - signature, - &key, - now_epoch_s, - )?; - } - non_ready => { - apply_watch_update(host, outcome_to_update(&non_ready), &key)?; - } - } + let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); + tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); + outcome + } + + fn label(&self) -> &'static str { + "twap" } - Ok(()) } fn poll_one( @@ -159,28 +156,14 @@ fn poll_one( Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) .unwrap_or(PollOutcome::TryNextBlock), - // A structured JSON-RPC error (the normal shape for an - // `eth_call` revert): the chain backend has already hex-decoded - // the `error.data` payload, so `decode_revert` dispatches - // `PollTryAtBlock` / `PollTryAtEpoch` / `OrderNotValid` / - // `PollNever` straight off the bytes. A revert the decoder does - // not recognise falls through to the safe `TryNextBlock`. - Err(ChainError::Rpc(rpc)) => rpc - .data - .as_deref() - .and_then(|bytes| decode_revert(bytes)) - .unwrap_or_else(|| { - tracing::warn!( - "eth_call reverted ({}); defaulting to TryNextBlock", - rpc.message - ); - PollOutcome::TryNextBlock - }), - // A transport-level fault (timeout, RPC down, ...): retry on the - // next block. - Err(ChainError::Fault(fault)) => { - tracing::warn!("eth_call failed ({fault}); defaulting to TryNextBlock"); - PollOutcome::TryNextBlock + // `classify_poll_error` is the one policy for what a failed + // poll call means to the watch lifecycle; only a transport + // fault warrants its own diagnostic here. + Err(err) => { + if let ChainError::Fault(fault) = &err { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + classify_poll_error(&err) } } } @@ -207,312 +190,36 @@ fn outcome_label(o: &PollOutcome) -> &'static str { } } -// ---- key conventions ---- +// ---- test-only seam mirrors ---- +// +// Thin views over the keeper / SDK canon so the dispatch tests can +// seed and inspect the store in the exact shapes production writes. -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") +#[cfg(test)] +fn watch_key(owner: &Address, params_hash: &alloy_primitives::B256) -> String { + WatchSet::::key(owner, params_hash) } +#[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) -} - -fn is_ready( - host: &H, - owner_hex: &str, - hash_hex: &str, - block_number: u64, - epoch_s: u64, -) -> Result { - if let Some(next) = read_u64(host, &format!("next_block:{owner_hex}:{hash_hex}"))? - && block_number < next - { - return Ok(false); - } - if let Some(next) = read_u64(host, &format!("next_epoch:{owner_hex}:{hash_hex}"))? - && epoch_s < next - { - return Ok(false); - } - Ok(true) -} - -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let bytes = host.get(key)?; - Ok(bytes - .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) - .map(u64::from_le_bytes)) -} - -// ---- submission path ---- - -/// `cowprotocol`-side rejection envelope for an `OrderCreation` we -/// failed to assemble. Surfaces in a Warn log; the watch is left in -/// place so the next poll can either re-construct or transition on -/// its own. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the submission warning log can carry `error_kind = -/// unknown_marker` without a match-ladder in the call site. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum BuildError { - /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't - /// know how to map. - #[error("GPv2OrderData carried an unknown enum marker")] - UnknownMarker, - /// `cowprotocol` rejected the body - typically `from == - /// Address::ZERO` or a `validTo` beyond the client-side horizon. - #[error(transparent)] - Cowprotocol(#[from] cowprotocol::Error), -} - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// freshly-polled TWAP tranche. -/// -/// The signed `order.appData` digest is submitted verbatim (the -/// hash-only `OrderCreationAppData::Hash` wire shape) - watch-tower -/// parity. The orderbook joins the document it already has registered -/// for that digest; when it has none, the submit rejects with -/// `INVALID_APP_DATA` and [`classify_api_error`] dispatches the retry. -fn build_order_creation( - order: &GPv2OrderData, - signature: Bytes, - from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; - let signature = Signature::Eip1271(signature.to_vec()); - let creation = OrderCreation::new_app_data_hash_only(&order_data, signature, from, None)?; - Ok(creation) + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) } -fn submit_ready( - host: &H, - chain_id: u64, - owner: Address, - order: &GPv2OrderData, - signature: Bytes, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Short-circuit if the orderbook UID for this exact - // (order, owner, chain) tuple is already in our local-store as - // `submitted:`. The poll-tick can re-fire `Ready` for the same - // TWAP child in successive blocks - `getTradeableOrderWithSignature` - // does not know shepherd already POSTed it - and re-submitting - // wastes a submit_order call and emits a misleading - // `DuplicatedOrder` Warn. The UID computation is deterministic - // from on-chain inputs (and matches what the orderbook derives - // server-side from the signed payload), so we can check before - // doing any network work. We also reuse the computed value below - // as the `submitted:{uid}` marker key, so the read and write - // paths agree. - let client_uid_hex = compute_uid_hex(chain_id, order, owner); - if let Some(uid_hex) = client_uid_hex.as_deref() - && host.get(&format!("submitted:{uid_hex}"))?.is_some() - { - tracing::info!("twap {uid_hex} already submitted; skipping poll re-submit"); - return Ok(()); - } - - // CoW Swap UI (and other clients) sign TWAPs with a non-empty - // `appData` hash that points at a JSON document already registered - // with the orderbook. Submit the signed digest verbatim (hash-only - // shape) and let the orderbook join its own registry - watch-tower - // parity. An unregistered digest rejects as `INVALID_APP_DATA` and - // `classify_api_error` dispatches the backoff. - let creation = match build_order_creation(order, signature, owner) { - Ok(c) => c, - Err(e) => { - tracing::warn!("twap submit skipped for {owner:#x}: {e}"); - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID for the marker key so the - // idempotency check at the top of `submit_ready` reads what - // we wrote. In production the server-returned - // UID is the same value (both sides derive it from the - // signed `OrderData` via the canonical - // `digest || owner || valid_to` layout); a divergence - // would be a protocol-level bug worth surfacing rather - // than silently splitting the keyspace. - let marker_uid = client_uid_hex.as_deref().unwrap_or(server_uid.as_str()); - let key = format!("submitted:{marker_uid}"); - // Empty marker - presence of the key is the receipt. - host.set(&key, b"")?; - if let Some(client_uid) = client_uid_hex.as_deref() - && client_uid != server_uid - { - tracing::warn!( - "twap UID divergence: client={client_uid} server={server_uid} \ - (marker stored under client UID for idempotency consistency)" - ); - } - tracing::info!("submitted {key}"); - } - Err(err) => { - apply_submit_retry(host, &err, watch_key, now_epoch_s)?; - } - } - Ok(()) -} - -/// Compute the orderbook UID hex (`0x` + 112 hex chars) for the given -/// on-chain (order, owner, chain) tuple, mirroring what `submit_order` -/// will deduce server-side. Used by [`submit_ready`] to short-circuit -/// poll-tick re-submissions of an already-submitted TWAP child. -/// -/// Returns `None` if the chain id is unsupported by `cowprotocol::Chain` -/// or the order carries an unknown enum marker - both cases also stop -/// the regular submit path downstream, so the caller can fall through -/// to the normal flow and let it surface the appropriate diagnostic. +#[cfg(test)] fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) -} - -// ---- OrderPostError -> retry action ---- - -fn apply_submit_retry( - host: &H, - err: &CowApiError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Only a typed orderbook rejection classifies; transport faults and - // raw HTTP errors are transient, so the watch stays in place. - let action = match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock => { - tracing::warn!("submit retry-next-block: {err}"); - } - RetryAction::Backoff { seconds } => { - let until = now_epoch_s.saturating_add(seconds); - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &until.to_le_bytes(), - )?; - } - tracing::warn!("submit backoff {seconds}s -> next_epoch={until}: {err}"); - } - RetryAction::Drop => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::warn!("submit dropped watch: {err}"); - } - // `RetryAction` is `#[non_exhaustive]`; future variants - // default to "leave the watch in place" (the conservative - // dispatch choice). Once a new variant gets a real meaning - // its arm should be added explicitly. - _ => { - tracing::warn!("submit unknown retry-action: {err} - leaving watch in place"); - } - } - Ok(()) -} - -// ---- PollOutcome lifecycle dispatch ---- - -/// What `apply_watch_update` should do for a given outcome. Kept as a -/// data type (rather than running the effects directly) so the -/// decision is host-free testable. -#[derive(Debug, Eq, PartialEq)] -enum WatchUpdate { - /// Leave the store untouched. Next block re-polls the watch. - NoOp, - /// Write `next_block:` so subsequent polls skip until the given - /// block number is reached. - SetNextBlock(u64), - /// Write `next_epoch:` so subsequent polls skip until the given - /// Unix-seconds timestamp is reached. - SetNextEpoch(u64), - /// Delete the watch and any stale gate keys - TWAP completed, - /// cancelled, or otherwise irrecoverable. - DropWatch, -} - -/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect -/// the contract specifies. `Ready` is handled by the submit -/// path and is rejected here so a caller cannot -/// accidentally erase the watch when an order was actually produced. -fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { - match outcome { - PollOutcome::Ready { .. } => WatchUpdate::NoOp, - PollOutcome::TryNextBlock => WatchUpdate::NoOp, - PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), - PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), - PollOutcome::DontTryAgain => WatchUpdate::DropWatch, - } -} - -fn apply_watch_update( - host: &H, - update: WatchUpdate, - watch_key: &str, -) -> Result<(), Fault> { - match update { - WatchUpdate::NoOp => Ok(()), - WatchUpdate::SetNextBlock(n) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &n.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::SetNextEpoch(t) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &t.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::DropWatch => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::info!("dropped watch {watch_key}"); - Ok(()) - } - } + shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; - use cowprotocol::OrderCreationAppData; + use alloy_primitives::{B256, U256, address, b256, hex}; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::OrderRejection; + use shepherd_sdk::cow::{CowApiError, OrderRejection}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -627,33 +334,6 @@ mod tests { } } - /// The signed `appData` digest goes into the body verbatim as the - /// hash-only shape - no document lookup, no digest re-derivation. - #[test] - fn build_order_creation_submits_app_data_hash_verbatim() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let creation = build_order_creation(&order, sig.clone(), owner).expect("build succeeds"); - assert_eq!(creation.from, owner); - assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); - assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!( - creation.app_data, - OrderCreationAppData::Hash { - hash: order.appData - } - ); - } - - #[test] - fn build_order_creation_rejects_zero_from() { - let err = - build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - #[test] fn watch_key_round_trips_via_parse() { let owner = address!("00112233445566778899aabbccddeeff00112233"); @@ -664,48 +344,6 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - #[test] - fn outcome_try_next_block_is_no_op() { - assert_eq!( - outcome_to_update(&PollOutcome::TryNextBlock), - WatchUpdate::NoOp - ); - } - - #[test] - fn outcome_try_on_block_sets_next_block_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryOnBlock(12_345)), - WatchUpdate::SetNextBlock(12_345), - ); - } - - #[test] - fn outcome_try_at_epoch_sets_next_epoch_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), - WatchUpdate::SetNextEpoch(1_700_000_000), - ); - } - - #[test] - fn outcome_dont_try_again_drops_watch() { - assert_eq!( - outcome_to_update(&PollOutcome::DontTryAgain), - WatchUpdate::DropWatch - ); - } - - #[test] - fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { - let order = Box::new(submittable_order()); - let outcome = PollOutcome::Ready { - order, - signature: Bytes::new(), - }; - assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); - } - // ---- MockHost dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed From b4800f5f9f3daa42efe6bf0627b5edb56666797a Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:36:17 +0000 Subject: [PATCH 03/89] feat(sdk-test): add MockVenue and namespaced MockLocalStore (#241) * feat(sdk-test): namespace the mock local store and share its limits Rework MockLocalStore to mirror the runtime store's shape: namespaced views over one shared row map, so identical key strings in sibling namespaces never collide, plus store-wide entry and byte limits that span namespaces the way one database file does. Fault injection stays per-view. * feat(sdk-test): add the programmable MockVenue on the cow-api seam Script per-call venue behaviour where MockCowApi replays one response: a strictly-draining queue of submit outcomes with a configurable steady-state fallback, per-route response sequences whose final entry sticks (a terminal order status persists across re-polls), and venue fault injection that overrides both without consuming them. MockHost grows a defaulted venue type parameter so the composed host carries either mock on the same CowApiHost seam. Acceptance tests drive the keeper run across multi-tick retry, backoff, and outage scenarios, and module-shaped strategy code against status sequences. --- Cargo.lock | 2 + crates/nexum-sdk-test/src/lib.rs | 222 ++++++++++-- crates/shepherd-sdk-test/Cargo.toml | 6 + crates/shepherd-sdk-test/src/lib.rs | 331 +++++++++++++++++- crates/shepherd-sdk-test/tests/mock_venue.rs | 348 +++++++++++++++++++ 5 files changed, 869 insertions(+), 40 deletions(-) create mode 100644 crates/shepherd-sdk-test/tests/mock_venue.rs diff --git a/Cargo.lock b/Cargo.lock index 61076120..23e6f02d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5114,6 +5114,8 @@ dependencies = [ name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ + "alloy-primitives", + "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..b9e135bb 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -60,9 +60,10 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Write as _}; +use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -191,41 +192,99 @@ impl ChainHost for MockChain { // ---------------------------------------------------------------- local-store -/// In-memory [`LocalStoreHost`] backed by a `HashMap`. Each operation -/// runs in O(1) except `list_keys`, which scans (small N expected for -/// tests). +/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: +/// namespaced views over one shared row map, plus store-wide entry +/// and byte limits. /// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. +/// A fresh store is the root view. [`namespaced`](Self::namespaced) +/// derives a sibling view over the same backing rows - identical key +/// strings in different namespaces never collide, matching the host's +/// per-module key prefixing. Limits sit on the shared backing store, +/// so one namespace's writes can exhaust another's headroom exactly +/// as two modules share one database file. Fault injection via +/// [`fail_on`](Self::fail_on) stays per-view. #[derive(Default)] pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, + shared: Rc, + namespace: String, /// Key patterns that trigger injected faults on any operation. error_patterns: RefCell>, } +/// Backing rows and limits shared by every namespaced view. +#[derive(Default)] +struct SharedRows { + /// Rows keyed by `(namespace, key)`. + rows: RefCell>>, + /// Total stored bytes (key + value) across all namespaces. + bytes: Cell, + /// When set, `set` on a new key fails once the store holds this + /// many rows. + max_entries: Cell>, + /// When set, `set` fails once stored bytes would exceed this. + max_bytes: Cell>, +} + impl MockLocalStore { - /// Number of rows currently held. + /// A view over the same backing rows under `namespace`. Views with + /// the same namespace alias the same data (two handles onto one + /// module store); different namespaces are fully isolated even for + /// identical key strings. + /// + /// # Panics + /// + /// On an empty namespace - the runtime rejects those too. + pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { + let namespace = namespace.into(); + assert!( + !namespace.is_empty(), + "MockLocalStore: namespace must not be empty", + ); + MockLocalStore { + shared: Rc::clone(&self.shared), + namespace, + error_patterns: RefCell::new(Vec::new()), + } + } + + /// Number of rows in this view's namespace. pub fn len(&self) -> usize { - self.rows.borrow().len() + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() } - /// Whether the store is empty. + /// Whether this view's namespace holds no rows. pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() + self.len() == 0 } - /// Direct read for assertions - bypasses the trait. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() } - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. + /// Cap the row count across every namespace. Once reached, `set` + /// on a new key fails; overwriting an existing key still succeeds. pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, across every namespace). + /// A `set` that would push the total past the cap fails; deletes + /// and same-key overwrites release the bytes they displace. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); } /// Inject a fault for any operation where the key starts with @@ -250,36 +309,64 @@ impl MockLocalStore { impl LocalStoreHost for MockLocalStore { fn get(&self, key: &str) -> Result>, Fault> { self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .cloned()) } fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { self.check_injected_error(key)?; - if let Some(limit) = *self.max_entries.borrow() { - let rows = self.rows.borrow(); - if rows.len() >= limit && !rows.contains_key(key) { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } + let mut rows = self.shared.rows.borrow_mut(); + let compound = (self.namespace.clone(), key.to_string()); + let existing = rows.get(&compound).map(Vec::len); + if existing.is_none() + && let Some(limit) = self.shared.max_entries.get() + && rows.len() >= limit + { + return Err(Fault::Internal(format!( + "MockLocalStore: max entries ({limit}) reached" + ))); } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); + // Same-key overwrites release the displaced bytes before the + // new row is charged. + let displaced = existing.map_or(0, |len| key.len() + len); + let total = self.shared.bytes.get() - displaced + key.len() + value.len(); + if let Some(budget) = self.shared.max_bytes.get() + && total > budget + { + return Err(Fault::Internal(format!( + "MockLocalStore: max bytes ({budget}) reached" + ))); + } + rows.insert(compound, value.to_vec()); + self.shared.bytes.set(total); Ok(()) } fn delete(&self, key: &str) -> Result<(), Fault> { self.check_injected_error(key)?; - self.rows.borrow_mut().remove(key); + if let Some(value) = self + .shared + .rows + .borrow_mut() + .remove(&(self.namespace.clone(), key.to_string())) + { + self.shared + .bytes + .set(self.shared.bytes.get() - key.len() - value.len()); + } Ok(()) } fn list_keys(&self, prefix: &str) -> Result, Fault> { self.check_injected_error(prefix)?; let mut keys: Vec = self + .shared .rows .borrow() .keys() - .filter(|k| k.starts_with(prefix)) - .cloned() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .map(|(_, key)| key.clone()) .collect(); keys.sort(); Ok(keys) @@ -671,6 +758,77 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn local_store_namespaces_isolate_identical_keys() { + let store = MockLocalStore::default(); + let other = store.namespaced("other-module"); + store.set("watch:a", b"mine").unwrap(); + other.set("watch:a", b"theirs").unwrap(); + + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + assert_eq!( + other.get("watch:a").unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + + // Scans, counts, and snapshots stay view-scoped. + assert_eq!(store.len(), 1); + assert_eq!(other.len(), 1); + assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); + assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); + + // Deletes never reach across the namespace boundary. + other.delete("watch:a").unwrap(); + assert!(other.is_empty()); + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + } + + #[test] + fn local_store_same_namespace_views_alias_the_same_rows() { + let store = MockLocalStore::default(); + let one = store.namespaced("mod"); + let two = store.namespaced("mod"); + one.set("k", b"v").unwrap(); + assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + #[should_panic(expected = "namespace must not be empty")] + fn local_store_empty_namespace_panics() { + let _ = MockLocalStore::default().namespaced(""); + } + + #[test] + fn local_store_entry_limit_spans_namespaces() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + let other = store.namespaced("other-module"); + store.set("a", b"1").unwrap(); + other.set("b", b"2").unwrap(); + // The store is one shared file: a sibling namespace's rows + // consume the same headroom. + let err = store.set("c", b"3").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + } + + #[test] + fn local_store_byte_budget_enforced_and_released() { + let store = MockLocalStore::default(); + store.set_max_bytes(8); + store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget + let err = store.set("x", b"y").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); + + // A same-key overwrite releases the displaced value first. + store.set("abcd", b"12").unwrap(); + store.set("x", b"y").unwrap(); + + // Deleting releases the whole row's bytes. + store.delete("abcd").unwrap(); + store.set("ab", b"12").unwrap(); + assert_eq!(store.len(), 2); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index a09c388f..83a3525c 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -15,3 +15,9 @@ nexum-sdk = { path = "../nexum-sdk" } nexum-sdk-test = { path = "../nexum-sdk-test" } shepherd-sdk = { path = "../shepherd-sdk" } serde_json = { workspace = true, features = ["std"] } + +[dev-dependencies] +# Order construction for the MockVenue acceptance tests that drive +# the keeper run end to end. +alloy-primitives.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index e1289cd0..2e08b74f 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -23,6 +23,23 @@ //! assert_eq!(host.cow_api.call_count(), 1); //! ``` //! +//! Per-call venue scripting - outcome queues, status sequences, fault +//! injection - goes through [`MockVenue`] on the same seam: +//! +//! ```rust +//! use nexum_sdk::host::Fault; +//! use shepherd_sdk::cow::{CowApiError, CowApiHost as _}; +//! use shepherd_sdk_test::MockHost; +//! +//! let host = MockHost::with_venue(); +//! host.cow_api +//! .enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); +//! host.cow_api.enqueue_submit(Ok("0xuid".into())); +//! +//! assert!(host.submit_order(1, b"{}").is_err()); +//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); +//! ``` +//! //! Modules that never touch the orderbook use `nexum-sdk-test`'s //! `MockHost` directly instead. @@ -30,6 +47,7 @@ #![warn(missing_docs)] use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; @@ -37,16 +55,18 @@ use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus [`MockCowApi`]. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. +/// mocks plus a venue mock on the `shepherd:cow/cow-api` seam - +/// [`MockCowApi`] by default, [`MockVenue`] via +/// [`with_venue`](MockHost::with_venue). Each field exposes the +/// per-trait mock so tests can program responses and assert on calls. #[derive(Default)] -pub struct MockHost { +pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, /// `nexum:host/local-store` mock. pub store: MockLocalStore, /// `shepherd:cow/cow-api` mock. - pub cow_api: MockCowApi, + pub cow_api: V, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -58,13 +78,20 @@ impl MockHost { } } -impl ChainHost for MockHost { +impl MockHost { + /// Fresh empty host with [`MockVenue`] on the cow-api seam. + pub fn with_venue() -> Self { + Self::default() + } +} + +impl ChainHost for MockHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { self.chain.request(chain_id, method, params) } } -impl LocalStoreHost for MockHost { +impl LocalStoreHost for MockHost { fn get(&self, key: &str) -> Result>, Fault> { self.store.get(key) } @@ -79,7 +106,7 @@ impl LocalStoreHost for MockHost { } } -impl CowApiHost for MockHost { +impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) } @@ -94,7 +121,7 @@ impl CowApiHost for MockHost { } } -impl LoggingHost for MockHost { +impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); } @@ -240,6 +267,181 @@ impl CowApiHost for MockCowApi { } } +// ---------------------------------------------------------------- venue + +/// Scripted in-memory venue on the [`CowApiHost`] seam: programmable +/// per-call behaviour, unlike [`MockCowApi`]'s single replayed +/// response. Compose it with the generic mocks via +/// [`MockHost::with_venue`]. +/// +/// The two queue disciplines differ deliberately. Submissions are +/// discrete effects, so the submit queue strictly drains - one outcome +/// per call, then the configured fallback (default: an `Unsupported` +/// fault), so a test that scripts N outcomes catches an unexpected +/// N+1th submit. Responses are observations, so a `(method, path)` +/// sequence advances per call and its final entry replays forever - a +/// terminal order status persists no matter how often it is re-polled. +/// An injected fault overrides both (without consuming the queues) +/// until cleared, modelling a venue outage. +#[derive(Default)] +pub struct MockVenue { + submit_queue: RefCell>, + submit_fallback: RefCell>, + response_sequences: RefCell>>, + response_fallback: RefCell>, + fault: RefCell>, + calls: RefCell>, + request_calls: RefCell>, +} + +/// One scripted venue reply: the body / UID on success, a typed +/// [`CowApiError`] otherwise. +type VenueOutcome = Result; + +impl MockVenue { + /// Append one `submit_order` outcome to the queue; each call + /// consumes one, in order. + pub fn enqueue_submit(&self, outcome: Result) { + self.submit_queue.borrow_mut().push_back(outcome); + } + + /// Steady-state `submit_order` response once the queue is drained. + /// Unset, a drained queue yields an `Unsupported` fault. + pub fn set_submit_fallback(&self, outcome: Result) { + *self.submit_fallback.borrow_mut() = Some(outcome); + } + + /// Append one outcome to the `(method, path)` response sequence. + /// Each matching `cow_api_request` call advances the sequence; the + /// final entry sticks. + pub fn enqueue_response( + &self, + method: impl Into, + path: impl Into, + outcome: Result, + ) { + self.response_sequences + .borrow_mut() + .entry((method.into(), path.into())) + .or_default() + .push_back(outcome); + } + + /// Append one status-probe outcome for the order, keyed on the + /// orderbook's `GET /api/v1/orders/{uid}` route. + pub fn enqueue_order_status(&self, uid: &str, outcome: Result) { + self.enqueue_response("GET", format!("/api/v1/orders/{uid}"), outcome); + } + + /// Catch-all `cow_api_request` response for calls with no + /// programmed sequence. Unset, those yield an `Unsupported` fault. + pub fn set_response_fallback(&self, outcome: Result) { + *self.response_fallback.borrow_mut() = Some(outcome); + } + + /// Fail every venue call with `err` until + /// [`clear_fault`](Self::clear_fault) - a scripted outage. Queued + /// outcomes are not consumed while the fault is active. + pub fn inject_fault(&self, err: CowApiError) { + *self.fault.borrow_mut() = Some(err); + } + + /// Lift an injected fault; queued outcomes resume where they left + /// off. + pub fn clear_fault(&self) { + *self.fault.borrow_mut() = None; + } + + /// All submissions, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last submission, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Convenience: parse the most recent submission body as JSON. + pub fn last_body_as_json(&self) -> Option { + self.last_call() + .and_then(|c| serde_json::from_slice(&c.body).ok()) + } + + /// Count of submissions (failed and injected-fault calls included). + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + /// All `cow_api_request` invocations, in arrival order. + pub fn request_calls(&self) -> Vec { + self.request_calls.borrow().clone() + } + + /// Scripted submit outcomes not yet consumed - assert `0` to prove + /// a scenario played out in full. + pub fn pending_submits(&self) -> usize { + self.submit_queue.borrow().len() + } +} + +impl CowApiHost for MockVenue { + fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { + self.calls.borrow_mut().push(SubmitCall { + chain_id, + body: body.to_vec(), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(outcome) = self.submit_queue.borrow_mut().pop_front() { + return outcome; + } + self.submit_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: submit queue exhausted and no fallback configured".to_string(), + ))) + }) + } + + fn cow_api_request( + &self, + chain_id: u64, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + self.request_calls.borrow_mut().push(RequestCall { + chain_id, + method: method.to_string(), + path: path.to_string(), + body: body.map(str::to_string), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(sequence) = self + .response_sequences + .borrow_mut() + .get_mut(&(method.to_string(), path.to_string())) + { + // Advance until one entry remains, then replay it: the + // sequence's final state persists. + if sequence.len() > 1 { + return sequence.pop_front().expect("length checked above"); + } + if let Some(last) = sequence.front() { + return last.clone(); + } + } + self.response_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: no response programmed for this request".to_string(), + ))) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -266,6 +468,119 @@ mod tests { ); } + // ---- MockVenue ---- + + #[test] + fn venue_submit_queue_drains_in_order_then_falls_back() { + let venue = MockVenue::default(); + venue.enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); + venue.enqueue_submit(Ok("0xuid".into())); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Timeout)), + )); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(venue.pending_submits(), 0); + + // A drained queue is unsupported by default: an unscripted + // extra submit fails loudly. + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + + venue.set_submit_fallback(Ok("0xsteady".into())); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xsteady"); + assert_eq!(venue.call_count(), 4, "every call is recorded"); + } + + #[test] + fn venue_records_submissions_like_the_single_shot_mock() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.submit_order(7, b"{\"x\":1}").unwrap(); + + let last = venue.last_call().unwrap(); + assert_eq!(last.chain_id, 7); + assert_eq!(last.body, b"{\"x\":1}"); + assert_eq!(venue.last_body_as_json().unwrap()["x"], 1); + } + + #[test] + fn venue_fault_injection_overrides_queues_until_cleared() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.enqueue_response("GET", "/api/v1/orders/0x1", Ok("{}".into())); + venue.inject_fault(CowApiError::Fault(Fault::Unavailable("down".into()))); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unavailable(_))), + )); + assert!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .is_err() + ); + + // The outage consumed nothing: outcomes resume on recovery. + venue.clear_fault(); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .unwrap(), + "{}", + ); + assert_eq!(venue.call_count(), 2); + assert_eq!(venue.request_calls().len(), 2); + } + + #[test] + fn venue_response_sequence_advances_and_final_entry_sticks() { + let venue = MockVenue::default(); + for body in ["\"open\"", "\"open\"", "\"fulfilled\""] { + venue.enqueue_order_status("0xuid", Ok(body.into())); + } + let probe = || { + venue + .cow_api_request(1, "GET", "/api/v1/orders/0xuid", None) + .unwrap() + }; + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"fulfilled\""); + // The terminal entry replays for any later re-poll. + assert_eq!(probe(), "\"fulfilled\""); + } + + #[test] + fn venue_unscripted_request_uses_the_fallback_then_defaults() { + let venue = MockVenue::default(); + assert!(matches!( + venue.cow_api_request(1, "GET", "/api/v1/anything", None), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + venue.set_response_fallback(Ok("catch-all".into())); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/anything", None) + .unwrap(), + "catch-all", + ); + } + + #[test] + fn mock_host_with_venue_dispatches_through_cow_host_bound() { + let host = MockHost::with_venue(); + host.cow_api.enqueue_submit(Ok("0xuid".into())); + + let _: &dyn shepherd_sdk::cow::CowHost = &host; + assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn mock_host_dispatches_through_cow_host_bound() { let host = MockHost::new(); diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs new file mode 100644 index 00000000..411f012e --- /dev/null +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -0,0 +1,348 @@ +//! MockVenue acceptance tests: the scripted venue driving the keeper +//! run (multi-tick retry, backoff, and outage scenarios the +//! single-replayed-response mock cannot express) and module-shaped +//! strategy code polling the venue directly. + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk_test::{MockHost, MockVenue}; + +const SEPOLIA: u64 = 11_155_111; + +type VenueHost = MockHost; + +/// Closure-backed source so each test scripts its own outcome. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn ready_source( + order: &GPv2OrderData, +) -> FnSource, &[u8], &Tick) -> PollOutcome> { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) +} + +fn seed_watch(host: &VenueHost) -> String { + WatchSet::new(host) + .put( + &sample_owner(), + &keccak256(b"conditional order params"), + b"params", + ) + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +// ---- keeper use ---- + +/// A transient rejection on the first tick keeps the watch alive; the +/// next tick's scripted success is journalled. Per-call scripting is +/// the point: one venue plays a different outcome on each tick. +#[test] +fn keeper_retries_a_transient_rejection_then_submits() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InsufficientFee"))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let source = ready_source(&order); + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + assert!(host.store.snapshot().contains_key(&key), "watch survives"); + assert!( + !Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + assert_eq!( + host.cow_api.pending_submits(), + 0, + "scenario played out in full" + ); +} + +/// A rate-limit with server guidance gates the watch on the epoch +/// clock; the venue is only reached again once the gate clears, and +/// the queued success then lands. +#[test] +fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let t0 = sample_tick(); + let source = ready_source(&order); + run(&host, &source, &t0).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // 2500ms rounds up to a 3s epoch gate: a tick inside it never + // reaches the venue. + let gated = Tick { + epoch_s: t0.epoch_s + 2, + ..t0 + }; + run(&host, &source, &gated).unwrap(); + assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); + + let clear = Tick { + epoch_s: t0.epoch_s + 3, + ..t0 + }; + run(&host, &source, &clear).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A venue outage is transient: the watch stays, nothing is gated, and +/// the first tick after recovery submits the queued outcome. +#[test] +fn keeper_survives_a_venue_outage_and_submits_on_recovery() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); + + let source = ready_source(&order); + run(&host, &source, &sample_tick()).unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert_eq!(host.cow_api.call_count(), 1); + + host.cow_api.clear_fault(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A scripted permanent rejection drops the watch through the ledger. +#[test] +fn keeper_drops_the_watch_on_a_scripted_permanent_rejection() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InvalidSignature"))); + + run(&host, &ready_source(&order), &sample_tick()).unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// Keeper rows written through the composed host stay invisible to a +/// sibling store namespace, and a decoy watch planted there never +/// reaches the sweep - the store-fidelity seam under the venue tests. +#[test] +fn keeper_sweep_ignores_sibling_namespace_watches() { + let host = MockHost::with_venue(); + seed_watch(&host); + let sibling = host.store.namespaced("other-module"); + assert!(sibling.is_empty(), "keeper rows must not leak across"); + sibling + .set( + "watch:0x00112233445566778899aabbccddeeff00112233:0xdead", + b"decoy", + ) + .unwrap(); + + let order = submittable_order(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + let polls = std::cell::Cell::new(0_u32); + run( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "only this module's watch is swept"); + assert_eq!(host.cow_api.call_count(), 1); +} + +// ---- module use ---- + +/// Module-shaped fill tracker: probe the orderbook status route and +/// journal an `observed:` receipt once the order reports fulfilled. +/// Generic over [`CowHost`] exactly like production strategy code. +fn record_fill(host: &H, chain_id: u64, uid: &str) -> Result { + let path = format!("/api/v1/orders/{uid}"); + let Ok(body) = host.cow_api_request(chain_id, "GET", &path, None) else { + return Ok(false); + }; + let fulfilled = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("status").and_then(|s| s.as_str().map(str::to_owned))) + .is_some_and(|status| status == "fulfilled"); + if fulfilled { + Journal::observed(host).record(uid)?; + } + Ok(fulfilled) +} + +/// The status sequence advances one entry per module poll and its +/// terminal entry persists across any number of re-polls. +#[test] +fn module_tracks_a_fill_through_a_status_sequence() { + let host = MockHost::with_venue(); + for body in [ + r#"{"status":"open"}"#, + r#"{"status":"open"}"#, + r#"{"status":"fulfilled"}"#, + ] { + host.cow_api.enqueue_order_status("0xuid", Ok(body.into())); + } + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + // Terminal status sticks: an over-eager re-poll sees it again. + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + assert!(Journal::observed(&host).contains("0xuid").unwrap()); + assert_eq!(host.cow_api.request_calls().len(), 4); + assert_eq!(host.cow_api.request_calls()[0].path, "/api/v1/orders/0xuid"); +} + +/// An outage mid-sequence surfaces to the module as a failed probe and +/// consumes nothing: the sequence resumes where it left off. +#[test] +fn module_probe_rides_out_an_injected_outage() { + let host = MockHost::with_venue(); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"open"}"#.into())); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"fulfilled"}"#.into())); + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Timeout)); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api.clear_fault(); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(Journal::observed(&host).contains("0xuid").unwrap()); +} + +/// A B256 hash keeps the watch-key helper honest against the venue +/// host's default type parameter. +#[test] +fn watch_key_helper_unifies_with_the_venue_host() { + let hash: B256 = keccak256(b"conditional order params"); + assert_eq!( + WatchSet::::key(&sample_owner(), &hash), + WatchSet::::key(&sample_owner(), &hash), + ); +} From 020e2136c3eb703d88fe169e2f44f7f7e8cd77b8 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:45:30 +0000 Subject: [PATCH 04/89] fix(keeper): drop stale watches and idle duplicate rejections (#242) * fix(sdk): drop the watch when the OrderCreation constructor rejects the order A constructor rejection (zero from, validTo beyond the client-side one-year horizon) is deterministic for the polled payload: skipping with the watch intact re-polled and re-warned on every block forever. Route it through the retry ledger as a drop, matching the pre-keeper net effect where the orderbook rejected the shipped body (e.g. ExcessiveValidTo) and the classifier dropped the watch. The unknown-enum-marker skip keeps its pinned keep-the-watch behaviour. Also log the keeper-level DontTryAgain removal so a permanent drop is observable even for sources that do not log their own outcomes. * fix(stop-loss): record the submitted receipt on a duplicate rejection classify_api_error maps DuplicatedOrder to TryNextBlock on the premise that the caller records the submitted: receipt - true for the run, but stop-loss consumed the classification without the compensating write, so a duplicate rejection re-POSTed every block until validTo. Mirror run::submit_ready: treat already-submitted as success wearing an error status, write the marker the dedup guard reads, and idle. * refactor(sdk): hoist the watch key to a free fn and shed the cross-layer dev cycle WatchSet::key never used the host parameter, forcing a meaningless turbofish at every call site (one test existed solely to prove two instantiations agree). keeper::watch_key is the free canon; WatchSet::key stays as a thin delegate for discoverability. The keeper acceptance tests only touch the local-store seam, so they now run against nexum-sdk-test's MockHost (the same MockLocalStore type), shrinking nexum-sdk's dev cycle from the cross-layer shepherd-sdk-test pair - which dragged the whole CoW domain layer into the world-neutral crate's dev graph - to the within-layer pair its own mock crate documents. * feat(twap-monitor): carry the revert cause on the permanent poll-drop path A revert that classifies to DontTryAgain destroyed the watch with only an Info outcome label; the selector and node message needed to triage a wrongly-dropped watch were discarded. Warn with both before returning the outcome, and pin the log lines in the drop test. The test-only watch_key wrapper now reuses the keeper free fn. * docs(sdk): correct stale order.rs rustdoc OrderCreation::from_signed_order_data was renamed to OrderCreation::new in cowprotocol 0.2; and only the unknown-marker case of order_uid_hex returning None also stops the submit path downstream - an unsupported chain id does not, so say so instead of claiming both do. * chore(backtest): retire the dead app_data resolution scripting The epic removed resolve_app_data and the orderbook-mock's GET /api/v1/app_data route, but the replay harness still programmed that route against a strategy that never requests it. Drop the dead programming (and the now-unused shepherd-sdk dependency) and refresh the module doc to the hash-only submission flow. The RejectedExpected classification is kept for historical report comparability until the harness is reworked around the observer-only strategy (follow-up). * docs: surface the keeper and run in the overview; record the cow-rs patch-channel move The overview's SDK table predated the epic's headline surface: add the nexum-sdk keeper row and the shepherd-sdk cow::run row. Amend ADR-0004 with the move of the [patch.crates-io] channel from bleu/cow-rs to nullislabs/cow-rs and what the pinned rev carries. * style: rustfmt the sweep additions * docs: reword PR-number references out of module comments Numeric issue/PR references in .rs files go stale across repository moves; describe the change instead of pointing at the review. --- Cargo.lock | 3 +- crates/nexum-sdk/Cargo.toml | 11 ++-- crates/nexum-sdk/src/keeper.rs | 15 ++++- crates/nexum-sdk/tests/keeper.rs | 21 ++++--- crates/shepherd-backtest/Cargo.toml | 1 - crates/shepherd-backtest/src/replay.rs | 37 ++++------- crates/shepherd-sdk-test/tests/mock_venue.rs | 16 ++--- crates/shepherd-sdk/src/cow/order.rs | 18 +++--- crates/shepherd-sdk/src/cow/run.rs | 44 +++++++++---- crates/shepherd-sdk/tests/run.rs | 27 ++++++++ docs/00-overview.md | 2 + .../0004-patch-cowprotocol-to-bleu-cow-rs.md | 6 ++ modules/examples/stop-loss/src/strategy.rs | 62 ++++++++++++++++++- modules/twap-monitor/src/strategy.rs | 56 ++++++++++++++--- tools/load-gen/src/main.rs | 2 +- 15 files changed, 237 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23e6f02d..f486526e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3591,9 +3591,9 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "http", + "nexum-sdk-test", "proptest", "serde_json", - "shepherd-sdk-test", "strum", "thiserror 2.0.18", "tracing", @@ -5063,7 +5063,6 @@ dependencies = [ "nexum-sdk", "serde", "serde_json", - "shepherd-sdk", "shepherd-sdk-test", ] diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 8410babb..67e5d8b2 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -39,11 +39,12 @@ tracing-core.workspace = true [dev-dependencies] proptest.workspace = true -# Dev-dependencies are excluded from Cargo's dependency-cycle check, so -# the nexum-sdk -> shepherd-sdk -> shepherd-sdk-test dev-dep chain is a -# normal, supported arrangement: the keeper stores acceptance-test -# against the same composed MockHost the flagship modules use. -shepherd-sdk-test = { path = "../shepherd-sdk-test" } +# Dev-dependencies are excluded from Cargo's dependency-cycle check, so the +# nexum-sdk <- nexum-sdk-test dev-dep is a normal, supported arrangement: the +# keeper stores acceptance-test against the composed world-neutral MockHost. +# The keeper never touches the orderbook, so a CoW-layer mock would only drag +# the domain crates into this crate's dev graph. +nexum-sdk-test = { path = "../nexum-sdk-test" } # The wasi:http client only links on the wasm guest target; host-side # consumers (tests, backtest tooling) compile the `http` module's types diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index ff78e082..60bcbb00 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -95,6 +95,14 @@ pub const SUBMITTED_PREFIX: &str = "submitted:"; /// upstream order as seen. pub const OBSERVED_PREFIX: &str = "observed:"; +/// Canonical watch key for an owner / commitment-hash pair (lowercase +/// `0x`-prefixed hex on both halves). Free-standing because the key +/// shape is a property of the store convention, not of any host. +#[must_use] +pub fn watch_key(owner: &Address, hash: &B256) -> String { + format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") +} + /// Borrowed view of a watch key's two hex halves, parsed from a /// `watch:{owner}:{hash}` row. Gate keys are derived from the exact /// substrings of the stored key, so a parse-then-derive round trip is @@ -161,10 +169,11 @@ impl<'h, H: LocalStoreHost> WatchSet<'h, H> { Self { host } } - /// Canonical key for an owner / commitment-hash pair (lowercase - /// `0x`-prefixed hex on both halves). + /// Canonical key for an owner / commitment-hash pair. Thin + /// delegate kept for discoverability; prefer the free + /// [`watch_key`], which needs no host turbofish. pub fn key(owner: &Address, hash: &B256) -> String { - format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") + watch_key(owner, hash) } /// Insert or overwrite the watch row; returns the key written. diff --git a/crates/nexum-sdk/tests/keeper.rs b/crates/nexum-sdk/tests/keeper.rs index ff8f9746..e271fa41 100644 --- a/crates/nexum-sdk/tests/keeper.rs +++ b/crates/nexum-sdk/tests/keeper.rs @@ -1,16 +1,17 @@ -//! Keeper-store acceptance tests against the composed CoW -//! `shepherd_sdk_test::MockHost` - the same host the flagship modules -//! test with. These live as an integration test (not `#[cfg(test)]`) -//! because the mock crate links `nexum-sdk` externally, and the -//! external and unit-test copies of the host traits are distinct types. +//! Keeper-store acceptance tests against the composed +//! `nexum_sdk_test::MockHost` - the keeper touches only the local +//! store, so the world-neutral mock is the whole seam. These live as +//! an integration test (not `#[cfg(test)]`) because the mock crate +//! links `nexum-sdk` externally, and the external and unit-test +//! copies of the host traits are distinct types. use alloy_primitives::{Address, B256, address, b256}; use nexum_sdk::host::{Fault, LocalStoreHost as _}; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, Retrier, RetryAction, - Tick, WATCH_PREFIX, WatchRef, WatchSet, + Tick, WATCH_PREFIX, WatchRef, WatchSet, watch_key, }; -use shepherd_sdk_test::MockHost; +use nexum_sdk_test::MockHost; fn sample_owner() -> Address { address!("00112233445566778899aabbccddeeff00112233") @@ -24,7 +25,7 @@ fn sample_hash() -> B256 { #[test] fn watch_key_is_lowercase_prefixed_hex() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); assert_eq!( key, concat!( @@ -36,7 +37,7 @@ fn watch_key_is_lowercase_prefixed_hex() { #[test] fn watch_key_round_trips_via_parse() { - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).expect("parse"); assert_eq!( watch.owner_hex().parse::
().unwrap(), @@ -113,7 +114,7 @@ fn put_overwrites_in_place() { fn get_absent_watch_is_none() { let host = MockHost::new(); let watches = WatchSet::new(&host); - let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let key = watch_key(&sample_owner(), &sample_hash()); let watch = WatchRef::parse(&key).unwrap(); assert_eq!(watches.get(watch).unwrap(), None); } diff --git a/crates/shepherd-backtest/Cargo.toml b/crates/shepherd-backtest/Cargo.toml index 2bc9f4b1..2ec14aa6 100644 --- a/crates/shepherd-backtest/Cargo.toml +++ b/crates/shepherd-backtest/Cargo.toml @@ -16,7 +16,6 @@ path = "src/main.rs" # `strategy::on_chain_logs` directly without an embedded runtime. ethflow-watcher = { path = "../../modules/ethflow-watcher" } nexum-sdk = { path = "../nexum-sdk" } -shepherd-sdk = { path = "../shepherd-sdk" } shepherd-sdk-test = { path = "../shepherd-sdk-test" } anyhow.workspace = true diff --git a/crates/shepherd-backtest/src/replay.rs b/crates/shepherd-backtest/src/replay.rs index 1ba6e44f..60d8f28e 100644 --- a/crates/shepherd-backtest/src/replay.rs +++ b/crates/shepherd-backtest/src/replay.rs @@ -2,11 +2,12 @@ //! //! Each [`EthFlowFixture`] is driven through the production strategy //! exactly the way the live engine does it: a fresh [`MockHost`] is -//! constructed, the resolved `app_data` JSON is programmed as the -//! `GET /api/v1/app_data/{hash}` response, the -//! `cow_api.submit_order` response is programmed to echo the -//! fixture's pre-derived UID, and `strategy::on_chain_logs` is invoked -//! with an alloy `Log` reconstructed from the raw `eth_getLogs` payload. +//! constructed, the `cow_api.submit_order` response is programmed to +//! echo the fixture's pre-derived UID, and `strategy::on_chain_logs` +//! is invoked with an alloy `Log` reconstructed from the raw +//! `eth_getLogs` payload. Submission is appData-hash-only: no +//! `GET /api/v1/app_data/{hash}` resolution runs first, so the +//! fixture's collected `app_data_resolved` document is schema-only. //! //! The classification falls into one of the four buckets defined in //! the issue: @@ -15,8 +16,7 @@ //! `OrderCreation` body. The body is captured for downstream //! validation (Phase 2B / orderbook quote round-trip). //! - `RejectedExpected`: the strategy returned without submitting in -//! a documented case - e.g. the app_data hash didn't resolve -//! (documented skip path), or dedup already saw the UID. +//! a documented case - e.g. dedup already saw the UID. //! - `RejectedUnexpected`: the strategy returned without submitting //! in a path we don't recognise; a follow-up should be //! filed before the report closes. @@ -24,7 +24,6 @@ //! bug or an `unreachable!` we want to investigate. use ethflow_watcher::strategy; -use shepherd_sdk::cow::{CowApiError, HttpFailure}; use shepherd_sdk_test::MockHost; use crate::fixtures::{EthFlowFixture, parse_address}; @@ -87,22 +86,6 @@ pub fn replay_ethflow(fx: &EthFlowFixture, chain_id: u64) -> ReplayOutcome { // re-run the orderbook itself. host.cow_api.respond(Ok(fx.uid.clone())); - // Program the `app_data` resolution path. If the - // collector captured a resolved document, hand it back verbatim; - // if the hash 404'd at collection time, return a host-side - // `Unavailable` so the strategy hits its documented "appData - // hash not mirrored" branch. - let app_data_path = format!("/api/v1/app_data/{}", fx.app_data_hash); - let app_data_response = match &fx.app_data_resolved { - Some(doc) => Ok(serde_json::to_string(doc).expect("re-serialise app_data")), - None => Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - })), - }; - host.cow_api - .respond_to_request_for("GET", app_data_path, app_data_response); - // Reconstruct the log fields. Topics + data come straight from the // collector's `raw_log`; the contract address is the EthFlow // owner the fixture pins. @@ -179,7 +162,11 @@ fn classify_ok(host: &MockHost, fx: &EthFlowFixture, log_lines: &[String]) -> Cl return Classification::Submitted; } // The strategy returned Ok without submitting. Distinguish the - // documented branches from anomalies. + // documented branches from anomalies. NOTE: the "not mirrored" + // skip path was retired with hash-only submission; this + // classification is kept for historical report comparability + // until the harness is reworked around the observer-only + // strategy (follow-up). if fx.app_data_resolved.is_none() { return Classification::RejectedExpected( "app_data hash not mirrored (documented skip path)".into(), diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index 411f012e..ca2ba439 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -6,7 +6,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; -use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, PollOutcome, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; @@ -336,13 +336,15 @@ fn module_probe_rides_out_an_injected_outage() { assert!(Journal::observed(&host).contains("0xuid").unwrap()); } -/// A B256 hash keeps the watch-key helper honest against the venue -/// host's default type parameter. +/// The free `watch_key` helper produces exactly the key +/// `WatchSet::put` writes through the venue host, so a test can seed +/// or assert rows without a host turbofish. #[test] fn watch_key_helper_unifies_with_the_venue_host() { + let host = MockHost::with_venue(); let hash: B256 = keccak256(b"conditional order params"); - assert_eq!( - WatchSet::::key(&sample_owner(), &hash), - WatchSet::::key(&sample_owner(), &hash), - ); + let written = WatchSet::new(&host) + .put(&sample_owner(), &hash, b"params") + .unwrap(); + assert_eq!(watch_key(&sample_owner(), &hash), written); } diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index 5bcef8fb..d0a79bfb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -12,8 +12,7 @@ use cowprotocol::{ }; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the -/// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` -/// expects. +/// typed [`OrderData`] shape `OrderCreation::new` expects. /// /// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride /// the wire as `bytes32` markers (the `keccak256` of the lowercase @@ -22,10 +21,10 @@ use cowprotocol::{ /// chain payload carries a marker the SDK doesn't recognise - the /// caller skips the order rather than ship a malformed body. /// -/// `receiver = Address::ZERO` is normalised to `None`; `OrderCreation:: -/// from_signed_order_data` does the same downstream, but doing it here +/// `receiver = Address::ZERO` is normalised to `None`; +/// `OrderCreation::new` does the same downstream, but doing it here /// keeps the EIP-712 hash inputs verbatim if a caller bypasses that -/// helper later. +/// constructor later. /// /// # Example /// @@ -79,9 +78,12 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { /// idempotency state before any network work. /// /// `None` when the chain id has no settlement domain or the order -/// carries an unknown enum marker; both also stop the submit path -/// downstream, so callers fall through and let it surface the -/// diagnostic. +/// carries an unknown enum marker. Only the unknown-marker case also +/// stops the submit path downstream ([`gpv2_to_order_data`] fails the +/// same way there); an unsupported chain id does not, so a caller +/// keying idempotency on this value alone re-submits until `validTo` +/// on such a chain - bounded, but callers adding new chains should +/// teach `cowprotocol::Chain` about them first. #[must_use] pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { let chain = Chain::try_from(chain_id).ok()?; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index cda6c6e0..f70433cf 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -17,7 +17,7 @@ //! the composed behaviour with one capture. use alloy_primitives::{Address, Bytes}; -use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; +use cowprotocol::{GPv2OrderData, OrderCreation, OrderData, Signature}; use nexum_sdk::host::Fault; use nexum_sdk::keeper::{ ConditionalSource, Gates, Journal, Retrier, RetryAction, Tick, WatchRef, WatchSet, @@ -55,7 +55,12 @@ where PollOutcome::TryNextBlock => {} PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, - PollOutcome::DontTryAgain => watches.remove(watch)?, + PollOutcome::DontTryAgain => { + // The removal is permanent; leave a trace of it even + // for sources that do not log their own outcomes. + tracing::info!("{} dropped watch {}", source.label(), watch.key()); + watches.remove(watch)?; + } } } Ok(()) @@ -93,10 +98,27 @@ fn submit_ready( return Ok(()); } - let creation = match build_order_creation(order, signature, owner) { + let Some(order_data) = gpv2_to_order_data(order) else { + // An unknown enum marker means the SDK cannot express this + // payload yet; skip rather than drop so an SDK upgrade can + // still pick the watch up. + tracing::warn!( + "{label} submit skipped for {owner:#x}: GPv2OrderData carried an unknown enum marker" + ); + return Ok(()); + }; + let creation = match build_order_creation(&order_data, signature, owner) { Ok(creation) => creation, - Err(message) => { - tracing::warn!("{label} submit skipped for {owner:#x}: {message}"); + Err(err) => { + // A constructor rejection (zero `from`, `validTo` beyond + // the client-side max horizon) is deterministic for this + // polled payload: keeping the watch would re-poll and + // re-warn on every block forever. Drop through the ledger + // - the same net effect as the pre-keeper flow, where + // the orderbook rejected the shipped body and the + // classifier dropped the watch. + tracing::warn!("{label} submit dropped watch for {owner:#x}: {err}"); + Retrier::new(host).apply(watch, RetryAction::Drop, tick.epoch_s)?; return Ok(()); } }; @@ -173,14 +195,14 @@ fn submit_ready( /// verbatim in the hash-only wire shape (watch-tower parity), and the /// signature is EIP-1271 - the conditional-order contract is the /// verifier. +/// +/// An `Err` is a client-side precondition failure that would recur on +/// every retry of the same payload; the caller drops the watch. fn build_order_creation( - order: &GPv2OrderData, + order_data: &OrderData, signature: Bytes, from: Address, -) -> Result { - let order_data = gpv2_to_order_data(order) - .ok_or_else(|| "GPv2OrderData carried an unknown enum marker".to_string())?; +) -> Result { let signature = Signature::Eip1271(signature.to_vec()); - OrderCreation::new_app_data_hash_only(&order_data, signature, from, None) - .map_err(|e| e.to_string()) + OrderCreation::new_app_data_hash_only(order_data, signature, from, None) } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index d5088626..a643803f 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -312,6 +312,33 @@ fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); } +/// A Ready order whose `validTo` exceeds the constructor's client-side +/// one-year horizon can never be submitted: the rejection is +/// deterministic for the polled payload, so the watch must drop +/// through the ledger instead of re-polling and warn-looping on every +/// block forever. (Watch-tower net effect: submit, orderbook rejects +/// with `ExcessiveValidTo`, classifier drops.) +#[test] +fn ready_beyond_the_valid_to_horizon_drops_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.validTo = valid_to_in(2 * 365 * 24 * 3_600); + + let source = src(move |_, _, _, _| ready_outcome(&order)); + let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); + result.unwrap(); + + assert_eq!(host.cow_api.call_count(), 0, "the body is never shipped"); + let snapshot = host.store.snapshot(); + assert!( + !snapshot.contains_key(&key), + "an unsubmittable order must not survive to warn-loop forever", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + assert!(logs.any(|e| e.message.contains("submit dropped watch"))); +} + // ---- submission failure dispatch ---- fn rejection(error_type: &str) -> CowApiError { diff --git a/docs/00-overview.md b/docs/00-overview.md index 11e3c0fb..e920b3f5 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -272,11 +272,13 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `Fault` + the `HostFault` trait - the shared failure vocabulary and per-interface typed errors (`ChainError`) with `?` support | | | `chain::{eth_call_params, parse_eth_call_result}` + `chain::chainlink` - JSON-RPC plumbing helpers | | | `config` / `address` - config-table lookups, decimal scaling, address parsing | +| | `keeper::{WatchSet, Gates, Journal, Retrier, ConditionalSource}` - the conditional-commitment strategy keeper: watch registry, poll gates, receipt journal, retry dispatch over the local-store seam | | | `http::{fetch, Fetch, FetchError, FetchOptions}` - allowlisted outbound HTTP over wasi:http on the standard `http` crate's `Request` / `Response` types | | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | | | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | +| | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | | `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | diff --git a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md index c8ba48b4..c78d14c6 100644 --- a/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md +++ b/docs/adr/0004-patch-cowprotocol-to-bleu-cow-rs.md @@ -36,3 +36,9 @@ This is not a parallel fork. `bleu/cow-rs:main` IS the head branch of upstream P - Bumping the rev is a single-line workspace edit; reviewers see one diff per primitive added to PR #5. - Drop the patch entirely once a published `cowprotocol` release contains both the alpha.3 follow-ups and the ADR-0007 protocol-primitive additions (`OrderPostError` rich variants + `retry_hint`, `OrderBookApi::with_base_url`, `wasm32` feature-gate). Until then, expect the patch rev to advance with every push to PR #5. - Modules built against this workspace inherit the patch transitively; modules built standalone against crates.io will see `alpha.3` and may hit the very bugs the patch closes. Flag this in the SDK README when M3 lands. + +## Addendum (2026-07): patch channel moved to nullislabs/cow-rs + +The patch target has since moved from `bleu/cow-rs` to `https://github.com/nullislabs/cow-rs` (rev `17fc0c5`). The fork carries two changes the workspace needs ahead of a published `cowprotocol` 0.2.0: the `OrderCreationAppData` hash-only submission shape (`OrderCreation::new_app_data_hash_only`, watch-tower parity for conditional-order submission) and the WASI clock fix that keeps `js_sys` out of non-browser wasm builds. The latest crates.io release (`0.2.0-alpha.1`) has neither. + +The decision and its consequences are otherwise unchanged: one workspace-level `[patch.crates-io]` line, advanced by bumping the rev, dropped entirely once a published `cowprotocol` release carries the hash-only constructor. The comment above `[patch.crates-io]` in the workspace `Cargo.toml` states the current drop condition. diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index bcec815b..8c97f74f 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -9,7 +9,7 @@ use nexum_sdk::config::{self, ConfigError}; use nexum_sdk::host::Fault; use nexum_sdk::prelude::{Address, Bytes, U256}; use shepherd_sdk::cow::{ - CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, + CowApiError, CowHost, RetryAction, classify_api_error, gpv2_to_order_data, is_already_submitted, }; use shepherd_sdk::prelude::{ BuyTokenDestination, Chain, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderKind, @@ -104,6 +104,20 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res ); } Err(err) => { + // Success wearing an error status: the orderbook already + // holds this exact order. Record the receipt under the + // key the dedup guard reads, so the next block idles + // instead of re-posting until `validTo`. + if let CowApiError::Rejected(rejection) = &err + && is_already_submitted(rejection) + { + host.set(&dedup_key, b"")?; + tracing::info!( + uid = %uid_hex, + "stop-loss already on the orderbook; receipt recorded", + ); + return Ok(()); + } // Only a typed orderbook rejection classifies; transport // faults and raw HTTP errors are transient (retry next // block) rather than a terminal drop. @@ -135,7 +149,7 @@ pub fn on_block(host: &H, chain_id: u64, settings: &Settings) -> Res } // `read_oracle` moved into `nexum_sdk::chain::chainlink::read_latest_answer` -// (PR #55 review): the same flow + `Option` return shape now serves +// (review consolidation): the same flow + `Option` return shape now serves // price-alert + stop-loss from the SDK, with `domain: &str` carrying the // module label into the Warn log. @@ -436,6 +450,50 @@ mod tests { assert_eq!(host.cow_api.call_count(), 1); // no resubmit } + /// A duplicate rejection is success wearing an error status: the + /// orderbook already holds the order (e.g. the local marker was + /// lost), so the receipt must be recorded and the next block must + /// idle instead of re-posting until `validTo`. + #[test] + fn duplicate_rejection_records_receipt_and_idles() { + let host = MockHost::new(); + let s = settings_below(250_000_000_000); + program_oracle( + &host, + s.oracle_address, + Ok(oracle_response_json(200_000_000_000)), + ); + + host.cow_api + .respond(Err(CowApiError::Rejected(OrderRejection { + status: 400, + error_type: "DuplicatedOrder".into(), + description: "order already exists".into(), + data: None, + }))); + + on_block(&host, SEPOLIA, &s).unwrap(); + + let uid = programmed_uid(&s); + assert!( + host.store + .snapshot() + .contains_key(&format!("submitted:{uid}")), + "the receipt must land under the key the dedup guard reads", + ); + assert!( + !host + .store + .snapshot() + .contains_key(&format!("dropped:{uid}")), + "already-submitted must never mark the order dropped", + ); + + // Second block: the receipt idles the loop, no re-POST. + on_block(&host, SEPOLIA, &s).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn transient_submit_error_leaves_state_unchanged() { let host = MockHost::new(); diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index fe0c72c1..06f4d8fc 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -157,13 +157,34 @@ fn poll_one( .and_then(|bytes| decode_return(&bytes)) .unwrap_or(PollOutcome::TryNextBlock), // `classify_poll_error` is the one policy for what a failed - // poll call means to the watch lifecycle; only a transport - // fault warrants its own diagnostic here. + // poll call means to the watch lifecycle; the diagnostics here + // cover the cases where the raw error carries information the + // outcome alone does not. Err(err) => { - if let ChainError::Fault(fault) = &err { - tracing::warn!("eth_call failed ({fault}); retrying next block"); + let outcome = classify_poll_error(&err); + match &err { + ChainError::Fault(fault) => { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + // A permanent drop deserves its cause on the record: + // the revert selector and the node's message are + // unrecoverable once the watch is gone. + ChainError::Rpc(rpc) if matches!(outcome, PollOutcome::DontTryAgain) => { + let selector = rpc + .data + .as_deref() + .and_then(|data| data.get(..4)) + .map(alloy_primitives::hex::encode_prefixed) + .unwrap_or_else(|| "none".to_string()); + tracing::warn!( + "eth_call reverted permanently (selector {selector}, {}); \ + dropping watch", + rpc.message, + ); + } + _ => {} } - classify_poll_error(&err) + outcome } } } @@ -196,9 +217,7 @@ fn outcome_label(o: &PollOutcome) -> &'static str { // seed and inspect the store in the exact shapes production writes. #[cfg(test)] -fn watch_key(owner: &Address, params_hash: &alloy_primitives::B256) -> String { - WatchSet::::key(owner, params_hash) -} +use nexum_sdk::keeper::watch_key; #[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { @@ -739,7 +758,8 @@ mod tests { })), ); - on_block(&host, sample_block(1_000)).unwrap(); + let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); assert!( @@ -753,5 +773,23 @@ mod tests { 0, "revert-to-drop path never submits" ); + // The destructive drop carries its cause: the revert selector + // and the node's message ride the Warn, and the keeper logs + // the removal itself. + let warn = logs.expect_one(|e| { + e.level == Level::WARN && e.message.contains("eth_call reverted permanently") + }); + assert!(warn.message.contains("execution reverted")); + let selector_hex = + alloy_primitives::hex::encode_prefixed(&IConditionalOrder::OrderNotValid::SELECTOR[..]); + assert!( + warn.message.contains(&selector_hex), + "the four-byte selector must be greppable: {}", + warn.message, + ); + logs.expect_one(|e| { + e.message + .contains(&format!("dropped watch {watch_key_str}")) + }); } } diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 1546ac42..15a05760 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -399,7 +399,7 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { /// needs no registration step. `validTo` is `u32::MAX` per the /// canonical EthFlow shape (the mock orderbook is /// permissive here, and shepherd's strategy will drop with the -/// expected Info-level log per PR #49). +/// expected Info-level log). fn encode_ethflow_create_order(eoa: Address, sell_amount: u128, quote_id: i64) -> Bytes { let order = EthFlowOrderData { buyToken: COW_TOKEN, From 6d2b2278ec28acca9a675b029c29e65ca8e023ed Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 23:49:56 +0000 Subject: [PATCH 05/89] feat(sdk): scaffold nexum-macros and #[module] attribute (#243) * feat(sdk): scaffold nexum-macros and ship #[module] attribute Add the `nexum-macros` proc-macro crate. `#[nexum_sdk::module]` generates the per-cdylib glue every module hand-wrote: the `wit_bindgen::generate!` call for the blanket `shepherd:cow/shepherd` world, the host adapter via `bind_host_via_wit_bindgen!`, the `Guest` implementation whose `on-event` dispatches to the named handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`, with undefined handlers ignored), and `export!`. The WIT directory is located by walking up from the consuming crate's manifest, so the emitted paths need no per-module tuning. Re-export it as `nexum_sdk::module` and port the example, balance-tracker and price-alert modules onto it; each strategy MockHost suite passes unchanged. * refactor(http-probe): port onto #[nexum::module] Complete the acceptance list: drop the hand-written wit-bindgen glue, host adapter, and Guest/export block in favour of the attribute, leaving only the init and on-block handlers. Strategy MockHost suite unchanged. --- Cargo.lock | 11 ++ Cargo.toml | 8 + crates/nexum-macros/Cargo.toml | 18 ++ crates/nexum-macros/src/lib.rs | 176 ++++++++++++++++++++ crates/nexum-sdk/Cargo.toml | 4 + crates/nexum-sdk/src/lib.rs | 10 ++ modules/example/Cargo.toml | 1 + modules/example/src/lib.rs | 90 +++++----- modules/examples/balance-tracker/src/lib.rs | 30 +--- modules/examples/http-probe/src/lib.rs | 33 ++-- modules/examples/price-alert/src/lib.rs | 31 ++-- 11 files changed, 302 insertions(+), 110 deletions(-) create mode 100644 crates/nexum-macros/Cargo.toml create mode 100644 crates/nexum-macros/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f486526e..0669df5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2266,6 +2266,7 @@ dependencies = [ name = "example" version = "0.1.0" dependencies = [ + "nexum-sdk", "wit-bindgen 0.59.0", ] @@ -3545,6 +3546,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "nexum-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "nexum-runtime" version = "0.2.0" @@ -3591,6 +3601,7 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "http", + "nexum-macros", "nexum-sdk-test", "proptest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 24b465db..25604bd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/nexum-cli", + "crates/nexum-macros", "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", @@ -115,6 +116,13 @@ http-body = "1" http-body-util = "0.1" bytes = "1" +# Proc-macro toolkit backing `nexum-macros`. Host-side only: the +# proc-macro crate always builds for the host, even when the module +# consuming it targets wasm. +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + # `wit-bindgen` is consumed by every guest module crate (example + # every strategy + every fixture). Hoisted so a single bump moves # them in lock-step. diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml new file mode 100644 index 00000000..ed9fcf2c --- /dev/null +++ b/crates/nexum-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nexum-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." + +[lib] +proc-macro = true + +[lints] +workspace = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs new file mode 100644 index 00000000..e5dca6f7 --- /dev/null +++ b/crates/nexum-macros/src/lib.rs @@ -0,0 +1,176 @@ +//! Proc-macro glue for nexum runtime modules. +//! +//! [`module`] turns an `impl` block of named handlers into a complete +//! per-cdylib module: it emits the `wit_bindgen::generate!` call for the +//! blanket `shepherd:cow/shepherd` world, the host adapter (via +//! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation +//! whose `on-event` dispatches to the handlers present, and `export!`. +//! +//! Consumers reach this through the `nexum_sdk::module` re-export rather +//! than depending on this crate directly. + +use std::path::{Path, PathBuf}; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{ImplItem, ItemImpl, Type}; + +/// The handler names recognised on a `#[module]` impl. Any method not in +/// this set is left untouched on the type; any handler in the set that is +/// absent is treated as a no-op in the generated `on-event` dispatch. +const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on_message"]; + +/// Generate the per-cdylib glue for a nexum module. +/// +/// Apply to an `impl` block whose associated functions are the event +/// handlers (`init`, `on_block`, `on_chain_logs`, `on_tick`, +/// `on_message`). Each handler takes the wit-bindgen payload for its +/// event and returns `Result<(), Fault>`; `init` takes the config table. +/// Handlers left undefined are ignored (their events become no-ops). The +/// macro emits `wit_bindgen::generate!`, the host adapter, the `Guest` +/// impl, and `export!` around the untouched impl. +/// +/// The one non-obvious invariant: the `wit`/wit-bindgen output +/// (`Guest`, `Fault`, the `nexum::host::*` modules) lands at the module +/// crate root, so the emitted glue and the handler bodies resolve those +/// names there; the WIT directory is located by walking up from +/// `CARGO_MANIFEST_DIR`. +#[proc_macro_attribute] +pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { + if !attr.is_empty() { + return syn::Error::new( + proc_macro2::Span::call_site(), + "#[nexum_sdk::module] takes no arguments", + ) + .to_compile_error() + .into(); + } + + let input = syn::parse_macro_input!(item as ItemImpl); + + let self_ty = &input.self_ty; + if !is_plain_type(self_ty) { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] must be applied to an inherent impl of a named type", + ) + .to_compile_error() + .into(); + } + + let present: Vec<&str> = input + .items + .iter() + .filter_map(|item| match item { + ImplItem::Fn(f) => { + let name = f.sig.ident.to_string(); + HANDLERS.into_iter().find(|h| *h == name) + } + _ => None, + }) + .collect(); + let has = |name: &str| present.contains(&name); + + let (nexum_wit, shepherd_wit) = match locate_wit() { + Ok(paths) => paths, + Err(msg) => { + return syn::Error::new(proc_macro2::Span::call_site(), msg) + .to_compile_error() + .into(); + } + }; + let nexum_wit = nexum_wit.to_string_lossy().into_owned(); + let shepherd_wit = shepherd_wit.to_string_lossy().into_owned(); + + // `init` is a required export; when the handler is absent the config + // is bound but unused, so drop it to keep the module warning-clean. + let init_impl = if has("init") { + quote! { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + <#self_ty>::init(config) + } + } + } else { + quote! { + fn init( + _config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), Fault> { + ::core::result::Result::Ok(()) + } + } + }; + + let arm = |handler: &str, variant| -> proc_macro2::TokenStream { + let variant = syn::Ident::new(variant, proc_macro2::Span::call_site()); + if has(handler) { + let call = syn::Ident::new(handler, proc_macro2::Span::call_site()); + quote! { nexum::host::types::Event::#variant(payload) => <#self_ty>::#call(payload), } + } else { + quote! { nexum::host::types::Event::#variant(_) => ::core::result::Result::Ok(()), } + } + }; + let block_arm = arm("on_block", "Block"); + let logs_arm = arm("on_chain_logs", "ChainLogs"); + let tick_arm = arm("on_tick", "Tick"); + let message_arm = arm("on_message", "Message"); + + quote! { + wit_bindgen::generate!({ + path: [#nexum_wit, #shepherd_wit], + world: "shepherd:cow/shepherd", + generate_all, + }); + + ::nexum_sdk::bind_host_via_wit_bindgen!(); + + #input + + #[doc(hidden)] + struct __NexumModuleExport; + + impl Guest for __NexumModuleExport { + #init_impl + + fn on_event(event: nexum::host::types::Event) -> ::core::result::Result<(), Fault> { + match event { + #block_arm + #logs_arm + #tick_arm + #message_arm + } + } + } + + export!(__NexumModuleExport); + } + .into() +} + +/// Whether a type is a plain named path (`Foo`), the only shape a module +/// export type may take. +fn is_plain_type(ty: &Type) -> bool { + matches!(ty, Type::Path(tp) if tp.qself.is_none()) +} + +/// Locate the workspace `wit/nexum-host` and `wit/shepherd-cow` +/// directories by walking up from the consuming crate's manifest. +fn locate_wit() -> Result<(PathBuf, PathBuf), String> { + let manifest = std::env::var("CARGO_MANIFEST_DIR") + .map_err(|_| "CARGO_MANIFEST_DIR is not set".to_string())?; + let mut dir: Option<&Path> = Some(Path::new(&manifest)); + while let Some(cur) = dir { + let wit = cur.join("wit"); + let nexum = wit.join("nexum-host"); + let shepherd = wit.join("shepherd-cow"); + if nexum.is_dir() && shepherd.is_dir() { + return Ok((nexum, shepherd)); + } + dir = cur.parent(); + } + Err(format!( + "could not find a `wit/` directory containing `nexum-host` and `shepherd-cow` \ + in any ancestor of {manifest}" + )) +} diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index 67e5d8b2..f713c5c1 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -19,6 +19,10 @@ description = "Guest-side SDK for nexum runtime modules: host-neutral helpers us stderr-echo = [] [dependencies] +# Re-exported as `nexum_sdk::module`; the proc-macro emits glue that +# calls back into this crate (`bind_host_via_wit_bindgen!`, the host +# trait seam, the tracing facade). +nexum-macros = { path = "../nexum-macros" } alloy-primitives.workspace = true # The `Log` type modules receive for chain-log events is alloy's own RPC log, # assembled from the WIT record at the binding edge (see `events`). diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index b11cd21e..bd255a8e 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -28,6 +28,11 @@ //! generates the per-module `WitBindgenHost` adapter over the //! wit-bindgen import shims. //! +//! - [`module`] - attribute macro that generates the whole per-cdylib +//! glue (the wit-bindgen call, the adapter above, the +//! `Guest`/`on-event` dispatch, and `export!`) from an `impl` block of +//! named handlers. +//! //! - [`keeper`] - strategy-keeper stores over [`LocalStoreHost`]: //! the watch-set registry ([`WatchSet`]), block/epoch gate keys //! ([`Gates`]) and the receipt-keyed idempotency journal @@ -104,6 +109,11 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_cfg))] +/// Generate the per-cdylib module glue (wit-bindgen, host adapter, +/// `Guest`/`on-event` dispatch, `export!`) from an `impl` block of named +/// handlers. See [`nexum_macros::module`]. +pub use nexum_macros::module; + pub mod address; pub mod chain; pub mod config; diff --git a/modules/example/Cargo.toml b/modules/example/Cargo.toml index c9a0ff70..19311814 100644 --- a/modules/example/Cargo.toml +++ b/modules/example/Cargo.toml @@ -12,4 +12,5 @@ workspace = true crate-type = ["cdylib"] [dependencies] +nexum-sdk = { path = "../../crates/nexum-sdk" } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/example/src/lib.rs b/modules/example/src/lib.rs index 6ed4deb6..273b299d 100644 --- a/modules/example/src/lib.rs +++ b/modules/example/src/lib.rs @@ -1,24 +1,24 @@ +//! # example (reference Shepherd module) +//! +//! The minimal reference module: one handler per event, each logging a +//! one-line summary through the raw host `logging` binding. It carries +//! no strategy layer and no `[config]` behaviour, so it doubles as the +//! smallest end-to-end demonstration of `#[nexum_sdk::module]` - the +//! attribute supplies the wit-bindgen call, the host adapter, the +//! `Guest`/`on-event` dispatch, and `export!`, leaving only the +//! handlers. + // wit_bindgen::generate! expands to host-import shims whose arity matches // the WIT signatures, which can exceed clippy's too-many-arguments threshold. #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: "../../wit/nexum-host", - world: "nexum:host/event-module", -}); - -use nexum::host::logging; -use nexum::host::types; +use nexum::host::{logging, types}; -// This is the SDK-free reference module: it depends only on -// `wit-bindgen` and installs no tracing subscriber, so it logs through -// the raw host `logging` binding directly. That binding is the same -// sink the `tracing` facade forwards to in the SDK-based modules, so -// the records are indistinguishable to the host. struct ExampleModule; -impl Guest for ExampleModule { +#[nexum_sdk::module] +impl ExampleModule { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { let name = config .iter() @@ -32,38 +32,38 @@ impl Guest for ExampleModule { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match &event { - types::Event::Block(block) => { - logging::log( - logging::Level::Info, - &format!( - "block {} on chain {} (ts={}ms)", - block.number, block.chain_id, block.timestamp - ), - ); - } - types::Event::ChainLogs(batch) => { - logging::log( - logging::Level::Info, - &format!("received {} chain-log entries", batch.logs.len()), - ); - } - types::Event::Tick(tick) => { - logging::log( - logging::Level::Info, - &format!("tick fired at {}ms", tick.fired_at), - ); - } - types::Event::Message(msg) => { - logging::log( - logging::Level::Info, - &format!("message on topic {}", msg.content_topic), - ); - } - } + fn on_block(block: types::Block) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!( + "block {} on chain {} (ts={}ms)", + block.number, block.chain_id, block.timestamp + ), + ); + Ok(()) + } + + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("received {} chain-log entries", batch.logs.len()), + ); Ok(()) } -} -export!(ExampleModule); + fn on_tick(tick: types::Tick) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("tick fired at {}ms", tick.fired_at), + ); + Ok(()) + } + + fn on_message(msg: types::Message) -> Result<(), Fault> { + logging::log( + logging::Level::Info, + &format!("message on topic {}", msg.content_topic), + ); + Ok(()) + } +} diff --git a/modules/examples/balance-tracker/src/lib.rs b/modules/examples/balance-tracker/src/lib.rs index 55aec121..263a7bcd 100644 --- a/modules/examples/balance-tracker/src/lib.rs +++ b/modules/examples/balance-tracker/src/lib.rs @@ -11,8 +11,8 @@ //! - `strategy.rs` holds the pure logic and tests against //! `nexum_sdk::host::Host`. It does not know `wit-bindgen` //! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Config //! @@ -27,28 +27,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct BalanceTracker; -impl Guest for BalanceTracker { +#[nexum_sdk::module] +impl BalanceTracker { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -61,15 +54,10 @@ impl Guest for BalanceTracker { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg).map_err(sdk_fault_into_wit) } } - -export!(BalanceTracker); diff --git a/modules/examples/http-probe/src/lib.rs b/modules/examples/http-probe/src/lib.rs index bb9ab42e..c48377b1 100644 --- a/modules/examples/http-probe/src/lib.rs +++ b/modules/examples/http-probe/src/lib.rs @@ -14,9 +14,8 @@ //! - `strategy.rs` holds the pure logic and tests against the SDK's //! `http::Fetch` seam, logging through the `tracing` facade. It does //! not know `wit-bindgen` exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, `install_tracing`, the -//! `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -36,28 +35,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level`, -// `HostLogSink`, `install_tracing` are generated below. Single source -// of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `sdk_fault_into_wit` and `install_tracing` (used by the handlers) are +// generated by the attribute alongside the wit-bindgen call and the +// `Guest`/`export!` glue. struct HttpProbe; -impl Guest for HttpProbe { +#[nexum_sdk::module] +impl HttpProbe { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -71,16 +63,11 @@ impl Guest for HttpProbe { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&nexum_sdk::http::WasiFetch, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(HttpProbe); diff --git a/modules/examples/price-alert/src/lib.rs b/modules/examples/price-alert/src/lib.rs index e9d922d0..94285268 100644 --- a/modules/examples/price-alert/src/lib.rs +++ b/modules/examples/price-alert/src/lib.rs @@ -16,8 +16,8 @@ //! - `strategy.rs` holds the pure logic and tests against //! `nexum_sdk::host::Host`. It does not know `wit-bindgen` //! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter, the `Guest` impl. +//! - `lib.rs` (this file) declares the handlers and defers the +//! per-cdylib glue to `#[nexum_sdk::module]`. //! //! ## Settings //! @@ -42,27 +42,21 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: ["../../../wit/nexum-host", "../../../wit/shepherd-cow"], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; use std::sync::OnceLock; use nexum::host::types; -// `WitBindgenHost`, `sdk_fault_into_wit`, `convert_level` are generated -// below. Single source of truth in `nexum-sdk`. -nexum_sdk::bind_host_via_wit_bindgen!(); - static SETTINGS: OnceLock = OnceLock::new(); +// `WitBindgenHost`, `sdk_fault_into_wit`, and `install_tracing` (used +// by the handlers) are generated by the attribute alongside the +// wit-bindgen call and the `Guest`/`export!` glue. struct PriceAlert; -impl Guest for PriceAlert { +#[nexum_sdk::module] +impl PriceAlert { fn init(config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; @@ -77,16 +71,11 @@ impl Guest for PriceAlert { Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { + fn on_block(block: types::Block) -> Result<(), Fault> { let Some(cfg) = SETTINGS.get() else { return Ok(()); }; - if let types::Event::Block(block) = event { - strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) - .map_err(sdk_fault_into_wit)?; - } - Ok(()) + strategy::on_block(&WitBindgenHost, block.chain_id, cfg, block.number) + .map_err(sdk_fault_into_wit) } } - -export!(PriceAlert); From 558e6274d00e2c25000f85195e65d49a9d3ce3f7 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:07:15 +0000 Subject: [PATCH 06/89] docs(sdk): rewrite doc 05 around the two-persona SDK plan (#245) Doc 05 was stamped future-direction and described the superseded macro/TypedState/Signer/HostTransport vision. Replace it with the shipped module-author persona (nexum-sdk + shepherd-sdk + the landed #[nexum::module] macro) and the planned venue-adapter persona (nexum-venue-sdk / per-venue-crate), clearly labelled as design intent tracked by a separate epic. Drop the deferred-features table and cross-link ADR-0009 and sdk.md. --- docs/05-sdk-design.md | 1223 +++++++++-------------------------------- 1 file changed, 267 insertions(+), 956 deletions(-) diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 697d3448..b4b784e6 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -1,982 +1,293 @@ -# SDK Design: Layered SDK (`nexum-sdk` + `shepherd-sdk`) - -> **Status: future direction, not in 0.2 scope.** This document is the **0.3+ north-star** vision for the layered SDK. The 0.2 SDK shipped a focused subset, and the macro-driven authoring model below was superseded by the host-trait seam in [ADR-0009](adr/0009-host-trait-surface.md) - that is the design that ships. Treat the macros, two-crate split, `TypedState`, `Signer`, `HostTransport` / `Provider`, and `cargo-nexum` CLI sections below as design intent, not API documentation. For the shipped surface, see [`sdk.md`](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. -> -> The split, for quick reference: -> -> | Feature | M3 status | Where | -> |---|---|---| -> | `shepherd-sdk` crate | ✅ shipped | `crates/shepherd-sdk/` | -> | `shepherd-sdk-test` crate (mock host) | ✅ shipped | `crates/shepherd-sdk-test/` | -> | Host traits (`ChainHost`, `LocalStoreHost`, `LoggingHost`) + supertrait `Host` | ✅ shipped | `crates/nexum-sdk/src/host.rs` (see ADR-0009); the CoW `CowApiHost` lives in `crates/shepherd-sdk/src/cow/` | -> | `strategy.rs` (pure logic) + `lib.rs` (wit-bindgen adapter) recipe | ✅ shipped | every M2/M3 module | -> | `Fault` + `HostFault` trait, `ChainError` (SDK-side mirror of wit) | ✅ shipped | `crates/nexum-sdk/src/host.rs` | -> | `chain` helpers (`eth_call_params`, `parse_eth_call_result`) | ✅ shipped | `crates/nexum-sdk/src/chain/`; the CoW `decode_revert_hex` lives in `crates/shepherd-sdk/src/cow/` | -> | `cow` helpers (`PollOutcome`, `RetryAction`, `classify_api_error`, `gpv2_to_order_data`, `decode_revert`, `IConditionalOrder`) | ✅ shipped | `crates/shepherd-sdk/src/cow/` | -> | `http::fetch` over wasi:http (+ `Fetch` seam, `FetchError`) | ✅ shipped | `crates/nexum-sdk/src/http.rs` | -> | `MockHost` with per-trait mocks (`MockChain`, `MockLocalStore`, `MockLogging`; CoW `MockCowApi`) | ✅ shipped | `crates/nexum-sdk-test/src/lib.rs` + `crates/shepherd-sdk-test/src/lib.rs` | -> | Separate `nexum-sdk` crate | ✅ shipped | `crates/nexum-sdk/` carries the generic surface (host seam, bind macro, chain/config/address, http, tracing); `shepherd-sdk` layers the CoW domain on top with no re-export | -> | `#[nexum::module]` / `#[shepherd::module]` proc macros | ❌ deferred (M5) | modules write `wit_bindgen::generate!` + `WitBindgenHost` adapter by hand | -> | Named event handlers (`on_block` / `on_chain_logs` / `on_tick` / `on_message` injection) | ❌ deferred (M5) | modules pattern-match on `types::Event` in `Guest::on_event` | -> | `async fn` handler support via `block_on` | ❌ deferred (M5) | strategy functions are synchronous | -> | Full alloy `Provider` via `HostTransport` | ❌ deferred (M5) | modules call `host.request(chain_id, method, params)` with JSON strings | -> | `TypedState` (postcard-backed typed local-store) | ❌ deferred (M5) | modules call `host.set(&key, &raw_bytes)` directly | -> | `Signer` (ECDSA + EIP-712 via `identity` host interface) | ❌ deferred (M5) | modules use `Signature::PreSign` / `Signature::Eip1271`; no key custody on the module side | -> | `Cow` typed CoW Protocol API client (quote / get_order / raw_request) | ❌ deferred (M5) | `cow-api` exposes only `submit-order` today | -> | `MockIdentity`, `MockProvider`, `WasmTestHarness` | ❌ deferred (M5) | tests against `&impl Host` + per-trait mocks | -> | `cargo nexum` CLI (new / build / package / publish) | ❌ deferred (M5) | modules use `cargo build --target wasm32-wasip2` directly | -> | `block.timestamp` in ms | ✅ shipped | confirmed in `nexum:host/types` | -> -> **Reader's guide**: treat the sections below as design intent the next two milestones move toward, not API documentation for the code that exists today. For M3 API reference, see [sdk.md](sdk.md) and the rustdoc on `crates/shepherd-sdk/`. The M3 architectural decision is captured in [ADR-0009](adr/0009-host-trait-surface.md). - -## Purpose - -The SDK is split into two layers: - -1. **`nexum-sdk`** -- the universal SDK for any `nexum:host/event-module`. It provides: - - WIT bindings (re-exported, version-pinned) - - A proc macro (`#[nexum::module]`) that eliminates boilerplate (supports `async fn` for natural `.await`) - - A full alloy `Provider` backed by the host's RPC stack (`HostTransport`) - - Typed local-store helpers (serde over raw bytes) - - A typed `Signer` for key management and signing - - Ethereum ABI helpers (alloy-sol-types integration) - - A test harness with a mock host (`MockHost`) - - A logging convenience layer - - The per-interface typed error model over the shared `Fault` vocabulary - -2. **`shepherd-sdk`** -- the CoW Protocol extension. It depends on `nexum-sdk` (modules import both directly; nothing is re-exported) and adds: - - CoW-specific WIT bindings (`shepherd:cow`) - - A typed CoW Protocol API client (`Cow`) - - A proc macro (`#[shepherd::module]`) that targets the `shepherd:cow/shepherd` world - - CoW-specific mock testing utilities - -Module authors should never interact with `wit-bindgen` or the canonical ABI directly. - -## Crate Structure +# SDK Design: The Two-Persona SDK Plan + +This document describes the guest-side SDK crates and the plan that +shapes them: a **module-author persona** and a **venue-adapter +persona**, each with its own crate pair and attribute macro. The +module-author persona is shipped and is what this document mostly +describes; the venue-adapter persona is design intent tracked by a +separate epic and is called out explicitly as such wherever it +appears below. + +For the architectural decision behind the host-trait seam that the +module-author persona builds on, see [ADR-0009](adr/0009-host-trait-surface.md). +For the rustdoc-level API reference (the source of truth once you are +writing module code), see [`sdk.md`](sdk.md) and the rustdoc under +`crates/nexum-sdk/`, `crates/shepherd-sdk/`, and `crates/nexum-macros/`. + +## The two personas + +The runtime has two kinds of guest authors, and they need different +things from the SDK: + +1. **Module author.** Writes an automation module against + `nexum:host/event-module` (or the CoW-extended `shepherd:cow/shepherd` + world): react to blocks, chain logs, ticks, or messages; read and + write local state; submit orders. This persona is served today by + `nexum-sdk` (+ the `#[nexum::module]` macro) and, for CoW-specific + modules, `shepherd-sdk` on top. + +2. **Venue adapter author.** Writes an adapter that exposes a trading + venue (CoW Protocol, a DEX, a lending market, ...) to modules + through a common intent surface, so a module author does not need + to know the venue's wire format. This persona is planned but not + yet shipped: the crate (`nexum-venue-sdk`), the per-venue crates + (e.g. a `cow-venue` crate carrying CoW's intent-body codec), the + `#[nexum::venue]` macro, and the `nexum-venue-test` conformance kit + are all tracked by the SDK-surfaces epic and have no code in the + tree yet. See [Venue-adapter persona (planned)](#venue-adapter-persona-planned) + below for the shape of the plan. + +Both personas share one proc-macro crate, `nexum-macros`, and the +same host-trait philosophy: guest code is written against small Rust +traits that mirror the WIT interfaces one-for-one, so strategy logic +can be unit-tested against an in-memory mock without a `wasm32-wasip2` +toolchain or a running wasmtime instance. + +## Module-author persona (shipped): `nexum-sdk` + `shepherd-sdk` + +### Crate structure ``` nexum-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # re-exports, prelude, provider() constructor (block_on is internal) -│ ├── bindings.rs # generated by wit-bindgen (checked in or build.rs) -│ ├── transport.rs # HostTransport -- alloy Transport impl over chain::request / chain::request-batch -│ ├── local_store.rs # typed local-store helpers -│ ├── signer.rs # Signer -- typed identity helpers (accounts, signing) -│ ├── abi.rs # Ethereum ABI encoding/decoding -│ ├── log.rs # logging convenience -│ ├── error.rs # Fault, HostFault, ChainError -│ └── testing.rs # mock host, test harness -└── macros/ - └── src/ - └── lib.rs # #[nexum::module] proc macro (async fn support) +└── src/ + ├── lib.rs # crate docs, `pub use nexum_macros::module` + ├── prelude.rs # alloy primitive re-exports (Address, B256, Bytes, U256, keccak256) + ├── host.rs # ChainHost / LocalStoreHost / LoggingHost + supertrait Host; Fault, ChainError, RpcError + ├── wit_bindgen_macro.rs # bind_host_via_wit_bindgen! - generates WitBindgenHost + converters + ├── keeper.rs # WatchSet, Gates, Journal, ConditionalSource, Retrier + ├── chain/ # eth_call_params, parse_eth_call_result, chainlink AggregatorV3 reader + ├── events.rs # native alloy Log assembly from the wire ChainLog record + ├── config.rs # (key, value) config-table lookups, decimal scaling + ├── address.rs # EVM address parsing with typed errors + ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) + └── tracing.rs # guest tracing facade + panic hook over a LogSink seam + +nexum-macros/ +├── Cargo.toml # proc-macro = true +└── src/ + └── lib.rs # #[module] attribute macro shepherd-sdk/ ├── Cargo.toml -├── src/ -│ ├── lib.rs # CoW-specific prelude and API; modules import nexum-sdk directly -│ ├── bindings.rs # generated CoW WIT bindings (shepherd:cow) -│ ├── cow.rs # Cow -- typed CoW Protocol API wrapper -│ └── testing.rs # CoW-specific mock utilities -└── macros/ - └── src/ - └── lib.rs # #[shepherd::module] proc macro (CoW variant) -``` - -The workspace root `wit/nexum-host/` is the **universal WIT definition**. The `wit/shepherd-cow/` directory extends it with CoW Protocol interfaces. The SDKs reference these via path (not a copy) to prevent drift: - -```toml -# nexum-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/nexum-host" -``` - -```toml -# shepherd-sdk/Cargo.toml -[package.metadata.component.target] -path = "../wit/shepherd-cow" -``` - -Both SDKs pin a specific `wit-bindgen` version so module authors are insulated from upstream churn. - -The crates above are the guest-side SDK. The host side ships separately as the `nexum-runtime` library plus the `nexum` binary (`crates/nexum-cli`). A Rust host embedding the runtime directly should start from `crates/nexum-runtime/examples/embed.rs` rather than the SDK. - -## The `#[nexum::module]` and `#[shepherd::module]` Macros - -### Universal: `#[nexum::module]` - -Without the macro, a module author writes (against the typed 0.2 config): - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -struct MyModule; - -impl Guest for MyModule { - fn init(config: Config) -> Result<(), Fault> { ... } - fn on_event(event: Event) -> Result<(), Fault> { - match event { - Event::Block(block) => { ... } - Event::ChainLogs(logs) => { ... } - Event::Tick(tick) => { ... } - Event::Message(msg) => { ... } - } - } -} - -export!(MyModule); -``` - -With the macro, module authors implement **named event handlers** instead. The macro generates the `on_event` match dispatch, the `Guest` trait impl, WIT bindings, and `export!`: - -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct TwapMonitor; - -impl TwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; - // ... - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - for log in &logs { - // ... - } - Ok(()) - } - - // on_tick / on_message not defined -> those events are silently ignored -} -``` - -The `#[nexum::module]` macro generates code against the `nexum:host/event-module` world. - -### CoW Protocol: `#[shepherd::module]` - -For CoW Protocol modules, the `#[shepherd::module]` macro targets the `shepherd:cow/shepherd` world, which extends `event-module` with the merged `cow-api` import: - -```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct CowTwapMonitor; - -impl CowTwapMonitor { - fn init(config: Config) -> Result<()> { - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let cow = Cow::new(block.chain_id); - let quote = cow.get_quote(&OrderQuoteRequest { /* ... */ })?; - // ... - Ok(()) - } -} -``` - -### What the macro generates - -For the universal `#[nexum::module]`: - -```rust -wit_bindgen::generate!({ world: "event-module", path: "..." }); - -impl Guest for TwapMonitor { - fn init(config: Config) -> Result<(), Fault> { - TwapMonitor::init(config.into()).map_err(Fault::from) - } - - fn on_event(event: types::Event) -> Result<(), Fault> { - nexum_sdk::block_on(async { - match event { - Event::Block(block) => { - let provider = nexum_sdk::provider(block.chain_id); - TwapMonitor::on_block(block, &provider).await - } - Event::ChainLogs(logs) => { - let provider = nexum_sdk::provider(logs[0].chain_id); - TwapMonitor::on_chain_logs(logs, &provider).await - } - Event::Tick(_) => Ok(()), // no handler defined - Event::Message(_) => Ok(()), // no handler defined - } - }).map_err(Fault::from) - } -} - -export!(TwapMonitor); -``` - -For the CoW `#[shepherd::module]`, the generated code additionally imports `shepherd:cow/cow-api` alongside the `nexum:host` base. - -### Named event handlers - -| Handler | Payload | Optional injectable context | -|---|---|---| -| `on_block(block)` | `Block` | `provider: &RootProvider` (from `block.chain_id`) | -| `on_chain_logs(logs)` | `Vec` | `provider: &RootProvider` (from the `chain-logs` batch chain id) | -| `on_tick(tick)` | `Tick` (`tick.fired_at`) | None (no chain context) | -| `on_message(message)` | `Message` | None | - -The macro inspects each handler's signature: - -- **If the second parameter is `&RootProvider`**: the macro creates `nexum_sdk::provider(chain_id)` (also for CoW modules) and passes it in. The chain_id is derived from the event payload (`block.chain_id`, `logs[0].chain_id`). -- **If no second parameter**: the macro passes only the payload. -- **Both sync and async handlers work.** Async handlers are wrapped in `block_on`; sync handlers are called directly. -- **Unimplemented handlers** become `Ok(())` -- the module only handles event types it cares about. - -### Escape hatch: `on_event` - -For modules that need custom dispatch logic, defining `on_event` directly takes precedence over named handlers: - -```rust -#[nexum::module] -struct CustomModule; - -impl CustomModule { - fn init(config: Config) -> Result<()> { Ok(()) } - - // Full control -- named handlers are ignored if on_event exists - async fn on_event(event: Event) -> Result<()> { - match event { - Event::Block(block) if block.chain_id == 42161 => { /* Arbitrum only */ } - Event::Block(_) => { /* other chains */ } - _ => {} - } - Ok(()) - } -} -``` - -Resolution order: -1. `on_event` defined -> use it directly (wrap in `block_on` if async) -2. Any of `on_block` / `on_chain_logs` / `on_tick` / `on_message` defined -> generate the match dispatch -3. Neither -> compile error - -> Full async design rationale: [07-rpc-namespace-design.md](07-rpc-namespace-design.md#eliminating-block_on-async-module-functions) - -## Prelude - -### Universal: `nexum_sdk::prelude` - -```rust -// nexum_sdk::prelude -pub use crate::bindings::nexum::host::types::*; -pub use crate::bindings::nexum::host::chain; -pub use crate::bindings::nexum::host::identity; -pub use crate::bindings::nexum::host::local_store; -pub use crate::bindings::nexum::host::remote_store; -pub use crate::bindings::nexum::host::messaging; -pub use crate::bindings::nexum::host::logging; -pub use crate::log::{trace, debug, info, warn, error}; -pub use crate::local_store::TypedState; -pub use crate::signer::Signer; -pub use crate::transport::HostTransport; -pub use crate::provider; -pub use crate::error::{Result, Fault, HostFault, ChainError, RpcError}; - -// Re-export alloy essentials so modules don't need direct alloy dependencies -pub use alloy_primitives::{Address, B256, U256, Bytes}; -pub use alloy_sol_types::sol; -pub use alloy_rpc_types::*; -pub use alloy_provider::Provider; -``` - -One `use nexum_sdk::prelude::*;` gives module authors everything they need -- including the alloy `Provider` trait, primitive types, `sol!` macro, and `Signer` for signing. - -`block_on` is no longer a public re-export in 0.2 -- it's hidden behind the `#[nexum::module]` macro. See the [migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) for the full SDK rename table. - -### CoW Protocol: `shepherd_sdk::prelude` - -```rust -// shepherd_sdk::prelude -- CoW-specific items only; no nexum-sdk re-export -pub use crate::bindings::shepherd::cow::cow_api; -pub use crate::cow::Cow; -``` - -CoW module authors write `use nexum_sdk::prelude::*;` alongside `use shepherd_sdk::prelude::*;` -- the CoW prelude adds only the merged CoW `cow-api` interface and the typed `Cow` client. - -## Typed Local-Store Helpers - -Raw local-store is `string -> list`. The SDK adds a typed layer using serde: - -```rust -use nexum_sdk::prelude::*; -use serde::{Serialize, Deserialize}; - -#[derive(Serialize, Deserialize)] -struct TwapProgress { - last_block: u64, - posted_parts: Vec<[u8; 32]>, -} - -// Read typed value -let progress: Option = TypedState::get("progress")?; - -// Write typed value -TypedState::set("progress", &TwapProgress { - last_block: 19_000_001, - posted_parts: vec![part_hash], -})?; - -// Delete -TypedState::delete("progress")?; - -// List keys by prefix -let keys: Vec = TypedState::list_keys("orders/")?; -``` - -Implementation: - -```rust -pub struct TypedState; - -impl TypedState { - pub fn get(key: &str) -> Result> { - match local_store::get(key)? { - Some(bytes) => Ok(Some(postcard::from_bytes(&bytes)?)), - None => Ok(None), - } - } - - pub fn set(key: &str, value: &T) -> Result<()> { - let bytes = postcard::to_allocvec(value)?; - local_store::set(key, &bytes)?; - Ok(()) - } - - pub fn delete(key: &str) -> Result<()> { - local_store::delete(key)?; - Ok(()) - } - - pub fn list_keys(prefix: &str) -> Result> { - Ok(local_store::list_keys(prefix)?) - } -} -``` - -Serialisation uses **postcard** (compact, no-std, deterministic) rather than JSON to minimise local-store storage overhead. - -## Signer - -The `identity` WIT interface provides cryptographic identity -- key management and signing (ECDSA secp256k1 by default, extensible). The SDK wraps this with a typed `Signer`: - -```rust -use nexum_sdk::prelude::*; - -// Get available signing accounts -let accounts = Signer::accounts()?; -for account in &accounts { - info!("available signer: 0x{}", hex::encode(account)); -} - -// Sign raw bytes with a specific account -let signature = Signer::sign(&accounts[0], &data_to_sign)?; -// signature is 65 bytes: r (32) || s (32) || v (1) - -// Sign EIP-712 typed data -let typed_data_json = r#"{"types":...,"primaryType":"Order","domain":...,"message":...}"#; -let signature = Signer::sign_typed_data(&accounts[0], typed_data_json)?; -``` - -Implementation: - -```rust -/// Typed client for the identity WIT interface. -/// -/// Provides cryptographic signing operations backed by the host engine's -/// key management. The host manages private keys -- modules never see them. -pub struct Signer; - -impl Signer { - /// Get available signing accounts (20-byte Ethereum addresses). - pub fn accounts() -> Result>> { - identity::accounts().map_err(Fault::from) - } - - /// Get available signing accounts as alloy `Address` types. - pub fn addresses() -> Result> { - let accounts = Self::accounts()?; - accounts - .into_iter() - .map(|a| { - Address::try_from(a.as_slice()) - .map_err(|_| Fault::InvalidInput("invalid address length".into())) - }) - .collect() - } - - /// Sign raw bytes with the specified account. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign(account: &[u8], data: &[u8]) -> Result> { - identity::sign(account, data).map_err(Fault::from) - } - - /// Sign EIP-712 typed data with the specified account. - /// `typed_data` is a JSON string conforming to the EIP-712 specification. - /// Returns a 65-byte ECDSA secp256k1 signature (r || s || v). - pub fn sign_typed_data(account: &[u8], typed_data: &str) -> Result> { - identity::sign_typed_data(account, typed_data).map_err(Fault::from) - } -} -``` - -Note: modules can also use `identity` indirectly through `chain`. When a module calls `chain::request` with a signing method (e.g. `eth_sendTransaction`, `eth_accounts`, `eth_signTypedData_v4`, `personal_sign`), the host's `chain` implementation delegates to the `identity` backend internally. `Signer` is for modules that need direct, raw signing operations -- e.g. EIP-712 over an off-chain order payload. - -Modules can match on `Fault::Denied` to distinguish "user rejected" from a transport failure -- see the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder mapping table. - -## Ethereum ABI Helpers & alloy Provider - -Modules frequently need to read chain state and encode/decode Ethereum calldata. The SDK provides a full alloy `Provider` (via `HostTransport` over `chain::request` / `chain::request-batch`) and integrates `alloy-sol-types` and `alloy-primitives` (compiled to WASM): - -```rust -use nexum_sdk::prelude::*; - -// Define the contract interface -sol! { - function getTradeableOrderWithSignature( - address owner, - bytes32 ctx, - bytes32 orderHash - ) external view returns ( - bytes memory order, - bytes memory signature - ); -} - -// Named handler -- provider is injected by the macro -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - // Full alloy Provider API -- natural .await - let block_num = provider.get_block_number().await?; - let balance = provider.get_balance(owner_addr).latest().await?; - - // Typed contract calls with sol! + EthCall builder - let tx = TransactionRequest::default() - .to(contract_addr) - .input(getTradeableOrderWithSignatureCall { - owner: owner_addr, - ctx: ctx_bytes, - orderHash: order_hash, - }.abi_encode().into()); - - let result = provider.call(tx).latest().await?; - let decoded = getTradeableOrderWithSignatureCall::abi_decode_returns(&result)?; - let order_bytes = decoded.order; - Ok(()) -} -``` - -The SDK re-exports: -- `alloy_primitives::{Address, B256, U256, Bytes}` -- core Ethereum types. -- `alloy_sol_types::sol!` -- compile-time ABI codec generation. -- `alloy_provider::Provider` -- the full alloy Provider trait. -- `alloy_rpc_types::*` -- `TransactionRequest`, `Filter`, `Block`, etc. - -These are already WASM-compatible (no-std support, no system dependencies). The `HostTransport` routes all RPC calls through the `chain::request` (and batched `chain::request-batch`) host functions -- see doc 07 for the full design. - -## CoW Protocol API: `Cow` - -The `cow-api` WIT interface (in `shepherd:cow`) exposes a REST passthrough to the CoW Protocol API plus a typed `submit-order` function (the two were separate interfaces, `cow` and `order`, in 0.1). The `shepherd-sdk` wraps this with a typed `Cow` client: - -```rust -use shepherd_sdk::prelude::*; - -let cow = Cow::new(42161); - -// Submit an order via the merged cow-api interface -let uid = cow.submit_order(&OrderCreation { - sell_token: sell_addr, - buy_token: buy_addr, - sell_amount: U256::from(1_000_000), - buy_amount: U256::from(950_000), - kind: OrderKind::Sell, - valid_to: (block.timestamp / 1000) + 300, // block.timestamp is ms in 0.2 - ..Default::default() -})?; - -// Get a quote -let quote = cow.get_quote(&OrderQuoteRequest { ... })?; - -// Get an order by UID -let order = cow.get_order(&uid)?; - -// Raw request for endpoints not yet wrapped -let resp = cow.raw_request("GET", "/api/v1/auction", None)?; -``` - -The `Cow` client handles JSON serialisation and routes requests through the host's `cow-api::request` (REST passthrough) and `cow-api::submit-order` (order submission) functions. - -## Logging Convenience - -Wrappers over the `logging` WIT interface (provided in `nexum-sdk`; CoW modules import them from `nexum-sdk` directly): - -```rust -// nexum_sdk::log -pub fn trace(msg: &str) { logging::log(Level::Trace, msg); } -pub fn debug(msg: &str) { logging::log(Level::Debug, msg); } -pub fn info(msg: &str) { logging::log(Level::Info, msg); } -pub fn warn(msg: &str) { logging::log(Level::Warn, msg); } -pub fn error(msg: &str) { logging::log(Level::Error, msg); } - -/// Format + log in one call -#[macro_export] -macro_rules! info { - ($($arg:tt)*) => { - $crate::log::info(&format!($($arg)*)) - }; -} -``` - -Usage: - -```rust -nexum_sdk::info!("processing block {} on chain {}", block.number, block.chain_id); -``` - -## Error Handling - -In 0.2 each interface declares its own typed error, and they share one payload-bearing `Fault` vocabulary for the cross-domain cases. The SDK exposes `Fault`, the `HostFault` trait (recovers an embedded fault plus a stable snake_case label), and the richer `ChainError`. A `From for Fault` fold lets a strategy aggregating store and chain calls `?`-propagate into one `Fault`. - -```rust -pub enum Fault { - Unsupported(String), - Unavailable(String), - Denied(String), - RateLimited(RateLimit), // { retry_after_ms: Option } - Timeout, - InvalidInput(String), - Internal(String), -} - -/// Recovers the shared fault from a richer, per-interface error, plus a -/// stable snake_case label for logs and metrics. -pub trait HostFault { - fn fault(&self) -> Option<&Fault>; - fn label(&self) -> &'static str; -} - -/// The chain interface embeds `Fault` and adds a structured JSON-RPC case. -pub enum ChainError { - Fault(Fault), - Rpc(RpcError), // { code: i32, message: String, data: Option> } -} - -pub type Result = core::result::Result; -``` - -Interfaces with nothing to add report `Fault` directly (identity, local-store, remote-store, messaging, and the module exports). The module exports return `Result<(), Fault>`; module-defined failures are plain `Fault` cases, and the supervisor supplies the module name and derives its log kind from the fault label. Module authors use `?` naturally, and match on the case (or on `ChainError::Rpc` for a revert) for retry/backoff: - -```rust -// Universal module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // ChainError -> Fault via From - let decoded = MyCall::abi_decode_returns(&data) - .map_err(|e| Fault::InvalidInput(e.to_string()))?; // module-defined - TypedState::set("last", &decoded)?; // store Fault - let sig = Signer::sign(&account, &data)?; // identity Fault - Ok(()) -} - -// Inspecting the case for retry decisions -match host.request(chain_id, "eth_blockNumber", "[]") { - Ok(n) => Ok(n), - Err(ChainError::Fault(Fault::Unavailable(_) | Fault::Timeout)) => retry(), - Err(ChainError::Fault(Fault::RateLimited(rl))) => backoff(rl.retry_after_ms), - Err(ChainError::Rpc(rpc)) => decode_revert(rpc.data), // structured revert - Err(e) => Err(e.into()), -} - -// CoW module -async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let num = provider.get_block_number().await?; // chain error - TypedState::set("last", &num)?; // store Fault - Cow::new(block.chain_id).submit_order(&order)?; // cow-api-error - Ok(()) -} -``` - -See [ADR-0011](adr/0011-per-interface-typed-errors.md) for the model and the [migration guide §2](migration/0.1-to-0.2.md#2-error-model-unification-both) for the embedder-side mapping of backend signals (HTTP codes, transport errors, wallet rejections) to `fault` cases. - -## Testing Framework - -### Universal: `nexum-sdk` Mock Host - -The `nexum-sdk` provides a mock host so modules can be tested without a live blockchain or runtime. +└── src/ + ├── lib.rs # crate docs; no re-export of nexum-sdk + ├── prelude.rs # cowprotocol order/signing/orderbook re-exports + ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost + └── cow/ # CowApiHost trait, gpv2_to_order_data, PollOutcome, decode_revert, + # RetryAction classifiers, run() (poll -> gate/journal/submit) +``` + +`nexum-sdk` is host-neutral and domain-free: any module targeting the +runtime pulls helpers and canonical primitive types from it regardless +of which world it exports. `shepherd-sdk` depends on `nexum-sdk` and +layers the CoW Protocol domain on top; modules that touch the +orderbook import both crates directly (nothing is re-exported between +them). `shepherd-sdk` has not been retired - the clean break described +in the SDK epic (folding its CoW surface into a future `cow-venue` +crate) is deferred to a follow-on train, sequenced after the +venue-adapter persona lands. + +Companion mock crates: `nexum-sdk-test` (in-memory `MockHost` over +`ChainHost` / `LocalStoreHost` / `LoggingHost`) and `shepherd-sdk-test` +(composes those mocks with `MockCowApi`). See +[Testing](#testing-nexum-sdk-test-and-shepherd-sdk-test) below. + +### The host-trait seam + +Neither crate calls `wit_bindgen`-generated functions directly. +Instead `nexum-sdk::host` exposes small traits that mirror the WIT +interfaces: ```rust -use nexum_sdk::testing::{MockHost, MockChain}; - -#[test] -fn test_monitor_processes_block() { - let mut host = MockHost::new(); - - // Set up mock chain state - host.chain(42161) - .block_number(19_000_001) - .mock_call( - "0xfdaFc9d...", // contract address - &get_active_orders_calldata(), // expected calldata - &mock_orders_response(), // return value - ); - - // Pre-populate local-store (simulating previous run) - host.local_store() - .set("last_block", &19_000_000u64.to_le_bytes()); - - // Dispatch a block event - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, // ms since epoch - })); - - assert!(result.is_ok()); - - // Verify local-store was updated - let last = host.local_store().get::("last_block").unwrap(); - assert_eq!(last, Some(19_000_001)); +pub trait ChainHost { + fn request(&self, chain_id: u64, method: &str, params: &str) -> Result; } -``` - -### Identity Mocking - -The `MockHost` supports identity mocking for modules that use signing: - -```rust -use nexum_sdk::testing::{MockHost, MockIdentity}; - -#[test] -fn test_module_signs_data() { - let mut host = MockHost::new(); - - // Configure mock identity with test accounts - host.identity() - .add_account(hex::decode("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").unwrap()) - .on_sign(|account, data| { - // Return a mock 65-byte signature - Ok(vec![0u8; 65]) - }) - .on_sign_typed_data(|account, typed_data| { - // Return a mock 65-byte signature for EIP-712 - Ok(vec![0u8; 65]) - }); - - let result = host.dispatch(Event::Block(Block { - chain_id: 1, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1700000000, - })); - - assert!(result.is_ok()); - - // Verify signing was called - assert_eq!(host.identity().sign_calls().len(), 1); +pub trait LocalStoreHost { + fn get(&self, key: &str) -> Result>, Fault>; + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault>; + fn delete(&self, key: &str) -> Result<(), Fault>; + fn list_keys(&self, prefix: &str) -> Result, Fault>; } -``` - -### CoW Protocol: `shepherd-sdk` Mock Extensions - -The `shepherd-sdk` extends `MockHost` with CoW-specific assertions: - -```rust -use shepherd_sdk::testing::{MockHost, MockCow}; - -#[test] -fn test_twap_monitor_submits_order() { - let mut host = MockHost::new(); - - host.chain(42161).block_number(19_000_001); - - let result = host.dispatch(Event::Block(Block { - chain_id: 42161, - number: 19_000_001, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - - assert!(result.is_ok()); - - // Verify an order was submitted (CoW-specific) - assert_eq!(host.submitted_orders().len(), 1); +pub trait LoggingHost { + fn log(&self, level: Level, message: &str); } -``` - -### MockHost Internals +pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} +impl Host for T {} +``` + +`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`), and +its own supertrait `CowHost: Host + CowApiHost`. Strategy code takes +`&impl Host` (or a narrower `` bound +when it only needs part of the surface) so tests inject +`nexum_sdk_test::MockHost` while the compiled module injects the +wit-bindgen-backed adapter. See [ADR-0009](adr/0009-host-trait-surface.md) +for the full rationale (four traits over one fat trait, the +`strategy.rs` / `lib.rs` split, and the world-neutral `HostError` +predecessor that per-interface typed errors later replaced - see +[ADR-0011](adr/0011-per-interface-typed-errors.md)). + +### The wit-bindgen adapter: `bind_host_via_wit_bindgen!` + +Every module still keeps its own `wit_bindgen::generate!` call (the +macro emits types into the calling crate; re-exporting wit-bindgen +output from a library crate would duplicate symbols and break the +component-export contract). What the SDK removes is the ~80 lines of +mechanical glue that used to sit next to it: the `nexum_sdk::bind_host_via_wit_bindgen!()` +declarative macro emits a `WitBindgenHost` struct, the `ChainHost` / +`LocalStoreHost` / `LoggingHost` impls over the generated import +shims, the `Fault` / `ChainError` converters in both directions, a +`Level` <-> wit-bindgen `logging::Level` converter, a +`From for nexum_sdk::events::Log` impl, and an +`install_tracing()` helper that routes `tracing::info!(...)` through +the bound host logging call. `shepherd-sdk::bind_cow_host_via_wit_bindgen!` +layers the `CowApiHost` impl on top of the same `WitBindgenHost` type. + +### The `#[nexum::module]` macro + +`nexum-macros` ships one attribute macro, re-exported as +`nexum_sdk::module`. Apply it to an inherent `impl` block whose +methods are named event handlers - `init`, `on_block`, +`on_chain_logs`, `on_tick`, `on_message` - and the macro generates the +`wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` +invocation, a `Guest` implementation whose `on_event` dispatches to +whichever handlers are present (absent handlers become a no-op for +that event), and `export!`: ```rust -// nexum-sdk: universal mock host -pub struct MockHost { - local_store: HashMap>, - chains: HashMap, - identity: MockIdentity, - logs: Vec<(Level, String)>, -} - -pub struct MockChain { - block_number: u64, - /// Maps (method, params_json) -> result_json for RPC mocking - rpc_mocks: HashMap<(String, String), String>, - logs: Vec, -} - -pub struct MockIdentity { - accounts: Vec>, - sign_handler: Option Result>>>, - sign_typed_data_handler: Option Result>>>, - sign_calls: Vec<(Vec, Vec)>, - sign_typed_data_calls: Vec<(Vec, String)>, -} - -// shepherd-sdk: extends with CoW-specific fields -pub struct CowMockHost { - inner: MockHost, // universal mock - submitted_orders: Vec<(u64, Vec)>, - cow_requests: Vec<(u64, String, String, Option)>, -} -``` +// modules/examples/http-probe/src/lib.rs (shipped) +mod strategy; -The mock host implements the same trait interface as the real host. Tests run as native Rust (not compiled to WASM) -- the mock substitutes for the WIT imports. +use nexum::host::types; -For alloy `Provider`-based tests, the `nexum-sdk` also provides `MockProvider` (backed by alloy's `Asserter`-based mock transport) -- see doc 07 for details. +struct HttpProbe; -### Integration Testing - -For tests that compile to WASM and run in a real wasmtime instance: - -```rust -use nexum_sdk::testing::WasmTestHarness; - -#[test] -fn test_module_as_component() { - let harness = WasmTestHarness::new("target/wasm32-wasip2/release/twap_monitor.wasm"); - - harness.mock_chain(42161).block_number(100); - harness.call_init(vec![("api_url".into(), "mock".into())]).unwrap(); - - let result = harness.call_on_event(Event::Block(Block { - chain_id: 42161, - number: 100, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - })); - assert!(result.is_ok()); -} -``` - -This tests the full component boundary (canonical ABI marshalling, host function binding). - -## Project Scaffolding - -### `cargo-nexum` CLI - -> **Future direction, not in 0.2 scope.** The `cargo-nexum` cargo subcommand described in this section does not ship in 0.2. Module authors today build with `cargo build --target wasm32-wasip2 --release` (the M5 reference repo includes a `justfile` with the canonical recipes). A `cargo-nexum` (or successor) scaffolding/packaging CLI is on the 0.3 roadmap. -> -> **Two separate tools (design intent):** `cargo-nexum` would be a cargo subcommand for **module authors** (new, build, package, publish). The `nexum` binary is the **operator runtime** (run, module list/restart, local-store purge). Embedders bypass the binary entirely and drive the runtime through the `nexum-runtime` library. - -```bash -cargo nexum new my-module -``` - -Generates: - -``` -my-module/ -├── Cargo.toml -├── module.toml # manifest template -└── src/ - └── lib.rs # minimal module skeleton -``` - -#### Universal module (targeting `nexum:host/event-module`) - -`Cargo.toml`: -```toml -[package] -name = "my-module" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nexum-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: -```rust -use nexum_sdk::prelude::*; - -#[nexum::module] -struct MyModule; - -impl MyModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); +#[nexum_sdk::module] +impl HttpProbe { + fn init(config: Vec<(String, String)>) -> Result<(), Fault> { + install_tracing(); + let cfg = strategy::parse_config(&config).map_err(sdk_fault_into_wit)?; + // ... Ok(()) } - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) + fn on_block(block: types::Block) -> Result<(), Fault> { + strategy::on_block(&nexum_sdk::http::WasiFetch, /* ... */ block.number) + .map_err(sdk_fault_into_wit) } } ``` -#### CoW Protocol module (targeting `shepherd:cow/shepherd`) - -`Cargo.toml`: -```toml -[package] -name = "my-cow-module" -version = "0.1.0" -edition = "2024" +Two things worth being precise about, since they differ from earlier +drafts of this plan: + +- **One macro, not two.** There is no separate `#[shepherd::module]`. + The macro currently always generates against the blanket + `shepherd:cow/shepherd` world with `generate_all` (every module gets + the full CoW-extended import set whether it uses `cow-api` or not). + Emitting a per-component world scoped to the manifest's declared + capabilities - which would retire the import-elision dependency + ADR-0009 flags - is separate follow-on work, tracked alongside the + venue-adapter macro below. +- **Handlers are synchronous.** `init` and the named handlers are + plain `fn`, called directly with no `block_on` wrapper. There is no + `async fn` handler support and no injected `&RootProvider` - modules + call `host.request(chain_id, method, params_json)` (or the + `chain::eth_call_params` / `parse_eth_call_result` helpers) directly + against `ChainHost`, per [doc 07](07-rpc-namespace-design.md). + +The `Guest`/`export!` shape the macro emits still follows the +`strategy.rs` (pure logic, tested against `&impl Host`) / `lib.rs` +(handlers plus the macro attribute) split from ADR-0009. The keeper +helpers in `nexum_sdk::keeper` - `WatchSet`, `Gates`, `Journal`, +`ConditionalSource`, `Retrier` - give conditional-commitment +modules (watchers that poll a set of pending commitments) a shared set +of `LocalStoreHost` conventions instead of hand-rolled key schemes. + +### Testing: `nexum-sdk-test` and `shepherd-sdk-test` -[lib] -crate-type = ["cdylib"] - -[dependencies] -shepherd-sdk = "0.2" - -[package.metadata.component] -package = "my:module" -``` - -`src/lib.rs`: ```rust -use shepherd_sdk::prelude::*; - -#[shepherd::module] -struct MyCowModule; - -impl MyCowModule { - fn init(config: Config) -> Result<()> { - info!("module initialised"); - Ok(()) - } - - async fn on_block(block: Block, provider: &RootProvider) -> Result<()> { - let block_num = provider.get_block_number().await?; - info!("block {} on chain {}", block_num, block.chain_id); - Ok(()) - } - - async fn on_chain_logs(logs: Vec, provider: &RootProvider) -> Result<()> { - info!("received {} logs", logs.len()); - Ok(()) - } - - fn on_tick(tick: Tick) -> Result<()> { - info!("tick fired at {} ms UTC", tick.fired_at); - Ok(()) - } -} -``` - -`module.toml` (same for both): -```toml -[module] -name = "my-module" -version = "0.1.0" -description = "" -authors = [] -component = "sha256:TODO" - -[module.resources] -max_memory_bytes = 10_485_760 -max_fuel_per_event = 100_000 -max_state_bytes = 52_428_800 - -[module.restart] -max_consecutive_failures = 10 - -[chains] -required = [] -optional = [] - -[capabilities] -required = ["chain", "local-store", "logging"] -optional = [] - -[config] -``` - -### Build - -```bash -cargo component build --release -# -> target/wasm32-wasip2/release/my_module.wasm -``` - -### Package - -```bash -cargo nexum package -# Computes sha256, updates module.toml, creates bundle directory -``` - -### Publish to Swarm - -```bash -cargo nexum publish --swarm http://localhost:1633 --batch-id -# Uploads bundle to Swarm, prints content reference -``` - -## SDK / Runtime Version Compatibility - -The WIT definition is versioned (`nexum:host@0.2.0`). The SDK pins this version. When the WIT evolves: - -- **Patch** (0.2.x): backwards-compatible additions (new host functions, new manifest fields, new SDK helpers). Old modules continue to work. -- **Minor** (0.x.0): may add new required exports. Old modules need recompilation. -- **Major** (x.0.0): breaking changes. Runtime supports multiple world versions during transition. - -The `bindgen!` macro on the host side uses wasmtime's **semver-aware resolution** -- a host implementing `@0.2.1` satisfies a guest compiled against `@0.2.0`. - -0.2 is the coordinated breaking-change window relative to 0.1. The 0.2.0 contracts (WIT package name, interface names, the per-interface typed errors over the shared `fault` vocabulary, the `module.toml` schema, the `#[nexum::module]` macro surface) are stable starting at 0.2.0 -- see the [migration guide §10](migration/0.1-to-0.2.md#10-deprecation-policy-going-forward-both) for the full deprecation policy. - -## Summary - -| SDK Layer | Provides | -|-----------|----------| -| `#[nexum::module]` | Eliminates WIT boilerplate; named event handlers (`on_block`, `on_chain_logs`, `on_tick`, `on_message`); `async fn` + provider injection (universal) | -| `#[shepherd::module]` | Same as above, targeting CoW Protocol's `shepherd:cow/shepherd` world | -| `provider(chain_id)` | Full alloy `Provider` backed by host RPC via `HostTransport` (including 0.2's `chain::request-batch` for real wire-level batching) | -| `Signer` | Typed identity client for accounts, signing, and EIP-712 (nexum-sdk) | -| `Cow` | Typed CoW Protocol API client backed by host `cow-api` interface (shepherd-sdk only) | -| `nexum_sdk::prelude::*` | Universal types, interfaces, alloy re-exports in one import | -| `shepherd_sdk::prelude::*` | CoW-specific types and interfaces; the universal surface comes from `nexum_sdk::prelude::*` | -| `TypedState` | Serde-based typed local-store over raw bytes | -| `sol!` | Compile-time Ethereum ABI codec (alloy-sol-types) | -| `log::{info!, ...}` | Formatted logging macros | -| `Fault` / `HostFault` / `ChainError` / `Result` | Per-interface typed errors over the shared `fault` vocabulary, with `?` support and case-based matching | -| `nexum_sdk::testing::MockHost` | Native-Rust unit tests with universal mock host (includes identity mocking) | -| `shepherd_sdk::testing::MockHost` | Extends universal mock with CoW-specific assertions | -| `testing::MockProvider` | alloy `Provider` mock for RPC-level testing | -| `testing::WasmTestHarness` | Integration tests against real wasmtime | -| `cargo nexum` | new / build / package / publish / check / migrate CLI | +use nexum_sdk::host::*; +use nexum_sdk_test::MockHost; + +let host = MockHost::new(); +host.chain.respond_to("eth_blockNumber", "[]", Ok("\"0x1\"".into())); + +assert_eq!(host.request(1, "eth_blockNumber", "[]").unwrap(), "\"0x1\""); +assert_eq!(host.chain.calls().len(), 1); +``` + +`MockHost` composes one mock per trait (`chain`, `store`, `logging` +in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on the +`shepherd:cow/cow-api` seam, backed by `MockCowApi` by default or the +per-call-scriptable `MockVenue` via `MockHost::with_venue()`), each +recording calls and letting tests program responses. Tests run as +plain native Rust against the traits - no `wasm32-wasip2` target, no +wasmtime instance, no network round-trip. This is the whole testing +story today; there is no `WasmTestHarness` or component-level +integration harness in the tree. + +## Venue-adapter persona (planned) + +The venue-adapter persona is the other half of the two-persona plan +and is **not shipped**. It is tracked by a set of open issues under +the SDK-surfaces epic and depends on a venue-adapter WIT world that +does not exist yet either. Nothing below this heading describes code +in this repository; it is recorded here so the module-author persona +above is read in the context of where the SDK is going, not as a +competing vision. + +The planned shape: + +- **`nexum-venue-sdk`** - a new crate carrying the guest-side + `VenueAdapter` trait over the (also planned) adapter-world bindgen, + a `borsh`-backed `IntentBody` derive that enforces a per-venue + version enum (an adapter rejects an intent body tagged with an + unknown version rather than misinterpreting it), and typed wrappers + over the scoped transport imports (`http`, `messaging`, `chain`) an + adapter is granted. +- **Per-venue crates** - e.g. a `cow-venue` crate that would carry + CoW Protocol's intent-body codec and become the eventual home for + the CoW helpers `shepherd-sdk::cow` carries today, once the clean + break happens. +- **`#[nexum::venue]`** - a second attribute macro in `nexum-macros`, + parallel to `#[nexum::module]`: it would emit the per-cdylib export + glue for an adapter and a per-component world matching the + manifest's declared capabilities (retiring the import-elision + dependency for the venue side from day one, rather than as + follow-on work). +- **`nexum-venue-test`** - a conformance kit: published codec + round-trip vectors (so a non-Rust adapter author can prove + byte-exact `IntentBody` encoding without linking Rust), header- + derivation golden fixtures, and a `MockTransport` for adapter unit + tests. + +Once this lands, `shepherd-sdk`'s CoW surface is expected to move into +the `cow-venue` crate as a single clean-break migration - the same +no-deprecation-window reasoning [ADR-0011](adr/0011-per-interface-typed-errors.md) +gives for pre-1.0 wire breaks applies here - and this document should +be revisited to describe the venue-adapter persona as shipped rather +than planned. + +## Non-Rust module and adapter authors + +For **non-Rust** authors (JavaScript, Python, Go, C++), neither SDK is +relevant - they generate bindings directly from the WIT package for +their target world with their language's `wit-bindgen`. The WIT is +the universal contract; both Rust SDKs are an ergonomics layer on top +of it, not a requirement. + +## Where to go next + +- [`sdk.md`](sdk.md) - the day-to-day API reference and rustdoc entry + point for module authors. +- [ADR-0009](adr/0009-host-trait-surface.md) - the host-trait seam + decision this document builds on. +- [ADR-0011](adr/0011-per-interface-typed-errors.md) - the typed + error model (`Fault`, `ChainError`, `CowApiError`) the host traits + return. +- [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - + the 0.1 -> 0.2 SDK rename table. +- [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough + design and why module authors call `host.request` directly rather + than through an injected provider. From c6f36e1b897903b636b6deff18f00f42c3a5af31 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:10:10 +0000 Subject: [PATCH 07/89] fix(sdk): M1 SDK sweep fixes - macro hardening, doc fidelity (#246) * fix(macros): reject typo'd handlers and malformed impls at expansion A method named on_blocks (or any other on_-prefixed typo) previously compiled as an ordinary helper while its event silently no-opped; the only signal was a dead_code warning that vanishes on pub items. Reserve the on_ prefix for the recognised handler set and error on anything else. Also reject trait impls, generic impls, and impls with no recognised handlers - all previously slipped past the self-type guard and failed later with incidental, unhygienic errors (or compiled into a do-nothing module). Document the wit-bindgen direct-dependency and prelude- shadowing invariants inherited from the generated glue. * docs(migration): rewrite SDK section 7 around the shipped surface Section 7 still described the superseded Signer/Messaging/RemoteStore/ provider() vision - APIs that never shipped - and a #[shepherd::module] macro that does not exist, while doc 05 endorsed it as the authoritative 0.1 -> 0.2 SDK table. Replace it with the real story: 0.1 had no SDK crate, 0.2 ships nexum-sdk/shepherd-sdk host traits, typed errors, and the single #[nexum_sdk::module] attribute. Point doc 05's cross-link text at what the section now says, and fix doc 05 details flagged by the sweep: the CowApiHost parenthetical (submit_order, cow_api_request), the crate trees (proptests.rs in both SDK crates), the testing-story claim (nexum-runtime's feature-gated test_utils::TestRuntime is an in-tree component-level harness), and the first #[nexum::module] mention now names the real nexum_sdk path. * docs(sdk): document the #[nexum_sdk::module] attribute in sdk.md sdk.md is doc 05's designated day-to-day reference but still presented the pre-macro bind_host_via_wit_bindgen! flow as the authoring pattern. Add an authoring section covering the attribute and its handler set, mention it in the intro, and include nexum-macros in the rustdoc command. --- crates/nexum-macros/src/lib.rs | 58 ++++++++++++++++++++++++++++++++-- docs/05-sdk-design.md | 28 ++++++++++------ docs/migration/0.1-to-0.2.md | 46 +++++++++++++++++---------- docs/sdk.md | 25 ++++++++++++--- 4 files changed, 122 insertions(+), 35 deletions(-) diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index e5dca6f7..93d0e9a3 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -16,8 +16,10 @@ use quote::quote; use syn::{ImplItem, ItemImpl, Type}; /// The handler names recognised on a `#[module]` impl. Any method not in -/// this set is left untouched on the type; any handler in the set that is -/// absent is treated as a no-op in the generated `on-event` dispatch. +/// this set is left untouched on the type, except that names starting +/// with `on_` are rejected at compile time (a typo'd handler would +/// otherwise silently never fire); any handler in the set that is absent +/// is treated as a no-op in the generated `on-event` dispatch. const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on_message"]; /// Generate the per-cdylib glue for a nexum module. @@ -34,7 +36,12 @@ const HANDLERS: [&str; 5] = ["init", "on_block", "on_chain_logs", "on_tick", "on /// (`Guest`, `Fault`, the `nexum::host::*` modules) lands at the module /// crate root, so the emitted glue and the handler bodies resolve those /// names there; the WIT directory is located by walking up from -/// `CARGO_MANIFEST_DIR`. +/// `CARGO_MANIFEST_DIR`. Two corollaries: the consuming crate must +/// declare `wit-bindgen` as a direct dependency (the emitted +/// `wit_bindgen::generate!` call resolves against the consumer's +/// namespace), and the crate root must not shadow std prelude names +/// such as `Result`, `Vec`, or `Ok` (wit-bindgen's generated `Guest` +/// trait refers to them unqualified). #[proc_macro_attribute] pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { if !attr.is_empty() { @@ -57,6 +64,42 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { .to_compile_error() .into(); } + if let Some((_, trait_path, _)) = &input.trait_ { + return syn::Error::new_spanned( + trait_path, + "#[nexum_sdk::module] must be applied to an inherent impl, not a trait impl", + ) + .to_compile_error() + .into(); + } + if !input.generics.params.is_empty() { + return syn::Error::new_spanned( + &input.generics, + "#[nexum_sdk::module] must be applied to a non-generic impl", + ) + .to_compile_error() + .into(); + } + + // A typo'd handler (`on_blocks`, `on_chainlogs`, ...) would otherwise + // compile as an ordinary helper while its event silently no-ops, so + // reserve the `on_` prefix for the recognised handler set. + for item in &input.items { + if let ImplItem::Fn(f) = item { + let name = f.sig.ident.to_string(); + if name.starts_with("on_") && !HANDLERS.contains(&name.as_str()) { + return syn::Error::new_spanned( + &f.sig.ident, + format!( + "`{name}` is not a recognised #[nexum_sdk::module] handler; expected one \ + of {HANDLERS:?} (rename helpers so they do not start with `on_`)" + ), + ) + .to_compile_error() + .into(); + } + } + } let present: Vec<&str> = input .items @@ -69,6 +112,15 @@ pub fn module(attr: TokenStream, item: TokenStream) -> TokenStream { _ => None, }) .collect(); + if present.is_empty() { + return syn::Error::new_spanned( + self_ty, + "#[nexum_sdk::module] found no recognised handlers on this impl; define at least one \ + of `init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message`", + ) + .to_compile_error() + .into(); + } let has = |name: &str| present.contains(&name); let (nexum_wit, shepherd_wit) = match locate_wit() { diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index b4b784e6..96699f85 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -23,8 +23,9 @@ things from the SDK: `nexum:host/event-module` (or the CoW-extended `shepherd:cow/shepherd` world): react to blocks, chain logs, ticks, or messages; read and write local state; submit orders. This persona is served today by - `nexum-sdk` (+ the `#[nexum::module]` macro) and, for CoW-specific - modules, `shepherd-sdk` on top. + `nexum-sdk` (+ the `#[nexum::module]` macro, spelled + `#[nexum_sdk::module]` in code) and, for CoW-specific modules, + `shepherd-sdk` on top. 2. **Venue adapter author.** Writes an adapter that exposes a trading venue (CoW Protocol, a DEX, a lending market, ...) to modules @@ -61,7 +62,8 @@ nexum-sdk/ ├── config.rs # (key, value) config-table lookups, decimal scaling ├── address.rs # EVM address parsing with typed errors ├── http.rs # Fetch trait seam, WasiFetch, FetchError (wasi:http) - └── tracing.rs # guest tracing facade + panic hook over a LogSink seam + ├── tracing.rs # guest tracing facade + panic hook over a LogSink seam + └── proptests.rs # cfg(test) property tests (not part of the public surface) nexum-macros/ ├── Cargo.toml # proc-macro = true @@ -74,8 +76,9 @@ shepherd-sdk/ ├── lib.rs # crate docs; no re-export of nexum-sdk ├── prelude.rs # cowprotocol order/signing/orderbook re-exports ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost - └── cow/ # CowApiHost trait, gpv2_to_order_data, PollOutcome, decode_revert, - # RetryAction classifiers, run() (poll -> gate/journal/submit) + ├── cow/ # CowApiHost trait, gpv2_to_order_data, PollOutcome, decode_revert, + │ # RetryAction classifiers, run() (poll -> gate/journal/submit) + └── proptests.rs # cfg(test) property tests (not part of the public surface) ``` `nexum-sdk` is host-neutral and domain-free: any module targeting the @@ -116,7 +119,8 @@ pub trait Host: ChainHost + LocalStoreHost + LoggingHost {} impl Host for T {} ``` -`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`), and +`shepherd-sdk` adds a fourth trait, `CowApiHost` (`submit_order`, +`cow_api_request`), and its own supertrait `CowHost: Host + CowApiHost`. Strategy code takes `&impl Host` (or a narrower `` bound when it only needs part of the surface) so tests inject @@ -223,9 +227,13 @@ in `nexum-sdk-test`; `shepherd-sdk-test` adds a `cow_api` field on the per-call-scriptable `MockVenue` via `MockHost::with_venue()`), each recording calls and letting tests program responses. Tests run as plain native Rust against the traits - no `wasm32-wasip2` target, no -wasmtime instance, no network round-trip. This is the whole testing -story today; there is no `WasmTestHarness` or component-level -integration harness in the tree. +wasmtime instance, no network round-trip. This is the whole SDK-side +testing story today. (The runtime crate separately ships a +feature-gated component-level harness - `nexum-runtime`'s +`test_utils::TestRuntime`, behind the `test-utils` feature - that +loads a compiled `.wasm` plus manifest under real wasmtime and +dispatches events to it; that is runtime-internal tooling, not part +of the module-author SDK contract.) ## Venue-adapter persona (planned) @@ -287,7 +295,7 @@ of it, not a requirement. error model (`Fault`, `ChainError`, `CowApiError`) the host traits return. - [Migration guide §7](migration/0.1-to-0.2.md#7-sdk-changes-author) - - the 0.1 -> 0.2 SDK rename table. + what changed in the SDK surface between 0.1 and 0.2. - [doc 07](07-rpc-namespace-design.md) - the `chain` RPC passthrough design and why module authors call `host.request` directly rather than through an injected provider. diff --git a/docs/migration/0.1-to-0.2.md b/docs/migration/0.1-to-0.2.md index 0bbcb844..6daafe1e 100644 --- a/docs/migration/0.1-to-0.2.md +++ b/docs/migration/0.1-to-0.2.md @@ -404,23 +404,35 @@ The Rust API surface is otherwise unchanged in 0.2. The C ABI and `nexum-host` e ### Rust SDK -```diff -- use nexum_sdk::{provider, Identity, MsgClient, RemoteStore}; -+ use nexum_sdk::{provider, Signer, Messaging, RemoteStore}; -``` - -| 0.1 type | 0.2 type | Notes | -|---|---|---| -| `IdentityClient` | `Signer` | Trait renamed to reflect what it does | -| `MsgClient` | `Messaging` | Drops the meaningless `Client` suffix | -| `CowClient` | `Cow` | Same | -| `HostTransport` | (internal) | Now `pub(crate)`; you access it through `provider()` | -| `block_on` (re-export) | (removed from public API) | Hidden behind the `#[nexum::module]` macro | -| `Error` (multiple variants per domain) | `Fault` + `HostFault` / `ChainError` | Per-interface typed errors over the shared vocabulary; see §2 | - -### Proc macro - -`#[nexum::module]` and `#[shepherd::module]` are unchanged in shape. They now generate against `event-module` / `shepherd` worlds. If you targeted `headless-module` explicitly anywhere, rename to `event-module`. +0.1 had no Rust SDK crate: modules called the wit-bindgen-generated +imports directly and hand-wrote the per-cdylib `Guest` / `export!` +glue. 0.2 ships `nexum-sdk` (host-neutral helpers) with +`shepherd-sdk` layering the CoW Protocol surface on top. What that +means for a module you are porting: + +- **Host traits.** Strategy code is written against the + `ChainHost` / `LocalStoreHost` / `LoggingHost` traits (plus + `CowApiHost` in `shepherd-sdk`) instead of the raw wit-bindgen + import shims; the `bind_host_via_wit_bindgen!` / + `bind_cow_host_via_wit_bindgen!` macros generate the adapter at + the cdylib boundary, and the `nexum-sdk-test` / + `shepherd-sdk-test` mocks slot in for native unit tests. +- **Typed errors.** Handlers and host traits use the `Fault` / + `ChainError` / `CowApiError` vocabulary from §2 in place of 0.1's + bare strings. +- **One attribute macro.** `#[nexum_sdk::module]` (a re-export of + `nexum_macros::module`) replaces the hand-written glue: applied to + an inherent `impl` block of named handlers (`init`, `on_block`, + `on_chain_logs`, `on_tick`, `on_message`), it emits the + `wit_bindgen::generate!` call, the host adapter, the `Guest` impl + dispatching to the handlers present, and `export!`. Handlers are + synchronous `fn`s; there is no `async` support, no `block_on`, and + no separate `#[shepherd::module]`. + +Earlier drafts of this section described a richer 0.2 surface +(`provider()`, `Signer`, `Messaging`, `RemoteStore`, a hidden +`HostTransport`); none of that shipped. See +[doc 05](../05-sdk-design.md) for the SDK that exists. ### Non-Rust SDKs diff --git a/docs/sdk.md b/docs/sdk.md index e752c5fa..1af60111 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -2,19 +2,34 @@ `nexum-sdk` is the guest-side library every module consumes: typed primitives, ABI helpers, an effect-trait seam for testing, the -per-module adapter macro, and a `prelude` that keeps boilerplate out -of module crates. `shepherd-sdk` layers the CoW Protocol surface on -top; modules that touch the orderbook depend on both crates and -import each directly (nothing is re-exported between them). +`#[nexum_sdk::module]` attribute macro and per-module adapter macro, +and a `prelude` that keeps boilerplate out of module crates. +`shepherd-sdk` layers the CoW Protocol surface on top; modules that +touch the orderbook depend on both crates and import each directly +(nothing is re-exported between them). This page is the entry point. The full API reference is the rustdoc site under `target/doc/nexum_sdk/` and `target/doc/shepherd_sdk/`, generated by: ```sh -RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk --no-deps --open +RUSTDOCFLAGS="-D warnings -D missing-docs" cargo doc -p shepherd-sdk -p nexum-sdk -p nexum-macros --no-deps --open ``` +## Authoring a module + +Modules are authored with the `#[nexum_sdk::module]` attribute +(re-exported from `nexum-macros`). Apply it to an inherent `impl` +block whose associated functions are the named event handlers - +`init`, `on_block`, `on_chain_logs`, `on_tick`, `on_message` - each +taking its wit-bindgen payload and returning `Result<(), Fault>`. +The macro generates the rest of the per-cdylib glue: the +`wit_bindgen::generate!` call, the `bind_host_via_wit_bindgen!()` +adapter, a `Guest` impl whose `on_event` dispatches to whichever +handlers are present (absent handlers no-op), and `export!`. See +[doc 05](05-sdk-design.md#the-nexummodule-macro) for a worked +example and the `nexum-macros` rustdoc for the fine print. + ## Supported host capabilities The SDK is host-neutral - it does not call wit-bindgen-generated From ae8b46918ecaa6580202d9768c4aa7f788031805 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:14:39 +0000 Subject: [PATCH 08/89] feat(wit): add nexum:value-flow types package v0.1.0 (#247) feat(wit): author the nexum:value-flow types package Add the egress-neutral value-flow vocabulary at 0.1.0: the settlement domain (evm-chain or offchain), the asset variant (native-token, erc20, erc721, erc1155, service, offchain), the service and offchain descriptors, and the big-endian asset-amount. The package is dependency-free so it can outlive any interface built on it. Every identifier is vetted against WIT keywords, in-flight component-model proposals, and the nine binding-target reserved-word lists, preferring a two-word kebab id wherever a single word is a keyword anywhere: native-token over native (a Java modifier), offchain over external (a Dart keyword), value-flow over value (value-imports is circling that word). A wasmtime bindgen smoke, compiled under test in the host crate through a throwaway world, names every generated type and field by its plain Rust spelling so a keyword collision surfaces as an escaped identifier and fails the build rather than a downstream binding. --- crates/nexum-runtime/src/bindings.rs | 51 ++++++++++++++++ wit/nexum-value-flow/types.wit | 87 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 wit/nexum-value-flow/types.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index a48bebca..008ed56b 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -15,3 +15,54 @@ wasmtime::component::bindgen!({ imports: { default: async }, exports: { default: async }, }); + +/// Bindgen smoke for the `nexum:value-flow` types package. The package has +/// no host consumer yet (the intent router that will bind it lands later), +/// so this compiles it under test only, through a throwaway world that +/// imports the interface. Its value is the identifier-hygiene gate: the +/// test names every generated type, variant, and field by its plain Rust +/// spelling, so a WIT id that collided with a Rust keyword would surface as +/// an `r#` escape and fail to compile here rather than in a downstream +/// binding. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package nexum:value-flow-smoke; + world smoke { + import nexum:value-flow/types@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use nexum::value_flow::types::{Asset, AssetAmount, OffchainDesc, ServiceDesc, Settlement}; + + let _ = Settlement::EvmChain(1); + let _ = Settlement::Offchain(String::new()); + + let service = ServiceDesc { + kind: String::new(), + summary: String::new(), + }; + let offchain = OffchainDesc { + domain: String::new(), + summary: String::new(), + }; + + let _ = Asset::NativeToken(Settlement::EvmChain(1)); + let _ = Asset::Erc20((1, Vec::new())); + let _ = Asset::Erc721((1, Vec::new(), Vec::new())); + let _ = Asset::Erc1155((1, Vec::new(), Vec::new())); + let _ = Asset::Service(service); + let asset = Asset::Offchain(offchain); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} diff --git a/wit/nexum-value-flow/types.wit b/wit/nexum-value-flow/types.wit new file mode 100644 index 00000000..e70b4f24 --- /dev/null +++ b/wit/nexum-value-flow/types.wit @@ -0,0 +1,87 @@ +package nexum:value-flow@0.1.0; + +/// The egress-neutral vocabulary for value in motion: where a deal settles, +/// what asset moves, and how much of it. Shared by intent headers, +/// simulation balance diffs, and analyser verdict subjects so that a spend +/// is written the same way in all three places. This is the platform's +/// hardest-freezing contract; the package carries no dependency so it can +/// outlive any interface built on it. +/// +/// Identifier hygiene is a freeze gate. Every identifier below is checked +/// against WIT keywords (including in-flight proposals -- the package is +/// `value-flow`, not `value`, because the component model's value-imports +/// feature is circling that word) and against the reserved words of the +/// nine binding-target languages (Rust, Python, JS, Go, C#, Java, Kotlin, +/// Swift, Dart). A two-word kebab id is preferred wherever a single word is +/// a keyword anywhere: `native-token` not `native` (a Java modifier), +/// `offchain` not `external` (a Dart keyword). WIT parses the rejected +/// spellings today; the cost lands later, as escaped identifiers in the +/// generated bindings for exactly the personas the SDK exists to serve. +interface types { + /// Where a deal comes to rest. A variant from day one: the chain-id + /// plumbing assumes every venue settles on an EVM chain, which the + /// off-chain marketplace target breaks. + variant settlement { + /// Settles on an EVM chain, identified by its chain id. + evm-chain(u64), + /// Settles off-chain: the payload is a jurisdiction or a + /// venue-defined domain. Settlement here is a legal or + /// out-of-band process, not a chain state transition. + offchain(string), + } + + /// A non-token service obligation whose worth the host cannot compute, + /// e.g. Swarm postage: storage capacity for a duration. Policy on a + /// service asset is adapter-attested, not host-verified; the consent + /// surface renders `summary` and must say the host verifies nothing. + record service-desc { + /// Namespaced service kind the venue defines, e.g. `swarm:postage`. + /// Stable enough for policy to match on. + kind: string, + /// Adapter-supplied human-readable description for the consent sheet. + summary: string, + } + + /// A real-world asset whose settlement is a legal process rather than a + /// chain state transition: a deed, a chattel, a registry entry. The host + /// verifies nothing about it. The case name mirrors `settlement.offchain` + /// deliberately: the same concept on two axes -- where the asset lives, + /// and where the deal settles. + record offchain-desc { + /// Jurisdiction or registry domain the asset lives in, e.g. an + /// ISO country code or a venue-defined registry name. + domain: string, + /// Adapter-supplied human-readable description for the consent sheet. + summary: string, + } + + /// A kind of value that can move. The ERC cases carry a bare chain id + /// rather than a `settlement` because an ERC token is inherently EVM; + /// `native-token` wraps `settlement` because a chain's own gas token is + /// the one asset that exists on every settlement domain. Each token + /// tuple carries raw big-endian bytes: a 20-byte address, and for the + /// NFT cases a token id of arbitrary width. + variant asset { + /// The settlement domain's own gas token (ETH, BZZ, ...). + native-token(settlement), + /// An ERC-20 token: (chain id, 20-byte contract address). + erc20(tuple>), + /// An ERC-721 NFT: (chain id, 20-byte contract address, token id). + erc721(tuple, list>), + /// An ERC-1155 token: (chain id, 20-byte contract address, token id). + erc1155(tuple, list>), + /// A non-token service, e.g. storage capacity for a duration. + service(service-desc), + /// A real-world asset settled off-chain. + offchain(offchain-desc), + } + + /// An amount of one asset. `amount` is a big-endian unsigned integer, + /// most-significant byte first, with no fixed width; an empty list is + /// zero. Amounts are never negative -- direction lives in whichever + /// field holds the pair (a header's `gives` vs `wants`), not here. + record asset-amount { + asset: asset, + amount: list, + } +} From 5b0c2486532991cb476d48fdec56d1ba83b711b0 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:21:08 +0000 Subject: [PATCH 09/89] feat(wit): author the nexum:intent package with pool interface (#248) feat(wit): author the nexum:intent package with the pool interface Add the venue-neutral intent ontology at 0.1.0 over the value-flow vocabulary: the intent-header (gives/wants/valid-until/settlement/ authorisation), the auth-scheme variant (eip712, eip1271, presign, offchain-sig, unsigned), the intent-status lifecycle with a structured fail-reason, the opaque receipt alias, and a self-contained venue-error (deliberately not embedding the host fault type, so the package's freeze cadence is not pinned to nexum:host versioning). The pool interface routes submit/status/cancel by a venue string with the body as opaque bytes. submit returns a submit-outcome variant from day one: accepted(receipt), or requires-signing(unsigned-tx) for venues whose settlement is a host-signed on-chain call. The unsigned-tx record only describes the call (chain, to, value, input); the host routes it through the guard's host-signed class and fills gas and fees, so adapters still structurally cannot move value. Identifier hygiene follows the value-flow rule (internal-error rather than internal, a Swift declaration keyword). A wasmtime bindgen smoke, compiled under test in the host crate through a throwaway world that imports the pool interface, names every generated type, case, and field by its plain Rust spelling and pins the three pool function signatures with a dummy host impl. --- crates/nexum-runtime/src/bindings.rs | 98 ++++++++++++++++++++ wit/nexum-intent/pool.wit | 26 ++++++ wit/nexum-intent/types.wit | 132 +++++++++++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 wit/nexum-intent/pool.wit create mode 100644 wit/nexum-intent/types.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 008ed56b..23d8776b 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -66,3 +66,101 @@ mod value_flow_smoke { assert!(amount.amount.is_empty()); } } + +/// Bindgen smoke for the `nexum:intent` package, mirroring the value-flow +/// smoke above: no host consumer exists yet (the pool router lands later), +/// so the package compiles under test only, through a throwaway world that +/// imports the pool interface and, transitively, the types interface and +/// its value-flow dependency. The test names every generated type, case, +/// and field by its plain Rust spelling, and a dummy `pool` host impl pins +/// the three function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod intent_smoke { + wasmtime::component::bindgen!({ + inline: " + package nexum:intent-smoke; + world smoke { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + }); + + use nexum::intent::types::{ + AuthScheme, FailReason, IntentHeader, IntentStatus, SubmitOutcome, UnsignedTx, VenueError, + }; + use nexum::value_flow::types::Settlement; + + struct DummyPool; + + impl nexum::intent::pool::Host for DummyPool { + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + #[test] + fn identifiers_bind_unescaped() { + use nexum::intent::pool::Host; + + let _ = AuthScheme::Eip712; + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Presign; + let _ = AuthScheme::OffchainSig; + let _ = AuthScheme::Unsigned; + + let header = IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Eip712, + }; + assert!(header.gives.is_empty() && header.wants.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Settled(None); + let _ = IntentStatus::Failed(FailReason { + code: String::new(), + detail: String::new(), + }); + let _ = IntentStatus::Expired; + let _ = IntentStatus::Cancelled; + + let tx = UnsignedTx { + chain_id: 1, + to: Vec::new(), + value: Vec::new(), + input: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::InvalidReceipt; + let _ = VenueError::Rejected(String::new()); + let _ = VenueError::Denied(String::new()); + let _ = VenueError::Unsupported(String::new()); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::InternalError(String::new()); + + let mut pool = DummyPool; + assert!(pool.submit(String::new(), Vec::new()).is_err()); + assert!(pool.status(String::new(), Vec::new()).is_err()); + assert!(pool.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/wit/nexum-intent/pool.wit b/wit/nexum-intent/pool.wit new file mode 100644 index 00000000..7655292a --- /dev/null +++ b/wit/nexum-intent/pool.wit @@ -0,0 +1,26 @@ +package nexum:intent@0.1.0; + +/// The strategy-module face of the intent core. The host is a router plus +/// a policy checkpoint: it resolves the venue id to the installed adapter, +/// has the adapter derive the header, runs guard policy on it, and only +/// then forwards the call. Bodies are opaque bytes at this boundary; typing +/// is recovered guest-side by venue SDK crates and host-side by the +/// adapter's header derivation. Bodies carry their own routing: there is no +/// chain parameter, a multichain venue's body schema names the chain and +/// the derived header's settlement field exposes the choice to policy. +interface pool { + use types.{intent-status, receipt, submit-outcome, venue-error}; + + /// Submit an opaque intent body to the named venue. Success is either + /// the venue's receipt or requires-signing: a transaction the host + /// must sign and send before the intent exists. + submit: func(venue: string, body: list) -> result; + + /// Report where a previously submitted intent is in its life. + status: func(venue: string, receipt: receipt) -> result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that settlement can no longer + /// happen: an already in-flight settlement may still win the race. + cancel: func(venue: string, receipt: receipt) -> result<_, venue-error>; +} diff --git a/wit/nexum-intent/types.wit b/wit/nexum-intent/types.wit new file mode 100644 index 00000000..22472e7b --- /dev/null +++ b/wit/nexum-intent/types.wit @@ -0,0 +1,132 @@ +package nexum:intent@0.1.0; + +/// The venue-neutral intent ontology: what a deal gives and wants, how the +/// venue authorizes it, and how its life at the venue is reported. Built on +/// the nexum:value-flow vocabulary so a submission is described the same +/// way to strategy modules, venue adapters, and guard policy. The package +/// deliberately does not depend on nexum:host: embedding the host fault +/// type in venue-error would pin this contract's freeze cadence to host +/// versioning, so venue-error carries its own transport cases. +/// +/// Identifier hygiene follows the value-flow rule: every id is checked +/// against WIT keywords and the reserved words of the nine binding-target +/// languages, preferring a two-word kebab id wherever a single word is a +/// keyword anywhere (`unsigned` not `none`, a Python keyword; +/// `internal-error` not `internal`, a Swift declaration keyword). +interface types { + use nexum:value-flow/types@0.1.0.{asset-amount, settlement}; + + /// How an intent is authorized at its venue. + variant auth-scheme { + /// An EIP-712 typed-data signature by host-held keys. The only + /// scheme that reaches the identity checkpoint at submit time. + eip712, + /// An EIP-1271 contract signature: consent happened on-chain when + /// the commitment was created, so the host signs nothing here. + eip1271, + /// A pre-signed authorization recorded at the settlement contract + /// ahead of submission. + presign, + /// A venue-defined off-chain signature scheme. + offchain-sig, + /// No authorization travels with the body. + unsigned, + } + + /// The adapter-derived description of an intent body: the stable + /// ontology guard policy runs on. Policy has teeth on `gives` (what + /// leaves the user's control); `wants` is display-grade because the + /// host can rarely verify the counterparty's obligation and must not + /// pretend to. + record intent-header { + /// Value leaving the user's control. + gives: list, + /// Value expected in return. Display-grade, not host-verified. + wants: list, + /// Expiry in milliseconds since the Unix epoch, UTC; absent means + /// the venue's own default lifetime applies. + valid-until: option, + /// Where the deal settles. + settlement: settlement, + /// How the venue authorizes the intent. + authorisation: auth-scheme, + } + + /// Venue-scoped stable identifier for a submitted intent (for CoW, + /// the 56-byte order UID). Opaque to the host and to policy. + type receipt = list; + + /// Why an intent failed terminally, as reported by the venue. + record fail-reason { + /// Venue-scoped machine-readable code, stable enough for a + /// module to match on. + code: string, + /// Human-readable detail for logs and the consent surface. + detail: string, + } + + /// Where an intent is in its life at the venue. + variant intent-status { + /// Accepted for processing but not yet live at the venue. + pending, + /// Live at the venue and eligible for settlement. + open, + /// Settled. The payload is venue-defined settlement proof (for an + /// EVM venue, typically the settlement transaction hash). + settled(option>), + /// Terminally failed. + failed(fail-reason), + /// Reached its expiry without settling. + expired, + /// Withdrawn before settlement. + cancelled, + } + + /// An EVM call the host must sign and send for the intent to exist + /// on-chain. The adapter only describes the call: the host routes it + /// through the guard's host-signed class, fills the gas and fee + /// fields, and signs, so adapters still structurally cannot move + /// value. Always a call to existing code; adapters cannot deploy. + record unsigned-tx { + /// Chain the transaction must land on. + chain-id: u64, + /// 20-byte address of the contract to call. + to: list, + /// Native value, big-endian unsigned; an empty list is zero. + value: list, + /// ABI-encoded calldata. + input: list, + } + + /// What a successful submit produced. A variant from day one: an + /// on-chain-settlement venue (an ethflow-style order) has no receipt + /// to give until a transaction is signed, and bolting that on later + /// would break every deployed module. + variant submit-outcome { + /// The venue holds the intent; the receipt is its stable id. + accepted(receipt), + /// Settlement requires a host-signed on-chain transaction first. + requires-signing(unsigned-tx), + } + + /// Failure of a pool or adapter call. + variant venue-error { + /// No installed adapter answers to the venue id. + unknown-venue, + /// Body bytes failed to decode against the venue's published + /// schema: malformed bytes or an unknown outer version. + invalid-body(string), + /// The receipt was not issued by this venue or is malformed. + invalid-receipt, + /// The venue refused the intent under its own rules. + rejected(string), + /// Guard policy refused the egress before it reached the venue. + denied(string), + /// The venue or adapter does not support the operation. + unsupported(string), + /// Transport or venue infrastructure failure; retry later. + unavailable(string), + /// Adapter or router failure that is not the caller's fault. + internal-error(string), + } +} From 39e117d861424326e816afb5b70b602514bd7bb6 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:29:29 +0000 Subject: [PATCH 10/89] feat(runtime): introduce the venue-adapter component kind (#249) * feat(runtime): introduce the venue-adapter component kind Add the second component kind alongside the event-module. A venue adapter speaks one venue's protocol over scoped transport only and exports the intent adapter face. - WIT: a nexum:intent/adapter interface (derive-header, submit, status, cancel) and a nexum:adapter/venue-adapter world that imports scoped chain and messaging, exports init plus the adapter face, and withholds the core-only primitives. - Bindings: a second bindgen path binding VenueAdapter beside EventModule, reusing the shared nexum:host interfaces via with. - Manifest: a module-kind discriminator defaulting to event-module, plus an adapter capability registry that recognises only the scoped transport. - Engine config: an [[adapters]] table carrying path, manifest, a per-adapter HTTP allowlist, and messaging-topic scopes. - Supervisor: adapters instantiate into supervised stores against a dedicated scoped linker, reusing the store, fuel, and restart machinery; the kind discriminator gates the load path. No routing yet. - Messaging: per-store content-topic scope enforced ahead of the deferred backend. * docs(runtime): keep the venue-adapter world token on one line The bindgen module rustdoc split the `nexum:adapter/venue-adapter` code span across a line break after the slash, so rustdoc rendered it with a stray space. Rewrap so the token stays intact. --- crates/nexum-runtime/src/bindings.rs | 30 ++ crates/nexum-runtime/src/builder.rs | 7 +- crates/nexum-runtime/src/engine_config.rs | 73 ++++ .../nexum-runtime/src/host/impls/messaging.rs | 72 +++- crates/nexum-runtime/src/host/state.rs | 6 + .../src/manifest/capabilities.rs | 67 ++++ crates/nexum-runtime/src/manifest/load.rs | 44 +++ crates/nexum-runtime/src/manifest/mod.rs | 2 +- crates/nexum-runtime/src/manifest/types.rs | 23 +- crates/nexum-runtime/src/supervisor.rs | 320 ++++++++++++++++-- crates/nexum-runtime/src/supervisor/tests.rs | 101 ++++++ wit/nexum-adapter/venue-adapter.wit | 30 ++ wit/nexum-intent/adapter.wit | 31 ++ 13 files changed, 774 insertions(+), 32 deletions(-) create mode 100644 wit/nexum-adapter/venue-adapter.wit create mode 100644 wit/nexum-intent/adapter.wit diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 23d8776b..c067249f 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -16,6 +16,36 @@ wasmtime::component::bindgen!({ exports: { default: async }, }); +/// WIT bindings for the second component kind: the +/// `nexum:adapter/venue-adapter` world. An adapter imports only the scoped +/// transport it needs (chain and messaging; outbound HTTP is wasi:http, +/// linked and allowlisted separately as for event-module) and exports the +/// `nexum:intent/adapter` face plus `init`. The shared `nexum:host` +/// interfaces are reused from the `event-module` bindings above via +/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type an +/// adapter sees are the very ones the core host constructs; the intent and +/// value-flow types generate here, their first non-test binding. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/nexum-host", + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": super::nexum::host::types, + "nexum:host/chain": super::nexum::host::chain, + "nexum:host/messaging": super::nexum::host::messaging, + }, + }); +} + +pub use venue_adapter::VenueAdapter; + /// Bindgen smoke for the `nexum:value-flow` types package. The package has /// no host consumer yet (the intent router that will bind it lands later), /// so this compiles it under test only, through a throwaway world that diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 86457759..aaad84e0 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -156,7 +156,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { clocks, ) .await? - } else if !engine_cfg.modules.is_empty() { + } else if !engine_cfg.modules.is_empty() || !engine_cfg.adapters.is_empty() { Supervisor::boot( &engine, &linker, @@ -168,13 +168,14 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { .await? } else { anyhow::bail!( - "no modules to run - set a module source or declare [[modules]] entries \ - in engine.toml" + "no modules to run - set a module source or declare [[modules]] or \ + [[adapters]] entries in engine.toml" ); }; info!( modules = supervisor.module_count(), + adapters = supervisor.adapter_count(), chains = supervisor.block_chains().len(), "supervisor ready" ); diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index b93a0096..cac75d82 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -82,6 +82,14 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, + /// Venue adapters the supervisor should boot alongside the modules. + /// Each entry resolves a `(component.wasm, module.toml)` pair like a + /// module, but the operator scopes its transport here rather than in + /// the adapter's own manifest: the installer of a venue adapter, not + /// the adapter author, decides which hosts and messaging topics it may + /// reach. + #[serde(default)] + pub adapters: Vec, } /// One `[[modules]]` table from `engine.toml`. @@ -98,6 +106,33 @@ pub struct ModuleEntry { pub manifest: Option, } +/// One `[[adapters]]` table from `engine.toml`. +/// +/// `path` and `manifest` mirror [`ModuleEntry`]; `manifest` defaults to a +/// sibling `module.toml`. The two scope fields are the operator's grant of +/// the adapter's transport: `http_allow` is the outbound HTTP host +/// allowlist the adapter's wasi:http gate enforces, and `messaging_topics` +/// scopes the messaging content topics it may publish to. Both default +/// empty; an empty `http_allow` denies every outbound request, and an +/// empty `messaging_topics` leaves messaging unscoped for parity with the +/// module default (the messaging backend itself is deferred). +#[derive(Debug, Deserialize)] +pub struct AdapterEntry { + /// Path to the compiled `.wasm` adapter component. + pub path: std::path::PathBuf, + /// Path to the adapter's `module.toml`. Defaults to `/module.toml`. + #[serde(default)] + pub manifest: Option, + /// Outbound HTTP host allowlist granted to this adapter. Each entry is + /// either an exact hostname or a `*.suffix` wildcard, matched the same + /// way as a module's `[capabilities.http].allow`. + #[serde(default)] + pub http_allow: Vec, + /// Messaging content topics this adapter may reach. + #[serde(default)] + pub messaging_topics: Vec, +} + #[derive(Debug, Deserialize)] pub struct EngineSection { #[serde(default = "default_state_dir")] @@ -795,6 +830,44 @@ window_secs = 0 assert_eq!(poison.window, Duration::from_secs(1)); } + #[test] + fn adapters_parse_with_scoped_transport_grants() { + let cfg: EngineConfig = toml::from_str( + r#" +[[adapters]] +path = "adapters/cow/cow_adapter.wasm" +http_allow = ["api.cow.fi", "*.cow.fi"] +messaging_topics = ["/nexum/1/cow-orders/proto"] + +[[adapters]] +path = "adapters/bare/bare.wasm" +manifest = "adapters/bare/module.toml" +"#, + ) + .expect("adapters parse"); + assert_eq!(cfg.adapters.len(), 2); + let first = &cfg.adapters[0]; + assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert!(first.manifest.is_none(), "manifest defaults to sibling"); + assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + let second = &cfg.adapters[1]; + assert_eq!( + second.manifest.as_deref(), + Some(Path::new("adapters/bare/module.toml")) + ); + assert!( + second.http_allow.is_empty() && second.messaging_topics.is_empty(), + "unset scope grants default empty", + ); + } + + #[test] + fn adapters_default_empty_when_absent() { + let cfg = EngineConfig::default(); + assert!(cfg.adapters.is_empty()); + } + #[test] fn extensions_tables_parse_opaquely() { let cfg: EngineConfig = toml::from_str( diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index f2ff87c2..bd450cb3 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,13 +1,43 @@ -//! `nexum:host/messaging`: deferred to 0.3 (Waku backend). `query` -//! returns an empty result, same posture as `identity::accounts`. +//! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so +//! `publish` reports `unsupported` and `query` returns empty, the same +//! posture as `identity::accounts`. The per-store topic scope is enforced +//! ahead of that stub: a venue adapter carrying a +//! `[[adapters]].messaging_topics` grant may only publish within it, so +//! the egress boundary is live even though delivery is not. use crate::bindings::nexum; use crate::bindings::nexum::host::types::Fault; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; +/// Whether `topic` falls within `scope`. An empty scope is unscoped and +/// admits every topic (the module default); otherwise a topic is admitted +/// when it equals a scope entry or descends from one read as a path prefix +/// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is +/// the `/` path separator, so a grant never leaks into a longer sibling +/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +fn topic_in_scope(topic: &str, scope: &[String]) -> bool { + if scope.is_empty() { + return true; + } + scope.iter().any(|allowed| { + if topic == allowed { + return true; + } + let prefix = allowed.strip_suffix('/').unwrap_or(allowed); + topic + .strip_prefix(prefix) + .is_some_and(|rest| rest.starts_with('/')) + }) +} + impl nexum::host::messaging::Host for HostState { - async fn publish(&mut self, _content_topic: String, _payload: Vec) -> Result<(), Fault> { + async fn publish(&mut self, content_topic: String, _payload: Vec) -> Result<(), Fault> { + if !topic_in_scope(&content_topic, &self.messaging_topics) { + return Err(Fault::Denied(format!( + "content topic {content_topic:?} outside this component's messaging scope" + ))); + } Err(Fault::Unsupported("Waku backend deferred to 0.3".into())) } @@ -21,3 +51,39 @@ impl nexum::host::messaging::Host for HostState { Ok(vec![]) } } + +#[cfg(test)] +mod tests { + use super::topic_in_scope; + + #[test] + fn empty_scope_admits_everything() { + assert!(topic_in_scope("/nexum/1/anything/proto", &[])); + } + + #[test] + fn exact_topic_is_admitted() { + let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); + } + + #[test] + fn prefix_scope_admits_the_family_but_not_a_sibling() { + let scope = vec!["/nexum/1/".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); + // A sibling namespace stays out. + assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + } + + #[test] + fn prefix_boundary_is_a_path_segment_not_a_substring() { + // A scope entry without a trailing slash still bounds on the path + // separator, so it cannot leak into a longer sibling segment. + let scope = vec!["/nexum/1/cow".to_owned()]; + assert!(topic_in_scope("/nexum/1/cow", &scope)); + assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 32723fb8..5dfbadb5 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -27,6 +27,12 @@ pub struct HostState { /// Per-module allowlist gate every wasi:http outgoing request /// passes through. pub http_gate: HttpGate, + /// Messaging content topics this store may publish to. Empty means + /// unscoped (the module default and current messaging posture); a + /// venue adapter carries its `[[adapters]].messaging_topics` grant + /// here, so an out-of-scope publish is refused before it reaches the + /// backend. + pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart /// sequence. Tags every captured log record. The namespace identity /// for storage is baked into `store`'s prefix. diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 6c7126e8..05d6b79a 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -28,6 +28,22 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; +/// The interfaces a `venue-adapter` world links: the scoped transport +/// only. An adapter has no local-store, remote-store, identity, or +/// logging - it moves bytes to and from its venue and nothing else. `http` +/// is not listed here for the same reason it is not in the core set: it +/// gates `wasi:http/*` and is handled by the registry directly. +pub const ADAPTER_CAPABILITIES: &[&str] = &["chain", "messaging"]; + +/// The adapter namespace: the same `nexum:host/` prefix as core but only +/// the scoped-transport interfaces. Validating an adapter manifest against +/// a registry built from this namespace rejects a declaration of any core +/// interface an adapter must not reach (e.g. `local-store`) as unknown. +pub const ADAPTER_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "nexum:host/", + ifaces: ADAPTER_CAPABILITIES, +}; + /// Import prefix of the wasi:http package. Every interface under it /// (outgoing-handler, types, ...) is gated by the single /// [`HTTP_CAPABILITY`] declaration. @@ -58,6 +74,17 @@ impl CapabilityRegistry { } } + /// The registry a venue adapter validates against: only the scoped + /// transport interfaces plus `http`. An adapter manifest that declares + /// a core-only capability (e.g. `local-store`) fails as unknown here, + /// and the adapter linker withholds the same interfaces so the + /// component cannot instantiate against them either. + pub fn adapter() -> Self { + Self { + namespaces: vec![ADAPTER_NAMESPACE], + } + } + /// Add an extension's namespace. pub fn register(&mut self, ns: NamespaceCaps) { self.namespaces.push(ns); @@ -313,4 +340,44 @@ mod tests { let r = registry_with_cow(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } + + #[test] + fn adapter_registry_knows_only_scoped_transport() { + // The scoped transport plus http are known; the core-only + // interfaces an adapter must not reach are not, so a manifest + // declaring them fails validation as unknown. + let r = CapabilityRegistry::adapter(); + assert!(r.is_known("chain")); + assert!(r.is_known("messaging")); + assert!(r.is_known("http")); + assert!(!r.is_known("local-store")); + assert!(!r.is_known("remote-store")); + assert!(!r.is_known("identity")); + assert!(!r.is_known("logging")); + } + + #[test] + fn adapter_registry_maps_transport_imports_but_not_core_only() { + let r = CapabilityRegistry::adapter(); + assert_eq!(r.wit_import_to_cap("nexum:host/chain@0.2.0"), Some("chain")); + assert_eq!( + r.wit_import_to_cap("nexum:host/messaging@0.2.0"), + Some("messaging") + ); + assert_eq!( + r.wit_import_to_cap("wasi:http/outgoing-handler@0.2.12"), + Some("http") + ); + // A core-only interface is not a recognised adapter capability. + assert_eq!(r.wit_import_to_cap("nexum:host/local-store@0.2.0"), None); + } + + #[test] + fn adapter_manifest_declaring_a_core_only_cap_is_unknown() { + // The load path validates declared names against the registry; an + // adapter declaring `local-store` must surface as unknown. + let r = CapabilityRegistry::adapter(); + assert!(!r.is_known("local-store")); + assert!(r.known_names().split(", ").all(|n| n != "local-store")); + } } diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index e0b5efb6..81368568 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -258,6 +258,50 @@ enabled = true assert_eq!(config.get("enabled").map(String::as_str), Some("true")); } + #[test] + fn module_kind_defaults_to_event_module() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "plain" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::EventModule); + } + + #[test] + fn module_kind_parses_venue_adapter() { + use crate::manifest::types::ModuleKind; + let manifest: Manifest = toml::from_str( + r#" +[module] +name = "cow" +kind = "venue-adapter" +"#, + ) + .expect("parse"); + assert_eq!(manifest.module.kind, ModuleKind::VenueAdapter); + } + + #[test] + fn module_kind_rejects_unknown_variant() { + let err = toml::from_str::( + r#" +[module] +name = "bad" +kind = "gadget" +"#, + ) + .expect_err("unknown kind rejected"); + let msg = err.to_string(); + assert!( + msg.contains("venue-adapter") || msg.contains("event-module"), + "error names the valid kinds: {msg}", + ); + } + #[test] fn host_allowed_exact_and_wildcard() { let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; diff --git a/crates/nexum-runtime/src/manifest/mod.rs b/crates/nexum-runtime/src/manifest/mod.rs index 19d283a4..e4c032f1 100644 --- a/crates/nexum-runtime/src/manifest/mod.rs +++ b/crates/nexum-runtime/src/manifest/mod.rs @@ -35,7 +35,7 @@ mod types; pub(crate) use capabilities::enforce_capabilities; pub use capabilities::{CapabilityRegistry, NamespaceCaps}; pub(crate) use load::{fallback_manifest, host_allowed, load}; -pub(crate) use types::{LoadedManifest, Subscription}; +pub(crate) use types::{LoadedManifest, ModuleKind, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 628641d2..4802e0d2 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -73,7 +73,7 @@ pub enum Subscription { /// durable per-subscription cursor and re-opens the log poller /// from just after the last dispatched block, instead of at the /// current head. Delivery is then at-least-once, so the module must - /// tolerate redelivery (the chassis idempotency journal already + /// tolerate redelivery (the keeper idempotency journal already /// dedups it). #[serde(default)] resume: bool, @@ -104,6 +104,27 @@ pub struct ModuleSection { pub version: String, #[serde(default)] pub component: String, + /// Which component kind this manifest describes. Defaults to + /// `event-module` so every existing `module.toml` keeps its meaning; + /// a venue adapter sets `kind = "venue-adapter"`. The supervisor picks + /// the bindgen and the scoped capability set from this discriminator. + #[serde(default)] + pub kind: ModuleKind, +} + +/// The component kind a manifest declares. The runtime carries two: the +/// original event-module over the six core primitives, and the venue +/// adapter over scoped transport only. Defaulting to `event-module` +/// preserves the meaning of every manifest written before adapters +/// existed. +#[derive(Debug, Deserialize, Default, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ModuleKind { + /// Event-driven automation over the six core primitives. + #[default] + EventModule, + /// A single-venue adapter over scoped chain, messaging, and HTTP. + VenueAdapter, } #[derive(Debug, Deserialize, Default)] diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 0b428d6b..79a93968 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -37,8 +37,10 @@ use wasmtime::component::{Component, HasSelf, Linker, ResourceTable}; use wasmtime::{Engine, Store}; use wasmtime_wasi::{HostMonotonicClock, HostWallClock, WasiCtxBuilder}; -use crate::bindings::{Config, EventModule, nexum}; -use crate::engine_config::{EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits}; +use crate::bindings::{Config, EventModule, VenueAdapter, nexum}; +use crate::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, OutboundHttpLimits, +}; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; use crate::host::extension::Extension; use crate::host::http::HttpGate; @@ -48,13 +50,20 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; +use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ModuleKind, Subscription}; /// Owns every loaded module and exposes the dispatch surface the /// event loop needs. Generic over the [`RuntimeTypes`] lattice binding /// the component seam backends. pub struct Supervisor { modules: Vec>, + /// Venue adapters instantiated into supervised stores. They boot + /// through the same store, fuel, and memory machinery as modules but + /// carry no subscriptions: nothing dispatches to them yet. A later + /// change wires the intent router - which reaches an adapter through + /// the same extension seam the `extension.rs` router note describes - + /// and folds them into the restart and poison sweeps. + adapters: Vec>, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -202,6 +211,47 @@ struct LoadedModule { poisoned: bool, } +/// A venue adapter instantiated into a supervised store. It mirrors +/// [`LoadedModule`] so the intent router a later change adds can drive +/// adapters through the same restart, poison, and fuel machinery, but +/// carries no subscriptions: nothing dispatches to an adapter yet. The +/// cached boot inputs (`component`, `init_config`, the scope grants, the +/// resource knobs) are what a re-instantiation needs; they are held now +/// and read once dispatch exists. +#[allow(dead_code)] +struct LoadedAdapter { + name: String, + bindings: VenueAdapter, + store: HostStore, + /// The run this store instantiates; restarts mint a fresh one. + run: RunId, + /// Fuel budget refilled before each adapter call. + fuel_per_event: u64, + /// Memory cap applied to the store on reinstantiation. + memory_limit: usize, + /// Cached for restart: re-instantiating from the compiled component. + component: Component, + /// Cached for restart: the `[config]` passed to the adapter's `init`. + init_config: Config, + /// Operator-granted outbound HTTP allowlist for this adapter. + http_allowlist: Vec, + /// Operator-granted outbound HTTP limits. + http_limits: OutboundHttpLimits, + /// Operator-granted messaging content-topic scopes. + messaging_topics: Vec, + /// `false` once an adapter call traps; folded into the restart sweep + /// when the router lands. `init` failure leaves it `false` at boot. + alive: bool, + /// Consecutive trap-style failures since the last success. + failure_count: u32, + /// Earliest instant a trapped adapter may be retried. + next_attempt: Option, + /// Sliding-window trap timestamps for the poison-pill check. + failure_timestamps: std::collections::VecDeque, + /// Permanent quarantine flag, as for modules. + poisoned: bool, +} + impl Supervisor { /// Compile + instantiate every module declared in /// `engine_cfg.modules`. The wasmtime `Engine` + `Linker` are @@ -230,10 +280,37 @@ impl Supervisor { .with_context(|| format!("load module {}", entry.path.display()))?; modules.push(loaded); } + // Adapters link only their scoped transport, so they instantiate + // against a dedicated linker built from the same core backends. + let adapter_linker = build_adapter_linker::(engine)?; + let adapter_registry = CapabilityRegistry::adapter(); + let mut adapters = Vec::with_capacity(engine_cfg.adapters.len()); + for entry in &engine_cfg.adapters { + let loaded = Self::load_adapter( + engine, + &adapter_linker, + entry, + components, + &engine_cfg.limits, + &adapter_registry, + clocks.as_ref(), + ) + .await + .with_context(|| format!("load adapter {}", entry.path.display()))?; + adapters.push(loaded); + } let alive = modules.iter().filter(|m| m.alive).count(); - info!(loaded = modules.len(), alive, "supervisor up"); + let adapters_alive = adapters.iter().filter(|a| a.alive).count(); + info!( + loaded = modules.len(), + alive, + adapters = adapters.len(), + adapters_alive, + "supervisor up" + ); Ok(Self { modules, + adapters, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -276,6 +353,9 @@ impl Supervisor { .await?; Ok(Self { modules: vec![loaded], + // The single-module override path serves `just run`; adapters + // are configured through `engine.toml` and boot via `boot`. + adapters: Vec::new(), engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -297,6 +377,7 @@ impl Supervisor { run: RunId, http_allowlist: Vec, http_limits: OutboundHttpLimits, + messaging_topics: Vec, memory_limit: usize, fuel: u64, clocks: Option<&WasiClockOverride>, @@ -347,6 +428,7 @@ impl Supervisor { limits, http_ctx: wasmtime_wasi_http::WasiHttpCtx::new(), http_gate: HttpGate::new(namespace, http_allowlist, http_limits), + messaging_topics, run, log_router: router, ext: components.ext.clone(), @@ -368,26 +450,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, ) -> Result> { - // Canonical name is module.toml (ADR-0001). nexum.toml is accepted - // with a deprecation warning during the 0.1→0.2 transition. - let manifest_path = entry.manifest.clone().or_else(|| { - let dir = entry.path.parent()?.to_owned(); - let canonical = dir.join("module.toml"); - if canonical.exists() { - return Some(canonical); - } - let legacy = dir.join("nexum.toml"); - if legacy.exists() { - warn!( - target: "manifest", - path = %legacy.display(), - "nexum.toml is deprecated; rename to module.toml \ - (ADR-0001). Support will be removed in 0.3." - ); - return Some(legacy); - } - None - }); + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { Some(p) if p.exists() => { info!(manifest = %p.display(), "loading module manifest"); @@ -434,6 +497,9 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), + // Event modules are unscoped for messaging; only venue + // adapters carry a topic grant. + Vec::new(), limits_cfg.memory(), limits_cfg.fuel(), clocks, @@ -513,11 +579,162 @@ impl Supervisor { }) } + /// Load one `[[adapters]]` entry: resolve its manifest, verify it + /// declares the venue-adapter kind, enforce the scoped-transport + /// capability set, build a supervised store carrying the operator's + /// HTTP and messaging grants, instantiate the `VenueAdapter` bindings + /// against the adapter linker, and run `init`. Nothing dispatches to + /// the result yet; it boots so the router can later reach it. + async fn load_adapter( + engine: &Engine, + linker: &Linker>, + entry: &AdapterEntry, + components: &Components, + limits_cfg: &ModuleLimits, + registry: &CapabilityRegistry, + clocks: Option<&WasiClockOverride>, + ) -> Result> { + let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); + let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { + Some(p) if p.exists() => { + info!(manifest = %p.display(), "loading adapter manifest"); + manifest::load(p, registry)? + } + _ => { + warn!( + component = %entry.path.display(), + "no module.toml - falling back to anonymous adapter" + ); + manifest::fallback_manifest() + } + }; + + // The manifest kind is the discriminator: an [[adapters]] entry + // whose manifest is (or defaults to) an event-module is a config + // error, caught here before instantiation. A fallback manifest has + // the default event-module kind, so an adapter must ship a + // module.toml that declares the venue-adapter kind explicitly. + let kind = loaded_manifest.manifest.module.kind; + if kind != ModuleKind::VenueAdapter { + return Err(anyhow!( + "adapter {} declares module kind {kind:?}; an [[adapters]] entry requires \ + a module.toml with [module] kind = \"venue-adapter\"", + entry.path.display(), + )); + } + + info!(component = %entry.path.display(), "compiling adapter component"); + let component = Component::from_file(engine, &entry.path) + .map_err(Error::from) + .with_context(|| format!("compile {}", entry.path.display()))?; + + // Enforce the scoped-transport capability set: `registry` is the + // adapter registry, so a declaration of any core-only interface + // fails at manifest load, and an undeclared transport import fails + // here. The linker withholds the same core-only interfaces, so an + // adapter reaching for one also fails to instantiate below. + manifest::enforce_capabilities( + &loaded_manifest, + component.component_type().imports(engine).map(|(n, _)| n), + registry, + ) + .with_context(|| format!("capability violation in {}", entry.path.display()))?; + + let adapter_namespace = if loaded_manifest.manifest.module.name.is_empty() { + "adapter".to_owned() + } else { + loaded_manifest.manifest.module.name.clone() + }; + info!( + adapter = %adapter_namespace, + fuel = limits_cfg.fuel(), + memory_bytes = limits_cfg.memory(), + http_allow = entry.http_allow.len(), + messaging_topics = entry.messaging_topics.len(), + "applied adapter resource limits and transport scope", + ); + + let run = RunId::new(adapter_namespace.clone(), 0); + let mut store = Self::build_store( + engine, + components, + run.clone(), + entry.http_allow.clone(), + limits_cfg.http(), + entry.messaging_topics.clone(), + limits_cfg.memory(), + limits_cfg.fuel(), + clocks, + )?; + let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) + .await + .map_err(Error::from) + .with_context(|| format!("instantiate {}", entry.path.display()))?; + + let config: Config = if loaded_manifest.config.is_empty() { + vec![("name".into(), adapter_namespace.clone())] + } else { + loaded_manifest.config.clone() + }; + let init_succeeded = match bindings + .call_init(&mut store, &config) + .await + .map_err(Error::from)? + { + Ok(()) => { + info!(adapter = %adapter_namespace, "adapter init succeeded"); + true + } + Err(e) => { + warn!( + adapter = %adapter_namespace, + kind = crate::host::error::fault_label(&e), + message = crate::host::error::fault_message(&e), + "adapter init failed - loaded but marked dead", + ); + false + } + }; + // Refuel after init so the first routed call starts with a full budget. + store.set_fuel(limits_cfg.fuel())?; + + Ok(LoadedAdapter { + name: adapter_namespace, + bindings, + store, + run, + fuel_per_event: limits_cfg.fuel(), + memory_limit: limits_cfg.memory(), + component, + init_config: config, + http_allowlist: entry.http_allow.clone(), + http_limits: limits_cfg.http(), + messaging_topics: entry.messaging_topics.clone(), + alive: init_succeeded, + failure_count: 0, + next_attempt: None, + failure_timestamps: std::collections::VecDeque::new(), + poisoned: false, + }) + } + /// Number of modules currently loaded. pub fn module_count(&self) -> usize { self.modules.len() } + /// Number of venue adapters instantiated under the supervisor. + pub fn adapter_count(&self) -> usize { + self.adapters.len() + } + + /// Number of adapters whose `init` succeeded and that are eligible for + /// routing once dispatch lands. + #[cfg_attr(not(test), allow(dead_code))] + pub fn adapter_alive_count(&self) -> usize { + self.adapters.iter().filter(|a| a.alive).count() + } + /// Chains any module asked for block events on. The caller opens /// one shared block subscription per chain and routes through /// `dispatch_block`. Sorted by numeric id and deduped (`Chain` is @@ -633,6 +850,7 @@ impl Supervisor { run.clone(), module.http_allowlist.clone(), module.http_limits, + Vec::new(), module.memory_limit, module.fuel_per_event, clocks.as_ref(), @@ -1018,6 +1236,60 @@ pub fn build_linker( Ok(linker) } +/// Build a `Linker` for the `venue-adapter` world: only the scoped +/// transport an adapter may reach - `chain`, `messaging`, and the +/// allowlisted `wasi:http` - plus the ambient WASI base. The core +/// `nexum:host` interfaces an adapter must not touch (local-store, +/// remote-store, identity, logging) are deliberately withheld, so an +/// adapter that imports one of them fails to instantiate rather than +/// silently gaining reach. Extensions are not linked into adapters: an +/// adapter speaks its venue's protocol over the standard transport, not a +/// domain extension surface. +pub fn build_adapter_linker( + engine: &Engine, +) -> anyhow::Result>> { + let mut linker = Linker::>::new(engine); + nexum::host::chain::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; + nexum::host::messaging::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + Ok(linker) +} + +/// Resolve a component's manifest path: the explicit `manifest` override +/// wins, else a sibling `module.toml`, else the deprecated `nexum.toml` +/// with a rename warning. `None` when neither sibling exists. Shared by the +/// module and adapter load paths. +fn resolve_manifest_path(component: &Path, explicit: Option<&Path>) -> Option { + if let Some(path) = explicit { + return Some(path.to_path_buf()); + } + // Canonical name is module.toml (ADR-0001). nexum.toml is accepted + // with a deprecation warning during the 0.1->0.2 transition. + let dir = component.parent()?.to_owned(); + let canonical = dir.join("module.toml"); + if canonical.exists() { + return Some(canonical); + } + let legacy = dir.join("nexum.toml"); + if legacy.exists() { + warn!( + target: "manifest", + path = %legacy.display(), + "nexum.toml is deprecated; rename to module.toml \ + (ADR-0001). Support will be removed in 0.3." + ); + return Some(legacy); + } + None +} + /// Assemble the capability registry from the core namespace plus every /// extension's namespace. The result must agree with the linker built from /// the same `extensions`: enforcement recognises an extension import as a diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 5568d259..666c074a 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -768,6 +768,7 @@ chain_id = 1 manifest: Some(example_manifest.clone()), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1312,6 +1313,7 @@ chain_id = 100 manifest: Some(chain_b_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1417,6 +1419,7 @@ chain_id = 100 manifest: Some(example_manifest), }, ], + adapters: Vec::new(), }; let mut supervisor = Supervisor::boot( @@ -1634,3 +1637,101 @@ fn chainlog_cursor_key_differs_by_each_input() { "address presence changes the key", ); } + +// ── venue-adapter boot ──────────────────────────────────────────────── + +/// The venue-adapter linker binds only the scoped transport (chain, +/// messaging, wasi base, allowlisted http) and withholds the core-only +/// interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn adapter_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + crate::supervisor::build_adapter_linker::(&engine) + .expect("adapter linker assembles"); +} + +/// The module-kind discriminator gates the adapter load path: an +/// `[[adapters]]` entry whose manifest is (or defaults to) an event-module +/// is rejected before instantiation with a message naming the required +/// kind. +#[tokio::test] +async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("cow.wasm"), + manifest: Some(manifest), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("venue-adapter"), + "the kind gate names the required kind: {msg}", + ); +} + +/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// compile step and fails only because the referenced wasm is absent. This +/// proves the discriminator routed the entry to the adapter load path +/// rather than rejecting it on kind. +#[tokio::test] +async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { + let engine = make_wasmtime_engine(); + let components = crate::test_utils::mock_components(); + let linker = crate::supervisor::build_linker::(&engine, &[]) + .expect("build_linker"); + + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + [capabilities]\nrequired = [\"chain\"]\n", + ) + .expect("write manifest"); + + let config = EngineConfig { + adapters: vec![crate::engine_config::AdapterEntry { + path: dir.path().join("missing-cow.wasm"), + manifest: Some(manifest), + http_allow: vec!["api.cow.fi".into()], + messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + }], + ..Default::default() + }; + + let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { + Ok(_) => panic!("absent adapter wasm must fail the compile step"), + Err(err) => err, + }; + let msg = format!("{err:#}"); + assert!( + msg.contains("compile") || msg.contains("missing-cow"), + "boot reached the compile step past the kind gate: {msg}", + ); + assert!( + !msg.contains("requires a module.toml"), + "the kind gate passed rather than rejecting: {msg}", + ); +} diff --git a/wit/nexum-adapter/venue-adapter.wit b/wit/nexum-adapter/venue-adapter.wit new file mode 100644 index 00000000..9656bc47 --- /dev/null +++ b/wit/nexum-adapter/venue-adapter.wit @@ -0,0 +1,30 @@ +package nexum:adapter@0.1.0; + +/// A venue adapter: the second component kind. Where an event-module +/// automates strategy over the six core primitives, a venue adapter +/// speaks one venue's protocol and nothing else. It imports only the +/// scoped transport it needs to reach its venue - chain RPC and +/// messaging - and exports the intent adapter face. It has no +/// local-store, remote-store, identity, or logging import: an adapter +/// structurally cannot touch host key material or persistent state, only +/// move bytes to and from its venue. The host links exactly these +/// imports into an adapter store, so an adapter that reaches for anything +/// else fails to instantiate. +world venue-adapter { + use nexum:host/types@0.2.0.{config, fault}; + + // Scoped transport. Outbound HTTP is wasi:http, linked separately and + // gated per-adapter by the `[[adapters]].http_allow` allowlist in + // engine.toml, the same way event-module treats outbound HTTP; the + // per-adapter `[[adapters]].messaging_topics` scopes the messaging + // content topics an adapter may reach. Time and randomness are + // ambient wasi:clocks / wasi:random. + import nexum:host/chain@0.2.0; + import nexum:host/messaging@0.2.0; + + /// Configure the adapter from its `[config]` before any submission. + /// Mirrors the event-module `init` so the supervisor boots both kinds + /// through the same store, fuel, and restart machinery. + export init: func(config: config) -> result<_, fault>; + export nexum:intent/adapter@0.1.0; +} diff --git a/wit/nexum-intent/adapter.wit b/wit/nexum-intent/adapter.wit new file mode 100644 index 00000000..1c0a28ce --- /dev/null +++ b/wit/nexum-intent/adapter.wit @@ -0,0 +1,31 @@ +package nexum:intent@0.1.0; + +/// The venue face of the intent core, the mirror of the strategy-facing +/// `pool` interface. One installed adapter answers for exactly one venue, +/// so none of these functions carries a `venue` argument: the router +/// resolves a venue id to its adapter and calls the adapter directly. +/// Bodies stay opaque bytes at this boundary; the adapter recovers typing +/// against its own venue schema. The package depends only on its own +/// types, never on `nexum:host`, so the adapter contract's freeze cadence +/// stays independent of host versioning. +interface adapter { + use types.{intent-header, intent-status, receipt, submit-outcome, venue-error}; + + /// Project an opaque intent body onto the stable header guard policy + /// runs on. A pure derivation: no transport, no side effects, so the + /// host can derive and inspect a header before deciding to submit. + derive-header: func(body: list) -> result; + + /// Submit an opaque intent body to this adapter's venue. Success is + /// either the venue's receipt or requires-signing: a transaction the + /// host must sign and send before the intent exists. + submit: func(body: list) -> result; + + /// Report where a previously submitted intent is in its life. + status: func(receipt: receipt) -> result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that settlement can no longer + /// happen: an already in-flight settlement may still win the race. + cancel: func(receipt: receipt) -> result<_, venue-error>; +} From c3ef5474c793f23752ea948bc13fd23417c2d65e Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:34:58 +0000 Subject: [PATCH 11/89] feat(runtime): route nexum:intent/pool to installed venue adapters (#250) Implement the strategy-facing pool import as a host binding linked into every module linker, dispatching to a shared router that owns the installed adapters. - Bindings: a pool-host bindgen world importing nexum:intent/pool, reusing the intent and value-flow types from the venue-adapter bindings via `with`, so the outcome and error the router hands back to a module are the very types an adapter's submit produced. - Router: resolve a venue id to its adapter, serialise invocation per adapter behind an async mutex (the wasmtime Store is not Sync), sequence derive-header, a no-op guard interposition seam, then submit. Status and cancel pass through without the header, guard, or quota. - Quota: a per-caller sliding-window submission budget, with adapter decode failures charged to the caller so a module feeding garbage bodies exhausts its own budget rather than the adapter's fuel. - Supervisor: adapters instantiate first and install into the router; modules then boot carrying the shared handle, rebuilt identically on restart. An init-failed adapter is loaded but not routable. - Manifest: register nexum:intent/pool as a declarable module capability. - Config: a [limits.quota] section resolving the per-caller budget. --- crates/nexum-runtime/src/bindings.rs | 35 + crates/nexum-runtime/src/engine_config.rs | 34 + crates/nexum-runtime/src/host/impls/mod.rs | 1 + crates/nexum-runtime/src/host/impls/pool.rs | 30 + crates/nexum-runtime/src/host/mod.rs | 1 + crates/nexum-runtime/src/host/pool_router.rs | 765 ++++++++++++++++++ crates/nexum-runtime/src/host/state.rs | 5 + .../src/manifest/capabilities.rs | 27 +- crates/nexum-runtime/src/supervisor.rs | 191 ++--- 9 files changed, 997 insertions(+), 92 deletions(-) create mode 100644 crates/nexum-runtime/src/host/impls/pool.rs create mode 100644 crates/nexum-runtime/src/host/pool_router.rs diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index c067249f..79c0729a 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -46,6 +46,41 @@ mod venue_adapter { pub use venue_adapter::VenueAdapter; +/// The strategy-facing `nexum:intent/pool` import bound host-side. The pool +/// world imports the interface a module calls; the intent and value-flow +/// types it uses are reused from the `venue_adapter` bindings above via +/// `with`, so the `SubmitOutcome` and `VenueError` the router hands back to a +/// module are the very ones an adapter's `submit` produced - no lift between +/// two structurally identical copies. Async, because the `Host` impl awaits +/// the per-adapter mutex and the adapter's own async guest calls. +mod pool_host { + wasmtime::component::bindgen!({ + inline: " + package nexum:pool-host; + world pool-host { + import nexum:intent/pool@0.1.0; + } + ", + path: ["../../wit/nexum-value-flow", "../../wit/nexum-intent"], + imports: { default: async }, + with: { + "nexum:value-flow/types": super::venue_adapter::nexum::value_flow::types, + "nexum:intent/types": super::venue_adapter::nexum::intent::types, + }, + }); +} + +/// The host-bound pool interface: the `Host` trait the router implements and +/// the `add_to_linker` the module linker calls. +pub use pool_host::nexum::intent::pool; +/// The shared intent ontology, re-exported at the plain spellings the router +/// and the `pool::Host` impl name. +pub use venue_adapter::nexum::intent::types::{ + AuthScheme, IntentHeader, IntentStatus, SubmitOutcome, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::nexum::value_flow::types as value_flow; + /// Bindgen smoke for the `nexum:value-flow` types package. The package has /// no host consumer yet (the intent router that will bind it lands later), /// so this compiles it under test only, through a throwaway world that diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index cac75d82..55a2ba80 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,6 +26,7 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; +use crate::host::pool_router::{DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, PoolQuota}; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; /// Errors surfaced by [`load_or_default`]. @@ -309,6 +310,9 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, + /// Per-caller intent submission quota. + #[serde(default)] + pub quota: QuotaLimitsSection, } impl ModuleLimits { @@ -386,6 +390,20 @@ impl ModuleLimits { .unwrap_or(POISON_WINDOW), ) } + + /// Resolved per-caller submission quota (overrides or defaults). A zero + /// `max_charges` is saturated up to 1 by the router builder, so a + /// misconfigured budget still admits one submission rather than bricking + /// every venue. + pub fn quota(&self) -> PoolQuota { + PoolQuota::new( + self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), + self.quota + .window_secs + .map(|s| Duration::from_secs(s.max(1))) + .unwrap_or(DEFAULT_QUOTA_WINDOW), + ) + } } /// `[limits.http]` outbound wasi:http limits. Every field is optional; @@ -459,6 +477,22 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } +/// `[limits.quota]` per-caller intent submission budget. Both optional; +/// omitted values resolve to the router defaults via [`ModuleLimits::quota`]. +/// +/// A caller (a strategy module, keyed by its namespace) may accrue at most +/// `max_charges` submissions within a sliding `window_secs`; a decode failure +/// charged back to the caller counts the same, so a module feeding garbage +/// bodies exhausts its own budget rather than the adapter's fuel. +#[derive(Debug, Default, Deserialize)] +pub struct QuotaLimitsSection { + /// Maximum submissions (plus charged decode failures) per caller in the + /// window. + pub max_charges: Option, + /// Sliding window the charges are counted across, in seconds. + pub window_secs: Option, +} + /// Resolved log retention limits the in-memory store enforces. Built by /// [`ModuleLimits::logs`]. #[derive(Debug, Clone, Copy)] diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 2247ed60..a5b80c14 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -11,5 +11,6 @@ mod identity; mod local_store; mod logging; mod messaging; +mod pool; mod remote_store; mod types; diff --git a/crates/nexum-runtime/src/host/impls/pool.rs b/crates/nexum-runtime/src/host/impls/pool.rs new file mode 100644 index 00000000..d02e29e5 --- /dev/null +++ b/crates/nexum-runtime/src/host/impls/pool.rs @@ -0,0 +1,30 @@ +//! `nexum:intent/pool`: the strategy-facing intent import. Every method is a +//! thin delegation to the shared [`PoolRouter`](crate::host::pool_router) +//! carried in the store; the router owns the venue resolution, per-adapter +//! serialisation, guard seam, and quota. The caller identity the router meters +//! against is this store's module namespace. + +use crate::bindings::pool::Host; +use crate::bindings::{IntentStatus, SubmitOutcome, VenueError}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; + +impl Host for HostState { + async fn submit(&mut self, venue: String, body: Vec) -> Result { + self.pool_router + .submit(&self.run.module, &venue, body) + .await + } + + async fn status( + &mut self, + venue: String, + receipt: Vec, + ) -> Result { + self.pool_router.status(&venue, receipt).await + } + + async fn cancel(&mut self, venue: String, receipt: Vec) -> Result<(), VenueError> { + self.pool_router.cancel(&venue, receipt).await + } +} diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index 66e4121e..5c41e467 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -31,5 +31,6 @@ pub mod http; mod impls; pub mod local_store_redb; pub mod logs; +pub mod pool_router; pub mod provider_pool; pub mod state; diff --git a/crates/nexum-runtime/src/host/pool_router.rs b/crates/nexum-runtime/src/host/pool_router.rs new file mode 100644 index 00000000..0c620d37 --- /dev/null +++ b/crates/nexum-runtime/src/host/pool_router.rs @@ -0,0 +1,765 @@ +//! The intent pool router: the strategy-facing `nexum:intent/pool` import +//! resolved to installed venue adapters. +//! +//! A module's `pool::submit(venue, body)` reaches the host here. The router +//! resolves the venue id to the one installed adapter that answers for it, +//! then drives a fixed sequence against that adapter: derive the header, +//! run the guard interposition seam on it, and only then submit. Status and +//! cancel are pass-throughs; they are not submissions, so they skip the +//! header, the guard, and the quota. +//! +//! Invocation is serialised per adapter. A wasmtime `Store` is not `Sync`, +//! so each adapter sits behind its own async mutex: concurrent pool calls to +//! the same venue queue on that mutex, while calls to different venues run +//! in parallel. The lock is held across the guest await, which is the whole +//! point - it is the actor boundary that keeps one adapter store +//! single-threaded. +//! +//! Fuel cannot cross stores, so a module that spams undecodable bodies would +//! otherwise burn an adapter's budget for free. Two mechanisms close that: +//! a per-caller submission quota gates every submit before the adapter is +//! touched, and a decode failure (the adapter's `invalid-body`) is charged +//! to the calling module's quota, so a caller feeding garbage exhausts its +//! own budget rather than the adapter's. + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures::future::BoxFuture; +use tokio::sync::Mutex as AsyncMutex; +use tracing::warn; +use wasmtime::Store; + +use crate::bindings::{IntentHeader, IntentStatus, SubmitOutcome, VenueAdapter, VenueError}; +use crate::host::component::RuntimeTypes; +use crate::host::state::HostState; + +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); + +/// Per-caller submission quota. Both a forwarded submission and a charged +/// decode failure consume one unit; the window slides so a caller's budget +/// refills as old charges age out. +#[derive(Debug, Clone, Copy)] +pub struct PoolQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl PoolQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for PoolQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// The guard interposition seam. The router runs this on the adapter-derived +/// header after `derive-header` and before `submit`. The shipped policy is a +/// no-op that allows every egress; the egress-guard epic replaces the +/// installed policy with the real facts-plus-analysers pipeline without the +/// router changing shape. +pub trait GuardPolicy: Send + Sync { + /// Decide whether the derived header may proceed to the adapter's submit. + fn check(&self, ctx: &GuardContext<'_>) -> GuardVerdict; +} + +/// What the guard sees: who is submitting, to which venue, and the header the +/// adapter derived from the opaque body. The header is the stable ontology +/// policy has teeth on; the raw body never reaches the guard. +pub struct GuardContext<'a> { + /// Namespace of the calling module. + pub caller: &'a str, + /// Venue id the submission is routed to. + pub venue: &'a str, + /// Adapter-derived header for the body. + pub header: &'a IntentHeader, +} + +/// The guard's decision on one egress. +pub enum GuardVerdict { + /// Forward the submission to the adapter. + Allow, + /// Refuse the egress with an operator-facing reason. + Deny(String), +} + +/// The shipped no-op policy: allow every egress. Named so the composition +/// root reads plainly and the egress-guard epic has an obvious thing to swap. +pub struct AllowAllGuard; + +impl GuardPolicy for AllowAllGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Allow + } +} + +/// The per-adapter invocation seam. One installed adapter answers for exactly +/// one venue; the router owns the adapter's `Store` behind an async mutex and +/// reaches it only through this trait, so the router's sequencing and quota +/// logic is testable against a stub that never spins up a wasmtime store. +/// +/// The futures are boxed so the router can hold heterogeneous adapters behind +/// one `dyn` slot without the whole router turning generic over an adapter +/// type it never names. +pub trait VenueInvoker: Send { + /// Project the opaque body onto the stable header the guard runs on. + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result>; + + /// Submit the opaque body to this adapter's venue. + fn submit<'a>(&'a mut self, body: &'a [u8]) + -> BoxFuture<'a, Result>; + + /// Report where a previously submitted intent is in its life. The receipt + /// is owned: it is used once, unlike the body a submission re-decodes. + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result>; + + /// Ask the venue to withdraw an intent. + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>>; +} + +/// The live adapter: a supervised wasmtime `Store` plus the `venue-adapter` +/// bindings, refuelled before each guest call. A trap is projected onto +/// `internal-error` rather than propagated: a misbehaving adapter must not be +/// the caller's fault, and it must not unwind through the router into the +/// calling module's store. +pub struct AdapterActor { + store: Store>, + bindings: VenueAdapter, + fuel_per_call: u64, +} + +impl AdapterActor { + /// Wrap an instantiated adapter store for routing. + pub fn new(store: Store>, bindings: VenueAdapter, fuel_per_call: u64) -> Self { + Self { + store, + bindings, + fuel_per_call, + } + } + + /// Refuel the store before a guest call so each invocation starts from a + /// full budget, mirroring the supervisor's per-event refuel. + fn refuel(&mut self) -> Result<(), VenueError> { + self.store + .set_fuel(self.fuel_per_call) + .map_err(|e| VenueError::InternalError(format!("adapter refuel failed: {e}"))) + } +} + +/// Project a wasmtime trap into the venue-error space. The root cause is +/// carried so an operator sees why the adapter died without the wasm frame +/// list leaking to the calling module. +fn trap_to_venue_error(trap: wasmtime::Error) -> VenueError { + VenueError::InternalError(format!("adapter trapped: {}", trap.root_cause())) +} + +impl VenueInvoker for AdapterActor { + fn derive_header<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_derive_header(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn submit<'a>( + &'a mut self, + body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_submit(&mut self.store, body) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn status(&mut self, receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_status(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } + + fn cancel(&mut self, receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.refuel()?; + match self + .bindings + .nexum_intent_adapter() + .call_cancel(&mut self.store, &receipt) + .await + { + Ok(res) => res, + Err(trap) => Err(trap_to_venue_error(trap)), + } + }) + } +} + +/// One installed adapter behind its serialising mutex. +type AdapterSlot = Arc>; + +/// Per-caller charge history, pruned to the quota window on each touch. +#[derive(Default)] +struct QuotaLedger { + per_caller: HashMap>, +} + +/// The shared router state. Cloning a [`PoolRouter`] is an `Arc` bump; every +/// module store carries the same handle, so a submission from any module +/// reaches the same adapters and the same quota ledger. +struct PoolRouterInner { + adapters: HashMap, + guard: Arc, + quota: PoolQuota, + ledger: Mutex, +} + +/// The strategy-facing pool router, cheap to clone and shared across every +/// module store. +#[derive(Clone)] +pub struct PoolRouter { + inner: Arc, +} + +impl PoolRouter { + /// An empty router: no adapters, the no-op guard, the default quota. This + /// is what an adapter store (which cannot call pool) and the single-module + /// `just run` path carry. + pub fn empty() -> Self { + PoolRouterBuilder::new(PoolQuota::default()).build() + } + + /// Resolve a venue id to its installed adapter slot. + fn resolve(&self, venue: &str) -> Result { + self.inner + .adapters + .get(venue) + .cloned() + .ok_or(VenueError::UnknownVenue) + } + + /// Whether `caller` has budget left in the current window. Read-only: it + /// prunes aged charges but does not record one. + fn quota_admits(&self, caller: &str) -> bool { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + (history.len() as u32) < self.inner.quota.max_charges + } + + /// Record one charge against `caller`'s budget. + fn charge(&self, caller: &str) { + let mut ledger = self.inner.ledger.lock().expect("quota ledger poisoned"); + let history = ledger.per_caller.entry(caller.to_owned()).or_default(); + prune(history, self.inner.quota.window); + history.push_back(Instant::now()); + } + + /// Submit an opaque body to `venue` on behalf of `caller`: resolve the + /// adapter, gate on the caller's quota, derive the header, run the guard + /// seam, then forward to the adapter. A decode failure is charged to the + /// caller before returning, so a caller feeding garbage exhausts its own + /// budget and is stopped at the gate on the next call rather than + /// re-invoking the adapter. + pub async fn submit( + &self, + caller: &str, + venue: &str, + body: Vec, + ) -> Result { + let slot = self.resolve(venue)?; + // Gate before touching the adapter so a quota-exhausted caller never + // reaches the adapter store or its mutex. + if !self.quota_admits(caller) { + return Err(VenueError::Denied(format!( + "submission quota exhausted for caller {caller}" + ))); + } + let mut adapter = slot.lock().await; + let header = match adapter.derive_header(&body).await { + Ok(header) => header, + Err(e) => { + // Charge decode failures to the caller before the adapter is + // invoked again; other venue errors are not the caller's fault. + if matches!(e, VenueError::InvalidBody(_)) { + self.charge(caller); + } + return Err(e); + } + }; + let ctx = GuardContext { + caller, + venue, + header: &header, + }; + if let GuardVerdict::Deny(reason) = self.inner.guard.check(&ctx) { + return Err(VenueError::Denied(reason)); + } + // A forwarded submission consumes one unit of the caller's budget. + self.charge(caller); + adapter.submit(&body).await + } + + /// Report where a previously submitted intent is in its life. Not a + /// submission: no header, no guard, no quota, just the serialised call. + pub async fn status(&self, venue: &str, receipt: Vec) -> Result { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.status(receipt).await + } + + /// Ask the venue to withdraw an intent. Not a submission, so it skips the + /// header, guard, and quota like `status`. + pub async fn cancel(&self, venue: &str, receipt: Vec) -> Result<(), VenueError> { + let slot = self.resolve(venue)?; + let mut adapter = slot.lock().await; + adapter.cancel(receipt).await + } + + /// Number of installed, routable adapters. + pub fn venue_count(&self) -> usize { + self.inner.adapters.len() + } +} + +/// Drop charge timestamps that have aged out of the window. +fn prune(history: &mut VecDeque, window: Duration) { + let now = Instant::now(); + while let Some(&front) = history.front() { + if now.duration_since(front) > window { + history.pop_front(); + } else { + break; + } + } +} + +/// Assembles a [`PoolRouter`]: adapters install first (at supervisor boot, +/// before any module store carries the built router), then the router +/// freezes. The guard defaults to the no-op [`AllowAllGuard`]; the +/// egress-guard epic overrides it here. +pub struct PoolRouterBuilder { + adapters: HashMap, + guard: Arc, + quota: PoolQuota, +} + +impl PoolRouterBuilder { + /// Start an empty builder with the given quota and the no-op guard. + pub fn new(quota: PoolQuota) -> Self { + Self { + adapters: HashMap::new(), + guard: Arc::new(AllowAllGuard), + quota, + } + } + + /// Override the guard policy. The egress-guard epic wires the real + /// pipeline through here; tests inject a denying policy to prove the seam. + pub fn with_guard(mut self, guard: Arc) -> Self { + self.guard = guard; + self + } + + /// Install an adapter under its venue id. Rejects a duplicate id: two + /// adapters answering the same venue would silently shadow one another, + /// which is a config error worth failing boot over. + pub fn install( + &mut self, + venue: String, + invoker: impl VenueInvoker + 'static, + ) -> Result<(), DuplicateVenue> { + if self.adapters.contains_key(&venue) { + return Err(DuplicateVenue { venue }); + } + self.adapters + .insert(venue, Arc::new(AsyncMutex::new(invoker))); + Ok(()) + } + + /// Freeze the builder into a shared router. + pub fn build(self) -> PoolRouter { + if self.quota.max_charges == 0 { + // A zero budget would deny every submission; saturate up to one so + // a misconfigured quota still admits a single submission rather + // than bricking every venue. Mirrors the poison-policy clamp. + warn!("pool submission quota max_charges is 0; clamping to 1"); + } + let quota = PoolQuota::new(self.quota.max_charges.max(1), self.quota.window); + PoolRouter { + inner: Arc::new(PoolRouterInner { + adapters: self.adapters, + guard: self.guard, + quota, + ledger: Mutex::new(QuotaLedger::default()), + }), + } + } +} + +/// Two installed adapters claimed the same venue id. +#[derive(Debug, thiserror::Error)] +#[error("venue id {venue:?} is claimed by more than one installed adapter")] +pub struct DuplicateVenue { + /// The colliding venue id. + pub venue: String, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::bindings::value_flow::Settlement; + use crate::bindings::{AuthScheme, IntentHeader}; + + use super::*; + + /// A programmable adapter that records call counts and returns canned + /// outcomes, so the router's sequencing, guard seam, and quota are tested + /// without a wasmtime store. + #[derive(Default)] + struct StubCalls { + derive: AtomicUsize, + submit: AtomicUsize, + status: AtomicUsize, + cancel: AtomicUsize, + /// Highest number of overlapping invocations observed; proves the + /// per-adapter mutex serialises access. + max_concurrency: AtomicUsize, + live: AtomicUsize, + } + + struct StubAdapter { + calls: Arc, + derive: Result, + submit: Result, + } + + impl StubAdapter { + fn new(calls: Arc) -> Self { + Self { + calls, + derive: Ok(header()), + submit: Ok(SubmitOutcome::Accepted(b"receipt".to_vec())), + } + } + + fn with_derive(mut self, derive: Result) -> Self { + self.derive = derive; + self + } + + async fn enter(&self) { + let live = self.calls.live.fetch_add(1, Ordering::SeqCst) + 1; + self.calls.max_concurrency.fetch_max(live, Ordering::SeqCst); + // Yield inside the critical section so any missing serialisation + // would let a second call observe `live == 2`. + tokio::task::yield_now().await; + self.calls.live.fetch_sub(1, Ordering::SeqCst); + } + } + + impl VenueInvoker for StubAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.derive.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.derive.clone() + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.calls.submit.fetch_add(1, Ordering::SeqCst); + self.enter().await; + self.submit.clone() + }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.calls.status.fetch_add(1, Ordering::SeqCst); + Ok(IntentStatus::Open) + }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { + self.calls.cancel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } + } + + /// A guard that refuses every egress with a fixed reason. + struct DenyGuard; + impl GuardPolicy for DenyGuard { + fn check(&self, _ctx: &GuardContext<'_>) -> GuardVerdict { + GuardVerdict::Deny("blocked by test policy".to_owned()) + } + } + + fn header() -> IntentHeader { + IntentHeader { + gives: Vec::new(), + wants: Vec::new(), + valid_until: None, + settlement: Settlement::EvmChain(1), + authorisation: AuthScheme::Unsigned, + } + } + + fn router_with( + quota: PoolQuota, + guard: Option>, + adapter: StubAdapter, + ) -> PoolRouter { + let mut builder = PoolRouterBuilder::new(quota); + if let Some(guard) = guard { + builder = builder.with_guard(guard); + } + builder + .install("cow".to_owned(), adapter) + .expect("install adapter"); + builder.build() + } + + #[tokio::test] + async fn submit_round_trips_through_derive_guard_submit() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + + let outcome = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect("submit succeeds"); + + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"receipt")); + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn unknown_venue_is_rejected_without_touching_an_adapter() { + let calls = Arc::new(StubCalls::default()); + let router = router_with(PoolQuota::default(), None, StubAdapter::new(calls.clone())); + + let err = router + .submit("mod-a", "unlisted", b"body".to_vec()) + .await + .expect_err("unknown venue rejected"); + + assert!(matches!(err, VenueError::UnknownVenue)); + assert_eq!(calls.derive.load(Ordering::SeqCst), 0); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn guard_deny_blocks_submit_after_deriving_the_header() { + let calls = Arc::new(StubCalls::default()); + let router = router_with( + PoolQuota::default(), + Some(Arc::new(DenyGuard)), + StubAdapter::new(calls.clone()), + ); + + let err = router + .submit("mod-a", "cow", b"body".to_vec()) + .await + .expect_err("guard denies"); + + assert!(matches!(err, VenueError::Denied(reason) if reason.contains("test policy"))); + // The seam runs on the derived header, then blocks: derive ran, submit + // did not. + assert_eq!(calls.derive.load(Ordering::SeqCst), 1); + assert_eq!(calls.submit.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn submission_quota_denies_once_the_budget_is_spent() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(2, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + let err = router + .submit("mod-a", "cow", b"b".to_vec()) + .await + .expect_err("third submit over quota"); + + assert!(matches!(err, VenueError::Denied(reason) if reason.contains("quota"))); + // The over-quota call is stopped at the gate, so the adapter saw only + // the two admitted submits. + assert_eq!(calls.submit.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn quota_is_per_caller() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(router.submit("mod-a", "cow", b"b".to_vec()).await.is_ok()); + assert!( + router.submit("mod-a", "cow", b"b".to_vec()).await.is_err(), + "mod-a is over its own budget" + ); + // A different caller has its own budget. + assert!( + router.submit("mod-b", "cow", b"b".to_vec()).await.is_ok(), + "mod-b has an independent budget" + ); + } + + #[tokio::test] + async fn decode_failures_are_charged_and_stop_re_invoking_the_adapter() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let adapter = + StubAdapter::new(calls.clone()).with_derive(Err(VenueError::InvalidBody("bad".into()))); + let router = router_with(quota, None, adapter); + + // First garbage body: derive fails, the failure is charged. + let first = router.submit("mod-a", "cow", b"junk".to_vec()).await; + assert!(matches!(first, Err(VenueError::InvalidBody(_)))); + // Second: the charge from the decode failure exhausts the budget, so + // the caller is stopped at the gate and the adapter is not re-invoked. + let second = router.submit("mod-a", "cow", b"junk".to_vec()).await; + assert!(matches!(second, Err(VenueError::Denied(_)))); + assert_eq!( + calls.derive.load(Ordering::SeqCst), + 1, + "adapter derive-header was invoked exactly once", + ); + } + + #[tokio::test] + async fn non_decode_venue_errors_are_not_charged() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let adapter = StubAdapter::new(calls.clone()) + .with_derive(Err(VenueError::Unavailable("rpc down".into()))); + let router = router_with(quota, None, adapter); + + assert!(matches!( + router.submit("mod-a", "cow", b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + // A venue-side failure did not spend the caller's budget: it may try + // again, so derive is reached a second time. + assert!(matches!( + router.submit("mod-a", "cow", b"b".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert_eq!(calls.derive.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn status_and_cancel_pass_through_without_quota() { + let calls = Arc::new(StubCalls::default()); + // A spent budget must not block reads: status and cancel are not + // submissions. + let quota = PoolQuota::new(1, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + assert!(matches!( + router.status("cow", b"r".to_vec()).await, + Ok(IntentStatus::Open) + )); + assert!(router.cancel("cow", b"r".to_vec()).await.is_ok()); + assert_eq!(calls.status.load(Ordering::SeqCst), 1); + assert_eq!(calls.cancel.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_calls_to_one_adapter_are_serialised() { + let calls = Arc::new(StubCalls::default()); + let quota = PoolQuota::new(1000, Duration::from_secs(3600)); + let router = router_with(quota, None, StubAdapter::new(calls.clone())); + + let mut handles = Vec::new(); + for _ in 0..8 { + let router = router.clone(); + handles.push(tokio::spawn(async move { + let _ = router.submit("mod-a", "cow", b"b".to_vec()).await; + })); + } + for h in handles { + h.await.expect("task joins"); + } + // The adapter mutex is held across the guest await, so no two calls + // ever overlapped inside the adapter. + assert_eq!(calls.max_concurrency.load(Ordering::SeqCst), 1); + } + + #[test] + fn duplicate_venue_id_is_rejected() { + let mut builder = PoolRouterBuilder::new(PoolQuota::default()); + let a = Arc::new(StubCalls::default()); + let b = Arc::new(StubCalls::default()); + builder + .install("cow".to_owned(), StubAdapter::new(a)) + .expect("first install"); + let err = builder + .install("cow".to_owned(), StubAdapter::new(b)) + .expect_err("second install collides"); + assert_eq!(err.venue, "cow"); + } + + #[test] + fn zero_quota_saturates_to_one() { + let router = PoolRouterBuilder::new(PoolQuota::new(0, Duration::from_secs(60))).build(); + assert_eq!(router.inner.quota.max_charges, 1); + } +} diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index 5dfbadb5..df3e2801 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -13,6 +13,7 @@ use wasmtime_wasi_http::WasiHttpCtx; use super::component::{Handle, RuntimeTypes}; use super::http::HttpGate; use super::logs::{LogRouter, RunId}; +use super::pool_router::PoolRouter; /// Per-module host state, generic over the [`RuntimeTypes`] lattice /// binding the backend seams. The composition root supplies the @@ -47,6 +48,10 @@ pub struct HostState { /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, + /// The intent pool router the `nexum:intent/pool` import dispatches to. + /// Every module store carries the same shared handle; an adapter store, + /// which cannot call pool, carries an empty one. + pub pool_router: PoolRouter, } // `WasiView: Send`, so the backends must be `Send` too; the lattice diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 05d6b79a..064aea56 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -28,6 +28,19 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; +/// Capability names under the `nexum:intent/` package a module may import. +/// Only the strategy-facing `pool` interface is a capability; the `types` +/// package is type-only and needs no declaration. +pub const INTENT_CAPABILITIES: &[&str] = &["pool"]; + +/// The intent namespace: the `nexum:intent/pool` import is linked into every +/// module linker, so a module that submits intents declares the `pool` +/// capability the same way it declares a `nexum:host/` one. +pub const INTENT_NAMESPACE: NamespaceCaps = NamespaceCaps { + prefix: "nexum:intent/", + ifaces: INTENT_CAPABILITIES, +}; + /// The interfaces a `venue-adapter` world links: the scoped transport /// only. An adapter has no local-store, remote-store, identity, or /// logging - it moves bytes to and from its venue and nothing else. `http` @@ -67,10 +80,11 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with only the core namespace. + /// The registry with the core `nexum:host/` namespace plus the + /// strategy-facing `nexum:intent/pool` import every module linker carries. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE, INTENT_NAMESPACE], } } @@ -243,6 +257,15 @@ mod tests { ); } + #[test] + fn intent_pool_is_a_core_capability_but_intent_types_is_not() { + let r = CapabilityRegistry::core(); + assert_eq!(r.wit_import_to_cap("nexum:intent/pool@0.1.0"), Some("pool")); + assert!(r.is_known("pool")); + // The type-only interface is not a capability and needs no declaration. + assert_eq!(r.wit_import_to_cap("nexum:intent/types@0.1.0"), None); + } + #[test] fn wit_import_to_cap_non_http_wasi_is_none() { let r = registry_with_cow(); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 79a93968..8dc6293f 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -47,6 +47,7 @@ use crate::host::http::HttpGate; #[cfg(test)] use crate::host::local_store_redb::LocalStore; use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; +use crate::host::pool_router::{AdapterActor, PoolRouter, PoolRouterBuilder}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; @@ -57,13 +58,17 @@ use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ModuleKind, Subs /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Venue adapters instantiated into supervised stores. They boot - /// through the same store, fuel, and memory machinery as modules but - /// carry no subscriptions: nothing dispatches to them yet. A later - /// change wires the intent router - which reaches an adapter through - /// the same extension seam the `extension.rs` router note describes - - /// and folds them into the restart and poison sweeps. - adapters: Vec>, + /// The intent pool router: every installed venue adapter's serialising + /// store, keyed by venue id. Cached so a module restart rebuilds a store + /// carrying the same shared handle. Adapters boot through the same store, + /// fuel, and memory machinery as modules but carry no subscriptions: + /// modules reach them through this router, not through dispatch. Folding + /// adapters into the restart and poison sweeps is still a later change. + pool_router: PoolRouter, + /// Venue adapters loaded at boot, whether or not `init` succeeded. + adapters_total: usize, + /// Adapters whose `init` succeeded and that are installed for routing. + adapters_alive: usize, /// Cached for module restart: re-instantiating a trapped module /// requires a fresh wasmtime `Store` + `Linker`, which in turn need /// the shared backends. The `Components` bundle is cheaply cloned @@ -211,45 +216,19 @@ struct LoadedModule { poisoned: bool, } -/// A venue adapter instantiated into a supervised store. It mirrors -/// [`LoadedModule`] so the intent router a later change adds can drive -/// adapters through the same restart, poison, and fuel machinery, but -/// carries no subscriptions: nothing dispatches to an adapter yet. The -/// cached boot inputs (`component`, `init_config`, the scope grants, the -/// resource knobs) are what a re-instantiation needs; they are held now -/// and read once dispatch exists. -#[allow(dead_code)] +/// A venue adapter instantiated into a supervised store, ready to install in +/// the pool router. It boots through the same store, fuel, and memory +/// machinery as a module but carries no subscriptions: modules reach it +/// through the router, not through dispatch. Adapter restart and poison +/// handling are still a later change; an `init` failure leaves `alive` false +/// so the adapter is loaded but not routable. struct LoadedAdapter { - name: String, - bindings: VenueAdapter, - store: HostStore, - /// The run this store instantiates; restarts mint a fresh one. - run: RunId, - /// Fuel budget refilled before each adapter call. - fuel_per_event: u64, - /// Memory cap applied to the store on reinstantiation. - memory_limit: usize, - /// Cached for restart: re-instantiating from the compiled component. - component: Component, - /// Cached for restart: the `[config]` passed to the adapter's `init`. - init_config: Config, - /// Operator-granted outbound HTTP allowlist for this adapter. - http_allowlist: Vec, - /// Operator-granted outbound HTTP limits. - http_limits: OutboundHttpLimits, - /// Operator-granted messaging content-topic scopes. - messaging_topics: Vec, - /// `false` once an adapter call traps; folded into the restart sweep - /// when the router lands. `init` failure leaves it `false` at boot. + /// Venue id the adapter answers for (its manifest name). + venue_id: String, + /// The refuelable adapter store, ready to serialise behind a router mutex. + actor: AdapterActor, + /// Whether `init` succeeded; a failed adapter is not installed for routing. alive: bool, - /// Consecutive trap-style failures since the last success. - failure_count: u32, - /// Earliest instant a trapped adapter may be retried. - next_attempt: Option, - /// Sliding-window trap timestamps for the poison-pill check. - failure_timestamps: std::collections::VecDeque, - /// Permanent quarantine flag, as for modules. - poisoned: bool, } impl Supervisor { @@ -265,52 +244,71 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - let mut modules = Vec::with_capacity(engine_cfg.modules.len()); - for entry in &engine_cfg.modules { - let loaded = Self::load_one( + // Adapters instantiate first: the pool router must contain them before + // any module store (which carries the built router) is built. Adapters + // link only their scoped transport, against a dedicated linker built + // from the same core backends, and their own stores carry an empty + // router since an adapter cannot call pool. + let adapter_linker = build_adapter_linker::(engine)?; + let adapter_registry = CapabilityRegistry::adapter(); + let mut router_builder = PoolRouterBuilder::new(engine_cfg.limits.quota()); + let adapters_total = engine_cfg.adapters.len(); + let mut adapters_alive = 0; + for entry in &engine_cfg.adapters { + let loaded = Self::load_adapter( engine, - linker, + &adapter_linker, entry, components, &engine_cfg.limits, - ®istry, + &adapter_registry, clocks.as_ref(), ) .await - .with_context(|| format!("load module {}", entry.path.display()))?; - modules.push(loaded); + .with_context(|| format!("load adapter {}", entry.path.display()))?; + if loaded.alive { + adapters_alive += 1; + router_builder + .install(loaded.venue_id.clone(), loaded.actor) + .with_context(|| format!("install adapter {}", loaded.venue_id))?; + } else { + warn!( + adapter = %loaded.venue_id, + "adapter init failed - not installed for routing", + ); + } } - // Adapters link only their scoped transport, so they instantiate - // against a dedicated linker built from the same core backends. - let adapter_linker = build_adapter_linker::(engine)?; - let adapter_registry = CapabilityRegistry::adapter(); - let mut adapters = Vec::with_capacity(engine_cfg.adapters.len()); - for entry in &engine_cfg.adapters { - let loaded = Self::load_adapter( + let pool_router = router_builder.build(); + + let mut modules = Vec::with_capacity(engine_cfg.modules.len()); + for entry in &engine_cfg.modules { + let loaded = Self::load_one( engine, - &adapter_linker, + linker, entry, components, &engine_cfg.limits, - &adapter_registry, + ®istry, clocks.as_ref(), + pool_router.clone(), ) .await - .with_context(|| format!("load adapter {}", entry.path.display()))?; - adapters.push(loaded); + .with_context(|| format!("load module {}", entry.path.display()))?; + modules.push(loaded); } let alive = modules.iter().filter(|m| m.alive).count(); - let adapters_alive = adapters.iter().filter(|a| a.alive).count(); info!( loaded = modules.len(), alive, - adapters = adapters.len(), + adapters = adapters_total, adapters_alive, "supervisor up" ); Ok(Self { modules, - adapters, + pool_router, + adapters_total, + adapters_alive, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -341,6 +339,10 @@ impl Supervisor { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; + // The single-module override path serves `just run`; adapters are + // configured through `engine.toml`, so the router is empty here and + // every pool call resolves to `unknown-venue`. + let pool_router = PoolRouter::empty(); let loaded = Self::load_one( engine, linker, @@ -349,13 +351,14 @@ impl Supervisor { limits, ®istry, clocks.as_ref(), + pool_router.clone(), ) .await?; Ok(Self { modules: vec![loaded], - // The single-module override path serves `just run`; adapters - // are configured through `engine.toml` and boot via `boot`. - adapters: Vec::new(), + pool_router, + adapters_total: 0, + adapters_alive: 0, engine: engine.clone(), components: components.clone(), extensions: extensions.to_vec(), @@ -381,6 +384,7 @@ impl Supervisor { memory_limit: usize, fuel: u64, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { let namespace: &str = &run.module; // Capture guest stdout/stderr per store instead of inheriting the @@ -434,6 +438,7 @@ impl Supervisor { ext: components.ext.clone(), chain: components.chain.clone(), store: module_store, + pool_router, }, ); store.limiter(|state| &mut state.limits); @@ -441,6 +446,9 @@ impl Supervisor { Ok(store) } + // One flat argument per shared input threaded onto the store, plus the + // pool router the module's `nexum:intent/pool` import dispatches to. + #[allow(clippy::too_many_arguments)] async fn load_one( engine: &Engine, linker: &Linker>, @@ -449,6 +457,7 @@ impl Supervisor { limits_cfg: &ModuleLimits, registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, + pool_router: PoolRouter, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -503,6 +512,7 @@ impl Supervisor { limits_cfg.memory(), limits_cfg.fuel(), clocks, + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &component, linker) .await @@ -655,6 +665,9 @@ impl Supervisor { ); let run = RunId::new(adapter_namespace.clone(), 0); + // An adapter store cannot call pool, so it carries an empty router; + // this also keeps the real router out of the adapter's `HostState`, + // so there is no reference cycle back into the router that owns it. let mut store = Self::build_store( engine, components, @@ -665,6 +678,7 @@ impl Supervisor { limits_cfg.memory(), limits_cfg.fuel(), clocks, + PoolRouter::empty(), )?; let bindings = VenueAdapter::instantiate_async(&mut store, &component, linker) .await @@ -699,22 +713,9 @@ impl Supervisor { store.set_fuel(limits_cfg.fuel())?; Ok(LoadedAdapter { - name: adapter_namespace, - bindings, - store, - run, - fuel_per_event: limits_cfg.fuel(), - memory_limit: limits_cfg.memory(), - component, - init_config: config, - http_allowlist: entry.http_allow.clone(), - http_limits: limits_cfg.http(), - messaging_topics: entry.messaging_topics.clone(), + venue_id: adapter_namespace, + actor: AdapterActor::new(store, bindings, limits_cfg.fuel()), alive: init_succeeded, - failure_count: 0, - next_attempt: None, - failure_timestamps: std::collections::VecDeque::new(), - poisoned: false, }) } @@ -723,16 +724,16 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters instantiated under the supervisor. + /// Number of venue adapters loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { - self.adapters.len() + self.adapters_total } - /// Number of adapters whose `init` succeeded and that are eligible for - /// routing once dispatch lands. + /// Number of adapters whose `init` succeeded and that are installed in the + /// pool router for routing. #[cfg_attr(not(test), allow(dead_code))] pub fn adapter_alive_count(&self) -> usize { - self.adapters.iter().filter(|a| a.alive).count() + self.adapters_alive } /// Chains any module asked for block events on. The caller opens @@ -837,9 +838,11 @@ impl Supervisor { // against the cached `Engine`. let linker = build_linker::(&self.engine, &self.extensions)?; - // Borrowed before the `&mut self.modules[idx]` reborrow so the - // restart path applies the same clock override as the initial boot. + // Borrowed before the `&mut self.modules[idx]` reborrow so the restart + // path applies the same clock override and the same shared pool router + // as the initial boot. let clocks = self.clocks.clone(); + let pool_router = self.pool_router.clone(); let module = &mut self.modules[idx]; // A restart is a new run: bump the sequence so its logs key // apart from the dead run's, which stays readable until evicted. @@ -854,6 +857,7 @@ impl Supervisor { module.memory_limit, module.fuel_per_event, clocks.as_ref(), + pool_router, )?; let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker) .await @@ -1226,6 +1230,13 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; + // The intent pool import is linked into every module linker; it dispatches + // to the shared router carried in each store's `HostState`. Modules that do + // not import it are unaffected. + crate::bindings::pool::add_to_linker::, HasSelf>>( + &mut linker, + |state| state, + )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. From 8b698c751d00c7255485ca0046fcbf29c8d74116 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 00:48:00 +0000 Subject: [PATCH 12/89] feat(venue-sdk): add nexum-venue-sdk crate (#251) * feat(sdk): ship nexum-venue-sdk over the venue-adapter world Introduce the venue-author persona crate: the VenueAdapter trait and export macro over an in-crate bindgen of the adapter world, the borsh IntentBody derive whose outer version enum fails unknown tags typedly, the typed intent client core over the byte-level pool seam, and typed wrappers over the scoped chain, messaging, and wasi:http imports. The derive expansion lands in nexum-macros and re-exports from the SDK; borsh enters the workspace dependency table. * fix(venue-sdk): wrap the wit chain-error data into Bytes The SDK RpcError.data is Bytes; the wit-bindgen chain-error carries Vec, so wrap it (Vec -> Bytes is O(1)) when reprojecting. --- Cargo.lock | 12 ++ Cargo.toml | 7 + crates/nexum-macros/Cargo.toml | 2 +- crates/nexum-macros/src/intent_body.rs | 134 +++++++++++++ crates/nexum-macros/src/lib.rs | 32 +++- crates/nexum-venue-sdk/Cargo.toml | 33 ++++ crates/nexum-venue-sdk/src/adapter.rs | 97 ++++++++++ crates/nexum-venue-sdk/src/bindings.rs | 26 +++ crates/nexum-venue-sdk/src/body.rs | 94 ++++++++++ crates/nexum-venue-sdk/src/client.rs | 93 +++++++++ crates/nexum-venue-sdk/src/faults.rs | 148 +++++++++++++++ crates/nexum-venue-sdk/src/lib.rs | 78 ++++++++ crates/nexum-venue-sdk/src/transport.rs | 158 ++++++++++++++++ crates/nexum-venue-sdk/tests/adapter.rs | 238 ++++++++++++++++++++++++ 14 files changed, 1148 insertions(+), 4 deletions(-) create mode 100644 crates/nexum-macros/src/intent_body.rs create mode 100644 crates/nexum-venue-sdk/Cargo.toml create mode 100644 crates/nexum-venue-sdk/src/adapter.rs create mode 100644 crates/nexum-venue-sdk/src/bindings.rs create mode 100644 crates/nexum-venue-sdk/src/body.rs create mode 100644 crates/nexum-venue-sdk/src/client.rs create mode 100644 crates/nexum-venue-sdk/src/faults.rs create mode 100644 crates/nexum-venue-sdk/src/lib.rs create mode 100644 crates/nexum-venue-sdk/src/transport.rs create mode 100644 crates/nexum-venue-sdk/tests/adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 0669df5e..9fc47ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3620,6 +3620,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "nexum-venue-sdk" +version = "0.1.0" +dependencies = [ + "borsh", + "nexum-macros", + "nexum-sdk", + "strum", + "thiserror 2.0.18", + "wit-bindgen 0.59.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 25604bd5..17bd563f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/nexum-runtime", "crates/nexum-sdk", "crates/nexum-sdk-test", + "crates/nexum-venue-sdk", "crates/shepherd-backtest", "crates/shepherd-cow-host", "crates/shepherd-sdk", @@ -52,6 +53,12 @@ futures = "0.3" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } +# Borsh wire codec behind the venue SDK's versioned `IntentBody` bodies. +# The venue SDK re-exports the runtime crate for its derive's generated +# code; `derive` is on so venue payload types can `#[derive(BorshSerialize, +# BorshDeserialize)]` through the same dependency. +borsh = { version = "1", features = ["derive"] } + # Observability. tracing = "0.1" # `tracing-core` alone (no subscriber registry) backs the guest-side diff --git a/crates/nexum-macros/Cargo.toml b/crates/nexum-macros/Cargo.toml index ed9fcf2c..1f4dd26a 100644 --- a/crates/nexum-macros/Cargo.toml +++ b/crates/nexum-macros/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export." +description = "Proc-macro glue for nexum runtime modules: #[module] emits the per-cdylib wit-bindgen, host adapter, event dispatch, and export; derive(IntentBody) emits the venue SDK's versioned body codec." [lib] proc-macro = true diff --git a/crates/nexum-macros/src/intent_body.rs b/crates/nexum-macros/src/intent_body.rs new file mode 100644 index 00000000..79b074cd --- /dev/null +++ b/crates/nexum-macros/src/intent_body.rs @@ -0,0 +1,134 @@ +//! Expansion for `#[derive(IntentBody)]`: the borsh codec over a +//! per-venue version enum. +//! +//! The derive enforces the outer-enum shape at compile time (an enum of +//! newtype variants, one published body version per variant) and emits +//! `to_bytes` / `from_bytes` whose wire form is the borsh enum layout: a +//! one-byte version tag (the variant's declaration index) followed by the +//! borsh-encoded payload. Decoding matches the tag itself, so an unknown +//! version surfaces as the typed `BodyError::UnknownVersion` rather than +//! a stringly borsh error, and a known version delegates the payload to +//! its type's `BorshDeserialize`. +//! +//! Generated code names the venue SDK by its crate path +//! (`::nexum_venue_sdk`), so the derive is only usable through that +//! crate's re-export. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +/// Expand the derive input into the `IntentBody` impl, or a compile +/// error naming the shape rule the input broke. +pub(crate) fn expand(input: &DeriveInput) -> syn::Result { + let name = &input.ident; + + if !input.generics.params.is_empty() { + return Err(syn::Error::new_spanned( + &input.generics, + "#[derive(IntentBody)] does not support generic version enums: a wire schema has \ + exactly one shape", + )); + } + + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] applies to the outer per-venue version enum: an enum with one \ + newtype variant per published body version", + )); + }; + + if data.variants.is_empty() { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] needs at least one version variant", + )); + } + if data.variants.len() > usize::from(u8::MAX) + 1 { + return Err(syn::Error::new_spanned( + name, + "#[derive(IntentBody)] supports at most 256 versions: the wire tag is one byte", + )); + } + + let mut encode_arms = Vec::with_capacity(data.variants.len()); + let mut decode_arms = Vec::with_capacity(data.variants.len()); + for (index, variant) in data.variants.iter().enumerate() { + if let Some((eq, _)) = &variant.discriminant { + return Err(syn::Error::new_spanned( + eq, + "#[derive(IntentBody)] does not support explicit discriminants: the version tag \ + is the variant's declaration index, so append new versions at the end", + )); + } + let payload_ty = match &variant.fields { + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => &fields.unnamed[0].ty, + _ => { + return Err(syn::Error::new_spanned( + &variant.ident, + "#[derive(IntentBody)] version variants carry exactly one unnamed payload \ + field, e.g. `V1(BodyV1)`", + )); + } + }; + + let ident = &variant.ident; + let tag = proc_macro2::Literal::u8_suffixed( + u8::try_from(index).expect("variant count checked above"), + ); + + encode_arms.push(quote! { + Self::#ident(payload) => { + let mut out = ::std::vec::Vec::new(); + out.push(#tag); + ::nexum_venue_sdk::body::__private::borsh::to_writer(&mut out, payload).map_err( + |err| ::nexum_venue_sdk::body::BodyError::Encode { + version: #tag, + detail: ::std::string::ToString::to_string(&err), + }, + )?; + ::core::result::Result::Ok(out) + } + }); + decode_arms.push(quote! { + #tag => ::core::result::Result::Ok(Self::#ident( + ::nexum_venue_sdk::body::__private::borsh::from_slice::<#payload_ty>(payload) + .map_err(|err| ::nexum_venue_sdk::body::BodyError::Malformed { + version: #tag, + detail: ::std::string::ToString::to_string(&err), + })?, + )), + }); + } + + Ok(quote! { + #[automatically_derived] + impl ::nexum_venue_sdk::body::IntentBody for #name { + fn to_bytes( + &self, + ) -> ::core::result::Result< + ::std::vec::Vec, + ::nexum_venue_sdk::body::BodyError, + > { + match self { + #(#encode_arms)* + } + } + + fn from_bytes( + bytes: &[u8], + ) -> ::core::result::Result { + let (version, payload) = bytes + .split_first() + .ok_or(::nexum_venue_sdk::body::BodyError::Empty)?; + match *version { + #(#decode_arms)* + version => ::core::result::Result::Err( + ::nexum_venue_sdk::body::BodyError::UnknownVersion { version }, + ), + } + } + } + }) +} diff --git a/crates/nexum-macros/src/lib.rs b/crates/nexum-macros/src/lib.rs index 93d0e9a3..ad4bb770 100644 --- a/crates/nexum-macros/src/lib.rs +++ b/crates/nexum-macros/src/lib.rs @@ -6,14 +6,40 @@ //! `nexum_sdk::bind_host_via_wit_bindgen!`), the `Guest` implementation //! whose `on-event` dispatches to the handlers present, and `export!`. //! -//! Consumers reach this through the `nexum_sdk::module` re-export rather -//! than depending on this crate directly. +//! [`derive@IntentBody`] implements the venue SDK's versioned body codec +//! over a per-venue version enum. +//! +//! Consumers reach these through the SDK re-exports (`nexum_sdk::module`, +//! `nexum_venue_sdk::IntentBody`) rather than depending on this crate +//! directly. + +mod intent_body; use std::path::{Path, PathBuf}; use proc_macro::TokenStream; use quote::quote; -use syn::{ImplItem, ItemImpl, Type}; +use syn::{DeriveInput, ImplItem, ItemImpl, Type}; + +/// Derive the venue SDK's `IntentBody` codec on the outer per-venue +/// version enum: one newtype variant per published body version, each +/// payload a borsh type. +/// +/// The wire form is the borsh enum layout (a one-byte tag, the variant's +/// declaration index, then the borsh payload), so the tag order is the +/// schema: append new versions, never reorder. Decoding an unknown tag +/// fails typedly as `BodyError::UnknownVersion`. +/// +/// Generated code resolves the SDK by crate path, so use the +/// `nexum_venue_sdk::IntentBody` re-export with `nexum-venue-sdk` as a +/// direct dependency. +#[proc_macro_derive(IntentBody)] +pub fn derive_intent_body(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as DeriveInput); + intent_body::expand(&input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} /// The handler names recognised on a `#[module]` impl. Any method not in /// this set is left untouched on the type, except that names starting diff --git a/crates/nexum-venue-sdk/Cargo.toml b/crates/nexum-venue-sdk/Cargo.toml new file mode 100644 index 00000000..f3fd250a --- /dev/null +++ b/crates/nexum-venue-sdk/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "nexum-venue-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Guest-side SDK for venue adapters: the VenueAdapter trait over the venue-adapter world bindgen, the borsh-versioned IntentBody codec, the typed intent client core, and typed wrappers over the scoped transport imports." + +[lib] +# Plain library - adapters link this and emit their own cdylib for the +# WASM Component. Building on the host target is also supported so the +# codec, client core, and conversions are unit-testable without a wasm +# toolchain (the wit-bindgen import shims compile to unreachable stubs +# off-wasm). + +[lints] +workspace = true + +[dependencies] +# Backs the `IntentBody` wire codec; re-exported (`body::__private`) for +# the derive's generated code so an adapter crate needs no direct borsh +# declaration unless its payloads derive the borsh traits themselves. +borsh.workspace = true +# Source of the `IntentBody` derive, re-exported at the crate root next +# to the trait it implements. +nexum-macros = { path = "../nexum-macros" } +# Host-neutral SDK layer this crate builds on: the `ChainHost` seam the +# chain wrapper implements, the shared `Fault` vocabulary, and the +# wasi:http `fetch` surface re-exported as `transport::http`. +nexum-sdk = { path = "../nexum-sdk" } +strum.workspace = true +thiserror.workspace = true +wit-bindgen.workspace = true diff --git a/crates/nexum-venue-sdk/src/adapter.rs b/crates/nexum-venue-sdk/src/adapter.rs new file mode 100644 index 00000000..58fc8ffc --- /dev/null +++ b/crates/nexum-venue-sdk/src/adapter.rs @@ -0,0 +1,97 @@ +//! The [`VenueAdapter`] trait and the export glue that turns an impl of +//! it into the component's `venue-adapter` world surface. +//! +//! The trait mirrors the world's export face one to one: `init` from the +//! world itself, the four intent functions from `nexum:intent/adapter`. +//! Functions are associated (no `self`): the component model instantiates +//! one adapter per venue and calls exports statically, so adapter state +//! lives in the adapter's own statics, exactly as in event modules. + +use crate::{Config, Fault, IntentHeader, IntentStatus, SubmitOutcome, VenueError}; + +/// One venue's protocol speaker: the guest-side face of the +/// `venue-adapter` world. Implement it on a unit struct and hand that to +/// [`export_venue_adapter!`](crate::export_venue_adapter); bodies and +/// receipts arrive as the opaque bytes the wire carries, and impls +/// recover typing through [`IntentBody`](crate::IntentBody) (whose +/// [`BodyError`](crate::BodyError) converts into [`VenueError`] via `?`). +pub trait VenueAdapter { + /// Configure the adapter from its `[config]` table before any + /// submission. Mirrors the event-module `init`, so the supervisor + /// boots both component kinds through the same machinery. + fn init(config: Config) -> Result<(), Fault>; + + /// Project an opaque intent body onto the stable header guard + /// policy runs on. Must be a pure derivation: no transport, no side + /// effects, so the host can inspect a header before deciding to + /// submit. + fn derive_header(body: Vec) -> Result; + + /// Submit an opaque intent body to this adapter's venue. Success is + /// either the venue's receipt or `requires-signing`: a transaction + /// the host must sign and send before the intent exists. + fn submit(body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(receipt: Vec) -> Result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can + /// no longer win the race. + fn cancel(receipt: Vec) -> Result<(), VenueError>; +} + +/// Export a [`VenueAdapter`] impl as the crate's `venue-adapter` world. +/// +/// Invoke once at the top level of the adapter's cdylib crate. Emits a +/// hidden shim type wiring the world's `Guest` traits to the adapter's +/// associated functions, then the wit-bindgen export glue; the linker +/// rejects a second invocation in one component (duplicate export +/// symbols), matching the one-adapter-per-component contract. +#[macro_export] +macro_rules! export_venue_adapter { + ($adapter:ty) => { + #[doc(hidden)] + struct __NexumVenueAdapterExport; + + impl $crate::bindings::Guest for __NexumVenueAdapterExport { + fn init( + config: ::std::vec::Vec<(::std::string::String, ::std::string::String)>, + ) -> ::core::result::Result<(), $crate::Fault> { + <$adapter as $crate::VenueAdapter>::init(config) + } + } + + impl $crate::bindings::exports::nexum::intent::adapter::Guest + for __NexumVenueAdapterExport + { + fn derive_header( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentHeader, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::derive_header(body) + } + + fn submit( + body: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::SubmitOutcome, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::submit(body) + } + + fn status( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<$crate::IntentStatus, $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::status(receipt) + } + + fn cancel( + receipt: ::std::vec::Vec, + ) -> ::core::result::Result<(), $crate::VenueError> { + <$adapter as $crate::VenueAdapter>::cancel(receipt) + } + } + + $crate::bindings::__export_venue_adapter_world!( + __NexumVenueAdapterExport with_types_in $crate::bindings + ); + }; +} diff --git a/crates/nexum-venue-sdk/src/bindings.rs b/crates/nexum-venue-sdk/src/bindings.rs new file mode 100644 index 00000000..c8b3c2f4 --- /dev/null +++ b/crates/nexum-venue-sdk/src/bindings.rs @@ -0,0 +1,26 @@ +//! Guest bindings for the `nexum:adapter/venue-adapter` world. +//! +//! Unlike event modules, which run `wit_bindgen::generate!` per cdylib, +//! the venue SDK generates the adapter world's bindings once, here: the +//! [`VenueAdapter`](crate::VenueAdapter) trait, the typed transport +//! wrappers, and the intent client core are all expressed over these +//! types, and [`export_venue_adapter!`](crate::export_venue_adapter) +//! emits the component export glue into the adapter's own cdylib via the +//! generated (hidden) export macro. Downstream bindgens wanting type +//! identity with this crate remap `nexum:intent/types` and +//! `nexum:value-flow/types` onto these modules with `with`. + +wit_bindgen::generate!({ + path: [ + "../../wit/nexum-host", + "../../wit/nexum-value-flow", + "../../wit/nexum-intent", + "../../wit/nexum-adapter", + ], + world: "nexum:adapter/venue-adapter", + generate_all, + pub_export_macro: true, + export_macro_name: "__export_venue_adapter_world", + default_bindings_module: "nexum_venue_sdk::bindings", + additional_derives: [PartialEq], +}); diff --git a/crates/nexum-venue-sdk/src/body.rs b/crates/nexum-venue-sdk/src/body.rs new file mode 100644 index 00000000..d542aca2 --- /dev/null +++ b/crates/nexum-venue-sdk/src/body.rs @@ -0,0 +1,94 @@ +//! The versioned intent-body codec: [`IntentBody`] and its typed +//! [`BodyError`]. +//! +//! An intent body crosses the pool and adapter boundaries as opaque +//! bytes; typing is recovered guest-side against the venue's published +//! schema. That schema is an outer version enum whose wire form is the +//! borsh enum layout: a one-byte version tag (the variant's declaration +//! index) followed by the borsh-encoded payload. `#[derive(IntentBody)]` +//! (re-exported at the crate root) implements the codec over such an +//! enum and is the intended way to get an impl; the derive owns the tag +//! handling, so an unknown version fails as the typed +//! [`BodyError::UnknownVersion`] instead of a stringly borsh error. +//! +//! The one non-obvious invariant: the tag order is the schema. Venues +//! append new versions at the end and never reorder or remove variants. + +use strum::IntoStaticStr; + +use crate::VenueError; + +/// The codec between a venue's typed body enum and the opaque bytes the +/// pool and adapter boundaries carry. Implement via +/// `#[derive(IntentBody)]` on the outer version enum. +pub trait IntentBody: Sized { + /// Encode as the one-byte version tag plus the borsh payload. + fn to_bytes(&self) -> Result, BodyError>; + + /// Decode, failing typedly on an empty body, an unknown version + /// tag, or a payload that does not parse as the tagged version + /// (including trailing bytes). + fn from_bytes(bytes: &[u8]) -> Result; +} + +/// Why a body failed to cross the [`IntentBody`] codec. +/// +/// `IntoStaticStr` yields a snake_case label per case for log and +/// metric fields. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum BodyError { + /// No bytes at all: not even a version tag. + #[error("empty body: missing the version tag")] + Empty, + /// The version tag names no published version of this body. The + /// decodable-future-versions story lives here: a v1 adapter handed + /// a v2 body reports the exact unknown tag instead of garbling the + /// payload. + #[error("unknown body version {version}")] + UnknownVersion { + /// The unrecognised wire tag. + version: u8, + }, + /// The tag named a known version but its payload did not decode + /// (malformed borsh or trailing bytes). + #[error("malformed version {version} payload: {detail}")] + Malformed { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's decode failure detail. + detail: String, + }, + /// A payload failed to encode. Only reachable through a fallible + /// custom `BorshSerialize` impl; derived payloads encode + /// infallibly. + #[error("version {version} payload failed to encode: {detail}")] + Encode { + /// The wire tag whose payload failed. + version: u8, + /// Borsh's encode failure detail. + detail: String, + }, +} + +/// Fold a codec failure into the wire error an adapter returns: decode +/// failures are the caller's malformed body (`invalid-body`, whose WIT +/// contract names exactly these two causes), an encode failure is the +/// adapter's own bug (`internal-error`). +impl From for VenueError { + fn from(err: BodyError) -> Self { + match err { + BodyError::Empty | BodyError::UnknownVersion { .. } | BodyError::Malformed { .. } => { + VenueError::InvalidBody(err.to_string()) + } + BodyError::Encode { .. } => VenueError::InternalError(err.to_string()), + } + } +} + +/// Re-exports for `#[derive(IntentBody)]` generated code only; not a +/// public surface. +#[doc(hidden)] +pub mod __private { + pub use borsh; +} diff --git a/crates/nexum-venue-sdk/src/client.rs b/crates/nexum-venue-sdk/src/client.rs new file mode 100644 index 00000000..edfc8dd7 --- /dev/null +++ b/crates/nexum-venue-sdk/src/client.rs @@ -0,0 +1,93 @@ +//! The typed intent client core: [`IntentClient`] over the byte-level +//! [`IntentPool`] seam. +//! +//! The pool boundary carries opaque bodies; this module is where a +//! typed body meets it. [`IntentClient`] binds one venue and encodes +//! through [`IntentBody`] before submission, so strategy code never +//! handles wire bytes. The seam is byte-level on purpose: the +//! strategy-module SDK implements [`IntentPool`] over its own +//! `nexum:intent/pool` import shims, tests implement it in memory +//! (an in-process adapter works directly), and the typed layer above is +//! shared by both. + +use strum::IntoStaticStr; + +use crate::{BodyError, IntentBody, IntentStatus, SubmitOutcome, VenueError}; + +/// Byte-level access to the strategy-facing `nexum:intent/pool` +/// interface, venue named per call as on the wire. +pub trait IntentPool { + /// Submit an opaque intent body to the named venue. + fn submit(&self, venue: &str, body: Vec) -> Result; + + /// Report where a previously submitted intent is in its life. + fn status(&self, venue: &str, receipt: &[u8]) -> Result; + + /// Ask the venue to withdraw an intent. Success means the venue + /// accepted the cancellation, not that an in-flight settlement can + /// no longer win the race. + fn cancel(&self, venue: &str, receipt: &[u8]) -> Result<(), VenueError>; +} + +/// A typed intent client bound to one venue: encodes an [`IntentBody`] +/// to wire bytes and forwards through the [`IntentPool`] seam. +#[derive(Clone, Debug)] +pub struct IntentClient

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

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

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

{ + inner: IntentClient

, +} + +impl CowClient

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

() else { tracing::warn!( "watch {} carried an unparseable owner; skipping", watch.key() ); - return PollOutcome::TryNextBlock; + return Verdict::TryNextBlock { reason: [0; 4] }; }; let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); @@ -140,7 +140,7 @@ fn poll_one( chain_id: u64, owner: &Address, params: &ConditionalOrderParams, -) -> PollOutcome { +) -> Verdict { let call = abi::getTradeableOrderWithSignatureCall { owner: *owner, params: abi::Params { @@ -155,13 +155,13 @@ fn poll_one( match host.request(chain_id, "eth_call", ¶ms_json) { Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) - .unwrap_or(PollOutcome::TryNextBlock), - // `classify_poll_error` is the one policy for what a failed + .unwrap_or(Verdict::TryNextBlock { reason: [0; 4] }), + // `LegacyRevertAdapter::classify` is the one policy for what a failed // poll call means to the watch lifecycle; the diagnostics here // cover the cases where the raw error carries information the // outcome alone does not. Err(err) => { - let outcome = classify_poll_error(&err); + let outcome = LegacyRevertAdapter::classify(&err); match &err { ChainError::Fault(fault) => { tracing::warn!("eth_call failed ({fault}); retrying next block"); @@ -169,7 +169,7 @@ fn poll_one( // A permanent drop deserves its cause on the record: // the revert selector and the node's message are // unrecoverable once the watch is gone. - ChainError::Rpc(rpc) if matches!(outcome, PollOutcome::DontTryAgain) => { + ChainError::Rpc(rpc) if matches!(outcome, Verdict::Invalid { .. }) => { let selector = rpc .data .as_deref() @@ -190,24 +190,28 @@ fn poll_one( } /// Decode a successful `getTradeableOrderWithSignature` return into -/// `Ready { order, signature }`. The wire format is `abi.encode(order, -/// signature)` - the canonical Solidity return tuple - so the two-tuple -/// parameter decode lines up. -fn decode_return(data: &[u8]) -> Option { +/// `Post { order, signature, .. }`. The wire format is the canonical +/// Solidity return tuple `abi.encode(order, signature)`, so the +/// two-tuple parameter decode lines up. The deployed 1.x contract +/// carries no next-poll hint, so `next_poll_timestamp` is synthetic +/// (`0`). +fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { + Some(Verdict::Post { order: Box::new(order), signature, + next_poll_timestamp: 0, }) } -fn outcome_label(o: &PollOutcome) -> &'static str { +fn outcome_label(o: &Verdict) -> &'static str { match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", } } @@ -341,15 +345,17 @@ mod tests { let wire = (order.clone(), sig.clone()).abi_encode_params(); match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { + Verdict::Post { order: o, signature: s, + next_poll_timestamp, } => { assert_eq!(o.sellToken, order.sellToken); assert_eq!(o.buyAmount, order.buyAmount); assert_eq!(s, sig); + assert_eq!(next_poll_timestamp, 0, "legacy path carries no hint"); } - other => panic!("expected Ready, got {other:?}"), + other => panic!("expected Post, got {other:?}"), } } @@ -723,8 +729,8 @@ mod tests { } #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle + fn poll_invalid_drops_watch_and_gates() { + // When `LegacyRevertAdapter` produces `Invalid`, the lifecycle // layer must delete the watch and any stale gates. Simulate the // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes. From df9171614e998ecab82896e565734f55140a55ba Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 01:26:55 +0000 Subject: [PATCH 23/89] docs: reconcile keeper prose and Verdict poll-seam references (#335) Tip-level residual after the chassis to keeper rename was folded down through the train. Carries the doc-comment prose the fold could not move mechanically (the private-keeper caveat, keeper-run wording) and the current-state design docs brought in line with the Verdict poll seam (PollOutcome to Verdict, decode_revert to LegacyRevertAdapter). --- crates/nexum-runtime/src/host/local_store_redb.rs | 2 +- crates/nexum-sdk/src/keeper.rs | 9 +++++++-- crates/shepherd-sdk/Cargo.toml | 4 ++-- crates/shepherd-sdk/src/cow/composable.rs | 4 ++-- crates/shepherd-sdk/src/cow/mod.rs | 2 +- crates/shepherd-sdk/src/cow/run.rs | 2 +- crates/shepherd-sdk/tests/run.rs | 2 +- docs/00-overview.md | 2 +- docs/05-sdk-design.md | 2 +- docs/08-platform-generalisation.md | 2 +- docs/diagrams/engine-boot.mmd | 2 +- docs/diagrams/sequence-twap.mmd | 2 +- docs/sdk.md | 6 +++--- modules/twap-monitor/src/strategy.rs | 6 +++--- 14 files changed, 26 insertions(+), 21 deletions(-) diff --git a/crates/nexum-runtime/src/host/local_store_redb.rs b/crates/nexum-runtime/src/host/local_store_redb.rs index 3f4e16c8..22096775 100644 --- a/crates/nexum-runtime/src/host/local_store_redb.rs +++ b/crates/nexum-runtime/src/host/local_store_redb.rs @@ -47,7 +47,7 @@ pub struct ModuleStore { } impl LocalStore { - /// Open (or create) the redb file at `path`. Materialises the shared + /// Open (or create) the redb file at `path`. Initialises the shared /// table so subsequent read transactions never hit `TableDoesNotExist`. pub fn open(path: impl AsRef) -> Result { let db = Database::create(path).map_err(StorageError::Open)?; diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 60bcbb00..637e7985 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -3,6 +3,11 @@ //! alone so they compile for any world and test against the in-memory //! mocks. //! +//! Private, single-tenant instance over the wallet's own authorised +//! orders; it submits intents to the venue and does not settle them. +//! This is not a public keeper network, fee marketplace, or MEV +//! searcher. +//! //! Three stores cover the machinery watcher modules hand-roll: //! //! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` @@ -19,7 +24,7 @@ //! one outcome out, at a given [`Tick`]. Implementations own the //! transport and the outcome shape. //! - [`Retrier`] - runs a [`RetryAction`]'s effect through the -//! stores after a failed run attempt. +//! stores after a failed keeper run attempt. //! //! [`WatchRef`] ties the first two together: gate keys are derived //! from the exact hex substrings of the stored watch key, and @@ -350,7 +355,7 @@ pub trait ConditionalSource { } /// What the retry ledger should do to a watch after a failed -/// run attempt. +/// keeper run attempt. /// /// `IntoStaticStr` exposes each variant as a snake_case `&'static /// str` for log and metric labels. `#[non_exhaustive]` so the diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index ea3a1e4c..c4ef8455 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -28,12 +28,12 @@ thiserror.workspace = true tracing.workspace = true [dev-dependencies] -# `capture_tracing` observes the run's diagnostics in the +# `capture_tracing` observes the keeper run's diagnostics in the # acceptance tests. nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true # Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, -# and the run acceptance-tests against the composed MockHost +# and the keeper run acceptance-tests against the composed MockHost # as an integration test - the mock crate links shepherd-sdk # externally, so the unit-test copy of the traits would not unify. shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 10e0fcb2..a7a4674e 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -3,7 +3,7 @@ //! //! Every strategy poll resolves to a [`Verdict`] - the structured //! outcome mirroring the composable-cow fork's structured generator. -//! The run and each strategy module +//! The keeper run and each strategy module //! dispatch on the `Verdict` variants alone; nothing downstream knows //! how the outcome was produced. //! @@ -105,7 +105,7 @@ pub enum Verdict { }, /// The generator needs off-chain input before it can produce an /// order. Never produced by [`LegacyRevertAdapter`]; the - /// run parks the watch untouched. + /// keeper run parks the watch untouched. NeedsInput { /// Source selector, log only. reason: [u8; 4], diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index fbb8fa91..1d216924 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -14,7 +14,7 @@ //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can //! be unit-tested without wit-bindgen scaffolding and re-used //! unchanged by TWAP, EthFlow, and future strategy modules. The -//! run is generic over the host traits alone. +//! keeper run is generic over the host traits alone. pub mod composable; pub mod error; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index c607058b..dbabd179 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -1,4 +1,4 @@ -//! Watch run: the poll-loop composition conditional- +//! Keeper run: the poll-loop composition conditional- //! commitment modules share. //! //! [`run`] walks the keeper watch set, polls each gate-ready diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index 151e7f69..cda235ba 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -1,4 +1,4 @@ -//! Run acceptance tests against the composed +//! Keeper-run acceptance tests against the composed //! `shepherd_sdk_test::MockHost`. These live as an integration test //! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` //! externally, and the external and unit-test copies of the traits diff --git a/docs/00-overview.md b/docs/00-overview.md index e920b3f5..7803f497 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -277,7 +277,7 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `Verdict`, `LegacyRevertAdapter`, `RetryAction`, `classify_api_error`) | | | `cow::run` - the shared poll-loop composition: sweep the keeper watch set, poll a `ConditionalSource`, submit `Ready` orders behind the `submitted:` journal guard and retry ledger | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | diff --git a/docs/05-sdk-design.md b/docs/05-sdk-design.md index 93409a40..8b62896f 100755 --- a/docs/05-sdk-design.md +++ b/docs/05-sdk-design.md @@ -76,7 +76,7 @@ shepherd-sdk/ ├── lib.rs # crate docs; no re-export of nexum-sdk ├── prelude.rs # cowprotocol order/signing/orderbook re-exports ├── wit_bindgen_macro.rs # bind_cow_host_via_wit_bindgen! - layers CowApiHost onto WitBindgenHost - ├── cow/ # CowApiHost trait, gpv2_to_order_data, PollOutcome, decode_revert, + ├── cow/ # CowApiHost trait, gpv2_to_order_data, Verdict, LegacyRevertAdapter, │ # RetryAction classifiers, run() (poll -> gate/journal/submit) └── proptests.rs # cfg(test) property tests (not part of the public surface) ``` diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index d0391e04..30c1f8b7 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -987,7 +987,7 @@ graph TD - **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`Verdict`, `RetryAction`, `gpv2_to_order_data`, `LegacyRevertAdapter`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. diff --git a/docs/diagrams/engine-boot.mmd b/docs/diagrams/engine-boot.mmd index ef7ea35b..5c08e634 100644 --- a/docs/diagrams/engine-boot.mmd +++ b/docs/diagrams/engine-boot.mmd @@ -30,7 +30,7 @@ sequenceDiagram PP-->>Bin: pool ready Bin->>LS: LocalStore::open(state_dir) - Note over LS: Creates redb file if missing,
materialises shared table. + Note over LS: Creates redb file if missing,
initialises shared table. LS-->>Bin: store ready Bin->>OBP: OrderBookPool::default() diff --git a/docs/diagrams/sequence-twap.mmd b/docs/diagrams/sequence-twap.mmd index ebb7766a..2a06efc9 100644 --- a/docs/diagrams/sequence-twap.mmd +++ b/docs/diagrams/sequence-twap.mmd @@ -31,7 +31,7 @@ sequenceDiagram RPC-->>Host: return value or revert Host-->>Mod: result (JSON encoded) Mod->>SolDec: decode return or interpret revert reason - SolDec-->>Mod: PollOutcome (module-defined enum) + SolDec-->>Mod: Verdict (module-defined enum) alt Ready (order, signature) Mod->>Cow: build OrderCreation diff --git a/docs/sdk.md b/docs/sdk.md index 1af60111..c8ee0f65 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -69,7 +69,7 @@ seam one-for-one. - `cow::order::gpv2_to_order_data` - convert the on-chain `GPv2OrderData` (12-field Solidity tuple with bytes32 markers) into the typed `OrderData` shape the orderbook signs against. - - `cow::composable::PollOutcome` + `cow::composable::decode_revert` + - `cow::composable::Verdict` + `cow::composable::LegacyRevertAdapter` - typed dispatch over the five `IConditionalOrder` custom errors (`OrderNotValid`, `PollTryNextBlock`, `PollTryAtBlock`, `PollTryAtEpoch`, `PollNever`). @@ -85,8 +85,8 @@ seam one-for-one. response into bytes. - `chain::chainlink::read_latest_answer` - Chainlink AggregatorV3 reader over the two helpers above. - (The CoW-specific `cow::decode_revert_hex(s)` - the `chain-error` - rpc revert bytes -> typed `PollOutcome` - lives in `shepherd-sdk`.) + (The CoW-specific `cow::LegacyRevertAdapter(s)` - the `chain-error` + rpc revert bytes -> typed `Verdict` - lives in `shepherd-sdk`.) - [`host`](../target/doc/nexum_sdk/host/index.html) - host trait seam plus the SDK's host-neutral `Fault` vocabulary (same cases diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 3de57d8b..3ca2de4a 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -69,9 +69,9 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: run every gate-ready watch through the keeper -/// composition. The block timestamp arrives in milliseconds; the tick -/// carries Unix seconds. +/// Poll entry: run the keeper over every gate-ready watch through the +/// shared composition. The block timestamp arrives in milliseconds; the +/// tick carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { let tick = Tick { chain_id: block.chain_id, From ddfb2b994196e410c7f154c0ff0e9a8ad011e977 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 15 Jul 2026 07:18:52 +0000 Subject: [PATCH 24/89] 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 25/89] =?UTF-8?q?docs:=20multi-chain=20deployment=20patter?= =?UTF-8?q?ns=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 26/89] =?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 27/89] 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 28/89] 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 29/89] 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 30/89] 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 31/89] 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 32/89] 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 33/89] 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 34/89] 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 35/89] 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 36/89] 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 37/89] 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 38/89] 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 39/89] 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 40/89] 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 41/89] 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 42/89] 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 43/89] 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 44/89] 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 45/89] 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 46/89] 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 47/89] 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 48/89] 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 49/89] 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 50/89] 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 51/89] 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 52/89] 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 53/89] 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 54/89] 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 55/89] 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 56/89] 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 57/89] 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 58/89] 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 59/89] 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 60/89] 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 61/89] 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 62/89] 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 63/89] 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 64/89] 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 65/89] 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 66/89] 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 67/89] 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 68/89] 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 69/89] 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 70/89] 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 71/89] 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 72/89] 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 73/89] 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 74/89] 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 75/89] 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 76/89] 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 77/89] 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 78/89] 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 79/89] 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 80/89] 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 81/89] 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 82/89] 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 83/89] 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 84/89] 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 85/89] 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 86/89] 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 87/89] 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 88/89] 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 a780d6327e93ffaa8efc686d29638433a0e78545 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 00:43:49 +0000 Subject: [PATCH 89/89] keeper: reserve submit journal before the venue call --- crates/composable-cow/src/sweep.rs | 34 ++++--- crates/composable-cow/tests/sweep.rs | 100 ++++++++++++++++++++- crates/nexum-sdk/src/keeper.rs | 51 +++++++++-- crates/videre-sdk/src/keeper.rs | 129 +++++++++++++++++++++++---- 4 files changed, 279 insertions(+), 35 deletions(-) diff --git a/crates/composable-cow/src/sweep.rs b/crates/composable-cow/src/sweep.rs index 768d51c4..59fab5b4 100644 --- a/crates/composable-cow/src/sweep.rs +++ b/crates/composable-cow/src/sweep.rs @@ -85,7 +85,11 @@ where /// 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. +/// happens. The id is reserved before the submit and committed on +/// acceptance, so a record write that faults after acceptance still +/// leaves a marker and the next tick does not re-post; a non-accepting +/// outcome releases the reservation. The venue's receipt rides the log +/// only. fn submit_ready( host: &H, venue: &CowClient, @@ -134,11 +138,16 @@ where tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); return Ok(()); } + // Reserve before the venue call: a record write that faults after + // acceptance still leaves this marker, so no next tick re-posts an + // accepted body. A non-accepting outcome releases it. + journal.reserve(&intent_id)?; 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. + journal.release(&intent_id)?; tracing::error!("{label} submit future suspended; retrying next tick"); return Ok(()); }; @@ -150,10 +159,9 @@ where 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 - // next tick's re-post idempotent. + // Commit best-effort: the reservation already guards the + // re-post, so a record fault here must not abort the sweep + // or unwind the accepted order. if let Err(fault) = journal.record(&intent_id) { tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } @@ -163,14 +171,17 @@ where ); } Ok(SubmitOutcome::RequiresSigning(_)) => { - // A sweep cannot sign; nothing is journalled, so the next - // tick surfaces the same ask afresh. + // A sweep cannot sign; the reservation is released, so the + // next tick surfaces the same ask afresh. + journal.release(&intent_id)?; tracing::warn!("{label} submit for {owner:#x} requires signing; not journalled"); } Err(ClientError::Body(err)) => { + journal.release(&intent_id)?; tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { + journal.release(&intent_id)?; let action = match &fault { VenueFault::Denied(detail) => classify_denied(detail), other => retry_action(other), @@ -191,9 +202,12 @@ where } } } - // `ClientError` is non-exhaustive; a future case leaves the - // watch for the next tick. - Err(err) => tracing::error!("submit failed: {err}"), + // `ClientError` is non-exhaustive; a future case releases the + // reservation and leaves the watch for the next tick. + Err(err) => { + journal.release(&intent_id)?; + tracing::error!("submit failed: {err}"); + } } Ok(()) } diff --git a/crates/composable-cow/tests/sweep.rs b/crates/composable-cow/tests/sweep.rs index 567a1384..78db820d 100644 --- a/crates/composable-cow/tests/sweep.rs +++ b/crates/composable-cow/tests/sweep.rs @@ -9,7 +9,7 @@ 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::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::{MockHost, capture_tracing}; use videre_sdk::client::sealed::SealedTransport; @@ -702,6 +702,104 @@ fn restart_with_a_journalled_intent_does_not_repost() { ); } +/// Wraps a host and faults the commit write (the empty marker), letting +/// the non-empty reserve through: the post-accept record fault the +/// reservation is meant to survive. +struct FailCommit(MockHost); + +impl LocalStoreHost for FailCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.0.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + if value.is_empty() { + return Err(Fault::Unavailable("commit store down".into())); + } + self.0.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.0.list_keys(prefix) + } +} + +/// A record write that faults after an accepted submit leaves the +/// reservation, so the next tick does not re-post - the p1 defect. +#[test] +fn record_fault_after_acceptance_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(accepted()); + + let fail = FailCommit(host); + let source = { + let order = order.clone(); + FnSource(move |_: &FailCommit, _: WatchRef<'_>, _: &[u8], _: &Tick| ready_outcome(&order)) + }; + + // Reserve lands, submit is accepted, the commit write faults and is + // swallowed - the reservation persists. + run(&fail, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!(venue.submit_count(), 1); + assert!( + Journal::submitted(&fail) + .contains(&intent_id(&order)) + .unwrap(), + "the reservation survives a faulted commit", + ); + + // The next tick re-polls but the reservation blocks the re-post. + run(&fail, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!( + venue.submit_count(), + 1, + "an accepted body is never re-posted" + ); +} + +/// A retryable submit fault releases the reservation, so the body +/// reaches the venue again rather than being skipped for good. +#[test] +fn retryable_fault_releases_the_reservation_and_retries() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::Unavailable("orderbook http 502".into()))); + venue.enqueue_submit(accepted()); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert!( + !host + .store + .snapshot() + .keys() + .any(|k| k.starts_with("submitted:")), + "a released reservation leaves no marker", + ); + + run(&host, &client(&venue), &source, &sample_tick()).unwrap(); + assert_eq!( + venue.submit_count(), + 2, + "a retryable fault must not permanently skip the watch", + ); + assert!( + Journal::submitted(&host) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + // ---- the generic seam ---- /// The seam proof: a `Post` verdict reaches the venue transport as the diff --git a/crates/nexum-sdk/src/keeper.rs b/crates/nexum-sdk/src/keeper.rs index 726bdae4..8dbe2309 100644 --- a/crates/nexum-sdk/src/keeper.rs +++ b/crates/nexum-sdk/src/keeper.rs @@ -104,6 +104,12 @@ pub const OBSERVED_PREFIX: &str = "observed:"; /// little-endian. pub const REFUSED_PREFIX: &str = "refused:"; +/// Journal marker [`Journal::reserve`] writes before a submit, distinct +/// from the empty marker [`Journal::record`] commits so +/// [`Journal::release`] can drop a still-pending reservation without +/// touching a committed receipt. +const RESERVE_MARK: &[u8] = b"\x01"; + /// 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. @@ -288,8 +294,11 @@ 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. +/// prefix. Presence of the key is the receipt, so re-recording is +/// idempotent. [`record`](Journal::record) commits an empty marker; +/// [`reserve`](Journal::reserve) writes a distinct pre-submit marker a +/// [`release`](Journal::release) can drop, so a submit path can guard +/// against a re-post before its record write lands. pub struct Journal<'h, H> { host: &'h H, prefix: &'static str, @@ -314,17 +323,41 @@ impl<'h, H: LocalStoreHost> Journal<'h, H> { } } - /// Record the receipt. + /// Reserve `receipt` ahead of a submit: a non-empty marker written + /// before the venue call. Presence bars a re-post, so a record + /// write that faults after an accepted submit still leaves a + /// marker. [`record`](Self::record) commits it on acceptance; + /// [`release`](Self::release) drops it on any non-accepting + /// outcome. + pub fn reserve(&self, receipt: &str) -> Result<(), Fault> { + self.host.set(&self.key(receipt), RESERVE_MARK) + } + + /// Commit the receipt: the durable empty presence marker. + /// Overwrites a prior [`reserve`](Self::reserve) in place. pub fn record(&self, receipt: &str) -> Result<(), Fault> { - self.host.set(&format!("{}{receipt}", self.prefix), b"") + self.host.set(&self.key(receipt), b"") + } + + /// Drop a still-pending reservation so a non-accepting submit + /// re-posts next sweep. No-op once [`record`](Self::record) has + /// committed, leaving the durable receipt intact. + pub fn release(&self, receipt: &str) -> Result<(), Fault> { + let key = self.key(receipt); + if self.host.get(&key)?.as_deref() == Some(RESERVE_MARK) { + self.host.delete(&key)?; + } + Ok(()) } - /// Whether the receipt is already journalled. + /// Whether the receipt is journalled, reserved or committed. pub fn contains(&self, receipt: &str) -> Result { - Ok(self - .host - .get(&format!("{}{receipt}", self.prefix))? - .is_some()) + Ok(self.host.get(&self.key(receipt))?.is_some()) + } + + /// Prefixed store key for a receipt. + fn key(&self, receipt: &str) -> String { + format!("{}{receipt}", self.prefix) } } diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index 732a271a..c0418106 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -63,16 +63,20 @@ 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`]. The [`submission_key`] is checked against the - /// `submitted:` [`Journal`] before every submit and recorded on + /// [`Retrier`]. The [`submission_key`] is reserved on the + /// `submitted:` [`Journal`] before every submit and committed 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 sweep. - /// Store faults abort the sweep, bar the post-acceptance marker - /// clear; venue refusals never do - they fold into per-watch - /// retry actions. + /// best-effort. The reserve is a durable write before the venue + /// call, so a record write that faults after an accepted submit, or + /// a trap after acceptance, still leaves the marker and the next + /// sweep does not re-post. A non-accepting outcome releases the + /// reservation, so a `requires-signing` answer or a retryable + /// refusal is retried afresh. The residual runs the other way: a + /// crash between the reserve and the venue call strands the marker, + /// deferring an unsent body, never duplicating a sent one. Store + /// faults abort the sweep, bar the post-acceptance record and 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, @@ -106,12 +110,21 @@ impl Keeper { report.duplicates += 1; continue; } + // Reserve before the venue call: a record write that + // faults after acceptance still leaves this marker, + // so the next sweep does not re-post. + journal.reserve(&key)?; 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. + // Commit best-effort: the reservation already + // guards the re-post, so a record fault must + // not abort the sweep and unwind it. + if let Err(fault) = journal.record(&key) { + tracing::error!( + %fault, + "record write failed after accepted submit", + ); + } if let Err(fault) = retrier.clear_refusal(watch) { tracing::error!( %fault, @@ -122,10 +135,14 @@ impl Keeper { continue; } Ok(SubmitOutcome::RequiresSigning(tx)) => { + journal.release(&key)?; report.unsigned.push(tx); continue; } - Err(fault) => retry_action(&fault), + Err(fault) => { + journal.release(&key)?; + retry_action(&fault) + } } } Sweep::WaitBlock => RetryAction::TryNextBlock, @@ -204,7 +221,7 @@ pub fn retry_action(fault: &VenueFault) -> RetryAction { mod tests { use std::cell::RefCell; - use nexum_sdk::host::{Fault, LocalStoreHost as _}; + use nexum_sdk::host::{Fault, LocalStoreHost}; use nexum_sdk::keeper::{Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk::prelude::{Address, B256, hex, keccak256}; use nexum_sdk_test::MockLocalStore; @@ -324,6 +341,88 @@ mod tests { assert_eq!(venue.submitted.borrow().len(), 1); } + /// Wraps the mock and faults the commit write (the empty marker), + /// letting the non-empty reserve through: the post-accept record + /// fault the reservation is meant to survive. + struct FailCommit(MockLocalStore); + + impl LocalStoreHost for FailCommit { + fn get(&self, key: &str) -> Result>, Fault> { + self.0.get(key) + } + fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { + if value.is_empty() { + return Err(Fault::Unavailable("commit store down".into())); + } + self.0.set(key, value) + } + fn delete(&self, key: &str) -> Result<(), Fault> { + self.0.delete(key) + } + fn list_keys(&self, prefix: &str) -> Result, Fault> { + self.0.list_keys(prefix) + } + } + + #[test] + fn record_fault_after_acceptance_does_not_resubmit() { + let host = FailCommit(MockLocalStore::default()); + put_watch(&host.0); + let venue = StubVenue::new(Ok(SubmitOutcome::Accepted(vec![0xAA]))); + let keeper = keeper(Sweep::Submit(b"body".to_vec()), &venue); + + // Reserve lands, submit is accepted, the commit write faults and + // is swallowed - the reservation persists. + let report = + run(keeper.sweep(&host, &TICK)).expect("a record fault must not abort the sweep"); + assert_eq!(report.submitted, 1); + assert_eq!(venue.submitted.borrow().len(), 1); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + Journal::submitted(&host).contains(&key).expect("reads"), + "the reservation survives a faulted commit", + ); + + // The next sweep re-polls but the reservation blocks the re-post. + 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, + "an accepted body is never re-posted", + ); + } + + #[test] + fn retryable_submit_fault_still_retries() { + 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); + + // Each transient fault releases the reservation, so the body + // reaches the venue again rather than being skipped for good. + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep runs").retried, + 1 + ); + assert_eq!( + run(keeper.sweep(&host, &TICK)).expect("sweep runs").retried, + 1 + ); + assert_eq!( + venue.submitted.borrow().len(), + 2, + "a retryable fault must not permanently skip the watch", + ); + let key = format!("stub:{}", hex::encode_prefixed(keccak256(b"body"))); + assert!( + !Journal::submitted(&host).contains(&key).expect("reads"), + "a released reservation leaves no marker", + ); + } + #[test] fn accepted_body_clears_the_refusal_marker() { let host = MockLocalStore::default();