diff --git a/crates/cow-venue/src/body.rs b/crates/cow-venue/src/body.rs index feb7d3c8..6bb39165 100644 --- a/crates/cow-venue/src/body.rs +++ b/crates/cow-venue/src/body.rs @@ -1,6 +1,6 @@ //! The CoW intent body and its versioned `IntentBody` codec. //! -//! A CoW intent is a direct order for the orderbook; [`CowIntent`] is +//! A CoW intent is an order for the orderbook; [`CowIntent`] is //! that sum, open for future intent kinds. [`CowIntentBody`] is the //! outer per-venue version enum the venue publishes, and //! `#[derive(IntentBody)]` gives it the borsh codec: a one-byte version @@ -13,13 +13,16 @@ use borsh::{BorshDeserialize, BorshSerialize}; use videre_sdk::IntentBody; -use crate::order::OrderBody; +use crate::order::{OrderBody, SignedOrder}; -/// What the CoW venue accepts: a direct order for the orderbook. +/// What the CoW venue accepts: an order for the orderbook. #[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] pub enum CowIntent { /// A direct `GPv2Order` to place on the orderbook. Order(OrderBody), + /// An owner-signed order with its EIP-1271 signature: what a + /// conditional-order keeper emits after a poll. + Signed(SignedOrder), } /// The outer per-venue version enum: the schema the CoW venue publishes. @@ -56,6 +59,16 @@ mod tests { &CowIntentBody::V1(CowIntent::Order(order_body())), ) .expect("order body encodes"); + vectors + .push_round_trip( + "v1-signed", + &CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_body(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + })), + ) + .expect("signed order encodes"); let bytes = |intent: CowIntent| CowIntentBody::V1(intent).to_bytes().expect("body encodes"); let mut unknown = bytes(CowIntent::Order(order_body())); diff --git a/crates/cow-venue/src/client.rs b/crates/cow-venue/src/client.rs index 892e4f4e..9dd86963 100644 --- a/crates/cow-venue/src/client.rs +++ b/crates/cow-venue/src/client.rs @@ -8,12 +8,17 @@ //! slice so the client that submits an order and the table that //! classifies its rejection version together. +use alloc::string::String; + use videre_sdk::client::{HostVenues, Venue, VenueClient, VenueId}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{BodyError, IntentBody as _}; use crate::body::CowIntentBody; /// The CoW venue marker: every [`CowClient`] call routes to -/// [`Venue::ID`] and encodes a [`CowIntentBody`]. +/// [`Venue::ID`] and encodes a [`CowIntentBody`]. An accepted submit's +/// receipt is the canonical [`OrderUid`](crate::OrderUid) in wire form. #[derive(Clone, Copy, Debug)] pub struct CowVenue; @@ -26,6 +31,19 @@ impl Venue for CowVenue { /// or submit a foreign body. pub type CowClient = VenueClient; +/// Deterministic intent-id for `body`: the sweep's +/// [`submission_key`] bound to [`CowVenue::ID`]. Derivable before any +/// network work, so a keeper journals the same key whether it submits +/// through the sweep or directly. +/// +/// The key covers the encoded body, so a signed payload +/// ([`CowIntent::Signed`](crate::CowIntent::Signed)) keys on its +/// signature: it scopes to that exact payload, not to the economic +/// order, which dedups only through the venue's duplicate response. +pub fn intent_id(body: &CowIntentBody) -> Result { + Ok(submission_key(&CowVenue::ID, &body.to_bytes()?)) +} + #[cfg(test)] mod tests { use std::cell::RefCell; @@ -91,6 +109,34 @@ mod tests { )) } + #[test] + fn intent_id_is_deterministic_and_body_scoped() { + use videre_sdk::IntentBody; + + use crate::body::CowIntent; + use crate::order::{BuyToken, OrderBody, SellToken, SignedOrder}; + + let body = sample_body(); + let id = intent_id(&body).expect("body encodes"); + assert_eq!(id, intent_id(&body.clone()).expect("body encodes")); + assert_eq!( + id, + submission_key(&CowVenue::ID, &body.to_bytes().expect("body encodes")), + "the id must be exactly the key the generic sweep journals", + ); + assert!(id.starts_with("cow:0x")); + + let other = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: OrderBody::sell(SellToken([0x11; 20]), [0x01; 32]) + .for_at_least(BuyToken([0x22; 20]), [0x02; 32]) + .valid_to(1_700_000_000) + .build(), + owner: [0x55; 20], + signature: vec![0xC0], + })); + assert_ne!(id, intent_id(&other).expect("body encodes")); + } + #[test] fn submit_routes_to_the_cow_venue_with_encoded_body() { use videre_sdk::IntentBody; diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index fc4392b5..4e0879ba 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -19,7 +19,8 @@ //! without pulling the codec transitively. //! //! The `client` slice layers on top: a typed [`CowClient`] bound to the -//! CoW venue plus the table-driven retry [`classification`] generated at +//! CoW venue, the deterministic [`intent_id`] journal key, and the +//! table-driven retry [`classification`] generated at //! build time from the shipped `data/classification.toml` (the TOML //! parser stays a build-time dependency, off the guest). It links the //! strategy keeper (for the retry action type) and is off by default, @@ -56,10 +57,11 @@ pub mod client; pub use body::{CowIntent, CowIntentBody}; #[cfg(feature = "body")] pub use order::{ - BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, SellToken, SellTokenSource, + BuyToken, BuyTokenDestination, OrderBody, OrderBuilder, OrderKind, OrderUid, SellToken, + SellTokenSource, SignedOrder, }; #[cfg(feature = "client")] pub use classification::{ClassificationTable, classify, is_already_submitted}; #[cfg(feature = "client")] -pub use client::{CowClient, CowVenue}; +pub use client::{CowClient, CowVenue, intent_id}; diff --git a/crates/cow-venue/src/order.rs b/crates/cow-venue/src/order.rs index a8ac4a57..8bb1d725 100644 --- a/crates/cow-venue/src/order.rs +++ b/crates/cow-venue/src/order.rs @@ -9,6 +9,8 @@ //! marker enums are canonical wire forms, not on-chain keccak markers, //! so the adapter, not this type, owns the projection to and from chain. +use alloc::vec::Vec; +use core::fmt; use core::marker::PhantomData; use borsh::{BorshDeserialize, BorshSerialize}; @@ -259,6 +261,70 @@ impl OrderBuilder { } } +/// An owner-signed order ready for the orderbook: what a +/// conditional-order keeper emits after a poll. +#[derive(BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq)] +pub struct SignedOrder { + /// The order to place. + pub order: OrderBody, + /// Order owner: the EIP-1271 verifier and the `from` of the + /// orderbook submission. + pub owner: Address, + /// Raw EIP-1271 signature bytes; the settlement verifies them + /// against `owner`. + pub signature: Vec, +} + +/// Canonical 56-byte orderbook order UID (order digest, owner, +/// `valid_to`) in wire form: the receipt bytes an accepted CoW submit +/// carries. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// The raw 56 bytes. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 56] { + &self.0 + } +} + +impl From<[u8; 56]> for OrderUid { + fn from(bytes: [u8; 56]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for OrderUid { + type Error = core::array::TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self(<[u8; 56]>::try_from(bytes)?)) + } +} + +impl From for Vec { + fn from(uid: OrderUid) -> Self { + uid.0.to_vec() + } +} + +impl fmt::Display for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("0x")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for OrderUid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + #[cfg(test)] mod tests { use super::*; @@ -367,4 +433,36 @@ mod tests { } } } + + #[test] + fn signed_order_borsh_round_trips() { + let signed = SignedOrder { + order: sample(), + owner: [0x55; 20], + signature: vec![0xC0, 0xFF, 0xEE], + }; + let bytes = borsh::to_vec(&signed).expect("encode"); + assert_eq!(SignedOrder::try_from_slice(&bytes).expect("decode"), signed); + } + + #[test] + fn order_uid_converts_only_from_56_bytes() { + let uid = OrderUid([0xAB; 56]); + assert_eq!(OrderUid::try_from(&uid.0[..]).expect("56 bytes"), uid); + assert!(OrderUid::try_from(&uid.0[..55]).is_err()); + assert_eq!(Vec::from(uid), vec![0xAB; 56]); + } + + #[test] + fn order_uid_displays_as_prefixed_hex() { + let mut bytes = [0u8; 56]; + bytes[0] = 0x01; + bytes[55] = 0xFF; + let uid = OrderUid(bytes); + let hex = uid.to_string(); + assert_eq!(hex.len(), 2 + 56 * 2); + assert!(hex.starts_with("0x01")); + assert!(hex.ends_with("ff")); + assert_eq!(format!("{uid:?}"), hex); + } } diff --git a/crates/shepherd-sdk-test/tests/mock_venue.rs b/crates/shepherd-sdk-test/tests/mock_venue.rs index 6867ecdd..d8af0612 100644 --- a/crates/shepherd-sdk-test/tests/mock_venue.rs +++ b/crates/shepherd-sdk-test/tests/mock_venue.rs @@ -8,7 +8,10 @@ use composable_cow::Verdict; 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, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowHost, CowIntent, CowIntentBody, OrderRejection, SignedOrder, + gpv2_to_order_data, order_data_to_body, order_uid_hex, run, +}; use shepherd_sdk_test::{MockHost, MockVenue}; const SEPOLIA: u64 = 11_155_111; @@ -107,6 +110,18 @@ fn client_uid(order: &GPv2OrderData) -> String { order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") } +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") +} + fn rejection(error_type: &str) -> CowApiError { CowApiError::Rejected(OrderRejection { status: 400, @@ -136,7 +151,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert!(host.store.snapshot().contains_key(&key), "watch survives"); assert!( !Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); @@ -144,7 +159,7 @@ fn keeper_retries_a_transient_rejection_then_submits() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); assert_eq!( @@ -190,7 +205,7 @@ fn keeper_backs_off_on_rate_limit_and_submits_after_the_gate() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } @@ -220,7 +235,7 @@ fn keeper_survives_a_venue_outage_and_submits_on_recovery() { assert_eq!(host.cow_api.call_count(), 2); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap() ); } diff --git a/crates/shepherd-sdk/src/cow/mod.rs b/crates/shepherd-sdk/src/cow/mod.rs index 84a97643..e733da5d 100644 --- a/crates/shepherd-sdk/src/cow/mod.rs +++ b/crates/shepherd-sdk/src/cow/mod.rs @@ -25,7 +25,7 @@ pub use error::{ CowApiError, HttpFailure, OrderRejection, RetryAction, classify_api_error, classify_submit_error, is_already_submitted, }; -pub use order::{gpv2_to_order_data, order_uid_hex}; +pub use order::{gpv2_to_order_data, order_data_to_body, order_uid_hex}; pub use run::run; /// The venue-neutral intent body types and their borsh `IntentBody` @@ -33,7 +33,7 @@ pub use run::run; /// this path stable while the module ports move off the legacy surface. pub use cow_venue::{ BuyToken, BuyTokenDestination, CowIntent, CowIntentBody, OrderBody, OrderBuilder, OrderKind, - SellToken, SellTokenSource, + OrderUid, SellToken, SellTokenSource, SignedOrder, intent_id, }; use nexum_sdk::host::Host; diff --git a/crates/shepherd-sdk/src/cow/order.rs b/crates/shepherd-sdk/src/cow/order.rs index d0a79bfb..79431954 100644 --- a/crates/shepherd-sdk/src/cow/order.rs +++ b/crates/shepherd-sdk/src/cow/order.rs @@ -92,6 +92,37 @@ pub fn order_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Op Some(format!("{}", order_data.uid(&domain, owner))) } +/// Project a typed [`OrderData`] into the venue wire +/// [`OrderBody`](cow_venue::OrderBody) a keeper emits. Total: every +/// typed field has exactly one wire form. +#[must_use] +pub fn order_data_to_body(order: &OrderData) -> cow_venue::OrderBody { + cow_venue::OrderBody { + sell_token: order.sell_token.into_array(), + buy_token: order.buy_token.into_array(), + receiver: order.receiver.map(Address::into_array), + sell_amount: order.sell_amount.to_be_bytes(), + buy_amount: order.buy_amount.to_be_bytes(), + valid_to: order.valid_to, + app_data: order.app_data.0, + fee_amount: order.fee_amount.to_be_bytes(), + kind: match order.kind { + OrderKind::Sell => cow_venue::OrderKind::Sell, + OrderKind::Buy => cow_venue::OrderKind::Buy, + }, + partially_fillable: order.partially_fillable, + sell_token_balance: match order.sell_token_balance { + SellTokenSource::Erc20 => cow_venue::SellTokenSource::Erc20, + SellTokenSource::External => cow_venue::SellTokenSource::External, + SellTokenSource::Internal => cow_venue::SellTokenSource::Internal, + }, + buy_token_balance: match order.buy_token_balance { + BuyTokenDestination::Erc20 => cow_venue::BuyTokenDestination::Erc20, + BuyTokenDestination::Internal => cow_venue::BuyTokenDestination::Internal, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -159,6 +190,30 @@ mod tests { assert!(gpv2_to_order_data(&g).is_none()); } + // ---- order_data_to_body ---- + + #[test] + fn order_data_to_body_projects_every_field() { + let g = submittable_gpv2(); + let order = gpv2_to_order_data(&g).expect("known markers"); + let body = order_data_to_body(&order); + assert_eq!(body.sell_token, g.sellToken.into_array()); + assert_eq!(body.buy_token, g.buyToken.into_array()); + assert_eq!(body.receiver, Some(g.receiver.into_array())); + assert_eq!(body.sell_amount, g.sellAmount.to_be_bytes::<32>()); + assert_eq!(body.buy_amount, g.buyAmount.to_be_bytes::<32>()); + assert_eq!(body.valid_to, g.validTo); + assert_eq!(body.app_data, g.appData.0); + assert_eq!(body.fee_amount, g.feeAmount.to_be_bytes::<32>()); + assert_eq!(body.kind, cow_venue::OrderKind::Sell); + assert!(!body.partially_fillable); + assert_eq!(body.sell_token_balance, cow_venue::SellTokenSource::Erc20); + assert_eq!( + body.buy_token_balance, + cow_venue::BuyTokenDestination::Erc20 + ); + } + // ---- order_uid_hex ---- const SEPOLIA: u64 = 11_155_111; diff --git a/crates/shepherd-sdk/src/cow/run.rs b/crates/shepherd-sdk/src/cow/run.rs index 910090e1..f66d73dc 100644 --- a/crates/shepherd-sdk/src/cow/run.rs +++ b/crates/shepherd-sdk/src/cow/run.rs @@ -6,8 +6,9 @@ //! [`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. +//! journal as the idempotency guard - keyed on the venue-and-body +//! [`intent_id`] - and the keeper [`Retrier`] as the failure +//! dispatch. //! //! Store faults abort the sweep (the next tick replays it); //! submission failures never do - they classify into a @@ -25,8 +26,8 @@ use nexum_sdk::keeper::{ }; use super::{ - CowApiError, CowHost, classify_submit_error, gpv2_to_order_data, is_already_submitted, - order_uid_hex, + CowApiError, CowHost, CowIntent, CowIntentBody, SignedOrder, classify_submit_error, + gpv2_to_order_data, intent_id, is_already_submitted, order_data_to_body, }; /// Poll every gate-ready watch once at `tick` and run each outcome's @@ -76,9 +77,12 @@ where /// `submitted:` journal and dispatching any failure through the retry /// ledger. /// -/// The UID is deterministic from on-chain inputs, so the idempotency -/// check runs before any network work; the same value keys the journal -/// marker after, so the read and write paths agree. +/// The journal keys on the deterministic venue-and-body +/// [`intent_id`], derived before any network work from the same body +/// bytes a venue submit carries - never from the assembled +/// `OrderCreation` - so the guard survives assembly moving into the +/// venue adapter. The orderbook's UID is the receipt; it rides the +/// log only. fn submit_ready( host: &H, watch: WatchRef<'_>, @@ -95,15 +99,6 @@ fn submit_ready( return Ok(()); }; - let journal = Journal::submitted(host); - let client_uid = order_uid_hex(tick.chain_id, order, owner); - if let Some(uid) = client_uid.as_deref() - && journal.contains(uid)? - { - tracing::info!("{label} {uid} already submitted; skipping re-submit"); - return Ok(()); - } - let Some(order_data) = gpv2_to_order_data(order) else { // An unknown enum marker means the SDK cannot express this // payload yet; skip rather than drop so an SDK upgrade can @@ -113,6 +108,24 @@ fn submit_ready( ); return Ok(()); }; + + let intent = CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + })); + let intent_id = match intent_id(&intent) { + Ok(id) => id, + Err(err) => { + tracing::error!("intent body encode failed: {err}"); + return Ok(()); + } + }; + let journal = Journal::submitted(host); + if journal.contains(&intent_id)? { + tracing::info!("{label} {intent_id} already submitted; skipping re-submit"); + return Ok(()); + } let creation = match build_order_creation(&order_data, signature, owner) { Ok(creation) => creation, Err(err) => { @@ -137,41 +150,29 @@ fn submit_ready( }; match host.submit_order(tick.chain_id, &body) { - Ok(server_uid) => { - // Prefer the client-computed UID so the guard above reads - // what this writes; a divergence would be a protocol bug - // worth a warning, never a silently split keyspace. - let marker = client_uid.as_deref().unwrap_or(server_uid.as_str()); + Ok(receipt) => { // The submit already succeeded; a journal-store fault here // must not abort the sweep or unwind the accepted order. // Log and carry on - the already-submitted arm keeps the // next tick's re-post idempotent. - if let Err(fault) = journal.record(marker) { - tracing::error!("submitted {marker} but journal write failed: {fault}"); + if let Err(fault) = journal.record(&intent_id) { + tracing::error!("submitted {intent_id} but journal write failed: {fault}"); } - if let Some(client) = client_uid.as_deref() - && client != server_uid - { - tracing::warn!( - "{label} UID divergence: client={client} server={server_uid} \ - (marker keyed on the client UID)" - ); - } - tracing::info!("submitted {marker}"); + tracing::info!("submitted {intent_id} (receipt {receipt})"); } Err(CowApiError::Rejected(rejection)) if is_already_submitted(&rejection) => { // Success wearing an error status: the orderbook already - // holds this order. Record the receipt and keep the watch - // so the next tick short-circuits instead of re-posting. - // As above, a journal fault post-submit only forfeits the - // short-circuit; it must not abort the sweep. - if let Some(uid) = client_uid.as_deref() - && let Err(fault) = journal.record(uid) - { - tracing::error!("orderbook already holds {uid} but journal write failed: {fault}"); + // holds this order. Journal the intent-id and keep the + // watch so the next tick short-circuits instead of + // re-posting. As above, a journal fault post-submit only + // forfeits the short-circuit; it must not abort the sweep. + if let Err(fault) = journal.record(&intent_id) { + tracing::error!( + "orderbook already holds {intent_id} but journal write failed: {fault}" + ); } tracing::info!( - "orderbook already holds this order ({}); receipt recorded", + "orderbook already holds this order ({}); intent-id journalled", rejection.error_type, ); } diff --git a/crates/shepherd-sdk/tests/run.rs b/crates/shepherd-sdk/tests/run.rs index 76677e0a..bff0f238 100644 --- a/crates/shepherd-sdk/tests/run.rs +++ b/crates/shepherd-sdk/tests/run.rs @@ -12,7 +12,10 @@ 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, order_uid_hex, run}; +use shepherd_sdk::cow::{ + CowApiError, CowIntent, CowIntentBody, OrderRejection, SignedOrder, gpv2_to_order_data, + order_data_to_body, run, +}; use shepherd_sdk_test::MockHost; const SEPOLIA: u64 = 11_155_111; @@ -99,8 +102,16 @@ fn seed_watch(host: &MockHost) -> String { .unwrap() } -fn client_uid(order: &GPv2OrderData) -> String { - order_uid_hex(SEPOLIA, order, sample_owner()).expect("supported chain, known markers") +/// The intent-id the keeper journals for `order`: the venue-and-body +/// key over the same signed body `run` derives pre-submit. +fn intent_id(order: &GPv2OrderData) -> String { + let order_data = gpv2_to_order_data(order).expect("known markers"); + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: order_data_to_body(&order_data), + owner: sample_owner().into_array(), + signature: hex!("c0ffeec0ffeec0ffee").to_vec(), + }))) + .expect("body encodes") } // ---- lifecycle outcomes ---- @@ -229,11 +240,11 @@ fn malformed_watch_rows_are_skipped() { // ---- ready -> submission ---- #[test] -fn ready_submits_once_and_journals_the_client_uid() { +fn ready_submits_once_and_journals_the_intent_id() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); - host.cow_api.respond(Ok(client_uid(&order))); + host.cow_api.respond(Ok("0xserveruid".to_string())); let source = { let order = order.clone(); @@ -244,15 +255,15 @@ fn ready_submits_once_and_journals_the_client_uid() { assert_eq!(host.cow_api.call_count(), 1); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "submitted:{{client_uid}} receipt must be recorded", + "submitted:{{intent_id}} marker must be recorded", ); assert_eq!(host.cow_api.last_call().unwrap().chain_id, SEPOLIA); } #[test] -fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { +fn ready_marker_keys_on_the_intent_id_never_the_server_receipt() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); @@ -262,25 +273,23 @@ fn ready_marker_keys_on_the_client_uid_when_the_server_diverges() { let order = order.clone(); src(move |_, _, _, _| ready_outcome(&order)) }; - let (result, logs) = capture_tracing(|| run(&host, &source, &sample_tick())); - result.unwrap(); + run(&host, &source, &sample_tick()).unwrap(); let snapshot = host.store.snapshot(); - assert!(snapshot.contains_key(&format!("submitted:{}", client_uid(&order)))); + assert!(snapshot.contains_key(&format!("submitted:{}", intent_id(&order)))); assert!( !snapshot.contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID", + "marker must key on the pre-submit intent-id, not the server receipt", ); - assert!(logs.any(|e| e.message.contains("UID divergence"))); } #[test] -fn ready_skips_the_orderbook_when_the_receipt_is_journalled() { +fn ready_skips_the_orderbook_when_the_intent_id_is_journalled() { let host = MockHost::new(); seed_watch(&host); let order = submittable_order(); Journal::submitted(&host) - .record(&client_uid(&order)) + .record(&intent_id(&order)) .unwrap(); let polls = Cell::new(0_u32); @@ -422,9 +431,9 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert!(host.store.snapshot().contains_key(&key)); assert!( Journal::submitted(&host) - .contains(&client_uid(&order)) + .contains(&intent_id(&order)) .unwrap(), - "already-submitted must record the receipt", + "already-submitted must journal the intent-id", ); // The next tick must not touch the orderbook again. @@ -432,6 +441,44 @@ fn duplicated_order_records_the_receipt_and_keeps_the_watch() { assert_eq!(host.cow_api.call_count(), 1); } +/// Restart regression: a keeper that posted, journalled, and then +/// restarted over the same persistent local store must not post the +/// same order again - one orderbook POST across both lives. +#[test] +fn restart_with_a_journalled_intent_does_not_repost() { + let host = MockHost::new(); + seed_watch(&host); + let order = submittable_order(); + host.cow_api.respond(Ok("0xserveruid".to_string())); + + let source = { + let order = order.clone(); + src(move |_, _, _, _| ready_outcome(&order)) + }; + run(&host, &source, &sample_tick()).unwrap(); + assert_eq!(host.cow_api.call_count(), 1); + + // A restarted keeper: fresh instance, the local store carried over. + let restarted = MockHost::new(); + for (key, value) in host.store.snapshot() { + restarted.store.set(&key, &value).unwrap(); + } + restarted.cow_api.respond(Ok("0xserveruid".to_string())); + + run(&restarted, &source, &sample_tick()).unwrap(); + + assert_eq!( + host.cow_api.call_count() + restarted.cow_api.call_count(), + 1, + "resubmit after restart must make no second orderbook POST", + ); + assert!( + Journal::submitted(&restarted) + .contains(&intent_id(&order)) + .unwrap(), + ); +} + /// A rate-limit fault with server guidance backs the watch off on the /// epoch clock - `RetryAction::Backoff` reached through the ledger. #[test] diff --git a/crates/videre-sdk/src/keeper.rs b/crates/videre-sdk/src/keeper.rs index f94a88f8..7255efd9 100644 --- a/crates/videre-sdk/src/keeper.rs +++ b/crates/videre-sdk/src/keeper.rs @@ -63,7 +63,7 @@ impl Keeper { /// Sweep the watch set once at `tick`: poll every ready watch, /// submit [`Sweep::Submit`] bodies through the venue seam, and /// run every other outcome and every venue refusal through the - /// [`Retrier`]. A venue-and-body key is checked against the + /// [`Retrier`]. The [`submission_key`] is checked against the /// `submitted:` [`Journal`] before every submit and recorded on /// acceptance, so an accepted body never reaches the venue twice; /// a `requires-signing` answer journals nothing and is surfaced @@ -156,8 +156,10 @@ pub struct SweepReport { /// Deterministic pre-submit journal key: the venue id and the /// keccak-256 of the body. The hash is a fixed-length suffix, so the -/// key is unambiguous whatever the venue id contains. -fn submission_key(venue: &VenueId, body: &[u8]) -> String { +/// key is unambiguous whatever the venue id contains. Public so a +/// keeper journalling outside [`Keeper::sweep`] writes the key the +/// sweep checks. +pub fn submission_key(venue: &VenueId, body: &[u8]) -> String { format!("{venue}:{}", hex::encode_prefixed(keccak256(body))) } diff --git a/modules/twap-monitor/src/strategy.rs b/modules/twap-monitor/src/strategy.rs index a9bcee04..540a6c7e 100644 --- a/modules/twap-monitor/src/strategy.rs +++ b/modules/twap-monitor/src/strategy.rs @@ -236,8 +236,15 @@ fn parse_watch_key(key: &str) -> Option<(&str, &str)> { } #[cfg(test)] -fn compute_uid_hex(chain_id: u64, order: &GPv2OrderData, owner: Address) -> Option { - shepherd_sdk::cow::order_uid_hex(chain_id, order, owner) +fn compute_intent_id(order: &GPv2OrderData, signature: &Bytes, owner: Address) -> Option { + use shepherd_sdk::cow::{CowIntent, CowIntentBody, SignedOrder}; + let order_data = shepherd_sdk::cow::gpv2_to_order_data(order)?; + shepherd_sdk::cow::intent_id(&CowIntentBody::V1(CowIntent::Signed(SignedOrder { + order: shepherd_sdk::cow::order_data_to_body(&order_data), + owner: owner.into_array(), + signature: signature.to_vec(), + }))) + .ok() } #[cfg(test)] @@ -493,7 +500,7 @@ mod tests { } #[test] - fn poll_ready_submits_order_and_persists_submitted_uid() { + fn poll_ready_submits_order_and_persists_the_intent_id() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -509,30 +516,22 @@ mod tests { ); host.cow_api.respond(Ok("0xfeedface".to_string())); - let (result, logs) = capture_tracing(|| on_block(&host, sample_block(1_000))); - result.unwrap(); + on_block(&host, sample_block(1_000)).unwrap(); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert_eq!(host.chain.call_count(), 1); assert_eq!(host.cow_api.call_count(), 1); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "expected submitted:{{client_uid}} marker" + .contains_key(&format!("submitted:{expected_id}")), + "expected submitted:{{intent_id}} marker" ); assert!( !host.store.snapshot().contains_key("submitted:0xfeedface"), - "marker must key on the client UID, not the divergent server UID" + "marker must key on the pre-submit intent-id, not the server receipt" ); - // The MockHost orderbook stub returns `0xfeedface` instead of - // the canonical UID; the strategy logs a Warn about the - // divergence (real orderbooks would not diverge). - let ev = logs - .expect_one(|e| e.level == Level::WARN && e.message.contains("twap UID divergence")); - assert!(ev.message.contains(&format!("client={expected_uid}"))); - assert!(ev.message.contains("server=0xfeedface")); } /// Regression guard: when `getTradeableOrderWithSignature` @@ -541,10 +540,10 @@ mod tests { /// POSTed it), the second tick must NOT call `submit_order` /// again. Without the guard the orderbook responds with /// `DuplicatedOrder` and a Warn fires for what is in fact - /// correct, finished work. The guard is the `submitted:{uid}` + /// correct, finished work. The guard is the `submitted:{intent_id}` /// short-circuit at the top of `submit_ready`. #[test] - fn poll_ready_skips_submit_when_submitted_uid_already_in_store() { + fn poll_ready_skips_submit_when_the_intent_id_is_already_journalled() { let host = MockHost::new(); let owner = address!("0011223344556677889900AABBCCDDEEFF001122"); let params = sample_params(); @@ -562,10 +561,10 @@ mod tests { // Seed the marker that a previous successful poll-tick would // have written. The poll path must read this and skip; the // orderbook submit must not be attempted. - let already_submitted_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let already_submitted = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); host.store - .set(&format!("submitted:{already_submitted_uid}"), b"") + .set(&format!("submitted:{already_submitted}"), b"") .expect("seed submitted marker"); on_block(&host, sample_block(1_000)).unwrap(); @@ -578,7 +577,7 @@ mod tests { assert_eq!( host.cow_api.call_count(), 0, - "submit_order must NOT be called when submitted:{{uid}} already exists", + "submit_order must NOT be called when submitted:{{intent_id}} already exists", ); assert_eq!( host.cow_api.request_calls().len(), @@ -636,13 +635,13 @@ mod tests { body.get("appDataHash").is_none(), "hash-only body must omit appDataHash, got: {body}" ); - let expected_uid = compute_uid_hex(SEPOLIA, &ready_order, owner) - .expect("Sepolia is supported + canonical markers"); + let expected_id = + compute_intent_id(&ready_order, &signature, owner).expect("canonical markers"); assert!( host.store .snapshot() - .contains_key(&format!("submitted:{expected_uid}")), - "submitted:{{client_uid}} marker must be written after a successful submit" + .contains_key(&format!("submitted:{expected_id}")), + "submitted:{{intent_id}} marker must be written after a successful submit" ); }