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"] }