diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094bc7d9..95949ba4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: cargo build --release --target wasm32-wasip2 --locked -p example -p twap-monitor -p ethflow-watcher -p price-alert -p balance-tracker -p stop-loss -p http-probe - -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb + -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb - run: cargo test --workspace --all-features --no-fail-fast --locked docs: diff --git a/Cargo.lock b/Cargo.lock index 9a2bc4fb..76cb1ef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1442,6 +1442,13 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clock-reader" +version = "0.1.0" +dependencies = [ + "wit-bindgen 0.58.0", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1548,9 +1555,8 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cowprotocol" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aaeeac1011ad799f5316aebcd45c052232c433a8cfa6a2abe32e40b354be770" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "cowprotocol-appdata", "cowprotocol-orderbook", @@ -1560,9 +1566,8 @@ dependencies = [ [[package]] name = "cowprotocol-appdata" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a9872ee4e4e515215bb1adcd12a82a91d05130aeedd43a2b218e9ccf8b6739" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "cid", @@ -1575,9 +1580,8 @@ dependencies = [ [[package]] name = "cowprotocol-orderbook" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8faa55dd7ab3e0fda87dee0caf4bbed19891feeca122aca78df988834d7e9b9" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "cowprotocol-appdata", @@ -1596,9 +1600,8 @@ dependencies = [ [[package]] name = "cowprotocol-primitives" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b6194563c076218c980ac0a5246a2cf75d0fd8558ef704ec108cdf0b27aa62" +version = "0.1.1" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-chains", "alloy-primitives", @@ -1610,9 +1613,8 @@ dependencies = [ [[package]] name = "cowprotocol-signing" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57411bec8ea0c3e348a8bcae0a11bb9a56f2cb21323337139e373859018a93f3" +version = "0.2.0" +source = "git+https://github.com/nullislabs/cow-rs?rev=17fc0c5f55d04fad38963911da07d5da9290fefb#17fc0c5f55d04fad38963911da07d5da9290fefb" dependencies = [ "alloy-primitives", "alloy-signer 1.8.3", @@ -3591,6 +3593,7 @@ dependencies = [ "http", "proptest", "serde_json", + "shepherd-sdk-test", "strum 0.28.0", "thiserror 2.0.18", "tracing", @@ -5080,16 +5083,21 @@ dependencies = [ "alloy-sol-types", "cowprotocol", "nexum-sdk", + "nexum-sdk-test", "proptest", "serde_json", + "shepherd-sdk-test", "strum 0.28.0", "thiserror 2.0.18", + "tracing", ] [[package]] name = "shepherd-sdk-test" version = "0.1.0" dependencies = [ + "alloy-primitives", + "cowprotocol", "nexum-sdk", "nexum-sdk-test", "serde_json", @@ -5774,8 +5782,6 @@ dependencies = [ "serde_json", "shepherd-sdk", "shepherd-sdk-test", - "strum 0.28.0", - "thiserror 2.0.18", "tracing", "wit-bindgen 0.58.0", ] diff --git a/Cargo.toml b/Cargo.toml index 084cbba1..7dbc87cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "modules/examples/http-probe", "modules/examples/price-alert", "modules/examples/stop-loss", + "modules/fixtures/clock-reader", "modules/fixtures/flaky-bomb", "modules/fixtures/fuel-bomb", "modules/fixtures/memory-bomb", @@ -99,7 +100,7 @@ alloy-chains = { version = "0.2", default-features = false, features = ["std", " # hoisting). The engine takes `http-client` for `OrderBookApi`; # guest-side consumers (SDK, strategies) express their own # `default-features = false` builds for the `cdylib` wasm target. -cowprotocol = { version = "0.1.0", default-features = false, features = ["http-client"] } +cowprotocol = { version = "0.2.0", default-features = false, features = ["http-client"] } # HTTP transport for `cow_api::request` REST passthrough and the # orderbook-mock test surface. @@ -182,6 +183,14 @@ unsafe_op_in_unsafe_fn = "warn" dbg_macro = "deny" todo = "deny" +# `cowprotocol` 0.2.0 (the `OrderCreationAppData` hash-only submission +# shape plus the WASI clock fix that keeps `js_sys` out of non-browser +# wasm builds) is not published yet; the latest crates.io release is +# 0.2.0-alpha.1 without either. Patch to the cow-rs rev carrying both. +# Drop once a cowprotocol release with the hash-only constructor ships. +[patch.crates-io] +cowprotocol = { git = "https://github.com/nullislabs/cow-rs", rev = "17fc0c5f55d04fad38963911da07d5da9290fefb" } + [profile.dev] panic = "abort" diff --git a/crates/nexum-cli/src/launch.rs b/crates/nexum-cli/src/launch.rs index 52829cc5..99db2562 100644 --- a/crates/nexum-cli/src/launch.rs +++ b/crates/nexum-cli/src/launch.rs @@ -4,7 +4,7 @@ use std::path::Path; -use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOns}; +use nexum_runtime::addons::{PrometheusAddOn, RuntimeAddOn}; use nexum_runtime::builder::RuntimeBuilder; use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ @@ -12,6 +12,7 @@ use nexum_runtime::host::component::{ }; use nexum_runtime::host::local_store_redb::LocalStore; use nexum_runtime::host::provider_pool::ProviderPool; +use nexum_runtime::runtime::task::TokioExecutor; use shepherd_cow_host::{ReferenceExt, ReferenceExtBuilder, extension}; /// The backends the reference engine ships: the core seams plus the @@ -40,7 +41,7 @@ pub async fn run_from_config( // Attach the reference add-on set. The binary ships the Prometheus // exporter; an embedder omits or replaces it by choosing a different // list here. - let add_ons: [&dyn RuntimeAddOns; 1] = [&PrometheusAddOn]; + let add_ons: [&dyn RuntimeAddOn; 1] = [&PrometheusAddOn]; // Assemble and launch over the type-state builder: bind the reference // lattice, wire cow-api as an extension (linker hook plus capability @@ -51,6 +52,10 @@ pub async fn run_from_config( .with_types::() .with_extensions([extension::()]) .with_module_source(wasm.map(Path::to_path_buf), manifest.map(Path::to_path_buf)) + // The launch root is the executor-selection seam: the binary spawns on + // tokio, while an embedder or a non-tokio target substitutes its own + // executor here. + .with_executor(&TokioExecutor) .with_components(ComponentsBuilder::new( ProviderPoolBuilder, LocalStoreBuilder, diff --git a/crates/nexum-runtime/src/addons.rs b/crates/nexum-runtime/src/addons.rs index 4709adfc..114d2357 100644 --- a/crates/nexum-runtime/src/addons.rs +++ b/crates/nexum-runtime/src/addons.rs @@ -7,7 +7,7 @@ //! replaces any of them instead of inheriting a fixed install. //! //! A future control-surface add-on (an admin or RPC socket) slots in beside -//! [`PrometheusAddOn`]: implement [`RuntimeAddOns`], read its own section +//! [`PrometheusAddOn`]: implement [`RuntimeAddOn`], read its own section //! from [`AddOnsContext`], and add it to the launcher's list at the //! composition root. @@ -39,7 +39,7 @@ impl AddOnHandle { /// A process-wide facility attached to the launch path. `install` reads the /// resolved config from `ctx` and returns a handle the launcher retains. -pub trait RuntimeAddOns { +pub trait RuntimeAddOn { /// Install the facility, returning its live handle. fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result; } @@ -47,7 +47,7 @@ pub trait RuntimeAddOns { /// An owned, ordered add-on set gathered behind one value. A preset or /// composition root returns this so a heterogeneous set travels together; /// the launcher borrows each element to install it. -pub type AddOns = Vec>; +pub type AddOns = Vec>; /// The Prometheus exporter add-on. With `[engine.metrics].enabled = true` /// it binds an HTTP listener serving `/metrics`; otherwise it installs the @@ -56,7 +56,7 @@ pub type AddOns = Vec>; /// production with observability by flipping one config flag. pub struct PrometheusAddOn; -impl RuntimeAddOns for PrometheusAddOn { +impl RuntimeAddOn for PrometheusAddOn { fn install(&self, ctx: &AddOnsContext<'_>) -> anyhow::Result { if ctx.metrics.enabled { let addr: std::net::SocketAddr = ctx.metrics.bind_addr.parse().map_err(|e| { diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index a2b202c0..fca4b2d6 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -11,7 +11,7 @@ use std::path::Path; -use crate::addons::RuntimeAddOns; +use crate::addons::RuntimeAddOn; use crate::builder::{AssembledRuntime, LaunchContext, LaunchRuntime}; use crate::engine_config::EngineConfig; use crate::host::component::{Components, RuntimeTypes}; @@ -34,7 +34,7 @@ pub async fn run( manifest: Option<&Path>, components: &Components, extensions: &[Extension], - add_ons: &[&dyn RuntimeAddOns], + add_ons: &[&dyn RuntimeAddOn], ) -> anyhow::Result<()> { let runtime = AssembledRuntime { components: components.clone(), @@ -47,7 +47,6 @@ pub async fn run( let executor = TokioExecutor; let ctx = LaunchContext { executor: &executor, - data_dir: &engine_cfg.engine.state_dir, config: engine_cfg, }; runtime.launch(ctx).await?.wait().await diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index aa1effe6..86457759 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf}; use tracing::{info, warn}; use wasmtime::Engine; -use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOns}; +use crate::addons::{AddOnHandle, AddOnsContext, RuntimeAddOn}; use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, @@ -36,16 +36,10 @@ pub use crate::supervisor::WasiClockOverride; use crate::supervisor::{self, Supervisor}; /// Ambient inputs the imperative launcher reads: the executor that spawns the -/// long-lived subscription and event-loop tasks, the resolved data directory, -/// and the loaded config. +/// long-lived subscription and event-loop tasks, and the loaded config. pub struct LaunchContext<'a> { /// Spawns the subscription and event-loop tasks. pub executor: &'a dyn TaskExecutor, - /// Directory the backends root their on-disk state at. Advisory: the - /// launcher receives pre-built backends, so it does not open the data - /// directory itself; a builder that opens the backends reads the data - /// directory at build time, not here. - pub data_dir: &'a Path, /// The loaded engine config. pub config: &'a EngineConfig, } @@ -98,7 +92,7 @@ pub struct AssembledRuntime<'a, T: RuntimeTypes> { /// Linker hooks and capability namespaces. pub extensions: Vec>, /// Cross-cutting facilities installed before the engine boots. - pub add_ons: &'a [&'a dyn RuntimeAddOns], + pub add_ons: &'a [&'a dyn RuntimeAddOn], /// Single-module source override; `None` runs `[[modules]]`. pub wasm: Option<&'a Path>, /// Manifest paired with `wasm`. @@ -231,8 +225,8 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { let event_loop = ctx.executor.spawn(Box::pin(async move { let shutdown = async move { // A failed signal registration must not resolve the shutdown - // future: park that leg so the programmatic trigger (or the - // handle dropping) remains the only stop. + // future; hold this leg on `pending()` so the programmatic + // trigger (or the handle dropping) stays the only stop. let signal = async { match event_loop::wait_for_shutdown_signal().await { Ok(name) => info!(signal = %name, "shutdown signal received"), @@ -247,7 +241,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { () = signal => {}, } }; - let mut supervisor = supervisor; + let mut supervisor = supervisor; // rebind as mut: the dispatch calls below take &mut self event_loop::run( &mut supervisor, block_streams, @@ -341,8 +335,8 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { self } - /// Bind the executor the launcher spawns its tasks on. Defaults to the - /// ambient tokio runtime. + /// Bind the executor the launcher spawns its tasks on. Defaults to + /// [`TokioExecutor`], which spawns on the ambient tokio runtime. pub fn with_executor(mut self, executor: &'a dyn TaskExecutor) -> Self { self.executor = Some(executor); self @@ -358,8 +352,8 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { /// Open the preset's backends and launch. Builds the [`Components`] bundle /// from the preset's component builders, installs the preset's add-ons, - /// then drives [`LaunchRuntime::launch`] on the bound executor (the - /// ambient tokio runtime by default). + /// then drives [`LaunchRuntime::launch`] on the bound executor + /// ([`TokioExecutor`] by default). pub async fn launch(self) -> anyhow::Result { let data_dir = self.config.engine.state_dir.clone(); let build_ctx = BuilderContext { @@ -368,11 +362,10 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { }; let components = R::components().build::(&build_ctx).await?; - // The preset owns its add-ons; the launcher borrows each one to - // install it, so both the owned set and the ref view stay live across - // the launch await. + // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is + // consumed by the launch call, so both must stay in scope for that call. let add_ons = R::add_ons(); - let add_on_refs: Vec<&dyn RuntimeAddOns> = add_ons.iter().map(|a| &**a).collect(); + let add_on_refs: Vec<&dyn RuntimeAddOn> = add_ons.iter().map(|a| &**a).collect(); let runtime = AssembledRuntime { components, @@ -382,9 +375,11 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { manifest: self.manifest.as_deref(), clocks: self.clocks, }; + // A named local keeps the default's borrow unambiguous (not a + // temporary); `with_executor` overrides it. + let default_executor = TokioExecutor; let ctx = LaunchContext { - executor: self.executor.unwrap_or(&TokioExecutor), - data_dir: &data_dir, + executor: self.executor.unwrap_or(&default_executor), config: self.config, }; runtime.launch(ctx).await @@ -418,8 +413,8 @@ impl<'a, T: RuntimeTypes> TypedBuilder<'a, T> { self } - /// Bind the executor the launcher spawns its tasks on. Defaults to the - /// ambient tokio runtime. + /// Bind the executor the launcher spawns its tasks on. Defaults to + /// [`TokioExecutor`], which spawns on the ambient tokio runtime. pub fn with_executor(mut self, executor: &'a dyn TaskExecutor) -> Self { self.executor = Some(executor); self @@ -465,10 +460,7 @@ pub struct ComponentsStage<'a, T: RuntimeTypes, C, S, E> { impl<'a, T: RuntimeTypes, C, S, E> ComponentsStage<'a, T, C, S, E> { /// Bind the cross-cutting add-on set installed before the engine boots. - pub fn with_add_ons( - self, - add_ons: &'a [&'a dyn RuntimeAddOns], - ) -> ReadyBuilder<'a, T, C, S, E> { + pub fn with_add_ons(self, add_ons: &'a [&'a dyn RuntimeAddOn]) -> ReadyBuilder<'a, T, C, S, E> { ReadyBuilder { config: self.config, extensions: self.extensions, @@ -492,7 +484,7 @@ pub struct ReadyBuilder<'a, T: RuntimeTypes, C, S, E> { executor: Option<&'a dyn TaskExecutor>, clocks: Option, components: ComponentsBuilder, - add_ons: &'a [&'a dyn RuntimeAddOns], + add_ons: &'a [&'a dyn RuntimeAddOn], } impl ReadyBuilder<'_, T, C, S, E> @@ -504,7 +496,7 @@ where { /// Open the backends and launch. Builds the [`Components`] bundle from the /// bound builders, then drives [`LaunchRuntime::launch`] on the bound - /// executor (the ambient tokio runtime by default). + /// executor ([`TokioExecutor`] by default). pub async fn launch(self) -> anyhow::Result { let data_dir = self.config.engine.state_dir.clone(); let build_ctx = BuilderContext { @@ -521,9 +513,11 @@ where manifest: self.manifest.as_deref(), clocks: self.clocks, }; + // A named local keeps the default's borrow unambiguous (not a + // temporary); `with_executor` overrides it. + let default_executor = TokioExecutor; let ctx = LaunchContext { - executor: self.executor.unwrap_or(&TokioExecutor), - data_dir: &data_dir, + executor: self.executor.unwrap_or(&default_executor), config: self.config, }; runtime.launch(ctx).await @@ -568,7 +562,7 @@ mod tests { #[tokio::test] async fn assembled_runtime_installs_add_ons_before_boot() { struct CountingAddOn(Arc); - impl RuntimeAddOns for CountingAddOn { + impl RuntimeAddOn for CountingAddOn { fn install(&self, _ctx: &AddOnsContext<'_>) -> anyhow::Result { self.0.fetch_add(1, Ordering::SeqCst); Ok(AddOnHandle::named("counting")) @@ -591,7 +585,7 @@ mod tests { let calls = Arc::new(AtomicUsize::new(0)); let add_on = CountingAddOn(calls.clone()); - let add_on_refs: Vec<&dyn RuntimeAddOns> = vec![&add_on]; + let add_on_refs: Vec<&dyn RuntimeAddOn> = vec![&add_on]; let runtime = AssembledRuntime { components, extensions: Vec::new(), @@ -603,7 +597,6 @@ mod tests { let executor = TokioExecutor; let ctx = LaunchContext { executor: &executor, - data_dir: &data_dir, config: &config, }; diff --git a/crates/nexum-runtime/src/host/component/builder.rs b/crates/nexum-runtime/src/host/component/builder.rs index 99179148..1657e5e9 100644 --- a/crates/nexum-runtime/src/host/component/builder.rs +++ b/crates/nexum-runtime/src/host/component/builder.rs @@ -73,6 +73,22 @@ impl ComponentBuilder for LocalStoreBuilder { } } +/// Names the component slot whose build failed. The leaf cause stays an +/// `anyhow::Error` because the backends fail for heterogeneous reasons +/// (I/O for the store, network for the chain). +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + /// The chain backend builder failed. + #[error("build the chain backend: {0}")] + Chain(anyhow::Error), + /// The store backend builder failed. + #[error("build the store backend: {0}")] + Store(anyhow::Error), + /// The extension payload builder failed. + #[error("build the extension payload: {0}")] + Ext(anyhow::Error), +} + /// The empty extension payload: a no-op builder for a core-only lattice /// (`Ext = ()`). impl ComponentBuilder for () { @@ -105,17 +121,18 @@ impl ComponentsBuilder { /// Drive each builder against `ctx`, then bundle the backends with a /// fresh log pipeline. The builder outputs must match the lattice /// seams: chain to [`RuntimeTypes::Chain`], store to - /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. - pub async fn build(self, ctx: &BuilderContext<'_>) -> anyhow::Result> + /// [`RuntimeTypes::Store`], ext to [`RuntimeTypes::Ext`]. A failing + /// sub-build returns the [`BuildError`] variant naming that slot. + pub async fn build(self, ctx: &BuilderContext<'_>) -> Result, BuildError> where T: RuntimeTypes, C: ComponentBuilder, S: ComponentBuilder, E: ComponentBuilder, { - let chain = self.chain.build(ctx).await?; - let store = self.store.build(ctx).await?; - let ext = self.ext.build(ctx).await?; + let chain = self.chain.build(ctx).await.map_err(BuildError::Chain)?; + let store = self.store.build(ctx).await.map_err(BuildError::Store)?; + let ext = self.ext.build(ctx).await.map_err(BuildError::Ext)?; let logs = LogPipeline::in_memory(ctx.config.limits.logs()); Ok(Components { chain, diff --git a/crates/nexum-runtime/src/host/component/mod.rs b/crates/nexum-runtime/src/host/component/mod.rs index eb5bca2d..0adb15fa 100644 --- a/crates/nexum-runtime/src/host/component/mod.rs +++ b/crates/nexum-runtime/src/host/component/mod.rs @@ -10,7 +10,8 @@ mod runtime_types; mod state; pub use builder::{ - BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, ProviderPoolBuilder, + BuildError, BuilderContext, ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, + ProviderPoolBuilder, }; pub use chain::{ChainMethod, ChainProvider}; pub use runtime_types::{Handle, RuntimeTypes}; diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 48673f70..1c7e9656 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -16,7 +16,7 @@ pub(crate) fn chain_denied(detail: impl Into) -> ChainError { /// Stable snake_case label for a [`Fault`], used as a metric label and /// structured-log `kind` field. Mirrors the SDK `HostFault::label` -/// vocabulary so host-side and guest-side labels align. +/// vocabulary. pub(crate) fn fault_label(fault: &Fault) -> &'static str { match fault { Fault::Unsupported(_) => "unsupported", @@ -74,16 +74,15 @@ impl From for ChainError { ref source, .. } => ChainError::Rpc(RpcError { - // Preserve the node-reported JSON-RPC code. Out-of-`i32` - // codes (never seen for real `-32768..-32000` codes) - // saturate to `-32603` Internal error. + // Preserve the node-reported JSON-RPC code. A code outside + // `i32` is a JSON-RPC spec violation, clamped to `-32603` + // Internal error. code: i32::try_from(code).unwrap_or(-32603), message: source.to_string(), data, }), - // Transport-level failure (no `ErrorResp`): classify into a - // fault so a guest can tell "the node reverted" apart from - // "the node was unreachable / timed out". + // Lets a guest tell "the node reverted" apart from "the node + // was unreachable / timed out". ProviderError::Rpc { source, .. } => ChainError::Fault(transport_fault(&source)), } } @@ -120,9 +119,8 @@ fn transport_fault(source: &alloy_transport::TransportError) -> Fault { } } -/// Project a [`StorageError`] into the `local-store` interface [`Fault`] -/// as an `internal`, carrying the backend detail verbatim. The interface -/// is the failure domain, so the fault omits the redundant subsystem tag. +/// The `local-store` interface is the failure domain, so the fault omits +/// the redundant subsystem tag. impl From for Fault { fn from(err: StorageError) -> Self { Fault::Internal(err.to_string()) diff --git a/crates/nexum-runtime/src/runtime/task.rs b/crates/nexum-runtime/src/runtime/task.rs index e2d25d68..8b55bb72 100644 --- a/crates/nexum-runtime/src/runtime/task.rs +++ b/crates/nexum-runtime/src/runtime/task.rs @@ -95,9 +95,9 @@ impl TaskSet { /// Aborts every task, then awaits each handle so all tasks are observed /// to finish before returning. The drained exit reasons are summarised - /// at debug for soak diagnosis: a clean drain reports every task as - /// [`TaskExit::ReceiverGone`]; a task that had already stopped abnormally - /// (aborted or panicked) counts against the aborted tally. + /// at debug for soak diagnosis: a task whose join yields an exit reason + /// counts clean (it finished on its own); a task whose join returns + /// `None` (aborted or panicked) counts against the aborted tally. pub async fn shutdown(mut self) { for handle in &self.handles { handle.abort(); @@ -107,7 +107,7 @@ impl TaskSet { let mut aborted = 0usize; for handle in self.handles.drain(..) { match handle.join().await { - Some(TaskExit::ReceiverGone) => clean += 1, + Some(_) => clean += 1, None => aborted += 1, } } diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 79dab968..e06e163a 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -609,4 +609,61 @@ direction = "above" rt.shutdown(); rt.wait().await.expect("clean shutdown"); } + + /// The guest observes the `WasiClockOverride` end to end: pin the harness + /// clock to a known instant, boot the clock-reader fixture under it, and + /// dispatch a block. The fixture reads `wasi:clocks/wall-clock` through + /// `std` and logs the wall time as whole seconds, so the logged value + /// equalling the pinned instant (and not the ambient host clock) proves + /// the override reaches the guest, not just the host boot path. + #[tokio::test] + async fn harness_guest_observes_the_clock_override() { + use std::time::{Duration, UNIX_EPOCH}; + + let Some(wasm) = module_wasm_or_skip("clock_reader.wasm") else { + return; + }; + + // A round instant far from the ambient clock: a stale ambient read + // would land in the 1.7-billion-plus range of the present, so an + // exact match on this value can only come from the override. + const PINNED_SECS: u64 = 1_700_000_000; + + let builder = TestRuntime::builder(wasm).manifest_inline(block_manifest("clock-reader", 1)); + builder + .clock() + .set(UNIX_EPOCH + Duration::from_secs(PINNED_SECS)); + + let mut rt = builder + .launch() + .await + .expect("launch clock-reader over the harness"); + + rt.push_block(header_numbered(19_000_000)); + let record = rt + .wait_for_log("clock-reader", &format!("clock wall {PINNED_SECS}")) + .await + .expect("the guest logs its wall-clock reading after dispatch"); + + // The line is a host-interface log carrying exactly the pinned + // seconds, parsed back to guard against a substring false positive. + assert_eq!( + record.source, + crate::host::logs::LogSource::HostInterface, + "the fixture logs through the host interface", + ); + let logged: u64 = record + .message + .rsplit(' ') + .next() + .and_then(|s| s.parse().ok()) + .expect("the log line ends in the wall-clock seconds"); + assert_eq!( + logged, PINNED_SECS, + "the guest read the overridden wall clock, not the ambient host clock", + ); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } } diff --git a/crates/nexum-sdk-test/src/lib.rs b/crates/nexum-sdk-test/src/lib.rs index 621da7bd..b9e135bb 100644 --- a/crates/nexum-sdk-test/src/lib.rs +++ b/crates/nexum-sdk-test/src/lib.rs @@ -60,9 +60,10 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{BTreeMap, HashMap}; use std::fmt::{self, Write as _}; +use std::rc::Rc; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -191,41 +192,99 @@ impl ChainHost for MockChain { // ---------------------------------------------------------------- local-store -/// In-memory [`LocalStoreHost`] backed by a `HashMap`. Each operation -/// runs in O(1) except `list_keys`, which scans (small N expected for -/// tests). +/// In-memory [`LocalStoreHost`] mirroring the runtime store's shape: +/// namespaced views over one shared row map, plus store-wide entry +/// and byte limits. /// -/// Supports optional error injection via [`MockLocalStore::fail_on`] -/// and entry-count limits via [`MockLocalStore::set_max_entries`]. +/// A fresh store is the root view. [`namespaced`](Self::namespaced) +/// derives a sibling view over the same backing rows - identical key +/// strings in different namespaces never collide, matching the host's +/// per-module key prefixing. Limits sit on the shared backing store, +/// so one namespace's writes can exhaust another's headroom exactly +/// as two modules share one database file. Fault injection via +/// [`fail_on`](Self::fail_on) stays per-view. #[derive(Default)] pub struct MockLocalStore { - rows: RefCell>>, - /// When set, `set` returns `StorageFull` if the store reaches this many entries. - max_entries: RefCell>, + shared: Rc, + namespace: String, /// Key patterns that trigger injected faults on any operation. error_patterns: RefCell>, } +/// Backing rows and limits shared by every namespaced view. +#[derive(Default)] +struct SharedRows { + /// Rows keyed by `(namespace, key)`. + rows: RefCell>>, + /// Total stored bytes (key + value) across all namespaces. + bytes: Cell, + /// When set, `set` on a new key fails once the store holds this + /// many rows. + max_entries: Cell>, + /// When set, `set` fails once stored bytes would exceed this. + max_bytes: Cell>, +} + impl MockLocalStore { - /// Number of rows currently held. + /// A view over the same backing rows under `namespace`. Views with + /// the same namespace alias the same data (two handles onto one + /// module store); different namespaces are fully isolated even for + /// identical key strings. + /// + /// # Panics + /// + /// On an empty namespace - the runtime rejects those too. + pub fn namespaced(&self, namespace: impl Into) -> MockLocalStore { + let namespace = namespace.into(); + assert!( + !namespace.is_empty(), + "MockLocalStore: namespace must not be empty", + ); + MockLocalStore { + shared: Rc::clone(&self.shared), + namespace, + error_patterns: RefCell::new(Vec::new()), + } + } + + /// Number of rows in this view's namespace. pub fn len(&self) -> usize { - self.rows.borrow().len() + self.shared + .rows + .borrow() + .keys() + .filter(|(ns, _)| *ns == self.namespace) + .count() } - /// Whether the store is empty. + /// Whether this view's namespace holds no rows. pub fn is_empty(&self) -> bool { - self.rows.borrow().is_empty() + self.len() == 0 } - /// Direct read for assertions - bypasses the trait. + /// Direct read of this view's namespace for assertions - bypasses + /// the trait. pub fn snapshot(&self) -> HashMap> { - self.rows.borrow().clone() + self.shared + .rows + .borrow() + .iter() + .filter(|((ns, _), _)| *ns == self.namespace) + .map(|((_, key), value)| (key.clone(), value.clone())) + .collect() } - /// Set a maximum number of entries. Once reached, `set` on a new - /// key returns a `StorageFull` error. `None` disables the limit. + /// Cap the row count across every namespace. Once reached, `set` + /// on a new key fails; overwriting an existing key still succeeds. pub fn set_max_entries(&self, limit: usize) { - *self.max_entries.borrow_mut() = Some(limit); + self.shared.max_entries.set(Some(limit)); + } + + /// Cap total stored bytes (key + value, across every namespace). + /// A `set` that would push the total past the cap fails; deletes + /// and same-key overwrites release the bytes they displace. + pub fn set_max_bytes(&self, limit: usize) { + self.shared.max_bytes.set(Some(limit)); } /// Inject a fault for any operation where the key starts with @@ -250,36 +309,64 @@ impl MockLocalStore { impl LocalStoreHost for MockLocalStore { fn get(&self, key: &str) -> Result>, Fault> { self.check_injected_error(key)?; - Ok(self.rows.borrow().get(key).cloned()) + Ok(self + .shared + .rows + .borrow() + .get(&(self.namespace.clone(), key.to_string())) + .cloned()) } fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { self.check_injected_error(key)?; - if let Some(limit) = *self.max_entries.borrow() { - let rows = self.rows.borrow(); - if rows.len() >= limit && !rows.contains_key(key) { - return Err(Fault::Internal(format!( - "MockLocalStore: max entries ({limit}) reached" - ))); - } + let mut rows = self.shared.rows.borrow_mut(); + let compound = (self.namespace.clone(), key.to_string()); + let existing = rows.get(&compound).map(Vec::len); + if existing.is_none() + && let Some(limit) = self.shared.max_entries.get() + && rows.len() >= limit + { + return Err(Fault::Internal(format!( + "MockLocalStore: max entries ({limit}) reached" + ))); } - self.rows - .borrow_mut() - .insert(key.to_string(), value.to_vec()); + // Same-key overwrites release the displaced bytes before the + // new row is charged. + let displaced = existing.map_or(0, |len| key.len() + len); + let total = self.shared.bytes.get() - displaced + key.len() + value.len(); + if let Some(budget) = self.shared.max_bytes.get() + && total > budget + { + return Err(Fault::Internal(format!( + "MockLocalStore: max bytes ({budget}) reached" + ))); + } + rows.insert(compound, value.to_vec()); + self.shared.bytes.set(total); Ok(()) } fn delete(&self, key: &str) -> Result<(), Fault> { self.check_injected_error(key)?; - self.rows.borrow_mut().remove(key); + if let Some(value) = self + .shared + .rows + .borrow_mut() + .remove(&(self.namespace.clone(), key.to_string())) + { + self.shared + .bytes + .set(self.shared.bytes.get() - key.len() - value.len()); + } Ok(()) } fn list_keys(&self, prefix: &str) -> Result, Fault> { self.check_injected_error(prefix)?; let mut keys: Vec = self + .shared .rows .borrow() .keys() - .filter(|k| k.starts_with(prefix)) - .cloned() + .filter(|(ns, key)| *ns == self.namespace && key.starts_with(prefix)) + .map(|(_, key)| key.clone()) .collect(); keys.sort(); Ok(keys) @@ -671,6 +758,77 @@ mod tests { assert_eq!(store.len(), 2); } + #[test] + fn local_store_namespaces_isolate_identical_keys() { + let store = MockLocalStore::default(); + let other = store.namespaced("other-module"); + store.set("watch:a", b"mine").unwrap(); + other.set("watch:a", b"theirs").unwrap(); + + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + assert_eq!( + other.get("watch:a").unwrap().as_deref(), + Some(&b"theirs"[..]), + ); + + // Scans, counts, and snapshots stay view-scoped. + assert_eq!(store.len(), 1); + assert_eq!(other.len(), 1); + assert_eq!(store.list_keys("").unwrap(), vec!["watch:a"]); + assert_eq!(store.snapshot().get("watch:a").unwrap(), b"mine"); + + // Deletes never reach across the namespace boundary. + other.delete("watch:a").unwrap(); + assert!(other.is_empty()); + assert_eq!(store.get("watch:a").unwrap().as_deref(), Some(&b"mine"[..])); + } + + #[test] + fn local_store_same_namespace_views_alias_the_same_rows() { + let store = MockLocalStore::default(); + let one = store.namespaced("mod"); + let two = store.namespaced("mod"); + one.set("k", b"v").unwrap(); + assert_eq!(two.get("k").unwrap().as_deref(), Some(&b"v"[..])); + } + + #[test] + #[should_panic(expected = "namespace must not be empty")] + fn local_store_empty_namespace_panics() { + let _ = MockLocalStore::default().namespaced(""); + } + + #[test] + fn local_store_entry_limit_spans_namespaces() { + let store = MockLocalStore::default(); + store.set_max_entries(2); + let other = store.namespaced("other-module"); + store.set("a", b"1").unwrap(); + other.set("b", b"2").unwrap(); + // The store is one shared file: a sibling namespace's rows + // consume the same headroom. + let err = store.set("c", b"3").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max entries"))); + } + + #[test] + fn local_store_byte_budget_enforced_and_released() { + let store = MockLocalStore::default(); + store.set_max_bytes(8); + store.set("abcd", b"1234").unwrap(); // 4 + 4 = 8, exactly at budget + let err = store.set("x", b"y").unwrap_err(); + assert!(matches!(err, Fault::Internal(ref m) if m.contains("max bytes"))); + + // A same-key overwrite releases the displaced value first. + store.set("abcd", b"12").unwrap(); + store.set("x", b"y").unwrap(); + + // Deleting releases the whole row's bytes. + store.delete("abcd").unwrap(); + store.set("ab", b"12").unwrap(); + assert_eq!(store.len(), 2); + } + #[test] fn mock_host_dispatches_through_supertrait() { let host = MockHost::new(); diff --git a/crates/nexum-sdk/Cargo.toml b/crates/nexum-sdk/Cargo.toml index b1318afb..4993fba6 100644 --- a/crates/nexum-sdk/Cargo.toml +++ b/crates/nexum-sdk/Cargo.toml @@ -35,6 +35,10 @@ tracing-core.workspace = true [dev-dependencies] proptest.workspace = true +# Dev-only cycle (this crate <- shepherd-sdk <- shepherd-sdk-test): +# cargo permits it, and the chassis stores acceptance-test against the +# same composed MockHost the flagship modules use. +shepherd-sdk-test = { path = "../shepherd-sdk-test" } # The tracing macros drive the facade's event path in unit tests; the # crate itself only needs `tracing-core`. tracing.workspace = true diff --git a/crates/nexum-sdk/src/chassis.rs b/crates/nexum-sdk/src/chassis.rs new file mode 100644 index 00000000..eabf3405 --- /dev/null +++ b/crates/nexum-sdk/src/chassis.rs @@ -0,0 +1,377 @@ +//! Strategy-chassis stores: the persistent-state conventions shared by +//! conditional-commitment modules, expressed over [`LocalStoreHost`] +//! alone so they compile for any world and test against the in-memory +//! mocks. +//! +//! Three stores cover the machinery watcher modules hand-roll: +//! +//! - [`WatchSet`] - the watch-set registry, one `watch:{owner}:{hash}` +//! row per conditional commitment. +//! - [`Gates`] - `next_block:` / `next_epoch:` gate keys holding a +//! u64 little-endian threshold, with an +//! [`is_ready`](Gates::is_ready) predicate the poll loop consults. +//! - [`Journal`] - the receipt-keyed idempotency journal of +//! `submitted:` / `observed:` presence markers. +//! +//! Two pieces drive the stores from the poll loop: +//! +//! - [`ConditionalSource`] - the world-neutral poll seam: one watch in, +//! one outcome out, at a given [`Tick`]. Implementations own the +//! transport and the outcome shape. +//! - [`RetryLedger`] - runs a [`RetryAction`]'s effect through the +//! stores after a failed materialisation attempt. +//! +//! [`WatchRef`] ties the first two together: gate keys are derived +//! from the exact hex substrings of the stored watch key, and +//! [`WatchSet::remove`] drops a watch together with all of its gate +//! keys so no failure path can orphan a gate. +//! +//! ``` +//! use nexum_sdk::chassis::{Gates, Journal, WatchRef, WatchSet}; +//! use nexum_sdk::host::{Fault, LocalStoreHost}; +//! use nexum_sdk::prelude::*; +//! +//! # use std::cell::RefCell; +//! # use std::collections::BTreeMap; +//! # #[derive(Default)] +//! # struct StubStore(RefCell>>); +//! # impl LocalStoreHost for StubStore { +//! # fn get(&self, key: &str) -> Result>, Fault> { +//! # Ok(self.0.borrow().get(key).cloned()) +//! # } +//! # fn set(&self, key: &str, value: &[u8]) -> Result<(), Fault> { +//! # self.0.borrow_mut().insert(key.into(), value.into()); +//! # Ok(()) +//! # } +//! # fn delete(&self, key: &str) -> Result<(), Fault> { +//! # self.0.borrow_mut().remove(key); +//! # Ok(()) +//! # } +//! # fn list_keys(&self, prefix: &str) -> Result, Fault> { +//! # Ok(self +//! # .0 +//! # .borrow() +//! # .keys() +//! # .filter(|k| k.starts_with(prefix)) +//! # .cloned() +//! # .collect()) +//! # } +//! # } +//! let host = StubStore::default(); +//! let watches = WatchSet::new(&host); +//! let key = watches.put(&Address::ZERO, &B256::ZERO, b"params")?; +//! let watch = WatchRef::parse(&key).expect("well-formed key"); +//! +//! let gates = Gates::new(&host); +//! gates.set_next_block(watch, 100)?; +//! assert!(!gates.is_ready(watch, 99, 0)?); +//! assert!(gates.is_ready(watch, 100, 0)?); +//! +//! let journal = Journal::submitted(&host); +//! journal.record("0xuid")?; +//! assert!(journal.contains("0xuid")?); +//! +//! watches.remove(watch)?; +//! assert!(watches.list()?.is_empty()); +//! # Ok::<(), Fault>(()) +//! ``` + +use alloy_primitives::{Address, B256}; +use strum::IntoStaticStr; + +use crate::host::{Fault, LocalStoreHost}; + +/// Prefix of every watch-set row. +pub const WATCH_PREFIX: &str = "watch:"; +/// Prefix of the block-height gate row paired with a watch. +pub const NEXT_BLOCK_PREFIX: &str = "next_block:"; +/// Prefix of the Unix-seconds gate row paired with a watch. +pub const NEXT_EPOCH_PREFIX: &str = "next_epoch:"; +/// Prefix of the submitted-receipt journal markers. +pub const SUBMITTED_PREFIX: &str = "submitted:"; +/// Prefix of the observed-receipt journal markers. +pub const OBSERVED_PREFIX: &str = "observed:"; + +/// Borrowed view of a watch key's two hex halves, parsed from a +/// `watch:{owner}:{hash}` row. Gate keys are derived from the exact +/// substrings of the stored key, so a parse-then-derive round trip is +/// byte-stable regardless of how the original writer cased the hex. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WatchRef<'k> { + owner_hex: &'k str, + hash_hex: &'k str, +} + +impl<'k> WatchRef<'k> { + /// Parse a `watch:{owner}:{hash}` key. `None` when the prefix or + /// the separating colon is missing. + pub fn parse(key: &'k str) -> Option { + let rest = key.strip_prefix(WATCH_PREFIX)?; + let (owner_hex, hash_hex) = rest.split_once(':')?; + Some(Self { + owner_hex, + hash_hex, + }) + } + + /// The owner half, verbatim from the key. + pub fn owner_hex(&self) -> &'k str { + self.owner_hex + } + + /// The commitment-hash half, verbatim from the key. + pub fn hash_hex(&self) -> &'k str { + self.hash_hex + } + + /// Rebuild the full watch key. + pub fn key(&self) -> String { + format!("{WATCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } + + /// The `next_block:` gate key paired with this watch. + pub fn next_block_key(&self) -> String { + format!("{NEXT_BLOCK_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } + + /// The `next_epoch:` gate key paired with this watch. + pub fn next_epoch_key(&self) -> String { + format!("{NEXT_EPOCH_PREFIX}{}:{}", self.owner_hex, self.hash_hex) + } +} + +/// Watch-set registry: one row per conditional commitment, keyed +/// `watch:{owner}:{hash}` with the encoded commitment parameters as +/// the value. +pub struct WatchSet<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> WatchSet<'h, H> { + /// Registry view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Canonical key for an owner / commitment-hash pair (lowercase + /// `0x`-prefixed hex on both halves). + pub fn key(owner: &Address, hash: &B256) -> String { + format!("{WATCH_PREFIX}{owner:#x}:{hash:#x}") + } + + /// Insert or overwrite the watch row; returns the key written. + /// Overwriting in place makes re-indexing a replayed log a no-op. + pub fn put(&self, owner: &Address, hash: &B256, value: &[u8]) -> Result { + let key = Self::key(owner, hash); + self.host.set(&key, value)?; + Ok(key) + } + + /// The stored value. `Ok(None)` when the watch is absent. + pub fn get(&self, watch: WatchRef<'_>) -> Result>, Fault> { + self.host.get(&watch.key()) + } + + /// Every watch key currently registered. + pub fn list(&self) -> Result, Fault> { + self.host.list_keys(WATCH_PREFIX) + } + + /// Drop the watch together with both of its gate keys. Gates go + /// first: a fault part-way leaves the watch row behind so a retry + /// re-drops it, and a gate key can never outlive its watch. + pub fn remove(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + Gates::new(self.host).clear(watch)?; + self.host.delete(&watch.key()) + } +} + +/// Gate-key discipline: `next_block:{owner}:{hash}` and +/// `next_epoch:{owner}:{hash}` rows holding a u64 little-endian +/// threshold. A malformed or absent row reads as "no gate", so a +/// corrupt value can only make a watch poll sooner, never wedge it. +pub struct Gates<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> Gates<'h, H> { + /// Gate view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Skip polls until the chain reaches `block`. + pub fn set_next_block(&self, watch: WatchRef<'_>, block: u64) -> Result<(), Fault> { + self.host.set(&watch.next_block_key(), &block.to_le_bytes()) + } + + /// Skip polls until the Unix-seconds clock reaches `epoch_s`. + pub fn set_next_epoch(&self, watch: WatchRef<'_>, epoch_s: u64) -> Result<(), Fault> { + self.host + .set(&watch.next_epoch_key(), &epoch_s.to_le_bytes()) + } + + /// Whether the watch is clear to poll at the given block height + /// and Unix-seconds timestamp. Both gates must pass; each is + /// inclusive at its threshold. + pub fn is_ready(&self, watch: WatchRef<'_>, block: u64, epoch_s: u64) -> Result { + if let Some(next) = self.read_u64(&watch.next_block_key())? + && block < next + { + return Ok(false); + } + if let Some(next) = self.read_u64(&watch.next_epoch_key())? + && epoch_s < next + { + return Ok(false); + } + Ok(true) + } + + /// Delete both gate keys. No-op for gates never set. + pub fn clear(&self, watch: WatchRef<'_>) -> Result<(), Fault> { + self.host.delete(&watch.next_block_key())?; + self.host.delete(&watch.next_epoch_key()) + } + + fn read_u64(&self, key: &str) -> Result, Fault> { + let bytes = self.host.get(key)?; + Ok(bytes + .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) + .map(u64::from_le_bytes)) + } +} + +/// Receipt-keyed idempotency journal: presence markers under a fixed +/// prefix. The marker value is empty - presence of the key is the +/// receipt - so re-recording is idempotent by construction. +pub struct Journal<'h, H> { + host: &'h H, + prefix: &'static str, +} + +impl<'h, H: LocalStoreHost> Journal<'h, H> { + /// Journal of receipts this module has submitted upstream + /// (`submitted:` markers). + pub fn submitted(host: &'h H) -> Self { + Self { + host, + prefix: SUBMITTED_PREFIX, + } + } + + /// Journal of receipts this module has observed upstream + /// (`observed:` markers). + pub fn observed(host: &'h H) -> Self { + Self { + host, + prefix: OBSERVED_PREFIX, + } + } + + /// Record the receipt. + pub fn record(&self, receipt: &str) -> Result<(), Fault> { + self.host.set(&format!("{}{receipt}", self.prefix), b"") + } + + /// Whether the receipt is already journalled. + pub fn contains(&self, receipt: &str) -> Result { + Ok(self + .host + .get(&format!("{}{receipt}", self.prefix))? + .is_some()) + } +} + +/// One poll dispatch's world view: chain, block height, and the block +/// clock in Unix seconds. Gate checks and backoff arithmetic read the +/// same instant a source is polled at, so a watch can never gate +/// itself against a clock it was not judged by. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Tick { + /// Chain the dispatch targets. + pub chain_id: u64, + /// Block height at the tick. + pub block: u64, + /// Block timestamp, Unix seconds. + pub epoch_s: u64, +} + +/// A source of conditional commitments: poll one watch, produce one +/// outcome. Generic over the host so implementations stay mock- +/// testable; deliberately no venue-transport abstraction - the source +/// owns its own wire (an `eth_call`, an HTTP probe, a stub). +/// +/// A transient failure should surface as a retry-flavoured outcome, +/// not tear down the caller's sweep: `poll` is infallible by contract. +pub trait ConditionalSource { + /// What one poll produces. + type Outcome; + + /// Poll the source for `watch` at `tick`. `params` is the stored + /// watch value (the encoded commitment parameters), passed + /// verbatim so the source owns the decode. + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Self::Outcome; + + /// Short strategy name compositions prefix shared log lines with + /// (for example `"twap"`). Diagnostic only - no behaviour keys + /// off it. + fn label(&self) -> &'static str { + "conditional" + } +} + +/// What the retry ledger should do to a watch after a failed +/// materialisation attempt. +/// +/// `IntoStaticStr` exposes each variant as a snake_case `&'static +/// str` for log and metric labels. `#[non_exhaustive]` so the +/// contract can grow a variant; downstream dispatch should treat an +/// unknown variant as "leave the watch in place" (the conservative +/// choice). +#[derive(Clone, Copy, Debug, Eq, PartialEq, IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +#[non_exhaustive] +pub enum RetryAction { + /// Leave the watch untouched; the next tick re-attempts. + TryNextBlock, + /// Gate the watch until `now + seconds` on the epoch clock. + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// Remove the watch and its gates; no retry can succeed. + Drop, +} + +/// Retry ledger: runs a [`RetryAction`]'s effect through the chassis +/// stores. `Backoff` saturates at `u64::MAX` on the epoch clock; +/// `Drop` delegates to [`WatchSet::remove`], so gates go first and no +/// failure path can orphan one. +pub struct RetryLedger<'h, H> { + host: &'h H, +} + +impl<'h, H: LocalStoreHost> RetryLedger<'h, H> { + /// Ledger view over the given host. + pub fn new(host: &'h H) -> Self { + Self { host } + } + + /// Apply `action` to the watch, with `now_epoch_s` as the backoff + /// origin. + pub fn apply( + &self, + watch: WatchRef<'_>, + action: RetryAction, + now_epoch_s: u64, + ) -> Result<(), Fault> { + match action { + RetryAction::TryNextBlock => Ok(()), + RetryAction::Backoff { seconds } => { + Gates::new(self.host).set_next_epoch(watch, now_epoch_s.saturating_add(seconds)) + } + RetryAction::Drop => WatchSet::new(self.host).remove(watch), + } + } +} diff --git a/crates/nexum-sdk/src/lib.rs b/crates/nexum-sdk/src/lib.rs index 63f03fef..4404992b 100644 --- a/crates/nexum-sdk/src/lib.rs +++ b/crates/nexum-sdk/src/lib.rs @@ -28,6 +28,12 @@ //! generates the per-module `WitBindgenHost` adapter over the //! wit-bindgen import shims. //! +//! - [`chassis`] - strategy-chassis stores over [`LocalStoreHost`]: +//! the watch-set registry ([`WatchSet`]), block/epoch gate keys +//! ([`Gates`]) and the receipt-keyed idempotency journal +//! ([`Journal`]); plus the [`ConditionalSource`] poll seam and the +//! [`RetryLedger`] dispatching a [`RetryAction`] through the stores. +//! //! - [`chain`] - `eth_call` JSON plumbing ([`eth_call_params`], //! [`parse_eth_call_result`]) and the Chainlink AggregatorV3 reader //! ([`read_latest_answer`]). @@ -73,6 +79,12 @@ //! [`LocalStoreHost`]: host::LocalStoreHost //! [`LoggingHost`]: host::LoggingHost //! [`Fault`]: host::Fault +//! [`WatchSet`]: chassis::WatchSet +//! [`Gates`]: chassis::Gates +//! [`Journal`]: chassis::Journal +//! [`ConditionalSource`]: chassis::ConditionalSource +//! [`RetryLedger`]: chassis::RetryLedger +//! [`RetryAction`]: chassis::RetryAction //! [`eth_call_params`]: chain::eth_call_params //! [`parse_eth_call_result`]: chain::parse_eth_call_result //! [`read_latest_answer`]: chain::chainlink::read_latest_answer @@ -94,6 +106,7 @@ pub mod address; pub mod chain; +pub mod chassis; pub mod config; pub mod events; pub mod host; diff --git a/crates/nexum-sdk/tests/chassis.rs b/crates/nexum-sdk/tests/chassis.rs new file mode 100644 index 00000000..bb0618ab --- /dev/null +++ b/crates/nexum-sdk/tests/chassis.rs @@ -0,0 +1,478 @@ +//! Chassis-store acceptance tests against the composed CoW +//! `shepherd_sdk_test::MockHost` - the same host the flagship modules +//! test with. These live as an integration test (not `#[cfg(test)]`) +//! because the mock crate links `nexum-sdk` externally, and the +//! external and unit-test copies of the host traits are distinct types. + +use alloy_primitives::{Address, B256, address, b256}; +use nexum_sdk::chassis::{ + ConditionalSource, Gates, Journal, NEXT_BLOCK_PREFIX, NEXT_EPOCH_PREFIX, RetryAction, + RetryLedger, Tick, WATCH_PREFIX, WatchRef, WatchSet, +}; +use nexum_sdk::host::{Fault, LocalStoreHost as _}; +use shepherd_sdk_test::MockHost; + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + b256!("0202020202020202020202020202020202020202020202020202020202020202") +} + +// ---- watch keys ---- + +#[test] +fn watch_key_is_lowercase_prefixed_hex() { + let key = WatchSet::::key(&sample_owner(), &sample_hash()); + assert_eq!( + key, + concat!( + "watch:0x00112233445566778899aabbccddeeff00112233:", + "0x0202020202020202020202020202020202020202020202020202020202020202", + ), + ); +} + +#[test] +fn watch_key_round_trips_via_parse() { + let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let watch = WatchRef::parse(&key).expect("parse"); + assert_eq!( + watch.owner_hex().parse::
().unwrap(), + sample_owner() + ); + assert_eq!(watch.hash_hex().parse::().unwrap(), sample_hash()); + assert_eq!(watch.key(), key); +} + +#[test] +fn parse_rejects_missing_prefix_or_separator() { + assert_eq!(WatchRef::parse("gate:0xaa:0xbb"), None); + assert_eq!(WatchRef::parse("watch:0xaa0xbb"), None); + assert_eq!(WatchRef::parse(""), None); +} + +#[test] +fn parse_preserves_key_substrings_verbatim() { + // A foreign writer may have cased the hex differently; gate keys + // must derive from the stored substrings, not from a re-rendered + // canonical form. + let watch = WatchRef::parse("watch:0xAABB:0xCCDD").expect("parse"); + assert_eq!(watch.owner_hex(), "0xAABB"); + assert_eq!(watch.hash_hex(), "0xCCDD"); + assert_eq!(watch.next_block_key(), "next_block:0xAABB:0xCCDD"); + assert_eq!(watch.next_epoch_key(), "next_epoch:0xAABB:0xCCDD"); +} + +// ---- watch-set registry ---- + +#[test] +fn put_get_list_round_trip() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + assert_eq!(watches.list().unwrap(), vec![key.clone()]); + + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"params"[..])); +} + +#[test] +fn put_overwrites_in_place() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + + watches + .put(&sample_owner(), &sample_hash(), b"one") + .unwrap(); + let key = watches + .put(&sample_owner(), &sample_hash(), b"two") + .unwrap(); + + assert_eq!(host.store.len(), 1, "re-put must not duplicate the row"); + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap().as_deref(), Some(&b"two"[..])); +} + +#[test] +fn get_absent_watch_is_none() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = WatchSet::::key(&sample_owner(), &sample_hash()); + let watch = WatchRef::parse(&key).unwrap(); + assert_eq!(watches.get(watch).unwrap(), None); +} + +#[test] +fn list_scans_only_the_watch_prefix() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + Journal::submitted(&host).record("0xuid").unwrap(); + + assert_eq!(watches.list().unwrap(), vec![key]); +} + +// ---- atomic delete ---- + +#[test] +fn remove_drops_watch_and_all_gate_keys() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let gates = Gates::new(&host); + + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + gates.set_next_block(watch, 500).unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + assert_eq!(host.store.len(), 3); + + watches.remove(watch).unwrap(); + + assert!(host.store.is_empty(), "watch and both gates must go"); +} + +#[test] +fn remove_without_gates_is_clean() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + watches.remove(WatchRef::parse(&key).unwrap()).unwrap(); + assert!(host.store.is_empty()); +} + +#[test] +fn remove_clears_gates_before_the_watch_row() { + // A fault on the watch delete must still find the gates gone: the + // retryable leftover is the watch row, never an orphaned gate. + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let gates = Gates::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + gates.set_next_block(watch, 500).unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + + host.store + .fail_on(WATCH_PREFIX, Fault::Unavailable("injected".into())); + + watches.remove(watch).unwrap_err(); + + let snapshot = host.store.snapshot(); + assert!( + !snapshot + .keys() + .any(|k| k.starts_with(NEXT_BLOCK_PREFIX) || k.starts_with(NEXT_EPOCH_PREFIX)), + "gates must already be gone when the watch delete faults", + ); + assert!( + snapshot.contains_key(&key), + "the watch row stays behind so a retry can re-drop it", + ); +} + +#[test] +fn remove_propagates_a_gate_delete_fault_and_keeps_the_watch() { + let host = MockHost::new(); + let watches = WatchSet::new(&host); + let key = watches + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap(); + let watch = WatchRef::parse(&key).unwrap(); + + host.store + .fail_on(NEXT_BLOCK_PREFIX, Fault::Unavailable("injected".into())); + + watches.remove(watch).unwrap_err(); + + assert!( + host.store.snapshot().contains_key(&key), + "a gate-delete fault must leave the watch for a retry", + ); +} + +// ---- gates ---- + +#[test] +fn ready_with_no_gates_set() { + let host = MockHost::new(); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + assert!(Gates::new(&host).is_ready(watch, 0, 0).unwrap()); +} + +#[test] +fn next_block_gate_is_inclusive_at_threshold() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 500).unwrap(); + + assert!(!gates.is_ready(watch, 499, u64::MAX).unwrap()); + assert!(gates.is_ready(watch, 500, u64::MAX).unwrap()); + assert!(gates.is_ready(watch, 501, u64::MAX).unwrap()); +} + +#[test] +fn next_epoch_gate_is_inclusive_at_threshold() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_epoch(watch, 1_700_000_000).unwrap(); + + assert!(!gates.is_ready(watch, u64::MAX, 1_699_999_999).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_700_000_000).unwrap()); +} + +#[test] +fn both_gates_must_pass() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 100).unwrap(); + gates.set_next_epoch(watch, 2_000).unwrap(); + + assert!(!gates.is_ready(watch, 100, 1_999).unwrap()); + assert!(!gates.is_ready(watch, 99, 2_000).unwrap()); + assert!(gates.is_ready(watch, 100, 2_000).unwrap()); +} + +#[test] +fn gate_values_are_u64_le() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 0x0102_0304_0506_0708).unwrap(); + + assert_eq!( + host.store.snapshot().get("next_block:0xaa:0xbb").unwrap(), + &0x0102_0304_0506_0708_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn malformed_gate_value_reads_as_no_gate() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + host.store.set("next_block:0xaa:0xbb", b"not8b").unwrap(); + + assert!( + gates.is_ready(watch, 0, 0).unwrap(), + "a corrupt gate can only make the watch poll sooner", + ); +} + +#[test] +fn clear_removes_both_gate_keys() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + gates.set_next_block(watch, 1).unwrap(); + gates.set_next_epoch(watch, 2).unwrap(); + + gates.clear(watch).unwrap(); + + assert!(host.store.is_empty()); + // And clearing again stays a no-op. + gates.clear(watch).unwrap(); +} + +#[test] +fn gate_fault_propagates_from_is_ready() { + let host = MockHost::new(); + let gates = Gates::new(&host); + let watch = WatchRef::parse("watch:0xaa:0xbb").unwrap(); + host.store + .fail_on(NEXT_EPOCH_PREFIX, Fault::Unavailable("injected".into())); + + gates.is_ready(watch, 0, 0).unwrap_err(); +} + +// ---- journal ---- + +#[test] +fn journal_round_trips_a_receipt() { + let host = MockHost::new(); + let journal = Journal::submitted(&host); + + assert!(!journal.contains("0xuid").unwrap()); + journal.record("0xuid").unwrap(); + assert!(journal.contains("0xuid").unwrap()); +} + +#[test] +fn journal_marker_is_an_empty_presence_row() { + let host = MockHost::new(); + Journal::submitted(&host).record("0xuid").unwrap(); + assert_eq!( + host.store.snapshot().get("submitted:0xuid").unwrap(), + &Vec::::new(), + ); +} + +#[test] +fn journal_record_is_idempotent() { + let host = MockHost::new(); + let journal = Journal::observed(&host); + journal.record("0xuid").unwrap(); + journal.record("0xuid").unwrap(); + assert_eq!(host.store.len(), 1); +} + +#[test] +fn submitted_and_observed_keyspaces_are_disjoint() { + let host = MockHost::new(); + Journal::submitted(&host).record("0xuid").unwrap(); + + assert!(!Journal::observed(&host).contains("0xuid").unwrap()); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key("submitted:0xuid")); + assert!(!snapshot.contains_key("observed:0xuid")); +} + +// ---- retry ledger ---- + +fn seeded_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +#[test] +fn ledger_try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let before = host.store.snapshot(); + + RetryLedger::new(&host) + .apply( + WatchRef::parse(&key).unwrap(), + RetryAction::TryNextBlock, + 1_000, + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); +} + +#[test] +fn ledger_backoff_gates_the_watch_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let ledger = RetryLedger::new(&host); + + ledger + .apply(watch, RetryAction::Backoff { seconds: 30 }, 1_000) + .unwrap(); + + let gates = Gates::new(&host); + assert!(!gates.is_ready(watch, u64::MAX, 1_029).unwrap()); + assert!(gates.is_ready(watch, u64::MAX, 1_030).unwrap()); + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_030_u64.to_le_bytes().to_vec(), + ); + assert!( + host.store.snapshot().contains_key(&key), + "backoff must keep the watch", + ); +} + +#[test] +fn ledger_backoff_saturates_on_the_epoch_clock() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + RetryLedger::new(&host) + .apply(watch, RetryAction::Backoff { seconds: u64::MAX }, 1_000) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &u64::MAX.to_le_bytes().to_vec(), + ); +} + +#[test] +fn ledger_drop_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 500).unwrap(); + + RetryLedger::new(&host) + .apply(watch, RetryAction::Drop, 1_000) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +#[test] +fn retry_action_labels_are_stable_snake_case() { + let cases: [(RetryAction, &str); 3] = [ + (RetryAction::TryNextBlock, "try_next_block"), + (RetryAction::Backoff { seconds: 1 }, "backoff"), + (RetryAction::Drop, "drop"), + ]; + for (action, label) in cases { + assert_eq!(<&'static str>::from(action), label); + } +} + +// ---- conditional source ---- + +/// A source is generic over the host and owns its outcome shape; the +/// chassis passes the stored params verbatim and the tick it judged +/// the gates by. +#[test] +fn conditional_source_sees_params_and_tick_verbatim() { + struct EchoSource; + impl ConditionalSource for EchoSource { + type Outcome = (usize, u64, u64, u64, String); + fn poll( + &self, + _host: &H, + watch: WatchRef<'_>, + params: &[u8], + tick: &Tick, + ) -> Self::Outcome { + ( + params.len(), + tick.chain_id, + tick.block, + tick.epoch_s, + watch.key(), + ) + } + } + + let host = MockHost::new(); + let key = seeded_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let tick = Tick { + chain_id: 1, + block: 42, + epoch_s: 1_700_000_000, + }; + + let (len, chain_id, block, epoch_s, echoed) = EchoSource.poll(&host, watch, b"params", &tick); + assert_eq!(len, b"params".len()); + assert_eq!(chain_id, 1); + assert_eq!(block, 42); + assert_eq!(epoch_s, 1_700_000_000); + assert_eq!(echoed, key); +} diff --git a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs index bc95a216..f4155888 100644 --- a/crates/shepherd-cow-host/src/cow_orderbook/tests.rs +++ b/crates/shepherd-cow-host/src/cow_orderbook/tests.rs @@ -261,7 +261,7 @@ fn sample_order_json() -> String { buy_token_balance: BuyTokenDestination::Erc20, }; let signature = Signature::from_bytes(SigningScheme::PreSign, &[]).expect("presign empty"); - let creation = OrderCreation::from_signed_order_data( + let creation = OrderCreation::new( &order_data, signature, Address::from([0x03; 20]), diff --git a/crates/shepherd-sdk-test/Cargo.toml b/crates/shepherd-sdk-test/Cargo.toml index a09c388f..e72e9176 100644 --- a/crates/shepherd-sdk-test/Cargo.toml +++ b/crates/shepherd-sdk-test/Cargo.toml @@ -15,3 +15,9 @@ nexum-sdk = { path = "../nexum-sdk" } nexum-sdk-test = { path = "../nexum-sdk-test" } shepherd-sdk = { path = "../shepherd-sdk" } serde_json = { workspace = true, features = ["std"] } + +[dev-dependencies] +# Order construction for the MockVenue acceptance tests that drive +# the chassis materialiser end to end. +alloy-primitives.workspace = true +cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/shepherd-sdk-test/src/lib.rs b/crates/shepherd-sdk-test/src/lib.rs index e1289cd0..2e08b74f 100644 --- a/crates/shepherd-sdk-test/src/lib.rs +++ b/crates/shepherd-sdk-test/src/lib.rs @@ -23,6 +23,23 @@ //! assert_eq!(host.cow_api.call_count(), 1); //! ``` //! +//! Per-call venue scripting - outcome queues, status sequences, fault +//! injection - goes through [`MockVenue`] on the same seam: +//! +//! ```rust +//! use nexum_sdk::host::Fault; +//! use shepherd_sdk::cow::{CowApiError, CowApiHost as _}; +//! use shepherd_sdk_test::MockHost; +//! +//! let host = MockHost::with_venue(); +//! host.cow_api +//! .enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); +//! host.cow_api.enqueue_submit(Ok("0xuid".into())); +//! +//! assert!(host.submit_order(1, b"{}").is_err()); +//! assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); +//! ``` +//! //! Modules that never touch the orderbook use `nexum-sdk-test`'s //! `MockHost` directly instead. @@ -30,6 +47,7 @@ #![warn(missing_docs)] use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; use nexum_sdk::Level; use nexum_sdk::host::{ChainError, ChainHost, Fault, LocalStoreHost, LoggingHost}; @@ -37,16 +55,18 @@ use nexum_sdk_test::{MockChain, MockLocalStore, MockLogging}; use shepherd_sdk::cow::{CowApiError, CowApiHost}; /// Composed in-memory host for CoW modules: the generic per-trait -/// mocks plus [`MockCowApi`]. Each field exposes the per-trait mock so -/// tests can program responses and assert on calls. +/// mocks plus a venue mock on the `shepherd:cow/cow-api` seam - +/// [`MockCowApi`] by default, [`MockVenue`] via +/// [`with_venue`](MockHost::with_venue). Each field exposes the +/// per-trait mock so tests can program responses and assert on calls. #[derive(Default)] -pub struct MockHost { +pub struct MockHost { /// `nexum:host/chain` mock. pub chain: MockChain, /// `nexum:host/local-store` mock. pub store: MockLocalStore, /// `shepherd:cow/cow-api` mock. - pub cow_api: MockCowApi, + pub cow_api: V, /// `nexum:host/logging` mock. pub logging: MockLogging, } @@ -58,13 +78,20 @@ impl MockHost { } } -impl ChainHost for MockHost { +impl MockHost { + /// Fresh empty host with [`MockVenue`] on the cow-api seam. + pub fn with_venue() -> Self { + Self::default() + } +} + +impl ChainHost for MockHost { fn request(&self, chain_id: u64, method: &str, params: &str) -> Result { self.chain.request(chain_id, method, params) } } -impl LocalStoreHost for MockHost { +impl LocalStoreHost for MockHost { fn get(&self, key: &str) -> Result>, Fault> { self.store.get(key) } @@ -79,7 +106,7 @@ impl LocalStoreHost for MockHost { } } -impl CowApiHost for MockHost { +impl CowApiHost for MockHost { fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { self.cow_api.submit_order(chain_id, body) } @@ -94,7 +121,7 @@ impl CowApiHost for MockHost { } } -impl LoggingHost for MockHost { +impl LoggingHost for MockHost { fn log(&self, level: Level, message: &str) { self.logging.log(level, message); } @@ -240,6 +267,181 @@ impl CowApiHost for MockCowApi { } } +// ---------------------------------------------------------------- venue + +/// Scripted in-memory venue on the [`CowApiHost`] seam: programmable +/// per-call behaviour, unlike [`MockCowApi`]'s single replayed +/// response. Compose it with the generic mocks via +/// [`MockHost::with_venue`]. +/// +/// The two queue disciplines differ deliberately. Submissions are +/// discrete effects, so the submit queue strictly drains - one outcome +/// per call, then the configured fallback (default: an `Unsupported` +/// fault), so a test that scripts N outcomes catches an unexpected +/// N+1th submit. Responses are observations, so a `(method, path)` +/// sequence advances per call and its final entry replays forever - a +/// terminal order status persists no matter how often it is re-polled. +/// An injected fault overrides both (without consuming the queues) +/// until cleared, modelling a venue outage. +#[derive(Default)] +pub struct MockVenue { + submit_queue: RefCell>, + submit_fallback: RefCell>, + response_sequences: RefCell>>, + response_fallback: RefCell>, + fault: RefCell>, + calls: RefCell>, + request_calls: RefCell>, +} + +/// One scripted venue reply: the body / UID on success, a typed +/// [`CowApiError`] otherwise. +type VenueOutcome = Result; + +impl MockVenue { + /// Append one `submit_order` outcome to the queue; each call + /// consumes one, in order. + pub fn enqueue_submit(&self, outcome: Result) { + self.submit_queue.borrow_mut().push_back(outcome); + } + + /// Steady-state `submit_order` response once the queue is drained. + /// Unset, a drained queue yields an `Unsupported` fault. + pub fn set_submit_fallback(&self, outcome: Result) { + *self.submit_fallback.borrow_mut() = Some(outcome); + } + + /// Append one outcome to the `(method, path)` response sequence. + /// Each matching `cow_api_request` call advances the sequence; the + /// final entry sticks. + pub fn enqueue_response( + &self, + method: impl Into, + path: impl Into, + outcome: Result, + ) { + self.response_sequences + .borrow_mut() + .entry((method.into(), path.into())) + .or_default() + .push_back(outcome); + } + + /// Append one status-probe outcome for the order, keyed on the + /// orderbook's `GET /api/v1/orders/{uid}` route. + pub fn enqueue_order_status(&self, uid: &str, outcome: Result) { + self.enqueue_response("GET", format!("/api/v1/orders/{uid}"), outcome); + } + + /// Catch-all `cow_api_request` response for calls with no + /// programmed sequence. Unset, those yield an `Unsupported` fault. + pub fn set_response_fallback(&self, outcome: Result) { + *self.response_fallback.borrow_mut() = Some(outcome); + } + + /// Fail every venue call with `err` until + /// [`clear_fault`](Self::clear_fault) - a scripted outage. Queued + /// outcomes are not consumed while the fault is active. + pub fn inject_fault(&self, err: CowApiError) { + *self.fault.borrow_mut() = Some(err); + } + + /// Lift an injected fault; queued outcomes resume where they left + /// off. + pub fn clear_fault(&self) { + *self.fault.borrow_mut() = None; + } + + /// All submissions, in arrival order. + pub fn calls(&self) -> Vec { + self.calls.borrow().clone() + } + + /// Last submission, if any. + pub fn last_call(&self) -> Option { + self.calls.borrow().last().cloned() + } + + /// Convenience: parse the most recent submission body as JSON. + pub fn last_body_as_json(&self) -> Option { + self.last_call() + .and_then(|c| serde_json::from_slice(&c.body).ok()) + } + + /// Count of submissions (failed and injected-fault calls included). + pub fn call_count(&self) -> usize { + self.calls.borrow().len() + } + + /// All `cow_api_request` invocations, in arrival order. + pub fn request_calls(&self) -> Vec { + self.request_calls.borrow().clone() + } + + /// Scripted submit outcomes not yet consumed - assert `0` to prove + /// a scenario played out in full. + pub fn pending_submits(&self) -> usize { + self.submit_queue.borrow().len() + } +} + +impl CowApiHost for MockVenue { + fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result { + self.calls.borrow_mut().push(SubmitCall { + chain_id, + body: body.to_vec(), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(outcome) = self.submit_queue.borrow_mut().pop_front() { + return outcome; + } + self.submit_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: submit queue exhausted and no fallback configured".to_string(), + ))) + }) + } + + fn cow_api_request( + &self, + chain_id: u64, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + self.request_calls.borrow_mut().push(RequestCall { + chain_id, + method: method.to_string(), + path: path.to_string(), + body: body.map(str::to_string), + }); + if let Some(err) = self.fault.borrow().as_ref() { + return Err(err.clone()); + } + if let Some(sequence) = self + .response_sequences + .borrow_mut() + .get_mut(&(method.to_string(), path.to_string())) + { + // Advance until one entry remains, then replay it: the + // sequence's final state persists. + if sequence.len() > 1 { + return sequence.pop_front().expect("length checked above"); + } + if let Some(last) = sequence.front() { + return last.clone(); + } + } + self.response_fallback.borrow().clone().unwrap_or_else(|| { + Err(CowApiError::Fault(Fault::Unsupported( + "MockVenue: no response programmed for this request".to_string(), + ))) + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -266,6 +468,119 @@ mod tests { ); } + // ---- MockVenue ---- + + #[test] + fn venue_submit_queue_drains_in_order_then_falls_back() { + let venue = MockVenue::default(); + venue.enqueue_submit(Err(CowApiError::Fault(Fault::Timeout))); + venue.enqueue_submit(Ok("0xuid".into())); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Timeout)), + )); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(venue.pending_submits(), 0); + + // A drained queue is unsupported by default: an unscripted + // extra submit fails loudly. + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + + venue.set_submit_fallback(Ok("0xsteady".into())); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xsteady"); + assert_eq!(venue.call_count(), 4, "every call is recorded"); + } + + #[test] + fn venue_records_submissions_like_the_single_shot_mock() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.submit_order(7, b"{\"x\":1}").unwrap(); + + let last = venue.last_call().unwrap(); + assert_eq!(last.chain_id, 7); + assert_eq!(last.body, b"{\"x\":1}"); + assert_eq!(venue.last_body_as_json().unwrap()["x"], 1); + } + + #[test] + fn venue_fault_injection_overrides_queues_until_cleared() { + let venue = MockVenue::default(); + venue.enqueue_submit(Ok("0xuid".into())); + venue.enqueue_response("GET", "/api/v1/orders/0x1", Ok("{}".into())); + venue.inject_fault(CowApiError::Fault(Fault::Unavailable("down".into()))); + + assert!(matches!( + venue.submit_order(1, b"{}"), + Err(CowApiError::Fault(Fault::Unavailable(_))), + )); + assert!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .is_err() + ); + + // The outage consumed nothing: outcomes resume on recovery. + venue.clear_fault(); + assert_eq!(venue.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/orders/0x1", None) + .unwrap(), + "{}", + ); + assert_eq!(venue.call_count(), 2); + assert_eq!(venue.request_calls().len(), 2); + } + + #[test] + fn venue_response_sequence_advances_and_final_entry_sticks() { + let venue = MockVenue::default(); + for body in ["\"open\"", "\"open\"", "\"fulfilled\""] { + venue.enqueue_order_status("0xuid", Ok(body.into())); + } + let probe = || { + venue + .cow_api_request(1, "GET", "/api/v1/orders/0xuid", None) + .unwrap() + }; + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"open\""); + assert_eq!(probe(), "\"fulfilled\""); + // The terminal entry replays for any later re-poll. + assert_eq!(probe(), "\"fulfilled\""); + } + + #[test] + fn venue_unscripted_request_uses_the_fallback_then_defaults() { + let venue = MockVenue::default(); + assert!(matches!( + venue.cow_api_request(1, "GET", "/api/v1/anything", None), + Err(CowApiError::Fault(Fault::Unsupported(_))), + )); + venue.set_response_fallback(Ok("catch-all".into())); + assert_eq!( + venue + .cow_api_request(1, "GET", "/api/v1/anything", None) + .unwrap(), + "catch-all", + ); + } + + #[test] + fn mock_host_with_venue_dispatches_through_cow_host_bound() { + let host = MockHost::with_venue(); + host.cow_api.enqueue_submit(Ok("0xuid".into())); + + let _: &dyn shepherd_sdk::cow::CowHost = &host; + assert_eq!(host.submit_order(1, b"{}").unwrap(), "0xuid"); + assert_eq!(host.cow_api.call_count(), 1); + } + #[test] fn mock_host_dispatches_through_cow_host_bound() { let host = MockHost::new(); diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs new file mode 100644 index 00000000..5d831af7 --- /dev/null +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -0,0 +1,350 @@ +//! MockVenue acceptance tests: the scripted venue driving the chassis +//! materialiser (multi-tick retry, backoff, and outage scenarios the +//! single-replayed-response mock cannot express) and module-shaped +//! strategy code polling the venue directly. + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::chassis::{ConditionalSource, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use shepherd_sdk::cow::{ + CowApiError, CowHost, OrderRejection, PollOutcome, materialise, order_uid_hex, +}; +use shepherd_sdk_test::{MockHost, MockVenue}; + +const SEPOLIA: u64 = 11_155_111; + +type VenueHost = MockHost; + +/// Closure-backed source so each test scripts its own outcome. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn ready_source( + order: &GPv2OrderData, +) -> FnSource, &[u8], &Tick) -> PollOutcome> { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) +} + +fn seed_watch(host: &VenueHost) -> String { + WatchSet::new(host) + .put( + &sample_owner(), + &keccak256(b"conditional order params"), + b"params", + ) + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +// ---- chassis use ---- + +/// A transient rejection on the first tick keeps the watch alive; the +/// next tick's scripted success is journalled. Per-call scripting is +/// the point: one venue plays a different outcome on each tick. +#[test] +fn chassis_retries_a_transient_rejection_then_submits() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InsufficientFee"))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let source = ready_source(&order); + materialise(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + assert!(host.store.snapshot().contains_key(&key), "watch survives"); + assert!( + !Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + + materialise(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); + assert_eq!( + host.cow_api.pending_submits(), + 0, + "scenario played out in full" + ); +} + +/// A rate-limit with server guidance gates the watch on the epoch +/// clock; the venue is only reached again once the gate clears, and +/// the queued success then lands. +#[test] +fn chassis_backs_off_on_rate_limit_and_submits_after_the_gate() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + + let t0 = sample_tick(); + let source = ready_source(&order); + materialise(&host, &source, &t0).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // 2500ms rounds up to a 3s epoch gate: a tick inside it never + // reaches the venue. + let gated = Tick { + epoch_s: t0.epoch_s + 2, + ..t0 + }; + materialise(&host, &source, &gated).unwrap(); + assert_eq!(host.cow_api.call_count(), 1, "gated tick must not submit"); + + let clear = Tick { + epoch_s: t0.epoch_s + 3, + ..t0 + }; + materialise(&host, &source, &clear).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A venue outage is transient: the watch stays, nothing is gated, and +/// the first tick after recovery submits the queued outcome. +#[test] +fn chassis_survives_a_venue_outage_and_submits_on_recovery() { + let host = MockHost::with_venue(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Unavailable("venue down".into()))); + + let source = ready_source(&order); + materialise(&host, &source, &sample_tick()).unwrap(); + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert_eq!(host.cow_api.call_count(), 1); + + host.cow_api.clear_fault(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + materialise(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 2); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap() + ); +} + +/// A scripted permanent rejection drops the watch through the ledger. +#[test] +fn chassis_drops_the_watch_on_a_scripted_permanent_rejection() { + let host = MockHost::with_venue(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api + .enqueue_submit(Err(rejection("InvalidSignature"))); + + materialise(&host, &ready_source(&order), &sample_tick()).unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// Chassis rows written through the composed host stay invisible to a +/// sibling store namespace, and a decoy watch planted there never +/// reaches the sweep - the store-fidelity seam under the venue tests. +#[test] +fn chassis_sweep_ignores_sibling_namespace_watches() { + let host = MockHost::with_venue(); + seed_watch(&host); + let sibling = host.store.namespaced("other-module"); + assert!(sibling.is_empty(), "chassis rows must not leak across"); + sibling + .set( + "watch:0x00112233445566778899aabbccddeeff00112233:0xdead", + b"decoy", + ) + .unwrap(); + + let order = submittable_order(); + host.cow_api.enqueue_submit(Ok(client_uid(&order))); + let polls = std::cell::Cell::new(0_u32); + materialise( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "only this module's watch is swept"); + assert_eq!(host.cow_api.call_count(), 1); +} + +// ---- module use ---- + +/// Module-shaped fill tracker: probe the orderbook status route and +/// journal an `observed:` receipt once the order reports fulfilled. +/// Generic over [`CowHost`] exactly like production strategy code. +fn record_fill(host: &H, chain_id: u64, uid: &str) -> Result { + let path = format!("/api/v1/orders/{uid}"); + let Ok(body) = host.cow_api_request(chain_id, "GET", &path, None) else { + return Ok(false); + }; + let fulfilled = serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("status").and_then(|s| s.as_str().map(str::to_owned))) + .is_some_and(|status| status == "fulfilled"); + if fulfilled { + Journal::observed(host).record(uid)?; + } + Ok(fulfilled) +} + +/// The status sequence advances one entry per module poll and its +/// terminal entry persists across any number of re-polls. +#[test] +fn module_tracks_a_fill_through_a_status_sequence() { + let host = MockHost::with_venue(); + for body in [ + r#"{"status":"open"}"#, + r#"{"status":"open"}"#, + r#"{"status":"fulfilled"}"#, + ] { + host.cow_api.enqueue_order_status("0xuid", Ok(body.into())); + } + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + // Terminal status sticks: an over-eager re-poll sees it again. + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + assert!(Journal::observed(&host).contains("0xuid").unwrap()); + assert_eq!(host.cow_api.request_calls().len(), 4); + assert_eq!(host.cow_api.request_calls()[0].path, "/api/v1/orders/0xuid"); +} + +/// An outage mid-sequence surfaces to the module as a failed probe and +/// consumes nothing: the sequence resumes where it left off. +#[test] +fn module_probe_rides_out_an_injected_outage() { + let host = MockHost::with_venue(); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"open"}"#.into())); + host.cow_api + .enqueue_order_status("0xuid", Ok(r#"{"status":"fulfilled"}"#.into())); + + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api + .inject_fault(CowApiError::Fault(Fault::Timeout)); + assert!(!record_fill(&host, SEPOLIA, "0xuid").unwrap()); + + host.cow_api.clear_fault(); + assert!(record_fill(&host, SEPOLIA, "0xuid").unwrap()); + assert!(Journal::observed(&host).contains("0xuid").unwrap()); +} + +/// A B256 hash keeps the watch-key helper honest against the venue +/// host's default type parameter. +#[test] +fn watch_key_helper_unifies_with_the_venue_host() { + let hash: B256 = keccak256(b"conditional order params"); + assert_eq!( + WatchSet::::key(&sample_owner(), &hash), + WatchSet::::key(&sample_owner(), &hash), + ); +} diff --git a/crates/shepherd-sdk/Cargo.toml b/crates/shepherd-sdk/Cargo.toml index b04bf522..eb8ed467 100644 --- a/crates/shepherd-sdk/Cargo.toml +++ b/crates/shepherd-sdk/Cargo.toml @@ -13,12 +13,21 @@ description = "CoW-domain guest SDK for Shepherd modules: cow-api host trait, or [dependencies] nexum-sdk = { path = "../nexum-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives.workspace = true alloy-sol-types.workspace = true serde_json.workspace = true strum.workspace = true thiserror.workspace = true +tracing.workspace = true [dev-dependencies] +# `capture_tracing` observes the materialiser's diagnostics in the +# acceptance tests. +nexum-sdk-test = { path = "../nexum-sdk-test" } proptest.workspace = true +# Dev-only cycle (this crate <- shepherd-sdk-test): cargo permits it, +# and the materialiser acceptance-tests against the composed MockHost +# as an integration test - the mock crate links shepherd-sdk +# externally, so the unit-test copy of the traits would not unify. +shepherd-sdk-test = { path = "../shepherd-sdk-test" } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md index 11866f15..a93d3dfb 100644 --- a/crates/shepherd-sdk/README.md +++ b/crates/shepherd-sdk/README.md @@ -25,7 +25,6 @@ use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; | `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | | `cow::composable` | `sol! IConditionalOrder` errors + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | | `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | -| `cow::app_data` | `resolve_app_data` - appData hash -> canonical JSON document. | | `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | ## Testing modules host-free @@ -64,8 +63,7 @@ crates/shepherd-sdk/ │ │ ├── mod.rs CowApiHost + CowHost │ │ ├── order.rs gpv2_to_order_data │ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) -│ │ ├── error.rs RetryAction + classify_api_error -│ │ └── app_data.rs resolve_app_data +│ │ └── error.rs RetryAction + classify_api_error │ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! └── README.md you are here diff --git a/crates/shepherd-sdk/src/cow/app_data.rs b/crates/shepherd-sdk/src/cow/app_data.rs deleted file mode 100644 index a142ed4b..00000000 --- a/crates/shepherd-sdk/src/cow/app_data.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! Resolve a 32-byte `appData` hash to its canonical JSON document. -//! -//! CoW Protocol orders carry an `appData` field as `bytes32 = -//! keccak256(appDataJSON)`. The orderbook validates submissions by -//! re-hashing the JSON body and comparing to the signed hash, so any -//! caller that doesn't already know the document text needs to look -//! it up - either via IPFS or via the orderbook's mirror at -//! `GET /api/v1/app_data/{hex}`. -//! -//! This module hides that lookup behind a single -//! [`resolve_app_data`] helper. Strategies (notably twap-monitor) -//! call it before assembling an `OrderCreation` so cow-swap UI's -//! richer appData docs (partner-id, slippage settings, -//! quote-id, etc.) round-trip cleanly through the submit path. -//! -//! ## Behaviour -//! -//! - `hash == EMPTY_APP_DATA_HASH` (`keccak256("{}")`) → short-circuit -//! to [`cowprotocol::EMPTY_APP_DATA_JSON`] (`"{}"`), no host call. -//! - Otherwise → `GET /api/v1/app_data/{hex}` on the chain's -//! orderbook. The 200 response is `{"fullAppData": ""}`; we -//! pull `fullAppData` out and return it verbatim. -//! - On 404 ([`CowApiError::Http`] with `status == 404`) → return the -//! same error so the caller can drop the submit gracefully (the -//! orderbook doesn't have the document mirrored; the caller has no -//! path to recover without operator intervention). -//! -//! ## Why not a typed CoW endpoint -//! -//! `cow-api::request` is the generic REST passthrough already in the -//! WIT surface (since 0.2.0); we use it rather than adding a typed -//! `cow-api::get-app-data` host method to keep this PR scoped to the -//! SDK + module layers (no WIT bump → no breaking module recompile). -//! Should the lookup become hot enough to merit a typed host -//! endpoint (e.g. for cache control), follow-up issue. -//! -//! ## Why not IPFS -//! -//! The orderbook already mirrors IPFS app_data docs and serves them -//! over a single HTTPS endpoint. Going to IPFS directly would -//! require a fresh capability (`ipfs`), bigger module footprint, -//! and worse latency than a single GET against an already-trusted -//! upstream. If the orderbook 404s, IPFS would too - the doc isn't -//! pinned anywhere we can see from inside the engine. - -use alloy_primitives::B256; -use cowprotocol::EMPTY_APP_DATA_HASH; -use nexum_sdk::host::Fault; - -use crate::cow::{CowApiError, CowApiHost}; - -/// Look up the JSON document corresponding to a signed `appData` -/// hash. See module-level docs for behaviour. -/// -/// The hash is a 32-byte EVM word; the SDK takes [`B256`] across the -/// public surface rather than a raw `&[u8; 32]` per the rubric's -/// protocol-ID newtype rule. Callers holding a raw byte array -/// convert via `B256::from_slice(&bytes[..])` at the WIT boundary. -/// -/// ```no_run -/// use shepherd_sdk::cow::{resolve_app_data, CowApiError, CowApiHost}; -/// use nexum_sdk::prelude::B256; -/// -/// fn pin_doc(host: &H, chain_id: u64, hash: &B256) -> Result { -/// resolve_app_data(host, chain_id, hash) -/// } -/// ``` -pub fn resolve_app_data( - host: &H, - chain_id: u64, - app_data_hash: &B256, -) -> Result { - if app_data_hash.as_slice() == EMPTY_APP_DATA_HASH.as_slice() { - return Ok(cowprotocol::EMPTY_APP_DATA_JSON.to_string()); - } - - let hex = encode_hex(app_data_hash); - let path = format!("/api/v1/app_data/{hex}"); - let response = host.cow_api_request(chain_id, "GET", &path, None)?; - - parse_full_app_data(&response).map_err(|e| { - CowApiError::Fault(Fault::Internal(format!( - "app_data response shape unexpected: {e} (body: {})", - truncate_for_log(&response) - ))) - }) -} - -/// Prefix of an unexpected response body for the diagnostic message. -/// -/// `Fault` carries no structured data field, so on the already-unexpected -/// shape-error path the raw body is folded into the message. Cap it so a -/// large or hostile response cannot bloat the log line; truncation is on a -/// char boundary and marked with an ellipsis. -fn truncate_for_log(body: &str) -> String { - const MAX: usize = 256; - match body.char_indices().nth(MAX) { - Some((end, _)) => format!("{}…", &body[..end]), - None => body.to_owned(), - } -} - -/// Lowercase `0x`-prefixed hex of a 32-byte appData hash. Delegates -/// to [`alloy_primitives::hex::encode`] (alloy is already a direct -/// dependency of this crate) per mfw78's PR #8 guidance against -/// carrying our own hex formatters. -fn encode_hex(hash: &B256) -> String { - format!("0x{}", alloy_primitives::hex::encode(hash.as_slice())) -} - -/// Parse the orderbook's `/api/v1/app_data/{hash}` response shape: -/// -/// ```json -/// {"fullAppData": ""} -/// ``` -/// -/// Some orderbook versions wrap the document in an outer envelope -/// (`{"appData": "...", "appDataHash": "...", "fullAppData": "..."}`); -/// we always pull `fullAppData` and ignore the rest. -fn parse_full_app_data(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|_| "body is not JSON")?; - let obj = v.as_object().ok_or("body is not a JSON object")?; - let full = obj - .get("fullAppData") - .ok_or("missing `fullAppData` field")?; - full.as_str() - .ok_or("`fullAppData` is not a string") - .map(str::to_owned) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - - use nexum_sdk::host::HostFault; - - use crate::cow::HttpFailure; - - /// Stub that captures the (chain_id, method, path) tuple and - /// returns a programmable response. Avoids pulling in - /// shepherd-sdk-test here (which depends on shepherd-sdk). - struct StubCowApi { - response: Result, - last_call: RefCell>, - } - - impl CowApiHost for StubCowApi { - fn submit_order(&self, _: u64, _: &[u8]) -> Result { - unimplemented!() - } - fn cow_api_request( - &self, - chain_id: u64, - method: &str, - path: &str, - _body: Option<&str>, - ) -> Result { - *self.last_call.borrow_mut() = Some((chain_id, method.to_string(), path.to_string())); - self.response.clone() - } - } - - fn ok_stub(body: &str) -> StubCowApi { - StubCowApi { - response: Ok(body.to_string()), - last_call: RefCell::new(None), - } - } - - fn http_err_stub(status: u16) -> StubCowApi { - StubCowApi { - response: Err(CowApiError::Http(HttpFailure { status, body: None })), - last_call: RefCell::new(None), - } - } - - #[test] - fn empty_hash_short_circuits_without_host_call() { - let stub = ok_stub("should never be read"); - let resolved = - resolve_app_data(&stub, 1, &B256::from_slice(EMPTY_APP_DATA_HASH.as_slice())).unwrap(); - assert_eq!(resolved, "{}"); - assert!( - stub.last_call.borrow().is_none(), - "host should not have been called" - ); - } - - #[test] - fn non_empty_hash_routes_to_orderbook_and_extracts_full_app_data() { - let stub = - ok_stub(r#"{"fullAppData":"{\"version\":\"1.1.0\"}","appDataHash":"0xc4bc..."}"#); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - bytes[1] = 0xbc; - let hash = B256::from(bytes); - let resolved = resolve_app_data(&stub, 11_155_111, &hash).unwrap(); - assert_eq!(resolved, r#"{"version":"1.1.0"}"#); - let (cid, method, path) = stub.last_call.borrow().clone().unwrap(); - assert_eq!(cid, 11_155_111); - assert_eq!(method, "GET"); - assert!(path.starts_with("/api/v1/app_data/0x"), "got path={path}"); - assert!( - path.contains("c4bc"), - "hex hash must be lower-case and 64 chars; got path={path}" - ); - } - - #[test] - fn missing_full_app_data_field_returns_internal_with_body_in_message() { - let body = r#"{"appDataHash":"0xabcd","appData":"{}"}"#; - let stub = ok_stub(body); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - let Fault::Internal(message) = err.fault().expect("shape error is a fault") else { - panic!("expected an internal fault, got {err:?}"); - }; - assert!(message.contains("fullAppData"), "got: {message}"); - // The raw body is folded into the message for diagnostics. - assert!(message.contains("appDataHash"), "got: {message}"); - } - - #[test] - fn oversized_shape_error_body_is_truncated_with_ellipsis() { - // A large non-conforming body must not bloat the log line. - let body = format!(r#"{{"junk":"{}"}}"#, "a".repeat(4096)); - let stub = ok_stub(&body); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - let Fault::Internal(message) = err.fault().expect("shape error is a fault") else { - panic!("expected an internal fault, got {err:?}"); - }; - assert!( - message.contains('…'), - "expected truncation ellipsis: {message}" - ); - assert!( - message.len() < body.len(), - "message must be shorter than the raw body" - ); - } - - #[test] - fn http_failure_propagates_unchanged() { - let stub = http_err_stub(404); - let mut bytes = [0u8; 32]; - bytes[0] = 0xc4; - let hash = B256::from(bytes); - let err = resolve_app_data(&stub, 1, &hash).unwrap_err(); - assert!( - matches!(err, CowApiError::Http(HttpFailure { status: 404, .. })), - "got {err:?}", - ); - } - - #[test] - fn hex_encoder_is_lower_case_and_64_wide() { - let mut bytes = [0u8; 32]; - bytes[31] = 0xff; - bytes[0] = 0xab; - let hash = B256::from(bytes); - assert_eq!(encode_hex(&hash), format!("0xab{}ff", "00".repeat(30))); - } -} diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 212e1240..47aa22ee 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -12,6 +12,7 @@ use alloy_primitives::{Bytes, U256}; use alloy_sol_types::{SolError, sol}; use cowprotocol::GPv2OrderData; +use nexum_sdk::host::ChainError; sol! { /// Five custom errors `IConditionalOrder.verify` reverts with. @@ -75,9 +76,9 @@ pub enum PollOutcome { /// /// Returns `None` when the selector is not one of the five /// [`IConditionalOrder`] errors - including a bare `Error(string)` -/// require-revert. Callers should treat that as `TryNextBlock` (the -/// safe default) so a transient RPC blip does not drop a still-valid -/// watch. +/// require-revert. [`classify_poll_error`] is the lifecycle policy on +/// top: it treats any such foreign selector as a permanent +/// contract-level rejection. #[must_use] pub fn decode_revert(data: &[u8]) -> Option { if data.len() < 4 { @@ -105,6 +106,29 @@ pub fn decode_revert(data: &[u8]) -> Option { } } +/// Classify a failed poll `eth_call` into a [`PollOutcome`] - the one +/// policy for what a poll failure means to the watch lifecycle. +/// +/// A revert payload big enough to carry a selector that +/// [`decode_revert`] does not recognise maps to `DontTryAgain`: it is +/// a contract-level rejection outside the `IConditionalOrder` +/// vocabulary (a handler-specific error, typically permanent), and +/// retrying it on every block loops forever. Only payload-free +/// failures - transport faults and reverts whose `data` is absent or +/// shorter than a selector - stay `TryNextBlock`. +#[must_use] +pub fn classify_poll_error(err: &ChainError) -> PollOutcome { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + decode_revert(data).unwrap_or(PollOutcome::DontTryAgain) + } + _ => PollOutcome::TryNextBlock, + }, + ChainError::Fault(_) => PollOutcome::TryNextBlock, + } +} + fn u256_to_u64_saturating(v: U256) -> u64 { u64::try_from(v).unwrap_or(u64::MAX) } @@ -187,4 +211,68 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } + + // ---- classify_poll_error ---- + + use nexum_sdk::host::{Fault, RpcError}; + + fn rpc(data: Option>) -> ChainError { + ChainError::Rpc(RpcError { + code: -32000, + message: "execution reverted".into(), + data: data.map(Into::into), + }) + } + + #[test] + fn classify_dispatches_a_recognised_selector() { + let revert = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(777_u64), + reason: "wait".to_string(), + } + .abi_encode(); + assert!(matches!( + classify_poll_error(&rpc(Some(revert))), + PollOutcome::TryOnBlock(777) + )); + } + + /// A handler-specific selector outside the `IConditionalOrder` + /// vocabulary is a permanent contract-level rejection: it must + /// drop, not re-poll every block forever. + #[test] + fn classify_unrecognised_selector_drops() { + let mut data = vec![0x7a, 0x93, 0x32, 0x34]; + data.extend_from_slice(&[0u8; 32]); + assert!(matches!( + classify_poll_error(&rpc(Some(data))), + PollOutcome::DontTryAgain + )); + // A bare 4-byte selector with no body classifies the same way. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + PollOutcome::DontTryAgain + )); + } + + #[test] + fn classify_payload_free_failures_stay_try_next_block() { + assert!(matches!( + classify_poll_error(&rpc(None)), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&rpc(Some(Vec::new()))), + PollOutcome::TryNextBlock + )); + // Sub-selector payloads cannot name a contract error. + assert!(matches!( + classify_poll_error(&rpc(Some(vec![0x01, 0x02]))), + PollOutcome::TryNextBlock + )); + assert!(matches!( + classify_poll_error(&ChainError::Fault(Fault::Timeout)), + PollOutcome::TryNextBlock + )); + } } diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs index 23360273..416a376b 100644 --- a/crates/shepherd-sdk/src/cow/error.rs +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -7,12 +7,16 @@ //! envelope. The guest dispatches on the variant directly, so no //! second JSON decode of a failure body happens strategy-side. //! -//! [`classify_api_error`] maps a decoded [`OrderRejection`] into a -//! [`RetryAction`] the lifecycle layer dispatches on. +//! [`classify_api_error`] maps a decoded [`OrderRejection`] into the +//! chassis [`RetryAction`] the retry ledger dispatches on; +//! [`classify_submit_error`] widens the table to the whole +//! [`CowApiError`] surface. use nexum_sdk::host::{Fault, HostFault}; use strum::IntoStaticStr; +pub use nexum_sdk::chassis::RetryAction; + /// A non-2xx orderbook reply with no typed rejection envelope. `body` /// is the raw response text, foreign orderbook JSON kept verbatim: a /// caller matches on `status` and reads `body` only for diagnostics. @@ -79,49 +83,19 @@ impl HostFault for CowApiError { } } -/// What the lifecycle layer should do after a failed submission. -/// -/// Mirrors the retry contract: `TryNextBlock` / -/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer -/// today because the retry classifier is bool-only; the -/// variant is kept so dispatch can grow into it once a server -/// `Retry-After` hint shows up. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the dispatch layer can record -/// `shepherd_cow_api_retry_total{action=...}` and surface the action -/// in `tracing::info!(retry_action = ...)` without an ad-hoc match -/// ladder. -#[derive(Debug, Eq, PartialEq, IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -pub enum RetryAction { - /// Leave the watch / placement in place; the next event will - /// re-attempt. - TryNextBlock, - /// Persist `next_attempt = now + seconds`. Reserved - no producer - /// today (kept so the dispatch contract is stable). - #[allow(dead_code)] - Backoff { - /// Seconds to wait before retrying. - seconds: u64, - }, - /// Remove the watch / mark as terminally rejected. The orderbook - /// will not accept this body on a retry. - Drop, -} - -/// Classify a decoded orderbook [`OrderRejection`] into a -/// [`RetryAction`]. +/// Classify a decoded orderbook [`OrderRejection`] into the chassis +/// [`RetryAction`] - the CoW `errorType` classification table. /// /// - Retriable `error_type`s (`InsufficientFee`, `TooManyLimitOrders`, /// `PriceExceedsMarketPrice`) -> `TryNextBlock`. +/// - Already-submitted rejections ([`is_already_submitted`]) -> +/// `TryNextBlock`: the order is live, so the watch must survive; the +/// materialiser also records the `submitted:` receipt so the next +/// tick short-circuits instead of re-posting. /// - Every other (including unrecognised) kind -> `Drop`. /// -/// Non-`Rejected` failures (transport faults, raw HTTP errors) carry -/// no `error_type` and are not classified here; the caller treats them -/// as transient (leave the watch in place) so a flaky orderbook does -/// not poison a still-valid order. +/// Non-`Rejected` failures carry no `error_type`; classify those with +/// [`classify_submit_error`]. /// /// # Example /// @@ -147,13 +121,48 @@ pub enum RetryAction { /// assert_eq!(classify_api_error(&permanent), RetryAction::Drop); /// ``` pub fn classify_api_error(rejection: &OrderRejection) -> RetryAction { - if is_retriable(&rejection.error_type) { + if is_already_submitted(rejection) || is_retriable(&rejection.error_type) { RetryAction::TryNextBlock } else { RetryAction::Drop } } +/// Whether the rejection says the orderbook already holds this exact +/// order: `DuplicatedOrder` (the orderbook's spelling) plus the +/// `DuplicateOrder` variant older deployments emit. Already-submitted +/// is success wearing an error status - dropping the watch on it would +/// kill every future tranche of a TWAP - so the caller records the +/// `submitted:` receipt and keeps the watch. +pub fn is_already_submitted(rejection: &OrderRejection) -> bool { + matches!( + rejection.error_type.as_str(), + "DuplicatedOrder" | "DuplicateOrder" + ) +} + +/// Classify a whole [`CowApiError`] from a submission into the chassis +/// [`RetryAction`]. +/// +/// A typed rejection dispatches through [`classify_api_error`]; a +/// rate-limit fault with server guidance becomes `Backoff` (hint +/// rounded up to whole seconds, minimum one). Everything else +/// (transport faults, raw HTTP errors, unguided rate limits) is +/// transient -> `TryNextBlock`, so a flaky orderbook never poisons a +/// still-valid order. +pub fn classify_submit_error(err: &CowApiError) -> RetryAction { + match err { + CowApiError::Rejected(rejection) => classify_api_error(rejection), + CowApiError::Fault(Fault::RateLimited(limit)) => match limit.retry_after_ms { + Some(ms) => RetryAction::Backoff { + seconds: ms.div_ceil(1000).max(1), + }, + None => RetryAction::TryNextBlock, + }, + _ => RetryAction::TryNextBlock, + } +} + /// Orderbook `errorType` values the protocol treats as transient: a /// fresh submission on a later block may succeed. Everything else /// (including unrecognised types) is permanent. Mirrors the upstream @@ -199,7 +208,6 @@ mod tests { for kind in [ "InvalidSignature", "WrongOwner", - "DuplicateOrder", "UnsupportedToken", "InvalidAppData", "InvalidErc1271Signature", @@ -220,6 +228,69 @@ mod tests { ); } + /// Both spellings pin: the orderbook emits `DuplicatedOrder`, the + /// older `DuplicateOrder` form must classify identically. Neither + /// may drop the watch - that would kill every future tranche. + #[test] + fn duplicated_order_is_already_submitted_and_never_drops() { + for kind in ["DuplicatedOrder", "DuplicateOrder"] { + assert!(is_already_submitted(&rejection(kind)), "{kind}"); + assert_eq!( + classify_api_error(&rejection(kind)), + RetryAction::TryNextBlock, + "{kind}", + ); + } + assert!(!is_already_submitted(&rejection("InsufficientFee"))); + assert!(!is_already_submitted(&rejection("InvalidSignature"))); + } + + #[test] + fn submit_error_rejection_routes_through_the_table() { + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InvalidSignature"))), + RetryAction::Drop, + ); + assert_eq!( + classify_submit_error(&CowApiError::Rejected(rejection("InsufficientFee"))), + RetryAction::TryNextBlock, + ); + } + + #[test] + fn submit_error_rate_limit_hint_becomes_backoff_in_whole_seconds() { + let limited = |ms| CowApiError::Fault(Fault::RateLimited(RateLimit { retry_after_ms: ms })); + assert_eq!( + classify_submit_error(&limited(Some(2_500))), + RetryAction::Backoff { seconds: 3 }, + ); + // Sub-second hints round up to a full second, never to zero. + assert_eq!( + classify_submit_error(&limited(Some(1))), + RetryAction::Backoff { seconds: 1 }, + ); + // No guidance -> plain next-block retry. + assert_eq!( + classify_submit_error(&limited(None)), + RetryAction::TryNextBlock + ); + } + + #[test] + fn submit_error_transient_shapes_stay_try_next_block() { + assert_eq!( + classify_submit_error(&CowApiError::Fault(Fault::Timeout)), + RetryAction::TryNextBlock, + ); + assert_eq!( + classify_submit_error(&CowApiError::Http(HttpFailure { + status: 502, + body: None, + })), + RetryAction::TryNextBlock, + ); + } + #[test] fn fault_case_recovers_embedded_fault_and_label() { let err = CowApiError::Fault(Fault::Timeout); diff --git a/crates/shepherd-sdk/src/cow/materialise.rs b/crates/shepherd-sdk/src/cow/materialise.rs new file mode 100644 index 00000000..509a5bf6 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/materialise.rs @@ -0,0 +1,176 @@ +//! Watch materialisation: the poll-loop composition conditional- +//! commitment modules share. +//! +//! [`materialise`] walks the chassis watch set, polls each gate-ready +//! watch through a [`ConditionalSource`], and runs the +//! [`PollOutcome`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Ready` drives one submission through the +//! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` +//! journal as the idempotency guard and the chassis [`RetryLedger`] +//! as the failure dispatch. +//! +//! Store faults abort the sweep (the next tick replays it); +//! submission failures never do - they classify into a +//! [`RetryAction`], the ledger applies the effect, and the sweep +//! moves on. Diagnostics go through the guest `tracing` facade - +//! the same channel strategy code logs on - so module tests observe +//! the composed behaviour with one capture. + +use alloy_primitives::{Address, Bytes}; +use cowprotocol::{GPv2OrderData, OrderCreation, Signature}; +use nexum_sdk::chassis::{ + ConditionalSource, Gates, Journal, RetryAction, RetryLedger, Tick, WatchRef, WatchSet, +}; +use nexum_sdk::host::Fault; + +use super::{ + CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, + is_already_submitted, order_uid_hex, +}; + +/// Poll every gate-ready watch once at `tick` and run each outcome's +/// effect. One source poll per ready watch; a `Ready` outcome makes at +/// most one `submit_order` call. +pub fn materialise(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> +where + H: CowHost, + S: ConditionalSource, +{ + let watches = WatchSet::new(host); + let gates = Gates::new(host); + for key in watches.list()? { + let Some(watch) = WatchRef::parse(&key) else { + continue; + }; + if !gates.is_ready(watch, tick.block, tick.epoch_s)? { + continue; + } + let Some(params) = watches.get(watch)? else { + continue; + }; + match source.poll(host, watch, ¶ms, tick) { + PollOutcome::Ready { order, signature } => { + submit_ready(host, watch, &order, signature, tick, source.label())?; + } + PollOutcome::TryNextBlock => {} + PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, + PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, + PollOutcome::DontTryAgain => watches.remove(watch)?, + } + } + Ok(()) +} + +/// Submit one freshly-polled `Ready` order, guarding on the +/// `submitted:` journal and dispatching any failure through the retry +/// ledger. +/// +/// The UID is deterministic from on-chain inputs, so the idempotency +/// check runs before any network work; the same value keys the journal +/// marker after, so the read and write paths agree. +fn submit_ready( + host: &H, + watch: WatchRef<'_>, + order: &GPv2OrderData, + signature: Bytes, + tick: &Tick, + label: &str, +) -> Result<(), Fault> { + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carries an unparseable owner; skipping submit", + watch.key(), + ); + return Ok(()); + }; + + let journal = Journal::submitted(host); + let client_uid = order_uid_hex(tick.chain_id, order, owner); + if let Some(uid) = client_uid.as_deref() + && journal.contains(uid)? + { + tracing::info!("{label} {uid} already submitted; skipping re-submit"); + return Ok(()); + } + + let creation = match build_order_creation(order, signature, owner) { + Ok(creation) => creation, + Err(message) => { + tracing::warn!("{label} submit skipped for {owner:#x}: {message}"); + return Ok(()); + } + }; + let body = match serde_json::to_vec(&creation) { + Ok(body) => body, + Err(e) => { + tracing::error!("OrderCreation JSON encode failed: {e}"); + return Ok(()); + } + }; + + match host.submit_order(tick.chain_id, &body) { + Ok(server_uid) => { + // Prefer the client-computed UID so the guard above reads + // what this writes; a divergence would be a protocol bug + // worth a warning, never a silently split keyspace. + let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + journal.record(marker)?; + if let Some(client) = client_uid.as_deref() + && client != server_uid + { + tracing::warn!( + "{label} UID divergence: client={client} server={server_uid} \ + (marker keyed on the client UID)" + ); + } + tracing::info!("submitted {marker}"); + } + Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { + // Success wearing an error status: the orderbook already + // holds this order. Record the receipt and keep the watch + // so the next tick short-circuits instead of re-posting. + if let Some(uid) = client_uid.as_deref() { + journal.record(uid)?; + } + tracing::info!( + "orderbook already holds this order ({}); receipt recorded", + rejection.error_type, + ); + } + Err(err) => { + let action = classify_submit_error(&err); + RetryLedger::new(host).apply(watch, action, tick.epoch_s)?; + match action { + RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {err}"), + RetryAction::Backoff { seconds } => { + tracing::warn!("submit backoff {seconds}s: {err}"); + } + RetryAction::Drop => tracing::warn!("submit dropped watch: {err}"), + // `RetryAction` is non-exhaustive; the ledger already + // ran the effect, so the log needs only the name. + other => { + let action_label: &'static str = other.into(); + tracing::warn!("submit retry action {action_label}: {err}"); + } + } + } + } + Ok(()) +} + +/// Assemble the `OrderCreation` body the orderbook expects from a +/// polled conditional order. The signed `appData` digest goes out +/// verbatim in the hash-only wire shape (watch-tower parity), and the +/// signature is EIP-1271 - the conditional-order contract is the +/// verifier. +fn build_order_creation( + order: &GPv2OrderData, + signature: Bytes, + from: Address, +) -> Result { + let order_data = gpv2_to_order_data(order) + .ok_or_else(|| "GPv2OrderData carried an unknown enum marker".to_string())?; + let signature = Signature::Eip1271(signature.to_vec()); + OrderCreation::new_app_data_hash_only(&order_data, signature, from, None) + .map_err(|e| e.to_string()) +} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 43acb866..48f1d5e8 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,22 +3,27 @@ //! Type conversions and ABI decoding helpers that translate between //! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, //! orderbook JSON) and the typed Rust surface (`OrderData`, -//! `PollOutcome`, `RetryAction`). +//! `PollOutcome`, `RetryAction`), plus [`materialise()`] - the +//! poll/submit composition over the chassis stores. //! -//! Each submodule stays purely host-neutral: helpers take primitive -//! arguments (`&[u8]`, `Option<&str>`, slices) so they can be unit- -//! tested without wit-bindgen scaffolding and re-used unchanged by -//! TWAP, EthFlow, and future strategy modules. +//! The codec submodules stay purely host-neutral: helpers take +//! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can +//! be unit-tested without wit-bindgen scaffolding and re-used +//! unchanged by TWAP, EthFlow, and future strategy modules. The +//! materialiser is generic over the host traits alone. -pub mod app_data; pub mod composable; pub mod error; +pub mod materialise; pub mod order; -pub use app_data::resolve_app_data; -pub use composable::{IConditionalOrder, PollOutcome, decode_revert}; -pub use error::{CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error}; -pub use order::gpv2_to_order_data; +pub use composable::{IConditionalOrder, PollOutcome, classify_poll_error, decode_revert}; +pub use error::{ + CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, + classify_submit_error, is_already_submitted, +}; +pub use materialise::materialise; +pub use order::{gpv2_to_order_data, order_uid_hex}; use nexum_sdk::host::Host; @@ -35,7 +40,7 @@ pub trait CowApiHost { /// given chain. The host routes to the correct base URL /// (`https://api.cow.fi//api/v1/...`). Returns the raw /// response body. Strategies that need a typed surface should - /// wrap this in an SDK helper (see [`resolve_app_data`]). + /// wrap this in an SDK helper. /// /// `method` is `"GET" | "POST" | "PUT" | "DELETE"`. /// `path` is the absolute orderbook path beginning with `/api/v1`. diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d983d649..5bcef8fb 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -7,7 +7,9 @@ //! into Rust enums. [`gpv2_to_order_data`] is the bridge. use alloy_primitives::Address; -use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource}; +use cowprotocol::{ + BuyTokenDestination, Chain, GPv2OrderData, OrderData, OrderKind, SellTokenSource, +}; /// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the /// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` @@ -71,6 +73,23 @@ pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { }) } +/// Orderbook UID hex (`0x` + 112 hex chars) for the given on-chain +/// (order, owner, chain) tuple - the same value the orderbook derives +/// server-side from the signed payload, so a client can key +/// idempotency state before any network work. +/// +/// `None` when the chain id has no settlement domain or the order +/// carries an unknown enum marker; both also stop the submit path +/// downstream, so callers fall through and let it surface the +/// diagnostic. +#[must_use] +pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { + let chain = Chain::try_from(chain_id).ok()?; + let domain = chain.settlement_domain(); + let order_data = gpv2_to_order_data(order)?; + Some(format!("{}", order_data.uid(&domain, owner))) +} + #[cfg(test)] mod tests { use super::*; @@ -137,4 +156,34 @@ mod tests { g.buyTokenBalance = B256::repeat_byte(0x55); assert!(gpv2_to_order_data(&g).is_none()); } + + // ---- order_uid_hex ---- + + const SEPOLIA: u64 = 11_155_111; + + #[test] + fn uid_hex_is_deterministic_and_canonical_shape() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + let uid = order_uid_hex(SEPOLIA, &g, owner).expect("supported chain, known markers"); + // 56 bytes: 32 digest + 20 owner + 4 validTo. + assert_eq!(uid.len(), 2 + 112); + assert!(uid.starts_with("0x")); + assert!( + uid.to_lowercase() + .contains("00112233445566778899aabbccddeeff00112233",) + ); + assert_eq!(order_uid_hex(SEPOLIA, &g, owner).unwrap(), uid); + } + + #[test] + fn uid_hex_none_on_unsupported_chain_or_unknown_marker() { + let g = submittable_gpv2(); + let owner = address!("00112233445566778899aabbccddeeff00112233"); + assert!(order_uid_hex(u64::MAX, &g, owner).is_none()); + + let mut bad = submittable_gpv2(); + bad.kind = B256::repeat_byte(0x42); + assert!(order_uid_hex(SEPOLIA, &bad, owner).is_none()); + } } diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 577a6655..0ec13501 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -17,9 +17,10 @@ //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), //! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), appData resolution -//! ([`resolve_app_data`]), and the [`RetryAction`] classifier -//! driving submit-failure dispatch. +//! [`decode_revert`]), the classifiers mapping submit failures into +//! the chassis [`RetryAction`], and [`materialise`] - the poll -> +//! outcome -> gate/journal/submit composition over the chassis +//! stores. //! //! - [`bind_cow_host_via_wit_bindgen!`](bind_cow_host_via_wit_bindgen) - //! the CoW layering of `nexum_sdk::bind_host_via_wit_bindgen!`: @@ -50,8 +51,8 @@ //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data //! [`PollOutcome`]: cow::PollOutcome //! [`decode_revert`]: cow::decode_revert -//! [`resolve_app_data`]: cow::resolve_app_data //! [`RetryAction`]: cow::RetryAction +//! [`materialise`]: cow::materialise() #![cfg_attr(not(test), warn(unused_crate_dependencies))] #![warn(missing_docs)] diff --git a/crates/shepherd-sdk/tests/materialise.rs b/crates/shepherd-sdk/tests/materialise.rs new file mode 100644 index 00000000..16e7697b --- /dev/null +++ b/crates/shepherd-sdk/tests/materialise.rs @@ -0,0 +1,424 @@ +//! Materialiser acceptance tests against the composed +//! `shepherd_sdk_test::MockHost`. These live as an integration test +//! (not `#[cfg(test)]`) because the mock crate links `shepherd-sdk` +//! externally, and the external and unit-test copies of the traits +//! are distinct types. + +use std::cell::Cell; + +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; +use nexum_sdk::chassis::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; +use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; +use nexum_sdk_test::capture_tracing; +use shepherd_sdk::cow::{CowApiError, OrderRejection, PollOutcome, materialise, order_uid_hex}; +use shepherd_sdk_test::MockHost; + +const SEPOLIA: u64 = 11_155_111; + +/// Closure-backed source so each test scripts its own outcome and +/// observes its own poll calls. +struct FnSource(F); + +impl ConditionalSource for FnSource +where + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + (self.0)(host, watch, params, tick) + } +} + +/// Pin the closure to the higher-ranked source signature at the +/// construction site so inference never guesses a too-narrow lifetime. +fn src(f: F) -> FnSource +where + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, +{ + FnSource(f) +} + +fn sample_owner() -> Address { + address!("00112233445566778899aabbccddeeff00112233") +} + +fn sample_hash() -> B256 { + keccak256(b"conditional order params") +} + +fn sample_tick() -> Tick { + Tick { + chain_id: SEPOLIA, + block: 1_000, + epoch_s: 1_700_000_000, + } +} + +/// `validTo` a given number of seconds from now. The `OrderCreation` +/// constructor's client-side max-horizon policy reads the wall clock +/// (not the block clock), so test orders must expire relative to it. +fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") +} + +fn submittable_order() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: Address::ZERO, + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: valid_to_in(3_600), + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } +} + +fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { + PollOutcome::Ready { + order: Box::new(order.clone()), + signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + } +} + +fn seed_watch(host: &MockHost) -> String { + WatchSet::new(host) + .put(&sample_owner(), &sample_hash(), b"params") + .unwrap() +} + +fn client_uid(order: &GPv2OrderData) -> String { + order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +} + +// ---- lifecycle outcomes ---- + +#[test] +fn try_next_block_leaves_the_store_untouched() { + let host = MockHost::new(); + seed_watch(&host); + let before = host.store.snapshot(); + + materialise( + &host, + &src(|_, _, _, _| PollOutcome::TryNextBlock), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.store.snapshot(), before); + assert_eq!(host.cow_api.call_count(), 0); +} + +#[test] +fn try_on_block_sets_the_block_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + materialise( + &host, + &src(|_, _, _, _| PollOutcome::TryOnBlock(2_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_block_key()).unwrap(), + &2_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn try_at_epoch_sets_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + + materialise( + &host, + &src(|_, _, _, _| PollOutcome::TryAtEpoch(1_800_000_000)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + host.store.snapshot().get(&watch.next_epoch_key()).unwrap(), + &1_800_000_000_u64.to_le_bytes().to_vec(), + ); +} + +#[test] +fn dont_try_again_removes_the_watch_and_its_gates() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + Gates::new(&host).set_next_block(watch, 1).unwrap(); + + materialise( + &host, + &src(|_, _, _, _| PollOutcome::DontTryAgain), + &sample_tick(), + ) + .unwrap(); + + assert!(host.store.is_empty(), "watch and gates must go"); +} + +// ---- gating and skipping ---- + +#[test] +fn gated_watch_is_not_polled() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 5_000) + .unwrap(); + let polls = Cell::new(0_u32); + + materialise( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0, "a gated watch must not reach the source"); +} + +#[test] +fn malformed_watch_rows_are_skipped() { + let host = MockHost::new(); + host.store.set("watch:no-separator", b"junk").unwrap(); + let polls = Cell::new(0_u32); + + materialise( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + PollOutcome::TryNextBlock + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 0); +} + +// ---- ready -> submission ---- + +#[test] +fn ready_submits_once_and_journals_the_client_uid() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok(client_uid(&order))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + materialise(&host, &source, &sample_tick()).unwrap(); + + assert_eq!(host.cow_api.call_count(), 1); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "submitted:{{client_uid}} receipt must be recorded", + ); + assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); +} + +#[test] +fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xfeedface".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + let (result, logs) = capture_tracing(|| materialise(&host, &source, &sample_tick())); + result.unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!( + !snapshot.contains_key("submitted:0xfeedface"), + "marker must key on the client UID, not the divergent server UID", + ); + assert!(logs.any(|e| e.message.contains("UID divergence"))); +} + +#[test] +fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + Journal::submitted(&host) + .record(&client_uid(&order)) + .unwrap(); + let polls = Cell::new(0_u32); + + materialise( + &host, + &src(|_, _, _, _| { + polls.set(polls.get() + 1); + ready_outcome(&order) + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(polls.get(), 1, "the source is still consulted"); + assert_eq!( + host.cow_api.call_count(), + 0, + "the journal guard must short-circuit before any network work", + ); +} + +#[test] +fn ready_with_unknown_marker_skips_submit_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let mut order = submittable_order(); + order.kind = B256::repeat_byte(0x42); + + materialise( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert_eq!(host.cow_api.call_count(), 0); + assert!(host.store.snapshot().contains_key(&key)); +} + +// ---- submission failure dispatch ---- + +fn rejection(error_type: &str) -> CowApiError { + CowApiError::Rejected(OrderRejection { + status: 400, + error_type: error_type.into(), + description: "test".into(), + data: None, + }) +} + +#[test] +fn transient_rejection_keeps_the_watch_ungated() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch_key = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InsufficientFee"))); + + materialise( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key)); + assert!(!snapshot.contains_key(&watch_key.next_block_key())); + assert!(!snapshot.contains_key(&watch_key.next_epoch_key())); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} + +#[test] +fn permanent_rejection_drops_the_watch_through_the_ledger() { + let host = MockHost::new(); + let key = seed_watch(&host); + Gates::new(&host) + .set_next_block(WatchRef::parse(&key).unwrap(), 1) + .unwrap(); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("InvalidSignature"))); + + materialise( + &host, + &src(move |_, _, _, _| ready_outcome(&order)), + &sample_tick(), + ) + .unwrap(); + + assert!( + host.store.is_empty(), + "a permanent rejection must drop the watch and its gates", + ); +} + +/// The orderbook already holds the order: the receipt is recorded, the +/// watch survives, and the next tick short-circuits on the journal +/// instead of re-posting. +#[test] +fn duplicated_order_records_the_receipt_and_keeps_the_watch() { + let host = MockHost::new(); + let key = seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Err(rejection("DuplicatedOrder"))); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + materialise(&host, &source, &sample_tick()).unwrap(); + + assert!(host.store.snapshot().contains_key(&key)); + assert!( + Journal::submitted(&host) + .contains(&client_uid(&order)) + .unwrap(), + "already-submitted must record the receipt", + ); + + // The next tick must not touch the orderbook again. + materialise(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); +} + +/// A rate-limit fault with server guidance backs the watch off on the +/// epoch clock - `RetryAction::Backoff` reached through the ledger. +#[test] +fn rate_limited_submit_backs_off_through_the_epoch_gate() { + let host = MockHost::new(); + let key = seed_watch(&host); + let watch = WatchRef::parse(&key).unwrap(); + let order = submittable_order(); + host.cow_api + .respond(Err(CowApiError::Fault(Fault::RateLimited(RateLimit { + retry_after_ms: Some(2_500), + })))); + + let tick = sample_tick(); + materialise(&host, &src(move |_, _, _, _| ready_outcome(&order)), &tick).unwrap(); + + let snapshot = host.store.snapshot(); + assert!(snapshot.contains_key(&key), "backoff must keep the watch"); + assert_eq!( + snapshot.get(&watch.next_epoch_key()).unwrap(), + &(tick.epoch_s + 3).to_le_bytes().to_vec(), + "2500ms rounds up to a 3s backoff from the tick clock", + ); + assert!(!snapshot.keys().any(|k| k.starts_with("submitted:"))); +} diff --git a/docs/00-overview.md b/docs/00-overview.md index 3b13ea89..11e3c0fb 100755 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -276,7 +276,7 @@ The SDK ships as two crate pairs: `nexum-sdk`, the generic module-author SDK (ho | | `tracing` + `bind_host_via_wit_bindgen!` - guest tracing facade and the per-module adapter macro | | | `prelude::*` - alloy primitives in one import | | `shepherd-sdk` | `cow::{CowApiHost, CowHost}` - the cow-api trait and orderbook host bound | -| | `cow::{order, composable, error, app_data}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`, `resolve_app_data`) | +| | `cow::{order, composable, error}` - CoW Protocol bridging (`gpv2_to_order_data`, `PollOutcome`, `decode_revert_hex`, `RetryAction`, `classify_api_error`) | | | `bind_cow_host_via_wit_bindgen!` - the CoW layering of the generic adapter macro | | | `prelude::*` - cowprotocol order / signing / orderbook surface in one import | | `nexum-sdk-test` | `MockHost` + per-trait `MockChain` / `MockLocalStore` / `MockLogging` + `capture_tracing` for native-Rust strategy tests | diff --git a/docs/08-platform-generalisation.md b/docs/08-platform-generalisation.md index fdd4926b..d0391e04 100755 --- a/docs/08-platform-generalisation.md +++ b/docs/08-platform-generalisation.md @@ -987,7 +987,7 @@ graph TD - **`nexum-sdk` (shipped)** - the universal Rust SDK for any module targeting `nexum:host/event-module`. It ships the host-trait seam (`ChainHost`, `LocalStoreHost`, `LoggingHost`, supertrait `Host`), `Fault` / `HostFault` / `ChainError`, the `bind_host_via_wit_bindgen!` adapter macro, chain / config / address helpers, the `http::fetch` helper over wasi:http, and the guest tracing facade. Would additionally provide `HostTransport` (alloy `Transport` trait over `chain::request` / `chain::request-batch`), `provider(chain_id)`, `TypedState` (serde over `local-store`), `RemoteStore` (typed wrapper over `remote-store`), `Messaging` (typed wrapper over `messaging`), `Signer` (typed wrapper over `identity`). Any module author - CoW, DeFi, gaming, whatever - uses this. -- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, `resolve_app_data`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. +- **`shepherd-sdk` (shipped)** - the CoW-domain layer: the `CowApiHost` trait and `CowHost` bound, CoW helpers (`PollOutcome`, `RetryAction`, `gpv2_to_order_data`, `decode_revert_hex`, …), and the `bind_cow_host_via_wit_bindgen!` macro layering the generic adapter. In the 0.3+ target, it would extend `nexum-sdk` with the typed `Cow` client and the `#[shepherd::module]` proc macro. A module author building a generic blockchain automation module depends only on `nexum-sdk`; a CoW Protocol module depends on both `nexum-sdk` and `shepherd-sdk` and imports each directly. diff --git a/docs/operations/load-testnet-runbook.md b/docs/operations/load-testnet-runbook.md index 3258c2fa..717f74b4 100644 --- a/docs/operations/load-testnet-runbook.md +++ b/docs/operations/load-testnet-runbook.md @@ -99,13 +99,10 @@ transactions into each block, not to mimic mainnet block times. ### Mock orderbook (port 9999) -`tools/orderbook-mock` serves the two endpoints shepherd's `cow-api` +`tools/orderbook-mock` serves the one endpoint shepherd's `cow-api` host backend hits per submission: - `POST /api/v1/orders` - returns a synthetic 56-byte OrderUid. -- `GET /api/v1/app_data/{hash}` - returns the empty appData document - so `resolve_app_data` is satisfied without a real - registry. Knobs (set via env in `scripts/load-bootstrap.sh` if needed): diff --git a/justfile b/justfile index 4d529b96..6e73b344 100644 --- a/justfile +++ b/justfile @@ -87,5 +87,5 @@ ci: cargo build --release --target wasm32-wasip2 \ -p example -p twap-monitor -p ethflow-watcher -p price-alert \ -p balance-tracker -p stop-loss -p http-probe \ - -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb + -p clock-reader -p flaky-bomb -p fuel-bomb -p memory-bomb -p panic-bomb cargo test --workspace --all-features --no-fail-fast diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index dac47c79..f34c506b 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -15,7 +15,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false } diff --git a/modules/ethflow-watcher/src/strategy.rs b/modules/ethflow-watcher/src/strategy.rs index 865068a6..e5b2b472 100644 --- a/modules/ethflow-watcher/src/strategy.rs +++ b/modules/ethflow-watcher/src/strategy.rs @@ -27,10 +27,11 @@ //! 2. Compute the orderbook UID from the on-chain order shape //! (`OrderData::uid(domain, contract)`). //! 3. GET `/api/v1/orders/{uid}` to confirm the orderbook indexer -//! picked up the placement. On 200, mark `observed:{uid}` so log -//! re-delivery is a no-op. On 404, log at Info - typical indexer -//! lag, do not write the marker so the next re-delivery rechecks. -//! Any other error is logged at Warn for operator follow-up. +//! picked up the placement. On 200, record `observed:{uid}` in the +//! chassis idempotency journal so log re-delivery is a no-op. On +//! 404, log at Info - typical indexer lag, do not write the marker +//! so the next re-delivery rechecks. Any other error is logged at +//! Warn for operator follow-up. use alloy_primitives::{Address, Bytes}; use alloy_sol_types::SolEvent; @@ -38,6 +39,7 @@ use cowprotocol::{ Chain, CoWSwapOnchainOrders::OrderPlacement, ETH_FLOW_PRODUCTION, ETH_FLOW_STAGING, GPv2OrderData, OnchainSignature, OrderUid, }; +use nexum_sdk::chassis::Journal; use nexum_sdk::events::Log; use nexum_sdk::host::Fault; use shepherd_sdk::cow::{CowApiError, CowHost, gpv2_to_order_data}; @@ -126,14 +128,15 @@ fn observe_placement( // Idempotency: once verified, do not re-check on log re-delivery // (engine restart, reorg replay, supervisor restart). - if host.get(&format!("observed:{uid_hex}"))?.is_some() { + let journal = Journal::observed(host); + if journal.contains(&uid_hex)? { return Ok(()); } let path = format!("/api/v1/orders/{uid_hex}"); match host.cow_api_request(chain_id, "GET", &path, None) { Ok(_) => { - host.set(&format!("observed:{uid_hex}"), b"")?; + journal.record(&uid_hex)?; tracing::info!( "ethflow observed {uid_hex} (orderbook indexed, sender={:#x})", placement.sender, @@ -175,7 +178,7 @@ fn compute_uid(chain_id: u64, placement: &DecodedPlacement) -> Option #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, hex}; + use alloy_primitives::{U256, address, b256, hex}; use alloy_sol_types::SolValue; use cowprotocol::{BuyTokenDestination, OnchainSigningScheme, OrderKind, SellTokenSource}; use nexum_sdk::Level; @@ -491,4 +494,73 @@ mod tests { "submit_order count must stay at zero - ethflow-watcher is observer-only" ); } + + /// Guard: the topic-0 hardcoded in `module.toml` matches the + /// keccak256 of the canonical `OrderPlacement` signature. + /// A typo or ABI drift would silently miss every EthFlow event. + #[test] + fn topic0_matches_order_placement_canonical_signature() { + assert_eq!( + OrderPlacement::SIGNATURE_HASH, + b256!("cf5f9de2984132265203b5c335b25727702ca77262ff622e136baa7362bf1da9"), + "module.toml event_signature must equal keccak256 of the canonical ABI signature", + ); + } + + /// 429 (rate-limit) from the orderbook check → Warn log + no marker. + /// Verifies the strategy does not conflate 429 with 404 (which would + /// suppress the warning) and does not panic or return an error. + #[test] + fn placement_log_warns_on_429_rate_limit() { + let host = MockHost::new(); + let event = sample_event(); + let (topics, data) = encode_log(&event); + let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let placement = decode_order_placement(&log).unwrap(); + let uid = computed_uid(&placement); + + host.cow_api + .respond_to_request(Err(CowApiError::Http(HttpFailure { + status: 429, + body: Some("Too Many Requests".to_string()), + }))); + + let (result, logs) = capture_tracing(|| on_chain_logs(&host, SEPOLIA, &[log])); + result.unwrap(); + + assert!( + !host + .store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "429 must NOT write observed: marker" + ); + logs.expect_one(|e| e.level == Level::WARN && e.message.contains("indexer check failed")); + } + + /// HTTP 200 with a malformed (non-JSON) body → `observed:{uid}` still + /// written. The strategy only inspects Ok vs Err, never parses the body, + /// so any successful response confirms indexer pickup regardless of body + /// content. + #[test] + fn placement_log_marks_observed_on_malformed_response_body() { + let host = MockHost::new(); + let event = sample_event(); + let (topics, data) = encode_log(&event); + let log = make_log(ETH_FLOW_PRODUCTION.as_slice(), &topics, &data); + let placement = decode_order_placement(&log).unwrap(); + let uid = computed_uid(&placement); + + host.cow_api + .respond_to_request(Ok("not-valid-json{{{{".to_string())); + + on_chain_logs(&host, SEPOLIA, &[log]).unwrap(); + + assert!( + host.store + .snapshot() + .contains_key(&format!("observed:{uid}")), + "200 with malformed body must still write observed: — strategy does not parse the response", + ); + } } diff --git a/modules/examples/stop-loss/Cargo.toml b/modules/examples/stop-loss/Cargo.toml index 3e6d347a..54940395 100644 --- a/modules/examples/stop-loss/Cargo.toml +++ b/modules/examples/stop-loss/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../../crates/nexum-sdk" } shepherd-sdk = { path = "../../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } serde_json = { version = "1", default-features = false, features = ["alloc"] } tracing = { version = "0.1", default-features = false } diff --git a/modules/examples/stop-loss/src/strategy.rs b/modules/examples/stop-loss/src/strategy.rs index 3f97ca3b..bcec815b 100644 --- a/modules/examples/stop-loss/src/strategy.rs +++ b/modules/examples/stop-loss/src/strategy.rs @@ -166,7 +166,7 @@ fn build_creation(chain_id: u64, settings: &Settings) -> Result<(OrderCreation, Fault::InvalidInput("GPv2OrderData carried an unknown enum marker".into()) })?; let uid = order_data.uid(&domain, settings.owner); - let creation = OrderCreation::from_signed_order_data( + let creation = OrderCreation::new( &order_data, Signature::PreSign, settings.owner, diff --git a/modules/fixtures/clock-reader/Cargo.toml b/modules/fixtures/clock-reader/Cargo.toml new file mode 100644 index 00000000..b2196f7b --- /dev/null +++ b/modules/fixtures/clock-reader/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "clock-reader" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Test fixture: on every event reads the WASI wall clock through std and logs it. Lets a test assert the guest observes a WasiClockOverride end to end." + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } diff --git a/modules/fixtures/clock-reader/module.toml b/modules/fixtures/clock-reader/module.toml new file mode 100644 index 00000000..23f9fa3f --- /dev/null +++ b/modules/fixtures/clock-reader/module.toml @@ -0,0 +1,21 @@ +# clock-reader test fixture. Subscribes to a single chain's blocks so the +# supervisor invokes `on_event` once per block; the handler reads the WASI +# wall clock through std and logs it. The integration test boots this +# fixture under a pinned clock override and asserts the logged time matches +# the override, proving the guest observes virtualized time end to end. + +[module] +name = "clock-reader" +version = "0.1.0" +component = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + +[capabilities] +required = ["logging"] +optional = [] + +[capabilities.http] +allow = [] + +[[subscription]] +kind = "block" +chain_id = 1 diff --git a/modules/fixtures/clock-reader/src/lib.rs b/modules/fixtures/clock-reader/src/lib.rs new file mode 100644 index 00000000..d2d7a54b --- /dev/null +++ b/modules/fixtures/clock-reader/src/lib.rs @@ -0,0 +1,48 @@ +//! # clock-reader (test fixture) +//! +//! On every event reads `std::time::SystemTime::now()` and logs the wall +//! time as whole seconds since the Unix epoch. Under `wasm32-wasip2` that +//! read routes to `wasi:clocks/wall-clock`, which the supervisor +//! virtualizes per store, so a test that boots this fixture under a pinned +//! clock override can assert from the log line that the guest observed the +//! overridden time rather than the ambient host clock. +//! +//! Not a production module. Lives under `modules/fixtures/` so it is +//! obviously test-only and never gets loaded by the testnet configs. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![allow(clippy::too_many_arguments)] + +use std::time::{SystemTime, UNIX_EPOCH}; + +wit_bindgen::generate!({ + path: "../../../wit/nexum-host", + world: "nexum:host/event-module", +}); + +use nexum::host::{logging, types}; + +struct ClockReader; + +impl Guest for ClockReader { + fn init(_config: Vec<(String, String)>) -> Result<(), Fault> { + // Minimal SDK-free fixture: no tracing subscriber is installed, + // so log through the raw host binding directly. + logging::log(logging::Level::Info, "clock-reader init"); + Ok(()) + } + + fn on_event(_event: types::Event) -> Result<(), Fault> { + // Whole seconds since the epoch is parseable and stable: the + // override pins wall time to an exact instant, so the guest reads + // that instant back rather than the ambient host clock. + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + logging::log(logging::Level::Info, &format!("clock wall {secs}")); + Ok(()) + } +} + +export!(ClockReader); diff --git a/modules/twap-monitor/Cargo.toml b/modules/twap-monitor/Cargo.toml index 44b90891..37bb304c 100644 --- a/modules/twap-monitor/Cargo.toml +++ b/modules/twap-monitor/Cargo.toml @@ -11,15 +11,13 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { path = "../../crates/nexum-sdk" } shepherd-sdk = { path = "../../crates/shepherd-sdk" } -cowprotocol = { version = "0.1.0", default-features = false } +cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } -serde_json = { version = "1", default-features = false, features = ["alloc"] } -strum = { version = "0.28", default-features = false, features = ["derive"] } -thiserror = "2" tracing = { version = "0.1", default-features = false } wit-bindgen = { version = "0.58", default-features = false, features = ["macros", "realloc"] } [dev-dependencies] +serde_json = { version = "1", default-features = false, features = ["alloc"] } shepherd-sdk-test = { path = "../../crates/shepherd-sdk-test" } nexum-sdk-test = { path = "../../crates/nexum-sdk-test" } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index c840d737..6c2c5113 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -7,20 +7,23 @@ //! imports and hands it to [`on_chain_logs`] / [`on_block`]; tests under //! `#[cfg(test)]` hand the same functions a //! `shepherd_sdk_test::MockHost`. +//! +//! The module owns decode and evaluate only: log decoding into the +//! chassis watch set, and the `getTradeableOrderWithSignature` poll +//! behind [`ConditionalSource`]. Gate discipline, the `submitted:` +//! journal, submission, and retry dispatch live in the shared +//! composition (`shepherd_sdk::cow::materialise`). -use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_primitives::{Address, Bytes, keccak256}; use alloy_sol_types::{SolCall, SolEvent, SolValue}; use cowprotocol::{ - COMPOSABLE_COW, Chain, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, - GPv2OrderData, OrderCreation, Signature, + COMPOSABLE_COW, ComposableCoW::ConditionalOrderCreated, ConditionalOrderParams, GPv2OrderData, }; use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; +use nexum_sdk::chassis::{ConditionalSource, Tick, WatchRef, WatchSet}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; -use shepherd_sdk::cow::{ - CowApiError, CowHost, PollOutcome, RetryAction, classify_api_error, decode_revert, - gpv2_to_order_data, -}; +use shepherd_sdk::cow::{CowHost, PollOutcome, classify_poll_error, materialise}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -66,9 +69,16 @@ pub fn on_chain_logs(host: &H, logs: &[Log]) -> Result<(), Fault> { Ok(()) } -/// Poll entry: scan every persisted watch and dispatch ready tranches. +/// Poll entry: materialise every gate-ready watch through the chassis +/// composition. The block timestamp arrives in milliseconds; the tick +/// carries Unix seconds. pub fn on_block(host: &H, block: BlockInfo) -> Result<(), Fault> { - poll_all_watches(host, &block) + let tick = Tick { + chain_id: block.chain_id, + block: block.number, + epoch_s: block.timestamp / 1000, + }; + materialise(host, &TwapSource, &tick) } // ---- indexing path ---- @@ -78,64 +88,51 @@ fn decode_conditional_order_created(log: &Log) -> Option<(Address, ConditionalOr Some((decoded.data.owner, decoded.data.params)) } -/// `set` overwrites in place, so re-indexing the same log (re-org -/// replay, overlapping subscription windows) produces no observable -/// side effect. +/// The watch set overwrites in place, so re-indexing the same log +/// (re-org replay, overlapping subscription windows) produces no +/// observable side effect. fn persist_watch( host: &H, owner: Address, params: &ConditionalOrderParams, ) -> Result<(), Fault> { let encoded = params.abi_encode(); - let params_hash = keccak256(&encoded); - let key = watch_key(&owner, ¶ms_hash); - host.set(&key, &encoded)?; + let key = WatchSet::new(host).put(&owner, &keccak256(&encoded), &encoded)?; tracing::info!("indexed {key}"); Ok(()) } // ---- poll path ---- -fn poll_all_watches(host: &H, block: &BlockInfo) -> Result<(), Fault> { - let now_epoch_s = block.timestamp / 1000; - let keys = host.list_keys("watch:")?; - for key in keys { - let Some((owner_hex, hash_hex)) = parse_watch_key(&key) else { - continue; - }; - if !is_ready(host, owner_hex, hash_hex, block.number, now_epoch_s)? { - continue; - } - let Some(value) = host.get(&key)? else { - continue; - }; - let Ok(params) = ConditionalOrderParams::abi_decode(&value) else { - tracing::warn!("watch {key} carried unparseable params; skipping"); - continue; +/// TWAP conditional source: decode the stored `ConditionalOrderParams` +/// and evaluate `getTradeableOrderWithSignature` on chain. A row this +/// source cannot decode polls again next block rather than tearing +/// down the sweep. +struct TwapSource; + +impl ConditionalSource for TwapSource { + type Outcome = PollOutcome; + + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + let Ok(params) = ConditionalOrderParams::abi_decode(params) else { + tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); + return PollOutcome::TryNextBlock; }; - let Ok(owner) = owner_hex.parse::
() else { - continue; + let Ok(owner) = watch.owner_hex().parse::
() else { + tracing::warn!( + "watch {} carried an unparseable owner; skipping", + watch.key() + ); + return PollOutcome::TryNextBlock; }; - let outcome = poll_one(host, block.chain_id, &owner, ¶ms); - tracing::info!("poll {key} -> {}", outcome_label(&outcome)); - match outcome { - PollOutcome::Ready { order, signature } => { - submit_ready( - host, - block.chain_id, - owner, - &order, - signature, - &key, - now_epoch_s, - )?; - } - non_ready => { - apply_watch_update(host, outcome_to_update(&non_ready), &key)?; - } - } + let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); + tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); + outcome + } + + fn label(&self) -> &'static str { + "twap" } - Ok(()) } fn poll_one( @@ -159,28 +156,14 @@ fn poll_one( Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) .unwrap_or(PollOutcome::TryNextBlock), - // A structured JSON-RPC error (the normal shape for an - // `eth_call` revert): the chain backend has already hex-decoded - // the `error.data` payload, so `decode_revert` dispatches - // `PollTryAtBlock` / `PollTryAtEpoch` / `OrderNotValid` / - // `PollNever` straight off the bytes. A revert the decoder does - // not recognise falls through to the safe `TryNextBlock`. - Err(ChainError::Rpc(rpc)) => rpc - .data - .as_deref() - .and_then(|bytes| decode_revert(bytes)) - .unwrap_or_else(|| { - tracing::warn!( - "eth_call reverted ({}); defaulting to TryNextBlock", - rpc.message - ); - PollOutcome::TryNextBlock - }), - // A transport-level fault (timeout, RPC down, ...): retry on the - // next block. - Err(ChainError::Fault(fault)) => { - tracing::warn!("eth_call failed ({fault}); defaulting to TryNextBlock"); - PollOutcome::TryNextBlock + // `classify_poll_error` is the one policy for what a failed + // poll call means to the watch lifecycle; only a transport + // fault warrants its own diagnostic here. + Err(err) => { + if let ChainError::Fault(fault) = &err { + tracing::warn!("eth_call failed ({fault}); retrying next block"); + } + classify_poll_error(&err) } } } @@ -207,348 +190,51 @@ fn outcome_label(o: &PollOutcome) -> &'static str { } } -// ---- key conventions ---- +// ---- test-only seam mirrors ---- +// +// Thin views over the chassis / SDK canon so the dispatch tests can +// seed and inspect the store in the exact shapes production writes. -/// Render the first 8 bytes of an `appData` hash as `0x12345678…` -/// for log lines. Full 32-byte hex is too noisy for an INFO log; -/// 8 bytes is unique enough to grep against the orderbook. -/// -/// Delegates to [`alloy_primitives::hex::encode`] per mfw78's PR #8 -/// guidance against carrying our own hex formatters. -fn hex_short(bytes: &[u8; 32]) -> String { - format!("0x{}…", alloy_primitives::hex::encode(&bytes[..8])) -} - -fn watch_key(owner: &Address, params_hash: &B256) -> String { - format!("watch:{owner:#x}:{params_hash:#x}") +#[cfg(test)] +fn watch_key(owner: &Address, params_hash: &alloy_primitives::B256) -> String { + WatchSet::::key(owner, params_hash) } +#[cfg(test)] fn parse_watch_key(key: &str) -> Option<(&str, &str)> { - let rest = key.strip_prefix("watch:")?; - let (owner, hash) = rest.split_once(':')?; - Some((owner, hash)) -} - -fn is_ready( - host: &H, - owner_hex: &str, - hash_hex: &str, - block_number: u64, - epoch_s: u64, -) -> Result { - if let Some(next) = read_u64(host, &format!("next_block:{owner_hex}:{hash_hex}"))? - && block_number < next - { - return Ok(false); - } - if let Some(next) = read_u64(host, &format!("next_epoch:{owner_hex}:{hash_hex}"))? - && epoch_s < next - { - return Ok(false); - } - Ok(true) -} - -fn read_u64(host: &H, key: &str) -> Result, Fault> { - let bytes = host.get(key)?; - Ok(bytes - .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) - .map(u64::from_le_bytes)) + let watch = WatchRef::parse(key)?; + Some((watch.owner_hex(), watch.hash_hex())) } -// ---- submission path ---- - -/// `cowprotocol`-side rejection envelope for an `OrderCreation` we -/// failed to assemble. Surfaces in a Warn log; the watch is left in -/// place so the next poll can either re-construct or transition on -/// its own. -/// -/// `IntoStaticStr` exposes each variant as a snake_case `&'static -/// str` so the submission warning log can carry `error_kind = -/// unknown_marker` without a match-ladder in the call site. -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] -#[non_exhaustive] -enum BuildError { - /// `GPv2OrderData` carried a marker (`kind`, balance enum) we don't - /// know how to map. - #[error("GPv2OrderData carried an unknown enum marker")] - UnknownMarker, - /// `cowprotocol` rejected the body - typically - /// `keccak256(app_data) != order.app_data` or `from == - /// Address::ZERO`. - #[error(transparent)] - Cowprotocol(#[from] cowprotocol::Error), -} - -/// Assemble the `OrderCreation` body the orderbook expects from a -/// freshly-polled TWAP tranche. -/// -/// `app_data_json` is the canonical JSON document whose -/// `keccak256` matches `order.appData`. The caller is responsible -/// for resolving it via [`shepherd_sdk::cow::resolve_app_data`] (or -/// any equivalent path); passing a mismatching string makes -/// `OrderCreation::from_signed_order_data` reject with -/// "app_data JSON digest does not match signed app_data hash". -fn build_order_creation( - order: &GPv2OrderData, - signature: Bytes, - from: Address, - app_data_json: String, -) -> Result { - let order_data = gpv2_to_order_data(order).ok_or(BuildError::UnknownMarker)?; - let signature = Signature::Eip1271(signature.to_vec()); - let creation = - OrderCreation::from_signed_order_data(&order_data, signature, from, app_data_json, None)?; - Ok(creation) -} - -fn submit_ready( - host: &H, - chain_id: u64, - owner: Address, - order: &GPv2OrderData, - signature: Bytes, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Short-circuit if the orderbook UID for this exact - // (order, owner, chain) tuple is already in our local-store as - // `submitted:`. The poll-tick can re-fire `Ready` for the same - // TWAP child in successive blocks - `getTradeableOrderWithSignature` - // does not know shepherd already POSTed it - and re-submitting - // wastes an appData GET + submit_order call and emits a - // misleading `DuplicatedOrder` Warn. The UID computation is - // deterministic from on-chain inputs (and matches what the - // orderbook derives server-side from the signed payload), so we - // can check before doing any network work. We also reuse the - // computed value below as the `submitted:{uid}` marker key, so - // the read and write paths agree. - let client_uid_hex = compute_uid_hex(chain_id, order, owner); - if let Some(uid_hex) = client_uid_hex.as_deref() - && host.get(&format!("submitted:{uid_hex}"))?.is_some() - { - tracing::info!("twap {uid_hex} already submitted; skipping poll re-submit"); - return Ok(()); - } - - // CoW Swap UI (and other clients) sign TWAPs with a - // non-empty `appData` hash that points at a JSON document held - // by the orderbook's app_data registry. Hard-coding - // `EMPTY_APP_DATA_JSON` here would produce a body whose - // `keccak256(appDataJson) != order.appData`, and the orderbook - // rejects with "app_data JSON digest does not match signed - // app_data hash". Resolve the document via the orderbook - // mirror; on 404 (orderbook doesn't know the hash) leave the - // watch in place - there is no path to recover without - // operator intervention. - let app_data_json = match shepherd_sdk::cow::resolve_app_data(host, chain_id, &order.appData) { - Ok(json) => json, - Err(CowApiError::Http(http)) if http.status == 404 => { - tracing::warn!( - "twap submit skipped for {owner:#x}: appData hash not mirrored on orderbook ({})", - hex_short(&order.appData.0), - ); - return Ok(()); - } - Err(err) => { - tracing::warn!("twap submit skipped for {owner:#x}: appData resolve failed: {err}",); - return Ok(()); - } - }; - - let creation = match build_order_creation(order, signature, owner, app_data_json) { - Ok(c) => c, - Err(e) => { - tracing::warn!("twap submit skipped for {owner:#x}: {e}"); - return Ok(()); - } - }; - let body = match serde_json::to_vec(&creation) { - Ok(b) => b, - Err(e) => { - tracing::error!("OrderCreation JSON encode failed: {e}"); - return Ok(()); - } - }; - match host.submit_order(chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID for the marker key so the - // idempotency check at the top of `submit_ready` reads what - // we wrote. In production the server-returned - // UID is the same value (both sides derive it from the - // signed `OrderData` via the canonical - // `digest || owner || valid_to` layout); a divergence - // would be a protocol-level bug worth surfacing rather - // than silently splitting the keyspace. - let marker_uid = client_uid_hex.as_deref().unwrap_or(server_uid.as_str()); - let key = format!("submitted:{marker_uid}"); - // Empty marker - presence of the key is the receipt. - host.set(&key, b"")?; - if let Some(client_uid) = client_uid_hex.as_deref() - && client_uid != server_uid - { - tracing::warn!( - "twap UID divergence: client={client_uid} server={server_uid} \ - (marker stored under client UID for idempotency consistency)" - ); - } - tracing::info!("submitted {key}"); - } - Err(err) => { - apply_submit_retry(host, &err, watch_key, now_epoch_s)?; - } - } - Ok(()) -} - -/// Compute the orderbook UID hex (`0x` + 112 hex chars) for the given -/// on-chain (order, owner, chain) tuple, mirroring what `submit_order` -/// will deduce server-side. Used by [`submit_ready`] to short-circuit -/// poll-tick re-submissions of an already-submitted TWAP child. -/// -/// Returns `None` if the chain id is unsupported by `cowprotocol::Chain` -/// or the order carries an unknown enum marker - both cases also stop -/// the regular submit path downstream, so the caller can fall through -/// to the normal flow and let it surface the appropriate diagnostic. +#[cfg(test)] fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - let chain = Chain::try_from(chain_id).ok()?; - let domain = chain.settlement_domain(); - let order_data = gpv2_to_order_data(order)?; - Some(format!("{}", order_data.uid(&domain, owner))) -} - -// ---- OrderPostError -> retry action ---- - -fn apply_submit_retry( - host: &H, - err: &CowApiError, - watch_key: &str, - now_epoch_s: u64, -) -> Result<(), Fault> { - // Only a typed orderbook rejection classifies; transport faults and - // raw HTTP errors are transient, so the watch stays in place. - let action = match err { - CowApiError::Rejected(rejection) => classify_api_error(rejection), - _ => RetryAction::TryNextBlock, - }; - match action { - RetryAction::TryNextBlock => { - tracing::warn!("submit retry-next-block: {err}"); - } - RetryAction::Backoff { seconds } => { - let until = now_epoch_s.saturating_add(seconds); - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &until.to_le_bytes(), - )?; - } - tracing::warn!("submit backoff {seconds}s -> next_epoch={until}: {err}"); - } - RetryAction::Drop => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::warn!("submit dropped watch: {err}"); - } - // `RetryAction` is `#[non_exhaustive]`; future variants - // default to "leave the watch in place" (the conservative - // dispatch choice). Once a new variant gets a real meaning - // its arm should be added explicitly. - _ => { - tracing::warn!("submit unknown retry-action: {err} - leaving watch in place"); - } - } - Ok(()) -} - -// ---- PollOutcome lifecycle dispatch ---- - -/// What `apply_watch_update` should do for a given outcome. Kept as a -/// data type (rather than running the effects directly) so the -/// decision is host-free testable. -#[derive(Debug, Eq, PartialEq)] -enum WatchUpdate { - /// Leave the store untouched. Next block re-polls the watch. - NoOp, - /// Write `next_block:` so subsequent polls skip until the given - /// block number is reached. - SetNextBlock(u64), - /// Write `next_epoch:` so subsequent polls skip until the given - /// Unix-seconds timestamp is reached. - SetNextEpoch(u64), - /// Delete the watch and any stale gate keys - TWAP completed, - /// cancelled, or otherwise irrecoverable. - DropWatch, -} - -/// Pure mapping from a non-Ready `PollOutcome` to the lifecycle effect -/// the contract specifies. `Ready` is handled by the submit -/// path and is rejected here so a caller cannot -/// accidentally erase the watch when an order was actually produced. -fn outcome_to_update(outcome: &PollOutcome) -> WatchUpdate { - match outcome { - PollOutcome::Ready { .. } => WatchUpdate::NoOp, - PollOutcome::TryNextBlock => WatchUpdate::NoOp, - PollOutcome::TryOnBlock(n) => WatchUpdate::SetNextBlock(*n), - PollOutcome::TryAtEpoch(t) => WatchUpdate::SetNextEpoch(*t), - PollOutcome::DontTryAgain => WatchUpdate::DropWatch, - } -} - -fn apply_watch_update( - host: &H, - update: WatchUpdate, - watch_key: &str, -) -> Result<(), Fault> { - match update { - WatchUpdate::NoOp => Ok(()), - WatchUpdate::SetNextBlock(n) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_block:{owner_hex}:{hash_hex}"), - &n.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::SetNextEpoch(t) => { - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - host.set( - &format!("next_epoch:{owner_hex}:{hash_hex}"), - &t.to_le_bytes(), - )?; - } - Ok(()) - } - WatchUpdate::DropWatch => { - host.delete(watch_key)?; - if let Some((owner_hex, hash_hex)) = parse_watch_key(watch_key) { - let _ = host.delete(&format!("next_block:{owner_hex}:{hash_hex}")); - let _ = host.delete(&format!("next_epoch:{owner_hex}:{hash_hex}")); - } - tracing::info!("dropped watch {watch_key}"); - Ok(()) - } - } + shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U256, address, b256, hex}; + use alloy_primitives::{B256, U256, address, b256, hex}; use cowprotocol::{BuyTokenDestination, OrderKind, SellTokenSource}; use nexum_sdk::Level; use nexum_sdk::host::LocalStoreHost as _; use nexum_sdk_test::capture_tracing; - use shepherd_sdk::cow::{HttpFailure, OrderRejection}; + use shepherd_sdk::cow::{CowApiError, OrderRejection}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; + /// `validTo` a given number of seconds from now. The constructor's + /// client-side max-horizon policy reads the wall clock (not the + /// block clock), so test orders must expire relative to it. + fn valid_to_in(seconds: u64) -> u32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_secs(); + u32::try_from(now + seconds).expect("test validTo fits u32") + } + fn sample_params() -> ConditionalOrderParams { ConditionalOrderParams { handler: address!("ffeeddccbbaa00998877665544332211ffeeddcc"), @@ -581,7 +267,7 @@ mod tests { receiver: Address::ZERO, sellAmount: U256::from(1_000_000_u64), buyAmount: U256::from(999_u64), - validTo: 0xffff_ffff, + validTo: valid_to_in(3_600), appData: cowprotocol::EMPTY_APP_DATA_HASH, feeAmount: U256::ZERO, kind: OrderKind::SELL, @@ -648,73 +334,6 @@ mod tests { } } - #[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, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .expect("build succeeds"); - assert_eq!(creation.from, owner); - assert_eq!(creation.signing_scheme, cowprotocol::SigningScheme::Eip1271); - assert_eq!(creation.signature.to_bytes(), sig.to_vec()); - assert_eq!(creation.app_data, cowprotocol::EMPTY_APP_DATA_JSON); - assert_eq!(creation.app_data_hash, cowprotocol::EMPTY_APP_DATA_HASH); - } - - /// When the caller supplies the matching JSON for a - /// non-empty `appData` hash, `build_order_creation` accepts the - /// body. Caller is responsible for resolving the document (in - /// production this is `submit_ready` via - /// `shepherd_sdk::cow::resolve_app_data`). - #[test] - fn build_order_creation_accepts_matching_non_empty_app_data() { - use alloy_primitives::keccak256; - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let app_data_json = r#"{"version":"1.1.0","metadata":{"partnerId":"shepherd-e2e"}}"#; - let app_data_hash = keccak256(app_data_json.as_bytes()); - - let mut order = submittable_order(); - order.appData = app_data_hash; - - let sig: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let creation = - build_order_creation(&order, sig, owner, app_data_json.to_string()).expect("build"); - assert_eq!(creation.app_data, app_data_json); - assert_eq!(creation.app_data_hash, app_data_hash); - } - - #[test] - fn build_order_creation_rejects_non_empty_app_data() { - let mut order = submittable_order(); - order.appData = B256::repeat_byte(0xee); - let owner = address!("00112233445566778899aabbccddeeff00112233"); - let err = build_order_creation( - &order, - Bytes::new(), - owner, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - - #[test] - fn build_order_creation_rejects_zero_from() { - let err = build_order_creation( - &submittable_order(), - Bytes::new(), - Address::ZERO, - cowprotocol::EMPTY_APP_DATA_JSON.to_string(), - ) - .unwrap_err(); - assert!(matches!(err, BuildError::Cowprotocol(_))); - } - #[test] fn watch_key_round_trips_via_parse() { let owner = address!("00112233445566778899aabbccddeeff00112233"); @@ -725,48 +344,6 @@ mod tests { assert_eq!(h.parse::().unwrap(), hash); } - #[test] - fn outcome_try_next_block_is_no_op() { - assert_eq!( - outcome_to_update(&PollOutcome::TryNextBlock), - WatchUpdate::NoOp - ); - } - - #[test] - fn outcome_try_on_block_sets_next_block_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryOnBlock(12_345)), - WatchUpdate::SetNextBlock(12_345), - ); - } - - #[test] - fn outcome_try_at_epoch_sets_next_epoch_gate() { - assert_eq!( - outcome_to_update(&PollOutcome::TryAtEpoch(1_700_000_000)), - WatchUpdate::SetNextEpoch(1_700_000_000), - ); - } - - #[test] - fn outcome_dont_try_again_drops_watch() { - assert_eq!( - outcome_to_update(&PollOutcome::DontTryAgain), - WatchUpdate::DropWatch - ); - } - - #[test] - fn outcome_ready_is_handled_by_submit_path_not_lifecycle() { - let order = Box::new(submittable_order()); - let outcome = PollOutcome::Ready { - order, - signature: Bytes::new(), - }; - assert_eq!(outcome_to_update(&outcome), WatchUpdate::NoOp); - } - // ---- MockHost dispatch tests ---- /// Build the alloy log the indexer expects from a well-formed @@ -975,28 +552,26 @@ mod tests { assert_eq!( host.cow_api.request_calls().len(), 0, - "appData resolve must NOT be called either - the guard short-circuits early", + "the REST passthrough must NOT be touched - the guard short-circuits early", ); } - /// Ready order with a non-empty `appData` field - /// triggers a `cow_api_request` call to - /// `/api/v1/app_data/{hex}`; the resolved JSON is passed to - /// `OrderCreation::from_signed_order_data` so the digest matches - /// and the submit succeeds. Before this PR the path returned - /// "app_data JSON digest does not match signed app_data hash" - /// and the watch sat in retry-loop forever. + /// A Ready order with a non-empty `appData` digest submits the + /// digest verbatim as the hash-only wire shape: `appData` carries + /// the `0x`-hex digest, `appDataHash` is absent, and no orderbook + /// GET runs first - watch-tower parity. The absence of + /// `appDataHash` is load-bearing: with both fields present the + /// orderbook reads the body as the full-document shape and rejects + /// it for a digest mismatch. #[test] - fn poll_ready_resolves_non_empty_app_data_then_submits() { + fn poll_ready_submits_non_empty_app_data_hash_only() { use alloy_primitives::keccak256; let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); seed_watch(&host, owner, ¶ms); - let app_data_json = r#"{"version":"1.1.0","metadata":{"partnerId":"shepherd-e2e"}}"#; - let app_data_hash = keccak256(app_data_json.as_bytes()); - + let app_data_hash = keccak256(b"registered elsewhere; this client never sees the doc"); let mut ready_order = submittable_order(); ready_order.appData = app_data_hash; @@ -1008,20 +583,6 @@ mod tests { Ok(quoted_hex(&wire)), ); host.cow_api.respond(Ok("0xfeedface".to_string())); - // Mirror the orderbook's `/api/v1/app_data/{hex}` response - // shape: a JSON envelope carrying `fullAppData` as a string. - let envelope = format!( - r#"{{"fullAppData":{}}}"#, - serde_json::Value::String(app_data_json.to_string()), - ); - host.cow_api.respond_to_request_for( - "GET", - format!( - "/api/v1/app_data/0x{}", - alloy_primitives::hex::encode(app_data_hash) - ), - Ok(envelope), - ); on_block(&host, sample_block(1_000)).unwrap(); @@ -1031,10 +592,18 @@ mod tests { "exactly one eth_call to poll Ready" ); assert_eq!(host.cow_api.call_count(), 1, "exactly one orderbook submit"); + assert!( + host.cow_api.request_calls().is_empty(), + "no appData GET before submit - the digest goes out verbatim", + ); + let body = host.cow_api.last_body_as_json().expect("body is JSON"); assert_eq!( - host.cow_api.request_calls().len(), - 1, - "exactly one app_data resolve", + body["appData"], + format!("0x{}", alloy_primitives::hex::encode(app_data_hash)), + ); + assert!( + body.get("appDataHash").is_none(), + "hash-only body must omit appDataHash, got: {body}" ); let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) .expect("Sepolia is supported + canonical markers"); @@ -1042,58 +611,10 @@ mod tests { host.store .snapshot() .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful resolve+submit" + "submitted:{{client_uid}} marker must be written after a successful submit" ); } - /// When the orderbook 404s the appData hash (no - /// mirror exists), the strategy logs a Warn and leaves the - /// watch in place - neither a `submitted:` nor a `dropped:` - /// marker is written, and no submit attempt is made. - #[test] - fn poll_ready_skips_submit_when_app_data_hash_not_mirrored() { - use alloy_primitives::keccak256; - let host = MockHost::new(); - let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); - let params = sample_params(); - let watch_key_str = seed_watch(&host, owner, ¶ms); - - let app_data_hash = keccak256(b"unknown"); - let mut ready_order = submittable_order(); - ready_order.appData = app_data_hash; - let signature: Bytes = hex!("c0ffeec0ffeec0ffee").to_vec().into(); - let wire = (ready_order, signature).abi_encode_params(); - host.chain.respond_to( - "eth_call", - programmed_eth_call_params(owner, ¶ms), - Ok(quoted_hex(&wire)), - ); - // No `respond_to_request_for` → MockCowApi falls back to - // the default "no response configured" Unsupported error. - // Switch the default to a 404 so the strategy hits the - // typed "appData not mirrored" branch. - host.cow_api - .respond_to_request(Err(CowApiError::Http(HttpFailure { - status: 404, - body: None, - }))); - - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); - - assert_eq!(host.cow_api.call_count(), 0, "no submit attempt on 404"); - let store = host.store.snapshot(); - assert!(!store.keys().any(|k| k.starts_with("submitted:"))); - assert!(!store.keys().any(|k| k.starts_with("dropped:"))); - assert!( - store.contains_key(&watch_key_str), - "watch stays in place so a later mirror can resolve it" - ); - logs.expect_one(|e| { - e.level == Level::WARN && e.message.contains("appData hash not mirrored") - }); - } - #[test] fn submit_transient_error_leaves_state_unchanged_for_next_block() { let host = MockHost::new(); @@ -1233,4 +754,16 @@ mod tests { "revert-to-drop path never submits" ); } + + /// Guard: the topic-0 hardcoded in `module.toml` matches the + /// keccak256 of the canonical `ConditionalOrderCreated` signature. + /// A typo or ABI drift would silently miss every registration event. + #[test] + fn topic0_matches_conditional_order_created_canonical_signature() { + assert_eq!( + ConditionalOrderCreated::SIGNATURE_HASH, + b256!("2cceac5555b0ca45a3744ced542f54b56ad2eb45e521962372eef212a2cbf361"), + "module.toml event_signature must equal keccak256 of the canonical ABI signature", + ); + } } diff --git a/tools/load-gen/src/main.rs b/tools/load-gen/src/main.rs index 1a0558e9..1546ac42 100644 --- a/tools/load-gen/src/main.rs +++ b/tools/load-gen/src/main.rs @@ -394,9 +394,9 @@ fn encode_twap_create(salt: B256, block_ts: u64) -> Bytes { } /// Encode `CoWSwapEthFlow.createOrder(EthFlowOrder.Data)` with a sell -/// amount matched to the tx `value`. `appData` is the empty hash so -/// the orderbook mirror's `GET /api/v1/app_data/{hash}` returns the -/// document without contention. `validTo` is `u32::MAX` per the +/// amount matched to the tx `value`. `appData` is the empty hash - a +/// digest every orderbook already knows, so hash-only submission +/// needs no registration step. `validTo` is `u32::MAX` per the /// canonical EthFlow shape (the mock orderbook is /// permissive here, and shepherd's strategy will drop with the /// expected Info-level log per PR #49). diff --git a/tools/orderbook-mock/src/main.rs b/tools/orderbook-mock/src/main.rs index 243ae7fa..0ad9d4aa 100644 --- a/tools/orderbook-mock/src/main.rs +++ b/tools/orderbook-mock/src/main.rs @@ -1,14 +1,11 @@ //! Mock CoW orderbook for shepherd load tests. //! -//! Serves the two endpoints shepherd's `cow-api` host backend hits on +//! Serves the one endpoint shepherd's `cow-api` host backend hits on //! every order submission: //! //! - `POST /api/v1/orders` - accepts any body, returns a synthetic //! 56-byte OrderUid as a JSON-encoded hex string. Counts a request //! for the operator report. -//! - `GET /api/v1/app_data/{hash}` - returns the empty appData -//! document so `resolve_app_data` is satisfied without -//! needing a real registry. //! //! Operator knobs (CLI): //! - `--port` (default 9999) @@ -30,7 +27,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; use axum::Router; -use axum::extract::{Path, State}; +use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; @@ -67,7 +64,6 @@ struct Cli { struct Counters { submits_ok: AtomicU64, submits_err: AtomicU64, - app_data_lookups: AtomicU64, } struct AppState { @@ -107,7 +103,6 @@ async fn main() -> anyhow::Result<()> { let app = Router::new() .route("/api/v1/orders", post(post_orders)) - .route("/api/v1/app_data/{hash}", get(get_app_data)) .route("/healthz", get(healthz)) .route("/_stats", get(stats)) .with_state(state.clone()); @@ -138,7 +133,6 @@ async fn stats(State(state): State>) -> impl IntoResponse { let body = serde_json::json!({ "submits_ok": state.counters.submits_ok.load(Ordering::Relaxed), "submits_err": state.counters.submits_err.load(Ordering::Relaxed), - "app_data_lookups": state.counters.app_data_lookups.load(Ordering::Relaxed), }); (StatusCode::OK, axum::Json(body)) } @@ -189,23 +183,6 @@ async fn post_orders(State(state): State>, body: String) -> impl I (StatusCode::CREATED, uid_hex).into_response() } -async fn get_app_data( - State(state): State>, - Path(_hash): Path, -) -> impl IntoResponse { - if state.cli.latency_ms > 0 { - tokio::time::sleep(Duration::from_millis(state.cli.latency_ms)).await; - } - state - .counters - .app_data_lookups - .fetch_add(1, Ordering::Relaxed); - // The empty appData document - keccak256("{}") matches the - // EMPTY_APP_DATA_HASH the test EOA and load-gen will sign over. - let body = serde_json::json!({ "fullAppData": "{}" }); - (StatusCode::OK, axum::Json(body)).into_response() -} - /// Tiny inline hex encoder - the mock does not depend on `alloy` to /// keep its dependency surface minimal. (The engine uses /// `alloy_primitives::hex::encode_prefixed` instead; that rule @@ -230,7 +207,6 @@ mod tests { let state = Arc::new(AppState::new(cli)); Router::new() .route("/api/v1/orders", post(post_orders)) - .route("/api/v1/app_data/{hash}", get(get_app_data)) .with_state(state) } @@ -264,25 +240,6 @@ mod tests { assert_eq!(s.len(), 116); } - #[tokio::test] - async fn get_app_data_returns_empty_document() { - let app = router_with(default_cli()); - let resp = app - .oneshot( - Request::get("/api/v1/app_data/0xdeadbeef") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(parsed["fullAppData"], "{}"); - } - #[tokio::test] async fn error_rate_one_always_returns_envelope() { let app = router_with(Cli {