diff --git a/crates/nexum-engine/src/host/cow_orderbook/tests.rs b/crates/nexum-engine/src/host/cow_orderbook/tests.rs index ef318c94..42083685 100644 --- a/crates/nexum-engine/src/host/cow_orderbook/tests.rs +++ b/crates/nexum-engine/src/host/cow_orderbook/tests.rs @@ -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; diff --git a/crates/nexum-engine/src/host/impls/cow_api.rs b/crates/nexum-engine/src/host/impls/cow_api.rs index 8b3ccd41..60e9943a 100644 --- a/crates/nexum-engine/src/host/impls/cow_api.rs +++ b/crates/nexum-engine/src/host/impls/cow_api.rs @@ -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"); @@ -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: "upstream".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)); + } +}