From 95fb57aa678e39213e2d99225b66d7b0562012af Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:24:54 -0300 Subject: [PATCH 01/21] chore(deps): bump cowprotocol patch to bleu/cow-rs main (BLEU-822 + BLEU-823 in) --- Cargo.toml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 56ee707..cb0bdbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,15 +100,17 @@ todo = "deny" # `cowprotocol` v1.0.0-alpha.3 (the crates.io release the engine # depends on) was cut from `cowdao-grants/cow-rs` PR #5 at commit -# `1742ffa`. `bleu/cow-rs` main has 18 commits since, including the +# `1742ffa`. `bleu/cow-rs` main has diverged since with: the # `composable::Proof` width fix (relevant to the TWAP poll path), -# `OrderCreation` zero-from-address fast-fail (closes a MEDIUM -# review finding from PR #5), and the `order_book` / `composable` -# submodule splits. Patching to that commit picks them up without -# waiting for an alpha.4 publish. Drop once `cowprotocol >= 1.0.0-alpha.4` -# ships. +# `OrderCreation` zero-from-address fast-fail, the `order_book` / +# `composable` submodule splits, `OrderPostErrorKind` + `retry_hint()` +# (BLEU-822, the protocol-level retry contract M2 modules dispatch +# on), and `OrderBookApi::with_base_url(chain, base_url)` for barn / +# staging routing (BLEU-823). Patching to that commit picks the lot +# up without waiting for an alpha.4 publish. Drop once +# `cowprotocol >= 1.0.0-alpha.4` ships. [patch.crates-io] -cowprotocol = { git = "https://github.com/bleu/cow-rs", rev = "c012404ffefc411bff543d2290e19ba7fbef2516" } +cowprotocol = { git = "https://github.com/bleu/cow-rs", rev = "57f5f553ab28c9fff54089daf2d39b4282f3e4dd" } [profile.dev] panic = "abort" From 0aabfbe5e59493dd8cb6245f37bcaf783da09071 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Tue, 23 Jun 2026 17:58:16 -0300 Subject: [PATCH 02/21] chore(rust-idiomatic): M2 compliance pass (filtered from M4/M5 compliance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtered subset of the compliance applied in PRs #66/#67 of bleu/nullis-shepherd, restricted to files that exist on the M2 epic head. M3+ files (shepherd-sdk, examples, backtest, deploy artifacts) and M4-coupled hunks (ProviderError typed-source variants, JoinSet reconnect tasks, supervisor restart helper) are skipped — they land via their own upstream PRs. Brings M2 epic in line with the repo-wide rust rubric (typed errors, no anyhow in libs, em-dash sweep, #[non_exhaustive] on public error enums). --- crates/nexum-engine/src/engine_config.rs | 23 +++++++++++-- crates/nexum-engine/src/host/impls/cow_api.rs | 5 +-- crates/nexum-engine/src/host/mod.rs | 10 +++--- crates/nexum-engine/src/manifest/error.rs | 1 + crates/nexum-engine/src/manifest/load.rs | 33 ++++++++++--------- crates/nexum-engine/src/manifest/mod.rs | 6 ++-- 6 files changed, 48 insertions(+), 30 deletions(-) diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs index be4649c..87e574d 100644 --- a/crates/nexum-engine/src/engine_config.rs +++ b/crates/nexum-engine/src/engine_config.rs @@ -20,8 +20,27 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::Deserialize; +use thiserror::Error; use tracing::{info, warn}; +/// Errors surfaced by [`load_or_default`]. +/// +/// Library-side modules must not propagate `anyhow::Error`; the rust +/// idiomatic rubric reserves `anyhow` for `main.rs` and +/// `supervisor.rs` top-level dispatch. The variants carry the +/// upstream error via `#[from]` so the caller in `main.rs` (which +/// uses `anyhow`) gets a free conversion through `?`. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum EngineConfigError { + /// Failed to read the config file from disk. + #[error("read engine config: {0}")] + Io(#[from] std::io::Error), + /// Config file was unparseable as TOML. + #[error("parse engine config: {0}")] + Toml(#[from] toml::de::Error), +} + /// Engine-side configuration loaded from `engine.toml`. #[derive(Debug, Default, Deserialize)] pub struct EngineConfig { @@ -130,8 +149,8 @@ fn default_log_level() -> String { } /// Read an engine config from disk, returning defaults if the file is -/// missing. Parse errors propagate. -pub fn load_or_default(path: Option<&Path>) -> anyhow::Result { +/// missing. Parse errors propagate via [`EngineConfigError`]. +pub fn load_or_default(path: Option<&Path>) -> Result { let path = match path { Some(p) => p.to_path_buf(), None => PathBuf::from("engine.toml"), diff --git a/crates/nexum-engine/src/host/impls/cow_api.rs b/crates/nexum-engine/src/host/impls/cow_api.rs index abeda01..5971e03 100644 --- a/crates/nexum-engine/src/host/impls/cow_api.rs +++ b/crates/nexum-engine/src/host/impls/cow_api.rs @@ -58,10 +58,7 @@ impl shepherd::cow::cow_api::Host for HostState { let start = Instant::now(); tracing::debug!(chain_id, bytes = order_data.len(), "cow-api::submit-order"); let result = match self.cow.submit_order_json(chain_id, &order_data).await { - Ok(uid) => Ok(format!( - "0x{}", - alloy_primitives::hex::encode(uid.as_slice()) - )), + Ok(uid) => Ok(alloy_primitives::hex::encode_prefixed(uid.as_slice())), Err(CowApiError::UnknownChain(id)) => Err(unimplemented( "cow-api", format!("chain {id} not in cowprotocol"), diff --git a/crates/nexum-engine/src/host/mod.rs b/crates/nexum-engine/src/host/mod.rs index 8303af8..a6662ee 100644 --- a/crates/nexum-engine/src/host/mod.rs +++ b/crates/nexum-engine/src/host/mod.rs @@ -12,9 +12,9 @@ //! - [`impls`] (private): the bindgen-side trait impls, one file per //! WIT interface, that dispatch to the backends above. -pub mod cow_orderbook; -pub mod error; +pub(crate) mod cow_orderbook; +pub(crate) mod error; mod impls; -pub mod local_store_redb; -pub mod provider_pool; -pub mod state; +pub(crate) mod local_store_redb; +pub(crate) mod provider_pool; +pub(crate) mod state; diff --git a/crates/nexum-engine/src/manifest/error.rs b/crates/nexum-engine/src/manifest/error.rs index 01db2ff..a9f4962 100644 --- a/crates/nexum-engine/src/manifest/error.rs +++ b/crates/nexum-engine/src/manifest/error.rs @@ -6,6 +6,7 @@ use super::types::KNOWN_CAPABILITIES; /// Errors returned while loading or validating a manifest. #[derive(Debug, Error)] +#[non_exhaustive] pub enum ParseError { /// Failed to read the manifest file from disk. #[error("manifest: i/o: {0}")] diff --git a/crates/nexum-engine/src/manifest/load.rs b/crates/nexum-engine/src/manifest/load.rs index 4f2430b..9c047c4 100644 --- a/crates/nexum-engine/src/manifest/load.rs +++ b/crates/nexum-engine/src/manifest/load.rs @@ -8,6 +8,8 @@ use std::collections::HashSet; use std::path::Path; +use tracing::{info, warn}; + use super::error::ParseError; use super::types::{KNOWN_CAPABILITIES, LoadedManifest, Manifest}; @@ -19,10 +21,11 @@ pub fn load(path: &Path) -> Result { let caps = manifest.capabilities.as_ref(); if caps.is_none() { - eprintln!( - "[deprecation] no [capabilities] section in module.toml - \ - defaulting to all-required (0.1 behaviour). This default \ - will be removed in 0.3; add an explicit [capabilities] block." + warn!( + target: "manifest", + "no [capabilities] section in module.toml - defaulting to \ + all-required (0.1 behaviour). This default will be removed \ + in 0.3; add an explicit [capabilities] block." ); } @@ -34,16 +37,13 @@ pub fn load(path: &Path) -> Result { } } if !c.required.is_empty() { - eprintln!( - "[manifest] required capabilities: {}", - c.required.join(", ") - ); + info!(target: "manifest", required = %c.required.join(", "), "required capabilities"); } if !c.optional.is_empty() { - eprintln!( - "[manifest] optional capabilities (advisory in 0.2; trap-stub fallback \ - ships in 0.3): {}", - c.optional.join(", ") + info!( + target: "manifest", + optional = %c.optional.join(", "), + "optional capabilities (advisory in 0.2; trap-stub fallback ships in 0.3)", ); } } @@ -53,7 +53,7 @@ pub fn load(path: &Path) -> Result { .map(|h| h.allow.clone()) .unwrap_or_default(); if !http_allowlist.is_empty() { - eprintln!("[manifest] http allowlist: {}", http_allowlist.join(", ")); + info!(target: "manifest", allow = %http_allowlist.join(", "), "http allowlist"); } let config = manifest @@ -72,9 +72,10 @@ pub fn load(path: &Path) -> Result { /// Synthesise a "0.1 fallback" manifest for when no `module.toml` is found. /// Emits the same deprecation warning as a missing-section manifest. pub fn fallback_manifest() -> LoadedManifest { - eprintln!( - "[deprecation] no module.toml found - defaulting to all-required \ - (0.1 behaviour). This default will be removed in 0.3; ship a \ + warn!( + target: "manifest", + "no module.toml found - defaulting to all-required (0.1 \ + behaviour). This default will be removed in 0.3; ship a \ module.toml alongside your component." ); LoadedManifest { diff --git a/crates/nexum-engine/src/manifest/mod.rs b/crates/nexum-engine/src/manifest/mod.rs index 9cd00b6..4569157 100644 --- a/crates/nexum-engine/src/manifest/mod.rs +++ b/crates/nexum-engine/src/manifest/mod.rs @@ -31,9 +31,9 @@ mod error; mod load; mod types; -pub use capabilities::enforce_capabilities; -pub use load::{extract_host, fallback_manifest, host_allowed, load}; -pub use types::{LoadedManifest, Subscription}; +pub(crate) use capabilities::enforce_capabilities; +pub(crate) use load::{extract_host, fallback_manifest, host_allowed, load}; +pub(crate) use types::{LoadedManifest, Subscription}; // CapabilityViolation, ParseError, and the *Section structs are // reachable through these functions' return / argument types; // consumers that need to name them directly do so via From e4d94df85a620e6ea689cd6afe4dfd5e2f9fac88 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 16:42:10 -0300 Subject: [PATCH 03/21] docs(nexum-engine): fix rustdoc intra-doc links after pub(crate) sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M2 compliance pass (2b11f91) narrowed several manifest/host re-exports from `pub` to `pub(crate)`. Three intra-doc links inherited from the wider M1 docs no longer resolve under `-D warnings`: - `crate::host::impls` — module is `mod impls;` (always private); the link doc-rendered as code at the M1-era visibility. Demote to a plain code span; the path is still grep-able and accurate. - `manifest::mod` link to `[load]` — ambiguous now that `pub(crate) use load::{... load};` makes `load` both a module and a function. Use `mod@load` to disambiguate to the module (matches the surrounding prose, which describes the file's responsibilities). - `manifest::types` link to `[super::load]` — same ambiguity, same fix: `mod@super::load`. Fixes the `RUSTDOCFLAGS="-D warnings" cargo doc` gate on dev/m2-base. --- crates/nexum-engine/src/bindings.rs | 2 +- crates/nexum-engine/src/manifest/mod.rs | 2 +- crates/nexum-engine/src/manifest/types.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/nexum-engine/src/bindings.rs b/crates/nexum-engine/src/bindings.rs index d0f57dd..9ddd00c 100644 --- a/crates/nexum-engine/src/bindings.rs +++ b/crates/nexum-engine/src/bindings.rs @@ -5,7 +5,7 @@ //! natively - no vendored `deps/` tree needed. The world name is fully //! qualified. //! -//! Every `Host` trait impl in [`crate::host::impls`] consumes types +//! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. wasmtime::component::bindgen!({ diff --git a/crates/nexum-engine/src/manifest/mod.rs b/crates/nexum-engine/src/manifest/mod.rs index 4569157..45c48da 100644 --- a/crates/nexum-engine/src/manifest/mod.rs +++ b/crates/nexum-engine/src/manifest/mod.rs @@ -21,7 +21,7 @@ //! //! - [`types`]: the serde `Manifest` shape + `LoadedManifest` the engine //! actually consumes, plus the `KNOWN_CAPABILITIES` registry. -//! - [`load`]: `module.toml` -> `LoadedManifest`, plus the host/URL +//! - [`mod@load`]: `module.toml` -> `LoadedManifest`, plus the host/URL //! helpers the `http` backend uses at request time. //! - [`capabilities`]: WIT-import vs declared-capabilities cross-check. //! - [`error`]: `ParseError`, `CapabilityViolation`. diff --git a/crates/nexum-engine/src/manifest/types.rs b/crates/nexum-engine/src/manifest/types.rs index 403a201..e91bff1 100644 --- a/crates/nexum-engine/src/manifest/types.rs +++ b/crates/nexum-engine/src/manifest/types.rs @@ -1,7 +1,7 @@ //! Data structures: `Manifest`, sections, and `LoadedManifest`. //! //! Plain serde shapes plus the `KNOWN_CAPABILITIES` registry. The parsing -//! and validation logic lives in [`super::load`]; capability enforcement +//! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. use serde::Deserialize; From c61677364f666b56c75e1bf4ca43e9526fa101b4 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 19:08:39 -0300 Subject: [PATCH 04/21] chore(nexum-engine): derive strum::IntoStaticStr on error enums Audit reference: milestone-rubric-grant-audit-2026-06-25.md, Major #1. Vendored rubric mandates `strum::IntoStaticStr` (with `#[strum(serialize_all = "snake_case")]`) on every error enum so `error_kind` labels on the `shepherd_chain_request_total` / `shepherd_cow_api_*` counters stay in lock-step with the Rust source of truth instead of growing a `match err { ... => "connect" ... }` ladder per call site. Enums covered on this milestone (the ones present on dev/m2-base): - `nexum_engine::host::cow_orderbook::CowApiError` - `nexum_engine::host::provider_pool::ProviderError` - `nexum_engine::manifest::error::ParseError` - `nexum_engine::engine_config::EngineConfigError` Also adds `#[non_exhaustive]` to `CowApiError` and `ProviderError` (audit Major #2). The other two already carried it. `strum = "0.26"` lands as a direct dep on nexum-engine. The workspace-deps hoist (audit P1, Major #5) is intentionally a separate judgment call left to Bruno; this commit ships the substantive rubric fix without coupling to the broader Cargo.toml restructure. --- crates/nexum-engine/Cargo.toml | 9 +++++++++ crates/nexum-engine/src/engine_config.rs | 7 ++++++- crates/nexum-engine/src/host/cow_orderbook.rs | 10 +++++++++- crates/nexum-engine/src/host/provider_pool.rs | 9 ++++++++- crates/nexum-engine/src/manifest/error.rs | 8 +++++++- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/nexum-engine/Cargo.toml b/crates/nexum-engine/Cargo.toml index e1370e4..223c3f6 100644 --- a/crates/nexum-engine/Cargo.toml +++ b/crates/nexum-engine/Cargo.toml @@ -16,6 +16,15 @@ wasmtime-wasi = "45" # Async + error plumbing. anyhow.workspace = true thiserror.workspace = true +# `strum::IntoStaticStr` on error enums gives metric labels (`error_kind`) +# free via a snake_case `&'static str` for every variant. Used at +# `tracing::warn!(error_kind = .into(), ...)` sites and +# any `metrics::counter!(... "error_kind" => kind)` recordings, so the +# Prometheus labels stay in lock-step with the Rust enum source of +# truth instead of needing a `match err { ... => "connect" ... }` +# ladder per call site. Pinned via the workspace so every consumer +# moves in lockstep. +strum.workspace = true tokio.workspace = true clap.workspace = true diff --git a/crates/nexum-engine/src/engine_config.rs b/crates/nexum-engine/src/engine_config.rs index 87e574d..7252e57 100644 --- a/crates/nexum-engine/src/engine_config.rs +++ b/crates/nexum-engine/src/engine_config.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::Deserialize; +use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; @@ -30,7 +31,11 @@ use tracing::{info, warn}; /// `supervisor.rs` top-level dispatch. The variants carry the /// upstream error via `#[from]` so the caller in `main.rs` (which /// uses `anyhow`) gets a free conversion through `?`. -#[derive(Debug, Error)] +/// +/// `IntoStaticStr` exposes the snake_case variant name for metric +/// labels and structured-log `error_kind` fields. +#[derive(Debug, Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] #[non_exhaustive] pub enum EngineConfigError { /// Failed to read the config file from disk. diff --git a/crates/nexum-engine/src/host/cow_orderbook.rs b/crates/nexum-engine/src/host/cow_orderbook.rs index 865ab88..364e8df 100644 --- a/crates/nexum-engine/src/host/cow_orderbook.rs +++ b/crates/nexum-engine/src/host/cow_orderbook.rs @@ -19,6 +19,7 @@ use std::collections::BTreeMap; use cowprotocol::{Chain, OrderBookApi, OrderCreation, OrderUid}; +use strum::IntoStaticStr; use thiserror::Error; /// Process-wide pool of `OrderBookApi` clients keyed by EVM chain id. @@ -120,7 +121,14 @@ impl OrderBookPool { } } -#[derive(Debug, Error)] +/// `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(u64), diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index 0c307cf..62816ae 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -20,6 +20,7 @@ use alloy_rpc_types_eth::{Filter, Header, Log}; use futures::stream::Stream; use futures::stream::StreamExt as _; use serde_json::value::RawValue; +use strum::IntoStaticStr; use thiserror::Error; use tracing::info; @@ -157,7 +158,13 @@ pub type BlockStream = Pin> pub type LogStream = Pin> + Send>>; /// Errors surfaced by [`ProviderPool`]. -#[derive(Debug, Error)] +/// +/// `IntoStaticStr` produces the snake_case variant name as +/// `&'static str` for metric labels and structured-log fields; the +/// per-variant Display still carries the detail via `thiserror`. +#[derive(Debug, Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] pub enum ProviderError { /// Chain id absent from the engine config. #[error("unknown chain {0} (no engine.toml entry)")] diff --git a/crates/nexum-engine/src/manifest/error.rs b/crates/nexum-engine/src/manifest/error.rs index a9f4962..941adfc 100644 --- a/crates/nexum-engine/src/manifest/error.rs +++ b/crates/nexum-engine/src/manifest/error.rs @@ -1,11 +1,17 @@ //! Error types for manifest parsing and capability enforcement. +use strum::IntoStaticStr; use thiserror::Error; use super::types::KNOWN_CAPABILITIES; /// Errors returned while loading or validating a manifest. -#[derive(Debug, Error)] +/// +/// `IntoStaticStr` exposes the snake_case variant name as a +/// `&'static str` for the manifest-loader's `tracing::warn!` / +/// `metrics::counter!` call sites. +#[derive(Debug, Error, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] #[non_exhaustive] pub enum ParseError { /// Failed to read the manifest file from disk. From f08405a0e64c840ebcc71f5589ce0deffe973c3f Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 19:08:55 -0300 Subject: [PATCH 05/21] refactor(local-store): extract `local_store_err` map closure helper Audit reference: milestone-rubric-grant-audit-2026-06-25.md, duplication finding "internal_error('local-store', err.to_string()) map closures" (4 sites in one file). The four `local-store` host endpoints all `.map_err`-ed the same `StorageError -> HostError` conversion inline. Replace with a single `local_store_err(StorageError) -> HostError` free function so a future error-model change (richer kind, structured `data`) lands in one place instead of four call sites. Behaviour is identical; the helper is `fn`, not a closure, so codegen is one shared symbol. --- .../src/host/impls/local_store.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/nexum-engine/src/host/impls/local_store.rs b/crates/nexum-engine/src/host/impls/local_store.rs index 340b942..77bf546 100644 --- a/crates/nexum-engine/src/host/impls/local_store.rs +++ b/crates/nexum-engine/src/host/impls/local_store.rs @@ -3,30 +3,32 @@ use crate::bindings::HostError; use crate::bindings::nexum; use crate::host::error::internal_error; +use crate::host::local_store_redb::StorageError; use crate::host::state::HostState; +/// Shared `StorageError` -> `HostError` conversion used by every +/// `local-store` host endpoint. Centralised so the `("local-store", +/// err.to_string())` shape stays consistent and a future error-model +/// change (richer kind, structured `data`) lands in one place +/// instead of four call sites. +fn local_store_err(err: StorageError) -> HostError { + internal_error("local-store", err.to_string()) +} + impl nexum::host::local_store::Host for HostState { async fn get(&mut self, key: String) -> Result>, HostError> { - self.store - .get(&key) - .map_err(|err| internal_error("local-store", err.to_string())) + self.store.get(&key).map_err(local_store_err) } async fn set(&mut self, key: String, value: Vec) -> Result<(), HostError> { - self.store - .set(&key, &value) - .map_err(|err| internal_error("local-store", err.to_string())) + self.store.set(&key, &value).map_err(local_store_err) } async fn delete(&mut self, key: String) -> Result<(), HostError> { - self.store - .delete(&key) - .map_err(|err| internal_error("local-store", err.to_string())) + self.store.delete(&key).map_err(local_store_err) } async fn list_keys(&mut self, prefix: String) -> Result, HostError> { - self.store - .list_keys(&prefix) - .map_err(|err| internal_error("local-store", err.to_string())) + self.store.list_keys(&prefix).map_err(local_store_err) } } From 440f09fafe6d6922bbc6837e0a4646730bb1ac4f Mon Sep 17 00:00:00 2001 From: brunota20 Date: Thu, 25 Jun 2026 19:09:07 -0300 Subject: [PATCH 06/21] fix(supervisor): emit nexum.toml deprecation via tracing::warn! Audit reference: milestone-rubric-grant-audit-2026-06-25.md, Major #10 (`eprintln!` in daemon-side manifest loader code). Every other deprecation site in the engine routes through `tracing` (the main binary installs a JSON `tracing_subscriber` and the manifest loader itself uses `warn!`). The supervisor's `nexum.toml` fallback was the lone `eprintln!` survivor, which bypasses the structured-log pipeline and breaks operators who set `RUST_LOG=warn,manifest=warn` for daemon log aggregation. Switches to `warn!(target: "manifest", path = %legacy.display(), ...)` so the deprecation surfaces through the same channel as the no-`[capabilities]` warning emitted a few lines later by `manifest::load`. --- crates/nexum-engine/src/supervisor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/nexum-engine/src/supervisor.rs b/crates/nexum-engine/src/supervisor.rs index 1462a22..f0fa480 100644 --- a/crates/nexum-engine/src/supervisor.rs +++ b/crates/nexum-engine/src/supervisor.rs @@ -135,8 +135,10 @@ impl Supervisor { } let legacy = dir.join("nexum.toml"); if legacy.exists() { - eprintln!( - "[deprecation] nexum.toml is deprecated; rename to module.toml \ + warn!( + target: "manifest", + path = %legacy.display(), + "nexum.toml is deprecated; rename to module.toml \ (ADR-0001). Support will be removed in 0.3." ); return Some(legacy); From dbf4d9c26e68c9c8c6c8c6dab3a67e6dfb989da5 Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Mon, 29 Jun 2026 19:51:02 -0300 Subject: [PATCH 07/21] test(nexum-engine): cover untested error variants and concurrent access (issues 3, 5, 7) Add 12 tests across three M2 host modules: cow_orderbook (5 tests): Network error on dead server, 5xx passthrough, BadPath leniency, Decode on invalid/wrong-schema JSON for submit_order. provider_pool (3 tests): InvalidParams through full request path, Rpc error on unreachable node, Rpc error on malformed node response. Adds test_config helper for EngineConfig construction. local_store_redb (4 tests): Concurrent writes across namespaces, concurrent reads during writes, list_keys/delete race, stress test with 8 writers on one namespace. --- .../src/host/cow_orderbook/tests.rs | 83 +++++++++ .../src/host/local_store_redb/tests.rs | 157 ++++++++++++++++++ crates/nexum-engine/src/host/provider_pool.rs | 66 ++++++++ 3 files changed, 306 insertions(+) diff --git a/crates/nexum-engine/src/host/cow_orderbook/tests.rs b/crates/nexum-engine/src/host/cow_orderbook/tests.rs index ef318c9..f66a253 100644 --- a/crates/nexum-engine/src/host/cow_orderbook/tests.rs +++ b/crates/nexum-engine/src/host/cow_orderbook/tests.rs @@ -206,3 +206,86 @@ fn sample_order_json() -> String { .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 — the response + // body is still surfaced to the caller. + let result = pool + .request(Chain::Mainnet.id(), "GET", "://not-a-path", None) + .await; + assert!(result.is_ok(), "Url::join treats this as a relative path, so no BadPath error"); +} + +#[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::BTreeMap::new(); + clients.insert( + Chain::Mainnet.id(), + 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(Chain::Mainnet.id(), "GET", "/api/v1/version", None) + .await + .unwrap_err(); + assert!(matches!(err, CowApiError::Network(_))); +} + +#[tokio::test] +async fn request_5xx_response_is_returned_verbatim() { + 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 body = pool + .request(Chain::Mainnet.id(), "GET", "/api/v1/health", None) + .await + .expect("5xx body is returned, not an Err"); + assert_eq!(body, r#"{"error":"internal"}"#); +} + +#[tokio::test] +async fn submit_order_rejects_invalid_json() { + let pool = OrderBookPool::default(); + let err = pool + .submit_order_json(Chain::Mainnet.id(), 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(Chain::Mainnet.id(), br#"{"valid":"json"}"#) + .await + .unwrap_err(); + assert!(matches!(err, CowApiError::Decode(_))); +} diff --git a/crates/nexum-engine/src/host/local_store_redb/tests.rs b/crates/nexum-engine/src/host/local_store_redb/tests.rs index 21ba42a..5f65356 100644 --- a/crates/nexum-engine/src/host/local_store_redb/tests.rs +++ b/crates/nexum-engine/src/host/local_store_redb/tests.rs @@ -91,3 +91,160 @@ fn module_handles_share_underlying_data() { fn store_prefix(name: &str) -> Vec { keccak256(name.as_bytes()).to_vec() } + +// --------------------------------------------------------------------------- +// Concurrent access tests +// --------------------------------------------------------------------------- + +#[test] +fn concurrent_writes_from_different_namespaces() { + let (_dir, store) = fresh(); + + let handles: Vec<_> = (0..8) + .map(|i| { + let s = store.clone(); + std::thread::spawn(move || { + let ms = s.module(&format!("ns-{i}")).unwrap(); + for j in 0..100 { + let key = format!("key-{j}"); + let val = format!("val-{i}-{j}").into_bytes(); + ms.set(&key, &val).unwrap(); + } + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread panicked"); + } + + for i in 0..8 { + let ms = store.module(&format!("ns-{i}")).unwrap(); + for j in 0..100 { + let key = format!("key-{j}"); + let expected = format!("val-{i}-{j}").into_bytes(); + assert_eq!( + ms.get(&key).unwrap().as_deref(), + Some(expected.as_slice()), + ); + } + } +} + +#[test] +fn concurrent_reads_during_writes() { + let (_dir, store) = fresh(); + let ms = store.module("rw").unwrap(); + + // Pre-populate namespace "rw" with 50 keys. + for j in 0..50 { + ms.set(&format!("k-{j}"), b"old").unwrap(); + } + + let writer_ms = ms.clone(); + let writer = std::thread::spawn(move || { + for j in 0..50 { + writer_ms.set(&format!("k-{j}"), b"new").unwrap(); + } + }); + + let readers: Vec<_> = (0..4) + .map(|_| { + let reader_ms = ms.clone(); + std::thread::spawn(move || { + for _ in 0..100 { + for j in 0..50 { + let val = reader_ms.get(&format!("k-{j}")).unwrap(); + let val = val.expect("key must exist"); + assert!( + val == b"old" || val == b"new", + "unexpected value: {:?}", + val, + ); + } + } + }) + }) + .collect(); + + writer.join().expect("writer panicked"); + for r in readers { + r.join().expect("reader panicked"); + } + + // Final state: all keys must be "new". + for j in 0..50 { + assert_eq!( + ms.get(&format!("k-{j}")).unwrap().as_deref(), + Some(&b"new"[..]), + ); + } +} + +#[test] +fn list_keys_races_with_delete() { + let (_dir, store) = fresh(); + let ms = store.module("race").unwrap(); + + // Pre-populate namespace "race" with 100 keys. + for i in 0..100 { + ms.set(&format!("k:{i}"), b"x").unwrap(); + } + + let deleter_ms = ms.clone(); + let deleter = std::thread::spawn(move || { + for i in 0..100 { + deleter_ms.delete(&format!("k:{i}")).unwrap(); + } + }); + + let lister_ms = ms.clone(); + let lister = std::thread::spawn(move || { + for _ in 0..50 { + let keys = lister_ms.list_keys("k:").unwrap(); + assert!( + keys.len() <= 100, + "list_keys returned more keys than expected: {}", + keys.len(), + ); + } + }); + + deleter.join().expect("deleter panicked"); + lister.join().expect("lister panicked"); +} + +#[test] +fn stress_many_writers_one_namespace() { + let (_dir, store) = fresh(); + let ms = store.module("shared").unwrap(); + + let handles: Vec<_> = (0..8) + .map(|i| { + let ms = ms.clone(); + std::thread::spawn(move || { + for j in 0..100 { + let key = format!("t{i}-k{j}"); + let val = format!("v-{i}-{j}").into_bytes(); + ms.set(&key, &val).unwrap(); + } + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread panicked"); + } + + // Verify all 800 keys are present with correct values. + for i in 0..8 { + for j in 0..100 { + let key = format!("t{i}-k{j}"); + let expected = format!("v-{i}-{j}").into_bytes(); + assert_eq!( + ms.get(&key).unwrap().as_deref(), + Some(expected.as_slice()), + ); + } + } +} diff --git a/crates/nexum-engine/src/host/provider_pool.rs b/crates/nexum-engine/src/host/provider_pool.rs index 62816ae..28cb2d7 100644 --- a/crates/nexum-engine/src/host/provider_pool.rs +++ b/crates/nexum-engine/src/host/provider_pool.rs @@ -237,4 +237,70 @@ mod tests { let result = RawValue::from_string(bad.to_owned()); assert!(result.is_err(), "invalid JSON should fail RawValue parse"); } + + /// Helper: build an `EngineConfig` with a single HTTP chain entry. + fn test_config(chain_id: u64, rpc_url: &str) -> EngineConfig { + use crate::engine_config::{ChainConfig, EngineConfig}; + let mut chains = BTreeMap::new(); + chains.insert( + chain_id, + ChainConfig { + rpc_url: rpc_url.to_owned(), + }, + ); + EngineConfig { + chains, + ..Default::default() + } + } + + #[tokio::test] + async fn invalid_params_through_request_produces_error() { + let cfg = test_config(1, "http://127.0.0.1:1"); + let pool = ProviderPool::from_config(&cfg).await.unwrap(); + let err = pool + .request(1, "eth_blockNumber".into(), "not json {{{".into()) + .await + .unwrap_err(); + assert!( + matches!(err, ProviderError::InvalidParams { .. }), + "expected InvalidParams, got: {err:?}" + ); + } + + #[tokio::test] + async fn rpc_error_on_unreachable_node() { + let cfg = test_config(1, "http://127.0.0.1:1"); + let pool = ProviderPool::from_config(&cfg).await.unwrap(); + let err = pool + .request(1, "eth_blockNumber".into(), "[]".into()) + .await + .unwrap_err(); + assert!( + matches!(err, ProviderError::Rpc { .. }), + "expected Rpc error, got: {err:?}" + ); + } + + #[tokio::test] + async fn rpc_error_on_malformed_node_response() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; + + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_string("not json")) + .mount(&server) + .await; + + let cfg = test_config(1, &server.uri()); + let pool = ProviderPool::from_config(&cfg).await.unwrap(); + let err = pool + .request(1, "eth_blockNumber".into(), "[]".into()) + .await + .unwrap_err(); + assert!( + matches!(err, ProviderError::Rpc { .. }), + "expected Rpc error from malformed response, got: {err:?}" + ); + } } From 938373aba540fad24b256168815f9c56fb1b64c4 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 11:00:59 -0300 Subject: [PATCH 08/21] feat(modules): module.toml for twap-monitor + ethflow-watcher (BLEU-834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-0001 (module.toml schema), authored for the two M2 modules: twap-monitor / module.toml - capabilities.required = ["logging", "local-store", "chain", "cow-api"] — matches the Rust imports the BLEU-826/827/828 paths exercise. - [[subscription]] log on Sepolia (chain_id 11155111) against ComposableCoW (0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74) with topic-0 keccak256( "ConditionalOrderCreated(address,(address,bytes32,bytes))" ) = 0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361. - [[subscription]] block on Sepolia for the BLEU-827 poll loop. ethflow-watcher / module.toml - Same capability set (chain reserved for a future eth_call — e.g. read the EthFlow refund pointer — without churning the manifest). - [[subscription]] log on Sepolia against CoWSwapEthFlow production (0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC) with topic-0 keccak256( "OrderPlacement(address,(address,address,address,uint256,uint256, uint32,bytes32,uint256,bytes32,bool,bytes32,bytes32), (uint8,bytes),bytes)" ) = 0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9. Both [capabilities.http].allow stay empty: all outbound HTTP flows through the cow-api capability, which routes via the host's pinned orderbook URL. The content hash field is the 0.2 placeholder all-zero sha256; 0.3 will validate it against the loaded component bytes. Linear: BLEU-834. Ref ADR-0001. --- modules/ethflow-watcher/module.toml | 35 ++++++++++++++++++++++++ modules/twap-monitor/module.toml | 41 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 modules/ethflow-watcher/module.toml create mode 100644 modules/twap-monitor/module.toml diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml new file mode 100644 index 0000000..9a78dfa --- /dev/null +++ b/modules/ethflow-watcher/module.toml @@ -0,0 +1,35 @@ +# ethflow-watcher: see `CoWSwapEthFlow.OrderPlacement`, lift the embedded +# `GPv2OrderData` into an `OrderCreation`, and submit it via the CoW +# Protocol orderbook with the EIP-1271 signing scheme. + +[module] +name = "ethflow-watcher" +version = "0.1.0" +# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will +# compare it against the sha256 of the loaded component bytes. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# Same set as twap-monitor for symmetry and future-proofing — the module +# imports logging, local-store and cow-api today; `chain` is declared +# because a follow-up may add an eth_call (e.g. to read the EthFlow +# refund pointer) without churning the manifest. +required = ["logging", "local-store", "chain", "cow-api"] +optional = [] + +[capabilities.http] +# All outbound HTTP goes through `cow-api`; no direct `http` calls. +allow = [] + +# --- subscriptions ------------------------------------------------------ + +# CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( +# "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, +# bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). +# `address` is the production deployment, identical on every chain CoW +# Protocol supports (cowprotocol::ETH_FLOW_PRODUCTION). +[[subscription]] +kind = "log" +chain_id = 11155111 +address = "0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC" +event_signature = "0xcf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9" diff --git a/modules/twap-monitor/module.toml b/modules/twap-monitor/module.toml new file mode 100644 index 0000000..fb3f361 --- /dev/null +++ b/modules/twap-monitor/module.toml @@ -0,0 +1,41 @@ +# twap-monitor: poll registered ComposableCoW conditional orders and +# submit ready ones via the CoW Protocol orderbook. + +[module] +name = "twap-monitor" +version = "0.1.0" +# Placeholder content hash. 0.2 parses but does not verify this; 0.3 will +# compare it against the sha256 of the loaded component bytes. +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +# Host interfaces the module imports and exercises: +# - logging -> structured runtime logs +# - 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"] +optional = [] + +[capabilities.http] +# All outbound HTTP goes through `cow-api` (which routes through the +# host's pinned orderbook URL); no direct `http` calls. +allow = [] + +# --- subscriptions ------------------------------------------------------ + +# ComposableCoW.ConditionalOrderCreated emissions on Sepolia. topic-0 = +# keccak256("ConditionalOrderCreated(address,(address,bytes32,bytes))"). +# Both `address` and `event_signature` are pinned so the supervisor +# does not deliver unrelated logs to the module. +[[subscription]] +kind = "log" +chain_id = 11155111 +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" +event_signature = "0x2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361" + +# New-block ticks drive the TWAP poll loop (`getTradeableOrderWithSignature`). +[[subscription]] +kind = "block" +chain_id = 11155111 From 3a778fa3c869e8ab0ee38bf20b1234a8489070f5 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 22 Jun 2026 08:52:53 -0300 Subject: [PATCH 09/21] review: address jeffersonBastos feedback on PR #54 (BLEU-834 manifests) Three threads from the internal review mirror of upstream nullislabs/shepherd PR #17: 1. ethflow-watcher/module.toml capabilities: move `chain` from required to optional. The comment on the original manifest already said the module does not call `chain` today; declaring it as required widened the grant for a capability the module does not exercise. Optional keeps "future-proofing" (BLEU-855 can use it without manifest churn) without violating least-privilege. 2. ethflow-watcher/module.toml subscription comment: soften the "identical on every chain" claim. cow-rs::ETH_FLOW_PRODUCTION is identical across chains today, but unlike ComposableCoW's CREATE2 address EthFlow has had multiple per-network and per-version deployments historically. Multi-chain config in M5 must re-check per `chain_id` instead of assuming the address carries. Address itself stays unchanged: 0xbA3cB449bD2B4ADddBc894D8697F5170800EAdeC is verified against the live Sepolia deployment (event firing observed in the COW-1064 dry-run on 2026-06-18 + cow-rs canonical constant + multiple load-test runs). 3. README.md module manifest example: the documented `address` field said `0xC92E8bdf79f0507f65a392b0ab4667716BFE0110` labeled "ComposableCoW", but that is the GPv2VaultRelayer (per scripts/lib.sh). ComposableCoW is `0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74`. Fixed the address; expanded the comment to clarify it is the canonical CREATE2 address (same on every supported chain). Stays on `feat/m2-module-manifests-bleu-834` as a stacked branch so upstream PR #17 + internal mirror PR #54 can see the fixes as a separate, atomic commit. --- README.md | 2 +- modules/ethflow-watcher/module.toml | 20 ++++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e44e9d4..0c5460f 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ allow = ["api.cow.fi"] [[subscription]] kind = "log" chain_id = 1 -address = "0xC92E8bdf79f0507f65a392b0ab4667716BFE0110" # ComposableCoW +address = "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" # ComposableCoW (canonical CREATE2 address, same on every supported chain) [[subscription]] kind = "block" diff --git a/modules/ethflow-watcher/module.toml b/modules/ethflow-watcher/module.toml index 9a78dfa..1732901 100644 --- a/modules/ethflow-watcher/module.toml +++ b/modules/ethflow-watcher/module.toml @@ -10,12 +10,13 @@ version = "0.1.0" component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" [capabilities] -# Same set as twap-monitor for symmetry and future-proofing — the module -# imports logging, local-store and cow-api today; `chain` is declared -# because a follow-up may add an eth_call (e.g. to read the EthFlow -# refund pointer) without churning the manifest. -required = ["logging", "local-store", "chain", "cow-api"] -optional = [] +# Least-privilege: the module exercises logging, local-store and +# cow-api today; `chain` is listed as optional so a follow-up (e.g. +# BLEU-855 adding an eth_call to read the EthFlow refund pointer) +# can use it without manifest churn, without widening the required +# grant for a capability the module does not call yet. +required = ["logging", "local-store", "cow-api"] +optional = ["chain"] [capabilities.http] # All outbound HTTP goes through `cow-api`; no direct `http` calls. @@ -26,8 +27,11 @@ allow = [] # CoWSwapEthFlow.OrderPlacement on Sepolia. topic-0 = keccak256( # "OrderPlacement(address,(address,address,address,uint256,uint256,uint32, # bytes32,uint256,bytes32,bool,bytes32,bytes32),(uint8,bytes),bytes)"). -# `address` is the production deployment, identical on every chain CoW -# Protocol supports (cowprotocol::ETH_FLOW_PRODUCTION). +# `address` is the Sepolia ETH_FLOW_PRODUCTION deployment from +# `cowprotocol/ethflowcontract/networks.prod.json`. Unlike +# ComposableCoW's CREATE2 address, EthFlow has had multiple per-network +# and per-version deployments; M5 multi-chain config MUST re-check the +# address per `chain_id` instead of assuming this value carries. [[subscription]] kind = "log" chain_id = 11155111 From 71d752429374eb802afa8cbe1fa8b63d314ce3f4 Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Mon, 29 Jun 2026 22:04:40 -0300 Subject: [PATCH 10/21] style: apply cargo fmt after rebase --- crates/nexum-engine/src/host/cow_orderbook/tests.rs | 13 ++++++------- .../nexum-engine/src/host/local_store_redb/tests.rs | 10 ++-------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/crates/nexum-engine/src/host/cow_orderbook/tests.rs b/crates/nexum-engine/src/host/cow_orderbook/tests.rs index f66a253..54ffc80 100644 --- a/crates/nexum-engine/src/host/cow_orderbook/tests.rs +++ b/crates/nexum-engine/src/host/cow_orderbook/tests.rs @@ -224,7 +224,10 @@ async fn request_rejects_malformed_path() { let result = pool .request(Chain::Mainnet.id(), "GET", "://not-a-path", None) .await; - assert!(result.is_ok(), "Url::join treats this as a relative path, so no BadPath error"); + assert!( + result.is_ok(), + "Url::join treats this as a relative path, so no BadPath error" + ); } #[tokio::test] @@ -235,9 +238,7 @@ async fn request_network_error_on_dead_server() { let mut clients = std::collections::BTreeMap::new(); clients.insert( Chain::Mainnet.id(), - OrderBookApi::new_with_base_url( - "http://127.0.0.1:1/".parse().expect("valid url"), - ), + OrderBookApi::new_with_base_url("http://127.0.0.1:1/".parse().expect("valid url")), ); let pool = OrderBookPool { clients, @@ -255,9 +256,7 @@ async fn request_5xx_response_is_returned_verbatim() { 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"}"#), - ) + .respond_with(ResponseTemplate::new(500).set_body_string(r#"{"error":"internal"}"#)) .expect(1) .mount(&mock) .await; diff --git a/crates/nexum-engine/src/host/local_store_redb/tests.rs b/crates/nexum-engine/src/host/local_store_redb/tests.rs index 5f65356..21b8e8f 100644 --- a/crates/nexum-engine/src/host/local_store_redb/tests.rs +++ b/crates/nexum-engine/src/host/local_store_redb/tests.rs @@ -123,10 +123,7 @@ fn concurrent_writes_from_different_namespaces() { for j in 0..100 { let key = format!("key-{j}"); let expected = format!("val-{i}-{j}").into_bytes(); - assert_eq!( - ms.get(&key).unwrap().as_deref(), - Some(expected.as_slice()), - ); + assert_eq!(ms.get(&key).unwrap().as_deref(), Some(expected.as_slice()),); } } } @@ -241,10 +238,7 @@ fn stress_many_writers_one_namespace() { for j in 0..100 { let key = format!("t{i}-k{j}"); let expected = format!("v-{i}-{j}").into_bytes(); - assert_eq!( - ms.get(&key).unwrap().as_deref(), - Some(expected.as_slice()), - ); + assert_eq!(ms.get(&key).unwrap().as_deref(), Some(expected.as_slice()),); } } } From 21ec7dc2f2be69b354876cb3b0bd1aea09299e69 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 09:25:25 -0300 Subject: [PATCH 11/21] feat(twap-monitor): workspace + skeleton (BLEU-825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add modules/twap-monitor/ as a workspace member. Cargo.toml declares [lib] crate-type = ["cdylib"] for WASM Component output, and pulls the deps the TWAP module path needs: cowprotocol (default-features off — only typed primitives and OrderCreation surface needed), alloy-sol-types (event/return decoding lands in BLEU-826/827), and wit-bindgen. src/lib.rs binds against the shepherd:cow/shepherd world (event- module imports + cow-api). generate_all is required because the world include pulls nexum:host/types across packages — without it, wit-bindgen panics on the missing cross-package mapping. init and on_event are stubbed: init logs once; on_event is a no-op until the Event::Log / Event::Block dispatch lands in BLEU-826 / BLEU-827. Verification: cargo build --target wasm32-wasip2 --release -p twap-monitor emits a 65 KB .wasm. Engine load is gated on module.toml (BLEU-834). --- Cargo.toml | 1 + modules/twap-monitor/Cargo.toml | 14 ++++++++++++++ modules/twap-monitor/src/lib.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 modules/twap-monitor/Cargo.toml create mode 100644 modules/twap-monitor/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index cb0bdbc..ca28695 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/nexum-engine", "modules/example", + "modules/twap-monitor", ] resolver = "2" diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml new file mode 100644 index 0000000..bafc696 --- /dev/null +++ b/modules/twap-monitor/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "twap-monitor" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +cowprotocol = { version = "1.0.0-alpha.3", default-features = false } +alloy-sol-types = { version = "1.5", default-features = false } +wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs new file mode 100644 index 0000000..72c0763 --- /dev/null +++ b/modules/twap-monitor/src/lib.rs @@ -0,0 +1,29 @@ +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![allow(clippy::too_many_arguments)] + +wit_bindgen::generate!({ + path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + world: "shepherd:cow/shepherd", + generate_all, +}); + +use nexum::host::logging; +use nexum::host::types; + +struct TwapMonitor; + +impl Guest for TwapMonitor { + fn init(_config: Vec<(String, String)>) -> Result<(), HostError> { + logging::log(logging::Level::Info, "twap-monitor init"); + Ok(()) + } + + fn on_event(_event: types::Event) -> Result<(), HostError> { + // Dispatch on Event::Log (ConditionalOrderCreated) and Event::Block + // (TWAP poll tick) lands in BLEU-826 / BLEU-827. + Ok(()) + } +} + +export!(TwapMonitor); From 30606521cb109f861f81de4ed3505826319f868d Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 09:48:50 -0300 Subject: [PATCH 12/21] =?UTF-8?q?feat(twap-monitor):=20index=20Conditional?= =?UTF-8?q?OrderCreated=20=E2=86=92=20local-store=20(BLEU-826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on_event(Event::Logs)` decodes each log against `ComposableCoW.ConditionalOrderCreated` via `alloy_sol_types`, extracts `(owner, params)`, and writes `watch:{owner}:{params_hash}` to local-store with the abi-encoded `ConditionalOrderParams` as the value. BLEU-827 reads this back via `list-keys("watch:")` and the value is exactly the `(handler, salt, staticInput)` tuple the poll path passes to `getTradeableOrderWithSignature`. Idempotency: `local_store::set` overwrites in place, so re-org replay or overlapping subscription windows produce no observable side effect. Resilience: `decode_conditional_order_created` returns `None` when topic0 does not match the event signature or the payload fails ABI decoding. Adjacent events on the same subscription (MerkleRootSet, SwapGuardSet) are silently skipped instead of short-circuiting the batch. The fn is on plain slices so the host-free unit tests cover well-formed / wrong-topic / empty- topics without wit-bindgen scaffolding. Block, Tick, and Message variants of `Event` are left unhandled in this PR — `Event::Block` dispatch lands in BLEU-827 (poll path); the other two are not used by this module. Adds `alloy-primitives` as a direct dep so the topic/data plumbing does not rely on alloy types leaking through `cowprotocol`'s re-exports. `cargo build --target wasm32-wasip2 --release -p twap-monitor` emits a 96 KB .wasm (up from the 65 KB skeleton because of the alloy + cowprotocol composable types now linked in). --- modules/twap-monitor/Cargo.toml | 3 +- modules/twap-monitor/src/lib.rs | 99 +++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index bafc696..6cde093 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -10,5 +10,6 @@ crate-type = ["cdylib"] [dependencies] cowprotocol = { version = "1.0.0-alpha.3", default-features = false } -alloy-sol-types = { version = "1.5", default-features = false } +alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } +alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 72c0763..fbf0515 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -8,8 +8,10 @@ wit_bindgen::generate!({ generate_all, }); -use nexum::host::logging; -use nexum::host::types; +use alloy_primitives::{Address, B256, keccak256}; +use alloy_sol_types::{SolEvent, SolValue}; +use cowprotocol::{ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams}; +use nexum::host::{local_store, logging, types}; struct TwapMonitor; @@ -19,11 +21,98 @@ impl Guest for TwapMonitor { Ok(()) } - fn on_event(_event: types::Event) -> Result<(), HostError> { - // Dispatch on Event::Log (ConditionalOrderCreated) and Event::Block - // (TWAP poll tick) lands in BLEU-826 / BLEU-827. + fn on_event(event: types::Event) -> Result<(), HostError> { + if let types::Event::Logs(logs) = event { + for log in &logs { + if let Some((owner, params)) = + decode_conditional_order_created(&log.topics, &log.data) + { + persist_watch(owner, ¶ms)?; + } + } + } + // Event::Block (TWAP poll) lands in BLEU-827; Tick / Message are not + // used by this module. Ok(()) } } +/// Decode a raw event log against `ComposableCoW.ConditionalOrderCreated`. +/// +/// Returns `None` when topic0 does not match the event signature or the +/// payload fails ABI decoding — both are non-fatal for an indexer that +/// shares a subscription with adjacent events. Kept on plain slices so +/// the host-free unit tests under `#[cfg(test)]` can call it without +/// wit-bindgen scaffolding. +fn decode_conditional_order_created( + topics: &[Vec], + data: &[u8], +) -> Option<(Address, ConditionalOrderParams)> { + let topic0 = topics.first()?; + if topic0.len() != 32 || B256::from_slice(topic0) != ConditionalOrderCreated::SIGNATURE_HASH { + return None; + } + let words: Vec = topics + .iter() + .filter(|t| t.len() == 32) + .map(|t| B256::from_slice(t)) + .collect(); + let decoded = ConditionalOrderCreated::decode_raw_log(words, data).ok()?; + Some((decoded.owner, decoded.params)) +} + +/// Persist a watch entry. `set` overwrites in place, so re-indexing the +/// same log (re-org replay, overlapping subscription windows) produces no +/// observable side effect — the idempotency the issue asks for. +fn persist_watch(owner: Address, params: &ConditionalOrderParams) -> Result<(), HostError> { + let encoded = params.abi_encode(); + let params_hash = keccak256(&encoded); + let key = format!("watch:{owner:#x}:{params_hash:#x}"); + local_store::set(&key, &encoded)?; + logging::log(logging::Level::Info, &format!("indexed {key}")); + Ok(()) +} + export!(TwapMonitor); + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256, hex}; + + #[test] + fn decodes_well_formed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = ConditionalOrderParams { + handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), + salt: b256!("0101010101010101010101010101010101010101010101010101010101010101"), + staticInput: hex!("deadbeef").to_vec().into(), + }; + // address indexed: 20-byte address left-padded to 32 bytes. + let owner_topic = { + let mut t = vec![0u8; 12]; + t.extend_from_slice(owner.as_slice()); + t + }; + let topics = vec![ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), owner_topic]; + let data = params.abi_encode(); + + let (decoded_owner, decoded_params) = + decode_conditional_order_created(&topics, &data).expect("decode succeeds"); + assert_eq!(decoded_owner, owner); + assert_eq!(decoded_params, params); + } + + #[test] + fn rejects_wrong_topic() { + let topics = + vec![b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec()]; + let data = vec![]; + assert!(decode_conditional_order_created(&topics, &data).is_none()); + } + + #[test] + fn rejects_empty_topics() { + assert!(decode_conditional_order_created(&[], &[]).is_none()); + } +} From bf7b8ac67c558a61a15938a917df69278116a2fb Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:05:35 -0300 Subject: [PATCH 13/21] feat(twap-monitor): eth_call poll path + PollOutcome decoder (BLEU-827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `on_event(Event::Block)` walks every persisted watch, skips the ones gated by a future `next_block:` / `next_epoch:` entry, and dispatches the ready ones via `chain::request("eth_call", [{to: COMPOSABLE_COW, data}, "latest"])` to `ComposableCoW.getTradeableOrderWithSignature(owner, params, "", [])`. Returns: - Successful return data → `<(GPv2OrderData, Bytes)>::abi_decode_params` → `PollOutcome::Ready { order, signature }`. - Revert payload → `decode_revert` matches the four-byte selector against the five `IConditionalOrder` errors: OrderNotValid → DontTryAgain PollNever → DontTryAgain PollTryNextBlock → TryNextBlock PollTryAtBlock(n) → TryOnBlock(n) PollTryAtEpoch(t) → TryAtEpoch(t) - Anything else falls back to TryNextBlock so a flaky RPC or unmodelled require-revert is retried instead of dropped. Decoder ABI: a local `abi::Params` struct mirrors the wire format of `cowprotocol::ConditionalOrderParams` because sol! cannot cross crate boundaries; the resulting call selector is byte-equal to the real contract. The successful return path decodes into the canonical `cowprotocol::GPv2OrderData` directly, so the 12-field struct is not duplicated. `Ready` boxes the order to keep `PollOutcome` cache-friendly (clippy::large_enum_variant). Storage conventions (shared with BLEU-830, which writes these): - `next_block:{owner}:{params_hash}` -> u64 LE — block number gate - `next_epoch:{owner}:{params_hash}` -> u64 LE — Unix-seconds gate Either / both / neither may be set; the watch polls when both pass. `block.timestamp` is milliseconds per WIT, so we divide by 1000 to compare against the `TryAtEpoch` (seconds) convention. Host follow-up: the chain backend currently swallows alloy's `RpcError::ErrorResp.data` (it becomes `host-error.message`, unstructured). `poll_one` is wired to consume structured revert hex via `host-error.data` once that lands — the `decode_revert_hex` test locks the path. Until then, every revert defaults to TryNextBlock, which is the safe choice. Tests: 14 new (return round-trip, all five revert variants, hex plumbing, eth_call JSON shape, watch-key round-trip, U256 saturation), keeping the 3 BLEU-826 regressions. `.wasm` grows from 96 KB to 215 KB (serde_json + IConditionalOrder ABI + the GPv2OrderData decode path linked in). Linear: BLEU-827. Ref ADR-0006. --- modules/twap-monitor/Cargo.toml | 1 + modules/twap-monitor/src/lib.rs | 476 ++++++++++++++++++++++++++++++-- 2 files changed, 450 insertions(+), 27 deletions(-) diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 6cde093..bd4afee 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -12,4 +12,5 @@ crate-type = ["cdylib"] cowprotocol = { version = "1.0.0-alpha.3", default-features = false } alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index fbf0515..56e5b29 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -8,10 +8,67 @@ wit_bindgen::generate!({ generate_all, }); -use alloy_primitives::{Address, B256, keccak256}; -use alloy_sol_types::{SolEvent, SolValue}; -use cowprotocol::{ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams}; -use nexum::host::{local_store, logging, types}; +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; +use cowprotocol::{ + COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, +}; +use nexum::host::{chain, local_store, logging, types}; + +mod abi { + use alloy_sol_types::sol; + + sol! { + /// Wire-format mirror of `cowprotocol::ConditionalOrderParams`. sol! + /// cannot reference Rust types declared in another sol! block, but + /// the ABI is identical (same field types in the same order) so the + /// generated call selector matches the real contract. + struct Params { + address handler; + bytes32 salt; + bytes staticInput; + } + + /// Selector source for `eth_call`. The successful return path + /// decodes into the canonical `cowprotocol::GPv2OrderData` instead + /// of duplicating the 12-field struct here. + function getTradeableOrderWithSignature( + address owner, + Params params, + bytes offchainInput, + bytes32[] proof + ) external view; + + /// Five custom errors `IConditionalOrder.verify` reverts with. + /// Source: `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. + interface IConditionalOrder { + error OrderNotValid(string reason); + error PollTryNextBlock(string reason); + error PollTryAtBlock(uint256 blockNumber, string reason); + error PollTryAtEpoch(uint256 timestamp, string reason); + error PollNever(string reason); + } + } +} + +/// Outcome of a single watch poll. Mirrors the BLEU-827 enum (rather than +/// `cowprotocol::PollOutcome`) so the lifecycle handler in BLEU-830 sees a +/// flat shape, with `Ready` carrying the materials BLEU-828's submit path +/// needs. +#[derive(Debug)] +#[allow(dead_code)] // Variants consumed by BLEU-828 (Ready) and BLEU-830 (others). +enum PollOutcome { + // `GPv2OrderData` is ~300 bytes; box it so this enum stays cache-friendly + // when the lifecycle handler shuffles outcomes around (clippy advice). + Ready { + order: Box, + signature: Bytes, + }, + TryAtEpoch(u64), + TryOnBlock(u64), + TryNextBlock, + DontTryAgain, +} struct TwapMonitor; @@ -22,28 +79,31 @@ impl Guest for TwapMonitor { } fn on_event(event: types::Event) -> Result<(), HostError> { - if let types::Event::Logs(logs) = event { - for log in &logs { - if let Some((owner, params)) = - decode_conditional_order_created(&log.topics, &log.data) - { - persist_watch(owner, ¶ms)?; + match event { + types::Event::Logs(logs) => { + for log in &logs { + if let Some((owner, params)) = + decode_conditional_order_created(&log.topics, &log.data) + { + persist_watch(owner, ¶ms)?; + } } } + types::Event::Block(block) => poll_all_watches(&block)?, + // Tick / Message are not used by this module. + _ => {} } - // Event::Block (TWAP poll) lands in BLEU-827; Tick / Message are not - // used by this module. Ok(()) } } +// ---- BLEU-826: indexing path ---- + /// Decode a raw event log against `ComposableCoW.ConditionalOrderCreated`. /// /// Returns `None` when topic0 does not match the event signature or the /// payload fails ABI decoding — both are non-fatal for an indexer that -/// shares a subscription with adjacent events. Kept on plain slices so -/// the host-free unit tests under `#[cfg(test)]` can call it without -/// wit-bindgen scaffolding. +/// shares a subscription with adjacent events. fn decode_conditional_order_created( topics: &[Vec], data: &[u8], @@ -61,18 +121,216 @@ fn decode_conditional_order_created( Some((decoded.owner, decoded.params)) } -/// Persist a watch entry. `set` overwrites in place, so re-indexing the -/// same log (re-org replay, overlapping subscription windows) produces no -/// observable side effect — the idempotency the issue asks for. +/// `set` overwrites in place, so re-indexing the same log (re-org replay, +/// overlapping subscription windows) produces no observable side effect. fn persist_watch(owner: Address, params: &ConditionalOrderParams) -> Result<(), HostError> { let encoded = params.abi_encode(); let params_hash = keccak256(&encoded); - let key = format!("watch:{owner:#x}:{params_hash:#x}"); + let key = watch_key(&owner, ¶ms_hash); local_store::set(&key, &encoded)?; logging::log(logging::Level::Info, &format!("indexed {key}")); Ok(()) } +// ---- BLEU-827: poll path ---- + +/// Iterate every persisted watch, skip the ones gated by a future +/// `next_block:` / `next_epoch:` entry, and dispatch the ready ones via +/// `eth_call`. +fn poll_all_watches(block: &types::Block) -> Result<(), HostError> { + let now_epoch_s = block.timestamp / 1000; + let keys = local_store::list_keys("watch:")?; + for key in keys { + let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { + continue; + }; + if !is_ready(owner_hex, hash_hex, block.number, now_epoch_s)? { + continue; + } + let Some(value) = local_store::get(&key)? else { + continue; + }; + let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { + logging::log( + logging::Level::Warn, + &format!("watch {key} carried unparseable params; skipping"), + ); + continue; + }; + let Ok(owner) = owner_hex.parse::
() else { + continue; + }; + let outcome = poll_one(block.chain_id, &owner, ¶ms); + logging::log( + logging::Level::Info, + &format!("poll {key} -> {}", outcome_label(&outcome)), + ); + // BLEU-830 will persist next_block / next_epoch / remove the watch + // based on `outcome`; BLEU-828 will submit on `Ready`. + } + Ok(()) +} + +fn poll_one(chain_id: u64, owner: &Address, params: &ConditionalOrderParams) -> PollOutcome { + let call = abi::getTradeableOrderWithSignatureCall { + owner: *owner, + params: abi::Params { + handler: params.handler, + salt: params.salt, + staticInput: params.staticInput.clone(), + }, + offchainInput: Bytes::new(), + proof: Vec::new(), + }; + let params_json = eth_call_params(&COMPOSABLE_COW, &call.abi_encode()); + match chain::request(chain_id, "eth_call", ¶ms_json) { + Ok(result_json) => parse_eth_call_result(&result_json) + .and_then(|bytes| decode_return(&bytes)) + .unwrap_or(PollOutcome::TryNextBlock), + Err(err) => { + // The host's chain backend currently stuffs the formatted RPC + // error into `message` with `data: None`; once it forwards the + // structured `error.data` from alloy's `RpcError::ErrorResp`, + // those bytes feed into `decode_revert` here. Until then, the + // `data` branch is unreachable on real traffic and the safe + // default is to retry on the next block. + if let Some(data) = err.data.as_deref() + && let Some(outcome) = decode_revert_hex(data) + { + return outcome; + } + logging::log( + logging::Level::Warn, + &format!("eth_call failed ({}); defaulting to TryNextBlock", err.message), + ); + PollOutcome::TryNextBlock + } + } +} + +/// Decode a successful `getTradeableOrderWithSignature` return into +/// `Ready { order, signature }`. The wire format is `abi.encode(order, +/// signature)` — the canonical Solidity return tuple — so the two-tuple +/// parameter decode lines up. +fn decode_return(data: &[u8]) -> Option { + let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; + Some(PollOutcome::Ready { + order: Box::new(order), + signature, + }) +} + +/// Decode a revert payload (selector + abi-encoded args) into a +/// `PollOutcome`. `None` when the selector is not one of the five +/// `IConditionalOrder` errors — including a bare `Error(string)` +/// require-revert, which the caller treats as TryNextBlock. +fn decode_revert(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + let selector: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match selector { + s if s == abi::IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), + s if s == abi::IConditionalOrder::PollTryNextBlock::SELECTOR => { + Some(PollOutcome::TryNextBlock) + } + s if s == abi::IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = abi::IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(PollOutcome::TryOnBlock(u256_to_u64_saturating( + decoded.blockNumber, + ))) + } + s if s == abi::IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = abi::IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( + decoded.timestamp, + ))) + } + s if s == abi::IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), + _ => None, + } +} + +/// Decode a hex string (with or without `0x` prefix, optionally wrapped in +/// JSON quotes) carrying revert bytes. +fn decode_revert_hex(s: &str) -> Option { + let stripped = s.trim_matches('"'); + let stripped = stripped.strip_prefix("0x").unwrap_or(stripped); + let bytes = alloy_primitives::hex::decode(stripped).ok()?; + decode_revert(&bytes) +} + +fn u256_to_u64_saturating(v: U256) -> u64 { + u64::try_from(v).unwrap_or(u64::MAX) +} + +fn outcome_label(o: &PollOutcome) -> &'static str { + match o { + PollOutcome::Ready { .. } => "Ready", + PollOutcome::TryAtEpoch(_) => "TryAtEpoch", + PollOutcome::TryOnBlock(_) => "TryOnBlock", + PollOutcome::TryNextBlock => "TryNextBlock", + PollOutcome::DontTryAgain => "DontTryAgain", + } +} + +// ---- key conventions shared with BLEU-830 ---- + +fn watch_key(owner: &Address, params_hash: &B256) -> String { + format!("watch:{owner:#x}:{params_hash:#x}") +} + +fn parse_watch_key(key: &str) -> Option<(&str, &str)> { + let rest = key.strip_prefix("watch:")?; + let (owner, hash) = rest.split_once(':')?; + Some((owner, hash)) +} + +fn is_ready( + owner_hex: &str, + hash_hex: &str, + block_number: u64, + epoch_s: u64, +) -> Result { + if let Some(next) = read_u64(&format!("next_block:{owner_hex}:{hash_hex}"))? + && block_number < next + { + return Ok(false); + } + if let Some(next) = read_u64(&format!("next_epoch:{owner_hex}:{hash_hex}"))? + && epoch_s < next + { + return Ok(false); + } + Ok(true) +} + +fn read_u64(key: &str) -> Result, HostError> { + let bytes = local_store::get(key)?; + Ok(bytes + .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) + .map(u64::from_le_bytes)) +} + +// ---- eth_call JSON plumbing ---- + +/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`. +fn eth_call_params(to: &Address, data: &[u8]) -> String { + let to_hex = format!("{to:#x}"); + let data_hex = alloy_primitives::hex::encode_prefixed(data); + serde_json::json!([{ "to": to_hex, "data": data_hex }, "latest"]).to_string() +} + +/// The host returns the raw JSON-RPC `result` field. For `eth_call` that +/// is a JSON string holding hex like `"0x1234..."`. Strip the JSON quotes, +/// strip the `0x` prefix, and hex-decode. Returns `None` on shape mismatch. +fn parse_eth_call_result(result_json: &str) -> Option> { + let s = serde_json::from_str::(result_json).ok()?; + let hex = s.strip_prefix("0x").unwrap_or(&s); + alloy_primitives::hex::decode(hex).ok() +} + export!(TwapMonitor); #[cfg(test)] @@ -80,15 +338,36 @@ mod tests { use super::*; use alloy_primitives::{address, b256, hex}; - #[test] - fn decodes_well_formed_log() { - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let params = ConditionalOrderParams { + fn sample_params() -> ConditionalOrderParams { + ConditionalOrderParams { handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), salt: b256!("0101010101010101010101010101010101010101010101010101010101010101"), staticInput: hex!("deadbeef").to_vec().into(), - }; - // address indexed: 20-byte address left-padded to 32 bytes. + } + } + + fn sample_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_u64), + buyAmount: U256::from(2_000_u64), + validTo: 1_700_000_000, + appData: B256::repeat_byte(0xaa), + feeAmount: U256::ZERO, + kind: B256::repeat_byte(0xbb), + partiallyFillable: false, + sellTokenBalance: B256::repeat_byte(0xcc), + buyTokenBalance: B256::repeat_byte(0xdd), + } + } + + // BLEU-826 regression — the indexer still produces the original tuple. + #[test] + fn decodes_well_formed_log() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let params = sample_params(); let owner_topic = { let mut t = vec![0u8; 12]; t.extend_from_slice(owner.as_slice()); @@ -107,12 +386,155 @@ mod tests { fn rejects_wrong_topic() { let topics = vec![b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec()]; - let data = vec![]; - assert!(decode_conditional_order_created(&topics, &data).is_none()); + assert!(decode_conditional_order_created(&topics, &[]).is_none()); } #[test] fn rejects_empty_topics() { assert!(decode_conditional_order_created(&[], &[]).is_none()); } + + // ---- BLEU-827 ---- + + #[test] + fn decode_return_round_trip() { + let order = sample_order(); + let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let wire = (order.clone(), sig.clone()).abi_encode_params(); + + match decode_return(&wire).expect("decode succeeds") { + PollOutcome::Ready { + order: o, + signature: s, + } => { + assert_eq!(o.sellToken, order.sellToken); + assert_eq!(o.buyAmount, order.buyAmount); + assert_eq!(s, sig); + } + other => panic!("expected Ready, got {other:?}"), + } + } + + #[test] + fn decode_revert_order_not_valid_maps_to_drop() { + let err = abi::IConditionalOrder::OrderNotValid { + reason: "expired".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::DontTryAgain) + )); + } + + #[test] + fn decode_revert_poll_never_maps_to_drop() { + let err = abi::IConditionalOrder::PollNever { + reason: "cancelled".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::DontTryAgain) + )); + } + + #[test] + fn decode_revert_try_next_block() { + let err = abi::IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::TryNextBlock) + )); + } + + #[test] + fn decode_revert_try_at_block_carries_number() { + let err = abi::IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(12_345_678_u64), + reason: "wait".to_string(), + }; + let outcome = decode_revert(&err.abi_encode()).expect("decode succeeds"); + assert!(matches!(outcome, PollOutcome::TryOnBlock(n) if n == 12_345_678)); + } + + #[test] + fn decode_revert_try_at_epoch_carries_timestamp() { + let err = abi::IConditionalOrder::PollTryAtEpoch { + timestamp: U256::from(1_700_000_000_u64), + reason: "soon".to_string(), + }; + let outcome = decode_revert(&err.abi_encode()).expect("decode succeeds"); + assert!(matches!(outcome, PollOutcome::TryAtEpoch(t) if t == 1_700_000_000)); + } + + #[test] + fn decode_revert_unknown_selector_returns_none() { + let mut data = vec![0xde, 0xad, 0xbe, 0xef]; + data.extend_from_slice(&[0u8; 32]); + assert!(decode_revert(&data).is_none()); + } + + #[test] + fn decode_revert_truncated_returns_none() { + assert!(decode_revert(&[0x01, 0x02]).is_none()); + } + + #[test] + fn decode_revert_hex_strips_prefix_and_quotes() { + let err = abi::IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(42_u64), + reason: "x".to_string(), + }; + let payload = alloy_primitives::hex::encode_prefixed(err.abi_encode()); + let quoted = format!("\"{payload}\""); + assert!(matches!( + decode_revert_hex("ed), + Some(PollOutcome::TryOnBlock(42)) + )); + } + + #[test] + fn u256_overflow_saturates() { + assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); + assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); + } + + #[test] + fn parse_eth_call_result_decodes_hex_string() { + assert_eq!( + parse_eth_call_result(r#""0xdeadbeef""#), + Some(vec![0xde, 0xad, 0xbe, 0xef]) + ); + } + + #[test] + fn parse_eth_call_result_handles_empty_hex() { + assert_eq!(parse_eth_call_result(r#""0x""#), Some(vec![])); + } + + #[test] + fn eth_call_params_shape() { + let to = address!("fdaFc9d1902f4e0b84f65F49f244b32b31013b74"); + let data = hex!("aabbcc").to_vec(); + let p = eth_call_params(&to, &data); + let parsed: serde_json::Value = serde_json::from_str(&p).unwrap(); + assert_eq!( + parsed[0]["to"], + "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74" + ); + assert_eq!(parsed[0]["data"], "0xaabbcc"); + assert_eq!(parsed[1], "latest"); + } + + #[test] + fn watch_key_round_trips_via_parse() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let hash = + b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let key = watch_key(&owner, &hash); + let (o, h) = parse_watch_key(&key).expect("parse"); + assert_eq!(o.parse::
().unwrap(), owner); + assert_eq!(h.parse::().unwrap(), hash); + } } From 879184ac8dfb7fd545f7b59c1210f0f5c4492f62 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:19:10 -0300 Subject: [PATCH 14/21] feat(twap-monitor): build OrderCreation and submit via cow-api (BLEU-828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On `PollOutcome::Ready { order, signature }`, convert the `GPv2OrderData` to the typed `OrderData` (maps the on-chain bytes32 markers `kind` / `sellTokenBalance` / `buyTokenBalance` via cowprotocol's `from_contract_bytes`), wrap the signature as `Signature::Eip1271` (ComposableCoW returns the orderbook wire form: raw verifier bytes, the orderbook re-prepends `from` before settlement), and feed everything through `OrderCreation::from_signed_order_data`. The body is then serde-encoded and pushed to `cow_api::submit_order(chain_id, body)`. On success, persist `submitted:{uid}` in local-store as an empty marker — presence of the key is the receipt; BLEU-830 may later attach metadata but the bare flag is enough to suppress double submits. Scope notes (deliberately deferred): - `app_data` is hard-coded to `EMPTY_APP_DATA_JSON`. Conditional orders that pin a real document on IPFS get rejected by `from_signed_order_data` (digest mismatch) and skipped with a Warn log instead of submitting a corrupt body. Resolving the document is its own concern. - Submission errors are logged. BLEU-829 wires `OrderPostError::retry_hint` into this site so the backoff / drop decision is data-driven. - `from` is set to the watch owner (the address that emitted `ConditionalOrderCreated`). The orderbook prepends this to the EIP-1271 blob during settlement. Tests: 7 new (gpv2_to_order_data marker mapping incl. zero- receiver normalisation, unknown kind / balance marker rejection; build_order_creation happy path with serde round- trip; rejection of non-empty app_data and `from = ZERO`). Total 24 host tests. `.wasm` 273 KB (was 215 KB; serde for OrderCreation, the OrderData/Signature/SigningScheme modules, and serde_with's runtime ride along). Linear: BLEU-828. Ref ADR-0006 (modules build orders themselves). --- modules/twap-monitor/src/lib.rs | 222 +++++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 2 deletions(-) diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 56e5b29..3570eea 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -11,9 +11,12 @@ wit_bindgen::generate!({ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; use cowprotocol::{ - COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, + BuyTokenDestination, COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, + ConditionalOrderParams, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderData, + OrderKind, SellTokenSource, Signature, }; use nexum::host::{chain, local_store, logging, types}; +use shepherd::cow::cow_api; mod abi { use alloy_sol_types::sol; @@ -165,8 +168,11 @@ fn poll_all_watches(block: &types::Block) -> Result<(), HostError> { logging::Level::Info, &format!("poll {key} -> {}", outcome_label(&outcome)), ); + if let PollOutcome::Ready { order, signature } = outcome { + submit_ready(block.chain_id, owner, &order, signature); + } // BLEU-830 will persist next_block / next_epoch / remove the watch - // based on `outcome`; BLEU-828 will submit on `Ready`. + // on the non-Ready arms. } Ok(()) } @@ -265,6 +271,130 @@ fn u256_to_u64_saturating(v: U256) -> u64 { u64::try_from(v).unwrap_or(u64::MAX) } +// ---- BLEU-828: submission path ---- + +/// Convert a freshly-polled `GPv2OrderData` into the `OrderData` shape the +/// orderbook signs against, mapping the on-chain `bytes32` markers for +/// `kind` / `sellTokenBalance` / `buyTokenBalance` to the typed enums. +/// Returns `None` when ComposableCoW emits a marker we don't know — the +/// caller skips the watch instead of submitting a malformed body. +fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { + Some(OrderData { + sell_token: gpv2.sellToken, + buy_token: gpv2.buyToken, + // `from_signed_order_data` already normalises Some(ZERO) -> None, + // but doing it here keeps the EIP-712 hash inputs verbatim if a + // caller bypasses that helper later. + receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), + sell_amount: gpv2.sellAmount, + buy_amount: gpv2.buyAmount, + valid_to: gpv2.validTo, + app_data: gpv2.appData, + fee_amount: gpv2.feeAmount, + kind: OrderKind::from_contract_bytes(gpv2.kind)?, + partially_fillable: gpv2.partiallyFillable, + sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, + buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, + }) +} + +/// Assemble the `OrderCreation` body the orderbook expects. +/// +/// `signature` is the EIP-1271 blob `ComposableCoW.getTradeableOrderWith +/// Signature` returns — in orderbook wire form (raw verifier bytes, the +/// orderbook re-prepends `from` before settlement). `from` is the owner +/// that emitted `ConditionalOrderCreated`. +/// +/// `app_data` is left at `EMPTY_APP_DATA_JSON`. If the conditional order +/// pins a non-empty document on IPFS, `from_signed_order_data` rejects the +/// mismatch (`keccak256("{}") != order.app_data`) and we surface the error +/// so the watch is not poisoned — resolving the document is a future +/// concern, not part of this PR. +fn build_order_creation( + order: &GPv2OrderData, + signature: Bytes, + from: Address, +) -> Result { + let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::from_signed_order_data( + &order_data, + signature, + from, + EMPTY_APP_DATA_JSON.to_string(), + None, + ) + .map_err(BuildError::Cowprotocol) +} + +#[derive(Debug)] +enum BuildError { + /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't + /// know how to map. + UnknownMarker, + /// `cowprotocol` rejected the body — typically `keccak256(app_data) != + /// order.app_data` (the conditional order pins a non-empty document) + /// or `from == Address::ZERO`. + Cowprotocol(cowprotocol::Error), +} + +impl core::fmt::Display for BuildError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnknownMarker => f.write_str("GPv2OrderData carried an unknown enum marker"), + Self::Cowprotocol(e) => write!(f, "{e}"), + } + } +} + +fn submit_ready(chain_id: u64, owner: Address, order: &GPv2OrderData, signature: Bytes) { + let creation = match build_order_creation(order, signature, owner) { + Ok(c) => c, + Err(e) => { + logging::log( + logging::Level::Warn, + &format!("twap submit skipped for {owner:#x}: {e}"), + ); + return; + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(b) => b, + Err(e) => { + logging::log( + logging::Level::Error, + &format!("OrderCreation JSON encode failed: {e}"), + ); + return; + } + }; + match cow_api::submit_order(chain_id, &body) { + Ok(uid) => { + let key = format!("submitted:{uid}"); + // Empty marker — presence of the key is the receipt. BLEU-830 + // may later attach metadata (block, attempt count) but the + // bare flag is enough to suppress double submits. + if let Err(e) = local_store::set(&key, b"") { + logging::log( + logging::Level::Error, + &format!("persist {key} failed: {}", e.message), + ); + return; + } + logging::log(logging::Level::Info, &format!("submitted {key}")); + } + Err(err) => { + // BLEU-829 wires `OrderPostError::retry_hint` here so the + // backoff / drop decision is data-driven. Until then, log + // and leave the watch in place for the next block. + logging::log( + logging::Level::Warn, + &format!("submit failed ({}): {}", err.code, err.message), + ); + } + } +} + fn outcome_label(o: &PollOutcome) -> &'static str { match o { PollOutcome::Ready { .. } => "Ready", @@ -537,4 +667,92 @@ mod tests { assert_eq!(o.parse::
().unwrap(), owner); assert_eq!(h.parse::().unwrap(), hash); } + + // ---- BLEU-828: submission shape ---- + + 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: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + #[test] + fn gpv2_to_order_data_normalises_zero_receiver_to_none() { + let mut g = submittable_order(); + g.receiver = Address::ZERO; + let od = gpv2_to_order_data(&g).expect("known markers"); + assert_eq!(od.receiver, None); + } + + #[test] + fn gpv2_to_order_data_preserves_non_zero_receiver() { + let mut g = submittable_order(); + g.receiver = address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"); + let od = gpv2_to_order_data(&g).expect("known markers"); + assert_eq!(od.receiver, Some(g.receiver)); + } + + #[test] + fn gpv2_to_order_data_unknown_kind_returns_none() { + let mut g = submittable_order(); + g.kind = B256::repeat_byte(0x42); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn gpv2_to_order_data_unknown_sell_token_balance_returns_none() { + let mut g = submittable_order(); + g.sellTokenBalance = B256::repeat_byte(0x99); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn build_order_creation_succeeds_with_empty_app_data() { + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); + let creation = build_order_creation(&submittable_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); + // serde round-trip — the submit path serialises this exact value. + let body = serde_json::to_vec(&creation).expect("json encode"); + let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(parsed["signingScheme"], "eip1271"); + assert_eq!(parsed["from"], format!("{owner:#x}")); + } + + #[test] + fn build_order_creation_rejects_non_empty_app_data() { + // ComposableCoW orders that pin a real document on IPFS get + // skipped: we only carry `EMPTY_APP_DATA_JSON` in this PR. + let mut order = submittable_order(); + order.appData = B256::repeat_byte(0xee); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let err = build_order_creation(&order, Bytes::new(), owner).unwrap_err(); + assert!(matches!(err, BuildError::Cowprotocol(_))); + } + + #[test] + fn build_order_creation_rejects_zero_from() { + let err = build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO) + .unwrap_err(); + assert!(matches!(err, BuildError::Cowprotocol(_))); + } } From eeeb974c1f636a1fbfc5055e70b2dcef3667e77a Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:31:02 -0300 Subject: [PATCH 15/21] feat(twap-monitor): wire OrderPostError retry_hint on submit (BLEU-829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After \`cow_api::submit_order\` returns Err, decode the orderbook's typed \`ApiError\` JSON from \`host-error.data\` and dispatch on \`OrderPostErrorKind::is_retriable()\`: - retriable (InsufficientFee, TooManyLimitOrders, PriceExceedsMarketPrice) -> RetryAction::TryNextBlock — leave the watch in place so the next block re-attempts. - permanent (InvalidSignature, WrongOwner, DuplicateOrder, UnsupportedToken, InvalidAppData, ...) -> RetryAction::Drop — delete watch:{owner}:{params_hash} and any stale next_block / next_epoch entries the lifecycle layer may have written. - typed payload missing or unparseable -> TryNextBlock (safe default: a flaky orderbook should not poison a still-valid watch). A \`RetryAction::Backoff { seconds }\` variant is defined for the BLEU-829 contract but has no producer: cowprotocol's surface today is bool-only (no server-supplied delay). The variant is kept so the dispatcher can grow into it once a hint shows up (e.g. server \`Retry-After\` header or a richer typed error). ## Host follow-up \`cow_api::submit_order\` in nullislabs/shepherd PR #8 stuffs the formatted error string into \`host-error.message\` with \`data: None\` and \`code: 0\`. \`try_decode_api_error\` reads from \`host-error.data\` already, so once the host forwards the upstream JSON the dispatch becomes data-driven without further module changes. Test \`classify_missing_data_defaults_to_try_next_block\` documents the current fallback; the four other classify tests lock the intended semantics for when the host catches up. Tests: 5 new (retriable / permanent / unknown / missing-data / malformed-data). Total 29 host tests. \`.wasm\` 298 KB (was 273 KB; adds the typed ApiError decode + the small dispatcher). Note: this branch also picks up the dev/m2-base bump to bleu/cow-rs main (\`57f5f55\`), which lands BLEU-822 (\`OrderPostErrorKind\`) + BLEU-823 — both now visible through the \`cowprotocol\` re-exports. Linear: BLEU-829. --- modules/twap-monitor/src/lib.rs | 206 +++++++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 16 deletions(-) diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 3570eea..64a8585 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -11,7 +11,7 @@ wit_bindgen::generate!({ use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue}; use cowprotocol::{ - BuyTokenDestination, COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, + ApiError, BuyTokenDestination, COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, EMPTY_APP_DATA_JSON, GPv2OrderData, OrderCreation, OrderData, OrderKind, SellTokenSource, Signature, }; @@ -169,7 +169,7 @@ fn poll_all_watches(block: &types::Block) -> Result<(), HostError> { &format!("poll {key} -> {}", outcome_label(&outcome)), ); if let PollOutcome::Ready { order, signature } = outcome { - submit_ready(block.chain_id, owner, &order, signature); + submit_ready(block.chain_id, owner, &order, signature, &key, now_epoch_s)?; } // BLEU-830 will persist next_block / next_epoch / remove the watch // on the non-Ready arms. @@ -347,7 +347,14 @@ impl core::fmt::Display for BuildError { } } -fn submit_ready(chain_id: u64, owner: Address, order: &GPv2OrderData, signature: Bytes) { +fn submit_ready( + chain_id: u64, + owner: Address, + order: &GPv2OrderData, + signature: Bytes, + watch_key: &str, + now_epoch_s: u64, +) -> Result<(), HostError> { let creation = match build_order_creation(order, signature, owner) { Ok(c) => c, Err(e) => { @@ -355,7 +362,7 @@ fn submit_ready(chain_id: u64, owner: Address, order: &GPv2OrderData, signature: logging::Level::Warn, &format!("twap submit skipped for {owner:#x}: {e}"), ); - return; + return Ok(()); } }; let body = match serde_json::to_vec(&creation) { @@ -365,7 +372,7 @@ fn submit_ready(chain_id: u64, owner: Address, order: &GPv2OrderData, signature: logging::Level::Error, &format!("OrderCreation JSON encode failed: {e}"), ); - return; + return Ok(()); } }; match cow_api::submit_order(chain_id, &body) { @@ -374,25 +381,107 @@ fn submit_ready(chain_id: u64, owner: Address, order: &GPv2OrderData, signature: // Empty marker — presence of the key is the receipt. BLEU-830 // may later attach metadata (block, attempt count) but the // bare flag is enough to suppress double submits. - if let Err(e) = local_store::set(&key, b"") { - logging::log( - logging::Level::Error, - &format!("persist {key} failed: {}", e.message), - ); - return; - } + local_store::set(&key, b"")?; logging::log(logging::Level::Info, &format!("submitted {key}")); } Err(err) => { - // BLEU-829 wires `OrderPostError::retry_hint` here so the - // backoff / drop decision is data-driven. Until then, log - // and leave the watch in place for the next block. + apply_submit_retry(&err, watch_key, now_epoch_s)?; + } + } + Ok(()) +} + +// ---- BLEU-829: OrderPostError -> retry action ---- + +/// What the lifecycle layer should do after a failed submission. +/// +/// Mirrors the BLEU-829 retry contract (`TryNextBlock` / `BackoffSeconds(s)` +/// / `Drop`). Today the `Backoff` arm has no producer because the +/// cowprotocol API exposes `retry_hint() -> bool` (no server-supplied +/// delay) — the variant is kept so the dispatcher can grow into it +/// once cowprotocol or the orderbook hands us a hint. +#[derive(Debug, Eq, PartialEq)] +enum RetryAction { + /// Leave the watch in place; it will be polled on the next block. + TryNextBlock, + /// Persist `next_epoch = now + seconds` so the watch is skipped + /// until that timestamp. Reserved for a future producer (the + /// cowprotocol surface today is bool-only, no server delay). + #[allow(dead_code)] + Backoff { seconds: u64 }, + /// Remove the watch entirely — the order will not be retried. + Drop, +} + +/// Try to decode the orderbook's typed error payload from a HostError. +/// +/// The host's `cow_api::submit_order` backend places the orderbook's +/// JSON body in `host-error.data` when the upstream returned a typed +/// `ApiError` (this forwarding is the host-side counterpart to BLEU-829; +/// see PR description for the status of that change). When `data` is +/// missing or fails to parse the function returns `None`, and the +/// dispatcher falls back to the safe default of "retry next block". +fn try_decode_api_error(err: &HostError) -> Option { + let data = err.data.as_deref()?; + serde_json::from_str::(data).ok() +} + +/// Classify a failed submission into the action the lifecycle layer +/// should take. Defaults to `TryNextBlock` whenever the typed payload +/// is absent or unrecognised — the safe choice that lets a flaky +/// orderbook recover without dropping a still-valid order. +fn classify_submit_error(err: &HostError) -> RetryAction { + match try_decode_api_error(err) { + Some(api) if api.retry_hint() => RetryAction::TryNextBlock, + Some(_) => RetryAction::Drop, + None => RetryAction::TryNextBlock, + } +} + +fn apply_submit_retry( + err: &HostError, + watch_key: &str, + now_epoch_s: u64, +) -> Result<(), HostError> { + let action = classify_submit_error(err); + match action { + RetryAction::TryNextBlock => { + logging::log( + logging::Level::Warn, + &format!("submit retry-next-block ({}): {}", err.code, err.message), + ); + } + RetryAction::Backoff { seconds } => { + let until = now_epoch_s.saturating_add(seconds); + if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { + local_store::set( + &format!("next_epoch:{owner_hex}:{hash_hex}"), + &until.to_le_bytes(), + )?; + } + logging::log( + logging::Level::Warn, + &format!( + "submit backoff {seconds}s -> next_epoch={until} ({}): {}", + err.code, err.message + ), + ); + } + RetryAction::Drop => { + // Drop the watch, plus any stale gating entries the lifecycle + // layer may have written. + local_store::delete(watch_key)?; + if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { + let _ = local_store::delete(&format!("next_block:{owner_hex}:{hash_hex}")); + let _ = local_store::delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); + } logging::log( logging::Level::Warn, - &format!("submit failed ({}): {}", err.code, err.message), + &format!("submit dropped watch ({}): {}", err.code, err.message), ); } } + Ok(()) } fn outcome_label(o: &PollOutcome) -> &'static str { @@ -755,4 +844,89 @@ mod tests { .unwrap_err(); assert!(matches!(err, BuildError::Cowprotocol(_))); } + + // ---- BLEU-829: submit-error classification ---- + + fn host_error_with_api(error_type: &str) -> HostError { + let body = serde_json::json!({ + "errorType": error_type, + "description": "test", + }); + HostError { + domain: "cow-api".into(), + kind: nexum::host::types::HostErrorKind::Denied, + code: 400, + message: format!("{error_type}: test"), + data: Some(body.to_string()), + } + } + + #[test] + fn classify_retriable_kind_returns_try_next_block() { + // InsufficientFee / TooManyLimitOrders / PriceExceedsMarketPrice + // are the three kinds cowprotocol::OrderPostErrorKind flags + // retriable today. + for kind in ["InsufficientFee", "TooManyLimitOrders", "PriceExceedsMarketPrice"] { + assert_eq!( + classify_submit_error(&host_error_with_api(kind)), + RetryAction::TryNextBlock, + "{kind} should be retriable", + ); + } + } + + #[test] + fn classify_permanent_kind_returns_drop() { + for kind in [ + "InvalidSignature", + "WrongOwner", + "DuplicateOrder", + "UnsupportedToken", + "InvalidAppData", + ] { + assert_eq!( + classify_submit_error(&host_error_with_api(kind)), + RetryAction::Drop, + "{kind} should be permanent", + ); + } + } + + #[test] + fn classify_unknown_kind_returns_drop() { + // `Unknown(_)` is non-retriable per cowprotocol's classification + // — the orderbook rejected the order with a string we don't + // recognise, so retrying as-is is unlikely to help. + assert_eq!( + classify_submit_error(&host_error_with_api("NewlyMintedErrorType")), + RetryAction::Drop, + ); + } + + #[test] + fn classify_missing_data_defaults_to_try_next_block() { + // Until the host backend forwards the orderbook JSON into + // host-error.data, we have no payload to decode. The safe + // default is to retry rather than poison a still-valid watch. + let err = HostError { + domain: "cow-api".into(), + kind: nexum::host::types::HostErrorKind::Internal, + code: 0, + message: "network reset".into(), + data: None, + }; + assert_eq!(classify_submit_error(&err), RetryAction::TryNextBlock); + } + + #[test] + fn classify_malformed_data_defaults_to_try_next_block() { + let err = HostError { + domain: "cow-api".into(), + kind: nexum::host::types::HostErrorKind::Denied, + code: 502, + message: "bad gateway".into(), + data: Some("upstream HTML".into()), + }; + assert_eq!(classify_submit_error(&err), RetryAction::TryNextBlock); + } } From 22f33c37842a7d0226dc4e7882d533e63aa57c88 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:36:10 -0300 Subject: [PATCH 16/21] feat(twap-monitor): PollOutcome lifecycle dispatch (BLEU-830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After poll_one, the non-Ready arms now reach a typed lifecycle step instead of dead-ending in the log: - TryNextBlock -> NoOp — re-poll next block - TryOnBlock(n) -> SetNextBlock(n) -> persist next_block:{...} - TryAtEpoch(t) -> SetNextEpoch(t) -> persist next_epoch:{...} - DontTryAgain -> DropWatch -> delete watch:{...} + best-effort delete of the stale next_block: / next_epoch: gates The decision is split out as a pure `outcome_to_update` returning a `WatchUpdate` enum, with the impure `apply_watch_update` performing the local-store writes. That partition lets the four host-free tests assert the mapping exhaustively without wit-bindgen scaffolding. `Ready` is deliberately mapped to `NoOp` here as a safety net — poll_all_watches routes Ready to submit_ready, which owns the post-submit book-keeping (submitted: marker + retry / drop). If a future refactor accidentally pipes Ready through the lifecycle path, the watch must NOT be erased. Wire-format conventions (u64 LE bytes, key shape watch:{owner}: {params_hash} and parallel next_block: / next_epoch:) stay the same as BLEU-827; no consumer changes required. Tests: 5 new (Ready, TryNextBlock, TryOnBlock, TryAtEpoch, DontTryAgain). Total 34 host tests. Linear: BLEU-830. --- modules/twap-monitor/src/lib.rs | 134 +++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 64a8585..8e0e01b 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -168,11 +168,14 @@ fn poll_all_watches(block: &types::Block) -> Result<(), HostError> { logging::Level::Info, &format!("poll {key} -> {}", outcome_label(&outcome)), ); - if let PollOutcome::Ready { order, signature } = outcome { - submit_ready(block.chain_id, owner, &order, signature, &key, now_epoch_s)?; + match outcome { + PollOutcome::Ready { order, signature } => { + submit_ready(block.chain_id, owner, &order, signature, &key, now_epoch_s)?; + } + non_ready => { + apply_watch_update(outcome_to_update(&non_ready), &key)?; + } } - // BLEU-830 will persist next_block / next_epoch / remove the watch - // on the non-Ready arms. } Ok(()) } @@ -494,6 +497,80 @@ fn outcome_label(o: &PollOutcome) -> &'static str { } } +// ---- BLEU-830: PollOutcome lifecycle dispatch ---- + +/// What `apply_watch_update` should do for a given outcome. Kept as a +/// data type (rather than running the effects directly) so the decision +/// is host-free testable; `apply_watch_update` is the impure other half. +#[derive(Debug, Eq, PartialEq)] +enum WatchUpdate { + /// Leave the store untouched. Next block re-polls the watch. + NoOp, + /// Write `next_block:` so subsequent polls skip until the given + /// block number is reached. + SetNextBlock(u64), + /// Write `next_epoch:` so subsequent polls skip until the given + /// Unix-seconds timestamp is reached. + SetNextEpoch(u64), + /// Delete the watch and any stale gate keys — TWAP completed, + /// cancelled, or otherwise irrecoverable. + DropWatch, +} + +/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect +/// the BLEU-830 contract specifies. `Ready` is handled by the submit +/// path (BLEU-828) and is rejected here so a caller cannot accidentally +/// erase the watch when an order was actually produced. +fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { + match outcome { + PollOutcome::Ready { .. } => WatchUpdate::NoOp, // belt-and-braces; caller routes Ready to submit_ready + PollOutcome::TryNextBlock => WatchUpdate::NoOp, + PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), + PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), + PollOutcome::DontTryAgain => WatchUpdate::DropWatch, + } +} + +fn apply_watch_update(update: WatchUpdate, watch_key: &str) -> Result<(), HostError> { + match update { + WatchUpdate::NoOp => Ok(()), + WatchUpdate::SetNextBlock(n) => { + if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { + local_store::set( + &format!("next_block:{owner_hex}:{hash_hex}"), + &n.to_le_bytes(), + )?; + } + Ok(()) + } + WatchUpdate::SetNextEpoch(t) => { + if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { + local_store::set( + &format!("next_epoch:{owner_hex}:{hash_hex}"), + &t.to_le_bytes(), + )?; + } + Ok(()) + } + WatchUpdate::DropWatch => { + local_store::delete(watch_key)?; + // Best-effort: drop any stale gates the previous lifecycle + // step may have written. `delete` is a no-op for absent keys + // already, so the `let _` discards a benign error if the + // underlying store complains. + if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { + let _ = local_store::delete(&format!("next_block:{owner_hex}:{hash_hex}")); + let _ = local_store::delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); + } + logging::log( + logging::Level::Info, + &format!("dropped watch {watch_key}"), + ); + Ok(()) + } + } +} + // ---- key conventions shared with BLEU-830 ---- fn watch_key(owner: &Address, params_hash: &B256) -> String { @@ -929,4 +1006,53 @@ mod tests { }; assert_eq!(classify_submit_error(&err), RetryAction::TryNextBlock); } + + // ---- BLEU-830: PollOutcome -> lifecycle effect ---- + + #[test] + fn outcome_try_next_block_is_no_op() { + assert_eq!( + outcome_to_update(&PollOutcome::TryNextBlock), + WatchUpdate::NoOp, + ); + } + + #[test] + fn outcome_try_on_block_sets_next_block_gate() { + assert_eq!( + outcome_to_update(&PollOutcome::TryOnBlock(12_345)), + WatchUpdate::SetNextBlock(12_345), + ); + } + + #[test] + fn outcome_try_at_epoch_sets_next_epoch_gate() { + assert_eq!( + outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), + WatchUpdate::SetNextEpoch(1_700_000_000), + ); + } + + #[test] + fn outcome_dont_try_again_drops_watch() { + assert_eq!( + outcome_to_update(&PollOutcome::DontTryAgain), + WatchUpdate::DropWatch, + ); + } + + #[test] + fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { + // Ready never reaches outcome_to_update in poll_all_watches (the + // match routes it to submit_ready). The mapping is a safety net: + // if a future refactor accidentally pipes Ready through here, the + // watch must NOT be erased — submit_ready owns the post-submit + // book-keeping. + let order = Box::new(submittable_order()); + let outcome = PollOutcome::Ready { + order, + signature: Bytes::new(), + }; + assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); + } } From 3a0f15655798e7d61415976e798cfaa9ab0fab05 Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:40:00 -0300 Subject: [PATCH 17/21] feat(ethflow-watcher): workspace + skeleton (BLEU-831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the twap-monitor skeleton (BLEU-825) for the EthFlow path. Adds modules/ethflow-watcher/ as a workspace member, with [lib] crate-type = ["cdylib"] for WASM Component output, and the same dep set (cowprotocol no-default-features, alloy-primitives, alloy-sol-types, wit-bindgen) pre-pulled so BLEU-832 (event decode) and BLEU-833 (EIP-1271 submit + retry) can layer in without churning Cargo.toml. src/lib.rs binds against shepherd:cow/shepherd, init logs once, on_event logs Event::Logs as a placeholder until BLEU-832 decodes the CoWSwapEthFlow OrderPlacement payload. cargo build --target wasm32-wasip2 --release -p ethflow-watcher emits a 67 KB .wasm (within ~3 KB of twap-monitor's skeleton — identical world + deps, identical link footprint). Engine load is gated on module.toml (BLEU-834). --- Cargo.toml | 1 + modules/ethflow-watcher/Cargo.toml | 15 +++++++++++++ modules/ethflow-watcher/src/lib.rs | 35 ++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 modules/ethflow-watcher/Cargo.toml create mode 100644 modules/ethflow-watcher/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index ca28695..9d23e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/nexum-engine", + "modules/ethflow-watcher", "modules/example", "modules/twap-monitor", ] diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml new file mode 100644 index 0000000..5d9fa3d --- /dev/null +++ b/modules/ethflow-watcher/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ethflow-watcher" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +cowprotocol = { version = "1.0.0-alpha.3", default-features = false } +alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } +alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs new file mode 100644 index 0000000..4442708 --- /dev/null +++ b/modules/ethflow-watcher/src/lib.rs @@ -0,0 +1,35 @@ +// wit_bindgen::generate! expands to host-import shims whose arity matches +// the WIT signatures, which can exceed clippy's too-many-arguments threshold. +#![allow(clippy::too_many_arguments)] + +wit_bindgen::generate!({ + path: ["../../wit/nexum-host", "../../wit/shepherd-cow"], + world: "shepherd:cow/shepherd", + generate_all, +}); + +use nexum::host::{logging, types}; + +struct EthFlowWatcher; + +impl Guest for EthFlowWatcher { + fn init(_config: Vec<(String, String)>) -> Result<(), HostError> { + logging::log(logging::Level::Info, "ethflow-watcher init"); + Ok(()) + } + + fn on_event(event: types::Event) -> Result<(), HostError> { + // CoWSwapEthFlow `OrderPlacement` decode lands in BLEU-832; the + // EIP-1271 submission path lands in BLEU-833. Block / Tick / + // Message are not used by this module. + if let types::Event::Logs(logs) = event { + logging::log( + logging::Level::Info, + &format!("ethflow received {} logs (decode in BLEU-832)", logs.len()), + ); + } + Ok(()) + } +} + +export!(EthFlowWatcher); From 25a7a27175e77b30058764201d460e5156e2222f Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:44:43 -0300 Subject: [PATCH 18/21] feat(ethflow-watcher): decode CoWSwapEthFlow OrderPlacement (BLEU-832) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`on_event(Event::Logs)\` matches each log against \`CoWSwapOnchainOrders.OrderPlacement\` and keeps the four fields BLEU-833's submission path will consume: (sender, order, signature, data) where \`order\` is the 12-field \`GPv2OrderData\` the settlement contract verifies, and \`signature\` is the typed \`OnchainSignature { scheme, data }\` pair (EIP-1271 or PreSign). Guardrails: \`decode_order_placement\` rejects the log when the contract address is not one of the canonical EthFlow deployments (production or staging — both share the same address on every chain). topic0 must match the event signature hash and the body must round-trip through \`SolEvent::decode_raw_log\`. The decoder is on plain slices so the seven host-free tests cover the happy path, the alternate staging address, an unrelated contract, a wrong topic, a truncated address, a truncated body, and an empty topic list. \`DecodedPlacement\` boxes \`GPv2OrderData\` (~300 bytes); the struct is kept private and \`#[allow(dead_code)]\` until BLEU-833 wires the submit path. \`cargo build --target wasm32-wasip2 --release -p ethflow-watcher\` -> 96 KB .wasm (was 67 KB skeleton; the \`CoWSwapOnchainOrders\` ABI + GPv2OrderData decode pull in ~30 KB of alloy sol-types runtime). Linear: BLEU-832. Ref ADR-0006. --- modules/ethflow-watcher/src/lib.rs | 214 ++++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 7 deletions(-) diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 4442708..f042c15 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -8,8 +8,26 @@ wit_bindgen::generate!({ generate_all, }); +use alloy_primitives::{Address, B256, Bytes}; +use alloy_sol_types::SolEvent; +use cowprotocol::{ + CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, + OnchainSignature, +}; use nexum::host::{logging, types}; +/// Fully decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. +/// `GPv2OrderData` is ~300 bytes; box it so the struct stays cache- +/// friendly when it later lands in the BLEU-833 submission path. +#[derive(Debug)] +#[allow(dead_code)] // Fields consumed by BLEU-833. +struct DecodedPlacement { + sender: Address, + order: Box, + signature: OnchainSignature, + data: Bytes, +} + struct EthFlowWatcher; impl Guest for EthFlowWatcher { @@ -19,17 +37,199 @@ impl Guest for EthFlowWatcher { } fn on_event(event: types::Event) -> Result<(), HostError> { - // CoWSwapEthFlow `OrderPlacement` decode lands in BLEU-832; the - // EIP-1271 submission path lands in BLEU-833. Block / Tick / - // Message are not used by this module. if let types::Event::Logs(logs) = event { - logging::log( - logging::Level::Info, - &format!("ethflow received {} logs (decode in BLEU-832)", logs.len()), - ); + for log in &logs { + if let Some(placement) = decode_order_placement(&log.address, &log.topics, &log.data) + { + log_placement(&placement); + // BLEU-833 will build OrderCreation + submit + apply + // OrderPostError::retry_hint right here. + } + } } + // Block / Tick / Message are not used by this module. Ok(()) } } +/// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`, +/// keeping the four fields the BLEU-833 submission path needs. +/// +/// Returns `None` when: +/// - the log's contract address is not one of the canonical `ETH_FLOW_*` +/// deployments (defensive — the host's `[[subscription]]` filter +/// already pins the address, but a misconfigured engine could still +/// leak through); +/// - topic0 does not match the event signature; or +/// - the ABI body fails to decode (truncated, wrong layout). +/// +/// Kept on plain slices so the host-free unit tests can call it without +/// wit-bindgen scaffolding. +fn decode_order_placement( + address: &[u8], + topics: &[Vec], + data: &[u8], +) -> Option { + if address.len() != 20 { + return None; + } + let contract = Address::from_slice(address); + if contract != ETH_FLOW_PRODUCTION && contract != ETH_FLOW_STAGING { + return None; + } + let topic0 = topics.first()?; + if topic0.len() != 32 || B256::from_slice(topic0) != OrderPlacement::SIGNATURE_HASH { + return None; + } + let words: Vec = topics + .iter() + .filter(|t| t.len() == 32) + .map(|t| B256::from_slice(t)) + .collect(); + let decoded = OrderPlacement::decode_raw_log(words, data).ok()?; + Some(DecodedPlacement { + sender: decoded.sender, + order: Box::new(decoded.order), + signature: decoded.signature, + data: decoded.data, + }) +} + +fn log_placement(p: &DecodedPlacement) { + logging::log( + logging::Level::Info, + &format!( + "ethflow OrderPlacement sender={:#x} sell={:#x} buy={:#x} valid_to={} sig_scheme={:?}", + p.sender, + p.order.sellToken, + p.order.buyToken, + p.order.validTo, + p.signature.scheme, + ), + ); +} + export!(EthFlowWatcher); + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{U256, address, hex}; + use alloy_sol_types::SolValue; + use cowprotocol::OnchainSigningScheme; + + fn sample_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: 1_700_000_000, + appData: B256::repeat_byte(0xaa), + feeAmount: U256::ZERO, + kind: B256::repeat_byte(0xbb), + partiallyFillable: false, + sellTokenBalance: B256::repeat_byte(0xcc), + buyTokenBalance: B256::repeat_byte(0xdd), + } + } + + fn sample_event() -> (Address, OrderPlacement) { + let sender = address!("00112233445566778899aabbccddeeff00112233"); + let event = OrderPlacement { + sender, + order: sample_order(), + signature: OnchainSignature { + scheme: OnchainSigningScheme::Eip1271, + data: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + }, + data: hex!("deadbeef").to_vec().into(), + }; + (sender, event) + } + + /// Build `(topics, data)` the way the EVM would emit them. The + /// indexed `sender` becomes topic1 (left-padded address); the three + /// non-indexed fields become the abi-encoded body. + fn encode_log(event: &OrderPlacement) -> (Vec>, Vec) { + let mut sender_topic = vec![0u8; 12]; + sender_topic.extend_from_slice(event.sender.as_slice()); + let topics = vec![OrderPlacement::SIGNATURE_HASH.to_vec(), sender_topic]; + let data = ( + event.order.clone(), + event.signature.clone(), + event.data.clone(), + ) + .abi_encode_params(); + (topics, data) + } + + #[test] + fn decodes_well_formed_placement() { + let (sender, event) = sample_event(); + let (topics, data) = encode_log(&event); + let address = ETH_FLOW_PRODUCTION.as_slice(); + + let decoded = decode_order_placement(address, &topics, &data).expect("decode succeeds"); + assert_eq!(decoded.sender, sender); + assert_eq!(decoded.order.sellToken, event.order.sellToken); + assert_eq!(decoded.order.buyAmount, event.order.buyAmount); + assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); + assert_eq!( + decoded.signature.data.as_ref(), + event.signature.data.as_ref() + ); + assert_eq!(decoded.data.as_ref(), event.data.as_ref()); + } + + #[test] + fn accepts_staging_address() { + let (_, event) = sample_event(); + let (topics, data) = encode_log(&event); + assert!(decode_order_placement(ETH_FLOW_STAGING.as_slice(), &topics, &data).is_some()); + } + + #[test] + fn rejects_unrelated_contract_address() { + let (_, event) = sample_event(); + let (topics, data) = encode_log(&event); + let stranger = address!("dead00000000000000000000000000000000dead"); + assert!(decode_order_placement(stranger.as_slice(), &topics, &data).is_none()); + } + + #[test] + fn rejects_wrong_topic_signature() { + let (_, event) = sample_event(); + let (_, data) = encode_log(&event); + let bad_topic = vec![0xaa_u8; 32]; + let sender_topic = vec![0u8; 32]; + assert!( + decode_order_placement( + ETH_FLOW_PRODUCTION.as_slice(), + &[bad_topic, sender_topic], + &data, + ) + .is_none() + ); + } + + #[test] + fn rejects_truncated_address() { + let (_, event) = sample_event(); + let (topics, data) = encode_log(&event); + assert!(decode_order_placement(&[0u8; 19], &topics, &data).is_none()); + } + + #[test] + fn rejects_truncated_data() { + let (topics, _) = encode_log(&sample_event().1); + assert!(decode_order_placement(ETH_FLOW_PRODUCTION.as_slice(), &topics, &[]).is_none()); + } + + #[test] + fn rejects_empty_topics() { + let (_, data) = encode_log(&sample_event().1); + assert!(decode_order_placement(ETH_FLOW_PRODUCTION.as_slice(), &[], &data).is_none()); + } +} From fbaec5673a259cece3296b81d34fcb13f2ae970c Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 10:57:03 -0300 Subject: [PATCH 19/21] feat(ethflow-watcher): build OrderCreation, submit, apply retry_hint (BLEU-833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`on_event(Event::Logs)\` now ends in a complete pipeline: 1. \`decode_order_placement\` (BLEU-832) lifts the log to a \`DecodedPlacement\` carrying the contract, sender, order, onchain signature and refund pointer. 2. \`build_eth_flow_creation\` translates that into a typed \`(OrderCreation, OrderUid)\`: - \`gpv2_to_order_data\` maps the on-chain \`bytes32\` markers to the typed \`OrderKind\` / balance enums; same logic as the TWAP module, kept inline because the two crates are independent. - \`to_signature\` lifts \`OnchainSignature\` into \`Signature::Eip1271(bytes)\` or \`Signature::PreSign\`. The hidden \`__Invalid\` sol! variant is surfaced as \`Option::None\` so a malformed event skips the placement instead of panicking. - \`OrderData::uid(domain, contract)\` computes the canonical 56-byte order UID locally; the orderbook returns the same value from POST /api/v1/orders and a Warn fires if they drift (domain or owner divergence). - \`from\` = EthFlow contract (the EIP-1271 verifier), NOT the user's \`sender\` — matches the on-chain signing scheme. - \`app_data\` is fixed to \`EMPTY_APP_DATA_JSON\` for now; placements pinning a real IPFS document are rejected by \`from_signed_order_data\` (digest mismatch) and skipped. 3. Serialise + \`cow_api::submit_order(chain_id, body)\`. 4. Persist the outcome: - success -> \`submitted:{uid}\` - retriable -> \`backoff:{uid}\` (same OrderPostError classification path as BLEU-829) - permanent -> \`dropped:{uid}\` \`apply_submit_retry\` mirrors BLEU-829's \`classify_submit_error\` — when the host forwards the orderbook JSON via \`host-error.data\`, the dispatch is data-driven; absent data, the safe default is \`backoff:\` (retry next event) rather than \`dropped:\`. The \`Backoff { seconds }\` variant of \`RetryAction\` is parked: cowprotocol's surface today is bool-only, so until a server hint shows up (Retry-After or a typed delay) the variant remains intentionally producer-less. Tests: 10 host tests covering BLEU-832 (2 decode regressions) and BLEU-833 (5 order-build edges + 3 error-classification arms). \`.wasm\` 268 KB (was 96 KB; the OrderCreation + serde_json + DomainSeparator + OrderUid surface get linked in). Same scope-knot on app-data resolution as the TWAP module; same host follow-up on \`host-error.data\` forwarding. Linear: BLEU-833. Ref ADR-0006. --- modules/ethflow-watcher/Cargo.toml | 1 + modules/ethflow-watcher/src/lib.rs | 422 +++++++++++++++++++++++------ 2 files changed, 333 insertions(+), 90 deletions(-) diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 5d9fa3d..cdde1fd 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -12,4 +12,5 @@ crate-type = ["cdylib"] cowprotocol = { version = "1.0.0-alpha.3", default-features = false } alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +serde_json = { version = "1", default-features = false, features = ["alloc"] } wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index f042c15..dec343b 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -11,23 +11,47 @@ wit_bindgen::generate!({ use alloy_primitives::{Address, B256, Bytes}; use alloy_sol_types::SolEvent; use cowprotocol::{ - CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, - OnchainSignature, + ApiError, BuyTokenDestination, Chain, CoWSwapOnchainOrders::OrderPlacement, + EMPTY_APP_DATA_JSON, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, OnchainSignature, + OnchainSigningScheme, OrderCreation, OrderData, OrderKind, OrderUid, SellTokenSource, + Signature, }; -use nexum::host::{logging, types}; +use nexum::host::{local_store, logging, types}; +use shepherd::cow::cow_api; /// Fully decoded payload of a `CoWSwapOnchainOrders.OrderPlacement` log. /// `GPv2OrderData` is ~300 bytes; box it so the struct stays cache- -/// friendly when it later lands in the BLEU-833 submission path. +/// friendly through the submit path. #[derive(Debug)] -#[allow(dead_code)] // Fields consumed by BLEU-833. struct DecodedPlacement { + /// EthFlow contract that emitted the event — also the EIP-1271 + /// verifier `from` for the submitted `OrderCreation`. + contract: Address, + /// Original native-token seller — logged for diagnostics; the + /// orderbook's `from` is the contract (EIP-1271 owner), not this. sender: Address, order: Box, signature: OnchainSignature, + /// Refund pointer / opaque placer metadata. Not consumed by the + /// submit path today, but the field is part of the BLEU-832 + /// decoder contract. + #[allow(dead_code)] data: Bytes, } +/// What the lifecycle layer should do after a failed submission. +/// Mirrors the BLEU-829 dispatch contract on the TWAP module; the +/// `Backoff` arm has no producer until a server-supplied hint exists. +#[derive(Debug, Eq, PartialEq)] +enum RetryAction { + TryNextBlock, + #[allow(dead_code)] + Backoff { + seconds: u64, + }, + Drop, +} + struct EthFlowWatcher; impl Guest for EthFlowWatcher { @@ -39,11 +63,10 @@ impl Guest for EthFlowWatcher { fn on_event(event: types::Event) -> Result<(), HostError> { if let types::Event::Logs(logs) = event { for log in &logs { - if let Some(placement) = decode_order_placement(&log.address, &log.topics, &log.data) + if let Some(placement) = + decode_order_placement(&log.address, &log.topics, &log.data) { - log_placement(&placement); - // BLEU-833 will build OrderCreation + submit + apply - // OrderPostError::retry_hint right here. + submit_placement(log.chain_id, &placement)?; } } } @@ -52,19 +75,17 @@ impl Guest for EthFlowWatcher { } } -/// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`, -/// keeping the four fields the BLEU-833 submission path needs. +// ---- BLEU-832: decode ---- + +/// Decode a raw event log against `CoWSwapOnchainOrders.OrderPlacement`. /// /// Returns `None` when: -/// - the log's contract address is not one of the canonical `ETH_FLOW_*` -/// deployments (defensive — the host's `[[subscription]]` filter -/// already pins the address, but a misconfigured engine could still -/// leak through); +/// - the log's contract address is neither `ETH_FLOW_PRODUCTION` nor +/// `ETH_FLOW_STAGING` (defensive — the host's `[[subscription]]` +/// filter already pins the address, but a misconfigured engine could +/// still leak through); /// - topic0 does not match the event signature; or -/// - the ABI body fails to decode (truncated, wrong layout). -/// -/// Kept on plain slices so the host-free unit tests can call it without -/// wit-bindgen scaffolding. +/// - the ABI body fails to decode. fn decode_order_placement( address: &[u8], topics: &[Vec], @@ -88,6 +109,7 @@ fn decode_order_placement( .collect(); let decoded = OrderPlacement::decode_raw_log(words, data).ok()?; Some(DecodedPlacement { + contract, sender: decoded.sender, order: Box::new(decoded.order), signature: decoded.signature, @@ -95,18 +117,167 @@ fn decode_order_placement( }) } -fn log_placement(p: &DecodedPlacement) { - logging::log( - logging::Level::Info, - &format!( - "ethflow OrderPlacement sender={:#x} sell={:#x} buy={:#x} valid_to={} sig_scheme={:?}", - p.sender, - p.order.sellToken, - p.order.buyToken, - p.order.validTo, - p.signature.scheme, - ), - ); +// ---- BLEU-833: submit + retry ---- + +#[derive(Debug)] +enum BuildError { + UnknownMarker, + UnknownSignatureScheme, + UnsupportedChain(u64), + Cowprotocol(cowprotocol::Error), +} + +impl core::fmt::Display for BuildError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnknownMarker => f.write_str("GPv2OrderData carried an unknown enum marker"), + Self::UnknownSignatureScheme => { + f.write_str("OnchainSignature carried an unknown scheme variant") + } + Self::UnsupportedChain(id) => write!(f, "chain {id} is not supported by cowprotocol"), + Self::Cowprotocol(e) => write!(f, "{e}"), + } + } +} + +fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { + Some(OrderData { + sell_token: gpv2.sellToken, + buy_token: gpv2.buyToken, + receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), + sell_amount: gpv2.sellAmount, + buy_amount: gpv2.buyAmount, + valid_to: gpv2.validTo, + app_data: gpv2.appData, + fee_amount: gpv2.feeAmount, + kind: OrderKind::from_contract_bytes(gpv2.kind)?, + partially_fillable: gpv2.partiallyFillable, + sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, + buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, + }) +} + +/// Lift `OnchainSignature` into the orderbook-typed `Signature`. The +/// EthFlow contract is the EIP-1271 verifier, so the `data` blob is +/// the raw verifier bytes; for `PreSign` the orderbook accepts an +/// empty payload. +fn to_signature(sig: &OnchainSignature) -> Option { + // sol! adds a hidden `__Invalid` variant on every Solidity enum, so + // exhaustive patterns require a wildcard; we surface it as `None` + // (caller falls back to skipping the placement) rather than panic. + match sig.scheme { + OnchainSigningScheme::Eip1271 => Some(Signature::Eip1271(sig.data.to_vec())), + OnchainSigningScheme::PreSign => Some(Signature::PreSign), + _ => None, + } +} + +/// Assemble `(OrderCreation, OrderUid)` from a placement. `from` is the +/// EthFlow contract (EIP-1271 owner). `app_data` is fixed to +/// `EMPTY_APP_DATA_JSON` — placements pinning a real IPFS document get +/// rejected by `from_signed_order_data` (digest mismatch) and skipped, +/// same scope limitation as the TWAP module. +fn build_eth_flow_creation( + chain_id: u64, + placement: &DecodedPlacement, +) -> Result<(OrderCreation, OrderUid), BuildError> { + let chain = Chain::try_from(chain_id).map_err(|_| BuildError::UnsupportedChain(chain_id))?; + let domain = chain.settlement_domain(); + let order_data = gpv2_to_order_data(&placement.order).ok_or(BuildError::UnknownMarker)?; + let uid = order_data.uid(&domain, placement.contract); + let signature = to_signature(&placement.signature).ok_or(BuildError::UnknownSignatureScheme)?; + let creation = OrderCreation::from_signed_order_data( + &order_data, + signature, + placement.contract, + EMPTY_APP_DATA_JSON.to_string(), + None, + ) + .map_err(BuildError::Cowprotocol)?; + Ok((creation, uid)) +} + +fn submit_placement(chain_id: u64, placement: &DecodedPlacement) -> Result<(), HostError> { + let (creation, uid) = match build_eth_flow_creation(chain_id, placement) { + Ok(x) => x, + Err(e) => { + logging::log( + logging::Level::Warn, + &format!( + "ethflow submit skipped (sender={:#x}): {e}", + placement.sender + ), + ); + return Ok(()); + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(b) => b, + Err(e) => { + logging::log( + logging::Level::Error, + &format!("OrderCreation JSON encode failed: {e}"), + ); + return Ok(()); + } + }; + let uid_hex = format!("{uid}"); + match cow_api::submit_order(chain_id, &body) { + Ok(server_uid) => { + // Persist under the server-supplied UID so downstream + // observers (cow-tooling, dune) join on the same key. The + // client UID we just computed should equal it; a Warn is + // worth a closer look if not (domain/owner divergence). + if server_uid != uid_hex { + logging::log( + logging::Level::Warn, + &format!("ethflow uid drift: local={uid_hex} server={server_uid}"), + ); + } + local_store::set(&format!("submitted:{server_uid}"), b"")?; + logging::log( + logging::Level::Info, + &format!("ethflow submitted {server_uid}"), + ); + } + Err(err) => apply_submit_retry(&err, &uid_hex)?, + } + Ok(()) +} + +fn try_decode_api_error(err: &HostError) -> Option { + let data = err.data.as_deref()?; + serde_json::from_str::(data).ok() +} + +fn classify_submit_error(err: &HostError) -> RetryAction { + match try_decode_api_error(err) { + Some(api) if api.retry_hint() => RetryAction::TryNextBlock, + Some(_) => RetryAction::Drop, + // Safe default — a flaky orderbook should not be treated as a + // permanent rejection. + None => RetryAction::TryNextBlock, + } +} + +fn apply_submit_retry(err: &HostError, uid_hex: &str) -> Result<(), HostError> { + match classify_submit_error(err) { + RetryAction::TryNextBlock | RetryAction::Backoff { .. } => { + local_store::set(&format!("backoff:{uid_hex}"), b"")?; + logging::log( + logging::Level::Warn, + &format!("ethflow backoff {uid_hex} ({}): {}", err.code, err.message), + ); + } + RetryAction::Drop => { + local_store::set(&format!("dropped:{uid_hex}"), b"")?; + logging::log( + logging::Level::Warn, + &format!("ethflow dropped {uid_hex} ({}): {}", err.code, err.message), + ); + } + } + Ok(()) } export!(EthFlowWatcher); @@ -116,42 +287,49 @@ mod tests { use super::*; use alloy_primitives::{U256, address, hex}; use alloy_sol_types::SolValue; - use cowprotocol::OnchainSigningScheme; - fn sample_order() -> GPv2OrderData { + fn submittable_order() -> GPv2OrderData { GPv2OrderData { sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), sellAmount: U256::from(1_000_000_u64), buyAmount: U256::from(999_u64), - validTo: 1_700_000_000, - appData: B256::repeat_byte(0xaa), + validTo: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, feeAmount: U256::ZERO, - kind: B256::repeat_byte(0xbb), + kind: OrderKind::SELL, partiallyFillable: false, - sellTokenBalance: B256::repeat_byte(0xcc), - buyTokenBalance: B256::repeat_byte(0xdd), + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + fn well_formed_placement() -> DecodedPlacement { + DecodedPlacement { + contract: ETH_FLOW_PRODUCTION, + sender: address!("00112233445566778899aabbccddeeff00112233"), + order: Box::new(submittable_order()), + signature: OnchainSignature { + scheme: OnchainSigningScheme::Eip1271, + data: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + }, + data: Bytes::new(), } } - fn sample_event() -> (Address, OrderPlacement) { - let sender = address!("00112233445566778899aabbccddeeff00112233"); - let event = OrderPlacement { - sender, - order: sample_order(), + fn sample_event_for_decode() -> OrderPlacement { + OrderPlacement { + sender: address!("00112233445566778899aabbccddeeff00112233"), + order: submittable_order(), signature: OnchainSignature { scheme: OnchainSigningScheme::Eip1271, data: hex!("c0ffeec0ffeec0ffee").to_vec().into(), }, data: hex!("deadbeef").to_vec().into(), - }; - (sender, event) + } } - /// Build `(topics, data)` the way the EVM would emit them. The - /// indexed `sender` becomes topic1 (left-padded address); the three - /// non-indexed fields become the abi-encoded body. fn encode_log(event: &OrderPlacement) -> (Vec>, Vec) { let mut sender_topic = vec![0u8; 12]; sender_topic.extend_from_slice(event.sender.as_slice()); @@ -165,71 +343,135 @@ mod tests { (topics, data) } + // ---- BLEU-832 regressions ---- + #[test] fn decodes_well_formed_placement() { - let (sender, event) = sample_event(); + let event = sample_event_for_decode(); let (topics, data) = encode_log(&event); - let address = ETH_FLOW_PRODUCTION.as_slice(); - - let decoded = decode_order_placement(address, &topics, &data).expect("decode succeeds"); - assert_eq!(decoded.sender, sender); - assert_eq!(decoded.order.sellToken, event.order.sellToken); - assert_eq!(decoded.order.buyAmount, event.order.buyAmount); + let decoded = decode_order_placement(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data) + .expect("decode succeeds"); + assert_eq!(decoded.contract, ETH_FLOW_PRODUCTION); + assert_eq!(decoded.sender, event.sender); assert_eq!(decoded.signature.scheme, OnchainSigningScheme::Eip1271); - assert_eq!( - decoded.signature.data.as_ref(), - event.signature.data.as_ref() - ); - assert_eq!(decoded.data.as_ref(), event.data.as_ref()); - } - - #[test] - fn accepts_staging_address() { - let (_, event) = sample_event(); - let (topics, data) = encode_log(&event); - assert!(decode_order_placement(ETH_FLOW_STAGING.as_slice(), &topics, &data).is_some()); } #[test] fn rejects_unrelated_contract_address() { - let (_, event) = sample_event(); + let event = sample_event_for_decode(); let (topics, data) = encode_log(&event); let stranger = address!("dead00000000000000000000000000000000dead"); assert!(decode_order_placement(stranger.as_slice(), &topics, &data).is_none()); } + // ---- BLEU-833: order construction ---- + #[test] - fn rejects_wrong_topic_signature() { - let (_, event) = sample_event(); - let (_, data) = encode_log(&event); - let bad_topic = vec![0xaa_u8; 32]; - let sender_topic = vec![0u8; 32]; - assert!( - decode_order_placement( - ETH_FLOW_PRODUCTION.as_slice(), - &[bad_topic, sender_topic], - &data, - ) - .is_none() + fn build_eip1271_creation_has_contract_as_from() { + let placement = well_formed_placement(); + let (creation, uid) = + build_eth_flow_creation(11_155_111, &placement).expect("build succeeds"); + assert_eq!(creation.from, placement.contract); + assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); + assert_eq!( + creation.signature.to_bytes(), + placement.signature.data.to_vec(), + ); + // UID layout = digest || owner || valid_to. Owner bytes must + // match the EthFlow contract. + assert_eq!(&uid.as_slice()[32..52], placement.contract.as_slice()); + // Last 4 bytes = validTo big-endian. + assert_eq!( + &uid.as_slice()[52..56], + &placement.order.validTo.to_be_bytes(), ); } #[test] - fn rejects_truncated_address() { - let (_, event) = sample_event(); - let (topics, data) = encode_log(&event); - assert!(decode_order_placement(&[0u8; 19], &topics, &data).is_none()); + fn build_presign_emits_presign_scheme() { + let mut placement = well_formed_placement(); + placement.signature = OnchainSignature { + scheme: OnchainSigningScheme::PreSign, + data: Bytes::new(), + }; + let (creation, _) = build_eth_flow_creation(1, &placement).expect("build succeeds"); + assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::PreSign); + assert!(creation.signature.to_bytes().is_empty()); } #[test] - fn rejects_truncated_data() { - let (topics, _) = encode_log(&sample_event().1); - assert!(decode_order_placement(ETH_FLOW_PRODUCTION.as_slice(), &topics, &[]).is_none()); + fn build_rejects_unsupported_chain() { + let placement = well_formed_placement(); + let err = build_eth_flow_creation(0xdead_beef, &placement).unwrap_err(); + assert!(matches!(err, BuildError::UnsupportedChain(0xdead_beef))); } #[test] - fn rejects_empty_topics() { - let (_, data) = encode_log(&sample_event().1); - assert!(decode_order_placement(ETH_FLOW_PRODUCTION.as_slice(), &[], &data).is_none()); + fn build_rejects_unknown_kind_marker() { + let mut placement = well_formed_placement(); + placement.order.kind = B256::repeat_byte(0x42); + let err = build_eth_flow_creation(1, &placement).unwrap_err(); + assert!(matches!(err, BuildError::UnknownMarker)); + } + + #[test] + fn build_rejects_non_empty_app_data() { + let mut placement = well_formed_placement(); + placement.order.appData = B256::repeat_byte(0xee); + let err = build_eth_flow_creation(1, &placement).unwrap_err(); + assert!(matches!(err, BuildError::Cowprotocol(_))); + } + + // ---- BLEU-833: error classification ---- + + fn host_error_with_api(error_type: &str) -> HostError { + let body = serde_json::json!({ + "errorType": error_type, + "description": "test", + }); + HostError { + domain: "cow-api".into(), + kind: nexum::host::types::HostErrorKind::Denied, + code: 400, + message: format!("{error_type}: test"), + data: Some(body.to_string()), + } + } + + #[test] + fn classify_retriable_returns_try_next_block() { + for kind in ["InsufficientFee", "TooManyLimitOrders", "PriceExceedsMarketPrice"] { + assert_eq!( + classify_submit_error(&host_error_with_api(kind)), + RetryAction::TryNextBlock, + ); + } + } + + #[test] + fn classify_permanent_returns_drop() { + for kind in [ + "InvalidSignature", + "WrongOwner", + "DuplicateOrder", + "InvalidErc1271Signature", + ] { + assert_eq!( + classify_submit_error(&host_error_with_api(kind)), + RetryAction::Drop, + ); + } + } + + #[test] + fn classify_missing_data_defaults_to_try_next_block() { + let err = HostError { + domain: "cow-api".into(), + kind: nexum::host::types::HostErrorKind::Internal, + code: 0, + message: "network reset".into(), + data: None, + }; + assert_eq!(classify_submit_error(&err), RetryAction::TryNextBlock); } } From d13ba69dfab627b1270b7116e8a13fcbe9d66cab Mon Sep 17 00:00:00 2001 From: brunota20 Date: Mon, 15 Jun 2026 11:39:49 -0300 Subject: [PATCH 20/21] fix(ethflow-watcher): idempotency guard on re-delivered placements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`submit_placement\` now checks for a prior terminal marker before calling \`cow_api::submit_order\`. Re-delivered \`OrderPlacement\` logs (engine restart with replay, host reconnect, indexer back-fill) would otherwise re-submit the same body, the orderbook would reject \`DuplicateOrder\` (permanent), and the module would end up with BOTH \`submitted:{uid}\` AND \`dropped:{uid}\` written for the same key. The guard is a typed `prior_outcome(uid_hex)` lookup: - \`Submitted\` -> skip (the most common re-delivery cause) - \`Dropped\` -> skip (orderbook permanently rejected previously) - \`Backoff\` -> proceed: a transient failure deserves a fresh attempt on re-delivery; the new outcome overrides. - \`None\` -> proceed: a clean first try. On a successful submit, any previous \`backoff:\` marker is also cleared so the local store carries at most one outcome flag per UID at rest. Same cleanup happens on a permanent drop in \`apply_submit_retry\`. Linear: BLEU-833 (fix on the same PR — review identified the re-delivery gap). --- modules/ethflow-watcher/src/lib.rs | 62 +++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index dec343b..0b182e1 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -211,6 +211,32 @@ fn submit_placement(chain_id: u64, placement: &DecodedPlacement) -> Result<(), H return Ok(()); } }; + let uid_hex = format!("{uid}"); + + // Idempotency. A host reconnect or engine restart may replay the same + // OrderPlacement log; without the guard we would attempt a second + // submit, the orderbook would reject `DuplicateOrder` (permanent), and + // we would end up with both `submitted:` AND `dropped:` written for + // the same UID. `backoff:` is *not* a short-circuit — a previous + // transient error deserves a fresh attempt on re-delivery. + match prior_outcome(&uid_hex)? { + PriorOutcome::Submitted => { + logging::log( + logging::Level::Info, + &format!("ethflow {uid_hex} already submitted; skipping"), + ); + return Ok(()); + } + PriorOutcome::Dropped => { + logging::log( + logging::Level::Info, + &format!("ethflow {uid_hex} previously dropped; skipping"), + ); + return Ok(()); + } + PriorOutcome::None | PriorOutcome::Backoff => {} + } + let body = match serde_json::to_vec(&creation) { Ok(b) => b, Err(e) => { @@ -221,7 +247,6 @@ fn submit_placement(chain_id: u64, placement: &DecodedPlacement) -> Result<(), H return Ok(()); } }; - let uid_hex = format!("{uid}"); match cow_api::submit_order(chain_id, &body) { Ok(server_uid) => { // Persist under the server-supplied UID so downstream @@ -235,6 +260,9 @@ fn submit_placement(chain_id: u64, placement: &DecodedPlacement) -> Result<(), H ); } local_store::set(&format!("submitted:{server_uid}"), b"")?; + // Clear any backoff: marker a prior transient error left + // behind; the terminal `submitted:` flag now supersedes it. + let _ = local_store::delete(&format!("backoff:{server_uid}")); logging::log( logging::Level::Info, &format!("ethflow submitted {server_uid}"), @@ -245,6 +273,34 @@ fn submit_placement(chain_id: u64, placement: &DecodedPlacement) -> Result<(), H Ok(()) } +/// Which terminal / transient marker (if any) the local store carries +/// for `uid_hex`. The submit path short-circuits on `Submitted` / +/// `Dropped`; `Backoff` still proceeds with a fresh attempt; `None` +/// means a clean first try. +#[derive(Debug, Eq, PartialEq)] +enum PriorOutcome { + None, + Submitted, + Backoff, + Dropped, +} + +fn prior_outcome(uid_hex: &str) -> Result { + // Terminal markers take precedence over `backoff:`. `submitted:` is + // checked first because a successful prior attempt is the most + // common reason a log gets re-delivered. + if local_store::get(&format!("submitted:{uid_hex}"))?.is_some() { + return Ok(PriorOutcome::Submitted); + } + if local_store::get(&format!("dropped:{uid_hex}"))?.is_some() { + return Ok(PriorOutcome::Dropped); + } + if local_store::get(&format!("backoff:{uid_hex}"))?.is_some() { + return Ok(PriorOutcome::Backoff); + } + Ok(PriorOutcome::None) +} + fn try_decode_api_error(err: &HostError) -> Option { let data = err.data.as_deref()?; serde_json::from_str::(data).ok() @@ -271,6 +327,10 @@ fn apply_submit_retry(err: &HostError, uid_hex: &str) -> Result<(), HostError> { } RetryAction::Drop => { local_store::set(&format!("dropped:{uid_hex}"), b"")?; + // Clear `backoff:` if a prior transient attempt left it + // behind — the terminal `dropped:` flag now supersedes it, + // and we want at most one "outcome" marker per UID at rest. + let _ = local_store::delete(&format!("backoff:{uid_hex}")); logging::log( logging::Level::Warn, &format!("ethflow dropped {uid_hex} ({}): {}", err.code, err.message), From 7a68e381a4063e56c11dd0519eeefb4a7c6e02af Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Mon, 29 Jun 2026 22:24:40 -0300 Subject: [PATCH 21/21] chore(modules): align deps with workspace + cargo fmt Update twap-monitor and ethflow-watcher Cargo.toml to use workspace dep versions (alloy-primitives 1.6, alloy-sol-types 1.6, wit-bindgen 0.58) and apply cargo fmt to the cherry-picked module source. --- modules/ethflow-watcher/Cargo.toml | 6 ++-- modules/ethflow-watcher/src/lib.rs | 6 +++- modules/twap-monitor/Cargo.toml | 6 ++-- modules/twap-monitor/src/lib.rs | 52 ++++++++++++++++-------------- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index cdde1fd..2636236 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] cowprotocol = { version = "1.0.0-alpha.3", default-features = false } -alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } -alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +alloy-primitives = { workspace = true, default-features = false, features = ["std"] } +alloy-sol-types = { workspace = true, default-features = false, features = ["std"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } +wit-bindgen.workspace = true diff --git a/modules/ethflow-watcher/src/lib.rs b/modules/ethflow-watcher/src/lib.rs index 0b182e1..de4872c 100644 --- a/modules/ethflow-watcher/src/lib.rs +++ b/modules/ethflow-watcher/src/lib.rs @@ -500,7 +500,11 @@ mod tests { #[test] fn classify_retriable_returns_try_next_block() { - for kind in ["InsufficientFee", "TooManyLimitOrders", "PriceExceedsMarketPrice"] { + for kind in [ + "InsufficientFee", + "TooManyLimitOrders", + "PriceExceedsMarketPrice", + ] { assert_eq!( classify_submit_error(&host_error_with_api(kind)), RetryAction::TryNextBlock, diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index bd4afee..323a429 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] cowprotocol = { version = "1.0.0-alpha.3", default-features = false } -alloy-primitives = { version = "1.5", default-features = false, features = ["std"] } -alloy-sol-types = { version = "1.5", default-features = false, features = ["std"] } +alloy-primitives = { workspace = true, default-features = false, features = ["std"] } +alloy-sol-types = { workspace = true, default-features = false, features = ["std"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } -wit-bindgen = { version = "0.57", default-features = false, features = ["macros", "realloc"] } +wit-bindgen.workspace = true diff --git a/modules/twap-monitor/src/lib.rs b/modules/twap-monitor/src/lib.rs index 8e0e01b..8b912d7 100644 --- a/modules/twap-monitor/src/lib.rs +++ b/modules/twap-monitor/src/lib.rs @@ -210,7 +210,10 @@ fn poll_one(chain_id: u64, owner: &Address, params: &ConditionalOrderParams) -> } logging::log( logging::Level::Warn, - &format!("eth_call failed ({}); defaulting to TryNextBlock", err.message), + &format!( + "eth_call failed ({}); defaulting to TryNextBlock", + err.message + ), ); PollOutcome::TryNextBlock } @@ -240,7 +243,9 @@ fn decode_revert(data: &[u8]) -> Option { let selector: [u8; 4] = data[..4].try_into().ok()?; let body = &data[4..]; match selector { - s if s == abi::IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), + s if s == abi::IConditionalOrder::OrderNotValid::SELECTOR => { + Some(PollOutcome::DontTryAgain) + } s if s == abi::IConditionalOrder::PollTryNextBlock::SELECTOR => { Some(PollOutcome::TryNextBlock) } @@ -441,11 +446,7 @@ fn classify_submit_error(err: &HostError) -> RetryAction { } } -fn apply_submit_retry( - err: &HostError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), HostError> { +fn apply_submit_retry(err: &HostError, watch_key: &str, now_epoch_s: u64) -> Result<(), HostError> { let action = classify_submit_error(err); match action { RetryAction::TryNextBlock => { @@ -562,10 +563,7 @@ fn apply_watch_update(update: WatchUpdate, watch_key: &str) -> Result<(), HostEr let _ = local_store::delete(&format!("next_block:{owner_hex}:{hash_hex}")); let _ = local_store::delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); } - logging::log( - logging::Level::Info, - &format!("dropped watch {watch_key}"), - ); + logging::log(logging::Level::Info, &format!("dropped watch {watch_key}")); Ok(()) } } @@ -669,7 +667,10 @@ mod tests { t.extend_from_slice(owner.as_slice()); t }; - let topics = vec![ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), owner_topic]; + let topics = vec![ + ConditionalOrderCreated::SIGNATURE_HASH.to_vec(), + owner_topic, + ]; let data = params.abi_encode(); let (decoded_owner, decoded_params) = @@ -680,8 +681,9 @@ mod tests { #[test] fn rejects_wrong_topic() { - let topics = - vec![b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec()]; + let topics = vec![ + b256!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").to_vec(), + ]; assert!(decode_conditional_order_created(&topics, &[]).is_none()); } @@ -826,8 +828,7 @@ mod tests { #[test] fn watch_key_round_trips_via_parse() { let owner = address!("00112233445566778899aabbccddeeff00112233"); - let hash = - b256!("0202020202020202020202020202020202020202020202020202020202020202"); + let hash = b256!("0202020202020202020202020202020202020202020202020202020202020202"); let key = watch_key(&owner, &hash); let (o, h) = parse_watch_key(&key).expect("parse"); assert_eq!(o.parse::
().unwrap(), owner); @@ -887,13 +888,10 @@ mod tests { fn build_order_creation_succeeds_with_empty_app_data() { let owner = address!("00112233445566778899aabbccddeeff00112233"); let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let creation = build_order_creation(&submittable_order(), sig.clone(), owner) - .expect("build succeeds"); + let creation = + build_order_creation(&submittable_order(), sig.clone(), owner).expect("build succeeds"); assert_eq!(creation.from, owner); - assert_eq!( - creation.signing_scheme, - cowprotocol::SigningScheme::Eip1271 - ); + 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); @@ -917,8 +915,8 @@ mod tests { #[test] fn build_order_creation_rejects_zero_from() { - let err = build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO) - .unwrap_err(); + let err = + build_order_creation(&submittable_order(), Bytes::new(), Address::ZERO).unwrap_err(); assert!(matches!(err, BuildError::Cowprotocol(_))); } @@ -943,7 +941,11 @@ mod tests { // InsufficientFee / TooManyLimitOrders / PriceExceedsMarketPrice // are the three kinds cowprotocol::OrderPostErrorKind flags // retriable today. - for kind in ["InsufficientFee", "TooManyLimitOrders", "PriceExceedsMarketPrice"] { + for kind in [ + "InsufficientFee", + "TooManyLimitOrders", + "PriceExceedsMarketPrice", + ] { assert_eq!( classify_submit_error(&host_error_with_api(kind)), RetryAction::TryNextBlock,