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
87 changes: 87 additions & 0 deletions crates/shepherd-sdk-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ impl CowApiHost for MockHost {
fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result<String, HostError> {
self.cow_api.submit_order(chain_id, body)
}
fn cow_api_request(
&self,
chain_id: u64,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<String, HostError> {
self.cow_api.cow_api_request(chain_id, method, path, body)
}
}

impl LoggingHost for MockHost {
Expand Down Expand Up @@ -255,6 +264,14 @@ impl LocalStoreHost for MockLocalStore {
pub struct MockCowApi {
response: RefCell<Option<Result<String, HostError>>>,
calls: RefCell<Vec<SubmitCall>>,
/// `cow_api_request` mock state. Keyed by `(method, path)` so
/// tests can program different responses for `GET
/// /api/v1/app_data/0x...` vs other endpoints. Falls back to the
/// unkeyed `request_response` if no key matches.
request_responses:
RefCell<std::collections::HashMap<(String, String), Result<String, HostError>>>,
request_response: RefCell<Option<Result<String, HostError>>>,
request_calls: RefCell<Vec<RequestCall>>,
}

/// One recorded [`MockCowApi::submit_order`] invocation.
Expand All @@ -266,6 +283,19 @@ pub struct SubmitCall {
pub body: Vec<u8>,
}

/// One recorded [`MockCowApi::cow_api_request`] invocation.
#[derive(Clone, Debug)]
pub struct RequestCall {
/// Chain the guest targeted.
pub chain_id: u64,
/// HTTP-style verb.
pub method: String,
/// Absolute orderbook path, e.g. `/api/v1/app_data/0xabcd...`.
pub path: String,
/// Optional JSON body (for POST/PUT).
pub body: Option<String>,
}

impl MockCowApi {
/// Program the response the mock returns on every subsequent
/// `submit_order` call. Defaults to a host-side `Unsupported`
Expand Down Expand Up @@ -296,6 +326,34 @@ impl MockCowApi {
}
}

impl MockCowApi {
/// Program a response for a specific `(method, path)` pair.
/// Highest priority — used when both this and `respond_to_request`
/// are set.
pub fn respond_to_request_for(
&self,
method: impl Into<String>,
path: impl Into<String>,
result: Result<String, HostError>,
) {
self.request_responses
.borrow_mut()
.insert((method.into(), path.into()), result);
}

/// Program the catch-all response for `cow_api_request` calls
/// that don't match a specific `(method, path)` key. Defaults
/// to host-side `Unsupported`.
pub fn respond_to_request(&self, result: Result<String, HostError>) {
*self.request_response.borrow_mut() = Some(result);
}

/// All `cow_api_request` invocations, in arrival order.
pub fn request_calls(&self) -> Vec<RequestCall> {
self.request_calls.borrow().clone()
}
}

impl CowApiHost for MockCowApi {
fn submit_order(&self, chain_id: u64, body: &[u8]) -> Result<String, HostError> {
self.calls.borrow_mut().push(SubmitCall {
Expand All @@ -309,6 +367,35 @@ impl CowApiHost for MockCowApi {
))
})
}

fn cow_api_request(
&self,
chain_id: u64,
method: &str,
path: &str,
body: Option<&str>,
) -> Result<String, HostError> {
self.request_calls.borrow_mut().push(RequestCall {
chain_id,
method: method.to_string(),
path: path.to_string(),
body: body.map(str::to_string),
});
if let Some(r) = self
.request_responses
.borrow()
.get(&(method.to_string(), path.to_string()))
.cloned()
{
return r;
}
self.request_response.borrow().clone().unwrap_or_else(|| {
Err(HostError::unsupported(
"cow-api",
"MockCowApi: no cow_api_request response configured",
))
})
}
}

// ---------------------------------------------------------------- logging
Expand Down
227 changes: 227 additions & 0 deletions crates/shepherd-sdk/src/cow/app_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
//! Resolve a 32-byte `appData` hash to its canonical JSON document.
//!
//! CoW Protocol orders carry an `appData` field as `bytes32 =
//! keccak256(appDataJSON)`. The orderbook validates submissions by
//! re-hashing the JSON body and comparing to the signed hash, so any
//! caller that doesn't already know the document text needs to look
//! it up — either via IPFS or via the orderbook's mirror at
//! `GET /api/v1/app_data/{hex}`.
//!
//! This module hides that lookup behind a single
//! [`resolve_app_data`] helper. Strategies (notably twap-monitor)
//! call it before assembling an `OrderCreation` so cow-swap UI's
//! richer appData docs (partner-id, slippage settings,
//! quote-id, etc.) round-trip cleanly through the submit path.
//!
//! ## Behaviour
//!
//! - `hash == EMPTY_APP_DATA_HASH` (`keccak256("{}")`) → short-circuit
//! to [`EMPTY_APP_DATA_JSON`] (`"{}"`), no host call.
//! - Otherwise → `GET /api/v1/app_data/{hex}` on the chain's
//! orderbook. The 200 response is `{"fullAppData": "<JSON>"}`; we
//! pull `fullAppData` out and return it verbatim.
//! - On 404 (`HostError.code == 404`) → return the same error so the
//! caller can drop the submit gracefully (the orderbook doesn't
//! have the document mirrored; the caller has no path to recover
//! without operator intervention).
//!
//! ## Why not a typed CoW endpoint
//!
//! `cow-api::request` is the generic REST passthrough already in the
//! WIT surface (since 0.2.0); we use it rather than adding a typed
//! `cow-api::get-app-data` host method to keep this PR scoped to the
//! SDK + module layers (no WIT bump → no breaking module recompile).
//! Should the lookup become hot enough to merit a typed host
//! endpoint (e.g. for cache control), follow-up issue [COW-1074].
//!
//! ## Why not IPFS
//!
//! The orderbook already mirrors IPFS app_data docs and serves them
//! over a single HTTPS endpoint. Going to IPFS directly would
//! require a fresh capability (`ipfs`), bigger module footprint,
//! and worse latency than a single GET against an already-trusted
//! upstream. If the orderbook 404s, IPFS would too — the doc isn't
//! pinned anywhere we can see from inside the engine.

use cowprotocol::EMPTY_APP_DATA_HASH;

use crate::host::{CowApiHost, HostError, HostErrorKind};

/// Look up the JSON document corresponding to a signed `appData`
/// hash. See module-level docs for behaviour.
///
/// ```no_run
/// use shepherd_sdk::cow::resolve_app_data;
/// use shepherd_sdk::host::{CowApiHost, HostError};
///
/// fn pin_doc<H: CowApiHost>(host: &H, chain_id: u64, hash: &[u8; 32]) -> Result<String, HostError> {
/// resolve_app_data(host, chain_id, hash)
/// }
/// ```
pub fn resolve_app_data<H: CowApiHost + ?Sized>(
host: &H,
chain_id: u64,
app_data_hash: &[u8; 32],
) -> Result<String, HostError> {
if app_data_hash.as_slice() == EMPTY_APP_DATA_HASH.as_slice() {
return Ok(cowprotocol::EMPTY_APP_DATA_JSON.to_string());
}

let hex = encode_hex(app_data_hash);
let path = format!("/api/v1/app_data/{hex}");
let response = host.cow_api_request(chain_id, "GET", &path, None)?;

parse_full_app_data(&response).map_err(|e| HostError {
domain: "cow-api".into(),
kind: HostErrorKind::Internal,
code: 0,
message: format!("app_data response shape unexpected: {e}"),
data: Some(response),
})
}

fn encode_hex(bytes: &[u8; 32]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(2 + 64);
out.push('0');
out.push('x');
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0xf) as usize] as char);
}
out
}

