diff --git a/Cargo.lock b/Cargo.lock index 6c3ed33967..35997cac20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,7 @@ dependencies = [ "url", "vergen", "web3", + "winner-selection", ] [[package]] @@ -8112,6 +8113,17 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winner-selection" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "itertools 0.14.0", + "thiserror 1.0.61", + "tracing", +] + [[package]] name = "winnow" version = "0.5.40" diff --git a/Cargo.toml b/Cargo.toml index fa95e4a136..894f38100c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ solver = { path = "crates/solver" } solvers = { path = "crates/solvers" } solvers-dto = { path = "crates/solvers-dto" } testlib = { path = "crates/testlib" } +winner-selection = { path = "crates/winner-selection" } time = "0.3.37" tiny-keccak = "2.0.2" tower = "0.4" diff --git a/crates/autopilot/Cargo.toml b/crates/autopilot/Cargo.toml index ecd973993e..e6985a7f7d 100644 --- a/crates/autopilot/Cargo.toml +++ b/crates/autopilot/Cargo.toml @@ -65,6 +65,7 @@ tower-http = { workspace = true, features = ["trace"] } tracing = { workspace = true } url = { workspace = true } web3 = { workspace = true } +winner-selection = { workspace = true } [dev-dependencies] mockall = { workspace = true } diff --git a/crates/autopilot/src/domain/competition/participant.rs b/crates/autopilot/src/domain/competition/participant.rs index 85c14f8615..4c140b9367 100644 --- a/crates/autopilot/src/domain/competition/participant.rs +++ b/crates/autopilot/src/domain/competition/participant.rs @@ -1,4 +1,7 @@ -use {super::Score, crate::infra, std::sync::Arc}; +pub use state::{RankType, Unscored}; +use {super::Score, crate::infra, ::winner_selection::state, std::sync::Arc}; +pub type Scored = state::Scored; +pub type Ranked = state::Ranked; #[derive(Clone)] pub struct Participant { @@ -7,27 +10,6 @@ pub struct Participant { state: State, } -#[derive(Clone)] -pub struct Unscored; - -#[derive(Clone)] -pub struct Scored { - pub(super) score: Score, -} - -#[derive(Clone)] -pub struct Ranked { - pub(super) rank_type: RankType, - pub(super) score: Score, -} - -#[derive(Clone)] -pub enum RankType { - Winner, - NonWinner, - FilteredOut, -} - impl Participant { pub fn solution(&self) -> &super::Solution { &self.solution @@ -38,51 +20,29 @@ impl Participant { } } -impl Participant { - pub fn new(solution: super::Solution, driver: Arc) -> Self { - Self { - solution, - driver, - state: Unscored, - } - } +impl state::WithState for Participant { + type State = State; + type WithState = Participant; - pub fn with_score(self, score: Score) -> Participant { + fn with_state(self, state: NewState) -> Self::WithState { Participant { solution: self.solution, driver: self.driver, - state: Scored { score }, + state, } } -} -impl Participant { - pub fn score(&self) -> Score { - self.state.score - } - - pub fn rank(self, rank_type: RankType) -> Participant { - Participant { - solution: self.solution, - driver: self.driver, - state: Ranked { - rank_type, - score: self.state.score, - }, - } + fn state(&self) -> &Self::State { + &self.state } } -impl Participant { - pub fn score(&self) -> Score { - self.state.score - } - - pub fn is_winner(&self) -> bool { - matches!(self.state.rank_type, RankType::Winner) - } - - pub fn filtered_out(&self) -> bool { - matches!(self.state.rank_type, RankType::FilteredOut) +impl Participant { + pub fn new(solution: super::Solution, driver: Arc) -> Self { + Self { + solution, + driver, + state: Unscored, + } } } diff --git a/crates/autopilot/src/domain/competition/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index 54d8a204e0..85b34b9472 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -27,25 +27,18 @@ use { crate::domain::{ self, - OrderUid, - auction::{ - Prices, - order::{self, TargetAmount}, - }, - competition::{Participant, RankType, Ranked, Score, Scored, Solution, Unscored}, + auction::order, + competition::{Participant, RankType, Ranked, Score, Solution, TradedOrder, Unscored}, eth::{self, WrappedNativeToken}, fee, - settlement::{ - math, - transaction::{self, ClearingPrices}, - }, }, - anyhow::{Context, Result}, - itertools::{Either, Itertools}, - num::Saturating, - std::collections::{HashMap, HashSet}, + ::winner_selection::state::{RankedItem, ScoredItem, UnscoredItem}, + std::collections::HashMap, + winner_selection::{self as ws, state::WithState}, }; +pub struct Arbitrator(ws::Arbitrator); + /// Implements auction arbitration in 3 phases: /// 1. filter unfair solutions /// 2. mark winners @@ -54,368 +47,226 @@ use { /// The functions assume the `Arbitrator` is the only one /// changing the ordering or the `participants`. impl Arbitrator { + pub fn new(max_winners: usize, wrapped_native_token: WrappedNativeToken) -> Self { + let token: eth::TokenAddress = wrapped_native_token.into(); + Self(ws::Arbitrator { + max_winners, + weth: token.0, + }) + } + /// Runs the entire auction mechanism on the passed in solutions. pub fn arbitrate( &self, participants: Vec>, auction: &domain::Auction, ) -> Ranking { - let partitioned = self.partition_unfair_solutions(participants, auction); - let filtered_out = partitioned - .discarded - .into_iter() - .map(|participant| participant.rank(RankType::FilteredOut)) - .collect(); + let context = to_ws_context(auction); + let mut participant_by_key = HashMap::with_capacity(participants.len()); + let mut solutions = Vec::with_capacity(participants.len()); + + for participant in participants { + let key = SolutionKey::from(participant.solution()); + let solution = to_ws_solution(participant.solution()); + participant_by_key.insert(key, participant); + solutions.push(solution); + } - let mut ranked = self.mark_winners(partitioned.kept); - ranked.sort_by_key(|participant| { - ( - // winners before non-winners - std::cmp::Reverse(participant.is_winner()), - // high score before low score - std::cmp::Reverse(participant.score()), - ) - }); - Ranking { - filtered_out, - ranked, + let ws_ranking = self.0.arbitrate(solutions, &context); + + let mut filtered_out = Vec::with_capacity(ws_ranking.filtered_out.len()); + for ws_solution in ws_ranking.filtered_out { + let key = SolutionKey::from(&ws_solution); + let participant = participant_by_key + .remove(&key) + .expect("every ranked solution has a matching participant"); + let score = ws_solution.score(); + filtered_out.push( + participant + .with_score(Score(eth::Ether(score))) + .rank(RankType::FilteredOut), + ); } - } - /// Removes unfair solutions from the set of all solutions. - fn partition_unfair_solutions( - &self, - participants: Vec>, - auction: &domain::Auction, - ) -> PartitionedSolutions { - // Discard all solutions where we can't compute the aggregate scores - // accurately because the fairness guarantees heavily rely on them. - let (mut participants, scores_by_solution) = - compute_scores_by_solution(participants, auction); - participants.sort_by_key(|participant| { - std::cmp::Reverse( - // we use the computed score to not trust the score provided by solvers - participant.score().get().0, - ) - }); - let baseline_scores = compute_baseline_scores(&scores_by_solution); - let (fair, unfair) = participants.into_iter().partition_map(|p| { - let aggregated_scores = scores_by_solution - .get(&SolutionKey { - driver: p.driver().submission_address, - solution_id: p.solution().id(), - }) - .expect("every remaining participant has an entry"); - // only keep solutions where each order execution is at least as good as - // the baseline solution. - // we only filter out unfair solutions with more than one token pair, - // to avoid reference scores set to 0. - // see https://github.com/fhenneke/comb_auctions/issues/2 - if aggregated_scores.len() == 1 - || aggregated_scores.iter().all(|(pair, score)| { - baseline_scores - .get(pair) - .is_none_or(|baseline| score >= baseline) - }) - { - Either::Left(p) - } else { - Either::Right(p) - } - }); - PartitionedSolutions { - kept: fair, - discarded: unfair, + let mut ranked = Vec::with_capacity(ws_ranking.ranked.len()); + for ranked_solution in ws_ranking.ranked { + let key = SolutionKey::from(&ranked_solution); + let participant = participant_by_key + .remove(&key) + .expect("every ranked solution has a matching participant"); + let score = ranked_solution.score(); + ranked.push( + participant + .with_score(Score(eth::Ether(score))) + .rank(ranked_solution.state().rank_type), + ); } - } - /// Picks winners and sorts all solutions where winners come before - /// non-winners and higher scores come before lower scores. - fn mark_winners(&self, participants: Vec>) -> Vec> { - let winner_indexes = self.pick_winners(participants.iter().map(|p| p.solution())); - participants - .into_iter() - .enumerate() - .map(|(index, participant)| { - let rank = match winner_indexes.contains(&index) { - true => RankType::Winner, - false => RankType::NonWinner, - }; - participant.rank(rank) - }) - .collect() + Ranking { + filtered_out, + ranked, + } } /// Computes the reference scores which are used to compute /// rewards for the winning solvers. pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap { - let mut reference_scores = HashMap::default(); - - for participant in &ranking.ranked { - let solver = participant.driver().submission_address; - if reference_scores.len() >= self.max_winners { - // all winners have been processed - return reference_scores; - } - if reference_scores.contains_key(&solver) { - // we already computed this solver's reference score - continue; - } - if !participant.is_winner() { - // we only want to compute the reference score of winners - continue; - } - - let participants_without_solver: Vec<_> = ranking - .ranked - .iter() - .filter(|p| p.driver().submission_address != solver) - .collect(); - let solutions = participants_without_solver.iter().map(|p| p.solution()); - let winner_indices = self.pick_winners(solutions); - - let score = participants_without_solver - .iter() - .enumerate() - .filter(|(index, _)| winner_indices.contains(index)) - .map(|(_, p)| p.score()) - .reduce(Score::saturating_add) - .unwrap_or_default(); - reference_scores.insert(solver, score); - } - - reference_scores + let ws_ranking = to_ws_ranking(ranking); + self.0 + .compute_reference_scores(&ws_ranking) + .into_iter() + .map(|(solver, score)| (solver, Score(eth::Ether(score)))) + .collect() } +} - /// Returns indices of winning solutions. - /// Assumes that `solutions` is sorted by score descendingly. - /// This logic was moved into a helper function to avoid a ton of `.clone()` - /// operations in `compute_reference_scores()`. - fn pick_winners<'a>(&self, solutions: impl Iterator) -> HashSet { - // Winners are selected one by one, starting from the best solution, - // until `max_winners` are selected. A solution can only - // win if none of the (sell_token, buy_token) pairs of the executed - // orders have been covered by any previously selected winning solution. - // In other words this enforces a uniform **directional** clearing price. - let mut already_swapped_tokens_pairs = HashSet::new(); - let mut winners = HashSet::default(); - for (index, solution) in solutions.enumerate() { - if winners.len() >= self.max_winners { - return winners; - } - - let swapped_token_pairs = solution - .orders() - .values() - .map(|order| DirectedTokenPair { - sell: order.sell.token.as_erc20(self.weth), - buy: order.buy.token.as_erc20(self.weth), - }) - .collect::>(); - - if swapped_token_pairs.is_disjoint(&already_swapped_tokens_pairs) { - winners.insert(index); - already_swapped_tokens_pairs.extend(swapped_token_pairs); - } - } - winners +fn to_ws_context(auction: &domain::Auction) -> ws::AuctionContext { + ws::AuctionContext { + fee_policies: auction + .orders + .iter() + .map(|order| { + let uid = ws::OrderUid(order.uid.0); + let policies = order + .protocol_fees + .iter() + .copied() + .map(to_ws_fee_policy) + .collect(); + (uid, policies) + }) + .collect(), + surplus_capturing_jit_order_owners: auction + .surplus_capturing_jit_order_owners + .iter() + .copied() + .collect(), + native_prices: auction + .prices + .iter() + .map(|(token, price)| (token.0, price.get().0)) + .collect(), } } -/// Let's call a solution that only trades 1 directed token pair a baseline -/// solution. Returns the best baseline solution (highest score) for -/// each token pair if one exists. -fn compute_baseline_scores(scores_by_solution: &ScoresBySolution) -> ScoreByDirection { - let mut baseline_directional_scores = ScoreByDirection::default(); - for scores in scores_by_solution.values() { - let Ok((token_pair, score)) = scores.iter().exactly_one() else { - // base solutions must contain exactly 1 directed token pair - continue; - }; - let current_best_score = baseline_directional_scores - .entry(token_pair.clone()) - .or_default(); - if score > current_best_score { - *current_best_score = *score; - } - } - baseline_directional_scores +fn to_ws_solution(solution: &Solution) -> ws::Solution { + ws::Solution::new( + solution.id(), + solution.solver(), + solution + .orders() + .iter() + .map(|(uid, order)| to_ws_order(*uid, order)) + .collect(), + solution + .prices() + .iter() + .map(|(token, price)| (token.0, price.get().0)) + .collect(), + ) } -/// Computes the `DirectionalScores` for all solutions and discards -/// solutions as invalid whenever that computation is not possible. -/// Solutions get discarded because fairness guarantees heavily -/// depend on these scores being accurate. -fn compute_scores_by_solution( - participants: Vec>, - auction: &domain::Auction, -) -> (Vec>, ScoresBySolution) { - let auction = Auction::from(auction); - let mut scores_by_solution = HashMap::default(); - let mut scored_participants = Vec::new(); - - for participant in participants { - match score_by_token_pair(participant.solution(), &auction) { - Ok(score) => { - let total_score = score - .values() - .fold(Score::default(), |acc, score| acc.saturating_add(*score)); - scores_by_solution.insert( - SolutionKey { - driver: participant.driver().submission_address, - solution_id: participant.solution().id(), - }, - score, - ); - scored_participants.push(participant.with_score(total_score)); - } - Err(err) => { - tracing::warn!( - driver = participant.driver().name, - ?err, - solution = ?participant.solution(), - "discarding solution where scores could not be computed" - ); - } - } +fn to_ws_order(uid: domain::OrderUid, order: &TradedOrder) -> ws::Order { + ws::Order { + uid: ws::OrderUid(uid.0), + sell_token: order.sell.token.0, + buy_token: order.buy.token.0, + sell_amount: order.sell.amount.0, + buy_amount: order.buy.amount.0, + executed_sell: order.executed_sell.0, + executed_buy: order.executed_buy.0, + side: match order.side { + order::Side::Buy => ws::Side::Buy, + order::Side::Sell => ws::Side::Sell, + }, } - - (scored_participants, scores_by_solution) } -/// Returns the total scores for each directed token pair of the solution. -/// E.g. if a solution contains 3 orders like: -/// sell A for B with a score of 10 -/// sell A for B with a score of 5 -/// sell B for C with a score of 5 -/// it will return a map like: -/// (A, B) => 15 -/// (B, C) => 5 -fn score_by_token_pair(solution: &Solution, auction: &Auction) -> Result { - let mut scores: HashMap = HashMap::default(); - for (uid, trade) in solution.orders() { - if !auction.contributes_to_score(uid) { - continue; - } - - let uniform_sell_price = solution - .prices() - .get(&trade.sell.token) - .context("no uniform clearing price for sell token")?; - let uniform_buy_price = solution - .prices() - .get(&trade.buy.token) - .context("no uniform clearing price for buy token")?; - - let trade = math::Trade { - uid: *uid, - sell: trade.sell, - buy: trade.buy, - side: trade.side, - executed: match trade.side { - order::Side::Buy => TargetAmount(trade.executed_buy.into()), - order::Side::Sell => TargetAmount(trade.executed_sell.into()), - }, - prices: transaction::Prices { - // clearing prices are denominated in the same underlying - // unit so we assign sell to sell and buy to buy - uniform: ClearingPrices { - sell: uniform_sell_price.get().into(), - buy: uniform_buy_price.get().into(), - }, - // for custom clearing prices we only need to know how - // much the traded tokens are worth relative to each - // other so we can simply use the swapped executed - // amounts here - custom: ClearingPrices { - sell: trade.executed_buy.into(), - buy: trade.executed_sell.into(), - }, - }, - }; - let score = trade - .score(&auction.fee_policies, auction.native_prices) - .context("failed to compute score")?; - - let token_pair = DirectedTokenPair { - sell: trade.sell.token, - buy: trade.buy.token, - }; - - scores - .entry(token_pair) - .or_default() - .saturating_add_assign(Score(score)); +fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { + let ranked = ranking + .ranked + .iter() + .map(|participant| { + let rank_type = if participant.is_winner() { + ws::RankType::Winner + } else { + ws::RankType::NonWinner + }; + to_ws_solution(participant.solution()) + .with_score(participant.score().get().0) + .rank(rank_type) + }) + .collect(); + + let filtered_out = ranking + .filtered_out + .iter() + .map(|participant| { + to_ws_solution(participant.solution()) + .with_score(participant.score().get().0) + .rank(ws::RankType::FilteredOut) + }) + .collect(); + + ws::Ranking { + filtered_out, + ranked, } - Ok(scores) } -pub struct Arbitrator { - pub max_winners: usize, - pub weth: WrappedNativeToken, +fn to_ws_fee_policy(policy: fee::Policy) -> ws::primitives::FeePolicy { + match policy { + fee::Policy::Surplus { + factor, + max_volume_factor, + } => ws::primitives::FeePolicy::Surplus { + factor: factor.get(), + max_volume_factor: max_volume_factor.get(), + }, + fee::Policy::PriceImprovement { + factor, + max_volume_factor, + quote, + } => ws::primitives::FeePolicy::PriceImprovement { + factor: factor.get(), + max_volume_factor: max_volume_factor.get(), + quote: ws::primitives::Quote { + sell_amount: quote.sell_amount, + buy_amount: quote.buy_amount, + fee: quote.fee, + solver: quote.solver, + }, + }, + fee::Policy::Volume { factor } => ws::primitives::FeePolicy::Volume { + factor: factor.get(), + }, + } } -/// Relevant data from `domain::Auction` but with data structures -/// optimized for the winner selection logic. -/// Avoids clones whenever possible. -struct Auction<'a> { - /// Fee policies for **all** orders that were in the original auction. - fee_policies: HashMap>, - surplus_capturing_jit_order_owners: HashSet, - native_prices: &'a Prices, +#[derive(Clone, Copy, Hash, Eq, PartialEq)] +struct SolutionKey { + solver: eth::Address, + solution_id: u64, } -impl Auction<'_> { - /// Returns whether an order is allowed to capture surplus and - /// therefore contributes to the total score of a solution. - fn contributes_to_score(&self, uid: &OrderUid) -> bool { - self.fee_policies.contains_key(uid) - || self - .surplus_capturing_jit_order_owners - .contains(&uid.owner()) +impl From<&Solution> for SolutionKey { + fn from(solution: &Solution) -> Self { + Self { + solver: solution.solver(), + solution_id: solution.id(), + } } } -impl<'a> From<&'a domain::Auction> for Auction<'a> { - fn from(original: &'a domain::Auction) -> Self { +impl From<&ws::Solution> for SolutionKey { + fn from(solution: &ws::Solution) -> Self { Self { - fee_policies: original - .orders - .iter() - .map(|o| (o.uid, &o.protocol_fees)) - .collect(), - native_prices: &original.prices, - surplus_capturing_jit_order_owners: original - .surplus_capturing_jit_order_owners - .iter() - .cloned() - .collect(), + solver: solution.solver(), + solution_id: solution.id(), } } } -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -struct DirectedTokenPair { - sell: eth::TokenAddress, - buy: eth::TokenAddress, -} - -/// Key to uniquely identify every solution. -#[derive(PartialEq, Eq, std::hash::Hash)] -struct SolutionKey { - driver: eth::Address, - solution_id: u64, -} - -/// Scores of all trades in a solution aggregated by the directional -/// token pair. E.g. all trades (WETH -> USDC) are aggregated into -/// one value and all trades (USDC -> WETH) into another. -type ScoreByDirection = HashMap; - -/// Mapping from solution to `DirectionalScores` for all solutions -/// of the auction. -type ScoresBySolution = HashMap; - pub struct Ranking { /// Solutions that were discarded because they were malformed /// in some way or deemed unfair by the selection mechanism. @@ -453,11 +304,6 @@ impl Ranking { } } -struct PartitionedSolutions { - kept: Vec>, - discarded: Vec>, -} - #[cfg(test)] mod tests { use { @@ -485,6 +331,7 @@ mod tests { collections::HashMap, hash::{DefaultHasher, Hash, Hasher}, }, + winner_selection::state::RankedItem, }; const DEFAULT_TOKEN_PRICE: u128 = 1_000; @@ -1275,10 +1122,10 @@ mod tests { } fn create_test_arbitrator() -> super::Arbitrator { - super::Arbitrator { - max_winners: 10, - weth: address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").into(), - } + super::Arbitrator::new( + 10, + address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").into(), + ) } fn address(id: u64) -> Address { diff --git a/crates/autopilot/src/domain/settlement/mod.rs b/crates/autopilot/src/domain/settlement/mod.rs index b433dd434c..8117a00e24 100644 --- a/crates/autopilot/src/domain/settlement/mod.rs +++ b/crates/autopilot/src/domain/settlement/mod.rs @@ -351,15 +351,19 @@ pub struct ExecutionEnded { #[cfg(test)] mod tests { use { - crate::domain::{ - self, - auction, - eth, - settlement::{OrderMatchKey, trade_to_key}, + crate::{ + domain::{ + self, + auction, + eth, + settlement::{OrderMatchKey, trade_to_key}, + }, + util::conv::U256Ext, }, alloy::{eips::BlockId, primitives::address}, hex_literal::hex, std::collections::{HashMap, HashSet}, + winner_selection::{self as ws, state::RankedItem}, }; #[derive(Clone)] @@ -376,6 +380,119 @@ mod tests { } } + fn score_trade_with_winner_selection( + trade: &super::trade::Trade, + auction: &super::Auction, + ) -> eth::U256 { + let order = ws_order_from_trade(trade); + let prices = ws_prices_from_auction(auction); + let context = ws::AuctionContext { + fee_policies: auction + .orders + .iter() + .map(|(uid, policies)| { + let policies = policies.iter().copied().map(to_ws_fee_policy).collect(); + (ws::OrderUid(uid.0), policies) + }) + .collect(), + surplus_capturing_jit_order_owners: auction + .surplus_capturing_jit_order_owners + .iter() + .copied() + .collect(), + native_prices: prices.clone(), + }; + let solution = ws::Solution::new(0, ws::Address::ZERO, vec![order], prices); + let arbitrator = ws::Arbitrator { + max_winners: 1, + weth: ws::Address::ZERO, + }; + let ranking = arbitrator.arbitrate(vec![solution], &context); + + ranking + .ranked + .first() + .map(|solution| solution.score()) + .unwrap_or_default() + } + + fn ws_order_from_trade(trade: &super::trade::Trade) -> ws::Order { + let trade = super::trade::math::Trade::from(trade); + let (executed_sell, executed_buy) = ws_executed_amounts(&trade); + + ws::Order { + uid: ws::OrderUid(trade.uid.0), + sell_token: trade.sell.token.0, + buy_token: trade.buy.token.0, + sell_amount: trade.sell.amount.0, + buy_amount: trade.buy.amount.0, + executed_sell, + executed_buy, + side: match trade.side { + auction::order::Side::Buy => ws::Side::Buy, + auction::order::Side::Sell => ws::Side::Sell, + }, + } + } + + fn ws_executed_amounts(trade: &super::trade::math::Trade) -> (eth::U256, eth::U256) { + match trade.side { + auction::order::Side::Sell => { + let executed_sell = trade.executed.0; + let executed_buy = executed_sell + .checked_mul(trade.prices.custom.sell) + .and_then(|value| value.checked_ceil_div(&trade.prices.custom.buy)) + .expect("invalid sell trade executed amounts"); + (executed_sell, executed_buy) + } + auction::order::Side::Buy => { + let executed_buy = trade.executed.0; + let executed_sell = executed_buy + .checked_mul(trade.prices.custom.buy) + .and_then(|value| value.checked_div(trade.prices.custom.sell)) + .expect("invalid buy trade executed amounts"); + (executed_sell, executed_buy) + } + } + } + + fn ws_prices_from_auction(auction: &super::Auction) -> HashMap { + auction + .prices + .iter() + .map(|(token, price)| (token.0, price.get().0)) + .collect() + } + + fn to_ws_fee_policy(policy: domain::fee::Policy) -> ws::primitives::FeePolicy { + match policy { + domain::fee::Policy::Surplus { + factor, + max_volume_factor, + } => ws::primitives::FeePolicy::Surplus { + factor: factor.get(), + max_volume_factor: max_volume_factor.get(), + }, + domain::fee::Policy::PriceImprovement { + factor, + max_volume_factor, + quote, + } => ws::primitives::FeePolicy::PriceImprovement { + factor: factor.get(), + max_volume_factor: max_volume_factor.get(), + quote: ws::primitives::Quote { + sell_amount: quote.sell_amount, + buy_amount: quote.buy_amount, + fee: quote.fee, + solver: quote.solver, + }, + }, + domain::fee::Policy::Volume { factor } => ws::primitives::FeePolicy::Volume { + factor: factor.get(), + }, + } + } + // https://etherscan.io/tx/0x030623e438f28446329d8f4ff84db897907fcac59b9943b31b7be66f23c877af // A transfer transaction that emits a settlement event, but it's not actually a // swap. @@ -889,7 +1006,7 @@ mod tests { ); assert_eq!( - trade.score(&auction).unwrap().0, + score_trade_with_winner_selection(&trade, &auction), eth::U256::from(769018961144625u128) // 2 x surplus ); } @@ -1236,7 +1353,10 @@ mod tests { jit_trade.fee_in_ether(&auction.prices).unwrap().0, eth::U256::ZERO ); - assert_eq!(jit_trade.score(&auction).unwrap().0, eth::U256::ZERO); + assert_eq!( + score_trade_with_winner_selection(&jit_trade, &auction), + eth::U256::ZERO + ); assert_eq!( jit_trade.fee_breakdown(&auction).unwrap().total.amount.0, eth::U256::ZERO diff --git a/crates/autopilot/src/domain/settlement/trade/math.rs b/crates/autopilot/src/domain/settlement/trade/math.rs index f905eca922..467c94e6bd 100644 --- a/crates/autopilot/src/domain/settlement/trade/math.rs +++ b/crates/autopilot/src/domain/settlement/trade/math.rs @@ -5,17 +5,13 @@ use { domain::{ self, OrderUid, - auction::{ - self, - order::{self, Side}, - }, + auction::{self, order}, eth, fee, settlement::transaction::{ClearingPrices, Prices}, }, util::conv::U256Ext, }, - alloy::primitives::{U512, ruint::UintTryFrom}, error::Math, num::{CheckedAdd, CheckedDiv, CheckedMul, CheckedSub}, std::collections::HashMap, @@ -34,67 +30,6 @@ pub struct Trade { } impl Trade { - /// Score defined as (surplus + protocol fees) first converted to buy - /// amounts and then converted to the native token. - /// - /// [CIP-38](https://forum.cow.fi/t/cip-38-solver-computed-fees-rank-by-surplus/2061>) as the - /// base of the score computation. - /// [Draft CIP](https://forum.cow.fi/t/cip-draft-updating-score-definition-for-buy-orders/2930) - /// as the latest revision to avoid edge cases for certain buy orders. - /// - /// Denominated in NATIVE token - pub fn score( - &self, - fee_policies: &HashMap>, - native_prices: &domain::auction::Prices, - ) -> Result { - let native_price_buy = native_prices - .get(&self.buy.token) - .ok_or(Error::MissingPrice(self.buy.token))?; - - let surplus_in_surplus_token = { - let user_surplus = self.surplus_over_limit_price()?.0; - let fees: eth::U256 = self.protocol_fees(fee_policies)?.into_iter().try_fold( - eth::U256::ZERO, - |acc, i| { - acc.checked_add(i.fee.amount.0) - .ok_or(Error::Math(Math::Overflow)) - }, - )?; - user_surplus - .checked_add(fees) - .ok_or(Error::Math(Math::Overflow))? - }; - - let score = match self.side { - // `surplus` of sell orders is already in buy tokens so we simply convert it to ETH - Side::Sell => native_price_buy.in_eth(eth::TokenAmount(surplus_in_surplus_token)), - Side::Buy => { - // `surplus` of buy orders is in sell tokens. We start with following formula: - // buy_amount / sell_amount == buy_price / sell_price - // - // since `surplus` of buy orders is in sell tokens we convert to buy amount via: - // buy_amount == (buy_price / sell_price) * surplus - // - // to avoid loss of precision because we work with integers we first multiply - // and then divide: - // buy_amount = surplus * buy_price / sell_price - let surplus_in_buy_tokens = surplus_in_surplus_token - .widening_mul(self.buy.amount.0) - .checked_div(U512::from(self.sell.amount.0)) - .ok_or(Error::Math(Math::DivisionByZero))?; - let surplus_in_buy_tokens: eth::U256 = - eth::U256::uint_try_from(surplus_in_buy_tokens) - .map_err(|_| Error::Math(Math::Overflow))?; - - // Afterwards we convert the buy token surplus to the native token. - native_price_buy.in_eth(surplus_in_buy_tokens.into()) - } - }; - - Ok(score) - } - /// A general surplus function. /// /// Can return different types of surplus based on the input parameters. diff --git a/crates/autopilot/src/domain/settlement/trade/mod.rs b/crates/autopilot/src/domain/settlement/trade/mod.rs index 7db1489024..5cad3671a1 100644 --- a/crates/autopilot/src/domain/settlement/trade/mod.rs +++ b/crates/autopilot/src/domain/settlement/trade/mod.rs @@ -40,11 +40,6 @@ impl Trade { } } - /// CIP38 score defined as surplus + protocol fee - pub fn score(&self, auction: &super::Auction) -> Result { - math::Trade::from(self).score(&auction.orders, &auction.prices) - } - /// Surplus of a trade. pub fn surplus_in_ether(&self, prices: &auction::Prices) -> Result { match self { diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index b72faf2019..bd1273f910 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -5,6 +5,7 @@ use { domain::{self, eth, settlement::transaction::EncodedTrade}, infra::persistence::dto::{AuctionId, RawAuctionData}, }, + ::winner_selection::state::RankedItem, alloy::primitives::B256, anyhow::Context, bigdecimal::ToPrimitive, @@ -226,7 +227,7 @@ impl Persistence { id: u256_to_big_decimal(&participant.solution().id().into()), solver: ByteArray(participant.solution().solver().0.0), is_winner: participant.is_winner(), - filtered_out: participant.filtered_out(), + filtered_out: participant.is_filtered_out(), score: number::conversions::alloy::u256_to_big_decimal( &participant.score().get().0, ), diff --git a/crates/autopilot/src/run_loop.rs b/crates/autopilot/src/run_loop.rs index 3da7271700..dd96f43bca 100644 --- a/crates/autopilot/src/run_loop.rs +++ b/crates/autopilot/src/run_loop.rs @@ -27,6 +27,7 @@ use { solvable_orders::SolvableOrdersCache, }, ::observe::metrics, + ::winner_selection::state::RankedItem, alloy::primitives::B256, anyhow::{Context, Result}, database::order_events::OrderEventLabel, @@ -129,7 +130,7 @@ impl RunLoop { probes, maintenance, competition_updates_sender, - winner_selection: winner_selection::Arbitrator { max_winners, weth }, + winner_selection: winner_selection::Arbitrator::new(max_winners, weth), wake_notify, } } @@ -514,7 +515,7 @@ impl RunLoop { .map(|(token, price)| (token.0, price.get().0)) .collect(), is_winner: participant.is_winner(), - filtered_out: participant.filtered_out(), + filtered_out: participant.is_filtered_out(), }) .collect(); // reverse as solver competition table is sorted from worst to best, diff --git a/crates/autopilot/src/shadow.rs b/crates/autopilot/src/shadow.rs index 3d47e6ca4a..1b846776f9 100644 --- a/crates/autopilot/src/shadow.rs +++ b/crates/autopilot/src/shadow.rs @@ -22,6 +22,7 @@ use { run_loop::observe, }, ::observe::metrics, + ::winner_selection::state::RankedItem, anyhow::Context, ethrpc::block_stream::CurrentBlockWatcher, itertools::Itertools, @@ -56,10 +57,10 @@ impl RunLoop { weth: WrappedNativeToken, ) -> Self { Self { - winner_selection: winner_selection::Arbitrator { - max_winners: max_winners_per_auction.get(), + winner_selection: winner_selection::Arbitrator::new( + max_winners_per_auction.get(), weth, - }, + ), orderbook, drivers, trusted_tokens, diff --git a/crates/winner-selection/Cargo.toml b/crates/winner-selection/Cargo.toml new file mode 100644 index 0000000000..22ab379444 --- /dev/null +++ b/crates/winner-selection/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "winner-selection" +version = "0.1.0" +edition = "2024" + +[dependencies] +alloy = { workspace = true } +itertools = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs new file mode 100644 index 0000000000..bdb0c39a74 --- /dev/null +++ b/crates/winner-selection/src/arbitrator.rs @@ -0,0 +1,752 @@ +//! Winner selection arbitrator. +//! +//! Implements the auction winner selection algorithm that picks the set of +//! solutions which maximize surplus while enforcing uniform directional +//! clearing prices. + +use { + crate::{ + auction::AuctionContext, + primitives::{DirectedTokenPair, FeePolicy, Quote, Side, as_erc20, price_in_eth}, + solution::{Order, RankType, Ranked, Scored, Solution, Unscored}, + state::{RankedItem, ScoredItem, UnscoredItem}, + util::U256Ext, + }, + alloy::primitives::{Address, U256}, + anyhow::{Context, Result}, + itertools::{Either, Itertools}, + std::{ + cmp::Reverse, + collections::{HashMap, HashSet}, + }, +}; + +/// Auction arbitrator responsible for selecting winning solutions. +pub struct Arbitrator { + /// Maximum number of winning solutions to select. + pub max_winners: usize, + /// Wrapped native token address (WETH on mainnet, WXDAI on Gnosis). + pub weth: Address, +} + +impl Arbitrator { + /// Runs the auction mechanism on solutions. + /// + /// Takes solutions and auction context, returns a ranking with winners. + pub fn arbitrate( + &self, + solutions: Vec>, + context: &AuctionContext, + ) -> Ranking { + let partitioned = self.partition_unfair_solutions(solutions, context); + let filtered_out = partitioned + .discarded + .into_iter() + .map(|s| s.rank(RankType::FilteredOut)) + .collect(); + let mut ranked = self.mark_winners(partitioned.kept); + + ranked.sort_by_key(|solution| (Reverse(solution.is_winner()), Reverse(solution.score()))); + + Ranking { + filtered_out, + ranked, + } + } + + /// Removes unfair solutions from the set of all solutions. + fn partition_unfair_solutions( + &self, + solutions: Vec>, + context: &AuctionContext, + ) -> PartitionedSolutions { + // Discard all solutions where we can't compute the aggregate scores + // accurately because the fairness guarantees heavily rely on them. + let (mut solutions, scores_by_solution) = + self.compute_scores_by_solution(solutions, context); + + // Sort by score descending + solutions.sort_by_key(|solution| Reverse(solution.score())); + + let baseline_scores = compute_baseline_scores(&scores_by_solution); + + // Partition into fair and unfair solutions + let (kept, discarded): (Vec<_>, Vec<_>) = solutions.into_iter().partition_map(|solution| { + let aggregated_scores = scores_by_solution + .get(&SolutionKey { + solver: solution.solver(), + solution_id: solution.id(), + }) + .expect("every remaining solution has an entry"); + + // only keep solutions where each order execution is at least as good as + // the baseline solution. + // we only filter out unfair solutions with more than one token pair, + // to avoid reference scores set to 0. + // see https://github.com/fhenneke/comb_auctions/issues/2 + if aggregated_scores.len() == 1 + || aggregated_scores.iter().all(|(pair, score)| { + baseline_scores + .get(pair) + .is_none_or(|baseline| score >= baseline) + }) + { + Either::Left(solution) + } else { + Either::Right(solution) + } + }); + + PartitionedSolutions { kept, discarded } + } + + /// Picks winners and sorts all solutions where winners come before + /// non-winners and higher scores come before lower scores. + fn mark_winners(&self, solutions: Vec>) -> Vec> { + let winner_indices = self.pick_winners(solutions.iter()); + + solutions + .into_iter() + .enumerate() + .map(|(index, solution)| { + let rank_type = if winner_indices.contains(&index) { + RankType::Winner + } else { + RankType::NonWinner + }; + solution.rank(rank_type) + }) + .collect() + } + + /// Computes the `DirectionalScores` for all solutions and discards + /// solutions as invalid whenever that computation is not possible. + /// Solutions get discarded because fairness guarantees heavily + /// depend on these scores being accurate. + fn compute_scores_by_solution( + &self, + solutions: Vec>, + context: &AuctionContext, + ) -> (Vec>, ScoresBySolution) { + let mut scores_by_solution = HashMap::default(); + let mut scored_solutions = Vec::new(); + + for solution in solutions { + match self.score_by_token_pair(&solution, context) { + Ok(score) => { + let total_score = score + .values() + .fold(U256::ZERO, |acc, s| acc.saturating_add(*s)); + scores_by_solution.insert( + SolutionKey { + solver: solution.solver(), + solution_id: solution.id(), + }, + score, + ); + scored_solutions.push(solution.with_score(total_score)); + } + Err(err) => { + tracing::warn!( + solution_id = solution.id(), + ?err, + "discarding solution where scores could not be computed" + ); + } + } + } + + (scored_solutions, scores_by_solution) + } + + /// Returns the total scores for each directed token pair of the solution. + /// E.g. if a solution contains 3 orders like: + /// sell A for B with a score of 10 + /// sell A for B with a score of 5 + /// sell B for C with a score of 5 + /// it will return a map like: + /// (A, B) => 15 + /// (B, C) => 5 + fn score_by_token_pair( + &self, + solution: &Solution, + context: &AuctionContext, + ) -> Result> { + let mut scores: HashMap = HashMap::default(); + + for order in solution.orders() { + if !context.contributes_to_score(&order.uid) { + continue; + } + + let score = self.compute_order_score(order, solution, context)?; + + let token_pair = DirectedTokenPair { + sell: order.sell_token, + buy: order.buy_token, + }; + + let entry = scores.entry(token_pair).or_default(); + *entry = entry.saturating_add(score); + } + + Ok(scores) + } + + /// Score defined as (surplus + protocol fees) first converted to buy + /// amounts and then converted to the native token. + /// + /// Follows CIP-38 as the base of the score computation. + /// + /// Denominated in NATIVE token. + fn compute_order_score( + &self, + order: &Order, + solution: &Solution, + context: &AuctionContext, + ) -> Result { + let native_price_buy = context + .native_prices + .get(&order.buy_token) + .context("missing native price for buy token")?; + + let _uniform_sell_price = solution + .prices() + .get(&order.sell_token) + .context("missing uniform clearing price for sell token")?; + let _uniform_buy_price = solution + .prices() + .get(&order.buy_token) + .context("missing uniform clearing price for buy token")?; + + let custom_prices = self.calculate_custom_prices_from_executed(order); + + // Calculate surplus in surplus token (buy token for sell orders, sell token for + // buy orders) + let surplus_in_surplus_token = { + let user_surplus = self.surplus_over_limit_price(order, &custom_prices)?; + let fees = self.protocol_fees(order, context, &custom_prices)?; + + user_surplus + .checked_add(fees) + .context("overflow adding fees to surplus")? + }; + + let score_eth = match order.side { + // `surplus` of sell orders is already in buy tokens so we simply convert it to ETH + Side::Sell => price_in_eth(*native_price_buy, surplus_in_surplus_token), + Side::Buy => { + // `surplus` of buy orders is in sell tokens. We start with following formula: + // buy_amount / sell_amount == buy_price / sell_price + // + // since `surplus` of buy orders is in sell tokens we convert to buy amount via: + // buy_amount == (buy_price / sell_price) * surplus + // + // to avoid loss of precision because we work with integers we first multiply + // and then divide: + // buy_amount = surplus * buy_price / sell_price + use alloy::primitives::{U512, ruint::UintTryFrom}; + + let surplus_in_buy_tokens = surplus_in_surplus_token + .widening_mul(order.buy_amount) + .checked_div(U512::from(order.sell_amount)) + .context("division by zero converting surplus to buy tokens")?; + let surplus_in_buy_tokens: U256 = U256::uint_try_from(surplus_in_buy_tokens) + .map_err(|_| anyhow::anyhow!("overflow converting surplus to buy tokens"))?; + + // Afterwards we convert the buy token surplus to the native token. + price_in_eth(*native_price_buy, surplus_in_buy_tokens) + } + }; + + Ok(score_eth) + } + + /// Calculate total protocol fees for an order. + /// + /// Returns the total fee in the surplus token. + fn protocol_fees( + &self, + order: &Order, + context: &AuctionContext, + base_prices: &ClearingPrices, + ) -> Result { + let policies = context + .fee_policies + .get(&order.uid) + .map(|v| v.as_slice()) + .unwrap_or_default(); + + let mut total_fee = U256::ZERO; + let mut current_prices = *base_prices; + + // Process policies in reverse order, updating custom prices as we go + for (i, policy) in policies.iter().enumerate().rev() { + let fee = self.protocol_fee(order, policy, ¤t_prices)?; + + total_fee = total_fee + .checked_add(fee) + .context("overflow adding protocol fees")?; + + // Update custom prices for next iteration (except last iteration) + if i != 0 { + current_prices = self.calculate_custom_prices(order, total_fee, base_prices)?; + } + } + + Ok(total_fee) + } + + /// Calculate a single protocol fee based on policy type. + fn protocol_fee( + &self, + order: &Order, + policy: &FeePolicy, + custom_prices: &ClearingPrices, + ) -> MathResult { + match policy { + FeePolicy::Surplus { + factor, + max_volume_factor, + } => { + let surplus = self.surplus_over_limit_price(order, custom_prices)?; + let surplus_fee = self.surplus_fee(surplus, *factor)?; + let volume_fee = self.volume_fee(order, custom_prices, *max_volume_factor)?; + Ok(surplus_fee.min(volume_fee)) + } + FeePolicy::PriceImprovement { + factor, + max_volume_factor, + quote, + } => { + let price_improvement = + self.price_improvement_over_quote(order, custom_prices, quote)?; + let surplus_fee = self.surplus_fee(price_improvement, *factor)?; + let volume_fee = self.volume_fee(order, custom_prices, *max_volume_factor)?; + Ok(surplus_fee.min(volume_fee)) + } + FeePolicy::Volume { factor } => self.volume_fee(order, custom_prices, *factor), + } + } + + /// Calculate surplus over limit price using custom clearing prices. + fn surplus_over_limit_price(&self, order: &Order, prices: &ClearingPrices) -> MathResult { + self.surplus_over( + order, + prices, + PriceLimits { + sell: order.sell_amount, + buy: order.buy_amount, + }, + ) + } + + /// Calculate surplus over arbitrary price limits. + fn surplus_over( + &self, + order: &Order, + prices: &ClearingPrices, + limits: PriceLimits, + ) -> MathResult { + let executed = match order.side { + Side::Buy => order.executed_buy, + Side::Sell => order.executed_sell, + }; + + match order.side { + Side::Buy => { + // Scale limit sell to support partially fillable orders + let limit_sell = limits + .sell + .checked_mul(executed) + .ok_or(MathError::Overflow)? + .checked_div(limits.buy) + .ok_or(MathError::DivisionByZero)?; + + let sold = executed + .checked_mul(prices.buy) + .ok_or(MathError::Overflow)? + .checked_div(prices.sell) + .ok_or(MathError::DivisionByZero)?; + + limit_sell.checked_sub(sold).ok_or(MathError::Negative) + } + Side::Sell => { + // Scale limit buy to support partially fillable orders (ceiling division) + let limit_buy = executed + .checked_mul(limits.buy) + .ok_or(MathError::Overflow)? + .checked_ceil_div(&limits.sell) + .ok_or(MathError::DivisionByZero)?; + + let bought = executed + .checked_mul(prices.sell) + .ok_or(MathError::Overflow)? + .checked_ceil_div(&prices.buy) + .ok_or(MathError::DivisionByZero)?; + + bought.checked_sub(limit_buy).ok_or(MathError::Negative) + } + } + } + + /// Calculate price improvement over quote. + /// + /// Returns 0 if there's no improvement (instead of error). + fn price_improvement_over_quote( + &self, + order: &Order, + prices: &ClearingPrices, + quote: &Quote, + ) -> MathResult { + let adjusted_quote = self.adjust_quote_to_order_limits(order, quote)?; + match self.surplus_over(order, prices, adjusted_quote) { + Ok(surplus) => Ok(surplus), + Err(MathError::Negative) => Ok(U256::ZERO), + Err(err) => Err(err), + } + } + + /// Adjust quote amounts to be comparable with order limits. + fn adjust_quote_to_order_limits( + &self, + order: &Order, + quote: &Quote, + ) -> MathResult { + match order.side { + Side::Sell => { + // Quote buy amount after fees + let quote_buy_amount = quote + .buy_amount + .checked_sub( + quote + .fee + .checked_mul(quote.buy_amount) + .ok_or(MathError::Overflow)? + .checked_div(quote.sell_amount) + .ok_or(MathError::DivisionByZero)?, + ) + .ok_or(MathError::Negative)?; + + // Scale to order's sell amount + let scaled_buy_amount = quote_buy_amount + .checked_mul(order.sell_amount) + .ok_or(MathError::Overflow)? + .checked_div(quote.sell_amount) + .ok_or(MathError::DivisionByZero)?; + + // Use max to handle out-of-market orders + let buy_amount = order.buy_amount.max(scaled_buy_amount); + + Ok(PriceLimits { + sell: order.sell_amount, + buy: buy_amount, + }) + } + Side::Buy => { + // Quote sell amount including fees + let quote_sell_amount = quote + .sell_amount + .checked_add(quote.fee) + .ok_or(MathError::Overflow)?; + + // Scale to order's buy amount + let scaled_sell_amount = quote_sell_amount + .checked_mul(order.buy_amount) + .ok_or(MathError::Overflow)? + .checked_div(quote.buy_amount) + .ok_or(MathError::DivisionByZero)?; + + // Use min to handle out-of-market orders + let sell_amount = order.sell_amount.min(scaled_sell_amount); + + Ok(PriceLimits { + sell: sell_amount, + buy: order.buy_amount, + }) + } + } + } + + /// Calculate surplus fee as a cut of surplus. + /// + /// Uses adjusted factor: fee = surplus * factor / (1 - factor) + fn surplus_fee(&self, surplus: U256, factor: f64) -> MathResult { + // Surplus fee is specified as a `factor` from raw surplus (before fee). + // Since we work with trades that already have the protocol fee applied, + // we need to calculate the protocol fee using an adjusted factor. + // + // fee = surplus_before_fee * factor + // surplus_after_fee = surplus_before_fee - fee + // fee = surplus_after_fee * factor / (1 - factor) + surplus + .checked_mul_f64(factor / (1.0 - factor)) + .ok_or(MathError::Overflow) + } + + /// Calculate volume fee as a cut of trade volume. + fn volume_fee(&self, order: &Order, prices: &ClearingPrices, factor: f64) -> MathResult { + // Volume fee is specified as a factor from raw volume (before fee). + // We need to calculate using an adjusted factor based on order side. + // + // Sell: fee = traded_buy_amount * factor / (1 - factor) + // Buy: fee = traded_sell_amount * factor / (1 + factor) + + let executed_in_surplus_token = match order.side { + Side::Sell => self.buy_amount(order, prices)?, + Side::Buy => self.sell_amount(order, prices)?, + }; + + let adjusted_factor = match order.side { + Side::Sell => factor / (1.0 - factor), + Side::Buy => factor / (1.0 + factor), + }; + + executed_in_surplus_token + .checked_mul_f64(adjusted_factor) + .ok_or(MathError::Overflow) + } + + /// Calculate custom clearing prices from executed amounts. + /// + /// Custom prices are derived from what was actually executed. + fn calculate_custom_prices_from_executed(&self, order: &Order) -> ClearingPrices { + ClearingPrices { + sell: order.executed_buy, + buy: order.executed_sell, + } + } + + /// Calculate custom clearing prices excluding protocol fees. + /// + /// This adjusts prices to reflect the trade without the accumulated fees. + fn calculate_custom_prices( + &self, + order: &Order, + protocol_fee: U256, + prices: &ClearingPrices, + ) -> MathResult { + let sell_amount = self.sell_amount(order, prices)?; + let buy_amount = self.buy_amount(order, prices)?; + + Ok(ClearingPrices { + sell: match order.side { + Side::Sell => buy_amount + .checked_add(protocol_fee) + .ok_or(MathError::Overflow)?, + Side::Buy => buy_amount, + }, + buy: match order.side { + Side::Sell => sell_amount, + Side::Buy => sell_amount + .checked_sub(protocol_fee) + .ok_or(MathError::Negative)?, + }, + }) + } + + /// Calculate effective sell amount (what left user's wallet). + fn sell_amount(&self, order: &Order, prices: &ClearingPrices) -> MathResult { + match order.side { + Side::Sell => Ok(order.executed_sell), + Side::Buy => order + .executed_buy + .checked_mul(prices.buy) + .ok_or(MathError::Overflow)? + .checked_div(prices.sell) + .ok_or(MathError::DivisionByZero), + } + } + + /// Calculate effective buy amount (what user received). + fn buy_amount(&self, order: &Order, prices: &ClearingPrices) -> MathResult { + match order.side { + Side::Sell => order + .executed_sell + .checked_mul(prices.sell) + .ok_or(MathError::Overflow)? + .checked_ceil_div(&prices.buy) + .ok_or(MathError::DivisionByZero), + Side::Buy => Ok(order.executed_buy), + } + } + + /// Returns indices of winning solutions. + /// Assumes that `solutions` is sorted by score descendingly. + /// This logic was moved into a helper function to avoid a ton of `.clone()` + /// operations in `compute_reference_scores()`. + fn pick_winners<'a, T: 'a>( + &self, + solutions: impl Iterator>, + ) -> HashSet { + // Winners are selected one by one, starting from the best solution, + // until `max_winners` are selected. A solution can only + // win if none of the (sell_token, buy_token) pairs of the executed + // orders have been covered by any previously selected winning solution. + // In other words this enforces a uniform **directional** clearing price. + let mut already_swapped_token_pairs = HashSet::new(); + let mut winners = HashSet::default(); + + for (index, solution) in solutions.enumerate() { + if winners.len() >= self.max_winners { + return winners; + } + + let swapped_token_pairs: HashSet = solution + .orders() + .iter() + .map(|order| DirectedTokenPair { + sell: as_erc20(order.sell_token, self.weth), + buy: as_erc20(order.buy_token, self.weth), + }) + .collect(); + + if swapped_token_pairs.is_disjoint(&already_swapped_token_pairs) { + winners.insert(index); + already_swapped_token_pairs.extend(swapped_token_pairs); + } + } + + winners + } + + /// Compute reference scores for winning solvers. + pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap { + let mut reference_scores = HashMap::default(); + + for ranked_solution in &ranking.ranked { + let solver = ranked_solution.solver(); + + if reference_scores.len() >= self.max_winners { + return reference_scores; + } + if reference_scores.contains_key(&solver) { + continue; + } + if !ranked_solution.is_winner() { + continue; + } + + // Compute score without this solver + let solutions_without_solver: Vec<_> = ranking + .ranked + .iter() + .filter(|s| s.solver() != solver) + .collect(); + + let winner_indices = self.pick_winners(solutions_without_solver.iter().copied()); + + let score = solutions_without_solver + .iter() + .enumerate() + .filter(|(index, _)| winner_indices.contains(index)) + .map(|(_, solution)| solution.score()) + .reduce(|acc, score| acc.saturating_add(score)) + .unwrap_or_default(); + + reference_scores.insert(solver, score); + } + + reference_scores + } +} + +/// Let's call a solution that only trades 1 directed token pair a baseline +/// solution. Returns the best baseline solution (highest score) for +/// each token pair if one exists. +fn compute_baseline_scores(scores_by_solution: &ScoresBySolution) -> ScoreByDirection { + let mut baseline_scores = HashMap::default(); + + for scores in scores_by_solution.values() { + let Ok((token_pair, score)) = scores.iter().exactly_one() else { + continue; + }; + + let current_best = baseline_scores.entry(token_pair.clone()).or_default(); + if score > current_best { + *current_best = *score; + } + } + + baseline_scores +} + +/// Result of partitioning solutions into fair and unfair. +struct PartitionedSolutions { + /// Solutions that passed fairness checks (with scores). + kept: Vec>, + /// Solutions that were filtered out as unfair (with scores). + discarded: Vec>, +} + +/// Final ranking of all solutions. +#[derive(Debug)] +pub struct Ranking { + /// Solutions that were filtered out as unfair (with scores and FilteredOut + /// rank). + pub filtered_out: Vec>, + /// Solutions that passed fairness checks, ordered by score (with + /// Winner/NonWinner ranks). + pub ranked: Vec>, +} + +impl Ranking { + /// All winning solutions. + pub fn winners(&self) -> impl Iterator> { + self.ranked.iter().filter(|s| s.is_winner()) + } + + /// All non-winning solutions that weren't filtered out. + pub fn non_winners(&self) -> impl Iterator> { + self.ranked.iter().filter(|s| !s.is_winner()) + } +} + +/// Clearing prices for a trade. +/// +/// These can be either uniform (same for all orders) or custom (adjusted for +/// protocol fees on a per-order basis). +#[derive(Debug, Clone, Copy)] +struct ClearingPrices { + /// Price of sell token in terms of buy token. + sell: U256, + /// Price of buy token in terms of sell token. + buy: U256, +} + +/// Price limits for an order or quote. +#[derive(Debug, Clone, Copy)] +struct PriceLimits { + /// Maximum sell amount. + sell: U256, + /// Minimum buy amount. + buy: U256, +} + +/// Key to uniquely identify every solution. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct SolutionKey { + solver: Address, + solution_id: u64, +} + +/// Scores of all trades in a solution aggregated by the directional +/// token pair. E.g. all trades (WETH -> USDC) are aggregated into +/// one value and all trades (USDC -> WETH) into another. +type ScoreByDirection = HashMap; + +/// Mapping from solution to `DirectionalScores` for all solutions +/// of the auction. +type ScoresBySolution = HashMap; + +type MathResult = std::result::Result; + +#[derive(Debug, thiserror::Error)] +enum MathError { + #[error("overflow")] + Overflow, + #[error("division by zero")] + DivisionByZero, + #[error("negative")] + Negative, +} diff --git a/crates/winner-selection/src/auction.rs b/crates/winner-selection/src/auction.rs new file mode 100644 index 0000000000..607e015f6a --- /dev/null +++ b/crates/winner-selection/src/auction.rs @@ -0,0 +1,62 @@ +//! Auction context for winner selection. +//! +//! The auction context provides additional data needed for winner selection +//! that isn't part of the solution itself. This data comes from the auction +//! and is the same for all solutions. + +use { + crate::primitives::{FeePolicy, OrderUid}, + alloy::primitives::{Address, U256}, + std::collections::{HashMap, HashSet}, +}; + +/// Auction context needed for winner selection. +/// +/// This contains auction-level data that's needed to run the winner selection +/// algorithm but isn't part of individual solutions. Both autopilot and driver +/// build this from their respective auction representations. +pub struct AuctionContext { + /// Fee policies for each order in the auction. + /// + /// Maps order UID to the list of fee policies that apply to that order. + /// Fee policies determine how protocol fees are calculated. + pub fee_policies: HashMap>, + + /// Addresses that are allowed to create JIT orders that count toward score. + /// + /// JIT (Just-In-Time) orders created by these addresses will contribute to + /// the solution's score during winner selection. + pub surplus_capturing_jit_order_owners: HashSet
, + + /// Native token prices for all tokens in the auction. + /// + /// These prices are used to convert token amounts to native token + /// (ETH/XDAI) for score calculation. Maps token address to its price in + /// native token. + pub native_prices: HashMap, +} + +impl AuctionContext { + /// Check if an order contributes to the solution's score. + /// + /// An order contributes to score if: + /// 1. It has fee policies defined (it's a user order from the auction), OR + /// 2. It's a JIT order from an allowed surplus-capturing owner + pub fn contributes_to_score(&self, uid: &OrderUid) -> bool { + self.fee_policies.contains_key(uid) + || self + .surplus_capturing_jit_order_owners + .contains(&uid.owner()) + } +} + +/// Default implementation for empty contexts. +impl Default for AuctionContext { + fn default() -> Self { + Self { + fee_policies: HashMap::new(), + surplus_capturing_jit_order_owners: HashSet::new(), + native_prices: HashMap::new(), + } + } +} diff --git a/crates/winner-selection/src/lib.rs b/crates/winner-selection/src/lib.rs new file mode 100644 index 0000000000..d3b6598491 --- /dev/null +++ b/crates/winner-selection/src/lib.rs @@ -0,0 +1,21 @@ +//! Minimal winner selection data structures and algorithm. +//! +//! This crate defines minimal data structures that contain only what's needed +//! to run the winner selection algorithm. Both autopilot and driver convert +//! their full solution types to these minimal structs, which are then sent to +//! the Pod Service for storage and later retrieval. + +pub mod arbitrator; +pub mod auction; +pub mod primitives; +pub mod solution; +pub mod state; +pub mod util; + +// Re-export key types for convenience +pub use { + arbitrator::{Arbitrator, Ranking}, + auction::AuctionContext, + primitives::{Address, DirectedTokenPair, OrderUid, Side, U256}, + solution::{Order, RankType, Ranked, Scored, Solution, Unscored}, +}; diff --git a/crates/winner-selection/src/primitives.rs b/crates/winner-selection/src/primitives.rs new file mode 100644 index 0000000000..81db4739f3 --- /dev/null +++ b/crates/winner-selection/src/primitives.rs @@ -0,0 +1,83 @@ +//! Primitive types for winner selection. + +pub use alloy::primitives::{Address, U256}; + +/// Native token constant (ETH on mainnet, XDAI on Gnosis) +pub const NATIVE_TOKEN: Address = Address::repeat_byte(0xee); + +/// If the token is ETH/XDAI, return WETH/WXDAI, converting it to ERC20. +pub fn as_erc20(token: Address, wrapped: Address) -> Address { + if token == NATIVE_TOKEN { + wrapped + } else { + token + } +} + +/// Convert a token amount to ETH using this price. +/// +/// Formula: `amount * price / 10^18` +pub fn price_in_eth(price: U256, amount: U256) -> U256 { + amount.saturating_mul(price) / U256::from(1_000_000_000_000_000_000u64) +} + +/// A directed token pair for tracking uniform clearing prices. +/// +/// The direction matters: selling token A to buy token B is different from +/// selling token B to buy token A for the purpose of uniform directional +/// clearing prices. +#[derive(Debug, Clone, Hash, Eq, PartialEq)] +pub struct DirectedTokenPair { + pub sell: Address, + pub buy: Address, +} + +/// A unique identifier for an order. +/// +/// This is a 56-byte array consisting of: +/// - 32 bytes: order digest (hash of order parameters) +/// - 20 bytes: owner address +/// - 4 bytes: valid until timestamp +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OrderUid(pub [u8; 56]); + +impl OrderUid { + /// Extract the owner address from the order UID. + pub fn owner(&self) -> Address { + // Bytes 32-51 contain the owner address (20 bytes) + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(&self.0[32..52]); + Address::from(bytes) + } +} + +/// Order side (buy or sell). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Side { + Buy, + Sell, +} + +/// Protocol fee policy. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FeePolicy { + /// Fee as a percentage of surplus over limit price. + Surplus { factor: f64, max_volume_factor: f64 }, + /// Fee as a percentage of price improvement over quote. + PriceImprovement { + factor: f64, + max_volume_factor: f64, + quote: Quote, + }, + /// Fee as a percentage of order volume. + Volume { factor: f64 }, +} + +/// Quote data for price improvement fee calculation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Quote { + pub sell_amount: U256, + pub buy_amount: U256, + pub fee: U256, + pub solver: Address, +} diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs new file mode 100644 index 0000000000..3fb8d9f22d --- /dev/null +++ b/crates/winner-selection/src/solution.rs @@ -0,0 +1,150 @@ +//! Minimal solution and order data structures. +//! +//! These structs contain only the data needed for winner selection, +//! making them small enough to efficiently send to/from the Pod Service. + +pub use state::{RankType, Unscored}; +use { + crate::{ + primitives::{OrderUid, Side}, + state, + }, + alloy::primitives::{Address, U256}, + std::collections::HashMap, +}; +pub type Scored = state::Scored; +pub type Ranked = state::Ranked; + +/// Minimal solution data needed for winner selection. +/// +/// This contains only what's absolutely necessary to run the winner selection +/// algorithm. Autopilot and driver convert their full solution types to this +/// minimal format before sending to the Pod Service. +/// +/// Estimated size: ~1.7KB for a solution with 5 orders and 10 unique tokens. +#[derive(Debug, Clone)] +pub struct Solution { + /// Solution ID from solver (unique per solver). + pub id: u64, + + /// Solver's submission address (used for identifying the solver). + pub solver: Address, + + /// Orders executed in this solution. + /// + /// Uses Vec instead of HashMap for smaller serialization size. + pub orders: Vec, + + /// Uniform clearing prices for all tokens in the solution. + /// + /// Maps token address to its price in the native token (ETH/XDAI). + /// These are the prices at which all orders trading these tokens are + /// settled. + pub prices: HashMap, + + /// State marker (score and ranking information). + state: State, +} + +impl Solution { + /// Get the solution ID. + pub fn id(&self) -> u64 { + self.id + } + + /// Get the solver address. + pub fn solver(&self) -> Address { + self.solver + } + + /// Get the orders. + pub fn orders(&self) -> &[Order] { + &self.orders + } + + /// Get the clearing prices. + pub fn prices(&self) -> &HashMap { + &self.prices + } +} + +impl state::WithState for Solution { + type State = State; + type WithState = Solution; + + fn with_state(self, state: NewState) -> Self::WithState { + Solution { + id: self.id, + solver: self.solver, + orders: self.orders, + prices: self.prices, + state, + } + } + + fn state(&self) -> &Self::State { + &self.state + } +} + +impl Solution { + /// Create a new unscored solution. + pub fn new( + id: u64, + solver: Address, + orders: Vec, + prices: HashMap, + ) -> Self { + Self { + id, + solver, + orders, + prices, + state: Unscored, + } + } +} + +/// Minimal order data needed for winner selection. +/// +/// Contains the essential information about how an order was executed, +/// including limit amounts (from the original order) and executed amounts +/// (what actually happened in this solution). +/// +/// Estimated size: ~225 bytes per order. +#[derive(Debug, Clone)] +pub struct Order { + /// Unique order identifier (56 bytes). + pub uid: OrderUid, + + /// Sell token address. + pub sell_token: Address, + + /// Buy token address. + pub buy_token: Address, + + /// Limit amount of sell token (from original order parameters). + /// + /// This is the maximum amount the user is willing to sell. + pub sell_amount: U256, + + /// Limit amount of buy token (from original order parameters). + /// + /// This is the minimum amount the user wants to receive. + pub buy_amount: U256, + + /// Amount of sell token that left the user's wallet (including fees). + /// + /// This is the actual executed amount in this solution. + pub executed_sell: U256, + + /// Amount of buy token the user received (after fees). + /// + /// This is the actual amount the user got in this solution. + pub executed_buy: U256, + + /// Order side (Buy or Sell). + /// + /// Determines how surplus is calculated. + pub side: Side, +} diff --git a/crates/winner-selection/src/state.rs b/crates/winner-selection/src/state.rs new file mode 100644 index 0000000000..9c0e961a56 --- /dev/null +++ b/crates/winner-selection/src/state.rs @@ -0,0 +1,103 @@ +//! Shared scoring and ranking state markers. + +/// Solution/participant that hasn't been scored yet. +#[derive(Debug, Clone, Copy)] +pub struct Unscored; + +/// Solution/participant with a computed score. +#[derive(Debug, Clone, Copy)] +pub struct Scored { + pub score: Score, +} + +/// Solution/participant with ranking information. +#[derive(Debug, Clone, Copy)] +pub struct Ranked { + pub rank_type: RankType, + pub score: Score, +} + +/// The type of ranking assigned to a solution/participant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RankType { + Winner, + NonWinner, + FilteredOut, +} + +impl RankType { + pub fn is_winner(self) -> bool { + matches!(self, RankType::Winner) + } + + pub fn is_filtered_out(self) -> bool { + matches!(self, RankType::FilteredOut) + } +} + +pub trait WithState { + type State; + type WithState; + + fn with_state(self, state: NewState) -> Self::WithState; + fn state(&self) -> &Self::State; +} + +pub trait UnscoredItem: WithState { + fn with_score(self, score: Score) -> Self::WithState>; +} + +impl UnscoredItem for T +where + T: WithState, +{ + fn with_score(self, score: Score) -> Self::WithState> { + self.with_state(Scored { score }) + } +} + +pub trait ScoredItem: WithState> { + fn score(&self) -> Score; + fn rank(self, rank_type: RankType) -> Self::WithState> + where + Self: Sized; +} + +impl ScoredItem for T +where + Score: Copy, + T: WithState>, +{ + fn score(&self) -> Score { + self.state().score + } + + fn rank(self, rank_type: RankType) -> Self::WithState> { + let score = self.state().score; + self.with_state(Ranked { rank_type, score }) + } +} + +pub trait RankedItem: WithState> { + fn score(&self) -> Score; + fn is_winner(&self) -> bool; + fn is_filtered_out(&self) -> bool; +} + +impl RankedItem for T +where + Score: Copy, + T: WithState>, +{ + fn score(&self) -> Score { + self.state().score + } + + fn is_winner(&self) -> bool { + self.state().rank_type.is_winner() + } + + fn is_filtered_out(&self) -> bool { + self.state().rank_type.is_filtered_out() + } +} diff --git a/crates/winner-selection/src/util.rs b/crates/winner-selection/src/util.rs new file mode 100644 index 0000000000..d9bfff466f --- /dev/null +++ b/crates/winner-selection/src/util.rs @@ -0,0 +1,32 @@ +//! Utility functions and extensions for winner selection. + +use alloy::primitives::U256; + +/// Extension trait for U256 to add utility methods. +pub trait U256Ext: Sized { + /// Ceiling division: (self + other - 1) / other + fn checked_ceil_div(&self, other: &Self) -> Option; + + /// Multiply U256 by f64 factor with high precision. + fn checked_mul_f64(&self, factor: f64) -> Option; +} + +impl U256Ext for U256 { + fn checked_ceil_div(&self, other: &Self) -> Option { + self.checked_add(other.checked_sub(Self::from(1u64))?)? + .checked_div(*other) + } + + fn checked_mul_f64(&self, factor: f64) -> Option { + // `factor` is first multiplied by the conversion factor to convert + // it to integer, to avoid rounding to 0. Then, the result is divided + // by the conversion factor to convert it back to the original scale. + // + // The higher the conversion factor (10^18) the precision is higher. E.g. + // 0.123456789123456789 will be converted to 123456789123456789. + const CONVERSION_FACTOR: f64 = 1_000_000_000_000_000_000.; + let multiplied = self.checked_mul(Self::from(factor * CONVERSION_FACTOR))? + / Self::from(CONVERSION_FACTOR); + Some(multiplied) + } +}