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();