From 14b53381b6c86f5343b4af457389cb9c52c943ea Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 6 Jul 2026 04:15:31 +0000 Subject: [PATCH 1/4] feat(cow-ext): carry the orderbook rejection data payload to the guest The orderbook rejection envelope has an optional structured data field (e.g. a minimum-fee quote) that the retired host-error envelope round-tripped in full but the typed order-rejection dropped. Add data: option to the record, populate it host-side from the parsed envelope, and thread it through the SDK mirror and the wit-bindgen conversion so no rejection information is lost end to end. From a90e12a11a0f0562cf1003e50da19c93fc6f86b4 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 6 Jul 2026 04:15:43 +0000 Subject: [PATCH 2/4] refactor(cow-sdk): drop the stale ApiError prelude re-export Nothing guest-side decodes the orderbook ApiError JSON any more: the host parses the envelope once into the typed order-rejection and the classifier dispatches on that. The re-export and its comment described the retired flow, so remove them from the prelude surface. From 8995c84321ee1117876ab0768f7c265a67e0dd54 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 8 Jul 2026 12:00:43 +0000 Subject: [PATCH 3/4] docs(cow): fix the OrderRejection.data and prelude surface docs From aeee3d0c9351ef6ec53cdbb27759502b1547fa19 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 6 Jul 2026 15:27:59 +0000 Subject: [PATCH 4/4] feat(sdk + twap-monitor): submit appData hash-only and retire resolve_app_data The orderbook accepts OrderCreation appData as the bare signed digest and joins the pre-registered document on its side, so the pre-submit GET /api/v1/app_data/{hash} was a redundant round-trip against the same database that validates the submission, plus a 404 dead end watch-tower does not have. Bump cowprotocol to 0.2.0, patched to the cow-rs rev that exposes OrderCreation::new_app_data_hash_only (the OrderCreationAppData::Hash wire shape, which omits appDataHash - the orderbook's untagged decoder would otherwise read the body as the full-document shape and reject it) and scopes the js_sys clock fallback to browser wasm32 so the constructor's valid_to horizon check reads WASI SystemTime instead of emitting a wasm-bindgen import no wasmtime host can satisfy. twap-monitor now submits order.appData verbatim; an unregistered digest rejects as InvalidAppData and classify_api_error dispatches the backoff, matching watch-tower exactly. Delete shepherd-sdk's resolve_app_data module and the orderbook-mock's now-unused appData GET route, and drop the sdk's serde_json dependency that existed only for that lookup. --- Cargo.lock | 42 ++- Cargo.toml | 10 +- .../src/cow_orderbook/tests.rs | 2 +- crates/shepherd-sdk/Cargo.toml | 3 +- crates/shepherd-sdk/README.md | 4 +- crates/shepherd-sdk/src/cow/app_data.rs | 269 ------------------ crates/shepherd-sdk/src/cow/mod.rs | 4 +- crates/shepherd-sdk/src/lib.rs | 6 +- docs/00-overview.md | 2 +- docs/08-platform-generalisation.md | 2 +- docs/operations/load-testnet-runbook.md | 5 +- modules/ethflow-watcher/Cargo.toml | 2 +- modules/examples/stop-loss/Cargo.toml | 2 +- modules/examples/stop-loss/src/strategy.rs | 2 +- modules/twap-monitor/Cargo.toml | 2 +- modules/twap-monitor/src/strategy.rs | 261 +++++------------ tools/load-gen/src/main.rs | 6 +- tools/orderbook-mock/src/main.rs | 47 +-- 18 files changed, 117 insertions(+), 554 deletions(-) delete mode 100644 crates/shepherd-sdk/src/cow/app_data.rs diff --git a/Cargo.lock b/Cargo.lock index 58cb5a1e..ed5ab9e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,9 +1555,8 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cowprotocol" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aaeeac1011ad799f5316aebcd45c052232c433a8cfa6a2abe32e40b354be770" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "cowprotocol-appdata", "cowprotocol-orderbook", @@ -1567,9 +1566,8 @@ dependencies = [ [[package]] name = "cowprotocol-appdata" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a9872ee4e4e515215bb1adcd12a82a91d05130aeedd43a2b218e9ccf8b6739" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "cid", @@ -1582,9 +1580,8 @@ dependencies = [ [[package]] name = "cowprotocol-orderbook" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8faa55dd7ab3e0fda87dee0caf4bbed19891feeca122aca78df988834d7e9b9" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "cowprotocol-appdata", @@ -1603,9 +1600,8 @@ dependencies = [ [[package]] name = "cowprotocol-primitives" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b6194563c076218c980ac0a5246a2cf75d0fd8558ef704ec108cdf0b27aa62" +version = "0.1.1" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-chains", "alloy-primitives", @@ -1617,9 +1613,8 @@ dependencies = [ [[package]] name = "cowprotocol-signing" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57411bec8ea0c3e348a8bcae0a11bb9a56f2cb21323337139e373859018a93f3" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "alloy-signer 1.8.3", @@ -1964,7 +1959,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -2238,7 +2233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3619,7 +3614,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4607,7 +4602,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4676,7 +4671,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5106,7 +5101,6 @@ dependencies = [ "cowprotocol", "nexum-sdk", "proptest", - "serde_json", "strum", "thiserror 2.0.18", ] @@ -5344,10 +5338,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.4.1", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6584,7 +6578,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 256abf2a..24b465db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,7 +100,7 @@ alloy-chains = { version = "0.2", default-features = false, features = ["std", " # 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.1.0", default-features = false, features = ["http-client"] } +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. @@ -183,6 +183,14 @@ unsafe_op_in_unsafe_fn = "warn" dbg_macro = "deny" todo = "deny" +# `cowprotocol` 0.2.0 (the `OrderCreationAppData` hash-only submission +# shape plus the WASI clock fix that keeps `js_sys` out of non-browser +# wasm builds) is not published yet; the latest crates.io release is +# 0.2.0-alpha.1 without either. Patch to the cow-rs rev carrying both. +# Drop once a cowprotocol release with the hash-only constructor ships. +[patch.crates-io] +cowprotocol = { git = "https://github.com/nullislabs/cow-rs", rev = "17fc0c5f55d04fad38963911da07d5da9290fefb" } + [profile.dev] panic = "abort" diff --git a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs index bc95a216..f4155888 100644 --- a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs +++ b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs @@ -261,7 +261,7 @@ fn sample_order_json() -> String { buy_token_balance: BuyTokenDestination::Erc20, }; let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::from_signed_order_data( + let creation = OrderCreation::new( &order_data, signature, Address::from([0x03; 20]), diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index b04bf522..fe8e1af3 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -13,10 +13,9 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or [dependencies] nexum-sdk = { path = "../nexum-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true alloy-sol-types.workspace = true -serde_json.workspace = true strum.workspace = true thiserror.workspace = true diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md index 11866f15..a93d3dfb 100644 --- a/crates/shepherd-sdk/README.md +++ b/crates/shepherd-sdk/README.md @@ -25,7 +25,6 @@ use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; | `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | | `cow::composable` | `sol! IConditionalOrder` errors + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | | `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | -| `cow::app_data` | `resolve_app_data` - appData hash -> canonical JSON document. | | `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | ## Testing modules host-free @@ -64,8 +63,7 @@ crates/shepherd-sdk/ │ │ ├── mod.rs CowApiHost + CowHost │ │ ├── order.rs gpv2_to_order_data │ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) -│ │ ├── error.rs RetryAction + classify_api_error -│ │ └── app_data.rs resolve_app_data +│ │ └── error.rs RetryAction + classify_api_error │ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! └── README.md you are here diff --git a/crates/shepherd-sdk/src/cow/app_data.rs b/crates/shepherd-sdk/src/cow/app_data.rs deleted file mode 100644 index a142ed4b..00000000 --- a/crates/shepherd-sdk/src/cow/app_data.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! Resolve a 32-byte `appData` hash to its canonical JSON document. -//! -//! CoW Protocol orders carry an `appData` field as `bytes32 = -//! keccak256(appDataJSON)`. The orderbook validates submissions by -//! re-hashing the JSON body and comparing to the signed hash, so any -//! caller that doesn't already know the document text needs to look -//! it up - either via IPFS or via the orderbook's mirror at -//! `GET /api/v1/app_data/{hex}`. -//! -//! This module hides that lookup behind a single -//! [`resolve_app_data`] helper. Strategies (notably twap-monitor) -//! call it before assembling an `OrderCreation` so cow-swap UI's -//! richer appData docs (partner-id, slippage settings, -//! quote-id, etc.) round-trip cleanly through the submit path. -//! -//! ## Behaviour -//! -//! - `hash == EMPTY_APP_DATA_HASH` (`keccak256("{}")`) → short-circuit -//! to [`cowprotocol::EMPTY_APP_DATA_JSON`] (`"{}"`), no host call. -//! - Otherwise → `GET /api/v1/app_data/{hex}` on the chain's -//! orderbook. The 200 response is `{"fullAppData": ""}`; we -//! pull `fullAppData` out and return it verbatim. -//! - On 404 ([`CowApiError::Http`] with `status == 404`) → return the -//! same error so the caller can drop the submit gracefully (the -//! orderbook doesn't have the document mirrored; the caller has no -//! path to recover without operator intervention). -//! -//! ## Why not a typed CoW endpoint -//! -//! `cow-api::request` is the generic REST passthrough already in the -//! WIT surface (since 0.2.0); we use it rather than adding a typed -//! `cow-api::get-app-data` host method to keep this PR scoped to the -//! SDK + module layers (no WIT bump → no breaking module recompile). -//! Should the lookup become hot enough to merit a typed host -//! endpoint (e.g. for cache control), follow-up issue. -//! -//! ## Why not IPFS -//! -//! The orderbook already mirrors IPFS app_data docs and serves them -//! over a single HTTPS endpoint. Going to IPFS directly would -//! require a fresh capability (`ipfs`), bigger module footprint, -//! and worse latency than a single GET against an already-trusted -//! upstream. If the orderbook 404s, IPFS would too - the doc isn't -//! pinned anywhere we can see from inside the engine. - -use alloy_primitives::B256; -use cowprotocol::EMPTY_APP_DATA_HASH; -use nexum_sdk::host::Fault; - -use crate::cow::{CowApiError, CowApiHost}; - -/// Look up the JSON document corresponding to a signed `appData` -/// hash. See module-level docs for behaviour. -/// -/// The hash is a 32-byte EVM word; the SDK takes [`B256`] across the -/// public surface rather than a raw `&[u8; 32]` per the rubric's -/// protocol-ID newtype rule. Callers holding a raw byte array -/// convert via `B256::from_slice(&bytes[..])` at the WIT boundary. -/// -/// ```no_run -/// use shepherd_sdk::cow::{resolve_app_data, CowApiError, CowApiHost}; -/// use nexum_sdk::prelude::B256; -/// -/// fn pin_doc(host: &H, chain_id: u64, hash: &B256) -> Result { -/// resolve_app_data(host, chain_id, hash) -/// } -/// ``` -pub fn resolve_app_data( - host: &H, - chain_id: u64, - app_data_hash: &B256, -) -> Result { - if app_data_hash.as_slice() == EMPTY_APP_DATA_HASH.as_slice() { - return Ok(cowprotocol::EMPTY_APP_DATA_JSON.to_string()); - } - - let hex = encode_hex(app_data_hash); - let path = format!("/api/v1/app_data/{hex}"); - let response = host.cow_api_request(chain_id, "GET", &path, None)?; - - parse_full_app_data(&response).map_err(|e| { - CowApiError::Fault(Fault::Internal(format!( - "app_data response shape unexpected: {e} (body: {})", - truncate_for_log(&response) - ))) - }) -} - -/// Prefix of an unexpected response body for the diagnostic message. -/// -/// `Fault` carries no structured data field, so on the already-unexpected -/// shape-error path the raw body is folded into the message. Cap it so a -/// large or hostile response cannot bloat the log line; truncation is on a -/// char boundary and marked with an ellipsis. -fn truncate_for_log(body: &str) -> String { - const MAX: usize = 256; - match body.char_indices().nth(MAX) { - Some((end, _)) => format!("{}…", &body[..end]), - None => body.to_owned(), - } -} - -/// Lowercase `0x`-prefixed hex of a 32-byte appData hash. Delegates -/// to [`alloy_primitives::hex::encode`] (alloy is already a direct -/// dependency of this crate) per mfw78's PR #8 guidance against -/// carrying our own hex formatters. -fn encode_hex(hash: &B256) -> String { - format!("0x{}", alloy_primitives::hex::encode(hash.as_slice())) -} - -/// Parse the orderbook's `/api/v1/app_data/{hash}` response shape: -/// -/// ```json -/// {"fullAppData": ""} -/// ``` -/// -/// Some orderbook versions wrap the document in an outer envelope -/// (`{"appData": "...", "appDataHash": "...", "fullAppData": "..."}`); -/// we always pull `fullAppData` and ignore the rest. -fn parse_full_app_data(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|_| "body is not JSON")?; - let obj = v.as_object().ok_or("body is not a JSON object")?; - let full = obj - .get("fullAppData") - .ok_or("missing `fullAppData` field")?; - full.as_str() - .ok_or("`fullAppData` is not a string") - .map(str::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - - use nexum_sdk::host::HostFault; - - use crate::cow::HttpFailure; - - /// Stub that captures the (chain_id, method, path) tuple and - /// returns a programmable response. Avoids pulling in - /// shepherd-sdk-test here (which depends on shepherd-sdk). - struct StubCowApi { - response: Result, - last_call: RefCell>, - } - - impl CowApiHost for StubCowApi { - fn submit_order(&self, _: u64, _: &[u8]) -> Result { - unimplemented!() - } - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - _body: Option<&str>, - ) -> Result { - *self.last_call.borrow_mut() = Some((chain_id, method.to_string(), path.to_string())); - self.response.clone() - } - } - - fn ok_stub(body: &str) -> StubCowApi { - StubCowApi { - response: Ok(body.to_string()), - last_call: RefCell::new(None), - } - } - - fn http_err_stub(status: u16) -> StubCowApi { - StubCowApi { - response: Err(CowApiError::Http(HttpFailure { status, body: None })), - last_call: RefCell::new(None), - } - } - - #[test] - fn empty_hash_short_circuits_without_host_call() { - let stub = ok_stub("should never be read"); - let resolved = - resolve_app_data(&stub, 1, &B256::from_slice(EMPTY_APP_DATA_HASH.as_slice())).unwrap(); - assert_eq!(resolved, "{}"); - assert!( - stub.last_call.borrow().is_none(), - "host should not have been called" - ); - } - - #[test] - fn non_empty_hash_routes_to_orderbook_and_extracts_full_app_data() { - let stub = - ok_stub(r#"{"fullAppData":"{\"version\":\"1.1.0\"}","appDataHash":"0xc4bc..."}"#); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - bytes[1] = 0xbc; - let hash = B256::from(bytes); - let resolved = resolve_app_data(&stub, 11_155_111, &hash).unwrap(); - assert_eq!(resolved, r#"{"version":"1.1.0"}"#); - let (cid, method, path) = stub.last_call.borrow().clone().unwrap(); - assert_eq!(cid, 11_155_111); - assert_eq!(method, "GET"); - assert!(path.starts_with("/api/v1/app_data/0x"), "got path={path}"); - assert!( - path.contains("c4bc"), - "hex hash must be lower-case and 64 chars; got path={path}" - ); - } - - #[test] - fn missing_full_app_data_field_returns_internal_with_body_in_message() { - let body = r#"{"appDataHash":"0xabcd","appData":"{}"}"#; - let stub = ok_stub(body); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - let Fault::Internal(message) = err.fault().expect("shape error is a fault") else { - panic!("expected an internal fault, got {err:?}"); - }; - assert!(message.contains("fullAppData"), "got: {message}"); - // The raw body is folded into the message for diagnostics. - assert!(message.contains("appDataHash"), "got: {message}"); - } - - #[test] - fn oversized_shape_error_body_is_truncated_with_ellipsis() { - // A large non-conforming body must not bloat the log line. - let body = format!(r#"{{"junk":"{}"}}"#, "a".repeat(4096)); - let stub = ok_stub(&body); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - let Fault::Internal(message) = err.fault().expect("shape error is a fault") else { - panic!("expected an internal fault, got {err:?}"); - }; - assert!( - message.contains('…'), - "expected truncation ellipsis: {message}" - ); - assert!( - message.len() < body.len(), - "message must be shorter than the raw body" - ); - } - - #[test] - fn http_failure_propagates_unchanged() { - let stub = http_err_stub(404); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - assert!( - matches!(err, CowApiError::Http(HttpFailure { status: 404, .. })), - "got {err:?}", - ); - } - - #[test] - fn hex_encoder_is_lower_case_and_64_wide() { - let mut bytes = [0u8; 32]; - bytes[31] = 0xff; - bytes[0] = 0xab; - let hash = B256::from(bytes); - assert_eq!(encode_hex(&hash), format!("0xab{}ff", "00".repeat(30))); - } -} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 43acb866..36cbdcca 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -10,12 +10,10 @@ //! tested without wit-bindgen scaffolding and re-used unchanged by //! TWAP, EthFlow, and future strategy modules. -pub mod app_data; pub mod composable; pub mod error; pub mod order; -pub use app_data::resolve_app_data; pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; pub use order::gpv2_to_order_data; @@ -35,7 +33,7 @@ pub trait CowApiHost { /// 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 (see [`resolve_app_data`]). + /// wrap this in an SDK helper. /// /// `method` is `"GET" | "POST" | "PUT" | "DELETE"`. /// `path` is the absolute orderbook path beginning with `/api/v1`. diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 577a6655..86e00dea 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -17,9 +17,8 @@ //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), //! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), appData resolution -//! ([`resolve_app_data`]), and the [`RetryAction`] classifier -//! driving submit-failure dispatch. +//! [`decode_revert`]), and the [`RetryAction`] classifier driving +//! submit-failure dispatch. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,7 +49,6 @@ //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data //! [`PollOutcome`]: cow::PollOutcome //! [`decode_revert`]: cow::decode_revert -//! [`resolve_app_data`]: cow::resolve_app_data //! [`RetryAction`]: cow::RetryAction #![cfg_attr(not(test), warn(unused_crate_dependencies))] diff --git a/docs/00-overview.md b/docs/00-overview.md index 3b13ea89..11e3c0fb 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -276,7 +276,7 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error, app_data}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`, `resolve_app_data`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | | `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index fdd4926b..d0391e04 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -987,7 +987,7 @@ graph TD - **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, `resolve_app_data`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 3258c2fa..717f74b4 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -99,13 +99,10 @@ transactions into each block, not to mimic mainnet block times. ### Mock orderbook (port 9999) -`tools/orderbook-mock` serves the two endpoints shepherd's `cow-api` +`tools/orderbook-mock` serves the one endpoint shepherd's `cow-api` host backend hits per submission: - `POST /api/v1/orders` - returns a synthetic 56-byte OrderUid. -- `GET /api/v1/app_data/{hash}` - returns the empty appData document - so `resolve_app_data` is satisfied without a real - registry. Knobs (set via env in `scripts/load-bootstrap.sh` if needed): diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 9f0f9e42..3beb4710 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -15,7 +15,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +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"] } tracing = { version = "0.1", default-features = false } diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml index 97124efd..0dab8781 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } shepherd-sdk = { path = "../../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +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 } diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index 3f97ca3b..bcec815b 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -166,7 +166,7 @@ fn build_creation(chain_id: u64, settings: &Settings) -> Result<(OrderCreation, Fault::InvalidInput("GPv2OrderData carried an unknown enum marker".into()) })?; let uid = order_data.uid(&domain, settings.owner); - let creation = OrderCreation::from_signed_order_data( + let creation = OrderCreation::new( &order_data, Signature::PreSign, settings.owner, diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index c21f456e..1910ae93 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index c840d737..a58a58bf 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -209,16 +209,6 @@ fn outcome_label(o: &PollOutcome) -> &'static str { // ---- key conventions ---- -/// Render the first 8 bytes of an `appData` hash as `0x12345678…` -/// for log lines. Full 32-byte hex is too noisy for an INFO log; -/// 8 bytes is unique enough to grep against the orderbook. -/// -/// Delegates to [`alloy_primitives::hex::encode`] per mfw78's PR #8 -/// guidance against carrying our own hex formatters. -fn hex_short(bytes: &[u8; 32]) -> String { - format!("0x{}…", alloy_primitives::hex::encode(&bytes[..8])) -} - fn watch_key(owner: &Address, params_hash: &B256) -> String { format!("watch:{owner:#x}:{params_hash:#x}") } @@ -274,9 +264,8 @@ enum BuildError { /// know how to map. #[error("GPv2OrderData carried an unknown enum marker")] UnknownMarker, - /// `cowprotocol` rejected the body - typically - /// `keccak256(app_data) != order.app_data` or `from == - /// Address::ZERO`. + /// `cowprotocol` rejected the body - typically `from == + /// Address::ZERO` or a `validTo` beyond the client-side horizon. #[error(transparent)] Cowprotocol(#[from] cowprotocol::Error), } @@ -284,22 +273,19 @@ enum BuildError { /// Assemble the `OrderCreation` body the orderbook expects from a /// freshly-polled TWAP tranche. /// -/// `app_data_json` is the canonical JSON document whose -/// `keccak256` matches `order.appData`. The caller is responsible -/// for resolving it via [`shepherd_sdk::cow::resolve_app_data`] (or -/// any equivalent path); passing a mismatching string makes -/// `OrderCreation::from_signed_order_data` reject with -/// "app_data JSON digest does not match signed app_data hash". +/// The signed `order.appData` digest is submitted verbatim (the +/// hash-only `OrderCreationAppData::Hash` wire shape) - watch-tower +/// parity. The orderbook joins the document it already has registered +/// for that digest; when it has none, the submit rejects with +/// `INVALID_APP_DATA` and [`classify_api_error`] dispatches the retry. fn build_order_creation( order: &GPv2OrderData, signature: Bytes, from: Address, - app_data_json: String, ) -> Result { let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; let signature = Signature::Eip1271(signature.to_vec()); - let creation = - OrderCreation::from_signed_order_data(&order_data, signature, from, app_data_json, None)?; + let creation = OrderCreation::new_app_data_hash_only(&order_data, signature, from, None)?; Ok(creation) } @@ -317,13 +303,13 @@ fn submit_ready( // `submitted:`. The poll-tick can re-fire `Ready` for the same // TWAP child in successive blocks - `getTradeableOrderWithSignature` // does not know shepherd already POSTed it - and re-submitting - // wastes an appData GET + submit_order call and emits a - // misleading `DuplicatedOrder` Warn. The UID computation is - // deterministic from on-chain inputs (and matches what the - // orderbook derives server-side from the signed payload), so we - // can check before doing any network work. We also reuse the - // computed value below as the `submitted:{uid}` marker key, so - // the read and write paths agree. + // wastes a submit_order call and emits a misleading + // `DuplicatedOrder` Warn. The UID computation is deterministic + // from on-chain inputs (and matches what the orderbook derives + // server-side from the signed payload), so we can check before + // doing any network work. We also reuse the computed value below + // as the `submitted:{uid}` marker key, so the read and write + // paths agree. let client_uid_hex = compute_uid_hex(chain_id, order, owner); if let Some(uid_hex) = client_uid_hex.as_deref() && host.get(&format!("submitted:{uid_hex}"))?.is_some() @@ -332,32 +318,13 @@ fn submit_ready( return Ok(()); } - // CoW Swap UI (and other clients) sign TWAPs with a - // non-empty `appData` hash that points at a JSON document held - // by the orderbook's app_data registry. Hard-coding - // `EMPTY_APP_DATA_JSON` here would produce a body whose - // `keccak256(appDataJson) != order.appData`, and the orderbook - // rejects with "app_data JSON digest does not match signed - // app_data hash". Resolve the document via the orderbook - // mirror; on 404 (orderbook doesn't know the hash) leave the - // watch in place - there is no path to recover without - // operator intervention. - let app_data_json = match shepherd_sdk::cow::resolve_app_data(host, chain_id, &order.appData) { - Ok(json) => json, - Err(CowApiError::Http(http)) if http.status == 404 => { - tracing::warn!( - "twap submit skipped for {owner:#x}: appData hash not mirrored on orderbook ({})", - hex_short(&order.appData.0), - ); - return Ok(()); - } - Err(err) => { - tracing::warn!("twap submit skipped for {owner:#x}: appData resolve failed: {err}",); - return Ok(()); - } - }; - - let creation = match build_order_creation(order, signature, owner, app_data_json) { + // CoW Swap UI (and other clients) sign TWAPs with a non-empty + // `appData` hash that points at a JSON document already registered + // with the orderbook. Submit the signed digest verbatim (hash-only + // shape) and let the orderbook join its own registry - watch-tower + // parity. An unregistered digest rejects as `INVALID_APP_DATA` and + // `classify_api_error` dispatches the backoff. + let creation = match build_order_creation(order, signature, owner) { Ok(c) => c, Err(e) => { tracing::warn!("twap submit skipped for {owner:#x}: {e}"); @@ -540,15 +507,27 @@ fn apply_watch_update( mod tests { use super::*; use alloy_primitives::{U256, address, b256, hex}; + use cowprotocol::OrderCreationAppData; 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::{HttpFailure, OrderRejection}; + use shepherd_sdk::cow::OrderRejection; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; + /// `validTo` a given number of seconds from now. The constructor's + /// client-side max-horizon policy reads the wall clock (not the + /// block clock), so test orders must expire relative to it. + fn valid_to_in(seconds: u64) -> u32 { + 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 sample_params() -> ConditionalOrderParams { ConditionalOrderParams { handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), @@ -581,7 +560,7 @@ mod tests { receiver: Address::ZERO, sellAmount: U256::from(1_000_000_u64), buyAmount: U256::from(999_u64), - validTo: 0xffff_ffff, + validTo: valid_to_in(3_600), appData: cowprotocol::EMPTY_APP_DATA_HASH, feeAmount: U256::ZERO, kind: OrderKind::SELL, @@ -648,70 +627,30 @@ mod tests { } } + /// The signed `appData` digest goes into the body verbatim as the + /// hash-only shape - no document lookup, no digest re-derivation. #[test] - fn build_order_creation_succeeds_with_empty_app_data() { + fn build_order_creation_submits_app_data_hash_verbatim() { let owner = address!("00112233445566778899aabbccddeeff00112233"); let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let creation = build_order_creation( - &submittable_order(), - sig.clone(), - owner, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .expect("build succeeds"); + let mut order = submittable_order(); + order.appData = B256::repeat_byte(0xee); + let creation = build_order_creation(&order, sig.clone(), owner).expect("build succeeds"); assert_eq!(creation.from, owner); assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!(creation.app_data, cowprotocol::EMPTY_APP_DATA_JSON); - assert_eq!(creation.app_data_hash, cowprotocol::EMPTY_APP_DATA_HASH); - } - - /// When the caller supplies the matching JSON for a - /// non-empty `appData` hash, `build_order_creation` accepts the - /// body. Caller is responsible for resolving the document (in - /// production this is `submit_ready` via - /// `shepherd_sdk::cow::resolve_app_data`). - #[test] - fn build_order_creation_accepts_matching_non_empty_app_data() { - use alloy_primitives::keccak256; - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let app_data_json = r#"{"version":"1.1.0","metadata":{"partnerId":"shepherd-e2e"}}"#; - let app_data_hash = keccak256(app_data_json.as_bytes()); - - let mut order = submittable_order(); - order.appData = app_data_hash; - - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let creation = - build_order_creation(&order, sig, owner, app_data_json.to_string()).expect("build"); - assert_eq!(creation.app_data, app_data_json); - assert_eq!(creation.app_data_hash, app_data_hash); - } - - #[test] - fn build_order_creation_rejects_non_empty_app_data() { - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let err = build_order_creation( - &order, - Bytes::new(), - owner, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); + assert_eq!( + creation.app_data, + OrderCreationAppData::Hash { + hash: order.appData + } + ); } #[test] fn build_order_creation_rejects_zero_from() { - let err = build_order_creation( - &submittable_order(), - Bytes::new(), - Address::ZERO, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .unwrap_err(); + let err = + build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); assert!(matches!(err, BuildError::Cowprotocol(_))); } @@ -975,28 +914,26 @@ mod tests { assert_eq!( host.cow_api.request_calls().len(), 0, - "appData resolve must NOT be called either - the guard short-circuits early", + "the REST passthrough must NOT be touched - the guard short-circuits early", ); } - /// Ready order with a non-empty `appData` field - /// triggers a `cow_api_request` call to - /// `/api/v1/app_data/{hex}`; the resolved JSON is passed to - /// `OrderCreation::from_signed_order_data` so the digest matches - /// and the submit succeeds. Before this PR the path returned - /// "app_data JSON digest does not match signed app_data hash" - /// and the watch sat in retry-loop forever. + /// 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. #[test] - fn poll_ready_resolves_non_empty_app_data_then_submits() { + fn poll_ready_submits_non_empty_app_data_hash_only() { use alloy_primitives::keccak256; let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); - let app_data_json = r#"{"version":"1.1.0","metadata":{"partnerId":"shepherd-e2e"}}"#; - let app_data_hash = keccak256(app_data_json.as_bytes()); - + let app_data_hash = keccak256(b"registered elsewhere; this client never sees the doc"); let mut ready_order = submittable_order(); ready_order.appData = app_data_hash; @@ -1008,20 +945,6 @@ mod tests { Ok(quoted_hex(&wire)), ); host.cow_api.respond(Ok("0xfeedface".to_string())); - // Mirror the orderbook's `/api/v1/app_data/{hex}` response - // shape: a JSON envelope carrying `fullAppData` as a string. - let envelope = format!( - r#"{{"fullAppData":{}}}"#, - serde_json::Value::String(app_data_json.to_string()), - ); - host.cow_api.respond_to_request_for( - "GET", - format!( - "/api/v1/app_data/0x{}", - alloy_primitives::hex::encode(app_data_hash) - ), - Ok(envelope), - ); on_block(&host, sample_block(1_000)).unwrap(); @@ -1031,10 +954,18 @@ mod tests { "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"); assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one app_data resolve", + 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}" ); let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) .expect("Sepolia is supported + canonical markers"); @@ -1042,58 +973,10 @@ mod tests { host.store .snapshot() .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful resolve+submit" + "submitted:{{client_uid}} marker must be written after a successful submit" ); } - /// When the orderbook 404s the appData hash (no - /// mirror exists), the strategy logs a Warn and leaves the - /// watch in place - neither a `submitted:` nor a `dropped:` - /// marker is written, and no submit attempt is made. - #[test] - fn poll_ready_skips_submit_when_app_data_hash_not_mirrored() { - use alloy_primitives::keccak256; - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - let watch_key_str = seed_watch(&host, owner, ¶ms); - - let app_data_hash = keccak256(b"unknown"); - let mut ready_order = submittable_order(); - ready_order.appData = app_data_hash; - 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)), - ); - // No `respond_to_request_for` → MockCowApi falls back to - // the default "no response configured" Unsupported error. - // Switch the default to a 404 so the strategy hits the - // typed "appData not mirrored" branch. - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); - - assert_eq!(host.cow_api.call_count(), 0, "no submit attempt on 404"); - let store = host.store.snapshot(); - assert!(!store.keys().any(|k| k.starts_with("submitted:"))); - assert!(!store.keys().any(|k| k.starts_with("dropped:"))); - assert!( - store.contains_key(&watch_key_str), - "watch stays in place so a later mirror can resolve it" - ); - logs.expect_one(|e| { - e.level == Level::WARN && e.message.contains("appData hash not mirrored") - }); - } - #[test] fn submit_transient_error_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 1a0558e9..1546ac42 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -394,9 +394,9 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { } /// Encode `CoWSwapEthFlow.createOrder(EthFlowOrder.Data)` with a sell -/// amount matched to the tx `value`. `appData` is the empty hash so -/// the orderbook mirror's `GET /api/v1/app_data/{hash}` returns the -/// document without contention. `validTo` is `u32::MAX` per the +/// amount matched to the tx `value`. `appData` is the empty hash - a +/// digest every orderbook already knows, so hash-only submission +/// needs no registration step. `validTo` is `u32::MAX` per the /// canonical EthFlow shape (the mock orderbook is /// permissive here, and shepherd's strategy will drop with the /// expected Info-level log per PR #49). diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 243ae7fa..0ad9d4aa 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,14 +1,11 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the two endpoints shepherd's `cow-api` host backend hits on +//! Serves the one endpoint shepherd's `cow-api` host backend hits on //! every order submission: //! //! - `POST /api/v1/orders` - accepts any body, returns a synthetic //! 56-byte OrderUid as a JSON-encoded hex string. Counts a request //! for the operator report. -//! - `GET /api/v1/app_data/{hash}` - returns the empty appData -//! document so `resolve_app_data` is satisfied without -//! needing a real registry. //! //! Operator knobs (CLI): //! - `--port` (default 9999) @@ -30,7 +27,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use axum::Router; -use axum::extract::{Path, State}; +use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; @@ -67,7 +64,6 @@ struct Cli { struct Counters { submits_ok: AtomicU64, submits_err: AtomicU64, - app_data_lookups: AtomicU64, } struct AppState { @@ -107,7 +103,6 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/api/v1/orders", post(post_orders)) - .route("/api/v1/app_data/{hash}", get(get_app_data)) .route("/healthz", get(healthz)) .route("/_stats", get(stats)) .with_state(state.clone()); @@ -138,7 +133,6 @@ async fn stats(State(state): State>) -> impl IntoResponse { let body = serde_json::json!({ "submits_ok": state.counters.submits_ok.load(Ordering::Relaxed), "submits_err": state.counters.submits_err.load(Ordering::Relaxed), - "app_data_lookups": state.counters.app_data_lookups.load(Ordering::Relaxed), }); (StatusCode::OK, axum::Json(body)) } @@ -189,23 +183,6 @@ async fn post_orders(State(state): State>, body: String) -> impl I (StatusCode::CREATED, uid_hex).into_response() } -async fn get_app_data( - State(state): State>, - Path(_hash): Path, -) -> impl IntoResponse { - if state.cli.latency_ms > 0 { - tokio::time::sleep(Duration::from_millis(state.cli.latency_ms)).await; - } - state - .counters - .app_data_lookups - .fetch_add(1, Ordering::Relaxed); - // The empty appData document - keccak256("{}") matches the - // EMPTY_APP_DATA_HASH the test EOA and load-gen will sign over. - let body = serde_json::json!({ "fullAppData": "{}" }); - (StatusCode::OK, axum::Json(body)).into_response() -} - /// Tiny inline hex encoder - the mock does not depend on `alloy` to /// keep its dependency surface minimal. (The engine uses /// `alloy_primitives::hex::encode_prefixed` instead; that rule @@ -230,7 +207,6 @@ mod tests { let state = Arc::new(AppState::new(cli)); Router::new() .route("/api/v1/orders", post(post_orders)) - .route("/api/v1/app_data/{hash}", get(get_app_data)) .with_state(state) } @@ -264,25 +240,6 @@ mod tests { assert_eq!(s.len(), 116); } - #[tokio::test] - async fn get_app_data_returns_empty_document() { - let app = router_with(default_cli()); - let resp = app - .oneshot( - Request::get("/api/v1/app_data/0xdeadbeef") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(parsed["fullAppData"], "{}"); - } - #[tokio::test] async fn error_rate_one_always_returns_envelope() { let app = router_with(Cli {