diff --git a/Cargo.lock b/Cargo.lock index b2b98b85..dddca7c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5930,13 +5930,14 @@ dependencies = [ "alloy-primitives", "alloy-sol-types", "composable-cow", + "cow-venue", "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", "shepherd-sdk", - "shepherd-sdk-test", "tracing", + "videre-sdk", "wit-bindgen 0.59.0", ] diff --git a/Dockerfile b/Dockerfile index 1fbaba16..bc53f0fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,9 +128,12 @@ COPY --from=build /src/modules/examples/price-alert/module.toml /opt/shepher COPY --from=build /src/modules/examples/balance-tracker/module.toml /opt/shepherd/manifests/balance-tracker.toml COPY --from=build /src/modules/examples/stop-loss/module.toml /opt/shepherd/manifests/stop-loss.toml -# The bundled cow venue adapter's manifest; installed via the +# The bundled cow venue adapter's manifests; installed via the # engine.toml [[adapters]] stanza, never compiled into the engine. +# One manifest per chain: mainnet (cow-venue.toml) and Sepolia +# (cow-venue.sepolia.toml); pick the one matching the run's chain. COPY --from=build /src/crates/cow-venue/module.toml /opt/shepherd/manifests/cow-venue.toml +COPY --from=build /src/crates/cow-venue/module.sepolia.toml /opt/shepherd/manifests/cow-venue.sepolia.toml # Drop privileges. The engine never needs root at runtime: it only # reads /etc/shepherd/engine.toml, writes to /var/lib/shepherd, and diff --git a/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml new file mode 100644 index 00000000..b28b4a4a --- /dev/null +++ b/crates/cow-venue/module.load.toml @@ -0,0 +1,24 @@ +# Load-test variant of the cow adapter manifest: Sepolia chain id with +# the orderbook re-pointed at tools/orderbook-mock (no live cow.fi). + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["localhost"] + +[config] +chain = "11155111" +orderbook-url = "http://localhost:9999" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml new file mode 100644 index 00000000..35635f09 --- /dev/null +++ b/crates/cow-venue/module.sepolia.toml @@ -0,0 +1,24 @@ +# Sepolia variant of the cow adapter manifest: same component, chain +# 11155111, so submits land on the Sepolia orderbook. Wire this from +# any engine config whose watchers index Sepolia. + +[module] +name = "cow" +version = "0.1.0" +kind = "venue-adapter" +# Placeholder content hash; parsed but not verified in 0.2. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["http"] +optional = [] + +[capabilities.http] +allow = ["api.cow.fi"] + +[config] +chain = "11155111" + +# Body-schema versions this adapter decodes: the handshake authority. +[venue] +body_versions = [1] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml index b660014b..0e5b555c 100644 --- a/crates/cow-venue/module.toml +++ b/crates/cow-venue/module.toml @@ -19,7 +19,8 @@ allow = ["api.cow.fi"] # One adapter instance speaks one chain's orderbook. `orderbook-url`, # `owner` (enables the pre-sign path), and `http-timeout-ms` are -# optional overrides. +# optional overrides. Sepolia and load-mock variants sit alongside +# (`module.sepolia.toml`, `module.load.toml`). [config] chain = "1" diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 99080dac..3953ab6a 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -144,32 +144,6 @@ async fn boot_production_module( .expect("boot_single") } -/// twap-monitor imports `shepherd:cow/cow-api`; with the cow extension -/// registered it boots, and a block dispatch reaches it and keeps it alive. -#[tokio::test] -async fn e2e_twap_monitor_block_dispatch() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, store) = temp_local_store(); - - let mut supervisor = boot_production_module(&engine, &linker, &store, &wasm, &manifest).await; - assert_eq!(supervisor.module_count(), 1); - assert_eq!(supervisor.alive_count(), 1); - - // twap-monitor subscribes to Sepolia blocks (poll path). A real poll - // would call chain::request, which ProviderPool::empty() does not - // satisfy - the module surfaces a fault and warns; the supervisor - // must keep the module alive because the strategy catches the error - // and returns Ok(()). - let dispatched = supervisor.dispatch_block(synthetic_sepolia_block()).await; - assert_eq!(dispatched, 1); - assert_eq!(supervisor.alive_count(), 1); -} - /// ethflow-watcher imports `shepherd:cow/cow-api` and subscribes to logs; /// it boots with the cow extension and a synthetic log is delivered. #[tokio::test] @@ -221,17 +195,17 @@ async fn e2e_stop_loss_block_dispatch() { } /// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the +/// a module that imports `shepherd:cow/cow-api` (ethflow-watcher) must +/// NOT boot when the cow extension is absent from the linker AND the /// capability registry. The paired linker-hook + capability-namespace /// registration is what makes the same module boot in the tests above; /// drop the pairing and boot fails. #[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { +async fn ethflow_watcher_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("ethflow-watcher") else { return; }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let manifest = production_module_toml("modules/ethflow-watcher/module.toml"); let engine = make_wasmtime_engine(); // Core-only: no cow linker hook, no cow capability namespace. let linker = build_linker::(&engine, &[]).expect("build_linker"); @@ -254,10 +228,10 @@ async fn twap_monitor_without_cow_extension_fails_to_boot() { let err = result .err() .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. + // Pin the failure to its specific cause: ethflow-watcher declares + // the cow-api capability, which a core-only registry does not + // recognise (registering it is exactly what the cow extension does). + // Rules out an unrelated failure masquerading as the invariant. let chain = format!("{err:#}"); assert!( chain.contains(r#"unknown capability "cow-api""#), diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs index ecc3c79e..ace107a8 100644 --- a/crates/videre-host/tests/platform.rs +++ b/crates/videre-host/tests/platform.rs @@ -709,6 +709,52 @@ async fn e2e_keeper_module_drives_the_venue_through_the_typed_client() { } } +/// The shepherd bundle pair: twap-monitor (a `#[videre_sdk::keeper]` +/// worker) boots against the installed cow adapter - the body-version +/// handshake admits the pair - and a Sepolia block dispatch reaches it +/// and keeps it alive. The chainless poll surfaces a fault the strategy +/// absorbs, so no orderbook traffic occurs. +#[tokio::test] +async fn e2e_twap_monitor_boots_against_the_cow_adapter() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("cow-venue"), + module_wasm_or_skip("twap-monitor"), + ) else { + return; + }; + + let components = mock_components(); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("crates/cow-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/twap-monitor/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!(supervisor.adapter_alive_count(), 1, "cow is routable"); + assert_eq!(supervisor.alive_count(), 1, "twap-monitor is alive"); + + // twap-monitor subscribes to Sepolia blocks (poll path); with no + // watches indexed the sweep is empty and the keeper stays alive. + assert_eq!(supervisor.dispatch_block(block(11_155_111)).await, 1); + assert_eq!(supervisor.alive_count(), 1); +} + /// The body-version handshake refuses a mismatched pair: an adapter /// decoding only v1 against a keeper encoding v2 fails the boot at the /// keeper's install, before instantiation, naming both sides' versions. diff --git a/engine.docker.toml b/engine.docker.toml index 151f3de8..e2e697a3 100644 --- a/engine.docker.toml +++ b/engine.docker.toml @@ -82,9 +82,11 @@ manifest = "/opt/shepherd/manifests/stop-loss.toml" # # The bundled cow venue adapter: the pool router resolves the `cow` # venue id through it. The operator grant scopes its outbound HTTP to -# the production orderbook. +# the production orderbook host. The Sepolia manifest matches +# twap-monitor's Sepolia subscriptions; a mainnet run swaps in +# /opt/shepherd/manifests/cow-venue.toml. [[adapters]] path = "/opt/shepherd/modules/cow_venue.wasm" -manifest = "/opt/shepherd/manifests/cow-venue.toml" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" http_allow = ["api.cow.fi"] diff --git a/engine.e2e.toml b/engine.e2e.toml index 330774b7..31960778 100644 --- a/engine.e2e.toml +++ b/engine.e2e.toml @@ -65,3 +65,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.example.toml b/engine.example.toml index 77dc3145..be346456 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -107,7 +107,9 @@ rpc_url = "${BASE_RPC_URL}" # The operator, not the adapter author, grants the transport scope: # `http_allow` is the outbound wasi:http host allowlist. The bundled # cow adapter builds with `just build-cow-venue`; its venue id is the -# manifest name (`cow`). +# manifest name (`cow`). One manifest per chain: `module.toml` is +# mainnet, `module.sepolia.toml` Sepolia - wire the one matching the +# chain the submitting modules index. # [[adapters]] # path = "target/wasm32-wasip2/release/cow_venue.wasm" diff --git a/engine.load.toml b/engine.load.toml index 7999669d..44924b49 100644 --- a/engine.load.toml +++ b/engine.load.toml @@ -41,6 +41,15 @@ rpc_url = "ws://localhost:8545" path = "./target/wasm32-wasip2/release/twap_monitor.wasm" manifest = "./modules/twap-monitor/module.toml" +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Load-variant manifest: Sepolia chain id with the +# orderbook re-pointed at tools/orderbook-mock, matching +# [extensions.cow.orderbook_urls] above. +[[adapters]] +path = "./target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "./crates/cow-venue/module.load.toml" +http_allow = ["localhost"] + [[modules]] path = "./target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "./modules/ethflow-watcher/module.toml" diff --git a/engine.m2.toml b/engine.m2.toml index cce435c3..11f29389 100644 --- a/engine.m2.toml +++ b/engine.m2.toml @@ -33,3 +33,11 @@ manifest = "modules/twap-monitor/module.toml" [[modules]] path = "target/wasm32-wasip2/release/ethflow_watcher.wasm" manifest = "modules/ethflow-watcher/module.toml" + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.docker.toml b/engine.soak.docker.toml index 3f29ea10..7efd05cc 100644 --- a/engine.soak.docker.toml +++ b/engine.soak.docker.toml @@ -46,3 +46,12 @@ manifest = "/opt/shepherd/manifests/balance-tracker.toml" [[modules]] path = "/opt/shepherd/modules/stop_loss.wasm" manifest = "/opt/shepherd/manifests/stop-loss.toml" + +# --- adapters ----------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through. Sepolia +# manifest: the adapter's orderbook must match the chain twap indexes. +[[adapters]] +path = "/opt/shepherd/modules/cow_venue.wasm" +manifest = "/opt/shepherd/manifests/cow-venue.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/engine.soak.toml b/engine.soak.toml index 8f6cabf8..ba3e78d7 100644 --- a/engine.soak.toml +++ b/engine.soak.toml @@ -9,6 +9,8 @@ # cargo build --target wasm32-wasip2 --release \ # -p twap-monitor -p ethflow-watcher -p price-alert \ # -p balance-tracker -p stop-loss +# cargo build --target wasm32-wasip2 --release \ +# -p cow-venue --features cow-venue/adapter # ./target/release/nexum --engine-config engine.soak.toml # # Swap rpc_url below for your paid endpoint before starting, or set it @@ -66,3 +68,13 @@ manifest = "modules/examples/balance-tracker/module.toml" [[modules]] path = "target/wasm32-wasip2/release/stop_loss.wasm" manifest = "modules/examples/stop-loss/module.toml" + +# --- adapters --------------------------------------------------------- + +# The cow venue adapter twap-monitor submits through (`just +# build-cow-venue`). Sepolia manifest: the adapter's orderbook must +# match the chain twap indexes. +[[adapters]] +path = "target/wasm32-wasip2/release/cow_venue.wasm" +manifest = "crates/cow-venue/module.sepolia.toml" +http_allow = ["api.cow.fi"] diff --git a/justfile b/justfile index 6f410367..1d22c5c2 100644 --- a/justfile +++ b/justfile @@ -43,7 +43,7 @@ build-m2: # (Sepolia, both M2 modules). See `docs/operations/m2-testnet-runbook.md`. # --pretty-logs keeps the runbook-friendly human-readable formatter; # production deploys omit the flag and emit JSON. -run-m2: build-m2 build-engine +run-m2: build-m2 build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.m2.toml --pretty-logs # Build the M3 example modules (price-alert + balance-tracker + stop-loss) @@ -74,7 +74,7 @@ build-e2e: build-m2 build-m3 # 127.0.0.1:9100/metrics. JSON logs (no --pretty-logs) so a # downstream `jq` filter can mine submitted/dropped/backoff markers # for the e2e report. See `docs/operations/e2e-testnet-runbook.md`. -run-e2e: build-e2e build-engine +run-e2e: build-e2e build-cow-venue build-engine cargo run -p shepherd -- --engine-config engine.e2e.toml # Zero-leak gate: host-layer crate graphs, runtime charter-symbol and diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 5533d59a..acacdadb 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -10,8 +10,10 @@ crate-type = ["cdylib"] [dependencies] composable-cow = { path = "../../crates/composable-cow" } +cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } +videre-sdk = { path = "../../crates/videre-sdk" } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } @@ -19,6 +21,6 @@ tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.59", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml index 3ef1883d..d7f45ca2 100644 --- a/modules/twap-monitor/module.toml +++ b/modules/twap-monitor/module.toml @@ -1,5 +1,5 @@ # twap-monitor: poll registered ComposableCoW conditional orders and -# submit ready ones via the CoW Protocol orderbook. +# submit ready ones to the CoW venue through the pool. [module] name = "twap-monitor" @@ -14,13 +14,13 @@ component = "sha256:000000000000000000000000000000000000000000000000000000000000 # - local-store -> watch: / next_block: / next_epoch: / submitted: / # backoff: / dropped: persistence # - chain -> eth_call into ComposableCoW.getTradeableOrderWithSignature -# - cow-api -> POST /api/v1/orders submission path -required = ["logging", "local-store", "chain", "cow-api"] +# - client -> videre:venue/client submit path to the cow adapter +required = ["logging", "local-store", "chain", "client"] optional = [] [capabilities.http] -# All outbound HTTP goes through `cow-api` (which routes through the -# host's pinned orderbook URL); no direct `http` calls. +# All outbound HTTP is the cow adapter's; the keeper makes no direct +# `http` calls. allow = [] # --- subscriptions ------------------------------------------------------ @@ -40,3 +40,13 @@ event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2c [[subscription]] kind = "block" chain_id = 11155111 + +# Status transitions the registry polls back for submitted orders. +[[subscription]] +kind = "intent-status" +venue = "cow" + +# The one body-schema version this keeper encodes; install refuses the +# keeper unless every installed venue adapter decodes it. +[venue] +body_version = 1 diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index fda3ac82..562a86d8 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -1,21 +1,19 @@ -//! # twap-monitor (Shepherd module) +//! # twap-monitor (Shepherd keeper module) //! //! Indexes `ComposableCoW.ConditionalOrderCreated` logs and polls each -//! watched conditional order on every block, submitting tranches to -//! the CoW orderbook as they go live. +//! watched conditional order on every block, submitting tranches to the +//! CoW venue through the pool as they go live. //! //! ## Module layout //! -//! - `strategy.rs` holds the pure logic and unit tests against -//! `nexum_sdk::host::Host`. It does not know `wit-bindgen` -//! exists. -//! - `lib.rs` (this file) is the per-cdylib glue: wit-bindgen import -//! shims, the `WitBindgenHost` adapter that bridges the generated -//! free functions to the SDK traits, and the `Guest` impl that -//! delegates each event variant to `strategy`. -//! -//! Same recipe as `modules/examples/price-alert` and -//! `modules/examples/stop-loss`. +//! - `strategy.rs` holds the pure logic and unit tests against the +//! `nexum_sdk::host` trait seams and the videre `VenueTransport` +//! seam. It does not know `wit-bindgen` exists. +//! - `lib.rs` (this file) is the `#[videre_sdk::keeper]` glue: the +//! macro derives the component world from `module.toml`, emits the +//! `WitBindgenHost` adapter, and dispatches each event variant to +//! `strategy` with the typed [`CowClient`] over the module's own +//! `videre:venue/client` import. // wit_bindgen::generate! expands to host-import shims whose arity // matches the WIT signatures, which can exceed clippy's @@ -23,52 +21,45 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![allow(clippy::too_many_arguments)] -wit_bindgen::generate!({ - path: [ - "../../wit/nexum-host", - "../../wit/shepherd-cow", - ], - world: "shepherd:cow/shepherd", - generate_all, -}); - mod strategy; +use cow_venue::CowClient; use nexum::host::types; -// `WitBindgenHost` and the fault and level `From` impls -// are generated below. Single source of truth in `nexum-sdk` + `shepherd-sdk`. -shepherd_sdk::bind_cow_host_via_wit_bindgen!(); - struct TwapMonitor; -impl Guest for TwapMonitor { +#[videre_sdk::keeper] +impl TwapMonitor { fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { install_tracing(); tracing::info!("twap-monitor init"); Ok(()) } - fn on_event(event: types::Event) -> Result<(), Fault> { - match event { - types::Event::ChainLogs(batch) => { - let logs: Vec = - batch.logs.into_iter().map(Into::into).collect(); - strategy::on_chain_logs(&WitBindgenHost, &logs)?; - } - types::Event::Block(block) => { - let info = strategy::BlockInfo { - chain_id: block.chain_id, - number: block.number, - timestamp: block.timestamp, - }; - strategy::on_block(&WitBindgenHost, info)?; - } - // Tick / Message are not used by this module. - _ => {} - } + fn on_chain_logs(batch: types::ChainLogs) -> Result<(), Fault> { + let logs: Vec = batch.logs.into_iter().map(Into::into).collect(); + strategy::on_chain_logs(&WitBindgenHost, &logs)?; + Ok(()) + } + + fn on_block(block: types::Block) -> Result<(), Fault> { + let info = strategy::BlockInfo { + chain_id: block.chain_id, + number: block.number, + timestamp: block.timestamp, + }; + strategy::on_block(&WitBindgenHost, &CowClient::new(), info)?; Ok(()) } -} -export!(TwapMonitor); + fn on_intent_status(update: types::IntentStatusUpdate) -> Result<(), Fault> { + let body = nexum_sdk::status_body::StatusBody::decode(&update.status) + .map_err(|err| Fault::InvalidInput(err.to_string()))?; + tracing::info!( + "cow intent status {:?} ({} receipt bytes)", + body.status, + update.receipt.len(), + ); + Ok(()) + } +} diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index d5426d58..15affe60 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -1,30 +1,34 @@ //! Pure strategy logic for the twap-monitor module. //! -//! Every interaction with the world flows through the -//! `nexum_sdk::host::Host` trait seam - no direct calls to wit- -//! bindgen-generated free functions live here. The `lib.rs` glue -//! wraps a `WitBindgenHost` adapter around the per-cdylib wit-bindgen -//! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under -//! `#[cfg(test)]` hand the same functions a -//! `shepherd_sdk_test::MockHost`. +//! Every interaction with the world flows through a trait seam: the +//! `nexum_sdk::host` traits for chain and store access and the videre +//! [`VenueTransport`] under the typed [`CowClient`] for submission - +//! no direct calls to wit-bindgen-generated free functions live here. +//! The `lib.rs` glue hands [`on_chain_logs`] / [`on_block`] the +//! `WitBindgenHost` adapter and the client over the module's own +//! `videre:venue/client` import; tests under `#[cfg(test)]` hand the +//! same functions a `nexum_sdk_test::MockHost` and a scripted +//! transport. //! //! The module owns decode and evaluate only: log decoding into the //! keeper watch set, and the `getTradeableOrderWithSignature` poll //! behind [`ConditionalSource`]. Gate discipline, the `submitted:` -//! journal, submission, and retry dispatch live in the shared -//! composition (`shepherd_sdk::cow::run`). +//! journal, submission through the pool, and retry dispatch live in +//! the shared composition (`shepherd_sdk::cow::run`). use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use composable_cow::{LegacyRevertAdapter, Verdict}; +use cow_venue::CowClient; use cowprotocol::{ COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; -use nexum_sdk::host::{ChainError, Fault}; +use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowApiTransport, CowClient, CowHost, events, run}; +use shepherd_sdk::cow::{events, run}; +use videre_sdk::VenueTransport; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -61,7 +65,7 @@ mod abi { /// Indexer entry: decode every `ComposableCoW.ConditionalOrderCreated` /// chain-log in a dispatch batch and persist its watch. -pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { +pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { for log in logs { if let Some((owner, params)) = decode_conditional_order_created(log) { persist_watch(host, owner, ¶ms)?; @@ -71,17 +75,20 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { } /// Poll entry: run the keeper over every gate-ready watch through the -/// shared composition, submitting through the typed client on the -/// transitional cow-api bridge. The block timestamp arrives in -/// milliseconds; the tick carries Unix seconds. -pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { +/// shared composition, submitting through the typed client onto the +/// pool. The block timestamp arrives in milliseconds; the tick carries +/// Unix seconds. +pub fn on_block(host: &H, venue: &CowClient, block: BlockInfo) -> Result<(), Fault> +where + H: ChainHost + LocalStoreHost, + T: VenueTransport, +{ let tick = Tick { chain_id: block.chain_id, block: block.number, epoch_s: block.timestamp / 1000, }; - let venue = CowClient::with_transport(CowApiTransport::new(host, tick.chain_id)); - run(host, &venue, &TwapSource, &tick) + run(host, venue, &TwapSource, &tick) } // ---- indexing path ---- @@ -99,7 +106,7 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr /// The watch set overwrites in place, so re-indexing the same log /// (re-org replay, overlapping subscription windows) produces no /// observable side effect. -fn persist_watch( +fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, @@ -118,7 +125,7 @@ fn persist_watch( /// down the sweep. struct TwapSource; -impl ConditionalSource for TwapSource { +impl ConditionalSource for TwapSource { type Outcome = Verdict; fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { @@ -143,7 +150,7 @@ impl ConditionalSource for TwapSource { } } -fn poll_one( +fn poll_one( host: &H, chain_id: u64, owner: &Address, @@ -225,7 +232,7 @@ fn outcome_label(o: &Verdict) -> &'static str { // ---- test-only seam mirrors ---- // -// Thin views over the keeper / SDK canon so the dispatch tests can +// Thin views over the keeper / venue canon so the dispatch tests can // seed and inspect the store in the exact shapes production writes. #[cfg(test)] @@ -238,30 +245,108 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { - use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; - let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; - shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { - order: shepherd_sdk::cow::order_data_to_body(&order_data), +fn signed_intent_body( + order: &GPv2OrderData, + signature: &Bytes, + owner: Address, +) -> Option { + use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; + use cow_venue::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = gpv2_to_order_data(order)?; + Some(CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), owner: owner.into_array(), signature: signature.to_vec(), }))) - .ok() +} + +#[cfg(test)] +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + cow_venue::intent_id(&signed_intent_body(order, signature, owner)?).ok() } #[cfg(test)] mod tests { - use super::*; + use std::cell::RefCell; + use std::collections::VecDeque; + use alloy_primitives::{B256, U256, address, b256, hex}; + use cow_venue::CowVenue; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; - use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::{CowApiError, OrderRejection}; - use shepherd_sdk_test::MockHost; + use nexum_sdk_test::{MockHost, capture_tracing}; + use videre_sdk::client::sealed::SealedTransport; + use videre_sdk::{ + IntentBody as _, IntentStatus, Quotation, SubmitOutcome, Venue as _, VenueFault, VenueId, + }; + + use super::*; const SEPOLIA: u64 = 11_155_111; + /// Scripted [`VenueTransport`]: one submit outcome per queued entry, + /// every submit recorded. Quote, status, and cancel are off the + /// module's poll path. + #[derive(Default)] + struct MockVenue { + outcomes: RefCell>>, + submits: RefCell)>>, + } + + impl MockVenue { + fn enqueue_submit(&self, outcome: Result) { + self.outcomes.borrow_mut().push_back(outcome); + } + + fn submits(&self) -> Vec<(String, Vec)> { + self.submits.borrow().clone() + } + + fn submit_count(&self) -> usize { + self.submits.borrow().len() + } + } + + impl SealedTransport for &MockVenue {} + + impl VenueTransport for &MockVenue { + async fn quote(&self, _venue: &VenueId, _body: Vec) -> Result { + unreachable!("quote not exercised") + } + + async fn submit( + &self, + venue: &VenueId, + body: Vec, + ) -> Result { + self.submits.borrow_mut().push((venue.to_string(), body)); + self.outcomes.borrow_mut().pop_front().unwrap_or_else(|| { + Err(VenueFault::Unavailable( + "MockVenue: unscripted submit".into(), + )) + }) + } + + async fn status( + &self, + _venue: &VenueId, + _receipt: &[u8], + ) -> Result { + unreachable!("status not exercised") + } + + async fn cancel(&self, _venue: &VenueId, _receipt: &[u8]) -> Result<(), VenueFault> { + unreachable!("cancel not exercised") + } + } + + /// Dispatch one block through `on_block` with the typed client over + /// the scripted transport. + fn dispatch(host: &MockHost, venue: &MockVenue, block: BlockInfo) -> Result<(), Fault> { + on_block(host, &CowClient::with_transport(venue), block) + } + /// `validTo` a given number of seconds from now. The constructor's /// client-side max-horizon policy reads the wall clock (not the /// block clock), so test orders must expire relative to it. @@ -384,7 +469,7 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - // ---- MockHost dispatch tests ---- + // ---- MockHost + MockVenue dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed /// `ConditionalOrderCreated`, assembled through the same WIT-edge @@ -478,6 +563,7 @@ mod tests { #[test] fn poll_skips_when_next_block_gate_is_in_future() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("00112233445566778899aabbccddeeff00112233"); let params = sample_params(); let key = seed_watch(&host, owner, ¶ms); @@ -491,19 +577,20 @@ mod tests { ) .unwrap(); - on_block(&host, sample_block(100)).unwrap(); + dispatch(&host, &venue, sample_block(100)).unwrap(); assert_eq!( host.chain.call_count(), 0, "gated watch must not issue eth_call" ); - assert_eq!(host.cow_api.call_count(), 0); + assert_eq!(venue.submit_count(), 0); } #[test] - fn poll_ready_submits_order_and_persists_the_intent_id() { + fn poll_ready_submits_the_intent_body_through_the_pool() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -516,14 +603,28 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); + let expected_body = signed_intent_body(&ready_order, &signature, owner) + .expect("canonical markers") + .to_bytes() + .expect("body encodes"); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); - assert_eq!(host.cow_api.call_count(), 1); + let submits = venue.submits(); + assert_eq!(submits.len(), 1); + assert_eq!( + submits[0].0, + CowVenue::ID.as_str(), + "routed to the cow venue" + ); + assert_eq!( + submits[0].1, expected_body, + "the wire carries the intent body" + ); assert!( host.store .snapshot() @@ -532,21 +633,22 @@ mod tests { ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the pre-submit intent-id, not the server receipt" + "marker must key on the pre-submit intent-id, not the venue receipt" ); } /// Regression guard: when `getTradeableOrderWithSignature` /// returns the same Ready tuple in consecutive poll-ticks (the /// on-chain conditional order does not know shepherd already - /// POSTed it), the second tick must NOT call `submit_order` - /// again. Without the guard the orderbook responds with - /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{intent_id}` - /// short-circuit at the top of `submit_ready`. + /// posted it), the second tick must NOT submit again. Without the + /// guard the venue refuses the duplicate and a Warn fires for what + /// is in fact correct, finished work. The guard is the + /// `submitted:{intent_id}` short-circuit at the top of + /// `submit_ready`. #[test] fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -562,14 +664,14 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the - // orderbook submit must not be attempted. + // venue submit must not be attempted. let already_submitted = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), @@ -577,28 +679,20 @@ mod tests { "poll still consults the chain to see Ready", ); assert_eq!( - host.cow_api.call_count(), + venue.submit_count(), 0, - "submit_order must NOT be called when submitted:{{intent_id}} already exists", - ); - assert_eq!( - host.cow_api.request_calls().len(), - 0, - "the REST passthrough must NOT be touched - the guard short-circuits early", + "the pool must NOT be touched when submitted:{{intent_id}} already exists", ); } - /// A Ready order with a non-empty `appData` digest submits the - /// digest verbatim as the hash-only wire shape: `appData` carries - /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook - /// GET runs first - watch-tower parity. The absence of - /// `appDataHash` is load-bearing: with both fields present the - /// orderbook reads the body as the full-document shape and rejects - /// it for a digest mismatch. + /// A Ready order with a non-empty `appData` digest rides the intent + /// body verbatim: assembly into the orderbook wire shape is the + /// adapter's, so the keeper ships exactly the digest the chain + /// returned - watch-tower parity. #[test] - fn poll_ready_submits_non_empty_app_data_hash_only() { - use alloy_primitives::keccak256; + fn poll_ready_carries_a_non_empty_app_data_digest_in_the_body() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); @@ -614,28 +708,25 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); - host.cow_api.respond(Ok("0xfeedface".to_string())); + venue.enqueue_submit(Ok(SubmitOutcome::Accepted(hex!("feedface").to_vec()))); - on_block(&host, sample_block(1_000)).unwrap(); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); assert_eq!( host.chain.call_count(), 1, "exactly one eth_call to poll Ready" ); - assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); - assert!( - host.cow_api.request_calls().is_empty(), - "no appData GET before submit - the digest goes out verbatim", - ); - let body = host.cow_api.last_body_as_json().expect("body is JSON"); + let submits = venue.submits(); + assert_eq!(submits.len(), 1, "exactly one pool submit"); + let cow_venue::CowIntentBody::V1(cow_venue::CowIntent::Signed(signed)) = + cow_venue::CowIntentBody::from_bytes(&submits[0].1).expect("body decodes") + else { + panic!("expected a signed V1 intent"); + }; assert_eq!( - body["appData"], - format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), - ); - assert!( - body.get("appDataHash").is_none(), - "hash-only body must omit appDataHash, got: {body}" + signed.order.app_data, app_data_hash.0, + "the digest goes out verbatim in the body", ); let expected_id = compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); @@ -648,8 +739,9 @@ mod tests { } #[test] - fn submit_transient_error_leaves_state_unchanged_for_next_block() { + fn submit_transient_fault_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -663,17 +755,13 @@ mod tests { Ok(quoted_hex(&wire)), ); - // InsufficientFee classifies as TryNextBlock per the - // retriable-error classifier. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InsufficientFee".into(), - description: "fee too low".into(), - data: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + // The adapter projects a retriable rejection onto `unavailable`, + // which the retry table folds to TryNextBlock. + venue.enqueue_submit(Err(VenueFault::Unavailable( + "InsufficientFee: fee too low".into(), + ))); + + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); // Watch still present, no gate written, no submitted marker. @@ -697,9 +785,13 @@ mod tests { }); } + /// The venue's throttle hint survives the pool seam: a rate-limited + /// refusal backs the watch off on the epoch clock instead of + /// hot-looping the submit every block. #[test] - fn submit_permanent_error_drops_watch() { + fn submit_rate_limited_backs_off_on_the_epoch_gate() { let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -712,22 +804,55 @@ mod tests { programmed_eth_call_params(owner, ¶ms), Ok(quoted_hex(&wire)), ); + venue.enqueue_submit(Err(VenueFault::RateLimited { + retry_after_ms: Some(2_500), + })); - // InvalidSignature classifies as Drop. - host.cow_api - .respond(Err(CowApiError::Rejected(OrderRejection { - status: 400, - error_type: "InvalidSignature".into(), - description: "bad sig".into(), - data: None, - }))); + dispatch(&host, &venue, sample_block(1_000)).unwrap(); - on_block(&host, sample_block(1_000)).unwrap(); + let snapshot = host.store.snapshot(); + assert!( + snapshot.contains_key(&watch_key_str), + "backoff must keep the watch" + ); + let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); + assert_eq!( + snapshot + .get(&format!("next_epoch:{owner_hex}:{hash_hex}")) + .unwrap(), + &(1_700_000_000_u64 + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); + } + + #[test] + fn submit_denied_drops_watch() { + let host = MockHost::new(); + let venue = MockVenue::default(); + let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); + let params = sample_params(); + let watch_key_str = seed_watch(&host, owner, ¶ms); + + let ready_order = submittable_order(); + let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (ready_order, signature).abi_encode_params(); + host.chain.respond_to( + "eth_call", + programmed_eth_call_params(owner, ¶ms), + Ok(quoted_hex(&wire)), + ); + + // The adapter projects a permanent rejection onto `denied`, + // which the retry table folds to Drop. + venue.enqueue_submit(Err(VenueFault::Denied("InvalidSignature: bad sig".into()))); + + dispatch(&host, &venue, sample_block(1_000)).unwrap(); let store = host.store.snapshot(); assert!( !store.contains_key(&watch_key_str), - "permanent error must drop the watch" + "permanent refusal must drop the watch" ); let (owner_hex, hash_hex) = parse_watch_key(&watch_key_str).unwrap(); assert!(!store.contains_key(&format!("next_block:{owner_hex}:{hash_hex}"))); @@ -746,6 +871,7 @@ mod tests { use nexum_sdk::host::RpcError; let host = MockHost::new(); + let venue = MockVenue::default(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); let watch_key_str = seed_watch(&host, owner, ¶ms); @@ -771,7 +897,7 @@ mod tests { })), ); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); + let (result, logs) = capture_tracing(|| dispatch(&host, &venue, sample_block(1_000))); result.unwrap(); assert!(!host.store.snapshot().contains_key(&watch_key_str)); @@ -781,11 +907,7 @@ mod tests { .snapshot() .contains_key(&format!("next_block:{owner_hex}:{hash_hex}")), ); - assert_eq!( - host.cow_api.call_count(), - 0, - "revert-to-drop path never submits" - ); + assert_eq!(venue.submit_count(), 0, "revert-to-drop path never submits"); // The destructive drop carries its cause: the revert selector // and the node's message ride the Warn, and the keeper logs // the removal itself.