From 41bc9f09e053684724b32cc39cd572c541dcb610 Mon Sep 17 00:00:00 2001 From: mfw78 Date: Wed, 9 Sep 2026 05:40:04 +0000 Subject: [PATCH] fix(cow): keep a commitment the orderbook can still be asked about Bumps the videre pin to 9ab1515 and supplies the CoW fault policy the seam there exists for. A receipt this keeper cannot correlate used to remove the commitment. The fork run reached `Post`, submitted, got a receipt that did not match the order, and destroyed a commitment whose order may well have been accepted. Only a re-registration brought it back. The orderbook keys an order by its uid and dedupes on that uid, so re-sending one it already holds comes back as already held rather than executed twice. That idempotency is what makes the retry safe, and it is a fact about this venue rather than about venues, which is why it is asserted here and not in the platform default. Denials now route through the shipped table for the reconcile pass as well as the submit path. They did not before: reconcile took the platform default, which knows nothing of `classification.toml`, so a stranded reservation for a clearable refusal was released while the same refusal on the submit path backed off. Closes #693. AI Assistance: Claude Code used for the policy and the tests. --- Cargo.lock | 10 +-- crates/composable-cow/Cargo.toml | 2 +- crates/composable-cow/src/run.rs | 19 +++--- crates/composable-cow/tests/run.rs | 34 ++++++++++ crates/cow-venue/Cargo.toml | 6 +- crates/cow-venue/src/classification.rs | 93 ++++++++++++++++++++++++++ crates/cow-venue/src/lib.rs | 4 +- crates/shepherd-engine/Cargo.toml | 4 +- modules/ccow-monitor/Cargo.toml | 2 +- modules/ethflow-watcher/Cargo.toml | 2 +- 10 files changed, 151 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db64625b..3a674db2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6035,7 +6035,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "videre-host" version = "0.1.0" -source = "git+https://github.com/nullislabs/videre-nexum-module?rev=fd8af027d9ae84823f8fcdcc6902b3af80cab451#fd8af027d9ae84823f8fcdcc6902b3af80cab451" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=9ab15152b9279974cb42c3ee3aff054344f6050d#9ab15152b9279974cb42c3ee3aff054344f6050d" dependencies = [ "anyhow", "derive_more", @@ -6053,7 +6053,7 @@ dependencies = [ [[package]] name = "videre-macros" version = "0.1.0" -source = "git+https://github.com/nullislabs/videre-nexum-module?rev=fd8af027d9ae84823f8fcdcc6902b3af80cab451#fd8af027d9ae84823f8fcdcc6902b3af80cab451" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=9ab15152b9279974cb42c3ee3aff054344f6050d#9ab15152b9279974cb42c3ee3aff054344f6050d" dependencies = [ "nexum-world", "proc-macro2", @@ -6065,7 +6065,7 @@ dependencies = [ [[package]] name = "videre-sdk" version = "0.1.0" -source = "git+https://github.com/nullislabs/videre-nexum-module?rev=fd8af027d9ae84823f8fcdcc6902b3af80cab451#fd8af027d9ae84823f8fcdcc6902b3af80cab451" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=9ab15152b9279974cb42c3ee3aff054344f6050d#9ab15152b9279974cb42c3ee3aff054344f6050d" dependencies = [ "borsh", "http", @@ -6081,7 +6081,7 @@ dependencies = [ [[package]] name = "videre-status-body" version = "0.1.0" -source = "git+https://github.com/nullislabs/videre-nexum-module?rev=fd8af027d9ae84823f8fcdcc6902b3af80cab451#fd8af027d9ae84823f8fcdcc6902b3af80cab451" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=9ab15152b9279974cb42c3ee3aff054344f6050d#9ab15152b9279974cb42c3ee3aff054344f6050d" dependencies = [ "borsh", "thiserror 2.0.18", @@ -6090,7 +6090,7 @@ dependencies = [ [[package]] name = "videre-test" version = "0.1.0" -source = "git+https://github.com/nullislabs/videre-nexum-module?rev=fd8af027d9ae84823f8fcdcc6902b3af80cab451#fd8af027d9ae84823f8fcdcc6902b3af80cab451" +source = "git+https://github.com/nullislabs/videre-nexum-module?rev=9ab15152b9279974cb42c3ee3aff054344f6050d#9ab15152b9279974cb42c3ee3aff054344f6050d" dependencies = [ "borsh", "hex", diff --git a/crates/composable-cow/Cargo.toml b/crates/composable-cow/Cargo.toml index 3809e6d3..f8ec91a2 100644 --- a/crates/composable-cow/Cargo.toml +++ b/crates/composable-cow/Cargo.toml @@ -22,7 +22,7 @@ nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2ed882 # `run` slice: the keeper run over the typed CoW client on the # `videre:venue/client` seam. cow-venue = { path = "../cow-venue", features = ["client", "assembly"], optional = true } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451", optional = true } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d", optional = true } tracing = { workspace = true, optional = true } [features] diff --git a/crates/composable-cow/src/run.rs b/crates/composable-cow/src/run.rs index 5376c6cd..02671725 100644 --- a/crates/composable-cow/src/run.rs +++ b/crates/composable-cow/src/run.rs @@ -11,13 +11,13 @@ //! never dropped. //! //! Store faults abort the run (the next tick replays it); a submission -//! failure folds into a [`RetryAction`], a `denied` refusal re-entering -//! the CoW classification by its errorType prefix ([`classify_denied`]). +//! failure folds into a [`RetryAction`] through [`CowFaults`], which the +//! reconcile pass reads too, so both paths classify a refusal alike. //! Diagnostics go through the guest `tracing` facade. use alloy_primitives::{Address, Bytes, hex}; use cow_venue::assembly::{gpv2_to_order_data, order_data_to_body}; -use cow_venue::{CowClient, CowIntent, CowIntentBody, CowVenue, SignedOrder, classify_denied}; +use cow_venue::{CowClient, CowFaults, CowIntent, CowIntentBody, CowVenue, SignedOrder}; use cowprotocol::GPv2OrderData; use nexum_sdk::host::{Fault, ListQuery, LocalStoreHost}; use nexum_sdk::keeper::{ @@ -26,11 +26,10 @@ use nexum_sdk::keeper::{ }; use std::task::Poll; +use videre_sdk::FaultPolicy as _; use videre_sdk::client::poll_once; -use videre_sdk::keeper::{retry_action, submission_key}; -use videre_sdk::{ - ClientError, IntentBody as _, SubmitOutcome, Venue as _, VenueFault, VenueTransport, -}; +use videre_sdk::keeper::submission_key; +use videre_sdk::{ClientError, IntentBody as _, SubmitOutcome, Venue as _, VenueTransport}; use crate::{NextPoll, Verdict}; @@ -57,6 +56,7 @@ where &journal, tick, videre_sdk::DEFAULT_RECONCILE_BUDGET, + &CowFaults, )) { Poll::Ready(res) => { res?; @@ -429,10 +429,7 @@ where tracing::error!("intent body encode failed: {err}"); } Err(ClientError::Venue(fault)) => { - let action = match &fault { - VenueFault::Denied(detail) => classify_denied(detail), - other => retry_action(other), - }; + let action = CowFaults.action(&fault); Retrier::new(host).apply(commitment, action, tick)?; match action { RetryAction::TryNextBlock => tracing::warn!("submit retry-next-block: {fault}"), diff --git a/crates/composable-cow/tests/run.rs b/crates/composable-cow/tests/run.rs index a80b5072..b2a2ee24 100644 --- a/crates/composable-cow/tests/run.rs +++ b/crates/composable-cow/tests/run.rs @@ -1946,3 +1946,37 @@ fn an_insufficient_valid_to_keeps_the_commitment() { assert!(host.store.snapshot().contains_key(&key)); } + +/// The reconcile pass must read the venue's policy, not the platform +/// default. A stranded reservation answered with a receipt this keeper +/// cannot correlate is kept, because the orderbook dedupes on the order +/// uid and the order may be on the book; releasing it forgets a submit +/// that is owed. +#[test] +fn reconcile_keeps_a_reservation_the_cow_policy_can_retry() { + let host = MockHost::new(); + seed_commitment(&host); + let key = "cow:0xdeadbeef"; + Journal::submitted(&host) + .reserve(key, b"body") + .expect("reserve"); + + let venue = MockVenue::default(); + venue.enqueue_submit(Err(VenueFault::ReceiptMismatch)); + + run( + &host, + &client(&venue), + &src(|_, _, _, _| Verdict::TryNextBlock { + reason: Selector::ZERO, + }), + &sample_tick(), + ) + .unwrap(); + + assert_eq!( + Journal::submitted(&host).mark(key).unwrap(), + Some(Mark::Reserved), + "the reservation survives a fault the venue can retry", + ); +} diff --git a/crates/cow-venue/Cargo.toml b/crates/cow-venue/Cargo.toml index 5fcf92b0..cb64655b 100644 --- a/crates/cow-venue/Cargo.toml +++ b/crates/cow-venue/Cargo.toml @@ -16,7 +16,7 @@ workspace = true borsh = { workspace = true, optional = true } # Source of the `IntentBody` derive and trait the version enum implements, # and the typed intent client the `client` slice binds to the CoW venue. -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451", optional = true } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d", optional = true } # `client` slice only: the keeper `RetryAction` the generated # classification table maps each errorType to. The TOML parse happens in # `build.rs`, so serde/toml/thiserror are build- and dev-only and never @@ -39,7 +39,7 @@ serde_json = { workspace = true, optional = true } http = { workspace = true, optional = true } url = { workspace = true, optional = true } # The registration seam: `VenueInvoker`, `VenueRegistry`, and `Liveness`. -videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451", optional = true } +videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d", optional = true } # `BoxFuture` plus `FutureExt::boxed`, the shape `VenueInvoker` returns. futures = { workspace = true, optional = true } # The venue owns its HTTP client; there is no scoped host import left to @@ -59,7 +59,7 @@ serde = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } # The conformance kit: holds the body codec to its published vector set. -videre-test = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451" } +videre-test = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d" } # Parity tests: the upstream `retry_hint()` the shipped table is # reconciled against. cowprotocol = { version = "0.2.0", default-features = false } diff --git a/crates/cow-venue/src/classification.rs b/crates/cow-venue/src/classification.rs index 4e5a9c86..ebdf80d8 100644 --- a/crates/cow-venue/src/classification.rs +++ b/crates/cow-venue/src/classification.rs @@ -112,6 +112,39 @@ pub fn is_already_submitted(error_type: OrderbookApiErrorType) -> bool { table().is_already_submitted(&error_type) } +/// How long a receipt this keeper cannot correlate holds a commitment +/// out of the rotation. +pub const RECEIPT_BACKOFF_S: u64 = 300; + +/// The CoW orderbook's fault policy. +/// +/// A submission is an order keyed by its uid, and the orderbook dedupes +/// on that uid, so re-sending one it already accepted comes back as +/// already held rather than executed twice. That idempotency is what +/// makes a receipt this keeper cannot correlate worth retrying: the +/// order may be on the book, and removing the commitment would forget +/// it. `videre` cannot assume that of a venue, which is why it is +/// asserted here rather than in the default. +/// +/// Denials route through the shipped table, so the reconcile pass and +/// the submit path read the same policy. They did not before: reconcile +/// took the platform default while the submit path took the table. +#[derive(Clone, Copy, Debug, Default)] +pub struct CowFaults; + +impl videre_sdk::FaultPolicy for CowFaults { + fn action(&self, fault: &videre_sdk::VenueFault) -> RetryAction { + use videre_sdk::VenueFault; + match fault { + VenueFault::Denied(detail) => classify_denied(detail), + VenueFault::InvalidReceipt | VenueFault::ReceiptMismatch => RetryAction::Backoff { + seconds: RECEIPT_BACKOFF_S, + }, + other => videre_sdk::retry_action(other), + } + } +} + /// Retry action for a coarse `denied` refusal: the `{errorType}:` /// prefix re-enters the table. /// @@ -376,4 +409,64 @@ mod tests { assert!(first.contains_key("error-type")); assert!(first.contains_key("action")); } + + /// The orderbook dedupes on the order uid, so a receipt this keeper + /// cannot correlate may still name an order that is on the book. + /// Removing the commitment would forget it. + #[test] + fn a_receipt_fault_backs_off_rather_than_removing() { + use videre_sdk::FaultPolicy as _; + + for fault in [ + videre_sdk::VenueFault::ReceiptMismatch, + videre_sdk::VenueFault::InvalidReceipt, + ] { + assert_eq!( + CowFaults.action(&fault), + RetryAction::Backoff { + seconds: RECEIPT_BACKOFF_S, + }, + "{fault:?}", + ); + assert!(!CowFaults.is_terminal(&fault), "{fault:?} must not release"); + } + } + + /// Denials route through the shipped table, so the reconcile pass + /// reads the same policy the submit path does. It took the platform + /// default before, which knows nothing of the table. + #[test] + fn a_denial_routes_through_the_table() { + use videre_sdk::FaultPolicy as _; + + let clearable = + videre_sdk::VenueFault::Denied("InsufficientBalance: not enough".to_owned()); + assert_eq!( + CowFaults.action(&clearable), + RetryAction::Backoff { seconds: 600 }, + ); + assert!(!CowFaults.is_terminal(&clearable)); + + let permanent = videre_sdk::VenueFault::Denied("WrongOwner: not yours".to_owned()); + assert_eq!(CowFaults.action(&permanent), RetryAction::Drop); + assert!(CowFaults.is_terminal(&permanent)); + } + + /// Everything the venue says nothing special about keeps the + /// platform default. + #[test] + fn other_faults_keep_the_platform_default() { + use videre_sdk::FaultPolicy as _; + + for fault in [ + videre_sdk::VenueFault::Timeout, + videre_sdk::VenueFault::InvalidBody("bad".to_owned()), + ] { + assert_eq!( + CowFaults.action(&fault), + videre_sdk::retry_action(&fault), + "{fault:?}", + ); + } + } } diff --git a/crates/cow-venue/src/lib.rs b/crates/cow-venue/src/lib.rs index f2829853..482926a1 100644 --- a/crates/cow-venue/src/lib.rs +++ b/crates/cow-venue/src/lib.rs @@ -60,6 +60,8 @@ pub use cowprotocol::Chain; pub use transport::{OrderbookHttp, Transport}; #[cfg(feature = "client")] -pub use classification::{ClassificationTable, classify, classify_denied, is_already_submitted}; +pub use classification::{ + ClassificationTable, CowFaults, classify, classify_denied, is_already_submitted, +}; #[cfg(feature = "client")] pub use client::{CowClient, CowVenue, VENUE_ID, intent_id}; diff --git a/crates/shepherd-engine/Cargo.toml b/crates/shepherd-engine/Cargo.toml index 8b5a5b79..9c4979b7 100644 --- a/crates/shepherd-engine/Cargo.toml +++ b/crates/shepherd-engine/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] nexum-launch = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2ed882f21e685921cd27d1edec870e8adbb312aa" } nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2ed882f21e685921cd27d1edec870e8adbb312aa" } -videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451" } +videre-host = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d" } # The CoW venue, a native `videre_host::VenueInvoker` linked into this # binary. It was a guest wasm component the operator wired by path until @@ -43,4 +43,4 @@ nexum-runtime = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2e # The versioned status-body codec the intent-status envelope carries; the # tests build a `StatusBody` and encode it into an `IntentStatusUpdate`. # Pinned to the same videre rev as `videre-host`. -videre-status-body = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451" } +videre-status-body = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d" } diff --git a/modules/ccow-monitor/Cargo.toml b/modules/ccow-monitor/Cargo.toml index d5c6ce76..dbbac47f 100644 --- a/modules/ccow-monitor/Cargo.toml +++ b/modules/ccow-monitor/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["cdylib"] composable-cow = { path = "../../crates/composable-cow", features = ["run"] } cow-venue = { path = "../../crates/cow-venue", features = ["client"] } nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2ed882f21e685921cd27d1edec870e8adbb312aa" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d" } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] } alloy-sol-types = { version = "1.6", default-features = false, features = ["std"] } tracing = { version = "0.1", default-features = false } diff --git a/modules/ethflow-watcher/Cargo.toml b/modules/ethflow-watcher/Cargo.toml index 50322b00..bd94a3cc 100644 --- a/modules/ethflow-watcher/Cargo.toml +++ b/modules/ethflow-watcher/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib"] [dependencies] nexum-sdk = { git = "https://github.com/nullislabs/nexum-runtime", rev = "2ed882f21e685921cd27d1edec870e8adbb312aa" } -videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "fd8af027d9ae84823f8fcdcc6902b3af80cab451" } +videre-sdk = { git = "https://github.com/nullislabs/videre-nexum-module", rev = "9ab15152b9279974cb42c3ee3aff054344f6050d" } cow-venue = { path = "../../crates/cow-venue", features = ["client", "assembly"] } cowprotocol = { version = "0.2.0", default-features = false } alloy-primitives = { version = "1.6", default-features = false, features = ["std"] }