Skip to content
Open
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
12 changes: 7 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/composable-cow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
103 changes: 62 additions & 41 deletions crates/composable-cow/src/fork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,34 +212,47 @@ sol! {
}
}

/// Classify a failed poll `eth_call`. Every reachable revert is
/// deterministic on-chain state, so all are terminal; a re-`create`
/// re-indexes through its own event. A payload-free failure is the
/// transport, not the contract, so it stays retryable.
/// What a poll revert says about the commitment.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
/// The contract refused with a selector this build can read.
Named(Selector),
/// The node executed the call and refused with nothing readable.
///
/// The poll interface is closed: a generator answers through
/// `GeneratorResult` codes and the registry's own refusals carry
/// selectors. An empty payload is outside that, so it names a
/// defect. It does not say whose, and the candidates are not alike:
/// a codeless owner is the registration's, a gas cap too low for the
/// handler or a wrong registry address is the operator's.
Unattributed,
/// The call never reached the node.
Transport,
}

/// Classify a poll revert. Attribution of [`Refusal::Unattributed`]
/// needs a second chain read, so it happens at the call site.
#[must_use]
pub fn classify_revert(err: &ChainError) -> Verdict {
pub fn classify_revert(err: &ChainError) -> Refusal {
let ChainError::Rpc(rpc) = err else {
// `ChainError` is `#[non_exhaustive]`: transport faults and any
// future case are payload-free, so they stay retryable.
return Verdict::TryNextBlock {
reason: Selector::ZERO,
};
};
let Some(data) = rpc.data.as_deref() else {
return Verdict::TryNextBlock {
reason: Selector::ZERO,
};
return Refusal::Transport;
};
let Some(reason) = data.get(..4).map(Selector::from_slice) else {
return Verdict::TryNextBlock {
reason: Selector::ZERO,
};
};
// Unrecognised still means the contract refused; a handler `Panic`
// lands here.
Verdict::Invalid { reason }
rpc.data
.as_deref()
.and_then(|data| data.get(..4))
.map_or(Refusal::Unattributed, |s| {
Refusal::Named(Selector::from_slice(s))
})
}

/// How long a payload-free revert takes the commitment out of the
/// rotation.
///
/// Long enough that the poll budget is not spent on a deterministic
/// failure, short enough that a commitment recovers on its own if the
/// cause was the node rather than the registration.
pub const PAYLOAD_FREE_BACKOFF_S: u64 = 3_600;

/// Selectors the classifier recognises, for logging and tests.
#[must_use]
pub fn is_residual_selector(selector: Selector) -> bool {
Expand Down Expand Up @@ -591,7 +604,7 @@ mod tests {
mod residual_tests {
use alloy_primitives::fixed_bytes;
use alloy_sol_types::SolError;
use nexum_sdk::host::RpcError;
use nexum_sdk::host::{Fault, RpcError};

use super::*;

Expand All @@ -614,11 +627,8 @@ mod residual_tests {
]
.map(Selector::from)
{
let verdict = classify_revert(&reverted(Some(selector.to_vec())));
assert!(
matches!(verdict, Verdict::Invalid { reason } if reason == selector),
"{selector:?} produced {verdict:?}",
);
let refusal = classify_revert(&reverted(Some(selector.to_vec())));
assert_eq!(refusal, Refusal::Named(selector), "{selector:?}");
assert!(is_residual_selector(selector));
}
}
Expand All @@ -628,22 +638,33 @@ mod residual_tests {
fn an_unrecognised_selector_is_terminal() {
let panic_selector = Selector::new([0x4e, 0x48, 0x7b, 0x71]);
assert!(!is_residual_selector(panic_selector));
assert!(matches!(
assert_eq!(
classify_revert(&reverted(Some(panic_selector.to_vec()))),
Verdict::Invalid { .. }
));
Refusal::Named(panic_selector),
);
}

/// The closed poll interface specifies neither of these, so both
/// name a defect the caller has to attribute before acting.
#[test]
fn a_payload_free_failure_stays_retryable() {
assert!(matches!(
classify_revert(&reverted(None)),
Verdict::TryNextBlock { .. }
));
assert!(matches!(
classify_revert(&reverted(Some(vec![1, 2]))),
Verdict::TryNextBlock { .. }
));
fn an_unreadable_payload_is_not_attributed_here() {
for data in [None, Some(vec![1, 2])] {
assert_eq!(
classify_revert(&reverted(data.clone())),
Refusal::Unattributed,
"{data:?}",
);
}
}

/// A transport fault never reached the node, so it says nothing
/// about the contract.
#[test]
fn a_transport_fault_is_its_own_class() {
assert_eq!(
classify_revert(&ChainError::Fault(Fault::Unavailable("node down".into()))),
Refusal::Transport,
);
}

/// A rename upstream must fail here, not silently reclassify a
Expand Down
2 changes: 1 addition & 1 deletion crates/composable-cow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub mod poll;
#[cfg(feature = "run")]
pub mod run;

pub use fork::{Mapped, PollResult, Suppressed, classify_revert, map_verdict, to_verdict};
pub use fork::{Mapped, PollResult, Refusal, Suppressed, classify_revert, map_verdict, to_verdict};
pub use poll::{NextPoll, Verdict};
#[cfg(feature = "run")]
pub use run::run;
19 changes: 8 additions & 11 deletions crates/composable-cow/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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};

Expand All @@ -57,6 +56,7 @@ where
&journal,
tick,
videre_sdk::DEFAULT_RECONCILE_BUDGET,
&CowFaults,
)) {
Poll::Ready(res) => {
res?;
Expand Down Expand Up @@ -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}"),
Expand Down
69 changes: 69 additions & 0 deletions crates/composable-cow/tests/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1946,3 +1946,72 @@ fn an_insufficient_valid_to_keeps_the_commitment() {

assert!(host.store.snapshot().contains_key(&key));
}

/// The reconcile pass must read the shipped table, not the platform
/// default which knows nothing of it.
///
/// A stranded reservation refused for a balance the owner can top up is
/// still owed, so it stays reserved. The default would release it, and
/// did before this policy reached reconcile.
#[test]
fn reconcile_keeps_a_reservation_the_table_says_is_clearable() {
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::Denied(
"InsufficientBalance: not enough sell token".into(),
)));

run(
&host,
&client(&venue),
&src(|_, _, _, _| Verdict::TryNextBlock {
reason: Selector::ZERO,
}),
&sample_tick(),
)
.unwrap();

assert_eq!(
Journal::submitted(&host).mark(key).unwrap(),
Some(Mark::Reserved),
"a clearable refusal leaves the submit owed",
);
}

/// And a receipt it cannot correlate ends the submission, because the
/// uid is a pure function of the body: re-posting it fails identically,
/// so leaving the reservation would re-post it on every tick forever.
#[test]
fn reconcile_releases_a_reservation_whose_receipt_cannot_be_correlated() {
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(),
None,
"the reservation is released rather than re-posted forever",
);
}
6 changes: 3 additions & 3 deletions crates/cow-venue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 }
Expand Down
Loading
Loading