Skip to content
Closed
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
34 changes: 34 additions & 0 deletions crates/nexum-engine/src/host/cow_orderbook/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,40 @@ async fn request_rejects_unknown_chain() {
assert!(matches!(err, CowApiError::UnknownChain(99_999)));
}

#[tokio::test]
async fn submit_order_propagates_orderbook_envelope() {
// The orderbook rejects with a typed envelope. The pool must
// surface `cowprotocol::Error::OrderbookApi { status, api }`
// so the WIT adapter can forward `api` to `HostError.data`
// (COW-1075). The string `DuplicatedOrder` is what the live
// Sepolia orderbook returns for an already-submitted order;
// it parses as `ApiError` even though `OrderPostErrorKind`
// falls back to `Unknown` for the spelling.
let mock = MockServer::start().await;
let envelope = r#"{"errorType":"DuplicatedOrder","description":"order already exists"}"#;
Mock::given(method("POST"))
.and(path("/api/v1/orders"))
.respond_with(ResponseTemplate::new(400).set_body_string(envelope))
.expect(1)
.mount(&mock)
.await;

let pool = pool_with_mainnet_at(&mock);
let err = pool
.submit_order_json(Chain::Mainnet.id(), sample_order_json().as_bytes())
.await
.expect_err("orderbook 400 surfaces as error");

match err {
CowApiError::Orderbook(cowprotocol::Error::OrderbookApi { status, api }) => {
assert_eq!(status, 400);
assert_eq!(api.error_type, "DuplicatedOrder");
assert_eq!(api.description, "order already exists");
}
other => panic!("expected OrderbookApi envelope, got {other:?}"),
}
}

#[tokio::test]
async fn submit_order_propagates_orderbook_response() {
let mock = MockServer::start().await;
Expand Down
110 changes: 103 additions & 7 deletions crates/nexum-engine/src/host/impls/cow_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,7 @@ impl shepherd::cow::cow_api::Host for HostState {
message: format!("invalid OrderCreation JSON: {err}"),
data: None,
}),
Err(CowApiError::Orderbook(err)) => Err(HostError {
domain: "cow-api".into(),
kind: HostErrorKind::Denied,
code: 0,
message: err.to_string(),
data: None,
}),
Err(CowApiError::Orderbook(err)) => Err(orderbook_to_host_error(err)),
Err(err) => Err(internal_error("cow-api", err.to_string())),
};
tracing::trace!(elapsed_ms = ?start.elapsed(), "cow-api::submit-order done");
Expand All @@ -90,3 +84,105 @@ impl shepherd::cow::cow_api::Host for HostState {
result
}
}

/// Project a `cowprotocol::Error` from `OrderBookApi::post_order` into
/// the WIT-side `HostError`.
///
/// For [`cowprotocol::Error::OrderbookApi`] (the orderbook returned a
/// typed `{"errorType": "...", ...}` envelope), the JSON-encoded
/// `ApiError` is forwarded verbatim in `HostError.data` so the guest's
/// `shepherd_sdk::cow::classify_api_error` can dispatch on `errorType`.
/// Without this projection the classifier is fed `None` and falls back
/// to `TryNextBlock`, producing infinite retry loops on permanent
/// rejections like `DuplicatedOrder` or `InvalidSignature` (COW-1075).
///
/// Other `cowprotocol::Error` variants (transport, serde, etc.) carry
/// no structured payload; `data` is left as `None` and the guest's
/// classifier applies its safe-default `TryNextBlock` branch.
fn orderbook_to_host_error(err: cowprotocol::Error) -> HostError {
let message = err.to_string();
if let cowprotocol::Error::OrderbookApi { status, api } = err {
let data = serde_json::to_string(&api).ok();
return HostError {
domain: "cow-api".into(),
kind: HostErrorKind::Denied,
code: i32::from(status),
message,
data,
};
}
HostError {
domain: "cow-api".into(),
kind: HostErrorKind::Denied,
code: 0,
message,
data: None,
}
}

#[cfg(test)]
mod tests {
use super::*;
use cowprotocol::error::ApiError;

#[test]
fn orderbook_api_error_is_forwarded_in_data() {
// The orderbook rejects with a typed envelope. The mapping
// must serialise it into HostError.data so the guest can
// dispatch on `errorType`.
let api = ApiError {
error_type: "DuplicatedOrder".to_owned(),
description: "order already exists".to_owned(),
data: None,
};
let err = cowprotocol::Error::OrderbookApi { status: 400, api };

let host_err = orderbook_to_host_error(err);

assert!(matches!(host_err.kind, HostErrorKind::Denied));
assert_eq!(host_err.code, 400);
let data = host_err.data.expect("orderbook envelope forwarded");
let parsed: ApiError = serde_json::from_str(&data).expect("data is ApiError JSON");
assert_eq!(parsed.error_type, "DuplicatedOrder");
assert_eq!(parsed.description, "order already exists");
}

#[test]
fn orderbook_api_error_preserves_optional_data_field() {
// ApiError carries an optional `data` field of its own. The
// forward must round-trip it so the guest sees what the
// orderbook actually returned.
let api = ApiError {
error_type: "InsufficientFee".to_owned(),
description: "fee too low".to_owned(),
data: Some(serde_json::json!({"min_fee": "1234"})),
};
let err = cowprotocol::Error::OrderbookApi { status: 400, api };

let host_err = orderbook_to_host_error(err);

let data = host_err.data.expect("envelope forwarded");
let parsed: ApiError = serde_json::from_str(&data).expect("round-trip");
assert_eq!(
parsed.data.expect("inner data preserved")["min_fee"],
"1234"
);
}

#[test]
fn non_envelope_cowprotocol_error_leaves_data_none() {
// Transport / serde / unexpected-status errors don't carry a
// structured ApiError; the guest classifier handles the
// None-data case via its TryNextBlock safe default.
let err = cowprotocol::Error::UnexpectedStatus {
status: 502,
body: "<html>upstream</html>".to_owned(),
};

let host_err = orderbook_to_host_error(err);

assert!(host_err.data.is_none());
assert_eq!(host_err.code, 0);
assert!(matches!(host_err.kind, HostErrorKind::Denied));
}
}