/// Parse the orderbook's `/api/v1/app_data/{hash}` response shape:
///
/// ```json
/// {"fullAppData": "<JSON string>"}
/// ```
///
/// Some orderbook versions wrap the document in an outer envelope
/// (`{"appData": "...", "appDataHash": "...", "fullAppData": "..."}`);
/// we always pull `fullAppData` and ignore the rest.
fn parse_full_app_data(body: &str) -> Result<String, &'static str> {
let v: serde_json::Value = serde_json::from_str(body).map_err(|_| "body is not JSON")?;
let obj = v.as_object().ok_or("body is not a JSON object")?;
let full = obj
.get("fullAppData")
.ok_or("missing `fullAppData` field")?;
full.as_str()
.ok_or("`fullAppData` is not a string")
.map(str::to_owned)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::host::HostErrorKind;
use std::cell::RefCell;

/// Stub that captures the (chain_id, method, path) tuple and
/// returns a programmable response. Avoids pulling in
/// shepherd-sdk-test here (which depends on shepherd-sdk).
struct StubCowApi {
response: Result<String, HostError>,
last_call: RefCell<Option<(u64, String, String)>>,
}

impl CowApiHost for StubCowApi {
fn submit_order(&self, _: u64, _: &[u8]) -> Result<String, HostError> {
unimplemented!()
}
fn cow_api_request(
&self,
chain_id: u64,
method: &str,
path: &str,
_body: Option<&str>,
) -> Result<String, HostError> {
*self.last_call.borrow_mut() = Some((chain_id, method.to_string(), path.to_string()));
self.response.clone()
}
}

