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
68 changes: 67 additions & 1 deletion crates/cow-venue/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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<u8> {
signed_bytes()
}

fn presign_body() -> Vec<u8> {
order_bytes()
}

fn receipt() -> Vec<u8> {
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<SubmitOutcome, VenueFault> {
submit_with(fetch, &Self::cfg(), body).map_err(VenueFault::from)
}

fn status(fetch: &MockFetch, receipt: &[u8]) -> Result<IntentStatus, VenueFault> {
status_with(fetch, &Self::cfg(), receipt).map_err(VenueFault::from)
}
}

videre_test::venue_reconcile_compliance!(CowReconcile);
}
42 changes: 40 additions & 2 deletions crates/videre-sdk/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -135,6 +137,42 @@ pub trait VenueTransport: sealed::SealedTransport {
) -> impl Future<Output = Result<(), VenueFault>>;
}

/// 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
Expand Down
4 changes: 3 additions & 1 deletion crates/videre-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/videre-test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
108 changes: 108 additions & 0 deletions crates/videre-test/src/reconcile.rs
Original file line number Diff line number Diff line change
@@ -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<u8>;
/// A pre-sign body the venue accepts.
fn presign_body() -> Vec<u8>;
/// The body-derived receipt a held body resolves to.
fn receipt() -> Vec<u8>;
/// 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<SubmitOutcome, VenueFault>;
/// Read a receipt's status through the adapter over `fetch`.
fn status(fetch: &MockFetch, receipt: &[u8]) -> Result<IntentStatus, VenueFault>;
}

/// 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<F: ReconcileFixture>(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<F: ReconcileFixture>() {
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<F: ReconcileFixture>() {
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>();
}
};
}
Loading