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
22 changes: 21 additions & 1 deletion crates/driver/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions crates/driver/src/domain/competition/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ type Balances = HashMap<BalanceGroup, order::SellAmount>;
/// 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
Expand Down Expand Up @@ -269,13 +273,29 @@ pub struct Competition {
pub mempools: Mempools,
/// Cached solutions with the most recent solutions at the front.
pub settlements: Mutex<VecDeque<Settlement>>,
/// 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<VecDeque<CachedQuoteSolution>>,
/// bad token and orders detector
pub risk_detector: Arc<risk_detector::Detector>,
fetcher: Arc<pre_processing::DataAggregator>,
order_sorting_strategies: Vec<Arc<dyn sorting::SortingStrategy>>,
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(
Expand Down Expand Up @@ -308,6 +328,7 @@ impl Competition {
simulator,
mempools,
settlements: Default::default(),
quote_solutions: Default::default(),
risk_detector,
fetcher,
order_sorting_strategies,
Expand Down Expand Up @@ -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.
Expand Down
62 changes: 46 additions & 16 deletions crates/driver/src/domain/quote.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use {
super::competition::{auction, risk_detector, solution},
super::competition::{Competition, auction, solution},
crate::{
boundary,
domain::{
Expand Down Expand Up @@ -35,11 +35,16 @@ pub struct Quote {
pub tx_origin: Option<eth::Address>,
#[debug(ignore)]
pub jit_orders: Vec<solution::trade::Jit>,
/// 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<u64>,
pub auction_id: Option<i64>,
}

impl Quote {
fn try_new(eth: &Ethereum, solution: competition::Solution) -> Result<Self, Error> {
let clearing_prices = Self::compute_clearing_prices(&solution)?;
fn try_new(eth: &Ethereum, solution: &competition::Solution) -> Result<Self, Error> {
let clearing_prices = Self::compute_clearing_prices(solution)?;

Ok(Self {
clearing_prices,
Expand All @@ -63,6 +68,8 @@ impl Quote {
_ => None,
})
.collect(),
solution_id: None,
auction_id: None,
})
}

Expand Down Expand Up @@ -119,6 +126,9 @@ pub struct Order {
pub side: order::Side,
pub deadline: chrono::DateTime<chrono::Utc>,
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<i64>,
}

impl Order {
Expand All @@ -132,11 +142,12 @@ impl Order {
solver: &Solver,
liquidity: &infra::liquidity::Fetcher,
tokens: &infra::tokens::Fetcher,
risk_detector: &risk_detector::Detector,
competition: &Competition,
) -> Result<Quote, Error> {
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 => {
Expand All @@ -147,40 +158,59 @@ 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(
&self,
eth: &Ethereum,
tokens: &infra::tokens::Fetcher,
quote_using_limit_orders: bool,
auction_id: Option<auction::Id>,
) -> Result<competition::Auction, Error> {
let tokens = tokens.get(&[self.buy().token, self.sell().token]).await;

let buy_token_metadata = tokens.get(&self.buy().token);
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(),
Expand Down
6 changes: 6 additions & 0 deletions crates/driver/src/infra/api/routes/quote/dto/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ impl Order {
},
deadline: self.deadline,
enable_fast_path: self.enable_fast_path,
auction_id: self.auction_id,
}
}
}
Expand All @@ -32,6 +33,11 @@ pub struct Order {
deadline: chrono::DateTime<chrono::Utc>,
#[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<i64>,
}

#[derive(Debug, Deserialize)]
Expand Down
8 changes: 8 additions & 0 deletions crates/driver/src/infra/api/routes/quote/dto/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand All @@ -45,6 +47,12 @@ pub struct Quote {
jit_orders: Vec<JitOrder>,
#[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<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
auction_id: Option<i64>,
}

#[serde_as]
Expand Down
2 changes: 1 addition & 1 deletion crates/driver/src/infra/api/routes/quote/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &quote);
Expand Down
45 changes: 45 additions & 0 deletions crates/driver/src/tests/cases/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 11 additions & 2 deletions crates/driver/src/tests/setup/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -322,6 +329,7 @@ async fn create_config_file(
merge-solutions = {}
haircut-bps = {}
max-solutions-to-propose = {}
fast-path-enabled = {}
"#,
solver.name,
addr,
Expand All @@ -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() {
Expand Down
Loading
Loading