fn ok_stub(body: &str) -> StubCowApi {
StubCowApi {
response: Ok(body.to_string()),
last_call: RefCell::new(None),
}
}

fn err_stub(code: i32, kind: HostErrorKind) -> StubCowApi {
StubCowApi {
response: Err(HostError {
domain: "cow-api".into(),
kind,
code,
message: "stub".into(),
data: None,
}),
last_call: RefCell::new(None),
}
}

#[test]
fn empty_hash_short_circuits_without_host_call() {
let stub = ok_stub("should never be read");
let resolved =
resolve_app_data(&stub, 1, EMPTY_APP_DATA_HASH.as_slice().try_into().unwrap()).unwrap();
assert_eq!(resolved, "{}");
assert!(
stub.last_call.borrow().is_none(),
"host should not have been called"
);
}

#[test]
fn non_empty_hash_routes_to_orderbook_and_extracts_full_app_data() {
let stub =
ok_stub(r#"{"fullAppData":"{\"version\":\"1.1.0\"}","appDataHash":"0xc4bc..."}"#);
let mut hash = [0u8; 32];
hash[0] = 0xc4;
hash[1] = 0xbc;
let resolved = resolve_app_data(&stub, 11_155_111, &hash).unwrap();
assert_eq!(resolved, r#"{"version":"1.1.0"}"#);
let (cid, method, path) = stub.last_call.borrow().clone().unwrap();
assert_eq!(cid, 11_155_111);
assert_eq!(method, "GET");
assert!(path.starts_with("/api/v1/app_data/0x"), "got path={path}");
assert!(
path.contains("c4bc"),
"hex hash must be lower-case and 64 chars; got path={path}"
);
}

#[test]
fn missing_full_app_data_field_returns_internal_with_body_in_data() {
let stub = ok_stub(r#"{"appDataHash":"0xabcd","appData":"{}"}"#);
let mut hash = [0u8; 32];
hash[0] = 0xc4;
let err = resolve_app_data(&stub, 1, &hash).unwrap_err();
assert_eq!(err.kind, HostErrorKind::Internal);
assert!(err.message.contains("fullAppData"), "got: {}", err.message);
assert!(
err.data.is_some(),
"raw body must be carried in data for debug"
);
}

#[test]
fn host_error_propagates_unchanged() {
let stub = err_stub(404, HostErrorKind::Unavailable);
let mut hash = [0u8; 32];
hash[0] = 0xc4;
let err = resolve_app_data(&stub, 1, &hash).unwrap_err();
assert_eq!(err.code, 404);
assert_eq!(err.kind, HostErrorKind::Unavailable);
}

#[test]
fn hex_encoder_is_lower_case_and_64_wide() {
let mut h = [0u8; 32];
h[31] = 0xff;
h[0] = 0xab;
assert_eq!(encode_hex(&h), format!("0xab{}ff", "00".repeat(30)));
}
}
2 changes: 2 additions & 0 deletions crates/shepherd-sdk/src/cow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
//! tested without wit-bindgen scaffolding and re-used unchanged by
//! TWAP, EthFlow, and future strategy modules.

pub mod app_data;
pub mod composable;
pub mod error;
pub mod order;

pub use app_data::resolve_app_data;
pub use composable::{IConditionalOrder, PollOutcome, decode_revert};
pub use error::{RetryAction, classify_api_error, try_decode_api_error};
pub use order::gpv2_to_order_data;
Loading