From ec502ae9bf0979ed55b65d4f277ea0b85ccfc197 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Fri, 24 Jul 2026 09:10:12 +0000 Subject: [PATCH] venue: reconcile-contract compliance suite --- crates/cow-venue/src/adapter.rs | 68 +++++++++++++++++- crates/videre-sdk/src/client.rs | 42 ++++++++++- crates/videre-sdk/src/lib.rs | 4 +- crates/videre-test/src/lib.rs | 2 + crates/videre-test/src/reconcile.rs | 108 ++++++++++++++++++++++++++++ 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 crates/videre-test/src/reconcile.rs diff --git a/crates/cow-venue/src/adapter.rs b/crates/cow-venue/src/adapter.rs index 693154ee..aa465e88 100644 --- a/crates/cow-venue/src/adapter.rs +++ b/crates/cow-venue/src/adapter.rs @@ -45,6 +45,12 @@ use crate::order::OrderUid; /// [`VenueAdapter`](videre_sdk::VenueAdapter) impl. pub struct CowAdapter; +// The reconcile floor: an already-held re-POST folds to the same accept +// outcome (`submit_with`, both auth paths) and status GETs the +// body-derived uid, so the adapter honours the contract. +impl videre_sdk::client::sealed::SealedReconcile for CowAdapter {} +impl videre_sdk::VenueReconcile for CowAdapter {} + /// Default per-request timeout bound: the SDK's per-phase default. const DEFAULT_TIMEOUT: Duration = videre_sdk::transport::http::DEFAULT_TIMEOUT; @@ -523,10 +529,11 @@ mod export { #[cfg(test)] mod tests { - use videre_sdk::IntentBody as _; use videre_sdk::transport::BoundedFetch; use videre_sdk::transport::http::FetchError; + use videre_sdk::{IntentBody as _, VenueFault}; use videre_test::MockFetch; + use videre_test::reconcile::ReconcileFixture; use super::*; use crate::body::{CowIntent, CowIntentBody}; @@ -945,4 +952,63 @@ mod tests { )); assert_eq!(fetch.request_count(), 0); } + + // ── reconcile contract ─────────────────────────────────────────── + + /// The shared compliance fixture: one owner-configured config drives + /// both auth paths (a signed order is authorised by its own owner, so + /// the config owner only enables the pre-sign path). + struct CowReconcile; + + impl CowReconcile { + fn cfg() -> AdapterConfig { + with_owner(owner()) + } + + fn uid() -> cowprotocol::OrderUid { + expected_uid(&Self::cfg()) + } + } + + impl ReconcileFixture for CowReconcile { + fn signed_body() -> Vec { + signed_bytes() + } + + fn presign_body() -> Vec { + order_bytes() + } + + fn receipt() -> Vec { + Self::uid().as_slice().to_vec() + } + + fn program_accept(fetch: &MockFetch) { + fetch.respond_to( + http::Method::POST, + ORDERS, + 201, + format!("\"{}\"", Self::uid()), + ); + } + + fn program_already_held(fetch: &MockFetch) { + reject(fetch, "DuplicatedOrder"); + } + + fn program_absent(fetch: &MockFetch) { + let uid = OrderUid::try_from(Self::receipt().as_slice()).expect("uid is 56 bytes"); + fetch.respond_to(http::Method::GET, status_url(&uid), 404, "not found"); + } + + fn submit(fetch: &MockFetch, body: &[u8]) -> Result { + submit_with(fetch, &Self::cfg(), body).map_err(VenueFault::from) + } + + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result { + status_with(fetch, &Self::cfg(), receipt).map_err(VenueFault::from) + } + } + + videre_test::venue_reconcile_compliance!(CowReconcile); } diff --git a/crates/videre-sdk/src/client.rs b/crates/videre-sdk/src/client.rs index 4a4e9443..08a535b0 100644 --- a/crates/videre-sdk/src/client.rs +++ b/crates/videre-sdk/src/client.rs @@ -77,11 +77,13 @@ pub trait Venue { type Body: IntentBody; } -/// Sealing marker for [`VenueTransport`]: a transport opts in by also -/// implementing it. +/// Sealing markers: a transport opts into [`VenueTransport`], and an +/// adapter into [`VenueReconcile`], by also implementing the respective +/// marker. #[doc(hidden)] pub mod sealed { pub trait SealedTransport {} + pub trait SealedReconcile {} } /// The byte-level seam under the typed client: `videre:venue/client` @@ -135,6 +137,42 @@ pub trait VenueTransport: sealed::SealedTransport { ) -> impl Future>; } +/// The reconcile contract a venue adapter honours so a keeper can +/// recover a stranded reservation without double-placing. A marker: it +/// adds no methods, it names the guarantees the adapter's +/// [`submit`](crate::VenueAdapter::submit) and +/// [`status`](crate::VenueAdapter::status) already give. Exactly-once +/// across the external POST is unreachable from the host alone (the call +/// is not inside the reserve transaction), so the floor is venue-side and +/// per-adapter. +/// +/// An adapter opts in by implementing it (and the sealing marker), and +/// proves it with `videre_test::venue_reconcile_compliance!`. +/// +/// # Contract +/// +/// 1. Mandatory re-POST idempotency. A [`submit`](crate::VenueAdapter::submit) +/// of a body the venue already holds resolves to the SAME outcome as +/// the first submit: a signed order folds to +/// [`SubmitOutcome::Accepted`] with the same receipt, a pre-sign order +/// to the same [`SubmitOutcome::RequiresSigning`] call. A held body +/// NEVER surfaces as a terminal [`VenueFault`]: it folds to the accept +/// outcome, never to a classified `denied`. This floor is what makes a +/// reconcile resubmit safe. +/// 2. Optional status fast-path. An adapter MAY derive a receipt from the +/// body, so a reconcile can [`status`](crate::VenueAdapter::status) it +/// first and commit without a redundant POST. +/// [`observe`](VenueTransport::observe) (defaulting `unsupported`) is +/// not the reconcile primitive; `submit` is. +/// +/// # Validity +/// +/// Reconcile trusts venue-side order validity (its `validTo` and limit +/// price); it does not re-poll the watch source. The contract therefore +/// requires adapters to carry self-describing order validity. (Maintainer +/// decision, 2026-07-24.) +pub trait VenueReconcile: crate::VenueAdapter + sealed::SealedReconcile {} + /// Poll a future once and return its state. `videre:venue/client@0.1.0` /// declares plain funcs, so a [`VenueTransport`] over the host import /// resolves on the first poll. [`Poll::Pending`] means a foreign diff --git a/crates/videre-sdk/src/lib.rs b/crates/videre-sdk/src/lib.rs index 5bdec44d..cb77ce80 100644 --- a/crates/videre-sdk/src/lib.rs +++ b/crates/videre-sdk/src/lib.rs @@ -85,7 +85,9 @@ pub mod transport; pub use adapter::VenueAdapter; pub use body::{BodyError, IntentBody}; -pub use client::{ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueTransport}; +pub use client::{ + ClientError, HostVenues, Quoted, Venue, VenueClient, VenueId, VenueReconcile, VenueTransport, +}; pub use faults::VenueFault; pub use keeper::{Keeper, Outcome, RunReport, retry_action}; /// Derive [`IntentBody`] on the outer per-venue version enum. See diff --git a/crates/videre-test/src/lib.rs b/crates/videre-test/src/lib.rs index 2485b000..dd907bd6 100644 --- a/crates/videre-test/src/lib.rs +++ b/crates/videre-test/src/lib.rs @@ -64,6 +64,7 @@ pub mod codec; pub mod fixture; pub mod header; +pub mod reconcile; pub mod reference; pub mod report; pub mod transport; @@ -74,6 +75,7 @@ pub use header::{ GoldenAsset, GoldenAssetAmount, GoldenAuthScheme, GoldenHeader, GoldenSettlement, HeaderGolden, HeaderGoldens, }; +pub use reconcile::ReconcileFixture; pub use report::{ConformanceReport, Violation}; pub use transport::{ ChainCall, Message, MessagingHost, MockChain, MockFetch, MockMessaging, MockTransport, diff --git a/crates/videre-test/src/reconcile.rs b/crates/videre-test/src/reconcile.rs new file mode 100644 index 00000000..20d49e8a --- /dev/null +++ b/crates/videre-test/src/reconcile.rs @@ -0,0 +1,108 @@ +//! The reconcile-contract compliance suite: an adapter proves it honours +//! [`VenueReconcile`](videre_sdk::VenueReconcile) by implementing +//! [`ReconcileFixture`] over its own mock transport and invoking +//! [`venue_reconcile_compliance!`](crate::venue_reconcile_compliance). + +use videre_sdk::{IntentStatus, SubmitOutcome, VenueFault}; + +use crate::MockFetch; + +/// The per-adapter fixtures the compliance suite drives. Implement it on +/// a unit type in the adapter's tests: the `program_*` hooks arm the +/// adapter's own [`MockFetch`], and `submit`/`status` run its paths, +/// lifting the adapter error into [`VenueFault`]. +pub trait ReconcileFixture { + /// A signed body the venue accepts and derives a receipt for. + fn signed_body() -> Vec; + /// A pre-sign body the venue accepts. + fn presign_body() -> Vec; + /// The body-derived receipt a held body resolves to. + fn receipt() -> Vec; + /// Arm the mock to accept a fresh submission. + fn program_accept(fetch: &MockFetch); + /// Arm the mock to reject a submission as already held. + fn program_already_held(fetch: &MockFetch); + /// Arm the mock to answer a status read as not-found. + fn program_absent(fetch: &MockFetch); + /// Submit a body through the adapter over `fetch`. + fn submit(fetch: &MockFetch, body: &[u8]) -> Result; + /// Read a receipt's status through the adapter over `fetch`. + fn status(fetch: &MockFetch, receipt: &[u8]) -> Result; +} + +/// A fresh submit and a re-POST of a held body resolve identically: +/// mandatory re-POST idempotency (contract point 1). +pub fn assert_re_post_idempotent(body: &[u8]) { + let fresh = MockFetch::default(); + F::program_accept(&fresh); + let first = F::submit(&fresh, body).expect("a fresh submit is accepted"); + + let held = MockFetch::default(); + F::program_already_held(&held); + let again = F::submit(&held, body).expect("a held body folds to accepted, never a fault"); + + assert!( + first == again, + "a re-POST of a held body must resolve to the same outcome", + ); +} + +/// A held body never surfaces as a terminal fault, across both auth +/// paths (contract point 1's floor). +pub fn assert_held_never_faults() { + for body in [F::signed_body(), F::presign_body()] { + let held = MockFetch::default(); + F::program_already_held(&held); + assert!( + F::submit(&held, &body).is_ok(), + "a held body must fold to accepted, never a terminal fault", + ); + } +} + +/// An absent status read stays retryable (`unavailable`), never terminal, +/// so a lagging read path does not strand a reconcile. +pub fn assert_status_absent_is_retryable() { + let fetch = MockFetch::default(); + F::program_absent(&fetch); + assert!( + matches!( + F::status(&fetch, &F::receipt()), + Err(VenueFault::Unavailable(_)), + ), + "an absent status read must stay retryable", + ); +} + +/// Instantiate the [`VenueReconcile`](videre_sdk::VenueReconcile) +/// compliance suite for a [`ReconcileFixture`]: re-POST idempotency on +/// both auth paths, a held body never faulting, and an absent status read +/// staying retryable. +#[macro_export] +macro_rules! venue_reconcile_compliance { + ($fixture:ty) => { + #[test] + fn reconcile_signed_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::signed_body(), + ); + } + + #[test] + fn reconcile_presign_re_post_is_idempotent() { + $crate::reconcile::assert_re_post_idempotent::<$fixture>( + &<$fixture as $crate::reconcile::ReconcileFixture>::presign_body(), + ); + } + + #[test] + fn reconcile_held_body_never_faults() { + $crate::reconcile::assert_held_never_faults::<$fixture>(); + } + + #[test] + fn reconcile_status_absent_is_retryable() { + $crate::reconcile::assert_status_absent_is_retryable::<$fixture>(); + } + }; +}