diff --git a/Cargo.lock b/Cargo.lock index c23352acee..d1e73a045b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3939,6 +3939,7 @@ dependencies = [ "liquidity-sources", "maplit", "mimalloc", + "mockall", "model", "moka", "num", diff --git a/crates/driver/Cargo.toml b/crates/driver/Cargo.toml index 470395b1c2..af86ad8248 100644 --- a/crates/driver/Cargo.toml +++ b/crates/driver/Cargo.toml @@ -97,6 +97,7 @@ app-data = { workspace = true, features = ["test_helpers"] } contracts = { workspace = true } ethrpc = { workspace = true, features = ["test-util"] } maplit = { workspace = true } +mockall = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["process", "test-util"] } diff --git a/crates/driver/src/domain/competition/mod.rs b/crates/driver/src/domain/competition/mod.rs index 864dbf39dc..eefb8357a9 100644 --- a/crates/driver/src/domain/competition/mod.rs +++ b/crates/driver/src/domain/competition/mod.rs @@ -33,7 +33,7 @@ use { time::Instant, }, tokio::{sync::mpsc, task}, - tracing::{Instrument, instrument}, + tracing::Instrument, }; pub mod auction; @@ -339,11 +339,11 @@ impl Competition { Self::sort_orders(auction, solver_address, order_sorting_strategies) }); - // We can sort the orders and fetch auction data in parallel + // We can sort the orders and fetch auction data in parallel. let (auction, balances, app_data) = tokio::join!(sort_orders_future, tasks.balances, tasks.app_data); - let auction = Self::run_blocking_with_timer("update_orders", move || { + let mut auction = Self::run_blocking_with_timer("update_orders", move || { // Same as before with sort_orders, we use spawn_blocking() because a lot of CPU // bound computations are happening and we want to avoid blocking // the runtime. @@ -351,16 +351,39 @@ impl Competition { }) .await; + let risk_detector = self.risk_detector.clone(); + let flashloans_enabled = self.solver.config().flashloans_enabled; + let liquidity_mode = self.solver.liquidity(); + // We can run bad token filtering and liquidity fetching in parallel - let (liquidity, auction) = tokio::join!( - async { - match self.solver.liquidity() { - solver::Liquidity::Fetch => tasks.liquidity.await, - solver::Liquidity::Skip => Arc::new(Vec::new()), + let (auction, liquidity) = tokio::join!( + tokio::spawn( + async move { + risk_detector + .without_unsupported_orders(&mut auction.orders, flashloans_enabled) + .await; + auction } - }, - self.without_unsupported_orders(auction) + .in_current_span(), + ), + tokio::spawn( + async move { + match liquidity_mode { + solver::Liquidity::Fetch => tasks.liquidity.await, + solver::Liquidity::Skip => Arc::new(Vec::new()), + } + } + .in_current_span(), + ), ); + let auction = auction.map_err(|err| { + tracing::error!(?err, "order filtering task failed"); + Error::InternalError(err.to_string()) + })?; + let liquidity = liquidity.map_err(|err| { + tracing::error!(?err, "liquidity fetch task failed"); + Error::InternalError(err.to_string()) + })?; let elapsed = start.elapsed(); metrics::get() @@ -951,16 +974,6 @@ impl Competition { } Ok(()) } - - #[instrument(skip_all)] - async fn without_unsupported_orders(&self, mut auction: Auction) -> Auction { - if !self.solver.config().flashloans_enabled { - auction.orders.retain(|o| o.app_data.flashloan().is_none()); - } - self.risk_detector - .filter_unsupported_orders_in_auction(auction) - .await - } } const MAX_SOLUTIONS_TO_MERGE: usize = 10; @@ -1077,4 +1090,6 @@ pub enum Error { NoValidOrdersFound, #[error("could not parse the request")] MalformedRequest, + #[error("internal error: {0}")] + InternalError(String), } diff --git a/crates/driver/src/domain/competition/risk_detector/bad_tokens/simulation.rs b/crates/driver/src/domain/competition/risk_detector/bad_tokens/simulation.rs index a6018e6035..3ae4866779 100644 --- a/crates/driver/src/domain/competition/risk_detector/bad_tokens/simulation.rs +++ b/crates/driver/src/domain/competition/risk_detector/bad_tokens/simulation.rs @@ -5,10 +5,12 @@ use { infra::{self, observe::metrics}, }, bad_tokens::{TokenQuality, trace_call::TraceCallDetectorRaw}, + eth_domain_types as eth, futures::FutureExt, model::interaction::InteractionData, request_sharing::BoxRequestSharing, std::{ + ops::Deref, sync::Arc, time::{Duration, Instant}, }, @@ -21,6 +23,29 @@ use { #[derive(Clone)] pub struct Detector(Arc); +#[cfg_attr(test, mockall::automock)] +#[async_trait::async_trait] +pub trait DetectorApi: Send + Sync { + async fn determine_sell_token_quality(&self, order: &Order, now: Instant) -> Quality; + fn get_quality(&self, token: ð::TokenAddress, now: Instant) -> Quality; + fn evict_outdated_entries(&self); +} + +#[async_trait::async_trait] +impl DetectorApi for Detector { + async fn determine_sell_token_quality(&self, order: &Order, now: Instant) -> Quality { + self.determine_sell_token_quality(order, now).await + } + + fn get_quality(&self, token: ð::TokenAddress, now: Instant) -> Quality { + Deref::deref(self).get_quality(token, now) + } + + fn evict_outdated_entries(&self) { + Deref::deref(self).evict_outdated_entries() + } +} + struct Inner { cache: Cache, detector: TraceCallDetectorRaw, @@ -107,7 +132,7 @@ impl Detector { } } -impl std::ops::Deref for Detector { +impl Deref for Detector { type Target = Cache; fn deref(&self) -> &Self::Target { diff --git a/crates/driver/src/domain/competition/risk_detector/mod.rs b/crates/driver/src/domain/competition/risk_detector/mod.rs index 357f0b6e04..da68d5f52e 100644 --- a/crates/driver/src/domain/competition/risk_detector/mod.rs +++ b/crates/driver/src/domain/competition/risk_detector/mod.rs @@ -6,20 +6,30 @@ //! was simply built with a buggy compiler which makes it incompatible //! with the settlement contract (see ). //! -//! Additionally there are some heuristics to detect when an +//! Additionally, there are some heuristics to detect when an //! order itself is somehow broken or causes issues and slipped through //! other detection mechanisms. One big error case is orders adjusting //! debt postions in lending protocols. While pre-checks might correctly //! detect that the EIP 1271 signature is valid the transfer of the token //! would fail because the user's debt position is not collateralized enough. -//! In other words the bad order detection is a last fail safe in case +//! In other words the bad order detection is a last fail-safe in case //! we were not able to predict issues with orders and pre-emptively //! filter them out of the auction. + use { - crate::domain::competition::{Auction, order::Uid}, + crate::domain::competition::{ + Order, + order::Uid, + risk_detector::bad_tokens::simulation::DetectorApi, + }, eth_domain_types as eth, futures::{StreamExt, stream::FuturesUnordered}, - std::{collections::HashMap, fmt, time::Instant}, + std::{ + collections::{HashMap, HashSet}, + fmt, + time::Instant, + }, + tracing::instrument, }; pub mod bad_orders; @@ -44,13 +54,22 @@ pub enum Quality { Unknown, } +enum OrderQuality { + /// Order should be kept + Good, + /// Order should not be kept + Bad, + /// Order needs to be evaluated further + Indeterminate, +} + #[derive(Default)] pub struct Detector { /// manually configured list of supported and unsupported tokens. Only /// tokens that get detected incorrectly by the automatic detectors get /// listed here and therefore have a higher precedence. hardcoded: HashMap, - simulation_detector: Option, + simulation_detector: Option>, metrics: Option, } @@ -66,11 +85,8 @@ impl Detector { /// Enables detection of unsupported tokens via simulation based detection /// methods. - pub fn with_simulation_detector( - &mut self, - detector: bad_tokens::simulation::Detector, - ) -> &mut Self { - self.simulation_detector = Some(detector); + pub fn with_simulation_detector(&mut self, detector: impl DetectorApi + 'static) -> &mut Self { + self.simulation_detector = Some(Box::new(detector)); self } @@ -80,72 +96,153 @@ impl Detector { self } - /// Removes all unsupported orders from the auction. - pub async fn filter_unsupported_orders_in_auction(&self, mut auction: Auction) -> Auction { + /// Filters out unsupported and disallowed orders from the auction. + #[instrument(skip_all)] + pub async fn without_unsupported_orders( + &self, + orders: &mut Vec, + flashloans_enabled: bool, + ) { let now = Instant::now(); + // List of orders that have been removed + let mut removed_uids: HashSet = HashSet::new(); + // Choose filtering path depending on whether simulation detector is set + let supported_orders = match &self.simulation_detector { + Some(detector) => { + let supported_orders = self + .supported_orders_with_detector( + orders.drain(..), + flashloans_enabled, + now, + &mut removed_uids, + detector.as_ref(), + ) + .await; + detector.evict_outdated_entries(); + supported_orders + } + None => self.supported_orders_without_detector( + orders.drain(..), + flashloans_enabled, + now, + &mut removed_uids, + ), + }; + if !removed_uids.is_empty() { + tracing::debug!(orders = ?removed_uids, "ignored orders with unsupported tokens"); + } + // Replace the original orders in the auction with supported orders + *orders = supported_orders; + } - // reuse the original allocation - let supported_orders = std::mem::take(&mut auction.orders); - let mut token_quality_checks = FuturesUnordered::new(); - let mut removed_uids = Vec::new(); - - let mut supported_orders: Vec<_> = supported_orders - .into_iter() - .filter(|order| { - self.metrics - .as_ref() - .map(|metrics| metrics.get_quality(&order.uid, now)) - .is_none_or(|q| q != Quality::Unsupported) - }) - .filter_map(|order| { - let sell = self.get_token_quality(order.sell.token, now); - let buy = self.get_token_quality(order.buy.token, now); - match (sell, buy) { - // both tokens supported => keep order - (Quality::Supported, Quality::Supported) => Some(order), - // at least 1 token unsupported => drop order - (Quality::Unsupported, _) | (_, Quality::Unsupported) => { - removed_uids.push(order.uid); - None - } - // sell token quality is unknown => keep order if token is supported - (Quality::Unknown, _) => { - let Some(detector) = &self.simulation_detector else { - // we can't determine quality => assume order is good - return Some(order); - }; - let check_tokens_fut = async move { - let quality = detector.determine_sell_token_quality(&order, now).await; - (order, quality) - }; - token_quality_checks.push(check_tokens_fut); - None - } - // buy token quality is unknown => keep order (because we can't - // determine quality and assume it's good) - (_, Quality::Unknown) => Some(order), + /// Filters orders using only static checks + fn supported_orders_without_detector( + &self, + orders: impl Iterator, + flashloans_enabled: bool, + now: Instant, + removed_uids: &mut HashSet, + ) -> Vec { + let mut supported_orders = Vec::new(); + for order in orders { + // Flashloans are disabled => drop order + if Self::is_disabled_flashloan_order(&order, flashloans_enabled) { + removed_uids.insert(order.uid); + continue; + } + // Determine whether to keep or drop order + match self.order_quality(&order, now) { + OrderQuality::Good | OrderQuality::Indeterminate => { + supported_orders.push(order); + } + OrderQuality::Bad => { + removed_uids.insert(order.uid); } - }) - .collect(); + } + } + supported_orders + } + /// Filters orders and uses simulation for unknown sell-token quality. + async fn supported_orders_with_detector( + &self, + orders: impl Iterator, + flashloans_enabled: bool, + now: Instant, + removed_uids: &mut HashSet, + detector: &dyn DetectorApi, + ) -> Vec { + let mut supported_orders = Vec::new(); + let mut token_quality_checks = FuturesUnordered::new(); + + for order in orders { + // Flashloans are disabled => drop order + if Self::is_disabled_flashloan_order(&order, flashloans_enabled) { + removed_uids.insert(order.uid); + continue; + } + // Determine whether to keep, simulate or drop order + match self.order_quality(&order, now) { + OrderQuality::Good => { + supported_orders.push(order); + } + OrderQuality::Bad => { + removed_uids.insert(order.uid); + } + OrderQuality::Indeterminate => { + // Add to quality checks to determine if supported or unsupported + token_quality_checks.push(async move { + let quality = detector.determine_sell_token_quality(&order, now).await; + (order, quality) + }); + } + } + } + // If no orders require simulation, return early + if token_quality_checks.is_empty() { + return supported_orders; + } + // Wait for all quality checks to complete while let Some((order, quality)) = token_quality_checks.next().await { if quality == Quality::Supported { supported_orders.push(order); } else { - removed_uids.push(order.uid); + removed_uids.insert(order.uid); } } + supported_orders + } - auction.orders = supported_orders; - if !removed_uids.is_empty() { - tracing::debug!(orders = ?removed_uids, "ignored orders with unsupported tokens"); + /// Classifies an order using metrics and static checks. + fn order_quality(&self, order: &Order, now: Instant) -> OrderQuality { + // Metrics determined quality is unsupported => drop order + if self + .metrics + .as_ref() + .map(|metrics| metrics.get_quality(&order.uid, now)) + .is_some_and(|q| q == Quality::Unsupported) + { + return OrderQuality::Bad; } - - if let Some(detector) = &self.simulation_detector { - detector.evict_outdated_entries(); + let sell = self.get_token_quality(order.sell.token, now); + let buy = self.get_token_quality(order.buy.token, now); + match (sell, buy) { + // both tokens supported => keep order + (Quality::Supported, Quality::Supported) => OrderQuality::Good, + // at least 1 token unsupported => drop order + (Quality::Unsupported, _) | (_, Quality::Unsupported) => OrderQuality::Bad, + // sell token quality is unknown => should require simulation detector, + // assume it is good if simulation detector is unavailable + (Quality::Unknown, _) => OrderQuality::Indeterminate, + // buy token quality is unknown => keep order (because we can't + // determine quality and assume it's good) + (_, Quality::Unknown) => OrderQuality::Good, } + } - auction + /// Returns true if flashloans are disabled and the order uses one. + fn is_disabled_flashloan_order(order: &Order, flashloans_enabled: bool) -> bool { + !flashloans_enabled && order.app_data.flashloan().is_some() } /// Updates the tokens quality metric for successful operation. @@ -162,6 +259,8 @@ impl Detector { } } + /// Returns the quality of a token using hardcoded configuration or the + /// simulation detector. fn get_token_quality(&self, token: eth::TokenAddress, now: Instant) -> Quality { match self.hardcoded.get(&token) { None | Some(Quality::Unknown) => (), @@ -182,3 +281,367 @@ impl fmt::Debug for Detector { .finish() } } + +#[cfg(test)] +mod tests { + use { + super::*, + crate::{ + domain::competition::{ + Order, + order::{ + BuyTokenBalance, + Kind, + Partial, + SellTokenBalance, + Side, + Signature, + Uid, + app_data::AppData, + signature, + }, + risk_detector::bad_tokens::simulation::MockDetectorApi, + }, + infra::solver, + util, + }, + app_data::{ + AppDataHash, + Flashloan, + ProtocolAppData, + Root, + ValidatedAppData, + hash_full_app_data, + }, + eth_domain_types::{Asset, TokenAmount, U256}, + std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, + }, + }; + + // Helper to create a mock eth:Address purely for test + fn addr(n: u8) -> eth::Address { + eth::Address::from_slice(&[n; 20]) + } + + // Helper to create a mock UID purely for test + fn uid(n: u8, signer: eth::Address, valid_to: u32) -> Uid { + let order_hash = eth::B256::from([n; 32]); + Uid::from_parts(order_hash, signer, valid_to) + } + + // Helper to create a mock order purely for test + fn order( + uid: Uid, + signer: eth::Address, + sell_token: eth::TokenAddress, + buy_token: eth::TokenAddress, + valid_to: u32, + flashloan: bool, + ) -> Order { + let app_data = if flashloan { + let protocol = ProtocolAppData { + flashloan: Some(Flashloan { + liquidity_provider: addr(99), + receiver: signer, + token: sell_token.into(), + protocol_adapter: addr(98), + amount: U256::from(1), + }), + ..Default::default() + }; + let root = Root::new(Some(protocol.clone())); + let document = serde_json::to_string(&root).unwrap(); + let hash = AppDataHash(hash_full_app_data(document.as_bytes())); + AppData::Full(Arc::new(ValidatedAppData { + hash, + document, + protocol, + })) + } else { + Default::default() + }; + + Order { + uid, + receiver: Some(signer), + created: util::Timestamp(0), + valid_to: util::Timestamp(valid_to), + buy: Asset { + token: buy_token, + amount: TokenAmount::from(1), + }, + sell: Asset { + token: sell_token, + amount: TokenAmount::from(1), + }, + side: Side::Sell, + kind: Kind::Limit, + app_data, + partial: Partial::No, + pre_interactions: vec![], + post_interactions: vec![], + sell_token_balance: SellTokenBalance::Erc20, + buy_token_balance: BuyTokenBalance::Erc20, + signature: Signature { + scheme: signature::Scheme::PreSign, + data: Default::default(), + signer, + }, + protocol_fees: Default::default(), + quote: Default::default(), + } + } + + // Helper to create a mock detector purely for test + fn detector( + hardcoded_tokens: HashMap, + metrics_bad_uids: HashSet, + ) -> Detector { + let metrics_detector = bad_orders::metrics::Detector::new( + 0.5, + 2, + false, + Duration::from_secs(60), + Duration::from_secs(60), + Duration::from_secs(60), + solver::Name("test_solver".into()), + ); + + let mut detector = Detector::new(hardcoded_tokens); + detector.with_metrics_detector(metrics_detector); + // Simulate multiple encoding failures to mark uid bad + for uid in metrics_bad_uids { + detector.encoding_failed(&[uid]); + detector.encoding_failed(&[uid]); + detector.encoding_failed(&[uid]); + } + + detector + } + + // Helper to create a mock simulation detector purely for test + fn simulation_detector(unsupported_uids: HashSet) -> MockDetectorApi { + let mut detector = MockDetectorApi::new(); + detector + .expect_determine_sell_token_quality() + .returning(move |order, _| { + if unsupported_uids.contains(&order.uid) { + Quality::Unsupported + } else { + Quality::Supported + } + }); + detector + .expect_get_quality() + .returning(|_, _| Quality::Unknown); + detector.expect_evict_outdated_entries().returning(|| ()); + detector + } + + #[tokio::test] + async fn without_unsupported_orders_returns_empty() { + let mut orders = vec![]; + let detector = detector(HashMap::new(), HashSet::new()); + detector.without_unsupported_orders(&mut orders, true).await; + assert!(orders.is_empty()); + } + + #[tokio::test] + async fn without_unsupported_orders_flashloan_disabled_returns_empty() { + let mut orders = vec![order( + uid(1, addr(1), u32::MAX), + addr(1), + addr(1).into(), + addr(2).into(), + u32::MAX, + true, + )]; + let detector = detector(HashMap::new(), HashSet::new()); + detector + .without_unsupported_orders(&mut orders, false) + .await; + assert!(orders.is_empty()); + } + + #[tokio::test] + async fn without_unsupported_orders_flashloan_enabled_kept() { + let id = uid(1, addr(1), u32::MAX); + let mut orders = vec![order( + id, + addr(1), + addr(1).into(), + addr(2).into(), + u32::MAX, + true, + )]; + let detector = detector(HashMap::new(), HashSet::new()); + + // Note: This could be filtered out for other reasons later in reality + // but is contained to be true for this test + detector.without_unsupported_orders(&mut orders, true).await; + assert_eq!(orders[0].uid, id); + } + + #[tokio::test] + async fn without_unsupported_orders_metrics_bad_returns_empty() { + let unsupported_uid = uid(1, addr(1), u32::MAX); + let mut orders = vec![order( + unsupported_uid, + addr(1), + addr(1).into(), + addr(2).into(), + u32::MAX, + false, + )]; + let detector = detector(HashMap::new(), HashSet::from([unsupported_uid])); + + detector.without_unsupported_orders(&mut orders, true).await; + assert!(orders.is_empty()); + } + + #[tokio::test] + async fn without_unsupported_orders_supported_kept() { + let uid = uid(1, addr(1), u32::MAX); + let sell_token = addr(1).into(); + let buy_token = addr(2).into(); + let mut orders = vec![order(uid, addr(1), sell_token, buy_token, u32::MAX, false)]; + let detector = detector( + HashMap::from([ + (sell_token, Quality::Supported), + (buy_token, Quality::Supported), + ]), + HashSet::new(), + ); + + detector.without_unsupported_orders(&mut orders, true).await; + assert_eq!(orders[0].uid, uid); + } + + #[tokio::test] + async fn without_unsupported_orders_buy_unsupported_empty() { + let uid = uid(1, addr(1), u32::MAX); + let sell_token = addr(1).into(); + let buy_token = addr(9).into(); + let mut orders = vec![order(uid, addr(1), sell_token, buy_token, u32::MAX, false)]; + let mut detector = detector( + HashMap::from([(buy_token, Quality::Unsupported)]), + HashSet::new(), + ); + detector.with_simulation_detector(simulation_detector(HashSet::new())); + + detector.without_unsupported_orders(&mut orders, true).await; + assert!(orders.is_empty()); + } + + #[tokio::test] + async fn without_unsupported_orders_mixed_orders_filters_correctly() { + let supported_uid = uid(1, addr(1), u32::MAX); + let bad_token_uid = uid(2, addr(2), u32::MAX); + let flashloan_uid = uid(3, addr(3), u32::MAX); + let metrics_bad_uid = uid(4, addr(4), u32::MAX); + let simulation_bad_uid = uid(5, addr(5), u32::MAX); + + let sell_token_supported = addr(1).into(); + let buy_token_supported = addr(2).into(); + let token_unsupported = addr(9).into(); + + // For tracking and reasoning about orders in this test: + // + // order( + // // description -> expected outcome + // uid, // if in metrics_bad_uids → discard + // // can also be used to verify order + // // inclusion/exclusion for the expected result. + // signer, // unused + // sell_token, // checked in hardcoded map in detector: + // // Supported → continue + // // Unsupported → discard + // // Unknown → may trigger simulation if detector is set + // buy_token, // checked in hardcoded map in detector: + // // Unsupported → discard + // valid_to, // unused + // flashloan, // if true and flashloans disabled → discard + // ) + // + // detector( + // HashMap::from([ + // (sell_token_supported, Quality::Supported), + // (buy_token_supported, Quality::Supported), + // (token_unsupported, Quality::Unsupported), + // ]), + // HashSet::from([metrics_bad_uid]), + // ) + // + // simulation_detector(HashSet::from([simulation_bad_uid])) + // Runs only when sell_token quality is Unknown and a simulation detector + // is set. If uid is in provided set → discard. + + let mut orders = vec![ + order( + // token supported -> keep + supported_uid, + addr(1), + sell_token_supported, + buy_token_supported, + u32::MAX, + false, + ), + order( + // token unsupported -> discard + bad_token_uid, + addr(2), + token_unsupported, + buy_token_supported, + u32::MAX, + false, + ), + order( + // flashloan -> discard, disabled + flashloan_uid, + addr(3), + sell_token_supported, + buy_token_supported, + u32::MAX, + true, + ), + order( + // metrics bad -> discard + metrics_bad_uid, + addr(4), + sell_token_supported, + buy_token_supported, + u32::MAX, + false, + ), + order( + // unsupported on quality check -> discard + simulation_bad_uid, + addr(5), + addr(5).into(), + buy_token_supported, + u32::MAX, + false, + ), + ]; + + let mut detector = detector( + HashMap::from([ + (sell_token_supported, Quality::Supported), + (buy_token_supported, Quality::Supported), + (token_unsupported, Quality::Unsupported), + ]), + HashSet::from([metrics_bad_uid]), + ); + detector.with_simulation_detector(simulation_detector(HashSet::from([simulation_bad_uid]))); + + detector + .without_unsupported_orders(&mut orders, false) + .await; + + assert_eq!(orders.len(), 1); + assert_eq!(orders[0].uid, supported_uid); + } +} diff --git a/crates/driver/src/domain/quote.rs b/crates/driver/src/domain/quote.rs index 4acc4946ba..6696695ff7 100644 --- a/crates/driver/src/domain/quote.rs +++ b/crates/driver/src/domain/quote.rs @@ -4,7 +4,10 @@ use { boundary, domain::{ self, - competition::{self, order}, + competition::{ + self, + order::{self, app_data::AppData}, + }, liquidity, time, }, @@ -118,6 +121,7 @@ pub struct Order { pub amount: order::TargetAmount, pub side: order::Side, pub deadline: chrono::DateTime, + pub app_data: AppData, } impl Order { @@ -141,12 +145,11 @@ impl Order { } solver::Liquidity::Skip => Default::default(), }; - - let auction = self + let mut auction = self .fake_auction(eth, tokens, solver.quote_using_limit_orders()) .await?; - let auction = risk_detector - .filter_unsupported_orders_in_auction(auction) + risk_detector + .without_unsupported_orders(&mut auction.orders, solver.config().flashloans_enabled) .await; if auction.orders.is_empty() { return Err(QuotingFailed::UnsupportedToken.into()); @@ -191,7 +194,7 @@ impl Order { } else { competition::order::Kind::Market }, - app_data: Default::default(), + app_data: self.app_data.clone(), partial: competition::order::Partial::No, pre_interactions: Default::default(), post_interactions: Default::default(), diff --git a/crates/driver/src/infra/api/error.rs b/crates/driver/src/infra/api/error.rs index 0b6529fa88..26c8425a66 100644 --- a/crates/driver/src/infra/api/error.rs +++ b/crates/driver/src/infra/api/error.rs @@ -29,6 +29,7 @@ enum Kind { TokenTemporarilySuspended, InsufficientLiquidity, CustomSolverError, + InternalError, } #[derive(Debug, Serialize)] @@ -69,9 +70,14 @@ impl From for (axum::http::StatusCode, axum::Json) { Kind::TokenTemporarilySuspended => "Token is temporarily suspended from trading", Kind::InsufficientLiquidity => "Insufficient liquidity for the requested trade size", Kind::CustomSolverError => "Solver returned a custom error", + Kind::InternalError => "Internal error occurred", + }; + let status = match value { + Kind::InternalError => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + _ => axum::http::StatusCode::BAD_REQUEST, }; ( - axum::http::StatusCode::BAD_REQUEST, + status, axum::Json(Error { kind: value, description: description.into(), @@ -146,6 +152,7 @@ impl From for (axum::http::StatusCode, axum::Json) { competition::Error::TooManyPendingSettlements => Kind::TooManyPendingSettlements, competition::Error::NoValidOrdersFound => Kind::NoValidOrders, competition::Error::MalformedRequest => Kind::MalformedRequest, + competition::Error::InternalError(_) => Kind::InternalError, }; error.into() } diff --git a/crates/driver/src/infra/api/mod.rs b/crates/driver/src/infra/api/mod.rs index 0ba4ad0441..075c251fd0 100644 --- a/crates/driver/src/infra/api/mod.rs +++ b/crates/driver/src/infra/api/mod.rs @@ -128,6 +128,7 @@ impl Api { ), liquidity: self.liquidity.clone(), tokens: tokens.clone(), + app_data_retriever: app_data_retriever.clone(), }))); let path = format!("/{name}"); infra::observe::mounting_solver(&name, &path); @@ -207,6 +208,10 @@ impl State { fn tokens(&self) -> &tokens::Fetcher { &self.0.tokens } + + fn app_data_retriever(&self) -> Option<&AppDataRetriever> { + self.0.app_data_retriever.as_ref() + } } struct Inner { @@ -215,4 +220,5 @@ struct Inner { competition: Arc, liquidity: liquidity::Fetcher, tokens: tokens::Fetcher, + app_data_retriever: Option, } diff --git a/crates/driver/src/infra/api/routes/quote/dto/order.rs b/crates/driver/src/infra/api/routes/quote/dto/order.rs index 64449b6a4b..68bae7e935 100644 --- a/crates/driver/src/infra/api/routes/quote/dto/order.rs +++ b/crates/driver/src/infra/api/routes/quote/dto/order.rs @@ -1,12 +1,22 @@ use { - crate::domain::{competition, quote}, + crate::domain::{ + competition::{ + self, + order::app_data::{APP_DATA_LEN, AppData, AppDataHash}, + }, + quote, + }, eth_domain_types as eth, serde::Deserialize, serde_with::serde_as, }; impl Order { - pub fn into_domain(self) -> quote::Order { + pub fn app_data_hash(&self) -> Option { + self.app_data.map(AppDataHash::from) + } + + pub fn into_domain(self, app_data: AppData) -> quote::Order { quote::Order { tokens: quote::Tokens::new(self.sell_token.into(), self.buy_token.into()), amount: self.amount.into(), @@ -15,6 +25,7 @@ impl Order { Kind::Buy => competition::order::Side::Buy, }, deadline: self.deadline, + app_data, } } } @@ -29,6 +40,9 @@ pub struct Order { amount: eth::U256, kind: Kind, deadline: chrono::DateTime, + #[serde(default)] + #[serde_as(as = "Option")] + app_data: Option<[u8; APP_DATA_LEN]>, } #[derive(Debug, Deserialize)] diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index 5fb791b2ac..952ef5d2c6 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -10,6 +10,11 @@ mod dto; pub use dto::OrderError; +use crate::domain::{ + competition::order::app_data::AppData, + quote::{self, QuotingFailed}, +}; + pub(in crate::infra::api) fn quote(router: axum::Router) -> axum::Router { router.route("/quote", axum::routing::get(route)) } @@ -19,7 +24,21 @@ async fn route( LoggingQuery(order): LoggingQuery, ) -> Result, (axum::http::StatusCode, axum::Json)> { let handle_request = async { - let order = order.into_domain(); + let app_data = match order.app_data_hash() { + Some(app_data_hash) => match state.app_data_retriever() { + Some(retriever) => match retriever.get_cached_or_fetch(&app_data_hash).await { + Ok(Some(app_data)) => AppData::Full(app_data), + Ok(None) => AppData::Hash(app_data_hash), + Err(err) => { + tracing::error!(?app_data_hash, ?err, "failed to fetch quote app data"); + return Err(quote::Error::from(QuotingFailed::UnsupportedToken).into()); + } + }, + None => AppData::Hash(app_data_hash), + }, + None => AppData::default(), + }; + let order = order.into_domain(app_data); observe::quoting(&order); let quote = order .quote( diff --git a/crates/driver/src/infra/observe/mod.rs b/crates/driver/src/infra/observe/mod.rs index 6a69180f6c..817f702366 100644 --- a/crates/driver/src/infra/observe/mod.rs +++ b/crates/driver/src/infra/observe/mod.rs @@ -459,6 +459,7 @@ fn competition_error(err: &competition::Error) -> &'static str { competition::Error::TooManyPendingSettlements => "TooManyPendingSettlements", competition::Error::NoValidOrdersFound => "NoValidOrdersFound", competition::Error::MalformedRequest => "MalformedRequest", + competition::Error::InternalError(_) => "InternalError", } } diff --git a/crates/driver/src/tests/cases/flashloan_hints.rs b/crates/driver/src/tests/cases/flashloan_hints.rs index 08bcbb0002..37905de080 100644 --- a/crates/driver/src/tests/cases/flashloan_hints.rs +++ b/crates/driver/src/tests/cases/flashloan_hints.rs @@ -11,6 +11,38 @@ use { std::sync::Arc, }; +#[tokio::test] +async fn flashloan_order_quote_fails_when_flashloans_disabled() { + let test = setup() + .flashloans_enabled(false) + .pool(ab_pool()) + .order(ab_order().app_data(flashloan_app_data())) + .quote() + .done() + .await; + + // TODO: there should also be a structured error response for requests, + // e.g test.quote().await.err() + let response = test.quote().await; + assert_ne!(response.status(), axum::http::StatusCode::OK); + let body: serde_json::Value = serde_json::from_str(response.body()).unwrap(); + assert_eq!(body["kind"], "QuotingFailed"); +} + +#[tokio::test] +async fn flashloan_order_quote_passes_when_flashloans_enabled() { + let test = setup() + .flashloans_enabled(true) + .pool(ab_pool()) + .order(ab_order().app_data(flashloan_app_data())) + .solution(ab_solution()) + .quote() + .done() + .await; + + test.quote().await.ok().amount().interactions(); +} + #[tokio::test] #[ignore] async fn solutions_with_flashloan() { @@ -19,7 +51,7 @@ async fn solutions_with_flashloan() { receiver: Address::from_slice(&[2; 20]), token: Address::from_slice(&[3; 20]), protocol_adapter: Address::from_slice(&[4; 20]), - amount: ::alloy::primitives::U256::from(3), + amount: alloy::primitives::U256::from(3), }; let protocol_app_data = ProtocolAppData { flashloan: Some(flashloan.clone()), @@ -56,7 +88,7 @@ async fn solutions_without_flashloan() { receiver: Address::from_slice(&[2; 20]), token: Address::from_slice(&[3; 20]), protocol_adapter: Address::from_slice(&[4; 20]), - amount: ::alloy::primitives::U256::from(3), + amount: alloy::primitives::U256::from(3), }; let protocol_app_data = ProtocolAppData { flashloan: Some(flashloan.clone()), @@ -96,7 +128,7 @@ async fn flashloan_order_filtered_when_flashloans_disabled() { receiver: Address::from_slice(&[2; 20]), token: Address::from_slice(&[3; 20]), protocol_adapter: Address::from_slice(&[4; 20]), - amount: ::alloy::primitives::U256::from(3), + amount: alloy::primitives::U256::from(3), }; let protocol_app_data = ProtocolAppData { flashloan: Some(flashloan), @@ -131,6 +163,20 @@ async fn flashloan_order_filtered_when_flashloans_disabled() { test.solve().await.ok(); } +fn flashloan_app_data() -> AppData { + let protocol = ProtocolAppData { + flashloan: Some(Flashloan { + liquidity_provider: Address::from_slice(&[1; 20]), + receiver: Address::from_slice(&[2; 20]), + token: Address::from_slice(&[3; 20]), + protocol_adapter: Address::from_slice(&[4; 20]), + amount: alloy::primitives::U256::from(3), + }), + ..Default::default() + }; + AppData::Full(Arc::new(protocol_app_data_into_validated(protocol))) +} + fn protocol_app_data_into_validated(protocol: ProtocolAppData) -> app_data::ValidatedAppData { let root = app_data::Root::new(Some(protocol.clone())); let document = serde_json::to_string(&root).unwrap(); diff --git a/crates/driver/src/tests/setup/driver.rs b/crates/driver/src/tests/setup/driver.rs index be91f6ccab..e83586034d 100644 --- a/crates/driver/src/tests/setup/driver.rs +++ b/crates/driver/src/tests/setup/driver.rs @@ -3,7 +3,7 @@ use { crate::{ domain::competition::order, infra::config::file::OrderPriorityStrategy, - tests::setup::{blockchain::Trade, orderbook::Orderbook}, + tests::setup::{AppData, blockchain::Trade, orderbook::Orderbook}, }, const_hex::ToHexExt, rand::seq::SliceRandom, @@ -183,7 +183,7 @@ pub fn quote_req(test: &Test) -> serde_json::Value { } let quote = test.quoted_orders.first().unwrap(); - json!({ + let mut request = json!({ "sellToken": test.blockchain.get_token(quote.order.sell_token).encode_hex_with_prefix(), "buyToken": test.blockchain.get_token(quote.order.buy_token).encode_hex_with_prefix(), "amount": match quote.order.side { @@ -195,7 +195,16 @@ pub fn quote_req(test: &Test) -> serde_json::Value { order::Side::Buy => "buy", }, "deadline": test.deadline, - }) + }); + match "e.order.app_data { + AppData::Hash(hash) => { + request["appData"] = json!(hash.0.0.encode_hex_with_prefix()); + } + AppData::Full(app_data) => { + request["appData"] = json!(app_data.hash.0.encode_hex_with_prefix()); + } + } + request } /// Create the config file for the driver to use. diff --git a/crates/driver/src/tests/setup/mod.rs b/crates/driver/src/tests/setup/mod.rs index 9eada0359a..5b6bccfe7a 100644 --- a/crates/driver/src/tests/setup/mod.rs +++ b/crates/driver/src/tests/setup/mod.rs @@ -1143,8 +1143,8 @@ impl Test { let res = self .client .get(format!( - "http://{}/{}/quote", - self.driver.addr, + "http://127.0.0.1:{}/{}/quote", + self.driver.addr.port(), solver::NAME )) .query(&driver::quote_req(self)) @@ -1513,13 +1513,21 @@ pub struct Quote<'a> { impl<'a> Quote<'a> { /// Expect the /quote endpoint to have returned a 200 OK response. pub fn ok(self) -> QuoteOk<'a> { - assert_eq!(self.status, axum::http::StatusCode::OK); + assert_eq!(self.status, StatusCode::OK); QuoteOk { trades: self.trades, body: self.body, blockchain: self.blockchain, } } + + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn body(&self) -> &str { + &self.body + } } pub struct QuoteOk<'a> {