diff --git a/crates/shepherd-sdk/src/chain/eth_call.rs b/crates/shepherd-sdk/src/chain/eth_call.rs new file mode 100644 index 0000000..3f3de50 --- /dev/null +++ b/crates/shepherd-sdk/src/chain/eth_call.rs @@ -0,0 +1,107 @@ +//! `eth_call` JSON helpers. + +use alloy_primitives::Address; + +use crate::cow::composable::{PollOutcome, decode_revert}; + +/// Build the JSON params array for `eth_call`: `[{to, data}, "latest"]`. +/// +/// Returned as a `String` rather than `serde_json::Value` so the caller +/// can hand it straight to `chain::request(chain_id, "eth_call", &p)` +/// without re-serialising. +pub fn eth_call_params(to: &Address, data: &[u8]) -> String { + let to_hex = format!("{to:#x}"); + let data_hex = alloy_primitives::hex::encode_prefixed(data); + serde_json::json!([{ "to": to_hex, "data": data_hex }, "latest"]).to_string() +} + +/// Parse the raw JSON-RPC `result` field a host's `chain::request` +/// returns for an `eth_call`. The value is a JSON string holding hex +/// like `"0x1234..."`; strip the JSON quotes, strip the `0x` prefix, +/// and hex-decode. Returns `None` on shape mismatch. +pub fn parse_eth_call_result(result_json: &str) -> Option> { + let s = serde_json::from_str::(result_json).ok()?; + let hex = s.strip_prefix("0x").unwrap_or(&s); + alloy_primitives::hex::decode(hex).ok() +} + +/// Decode a hex string carrying revert bytes (optionally `0x`-prefixed, +/// optionally JSON-quoted) into a [`PollOutcome`] via +/// [`crate::cow::composable::decode_revert`]. +/// +/// This is the bridge between the host's structured error data (a hex +/// string in `host-error.data`) and the typed +/// [`crate::cow::composable::PollOutcome`] dispatch. +pub fn decode_revert_hex(s: &str) -> Option { + let stripped = s.trim_matches('"'); + let stripped = stripped.strip_prefix("0x").unwrap_or(stripped); + let bytes = alloy_primitives::hex::decode(stripped).ok()?; + decode_revert(&bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{U256, address, hex}; + use alloy_sol_types::SolError; + + use crate::cow::composable::IConditionalOrder; + + #[test] + fn eth_call_params_shape() { + let to = address!("fdaFc9d1902f4e0b84f65F49f244b32b31013b74"); + let data = hex!("aabbcc").to_vec(); + let p = eth_call_params(&to, &data); + let parsed: serde_json::Value = serde_json::from_str(&p).unwrap(); + assert_eq!( + parsed[0]["to"], + "0xfdafc9d1902f4e0b84f65f49f244b32b31013b74" + ); + assert_eq!(parsed[0]["data"], "0xaabbcc"); + assert_eq!(parsed[1], "latest"); + } + + #[test] + fn parse_eth_call_result_decodes_hex_string() { + assert_eq!( + parse_eth_call_result(r#""0xdeadbeef""#), + Some(vec![0xde, 0xad, 0xbe, 0xef]), + ); + } + + #[test] + fn parse_eth_call_result_handles_empty_hex() { + assert_eq!(parse_eth_call_result(r#""0x""#), Some(vec![])); + } + + #[test] + fn parse_eth_call_result_rejects_non_json() { + assert_eq!(parse_eth_call_result("garbage"), None); + } + + #[test] + fn decode_revert_hex_strips_prefix_and_quotes() { + let err = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(42_u64), + reason: "x".to_string(), + }; + let payload = alloy_primitives::hex::encode_prefixed(err.abi_encode()); + let quoted = format!("\"{payload}\""); + assert!(matches!( + decode_revert_hex("ed), + Some(PollOutcome::TryOnBlock(42)) + )); + } + + #[test] + fn decode_revert_hex_handles_unprefixed_naked_hex() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + let payload = alloy_primitives::hex::encode(err.abi_encode()); + assert!(matches!( + decode_revert_hex(&payload), + Some(PollOutcome::TryNextBlock) + )); + } +} diff --git a/crates/shepherd-sdk/src/chain/mod.rs b/crates/shepherd-sdk/src/chain/mod.rs new file mode 100644 index 0000000..edd9bd2 --- /dev/null +++ b/crates/shepherd-sdk/src/chain/mod.rs @@ -0,0 +1,10 @@ +//! `chain::request` JSON plumbing. +//! +//! Build the `[{to, data}, "latest"]` params array for `eth_call`, +//! parse the `"0x..."` hex result string, decode revert payloads from +//! the host's structured error data. Pure-logic helpers so a module +//! can plumb its own `chain::request` shim around them. + +pub mod eth_call; + +pub use eth_call::{decode_revert_hex, eth_call_params, parse_eth_call_result}; diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs new file mode 100644 index 0000000..2b63a90 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -0,0 +1,189 @@ +//! ComposableCoW poll-revert decoding. +//! +//! `ComposableCoW.getTradeableOrderWithSignature` reverts with one of +//! five custom errors when the conditional order is not ready, expired, +//! or otherwise non-tradeable. This module mirrors that error surface +//! and maps each revert to the typed [`PollOutcome`] every TWAP / +//! strategy module dispatches on. +//! +//! Source for the Solidity errors: +//! `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. + +use alloy_primitives::{Bytes, U256}; +use alloy_sol_types::{SolError, sol}; +use cowprotocol::GPv2OrderData; + +sol! { + /// Five custom errors `IConditionalOrder.verify` reverts with. + /// Selector source for [`decode_revert`]. The wire shape mirrors + /// the Solidity definitions verbatim so the four-byte selectors + /// computed here match what the contract emits. + #[derive(Debug)] + interface IConditionalOrder { + /// `OrderNotValid(string)` — the order condition is permanently + /// not met. Watch towers drop. + error OrderNotValid(string reason); + /// `PollTryNextBlock(string)` — try again on the next block. + error PollTryNextBlock(string reason); + /// `PollTryAtBlock(uint256, string)` — try at or after the + /// given block number. + error PollTryAtBlock(uint256 blockNumber, string reason); + /// `PollTryAtEpoch(uint256, string)` — try at or after the + /// given Unix timestamp (seconds). + error PollTryAtEpoch(uint256 timestamp, string reason); + /// `PollNever(string)` — the conditional order is dead. + error PollNever(string reason); + } +} + +/// Outcome of a single watch poll. Mirrors the BLEU-827 enum shape: +/// `Ready` carries the materials the submit path needs; the other +/// variants drive the lifecycle handler (BLEU-830). +/// +/// `Ready` is intentionally never produced by [`decode_revert`] — it +/// only comes from the successful return path the poll module +/// constructs at the call site. +#[derive(Debug)] +pub enum PollOutcome { + /// Conditional order is tradeable now; submit `order` with the + /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed + /// to keep the enum cache-friendly (~300 bytes vs. ~8 for the + /// other variants). + Ready { + /// The 12-field order ready to submit. + order: Box, + /// EIP-1271 wire-form signature (raw verifier bytes; the + /// orderbook prepends `from` before settlement). + signature: Bytes, + }, + /// Retry on the very next block — typical for time-sliced TWAP + /// schedules and other handlers that re-check on every tick. + TryNextBlock, + /// Retry once block number reaches the embedded value. + TryOnBlock(u64), + /// Retry once the wall clock (Unix seconds, UTC) reaches the + /// embedded value. + TryAtEpoch(u64), + /// Order is dead — drop the watch. Aggregates `OrderNotValid` and + /// `PollNever` reverts; the original reason string is dropped + /// because the lifecycle handler does not key off it today. + DontTryAgain, +} + +/// Decode a `getTradeableOrderWithSignature` revert payload into a +/// [`PollOutcome`]. +/// +/// Returns `None` when the selector is not one of the five +/// [`IConditionalOrder`] errors — including a bare `Error(string)` +/// require-revert. Callers should treat that as `TryNextBlock` (the +/// safe default) so a transient RPC blip does not drop a still-valid +/// watch. +pub fn decode_revert(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + let selector: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match selector { + s if s == IConditionalOrder::OrderNotValid::SELECTOR => Some(PollOutcome::DontTryAgain), + s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => Some(PollOutcome::TryNextBlock), + s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(PollOutcome::TryOnBlock(u256_to_u64_saturating( + decoded.blockNumber, + ))) + } + s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( + decoded.timestamp, + ))) + } + s if s == IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), + _ => None, + } +} + +fn u256_to_u64_saturating(v: U256) -> u64 { + u64::try_from(v).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn order_not_valid_maps_to_drop() { + let err = IConditionalOrder::OrderNotValid { + reason: "expired".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::DontTryAgain) + )); + } + + #[test] + fn poll_never_maps_to_drop() { + let err = IConditionalOrder::PollNever { + reason: "cancelled".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::DontTryAgain) + )); + } + + #[test] + fn try_next_block() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::TryNextBlock) + )); + } + + #[test] + fn try_at_block_carries_number() { + let err = IConditionalOrder::PollTryAtBlock { + blockNumber: U256::from(12_345_678_u64), + reason: "wait".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::TryOnBlock(12_345_678)) + )); + } + + #[test] + fn try_at_epoch_carries_timestamp() { + let err = IConditionalOrder::PollTryAtEpoch { + timestamp: U256::from(1_700_000_000_u64), + reason: "soon".to_string(), + }; + assert!(matches!( + decode_revert(&err.abi_encode()), + Some(PollOutcome::TryAtEpoch(1_700_000_000)) + )); + } + + #[test] + fn unknown_selector_returns_none() { + let mut data = vec![0xde, 0xad, 0xbe, 0xef]; + data.extend_from_slice(&[0u8; 32]); + assert!(decode_revert(&data).is_none()); + } + + #[test] + fn truncated_returns_none() { + assert!(decode_revert(&[0x01, 0x02]).is_none()); + } + + #[test] + fn u256_saturates_at_max() { + assert_eq!(u256_to_u64_saturating(U256::MAX), u64::MAX); + assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); + } +} diff --git a/crates/shepherd-sdk/src/cow/error.rs b/crates/shepherd-sdk/src/cow/error.rs new file mode 100644 index 0000000..8555884 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/error.rs @@ -0,0 +1,137 @@ +//! Orderbook submission error classification. +//! +//! Maps `cow_api::submit_order` failures into a typed [`RetryAction`] +//! the lifecycle layer dispatches on. The orderbook returns a typed +//! [`ApiError`](cowprotocol::error::ApiError) JSON body on permanent +//! / transient failures; the host forwards that JSON in +//! `host-error.data` (once the chain backend supports it — see ADR +//! follow-up). Until then, [`classify_api_error`] falls back to +//! `TryNextBlock` so a flaky orderbook does not poison still-valid +//! orders. + +use cowprotocol::error::ApiError; + +/// What the lifecycle layer should do after a failed submission. +/// +/// Mirrors the BLEU-829 retry contract: `TryNextBlock` / +/// `BackoffSeconds(s)` / `Drop`. The `Backoff` arm has no producer +/// today because cowprotocol's `retry_hint()` is bool-only; the +/// variant is kept so dispatch can grow into it once a server +/// `Retry-After` hint shows up. +#[derive(Debug, Eq, PartialEq)] +pub enum RetryAction { + /// Leave the watch / placement in place; the next event will + /// re-attempt. + TryNextBlock, + /// Persist `next_attempt = now + seconds`. Reserved — no producer + /// today (kept so the dispatch contract is stable). + #[allow(dead_code)] + Backoff { + /// Seconds to wait before retrying. + seconds: u64, + }, + /// Remove the watch / mark as terminally rejected. The orderbook + /// will not accept this body on a retry. + Drop, +} + +/// Best-effort decode of the orderbook's typed [`ApiError`] body from +/// the `host-error.data` field a guest receives on a failed +/// `cow_api::submit_order` call. Returns `None` when the host did not +/// forward a payload, or when the payload does not parse as +/// `ApiError`. +pub fn try_decode_api_error(host_error_data: Option<&str>) -> Option { + serde_json::from_str::(host_error_data?).ok() +} + +/// Classify the host's failure-side payload (the JSON the orderbook +/// returned) into a [`RetryAction`]. +/// +/// - Retriable kinds per `OrderPostErrorKind::is_retriable` (today: +/// `InsufficientFee`, `TooManyLimitOrders`, `PriceExceedsMarketPrice`) +/// → `TryNextBlock`. +/// - Recognised non-retriable kinds → `Drop`. +/// - Payload absent or unparseable → `TryNextBlock` (safe default; a +/// flaky orderbook should not be treated as a permanent rejection). +pub fn classify_api_error(host_error_data: Option<&str>) -> RetryAction { + match try_decode_api_error(host_error_data) { + Some(api) if api.retry_hint() => RetryAction::TryNextBlock, + Some(_) => RetryAction::Drop, + None => RetryAction::TryNextBlock, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body_for(error_type: &str) -> String { + serde_json::json!({ + "errorType": error_type, + "description": "test", + }) + .to_string() + } + + #[test] + fn retriable_kinds_yield_try_next_block() { + for kind in [ + "InsufficientFee", + "TooManyLimitOrders", + "PriceExceedsMarketPrice", + ] { + assert_eq!( + classify_api_error(Some(&body_for(kind))), + RetryAction::TryNextBlock, + "{kind}", + ); + } + } + + #[test] + fn permanent_kinds_yield_drop() { + for kind in [ + "InvalidSignature", + "WrongOwner", + "DuplicateOrder", + "UnsupportedToken", + "InvalidAppData", + "InvalidErc1271Signature", + ] { + assert_eq!( + classify_api_error(Some(&body_for(kind))), + RetryAction::Drop, + "{kind}", + ); + } + } + + #[test] + fn unknown_kind_yields_drop() { + // `Unknown(_)` is non-retriable per cowprotocol's classifier. + assert_eq!( + classify_api_error(Some(&body_for("NewlyMintedErrorType"))), + RetryAction::Drop, + ); + } + + #[test] + fn missing_data_yields_try_next_block() { + assert_eq!(classify_api_error(None), RetryAction::TryNextBlock); + } + + #[test] + fn malformed_data_yields_try_next_block() { + assert_eq!( + classify_api_error(Some("upstream")), + RetryAction::TryNextBlock, + ); + } + + #[test] + fn try_decode_round_trips() { + let body = body_for("InsufficientFee"); + let api = try_decode_api_error(Some(&body)).expect("decode"); + assert_eq!(api.error_type, "InsufficientFee"); + } +} diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs new file mode 100644 index 0000000..dd80f96 --- /dev/null +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -0,0 +1,19 @@ +//! CoW Protocol bridging. +//! +//! Type conversions and ABI decoding helpers that translate between +//! the on-chain shape (`GPv2OrderData`, `IConditionalOrder` reverts, +//! orderbook JSON) and the typed Rust surface (`OrderData`, +//! `PollOutcome`, `RetryAction`). +//! +//! Each submodule stays purely host-neutral: helpers take primitive +//! arguments (`&[u8]`, `Option<&str>`, slices) so they can be unit- +//! tested without wit-bindgen scaffolding and re-used unchanged by +//! TWAP, EthFlow, and future strategy modules. + +pub mod composable; +pub mod error; +pub mod order; + +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; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs new file mode 100644 index 0000000..a499c0e --- /dev/null +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -0,0 +1,112 @@ +//! `GPv2OrderData` -> `OrderData` bridging. +//! +//! ComposableCoW and CoWSwapEthFlow both emit / return the 12-field +//! `GPv2OrderData` Solidity tuple, with `kind` / `sellTokenBalance` / +//! `buyTokenBalance` as 32-byte keccak markers. The orderbook signs +//! against the typed `OrderData` shape, with those markers projected +//! into Rust enums. [`gpv2_to_order_data`] is the bridge. + +use alloy_primitives::Address; +use cowprotocol::{ + BuyTokenDestination, GPv2OrderData, OrderData, OrderKind, SellTokenSource, +}; + +/// Convert a freshly-polled / freshly-placed [`GPv2OrderData`] into the +/// typed [`OrderData`] shape `OrderCreation::from_signed_order_data` +/// expects. +/// +/// The `kind`, `sellTokenBalance`, and `buyTokenBalance` fields ride +/// the wire as `bytes32` markers (the `keccak256` of the lowercase +/// variant name). This helper hands them off to cowprotocol's +/// `from_contract_bytes` classifiers and returns `None` when the on- +/// chain payload carries a marker the SDK doesn't recognise — the +/// caller skips the order rather than ship a malformed body. +/// +/// `receiver = Address::ZERO` is normalised to `None`; `OrderCreation:: +/// from_signed_order_data` does the same downstream, but doing it here +/// keeps the EIP-712 hash inputs verbatim if a caller bypasses that +/// helper later. +pub fn gpv2_to_order_data(gpv2: &GPv2OrderData) -> Option { + Some(OrderData { + sell_token: gpv2.sellToken, + buy_token: gpv2.buyToken, + receiver: (gpv2.receiver != Address::ZERO).then_some(gpv2.receiver), + sell_amount: gpv2.sellAmount, + buy_amount: gpv2.buyAmount, + valid_to: gpv2.validTo, + app_data: gpv2.appData, + fee_amount: gpv2.feeAmount, + kind: OrderKind::from_contract_bytes(gpv2.kind)?, + partially_fillable: gpv2.partiallyFillable, + sell_token_balance: SellTokenSource::from_contract_bytes(gpv2.sellTokenBalance)?, + buy_token_balance: BuyTokenDestination::from_contract_bytes(gpv2.buyTokenBalance)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{B256, U256, address}; + + fn submittable_gpv2() -> GPv2OrderData { + GPv2OrderData { + sellToken: address!("6810e776880C02933D47DB1b9fc05908e5386b96"), + buyToken: address!("DAE5F1590db13E3B40423B5b5c5fbf175515910b"), + receiver: address!("DeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"), + sellAmount: U256::from(1_000_000_u64), + buyAmount: U256::from(999_u64), + validTo: 0xffff_ffff, + appData: cowprotocol::EMPTY_APP_DATA_HASH, + feeAmount: U256::ZERO, + kind: OrderKind::SELL, + partiallyFillable: false, + sellTokenBalance: SellTokenSource::ERC20, + buyTokenBalance: BuyTokenDestination::ERC20, + } + } + + #[test] + fn happy_path_round_trips_markers() { + let g = submittable_gpv2(); + let od = gpv2_to_order_data(&g).expect("known markers"); + assert_eq!(od.sell_token, g.sellToken); + assert_eq!(od.buy_token, g.buyToken); + assert_eq!(od.kind, OrderKind::Sell); + assert_eq!(od.sell_token_balance, SellTokenSource::Erc20); + assert_eq!(od.buy_token_balance, BuyTokenDestination::Erc20); + } + + #[test] + fn zero_receiver_normalises_to_none() { + let mut g = submittable_gpv2(); + g.receiver = Address::ZERO; + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, None); + } + + #[test] + fn non_zero_receiver_preserved() { + let g = submittable_gpv2(); + assert_eq!(gpv2_to_order_data(&g).unwrap().receiver, Some(g.receiver)); + } + + #[test] + fn unknown_kind_marker_returns_none() { + let mut g = submittable_gpv2(); + g.kind = B256::repeat_byte(0x42); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_sell_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.sellTokenBalance = B256::repeat_byte(0x99); + assert!(gpv2_to_order_data(&g).is_none()); + } + + #[test] + fn unknown_buy_token_balance_returns_none() { + let mut g = submittable_gpv2(); + g.buyTokenBalance = B256::repeat_byte(0x55); + assert!(gpv2_to_order_data(&g).is_none()); + } +} diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index dc815d7..cb554f0 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -53,27 +53,14 @@ #![warn(missing_docs)] #![cfg_attr(docsrs, feature(doc_cfg))] +pub mod chain; +pub mod cow; pub mod prelude; -/// CoW Protocol bridging: `GPv2OrderData` <-> typed `OrderData`, -/// `IConditionalOrder` revert decoding, retry classification. -/// -/// Skeleton — populated by [BLEU-840]( -/// https://linear.app/bleu-builders/issue/BLEU-840). -pub mod cow {} - -/// `eth_call` JSON plumbing: build the `[{to, data}, "latest"]` -/// params object, parse the `"0x..."` hex result, decode revert -/// payloads from the host's structured error data. -/// -/// Skeleton — populated by [BLEU-840]( -/// https://linear.app/bleu-builders/issue/BLEU-840). -pub mod chain {} - /// `local-store` helpers: `WatchSet`, `BackoffLedger` per ADR-0006. /// -/// Skeleton — populated by [BLEU-840]( -/// https://linear.app/bleu-builders/issue/BLEU-840). +/// Skeleton — populated by a follow-up to BLEU-840 once a second +/// strategy module needs the same key conventions. pub mod store {} #[cfg(test)]