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; -}