diff --git a/crates/driver/openapi.yml b/crates/driver/openapi.yml index edb5f83d22..215b50754e 100644 --- a/crates/driver/openapi.yml +++ b/crates/driver/openapi.yml @@ -46,11 +46,20 @@ paths: $ref: "#/components/schemas/DateTime" required: true - in: query - name: fastPath + name: enableFastPath description: Hint that this quote is intended for fast-path execution. schema: type: boolean required: false + - in: query + name: auctionId + description: >- + Real auction id the orderbook allocated from the shared auctions + sequence, under which a fast-path solution is cached for a later + `/settle`. Only sent for fast-path quotes. + schema: + type: integer + required: false responses: "200": description: Quote successfully created. @@ -439,6 +448,17 @@ components: supportsFastPath: type: boolean description: Whether the quoting solver supports fast-path (out-of-competition) execution. + solutionId: + type: integer + description: >- + For fast-path quotes: id of the cached solution, to later submit it + via `/settle`. Present only when the solution was successfully cached. + auctionId: + type: integer + description: >- + For fast-path quotes: the auction id the solution was cached under, + to later submit it via `/settle`. Present only when the solution was + successfully cached. required: - clearingPrices - solver diff --git a/crates/driver/src/domain/competition/mod.rs b/crates/driver/src/domain/competition/mod.rs index 7febd5445a..e8a21daf86 100644 --- a/crates/driver/src/domain/competition/mod.rs +++ b/crates/driver/src/domain/competition/mod.rs @@ -64,6 +64,10 @@ type Balances = HashMap; /// auction still find its settlement. const MAX_CONCURRENT_AUCTIONS: usize = 5; +/// Upper bound on cached fast-path quote solutions, each fast-path quote pushes +/// exactly one entry, at a cadence unrelated to auctions. +const MAX_CACHED_QUOTE_SOLUTIONS: usize = 100; + /// An ongoing competition. There is one competition going on per solver at any /// time. The competition stores settlements to solutions generated by the /// driver, and allows them to be executed onchain when requested later. The @@ -269,6 +273,14 @@ pub struct Competition { pub mempools: Mempools, /// Cached solutions with the most recent solutions at the front. pub settlements: Mutex>, + /// Cached fast-path quote solutions, most recent at the front. Unlike + /// `/solve` (which caches ready-to-submit settlements), a quote runs + /// against a throwaway auction whose order is synthetic and unsigned, + /// so its solution cannot be encoded into a submittable settlement + /// until the real order exists at settle time. We therefore cache the + /// solution and its auction and (re-)encode later, on the fast-path + /// settle. + pub quote_solutions: Mutex>, /// bad token and orders detector pub risk_detector: Arc, fetcher: Arc, @@ -276,6 +288,14 @@ pub struct Competition { submitter_pool: SubmitterPool, } +/// A fast-path quote solution cached for later settlement, together with the +/// throwaway auction it was solved against. +#[derive(Debug)] +pub struct CachedQuoteSolution { + pub auction: Auction, + pub solution: Solution, +} + impl Competition { #[expect(clippy::too_many_arguments)] pub fn new( @@ -308,6 +328,7 @@ impl Competition { simulator, mempools, settlements: Default::default(), + quote_solutions: Default::default(), risk_detector, fetcher, order_sorting_strategies, @@ -532,6 +553,15 @@ impl Competition { Ok(scored.into_iter().map(|(solved, _)| solved).collect()) } + /// Caches a fast-path quote's solution, keyed by the auction id (allocated + /// by the orderbook from the shared sequence) and the solution id, so it + /// can be settled later via the fast-path settle path. + pub fn cache_quote_solution(&self, auction: Auction, solution: Solution) { + let mut lock = self.quote_solutions.lock().unwrap(); + lock.push_front(CachedQuoteSolution { auction, solution }); + lock.truncate(MAX_CACHED_QUOTE_SOLUTIONS); + } + /// Re-simulate all proposed solutions on every new block and drop any /// that start reverting. Returns once every solution has reverted; /// otherwise runs forever and the caller must impose a deadline. diff --git a/crates/driver/src/domain/quote.rs b/crates/driver/src/domain/quote.rs index cd97752666..a9f296c05d 100644 --- a/crates/driver/src/domain/quote.rs +++ b/crates/driver/src/domain/quote.rs @@ -1,5 +1,5 @@ use { - super::competition::{auction, risk_detector, solution}, + super::competition::{Competition, auction, solution}, crate::{ boundary, domain::{ @@ -35,11 +35,16 @@ pub struct Quote { pub tx_origin: Option, #[debug(ignore)] pub jit_orders: Vec, + /// For fast-path quotes: the id of the cached solution and the auction id + /// it was cached under, so the caller can later settle it via `/settle`. + /// `None` for regular quotes. + pub solution_id: Option, + pub auction_id: Option, } impl Quote { - fn try_new(eth: &Ethereum, solution: competition::Solution) -> Result { - let clearing_prices = Self::compute_clearing_prices(&solution)?; + fn try_new(eth: &Ethereum, solution: &competition::Solution) -> Result { + let clearing_prices = Self::compute_clearing_prices(solution)?; Ok(Self { clearing_prices, @@ -63,6 +68,8 @@ impl Quote { _ => None, }) .collect(), + solution_id: None, + auction_id: None, }) } @@ -119,6 +126,9 @@ pub struct Order { pub side: order::Side, pub deadline: chrono::DateTime, pub enable_fast_path: bool, + /// Real auction id the orderbook allocated from the shared `auctions` + /// sequence for a fast-path quote. `None` for regular quotes. + pub auction_id: Option, } impl Order { @@ -132,11 +142,12 @@ impl Order { solver: &Solver, liquidity: &infra::liquidity::Fetcher, tokens: &infra::tokens::Fetcher, - risk_detector: &risk_detector::Detector, + competition: &Competition, ) -> Result { if self.enable_fast_path && !solver.fast_path_enabled() { return Err(Error::QuotingFailed(QuotingFailed::FastPathNotSupported)); } + let fast_path = self.enable_fast_path && solver.fast_path_enabled(); let liquidity = match solver.liquidity() { solver::Liquidity::Fetch => { @@ -147,25 +158,43 @@ impl Order { solver::Liquidity::Skip => Default::default(), }; + // For fast-path quotes the orderbook allocates a real auction id from the + // shared `auctions` sequence and forwards it here, so the solution can be + // encoded into a settlement and cached for a later `/settle`. + let auction_id = fast_path + .then_some(self.auction_id) + .flatten() + .map(auction::Id); let auction = self - .fake_auction(eth, tokens, solver.quote_using_limit_orders()) + .fake_auction(eth, tokens, solver.quote_using_limit_orders(), auction_id) .await?; - let auction = risk_detector + let auction = competition + .risk_detector .filter_unsupported_orders_in_auction(auction) .await; if auction.orders.is_empty() { return Err(QuotingFailed::UnsupportedToken.into()); } let solutions = solver.solve(&auction, &liquidity).await?; - Quote::try_new( - eth, - // TODO(#1468): choose the best solution in the future, but for now just pick the - // first solution - solutions - .into_iter() - .find(|solution| !solution.is_empty(auction.surplus_capturing_jit_order_owners())) - .ok_or(QuotingFailed::NoSolutions)?, - ) + // TODO(#1468): choose the best solution in the future, but for now just pick + // the first solution. + let solution = solutions + .into_iter() + .find(|solution| !solution.is_empty(auction.surplus_capturing_jit_order_owners())) + .ok_or(QuotingFailed::NoSolutions)?; + let mut quote = Quote::try_new(eth, &solution)?; + + // Cache the solution so the autopilot can settle it during the fast-path + // exclusivity window. The quote's order is synthetic (unsigned), so the + // settlement is not encoded here but deferred to the fast-path settle, + // once the real order exists. + if let Some(auction_id) = auction_id { + let solution_id = solution.id().get(); + competition.cache_quote_solution(auction, solution); + quote.solution_id = Some(solution_id); + quote.auction_id = Some(auction_id.0); + } + Ok(quote) } async fn fake_auction( @@ -173,6 +202,7 @@ impl Order { eth: &Ethereum, tokens: &infra::tokens::Fetcher, quote_using_limit_orders: bool, + auction_id: Option, ) -> Result { let tokens = tokens.get(&[self.buy().token, self.sell().token]).await; @@ -180,7 +210,7 @@ impl Order { let sell_token_metadata = tokens.get(&self.sell().token); competition::Auction::new( - None, + auction_id, vec![competition::Order { data: std::sync::Arc::new(competition::order::OrderData { uid: Default::default(), 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 3bd1cc34e6..98661bbd62 100644 --- a/crates/driver/src/infra/api/routes/quote/dto/order.rs +++ b/crates/driver/src/infra/api/routes/quote/dto/order.rs @@ -16,6 +16,7 @@ impl Order { }, deadline: self.deadline, enable_fast_path: self.enable_fast_path, + auction_id: self.auction_id, } } } @@ -32,6 +33,11 @@ pub struct Order { deadline: chrono::DateTime, #[serde(default)] enable_fast_path: bool, + /// Real auction id the orderbook allocated from the shared `auctions` + /// sequence, under which a fast-path solution is cached for a later + /// `/settle`. Only present for fast-path quotes. + #[serde(default)] + auction_id: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/driver/src/infra/api/routes/quote/dto/quote.rs b/crates/driver/src/infra/api/routes/quote/dto/quote.rs index 44ecca2928..39abd77364 100644 --- a/crates/driver/src/infra/api/routes/quote/dto/quote.rs +++ b/crates/driver/src/infra/api/routes/quote/dto/quote.rs @@ -25,6 +25,8 @@ impl Quote { tx_origin: quote.tx_origin, jit_orders: quote.jit_orders.into_iter().map(Into::into).collect(), supports_fast_path, + solution_id: quote.solution_id, + auction_id: quote.auction_id, } } } @@ -45,6 +47,12 @@ pub struct Quote { jit_orders: Vec, #[serde(skip_serializing_if = "std::ops::Not::not")] supports_fast_path: bool, + /// For fast-path quotes: the cached solution id and the auction id it was + /// cached under, to later submit it via `/settle`. + #[serde(skip_serializing_if = "Option::is_none")] + solution_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auction_id: Option, } #[serde_as] diff --git a/crates/driver/src/infra/api/routes/quote/mod.rs b/crates/driver/src/infra/api/routes/quote/mod.rs index 444b6e50c7..37ea9c6c8e 100644 --- a/crates/driver/src/infra/api/routes/quote/mod.rs +++ b/crates/driver/src/infra/api/routes/quote/mod.rs @@ -27,7 +27,7 @@ async fn route( state.solver(), state.liquidity(), state.tokens(), - &state.competition().risk_detector, + state.competition(), ) .await; observe::quoted(state.solver().name(), &order, "e); diff --git a/crates/driver/src/tests/cases/quote.rs b/crates/driver/src/tests/cases/quote.rs index 53b3ee76c5..f4039ccfb8 100644 --- a/crates/driver/src/tests/cases/quote.rs +++ b/crates/driver/src/tests/cases/quote.rs @@ -89,6 +89,51 @@ async fn with_jit_order() { quote.ok().amount().interactions().jit_order(); } +/// A fast-path quote caches its solution in the driver, echoing the solution id +/// and the orderbook-provided auction id so the autopilot can later settle it. +#[tokio::test] +#[ignore] +async fn fast_path_caching() { + const AUCTION_ID: i64 = 42; + + let test = tests::setup() + .pool(ab_pool()) + .order(ab_order()) + .solution(ab_solution()) + .solvers(vec![tests::setup::test_solver().fast_path_enabled()]) + .auction_id(AUCTION_ID) + .quote() + .quote_fast_path() + .done() + .await; + + let quote = test.quote().await.ok(); + + // Both ids are echoed only when the solution was cached: `auction_id` must be + // the id the request carried (the one the orderbook allocated from the shared + // sequence), and `solution_id` is present (its value is process-global, so + // not asserted). The accessors panic if the fields are absent. + quote.solution_id(); + assert_eq!(quote.auction_id(), AUCTION_ID); +} + +/// A regular quote (no `enableFastPath`), even from a fast-path-capable solver, +/// does not cache a settlement and carries no settle info. +#[tokio::test] +#[ignore] +async fn regular_quote_has_no_settle_info() { + let test = tests::setup() + .pool(ab_pool()) + .order(ab_order()) + .solution(ab_solution()) + .solvers(vec![tests::setup::test_solver().fast_path_enabled()]) + .quote() + .done() + .await; + + test.quote().await.ok().no_fast_path_settle_info(); +} + /// Test that quote haircut correctly reduces the executed amount for quotes /// when configured. The haircut should make quotes more conservative without /// affecting the ability to place and execute orders. diff --git a/crates/driver/src/tests/setup/driver.rs b/crates/driver/src/tests/setup/driver.rs index 099a7f7037..8836c762fe 100644 --- a/crates/driver/src/tests/setup/driver.rs +++ b/crates/driver/src/tests/setup/driver.rs @@ -183,7 +183,7 @@ pub fn quote_req(test: &Test) -> serde_json::Value { } let quote = test.quoted_orders.first().unwrap(); - json!({ + let mut req = 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,14 @@ pub fn quote_req(test: &Test) -> serde_json::Value { order::Side::Buy => "buy", }, "deadline": test.deadline, - }) + }); + if test.quote_fast_path { + // Mirrors what the orderbook does for fast-path quotes: request fast-path + // and hand over the real auction id it allocated from the shared sequence. + req["enableFastPath"] = json!(true); + req["auctionId"] = json!(test.auction_id); + } + req } /// Create the config file for the driver to use. @@ -322,6 +329,7 @@ async fn create_config_file( merge-solutions = {} haircut-bps = {} max-solutions-to-propose = {} + fast-path-enabled = {} "#, solver.name, addr, @@ -338,6 +346,7 @@ async fn create_config_file( solver.merge_solutions, solver.haircut_bps, solver.max_solutions_to_propose, + solver.fast_path_enabled, ) .unwrap(); if !solver.submission_accounts.is_empty() { diff --git a/crates/driver/src/tests/setup/mod.rs b/crates/driver/src/tests/setup/mod.rs index 2180cddae6..af23f6116f 100644 --- a/crates/driver/src/tests/setup/mod.rs +++ b/crates/driver/src/tests/setup/mod.rs @@ -365,6 +365,8 @@ pub struct Solver { max_solutions_to_propose: usize, /// Additional submission accounts for EIP-7702 parallel settlement. submission_accounts: Vec, + /// Whether this solver supports fast-path (out-of-competition) quotes. + fast_path_enabled: bool, } #[derive(Debug, Clone)] @@ -394,6 +396,7 @@ pub fn test_solver() -> Solver { haircut_bps: 0, max_solutions_to_propose: 1, submission_accounts: vec![], + fast_path_enabled: false, } } @@ -445,6 +448,11 @@ impl Solver { self } + pub fn fast_path_enabled(mut self) -> Self { + self.fast_path_enabled = true; + self + } + pub fn submission_account(mut self, signer: PrivateKeySigner) -> Self { self.submission_accounts.push(signer); self @@ -532,6 +540,9 @@ pub struct Setup { solutions: Vec, /// Is this a test for the /quote endpoint? quote: bool, + /// Should the /quote request ask for fast-path (out-of-competition) + /// execution, i.e. set `enableFastPath` and pass the auction id? + quote_fast_path: bool, /// List of solvers in this test solvers: Vec, /// Should simulation be enabled? True by default. @@ -1003,6 +1014,7 @@ impl Setup { quoted_orders: "es, deadline: time::Deadline::new(deadline, solver.timeouts), quote: self.quote, + quote_fast_path_auction_id: self.quote_fast_path.then_some(self.auction_id), fee_handler: solver.fee_handler, private_key: solver.signer.clone(), expected_surplus_capturing_jit_order_owners: surplus_capturing_jit_order_owners @@ -1041,6 +1053,7 @@ impl Setup { settle_submission_deadline: self.settle_submission_deadline, quoted_orders: quotes, quote: self.quote, + quote_fast_path: self.quote_fast_path, surplus_capturing_jit_order_owners, auction_id: self.auction_id, } @@ -1054,6 +1067,15 @@ impl Setup { } } + /// Make the /quote request ask for fast-path execution (sets + /// `enableFastPath` and passes the auction id in the request). + pub fn quote_fast_path(self) -> Self { + Self { + quote_fast_path: true, + ..self + } + } + /// Solver send the solution as JIT order pub fn jit_order(mut self, jit_order: JitOrder) -> Self { self.jit_orders.push(jit_order); @@ -1082,6 +1104,8 @@ pub struct Test { settle_submission_deadline: u64, /// Is this testing the /quote endpoint? quote: bool, + /// Should the /quote request ask for fast-path execution? + quote_fast_path: bool, /// List of surplus capturing JIT-order owners surplus_capturing_jit_order_owners: Vec, auction_id: i64, @@ -1535,6 +1559,27 @@ impl QuoteOk<'_> { &self.body } + /// The id of the cached fast-path solution. Present only when the solution + /// was successfully cached for a later `/settle`. + pub fn solution_id(&self) -> u64 { + let body: serde_json::Value = serde_json::from_str(&self.body).unwrap(); + body.get("solutionId").unwrap().as_u64().unwrap() + } + + /// The auction id the cached fast-path solution was cached under. + pub fn auction_id(&self) -> i64 { + let body: serde_json::Value = serde_json::from_str(&self.body).unwrap(); + body.get("auctionId").unwrap().as_i64().unwrap() + } + + /// Assert the quote carries no fast-path settle info (regular quote). + pub fn no_fast_path_settle_info(self) -> Self { + let body: serde_json::Value = serde_json::from_str(&self.body).unwrap(); + assert!(body.get("solutionId").is_none()); + assert!(body.get("auctionId").is_none()); + self + } + /// Check that the quote returns the expected amount of tokens. This is /// based on the state of the blockchain and the test setup. pub fn amount(self) -> Self { diff --git a/crates/driver/src/tests/setup/solver.rs b/crates/driver/src/tests/setup/solver.rs index e48ae9ea80..1ddeb3b39a 100644 --- a/crates/driver/src/tests/setup/solver.rs +++ b/crates/driver/src/tests/setup/solver.rs @@ -49,6 +49,9 @@ pub struct Config<'a> { pub deadline: time::Deadline, /// Is this a test for the /quote endpoint? pub quote: bool, + /// For fast-path quotes: the auction id the driver sends in the `/solve` + /// request (the id the orderbook allocated). `None` for regular quotes. + pub quote_fast_path_auction_id: Option, pub fee_handler: FeeHandler, pub private_key: PrivateKeySigner, pub expected_surplus_capturing_jit_order_owners: Vec
, @@ -514,7 +517,12 @@ impl Solver { let base_fee = eth.current_block().borrow().base_fee; let effective_gas_price = eth.gas_price().await.unwrap().effective(base_fee).to_string(); let expected = json!({ - "id": (!config.quote).then_some("1"), + "id": match (config.quote, config.quote_fast_path_auction_id) { + // Regular auctions use a fixed id; fast-path quotes carry the + // orderbook-allocated id; plain quotes have none. + (false, _) => Some("1".to_owned()), + (true, fast_path_id) => fast_path_id.map(|id| id.to_string()), + }, "tokens": tokens_json, "orders": orders_json, "liquidity": [],