Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion crates/cow-venue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -83,6 +87,7 @@ assembly = ["body", "dep:cowprotocol", "dep:alloy-sol-types"]
adapter = [
"assembly",
"client",
"dep:tracing",
"dep:serde",
"dep:serde_json",
"dep:http",
Expand Down
1 change: 1 addition & 0 deletions crates/cow-venue/module.load.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/cow-venue/module.sepolia.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ allow = ["api.cow.fi"]

[config]
chain = "11155111"
dry-run = "false"

# Body-schema versions this adapter decodes: the handshake authority.
[venue]
Expand Down
9 changes: 7 additions & 2 deletions crates/cow-venue/module.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 145 additions & 7 deletions crates/cow-venue/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -57,6 +58,7 @@ pub(crate) struct AdapterConfig {
pub(crate) base: Url,
pub(crate) owner: Option<Address>,
pub(crate) timeout: Duration,
pub(crate) dry_run: bool,
}

impl AdapterConfig {
Expand All @@ -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" => {
Expand All @@ -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)),
};
}
_ => {}
}
}
Expand All @@ -103,6 +113,7 @@ impl AdapterConfig {
base: base.unwrap_or_else(|| chain.orderbook_base_url()),
owner,
timeout,
dry_run,
})
}
}
Expand Down Expand Up @@ -151,7 +162,9 @@ pub(crate) fn derive_header_with(chain: u64, body: &[u8]) -> Result<IntentHeader
/// canonical UID; an unsigned order posts pre-sign, success carrying
/// the `setPreSignature` call. An already-held rejection is success on
/// the client-derived UID. An accepted UID is reconciled against the
/// local derivation; a disagreement is a typed refusal.
/// local derivation; a disagreement is a typed refusal. In dry-run
/// mode the body still assembles and validates, the post is skipped,
/// and the outcome carries the client-derived UID in the live shape.
pub(crate) fn submit_with(
fetch: &impl Fetch,
config: &AdapterConfig,
Expand All @@ -163,6 +176,11 @@ pub(crate) fn submit_with(
let owner = signed.owner;
let creation = assembly::build_order_creation(&order, &signed.signature, owner)
.map_err(|e| VenueError::InvalidBody(e.to_string()))?;
if config.dry_run {
let uid = assembly::order_uid(config.chain, &order, owner);
tracing::info!("dry-run suppressed signed post; orderUid {uid}");
return Ok(SubmitOutcome::Accepted(uid.as_slice().to_vec()));
}
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.
Expand All @@ -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(),
Expand All @@ -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<IntentStatus, VenueError> {
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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
}
}

Expand All @@ -537,6 +577,13 @@ mod tests {
}
}

fn dry(config: AdapterConfig) -> AdapterConfig {
AdapterConfig {
dry_run: true,
..config
}
}

fn owner() -> Address {
Address::repeat_byte(0x55)
}
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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();
Expand Down
Loading