From d1cefb871f4e11479c092de688dc721ad8492d58 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Tue, 14 Jul 2026 06:44:36 +0000 Subject: [PATCH] refactor(cow): structured Verdict poll seam + LegacyRevertAdapter (Wave 1, ADR-0013) --- crates/shepherd-sdk-test/tests/mock_venue.rs | 17 +- crates/shepherd-sdk/README.md | 4 +- crates/shepherd-sdk/src/cow/composable.rs | 304 ++++++++++++------- crates/shepherd-sdk/src/cow/mod.rs | 8 +- crates/shepherd-sdk/src/cow/run.rs | 27 +- crates/shepherd-sdk/src/lib.rs | 9 +- crates/shepherd-sdk/src/proptests.rs | 12 +- crates/shepherd-sdk/tests/run.rs | 35 ++- modules/twap-monitor/src/strategy.rs | 56 ++-- 9 files changed, 287 insertions(+), 185 deletions(-) diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index ca2ba439..e9b3bef0 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -7,7 +7,7 @@ use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource}; use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Journal, Tick, WatchRef, WatchSet, watch_key}; -use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, CowHost, OrderRejection, Verdict, order_uid_hex, run}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; @@ -19,11 +19,11 @@ struct FnSource(F); impl ConditionalSource for FnSource where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { (self.0)(host, watch, params, tick) } } @@ -32,7 +32,7 @@ where /// construction site so inference never guesses a too-narrow lifetime. fn src(f: F) -> FnSource where - F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&VenueHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, { FnSource(f) } @@ -77,16 +77,17 @@ fn submittable_order() -> GPv2OrderData { } } -fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { - PollOutcome::Ready { +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { order: Box::new(order.clone()), signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: 0, } } fn ready_source( order: &GPv2OrderData, -) -> FnSource, &[u8], &Tick) -> PollOutcome> { +) -> FnSource, &[u8], &Tick) -> Verdict> { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) } diff --git a/crates/shepherd-sdk/README.md b/crates/shepherd-sdk/README.md index a93d3dfb..13a9ae0c 100644 --- a/crates/shepherd-sdk/README.md +++ b/crates/shepherd-sdk/README.md @@ -23,7 +23,7 @@ use shepherd_sdk::cow::{gpv2_to_order_data, classify_api_error, RetryAction}; | `prelude` | One-liner `use ::*` for cowprotocol order / signing / orderbook surface (alloy primitives come from `nexum_sdk::prelude`). | | `cow` | `CowApiHost` trait for `shepherd:cow/cow-api` + the `CowHost` bound over the core `nexum_sdk::host::Host`. | | `cow::order` | `gpv2_to_order_data` - `GPv2OrderData` -> typed `OrderData`. | -| `cow::composable` | `sol! IConditionalOrder` errors + `PollOutcome` + `decode_revert` + `decode_revert_hex`. | +| `cow::composable` | `sol! IConditionalOrder` errors + `Verdict` + `LegacyRevertAdapter`. | | `cow::error` | `CowApiError` (mirror of `cow-api-error`: `Fault` / `Http` / `Rejected`) + `RetryAction` enum + `classify_api_error` over an `OrderRejection`. | | `wit_bindgen_macro` | `bind_cow_host_via_wit_bindgen!` - the generic `WitBindgenHost` adapter plus the `CowApiHost` impl. | @@ -62,7 +62,7 @@ crates/shepherd-sdk/ │ ├── cow/ │ │ ├── mod.rs CowApiHost + CowHost │ │ ├── order.rs gpv2_to_order_data -│ │ ├── composable.rs IConditionalOrder + PollOutcome + decode_revert(_hex) +│ │ ├── composable.rs IConditionalOrder + Verdict + LegacyRevertAdapter │ │ └── error.rs RetryAction + classify_api_error │ └── wit_bindgen_macro.rs bind_cow_host_via_wit_bindgen! └── README.md you are here diff --git a/crates/shepherd-sdk/src/cow/composable.rs b/crates/shepherd-sdk/src/cow/composable.rs index 47aa22ee..10e0fcb2 100644 --- a/crates/shepherd-sdk/src/cow/composable.rs +++ b/crates/shepherd-sdk/src/cow/composable.rs @@ -1,10 +1,21 @@ -//! ComposableCoW poll-revert decoding. +//! ComposableCoW poll seam: the structured [`Verdict`] and the +//! quarantined [`LegacyRevertAdapter`]. //! -//! `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. +//! Every strategy poll resolves to a [`Verdict`] - the structured +//! outcome mirroring the composable-cow fork's structured generator. +//! The run and each strategy module +//! dispatch on the `Verdict` variants alone; nothing downstream knows +//! how the outcome was produced. +//! +//! The deployed ComposableCoW 1.x contract does not speak that +//! structured vocabulary. Its `getTradeableOrderWithSignature` reverts +//! with one of five custom errors when the conditional order is not +//! ready, expired, or otherwise non-tradeable. That reverting wire is +//! frozen: this module keeps decoding it, but the decode is quarantined +//! behind [`LegacyRevertAdapter`], which maps each legacy revert onto a +//! [`Verdict`]. When the fork's structured generator ships, the adapter +//! is the single seam that retires - the `Verdict` surface and every +//! dispatch site stay put. //! //! Source for the Solidity errors: //! `cowprotocol/composable-cow/src/interfaces/IConditionalOrder.sol`. @@ -15,9 +26,10 @@ use cowprotocol::GPv2OrderData; use nexum_sdk::host::ChainError; 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 + /// Five custom errors `IConditionalOrder.verify` reverts with - + /// the deployed ComposableCoW 1.x error surface. Selector source + /// for [`LegacyRevertAdapter::decode`]. The wire shape mirrors the + /// Solidity definitions verbatim so the four-byte selectors /// computed here match what the contract emits. #[derive(Debug)] interface IConditionalOrder { @@ -37,95 +49,140 @@ sol! { } } -/// Outcome of a single watch poll. Mirrors the enum shape: -/// `Ready` carries the materials the submit path needs; the other -/// variants drive the lifecycle handler. +/// Structured outcome of a single watch poll, mirroring the +/// composable-cow fork's structured generator. /// -/// `Ready` is intentionally never produced by [`decode_revert`] - it -/// only comes from the successful return path the poll module -/// constructs at the call site. +/// Every variant except `Post` carries a `reason`: the raw 4-byte +/// selector the outcome was derived from, for logging only - no +/// behaviour keys off it. It is `[0; 4]` when the outcome is synthetic +/// (no selector available, e.g. a transport fault). `Post` is the only +/// variant [`LegacyRevertAdapter`] never produces; it comes from the +/// successful return path each strategy constructs at the call site. #[derive(Debug)] -pub enum PollOutcome { +pub enum Verdict { /// 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 { + /// embedded EIP-1271 `signature` blob. `GPv2OrderData` is boxed to + /// keep the enum cache-friendly (~300 bytes vs. a few for the other + /// variants). + Post { /// 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, + /// Advisory Unix timestamp (seconds) the fork's generator hints + /// the next poll at. `0` when synthetic - the legacy adapter + /// has no such hint, so the submit path ignores it. + next_poll_timestamp: u64, + }, + /// Retry once the wall clock (Unix seconds, UTC) reaches + /// `wait_until`. + WaitTimestamp { + /// Unix timestamp (seconds) to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], + }, + /// Retry once the block number reaches `wait_until`. + WaitBlock { + /// Block number to re-poll at or after. + wait_until: u64, + /// Source selector, log only. + reason: [u8; 4], }, /// 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, + TryNextBlock { + /// Source selector, log only. + reason: [u8; 4], + }, + /// Order is dead - drop the watch. Aggregates the legacy + /// `OrderNotValid` and `PollNever` reverts and any unrecognised + /// contract-level rejection. + Invalid { + /// Source selector, log only. + reason: [u8; 4], + }, + /// The generator needs off-chain input before it can produce an + /// order. Never produced by [`LegacyRevertAdapter`]; the + /// run parks the watch untouched. + NeedsInput { + /// Source selector, log only. + reason: [u8; 4], + }, } -/// 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. [`classify_poll_error`] is the lifecycle policy on -/// top: it treats any such foreign selector as a permanent -/// contract-level rejection. -#[must_use] -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, - ))) +/// Quarantined decoder for the deployed ComposableCoW 1.x reverting +/// wire. Maps each legacy `getTradeableOrderWithSignature` revert onto +/// a [`Verdict`]; this is the single seam that retires when the fork's +/// structured generator ships. +#[derive(Debug, Clone, Copy)] +pub struct LegacyRevertAdapter; + +impl LegacyRevertAdapter { + /// Decode a `getTradeableOrderWithSignature` revert payload into a + /// [`Verdict`]. + /// + /// Returns `None` when the selector is not one of the five + /// [`IConditionalOrder`] errors - including a bare `Error(string)` + /// require-revert. [`classify`](Self::classify) is the lifecycle + /// policy on top: it treats any such foreign selector as a + /// permanent contract-level rejection. + #[must_use] + pub fn decode(data: &[u8]) -> Option { + if data.len() < 4 { + return None; } - s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { - let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; - Some(PollOutcome::TryAtEpoch(u256_to_u64_saturating( - decoded.timestamp, - ))) + let reason: [u8; 4] = data[..4].try_into().ok()?; + let body = &data[4..]; + match reason { + s if s == IConditionalOrder::OrderNotValid::SELECTOR => { + Some(Verdict::Invalid { reason }) + } + s if s == IConditionalOrder::PollTryNextBlock::SELECTOR => { + Some(Verdict::TryNextBlock { reason }) + } + s if s == IConditionalOrder::PollTryAtBlock::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtBlock::abi_decode_raw(body).ok()?; + Some(Verdict::WaitBlock { + wait_until: u256_to_u64_saturating(decoded.blockNumber), + reason, + }) + } + s if s == IConditionalOrder::PollTryAtEpoch::SELECTOR => { + let decoded = IConditionalOrder::PollTryAtEpoch::abi_decode_raw(body).ok()?; + Some(Verdict::WaitTimestamp { + wait_until: u256_to_u64_saturating(decoded.timestamp), + reason, + }) + } + s if s == IConditionalOrder::PollNever::SELECTOR => Some(Verdict::Invalid { reason }), + _ => None, } - s if s == IConditionalOrder::PollNever::SELECTOR => Some(PollOutcome::DontTryAgain), - _ => None, } -} -/// Classify a failed poll `eth_call` into a [`PollOutcome`] - the one -/// policy for what a poll failure means to the watch lifecycle. -/// -/// A revert payload big enough to carry a selector that -/// [`decode_revert`] does not recognise maps to `DontTryAgain`: it is -/// a contract-level rejection outside the `IConditionalOrder` -/// vocabulary (a handler-specific error, typically permanent), and -/// retrying it on every block loops forever. Only payload-free -/// failures - transport faults and reverts whose `data` is absent or -/// shorter than a selector - stay `TryNextBlock`. -#[must_use] -pub fn classify_poll_error(err: &ChainError) -> PollOutcome { - match err { - ChainError::Rpc(rpc) => match rpc.data.as_deref() { - Some(data) if data.len() >= 4 => { - decode_revert(data).unwrap_or(PollOutcome::DontTryAgain) - } - _ => PollOutcome::TryNextBlock, - }, - ChainError::Fault(_) => PollOutcome::TryNextBlock, + /// Classify a failed poll `eth_call` into a [`Verdict`] - the one + /// policy for what a poll failure means to the watch lifecycle. + /// + /// A revert payload big enough to carry a selector that + /// [`decode`](Self::decode) does not recognise maps to `Invalid`: + /// it is a contract-level rejection outside the `IConditionalOrder` + /// vocabulary (a handler-specific error, typically permanent), and + /// retrying it on every block loops forever. Only payload-free + /// failures - transport faults and reverts whose `data` is absent + /// or shorter than a selector - stay `TryNextBlock`. + #[must_use] + pub fn classify(err: &ChainError) -> Verdict { + match err { + ChainError::Rpc(rpc) => match rpc.data.as_deref() { + Some(data) if data.len() >= 4 => { + let reason: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]); + Self::decode(data).unwrap_or(Verdict::Invalid { reason }) + } + _ => Verdict::TryNextBlock { reason: [0; 4] }, + }, + ChainError::Fault(_) => Verdict::TryNextBlock { reason: [0; 4] }, + } } } @@ -138,24 +195,24 @@ mod tests { use super::*; #[test] - fn order_not_valid_maps_to_drop() { + fn order_not_valid_maps_to_invalid() { let err = IConditionalOrder::OrderNotValid { reason: "expired".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } #[test] - fn poll_never_maps_to_drop() { + fn poll_never_maps_to_invalid() { let err = IConditionalOrder::PollNever { reason: "cancelled".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::DontTryAgain) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::Invalid { .. }) )); } @@ -165,8 +222,8 @@ mod tests { reason: "noop".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryNextBlock) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::TryNextBlock { .. }) )); } @@ -177,8 +234,11 @@ mod tests { reason: "wait".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryOnBlock(12_345_678)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitBlock { + wait_until: 12_345_678, + .. + }) )); } @@ -189,21 +249,36 @@ mod tests { reason: "soon".to_string(), }; assert!(matches!( - decode_revert(&err.abi_encode()), - Some(PollOutcome::TryAtEpoch(1_700_000_000)) + LegacyRevertAdapter::decode(&err.abi_encode()), + Some(Verdict::WaitTimestamp { + wait_until: 1_700_000_000, + .. + }) )); } + #[test] + fn decoded_reason_carries_the_selector() { + let err = IConditionalOrder::PollTryNextBlock { + reason: "noop".to_string(), + }; + let Some(Verdict::TryNextBlock { reason }) = LegacyRevertAdapter::decode(&err.abi_encode()) + else { + panic!("expected TryNextBlock"); + }; + assert_eq!(reason, IConditionalOrder::PollTryNextBlock::SELECTOR); + } + #[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()); + assert!(LegacyRevertAdapter::decode(&data).is_none()); } #[test] fn truncated_returns_none() { - assert!(decode_revert(&[0x01, 0x02]).is_none()); + assert!(LegacyRevertAdapter::decode(&[0x01, 0x02]).is_none()); } #[test] @@ -212,7 +287,7 @@ mod tests { assert_eq!(u256_to_u64_saturating(U256::from(42_u64)), 42); } - // ---- classify_poll_error ---- + // ---- LegacyRevertAdapter::classify ---- use nexum_sdk::host::{Fault, RpcError}; @@ -232,47 +307,50 @@ mod tests { } .abi_encode(); assert!(matches!( - classify_poll_error(&rpc(Some(revert))), - PollOutcome::TryOnBlock(777) + LegacyRevertAdapter::classify(&rpc(Some(revert))), + Verdict::WaitBlock { + wait_until: 777, + .. + } )); } /// A handler-specific selector outside the `IConditionalOrder` - /// vocabulary is a permanent contract-level rejection: it must - /// drop, not re-poll every block forever. + /// vocabulary is a permanent contract-level rejection: it must map + /// to `Invalid`, not re-poll every block forever. #[test] - fn classify_unrecognised_selector_drops() { + fn classify_unrecognised_selector_is_invalid() { let mut data = vec![0x7a, 0x93, 0x32, 0x34]; data.extend_from_slice(&[0u8; 32]); assert!(matches!( - classify_poll_error(&rpc(Some(data))), - PollOutcome::DontTryAgain + LegacyRevertAdapter::classify(&rpc(Some(data))), + Verdict::Invalid { .. } )); // A bare 4-byte selector with no body classifies the same way. assert!(matches!( - classify_poll_error(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), - PollOutcome::DontTryAgain + LegacyRevertAdapter::classify(&rpc(Some(vec![0x2c, 0x7c, 0xa6, 0xd7]))), + Verdict::Invalid { .. } )); } #[test] fn classify_payload_free_failures_stay_try_next_block() { assert!(matches!( - classify_poll_error(&rpc(None)), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(None)), + Verdict::TryNextBlock { .. } )); assert!(matches!( - classify_poll_error(&rpc(Some(Vec::new()))), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(Some(Vec::new()))), + Verdict::TryNextBlock { .. } )); // Sub-selector payloads cannot name a contract error. assert!(matches!( - classify_poll_error(&rpc(Some(vec![0x01, 0x02]))), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&rpc(Some(vec![0x01, 0x02]))), + Verdict::TryNextBlock { .. } )); assert!(matches!( - classify_poll_error(&ChainError::Fault(Fault::Timeout)), - PollOutcome::TryNextBlock + LegacyRevertAdapter::classify(&ChainError::Fault(Fault::Timeout)), + Verdict::TryNextBlock { .. } )); } } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index cb3b920b..fbb8fa91 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -3,9 +3,13 @@ //! 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`), plus [`run()`] - the +//! `Verdict`, `RetryAction`), plus [`run()`] - the //! poll/submit composition over the keeper stores. //! +//! The poll seam is the structured [`Verdict`]; the deployed +//! ComposableCoW 1.x reverting wire is decoded behind the quarantined +//! [`LegacyRevertAdapter`]. +//! //! The codec submodules stay purely host-neutral: helpers take //! primitive arguments (`&[u8]`, `Option<&str>`, slices) so they can //! be unit-tested without wit-bindgen scaffolding and re-used @@ -17,7 +21,7 @@ pub mod error; pub mod order; pub mod run; -pub use composable::{IConditionalOrder, PollOutcome, classify_poll_error, decode_revert}; +pub use composable::{IConditionalOrder, LegacyRevertAdapter, Verdict}; pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index f70433cf..c607058b 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -3,8 +3,8 @@ //! //! [`run`] walks the keeper watch set, polls each gate-ready //! watch through a [`ConditionalSource`], and runs the -//! [`PollOutcome`]'s effect: lifecycle outcomes update the gate and -//! watch stores, `Ready` drives one submission through the +//! [`Verdict`]'s effect: lifecycle outcomes update the gate and +//! watch stores, `Post` drives one submission through the //! [`CowApiHost`](super::CowApiHost) seam with the `submitted:` //! journal as the idempotency guard and the keeper [`Retrier`] //! as the failure dispatch. @@ -24,17 +24,17 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, PollOutcome, classify_submit_error, gpv2_to_order_data, - is_already_submitted, order_uid_hex, + CowApiError, CowHost, Verdict, classify_submit_error, gpv2_to_order_data, is_already_submitted, + order_uid_hex, }; /// Poll every gate-ready watch once at `tick` and run each outcome's -/// effect. One source poll per ready watch; a `Ready` outcome makes at +/// effect. One source poll per ready watch; a `Post` outcome makes at /// most one `submit_order` call. pub fn run(host: &H, source: &S, tick: &Tick) -> Result<(), Fault> where H: CowHost, - S: ConditionalSource, + S: ConditionalSource, { let watches = WatchSet::new(host); let gates = Gates::new(host); @@ -49,18 +49,23 @@ where continue; }; match source.poll(host, watch, ¶ms, tick) { - PollOutcome::Ready { order, signature } => { + Verdict::Post { + order, signature, .. + } => { submit_ready(host, watch, &order, signature, tick, source.label())?; } - PollOutcome::TryNextBlock => {} - PollOutcome::TryOnBlock(block) => gates.set_next_block(watch, block)?, - PollOutcome::TryAtEpoch(epoch_s) => gates.set_next_epoch(watch, epoch_s)?, - PollOutcome::DontTryAgain => { + Verdict::TryNextBlock { .. } => {} + Verdict::WaitBlock { wait_until, .. } => gates.set_next_block(watch, wait_until)?, + Verdict::WaitTimestamp { wait_until, .. } => gates.set_next_epoch(watch, wait_until)?, + Verdict::Invalid { .. } => { // The removal is permanent; leave a trace of it even // for sources that do not log their own outcomes. tracing::info!("{} dropped watch {}", source.label(), watch.key()); watches.remove(watch)?; } + Verdict::NeedsInput { .. } => { + tracing::info!("watch {} parked awaiting input", watch.key()); + } } } Ok(()) diff --git a/crates/shepherd-sdk/src/lib.rs b/crates/shepherd-sdk/src/lib.rs index 70dacc1c..491e7752 100644 --- a/crates/shepherd-sdk/src/lib.rs +++ b/crates/shepherd-sdk/src/lib.rs @@ -16,8 +16,9 @@ //! - [`cow`] - the [`CowApiHost`] trait for `shepherd:cow/cow-api` //! (and the [`CowHost`] bound over the core [`Host`]), //! `GPv2OrderData` -> `OrderData` bridging ([`gpv2_to_order_data`]), -//! `IConditionalOrder` revert decoding ([`PollOutcome`] + -//! [`decode_revert`]), the classifiers mapping submit failures into +//! the structured poll seam ([`Verdict`]) with the deployed 1.x +//! revert decoding quarantined behind [`LegacyRevertAdapter`], the +//! classifiers mapping submit failures into //! the keeper [`RetryAction`], and [`run`] - the poll -> //! outcome -> gate/journal/submit composition over the keeper //! stores. @@ -49,8 +50,8 @@ //! [`CowHost`]: cow::CowHost //! [`Host`]: nexum_sdk::host::Host //! [`gpv2_to_order_data`]: cow::gpv2_to_order_data -//! [`PollOutcome`]: cow::PollOutcome -//! [`decode_revert`]: cow::decode_revert +//! [`Verdict`]: cow::Verdict +//! [`LegacyRevertAdapter`]: cow::LegacyRevertAdapter //! [`RetryAction`]: cow::RetryAction //! [`run`]: cow::run() diff --git a/crates/shepherd-sdk/src/proptests.rs b/crates/shepherd-sdk/src/proptests.rs index ea46e0e3..6d5c1453 100644 --- a/crates/shepherd-sdk/src/proptests.rs +++ b/crates/shepherd-sdk/src/proptests.rs @@ -4,7 +4,7 @@ //! //! Covered here: //! -//! - `decode_revert` selector dispatch (no-panic guard). +//! - `LegacyRevertAdapter::decode` selector dispatch (no-panic guard). //! - `gpv2_to_order_data` marker mapping (no-panic guard). //! //! The generic properties (`eth_call` round-trip, `scale_decimal`) @@ -15,12 +15,12 @@ use proptest::prelude::*; proptest! { - /// `decode_revert` on arbitrary revert bytes must never panic and - /// must return `None` for inputs shorter than the 4-byte EVM - /// selector. + /// `LegacyRevertAdapter::decode` on arbitrary revert bytes must + /// never panic and must return `None` for inputs shorter than the + /// 4-byte EVM selector. #[test] - fn decode_revert_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { - let outcome = crate::cow::decode_revert(&bytes); + fn legacy_revert_decode_never_panics(bytes in proptest::collection::vec(any::(), 0..64)) { + let outcome = crate::cow::LegacyRevertAdapter::decode(&bytes); if bytes.len() < 4 { prop_assert!(outcome.is_none()); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index a643803f..151e7f69 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -11,7 +11,7 @@ use cowprotocol::{BuyTokenDestination, GPv2OrderData, OrderKind, SellTokenSource use nexum_sdk::host::{Fault, LocalStoreHost as _, RateLimit}; use nexum_sdk::keeper::{ConditionalSource, Gates, Journal, Tick, WatchRef, WatchSet}; use nexum_sdk_test::capture_tracing; -use shepherd_sdk::cow::{CowApiError, OrderRejection, PollOutcome, order_uid_hex, run}; +use shepherd_sdk::cow::{CowApiError, OrderRejection, Verdict, order_uid_hex, run}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -22,11 +22,11 @@ struct FnSource(F); impl ConditionalSource for FnSource where - F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&H, WatchRef<'_>, &[u8], &Tick) -> Verdict, { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { (self.0)(host, watch, params, tick) } } @@ -35,7 +35,7 @@ where /// construction site so inference never guesses a too-narrow lifetime. fn src(f: F) -> FnSource where - F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> PollOutcome, + F: Fn(&MockHost, WatchRef<'_>, &[u8], &Tick) -> Verdict, { FnSource(f) } @@ -84,10 +84,11 @@ fn submittable_order() -> GPv2OrderData { } } -fn ready_outcome(order: &GPv2OrderData) -> PollOutcome { - PollOutcome::Ready { +fn ready_outcome(order: &GPv2OrderData) -> Verdict { + Verdict::Post { order: Box::new(order.clone()), signature: hex!("c0ffeec0ffeec0ffee").to_vec().into(), + next_poll_timestamp: 0, } } @@ -111,7 +112,7 @@ fn try_next_block_leaves_the_store_untouched() { run( &host, - &src(|_, _, _, _| PollOutcome::TryNextBlock), + &src(|_, _, _, _| Verdict::TryNextBlock { reason: [0; 4] }), &sample_tick(), ) .unwrap(); @@ -128,7 +129,10 @@ fn try_on_block_sets_the_block_gate() { run( &host, - &src(|_, _, _, _| PollOutcome::TryOnBlock(2_000)), + &src(|_, _, _, _| Verdict::WaitBlock { + wait_until: 2_000, + reason: [0; 4], + }), &sample_tick(), ) .unwrap(); @@ -147,7 +151,10 @@ fn try_at_epoch_sets_the_epoch_gate() { run( &host, - &src(|_, _, _, _| PollOutcome::TryAtEpoch(1_800_000_000)), + &src(|_, _, _, _| Verdict::WaitTimestamp { + wait_until: 1_800_000_000, + reason: [0; 4], + }), &sample_tick(), ) .unwrap(); @@ -159,7 +166,7 @@ fn try_at_epoch_sets_the_epoch_gate() { } #[test] -fn dont_try_again_removes_the_watch_and_its_gates() { +fn invalid_removes_the_watch_and_its_gates() { let host = MockHost::new(); let key = seed_watch(&host); let watch = WatchRef::parse(&key).unwrap(); @@ -167,7 +174,7 @@ fn dont_try_again_removes_the_watch_and_its_gates() { run( &host, - &src(|_, _, _, _| PollOutcome::DontTryAgain), + &src(|_, _, _, _| Verdict::Invalid { reason: [0; 4] }), &sample_tick(), ) .unwrap(); @@ -190,7 +197,7 @@ fn gated_watch_is_not_polled() { &host, &src(|_, _, _, _| { polls.set(polls.get() + 1); - PollOutcome::TryNextBlock + Verdict::TryNextBlock { reason: [0; 4] } }), &sample_tick(), ) @@ -209,7 +216,7 @@ fn malformed_watch_rows_are_skipped() { &host, &src(|_, _, _, _| { polls.set(polls.get() + 1); - PollOutcome::TryNextBlock + Verdict::TryNextBlock { reason: [0; 4] } }), &sample_tick(), ) diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index 06f4d8fc..3de57d8b 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -23,7 +23,7 @@ use nexum_sdk::chain::{eth_call_params, parse_eth_call_result}; use nexum_sdk::events::Log; use nexum_sdk::host::{ChainError, Fault}; use nexum_sdk::keeper::{ConditionalSource, Tick, WatchRef, WatchSet}; -use shepherd_sdk::cow::{CowHost, PollOutcome, classify_poll_error, run}; +use shepherd_sdk::cow::{CowHost, LegacyRevertAdapter, Verdict, run}; /// Block fields the poll path reads on every dispatch. pub struct BlockInfo { @@ -111,19 +111,19 @@ fn persist_watch( struct TwapSource; impl ConditionalSource for TwapSource { - type Outcome = PollOutcome; + type Outcome = Verdict; - fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> PollOutcome { + fn poll(&self, host: &H, watch: WatchRef<'_>, params: &[u8], tick: &Tick) -> Verdict { let Ok(params) = ConditionalOrderParams::abi_decode(params) else { tracing::warn!("watch {} carried unparseable params; skipping", watch.key()); - return PollOutcome::TryNextBlock; + return Verdict::TryNextBlock { reason: [0; 4] }; }; let Ok(owner) = watch.owner_hex().parse::
() else { tracing::warn!( "watch {} carried an unparseable owner; skipping", watch.key() ); - return PollOutcome::TryNextBlock; + return Verdict::TryNextBlock { reason: [0; 4] }; }; let outcome = poll_one(host, tick.chain_id, &owner, ¶ms); tracing::info!("poll {} -> {}", watch.key(), outcome_label(&outcome)); @@ -140,7 +140,7 @@ fn poll_one( chain_id: u64, owner: &Address, params: &ConditionalOrderParams, -) -> PollOutcome { +) -> Verdict { let call = abi::getTradeableOrderWithSignatureCall { owner: *owner, params: abi::Params { @@ -155,13 +155,13 @@ fn poll_one( match host.request(chain_id, "eth_call", ¶ms_json) { Ok(result_json) => parse_eth_call_result(&result_json) .and_then(|bytes| decode_return(&bytes)) - .unwrap_or(PollOutcome::TryNextBlock), - // `classify_poll_error` is the one policy for what a failed + .unwrap_or(Verdict::TryNextBlock { reason: [0; 4] }), + // `LegacyRevertAdapter::classify` is the one policy for what a failed // poll call means to the watch lifecycle; the diagnostics here // cover the cases where the raw error carries information the // outcome alone does not. Err(err) => { - let outcome = classify_poll_error(&err); + let outcome = LegacyRevertAdapter::classify(&err); match &err { ChainError::Fault(fault) => { tracing::warn!("eth_call failed ({fault}); retrying next block"); @@ -169,7 +169,7 @@ fn poll_one( // A permanent drop deserves its cause on the record: // the revert selector and the node's message are // unrecoverable once the watch is gone. - ChainError::Rpc(rpc) if matches!(outcome, PollOutcome::DontTryAgain) => { + ChainError::Rpc(rpc) if matches!(outcome, Verdict::Invalid { .. }) => { let selector = rpc .data .as_deref() @@ -190,24 +190,28 @@ fn poll_one( } /// Decode a successful `getTradeableOrderWithSignature` return into -/// `Ready { order, signature }`. The wire format is `abi.encode(order, -/// signature)` - the canonical Solidity return tuple - so the two-tuple -/// parameter decode lines up. -fn decode_return(data: &[u8]) -> Option { +/// `Post { order, signature, .. }`. The wire format is the canonical +/// Solidity return tuple `abi.encode(order, signature)`, so the +/// two-tuple parameter decode lines up. The deployed 1.x contract +/// carries no next-poll hint, so `next_poll_timestamp` is synthetic +/// (`0`). +fn decode_return(data: &[u8]) -> Option { let (order, signature) = <(GPv2OrderData, Bytes)>::abi_decode_params(data).ok()?; - Some(PollOutcome::Ready { + Some(Verdict::Post { order: Box::new(order), signature, + next_poll_timestamp: 0, }) } -fn outcome_label(o: &PollOutcome) -> &'static str { +fn outcome_label(o: &Verdict) -> &'static str { match o { - PollOutcome::Ready { .. } => "Ready", - PollOutcome::TryAtEpoch(_) => "TryAtEpoch", - PollOutcome::TryOnBlock(_) => "TryOnBlock", - PollOutcome::TryNextBlock => "TryNextBlock", - PollOutcome::DontTryAgain => "DontTryAgain", + Verdict::Post { .. } => "Post", + Verdict::WaitTimestamp { .. } => "WaitTimestamp", + Verdict::WaitBlock { .. } => "WaitBlock", + Verdict::TryNextBlock { .. } => "TryNextBlock", + Verdict::Invalid { .. } => "Invalid", + Verdict::NeedsInput { .. } => "NeedsInput", } } @@ -341,15 +345,17 @@ mod tests { let wire = (order.clone(), sig.clone()).abi_encode_params(); match decode_return(&wire).expect("decode succeeds") { - PollOutcome::Ready { + Verdict::Post { order: o, signature: s, + next_poll_timestamp, } => { assert_eq!(o.sellToken, order.sellToken); assert_eq!(o.buyAmount, order.buyAmount); assert_eq!(s, sig); + assert_eq!(next_poll_timestamp, 0, "legacy path carries no hint"); } - other => panic!("expected Ready, got {other:?}"), + other => panic!("expected Post, got {other:?}"), } } @@ -723,8 +729,8 @@ mod tests { } #[test] - fn poll_dont_try_again_drops_watch_and_gates() { - // When `decode_revert` produces `DontTryAgain`, the lifecycle + fn poll_invalid_drops_watch_and_gates() { + // When `LegacyRevertAdapter` produces `Invalid`, the lifecycle // layer must delete the watch and any stale gates. Simulate the // wire shape the chain backend forwards: a `ChainError::Rpc` // carrying the already-decoded `OrderNotValid` revert bytes.