Skip to content
Merged
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
19 changes: 16 additions & 3 deletions crates/cow-venue/src/body.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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()));
Expand Down
48 changes: 47 additions & 1 deletion crates/cow-venue/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment (and the PR body) states "SubmitOutcome's accepted receipt is now the canonical 56-byte OrderUid," but nothing in this diff actually wires OrderUid into submit_order's return type or SubmitOutcome — neither symbol is touched here. OrderUid is added with solid conversions and round-trip tests, but it's constructed only in this PR's own unit tests; no production call site builds one from a real submit response yet. Worth either wiring it into the real return path in this PR, or scoping this comment/the PR description to "the type is added, integration follows" so it doesn't read as already-shipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct as written, and the diff confirms it: this PR adds exactly that one doc line to client.rs and touches neither submit_order nor SubmitOutcome. OrderUid is built only in this PR's own unit tests.

The wiring lands in the very next car, #467 (feat/m4-cow-adapter-cdylib), which adds cow-venue/src/adapter.rs. There post_order decodes the orderbook's uid from the success response and submit returns SubmitOutcome::Accepted(uid.as_slice().to_vec()) (:174), the already-held path falls back to the derived assembly::order_uid rather than inventing a receipt (:172), the status path rejects anything that is not a 56-byte order uid (:203-204), and the tests assert receipt == uid.as_slice(). So the claim is accurate one car later, and byte-identical at the end-of-train tip where it holds.

I have left the code comment alone rather than spend a re-push and a full CI run on a line that is true from the next car onward, and scoped the PR body instead, which is where it read as already shipped. If you would rather the comment itself carry the "type added here, integration in #467" hedge for the duration of one car, say so and I will fold it into the next ripple.

#[derive(Clone, Copy, Debug)]
pub struct CowVenue;

Expand All @@ -26,6 +31,19 @@ impl Venue for CowVenue {
/// or submit a foreign body.
pub type CowClient<T = HostVenues> = VenueClient<CowVenue, T>;

/// 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<String, BodyError> {
Ok(submission_key(&CowVenue::ID, &body.to_bytes()?))
}

#[cfg(test)]
mod tests {
use std::cell::RefCell;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions crates/cow-venue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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};
98 changes: 98 additions & 0 deletions crates/cow-venue/src/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -259,6 +261,70 @@ impl OrderBuilder<Ready> {
}
}

/// 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SignedOrder derives BorshSerialize over the whole struct including signature: Vec<u8>, and intent_id hashes the full encoded body — so the signature bytes are part of the dedup key. That's fine for collision-safety, but it means a legitimate re-sign of the same underlying order (same order+owner, fresh EIP-1271 signature after e.g. a keeper restart mid-flight, before the first attempt's journal write landed) produces a different intent_id and defeats dedup rather than falsely triggering it — the opposite failure mode from what this PR is guarding against. Concrete scenario: keeper signs, crashes before journaling, restarts, re-signs the same order, and if the first submit actually reached the orderbook, the second (differently-keyed) attempt isn't recognized as a duplicate. Worth either hashing (venue, owner, order) and excluding signature, or documenting explicitly that this key scopes to "this exact signed payload," not "this economic order."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact is confirmed at the end-of-train tip: the sweep builds CowIntentBody::V1(CowIntent::Signed(SignedOrder { order, owner, signature })) (composable-cow/src/sweep.rs:120) and intent_id hashes the whole encoded body, so the signature bytes really are in the dedup key. Filed as #558.

The concrete scenario does not arise on this path though, for two independent reasons, both checked rather than assumed.

The keeper never signs. submit_ready receives the signature from source.poll's Verdict::Post { order, signature, .. } (composable-cow/src/sweep.rs:60), so the bytes come from the conditional-order source rather than being generated per attempt. For ComposableCoW that is the on-chain proof, and a restart re-reads the same deterministic bytes for the same order and params. There is no "re-sign" step in the loop to produce fresh bytes.

Even a genuinely different signature costs only one redundant POST. The orderbook's identity for an order is signature-independent: assembly::order_uid is order.uid(&chain.settlement_domain(), owner). So a differently-keyed second attempt at the same economic order still lands on the duplicate path, classification::is_already_submitted maps it to Refusal::AlreadyHeld (cow-venue/src/adapter.rs:391), and the adapter answers SubmitOutcome::Accepted with the derived canonical uid (:172). It comes back accepted, not double-posted.

So the failure mode is milder than "defeats dedup": dedup degrades to the venue's own idempotency rather than breaking. What genuinely remains is your second option, documenting that the key scopes to this exact signed payload and not to the economic order, plus the latent hazard that the argument above leans on a venue whose duplicate response is idempotent. #558 carries both, with documenting as the recommendation and excluding the signature from the key as the alternative.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisited this and folded it into the PR rather than leaving it to the tracker, taking your documentation option.

intent_id's rustdoc now says the key covers the encoded body, so a signed payload keys on its signature and scopes to that exact payload rather than the economic order, which dedups only through the venue's duplicate response.

Folding was safe on this car specifically: cow-venue/src/client.rs is touched by no other open car in the train, and the only later change is #479's grouping move, a pure R100 rename with a zero-byte content delta.

The analysis above still stands on why the runtime behaviour is sound today, so this is a precision fix to the contract rather than a behaviour change. #558 stays open only to record the alternative (keying on (venue, owner, order)) should a venue ever be added whose duplicate response is not idempotent, and #466 now carries Closes #558.

/// 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<u8>,
}

/// 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<Self, Self::Error> {
Ok(Self(<[u8; 56]>::try_from(bytes)?))
}
}

impl From<OrderUid> for Vec<u8> {
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::*;
Expand Down Expand Up @@ -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);
}
}
25 changes: 20 additions & 5 deletions crates/shepherd-sdk-test/tests/mock_venue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -136,15 +151,15 @@ 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()
);

run(&host, &source, &sample_tick()).unwrap();
assert_eq!(host.cow_api.call_count(), 2);
assert!(
Journal::submitted(&host)
.contains(&client_uid(&order))
.contains(&intent_id(&order))
.unwrap()
);
assert_eq!(
Expand Down Expand Up @@ -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()
);
}
Expand Down Expand Up @@ -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()
);
}
Expand Down
4 changes: 2 additions & 2 deletions crates/shepherd-sdk/src/cow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ 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`
/// codec, re-exported from the `cow-venue` default slice. The shim keeps
/// 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;
Expand Down
Loading
Loading