diff --git a/Cargo.lock b/Cargo.lock index dff0fe81..fecb0271 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1553,10 +1553,12 @@ dependencies = [ "cowprotocol", "http", "nexum-sdk", + "nexum-sdk-test", "serde", "serde_json", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "tracing", "url", "videre-sdk", "videre-test", diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 1376d15e..851c263a 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -41,7 +41,9 @@ cowprotocol = { version = "0.2.0", default-features = false, optional = true } alloy-primitives = { workspace = true, features = ["borsh"], optional = true } alloy-sol-types = { workspace = true, optional = true } # `adapter` slice: the orderbook REST speaker over the scoped -# wasi:http transport. +# wasi:http transport. Diagnostics go through the guest `tracing` +# facade. +tracing = { workspace = true, optional = true } serde = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } http = { workspace = true, optional = true } @@ -62,6 +64,8 @@ toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. videre-test = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "f58218e7232dcce71fe91bf94083e0d887ea1605" } +# Tracing capture for the dry-run suppression-line assertions. +nexum-sdk-test = { git = "https://github.com/nullislabs/nexum-runtime", rev = "d78a368aa0544dd8cf1100ce657df0eada781f1a" } # Parity tests: the upstream `retry_hint()` the shipped table is # reconciled against. cowprotocol = { version = "0.2.0", default-features = false } @@ -83,6 +87,7 @@ assembly = ["body", "dep:cowprotocol", "dep:alloy-sol-types"] adapter = [ "assembly", "client", + "dep:tracing", "dep:serde", "dep:serde_json", "dep:http", diff --git a/crates/cow-venue/module.load.toml b/crates/cow-venue/module.load.toml index b28b4a4a..a98fdb1f 100644 --- a/crates/cow-venue/module.load.toml +++ b/crates/cow-venue/module.load.toml @@ -18,6 +18,7 @@ allow = ["localhost"] [config] chain = "11155111" orderbook-url = "http://localhost:9999" +dry-run = "false" # Body-schema versions this adapter decodes: the handshake authority. [venue] diff --git a/crates/cow-venue/module.sepolia.toml b/crates/cow-venue/module.sepolia.toml index 35635f09..88404b36 100644 --- a/crates/cow-venue/module.sepolia.toml +++ b/crates/cow-venue/module.sepolia.toml @@ -18,6 +18,7 @@ allow = ["api.cow.fi"] [config] chain = "11155111" +dry-run = "false" # Body-schema versions this adapter decodes: the handshake authority. [venue] diff --git a/crates/cow-venue/module.toml b/crates/cow-venue/module.toml index 0e5b555c..b493b49c 100644 --- a/crates/cow-venue/module.toml +++ b/crates/cow-venue/module.toml @@ -18,11 +18,16 @@ optional = [] allow = ["api.cow.fi"] # One adapter instance speaks one chain's orderbook. `orderbook-url`, -# `owner` (enables the pre-sign path), and `http-timeout-ms` are -# optional overrides. Sepolia and load-mock variants sit alongside +# `owner` (enables the pre-sign path), `http-timeout-ms`, and +# `dry-run` ("true" suppresses order posts and status reads, carrying +# the locally derived uid; quotes still go live; default "false") are +# optional overrides. A committed dry-run submission marker suppresses +# the same body's later live submit; leaving dry-run needs a fresh +# journal store. Sepolia and load-mock variants sit alongside # (`module.sepolia.toml`, `module.load.toml`). [config] chain = "1" +dry-run = "false" # Body-schema versions this adapter decodes: the handshake authority. # Install asserts the adapter's body-versions export equals it. diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 9133b70d..417dccb2 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -11,7 +11,8 @@ //! `[config]` keys: `chain` (required, decimal chain id), optional //! `orderbook-url`, `owner` (hex address enabling the pre-sign path), //! `http-timeout-ms` (per-request bound, default the SDK per-phase -//! timeout). +//! timeout), `dry-run` ("true" suppresses order posts and status +//! reads, default "false"; quotes still go to the orderbook). use core::time::Duration; use std::sync::{PoisonError, RwLock}; @@ -57,6 +58,7 @@ pub(crate) struct AdapterConfig { pub(crate) base: Url, pub(crate) owner: Option
, pub(crate) timeout: Duration, + pub(crate) dry_run: bool, } impl AdapterConfig { @@ -70,6 +72,7 @@ impl AdapterConfig { let mut base = None; let mut owner = None; let mut timeout = DEFAULT_TIMEOUT; + let mut dry_run = false; for (key, value) in config { match key.as_str() { "chain" => { @@ -92,6 +95,13 @@ impl AdapterConfig { let ms: u64 = value.parse().map_err(|_| invalid(key, value))?; timeout = Duration::from_millis(ms.max(1)); } + "dry-run" => { + dry_run = match value.as_str() { + "true" => true, + "false" => false, + _ => return Err(invalid(key, value)), + }; + } _ => {} } } @@ -103,6 +113,7 @@ impl AdapterConfig { base: base.unwrap_or_else(|| chain.orderbook_base_url()), owner, timeout, + dry_run, }) } } @@ -151,7 +162,9 @@ pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result reconciled_uid(uid, config, &order, owner)?, // Locally derived and unverified: no UID in the reply. @@ -177,10 +195,16 @@ pub(crate) fn submit_with( let order = assembly::body_to_order_data(&wire); let creation = assembly::build_presign_creation(&order, owner) .map_err(|e| VenueError::InvalidBody(e.to_string()))?; - let uid = match post_order(fetch, config, &creation)? { - Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, - // Locally derived and unverified: no UID in the reply. - Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + let uid = if config.dry_run { + let uid = assembly::order_uid(config.chain, &order, owner); + tracing::info!("dry-run suppressed pre-sign post; orderUid {uid}"); + uid + } else { + match post_order(fetch, config, &creation)? { + Posted::Accepted(uid) => reconciled_uid(uid, config, &order, owner)?, + // Locally derived and unverified: no UID in the reply. + Posted::AlreadyHeld => assembly::order_uid(config.chain, &order, owner), + } }; Ok(SubmitOutcome::RequiresSigning(UnsignedTx { chain: config.chain.id(), @@ -207,13 +231,17 @@ fn reconciled_uid( Ok(server) } -/// Poll one receipt's orderbook lifecycle state. +/// Poll one receipt's orderbook lifecycle state. In dry-run mode a +/// valid receipt reports `open` without an orderbook read. pub(crate) fn status_with( fetch: &impl Fetch, config: &AdapterConfig, receipt: &[u8], ) -> Result { let uid = OrderUid::try_from(receipt).map_err(|_| VenueError::InvalidReceipt)?; + if config.dry_run { + return Ok(IntentStatus::Open); + } let url = join(config, &format!("api/v1/orders/{uid}"))?; let response = call(fetch, http::Method::GET, url, None)?; if response.status() == http::StatusCode::NOT_FOUND { @@ -452,9 +480,20 @@ mod export { use super::{AdapterConfig, CowAdapter}; + /// Stderr-backed tracing sink; the host captures guest stderr as + /// tagged log records. + struct StderrSink; + + impl nexum_sdk::tracing::LogSink for StderrSink { + fn log(&self, level: tracing::Level, message: &str) { + eprintln!("{level} {message}"); + } + } + #[cfg_attr(target_arch = "wasm32", videre_sdk::venue)] impl VenueAdapter for CowAdapter { fn init(config: Config) -> Result<(), Fault> { + nexum_sdk::tracing::init(StderrSink); AdapterConfig::parse(&config).map(super::store_config) } @@ -527,6 +566,7 @@ mod tests { base: Url::parse("https://orderbook.test/").expect("test url parses"), owner: None, timeout: Duration::from_secs(5), + dry_run: false, } } @@ -537,6 +577,13 @@ mod tests { } } + fn dry(config: AdapterConfig) -> AdapterConfig { + AdapterConfig { + dry_run: true, + ..config + } + } + fn owner() -> Address { Address::repeat_byte(0x55) } @@ -613,6 +660,27 @@ mod tests { assert_eq!(parsed.timeout, Duration::from_millis(1500)); } + #[test] + fn config_dry_run_defaults_off_and_parses_strictly() { + let chain = ("chain".to_owned(), "1".to_owned()); + let parsed = + AdapterConfig::parse(std::slice::from_ref(&chain)).expect("chain alone suffices"); + assert!(!parsed.dry_run); + for (value, expected) in [("true", true), ("false", false)] { + let pairs = [chain.clone(), ("dry-run".to_owned(), value.to_owned())]; + let parsed = AdapterConfig::parse(&pairs).expect("literal parses"); + assert_eq!(parsed.dry_run, expected); + } + for bad in ["yes", "1", "TRUE"] { + let pairs = [chain.clone(), ("dry-run".to_owned(), bad.to_owned())]; + assert!(matches!( + AdapterConfig::parse(&pairs), + Err(videre_sdk::Fault::InvalidInput(msg)) + if msg == format!("config dry-run is invalid: {bad}") + )); + } + } + #[test] fn config_refuses_a_missing_or_malformed_chain() { assert!(matches!( @@ -747,6 +815,76 @@ mod tests { assert_eq!(fetch.request_count(), 0); } + #[test] + fn dry_run_signed_submit_accepts_the_derived_uid_without_posting() { + let config = dry(config()); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + + let (outcome, logs) = + nexum_sdk_test::capture_tracing(|| submit_with(&fetch, &config, &signed_bytes())); + let SubmitOutcome::Accepted(receipt) = outcome.expect("accepted") else { + panic!("dry-run signed submit must accept"); + }; + assert_eq!(receipt, uid.as_slice()); + assert_eq!(fetch.request_count(), 0); + logs.expect_one(|e| { + e.message.contains("dry-run suppressed signed post") + && e.message.contains(&uid.to_string()) + }); + } + + #[test] + fn dry_run_presign_submit_requires_signing_without_posting() { + let config = dry(with_owner(owner())); + let uid = expected_uid(&config); + let fetch = MockFetch::default(); + + let (outcome, logs) = + nexum_sdk_test::capture_tracing(|| submit_with(&fetch, &config, &order_bytes())); + let SubmitOutcome::RequiresSigning(tx) = outcome.expect("requires signing") else { + panic!("dry-run pre-sign submit must require signing, as live does"); + }; + assert_eq!(tx.chain, SEPOLIA); + assert_eq!(tx.to, config.chain.settlement().as_slice()); + assert_eq!(tx.data, assembly::set_pre_signature_calldata(&uid)); + assert_eq!(fetch.request_count(), 0); + logs.expect_one(|e| { + e.message.contains("dry-run suppressed pre-sign post") + && e.message.contains(&uid.to_string()) + }); + } + + #[test] + fn dry_run_still_refuses_a_body_the_live_path_would() { + let fetch = MockFetch::default(); + let body = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: Address::ZERO, + signature: vec![0xC0, 0xFF, 0xEE], + })) + .to_bytes() + .expect("body encodes"); + + assert!(matches!( + submit_with(&fetch, &dry(config()), &body), + Err(VenueError::InvalidBody(_)) + )); + assert_eq!(fetch.request_count(), 0); + } + + #[test] + fn dry_run_status_reports_open_without_polling() { + let fetch = MockFetch::default(); + let uid = OrderUid([0xAB; 56]); + + assert_eq!( + status_with(&fetch, &dry(config()), uid.as_bytes()).expect("open"), + IntentStatus::Open, + ); + assert_eq!(fetch.request_count(), 0); + } + #[test] fn rejections_project_through_the_classification_table() { let config = config();