From dee28ce4b386f563c4e7466aa1248c769d63f5ea Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 24 Dec 2025 17:30:00 +0000 Subject: [PATCH 01/11] tmp --- Cargo.lock | 16 + crates/winner-selection/Cargo.toml | 16 + crates/winner-selection/src/arbitrator.rs | 369 ++++++++++++++++++++++ crates/winner-selection/src/auction.rs | 65 ++++ crates/winner-selection/src/lib.rs | 29 ++ crates/winner-selection/src/primitives.rs | 262 +++++++++++++++ crates/winner-selection/src/solution.rs | 82 +++++ 7 files changed, 839 insertions(+) create mode 100644 crates/winner-selection/Cargo.toml create mode 100644 crates/winner-selection/src/arbitrator.rs create mode 100644 crates/winner-selection/src/auction.rs create mode 100644 crates/winner-selection/src/lib.rs create mode 100644 crates/winner-selection/src/primitives.rs create mode 100644 crates/winner-selection/src/solution.rs diff --git a/Cargo.lock b/Cargo.lock index 6c3ed33967..3de6ae39f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8112,6 +8112,22 @@ 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", + "derive_more 1.0.0", + "hex", + "itertools 0.14.0", + "num", + "serde", + "serde_json", + "thiserror 1.0.61", + "tracing", +] + [[package]] name = "winnow" version = "0.5.40" diff --git a/crates/winner-selection/Cargo.toml b/crates/winner-selection/Cargo.toml new file mode 100644 index 0000000000..13dfac13cb --- /dev/null +++ b/crates/winner-selection/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "winner-selection" +version = "0.1.0" +edition = "2024" + +[dependencies] +alloy = { workspace = true, features = ["serde"] } +serde = { workspace = true } +serde_json = { workspace = true } +num = { workspace = true } +itertools = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +derive_more = { workspace = true } +tracing = { workspace = true } +hex = "0.4" diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs new file mode 100644 index 0000000000..f6277f460f --- /dev/null +++ b/crates/winner-selection/src/arbitrator.rs @@ -0,0 +1,369 @@ +//! 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, Price, Score, Side, TokenAmount, WrappedNativeToken}, + solution::{Order, Solution}, + }, + alloy::primitives::{Address, U256}, + anyhow::{Context, Result}, + itertools::{Either, Itertools}, + num::Saturating, + std::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: WrappedNativeToken, +} + +impl Arbitrator { + /// Runs the auction mechanism on solutions. + /// + /// Takes solutions and auction context, returns a ranking with winners. + pub fn arbitrate( + &self, + mut solutions: Vec, + context: &AuctionContext, + ) -> Ranking + where + FeePolicy: AsRef<[u8]>, // Placeholder - will be refined when integrating + { + // Compute scores and filter out invalid solutions + let scores_by_solution = self.compute_scores(&mut solutions, context); + + // Compute total scores for sorting + let total_scores: HashMap = scores_by_solution + .iter() + .map(|(id, scores_by_pair)| { + let total = scores_by_pair + .values() + .copied() + .reduce(Score::saturating_add) + .unwrap_or_default(); + (*id, total) + }) + .collect(); + + // Sort by score descending + solutions.sort_by_key(|solution| { + std::cmp::Reverse(total_scores.get(&solution.id).copied().unwrap_or_default()) + }); + + let baseline_scores = compute_baseline_scores(&scores_by_solution); + + // Partition into fair and unfair solutions + let (fair, unfair): (Vec<_>, Vec<_>) = solutions.into_iter().partition_map(|solution| { + let aggregated_scores = match scores_by_solution.get(&solution.id) { + Some(scores) => scores, + None => return Either::Right(solution), // Filtered out + }; + + // Only filter out unfair solutions with more than one token pair + 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) + } + }); + + // Pick winners from fair solutions + let winner_indices = self.pick_winners(fair.iter()); + let ranked: Vec<_> = fair + .into_iter() + .enumerate() + .map(|(index, solution)| RankedSolution { + solution, + is_winner: winner_indices.contains(&index), + }) + .collect(); + + Ranking { + filtered_out: unfair, + ranked, + } + } + + /// Compute scores for all solutions. + fn compute_scores( + &self, + solutions: &mut [Solution], + context: &AuctionContext, + ) -> HashMap> { + let mut scores = HashMap::new(); + + for solution in solutions.iter() { + let mut solution_scores = HashMap::new(); + + for order in &solution.orders { + // Only score orders that contribute to the solution's score + if !context.contributes_to_score(&order.uid) { + continue; + } + + // Compute score for this order + match self.compute_order_score(order, solution, context) { + Ok(score) => { + let pair = DirectedTokenPair { + sell: order.sell_token.as_erc20(self.weth), + buy: order.buy_token.as_erc20(self.weth), + }; + solution_scores + .entry(pair) + .or_insert(Score::default()) + .saturating_add_assign(score); + } + Err(err) => { + tracing::warn!( + ?order.uid, + ?err, + "failed to compute score for order, skipping solution" + ); + // Return empty scores for this solution (will be filtered out) + scores.insert(solution.id, HashMap::new()); + continue; + } + } + } + + scores.insert(solution.id, solution_scores); + } + + scores + } + + /// Compute score for a single order. + /// + /// Score = surplus + protocol_fees (converted to native token). + fn compute_order_score( + &self, + order: &Order, + solution: &Solution, + context: &AuctionContext, + ) -> Result { + // Get native prices for the tokens + let _native_price_sell = context + .native_prices + .get(&order.sell_token) + .context("missing native price for sell token")?; + let native_price_buy = context + .native_prices + .get(&order.buy_token) + .context("missing native price for buy token")?; + + // Get uniform clearing prices (native prices of the tokens) + 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")?; + + // Calculate surplus over limit price + let surplus = self.calculate_surplus(order, uniform_sell_price, uniform_buy_price)?; + + // TODO: Calculate protocol fees from fee policies + // For now, just return the surplus + // let fees = self.calculate_protocol_fees(order, context)?; + + // Convert surplus to native token based on order side + let score_eth = match order.side { + Side::Sell => { + // Surplus for sell orders is in buy token, convert directly to ETH + native_price_buy.in_eth(TokenAmount(surplus)) + } + Side::Buy => { + // Surplus for buy orders is in sell token, first convert to buy token amount + // then to ETH using: buy_amount = surplus * buy_limit / sell_limit + let surplus_in_buy_tokens = + surplus.saturating_mul(order.buy_amount.0) / order.sell_amount.0; + native_price_buy.in_eth(TokenAmount(surplus_in_buy_tokens)) + } + }; + + Score::new(score_eth).context("zero score") + } + + /// Calculate user surplus over limit price. + /// + /// Returns surplus in the "surplus token" (buy token for sell orders, sell + /// token for buy orders). + fn calculate_surplus( + &self, + order: &Order, + uniform_sell_price: &Price, + uniform_buy_price: &Price, + ) -> Result { + match order.side { + Side::Sell => { + // For sell orders, surplus = bought - limit_buy + // bought = executed_sell * uniform_sell / uniform_buy + let bought = order.executed_sell.0.saturating_mul(uniform_sell_price.0.0) + / uniform_buy_price.0.0; + + // Scale limit buy for partially fillable orders + let limit_buy = order + .executed_sell + .0 + .saturating_mul(order.buy_amount.0) + .saturating_add(order.sell_amount.0 - U256::from(1u64)) // Ceiling division + / order.sell_amount.0; + + bought + .checked_sub(limit_buy) + .context("negative surplus (unfair trade)") + } + Side::Buy => { + // For buy orders, surplus = limit_sell - sold + // sold = executed_buy * uniform_buy / uniform_sell + let sold = order.executed_buy.0.saturating_mul(uniform_buy_price.0.0) + / uniform_sell_price.0.0; + + // Scale limit sell for partially fillable orders + let limit_sell = + order.executed_buy.0.saturating_mul(order.sell_amount.0) / order.buy_amount.0; + + limit_sell + .checked_sub(sold) + .context("negative surplus (unfair trade)") + } + } + } + + /// Pick winners based on directional token pairs. + fn pick_winners<'a>(&self, solutions: impl Iterator) -> HashSet { + 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: order.sell_token.as_erc20(self.weth), + buy: order.buy_token.as_erc20(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.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 = ranking + .ranked + .iter() + .filter(|s| s.solution.solver != solver) + .map(|s| &s.solution); + + let winner_indices = self.pick_winners(solutions_without_solver.clone()); + + let score = solutions_without_solver + .enumerate() + .filter(|(index, _)| winner_indices.contains(index)) + .map(|(_, _solution)| Score::default()) // TODO: Get actual scores + .reduce(Score::saturating_add) + .unwrap_or_default(); + + reference_scores.insert(solver, score); + } + + reference_scores + } +} + +/// Compute baseline scores (best single-pair solutions). +fn compute_baseline_scores( + scores_by_solution: &HashMap>, +) -> HashMap { + 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 +} + +/// A solution with its ranking status. +#[derive(Debug, Clone)] +pub struct RankedSolution { + pub solution: Solution, + pub is_winner: bool, +} + +/// Final ranking of all solutions. +#[derive(Debug)] +pub struct Ranking { + /// Solutions that were filtered out as unfair. + pub filtered_out: Vec, + /// Solutions that passed fairness checks, ordered by score. + pub ranked: Vec, +} + +impl Ranking { + /// All winning solutions. + pub fn winners(&self) -> impl Iterator { + self.ranked + .iter() + .filter(|r| r.is_winner) + .map(|r| &r.solution) + } + + /// All non-winning solutions that weren't filtered out. + pub fn non_winners(&self) -> impl Iterator { + self.ranked + .iter() + .filter(|r| !r.is_winner) + .map(|r| &r.solution) + } +} diff --git a/crates/winner-selection/src/auction.rs b/crates/winner-selection/src/auction.rs new file mode 100644 index 0000000000..a4128f60a3 --- /dev/null +++ b/crates/winner-selection/src/auction.rs @@ -0,0 +1,65 @@ +//! 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::{OrderUid, Price, TokenAddress}, + alloy::primitives::Address, + 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. +/// +/// Generic over `FeePolicy` type to allow both autopilot and driver to use +/// their own fee policy types without requiring conversions. +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 contexts without fee policies. +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..5de6ac4379 --- /dev/null +++ b/crates/winner-selection/src/lib.rs @@ -0,0 +1,29 @@ +//! 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; + +// Re-export key types for convenience +pub use { + arbitrator::{Arbitrator, Ranking}, + auction::AuctionContext, + primitives::{ + DirectedTokenPair, + Ether, + OrderUid, + Price, + Score, + Side, + TokenAddress, + TokenAmount, + WrappedNativeToken, + }, + solution::{Order, Solution}, +}; diff --git a/crates/winner-selection/src/primitives.rs b/crates/winner-selection/src/primitives.rs new file mode 100644 index 0000000000..a35594363f --- /dev/null +++ b/crates/winner-selection/src/primitives.rs @@ -0,0 +1,262 @@ +//! Primitive types for winner selection. + +// Re-export alloy primitives +pub use alloy::primitives::{Address as EthAddress, U256 as EthU256}; +use { + alloy::primitives::{Address, U256}, + derive_more::{Display, From, Into}, +}; + +/// Native token constant (ETH on mainnet, XDAI on Gnosis) +pub const NATIVE_TOKEN: TokenAddress = TokenAddress(Address::repeat_byte(0xee)); + +/// An ERC20 token address. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + From, + Into, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct TokenAddress(pub Address); + +impl TokenAddress { + /// If the token is ETH/XDAI, return WETH/WXDAI, converting it to ERC20. + pub fn as_erc20(self, wrapped: WrappedNativeToken) -> Self { + if self == NATIVE_TOKEN { + wrapped.into() + } else { + self + } + } +} + +/// ERC20 representation of the chain's native token (WETH on mainnet, WXDAI on +/// Gnosis). +#[derive(Debug, Clone, Copy, From, Into, serde::Serialize, serde::Deserialize)] +#[serde(transparent)] +pub struct WrappedNativeToken(pub TokenAddress); + +impl From
for WrappedNativeToken { + fn from(value: Address) -> Self { + WrappedNativeToken(value.into()) + } +} + +/// An ERC20 token amount. +#[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + From, + Into, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct TokenAmount(pub U256); + +/// An amount denominated in the native token (ETH/XDAI). +#[derive( + Debug, + Default, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + From, + Into, + Display, + derive_more::Add, + derive_more::Sub, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct Ether(pub U256); + +impl Ether { + pub const ZERO: Self = Self(U256::ZERO); + + pub fn saturating_add(self, other: Self) -> Self { + Self(self.0.saturating_add(other.0)) + } + + pub fn saturating_sub(self, other: Self) -> Self { + Self(self.0.saturating_sub(other.0)) + } + + pub fn checked_add(self, other: Self) -> Option { + self.0.checked_add(other.0).map(Self) + } + + pub fn checked_sub(self, other: Self) -> Option { + self.0.checked_sub(other.0).map(Self) + } +} + +/// A price for converting token amounts to native token (ETH/XDAI). +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, From, serde::Serialize, serde::Deserialize, +)] +#[serde(transparent)] +pub struct Price(pub Ether); + +impl Price { + /// Convert a token amount to ETH using this price. + /// + /// Formula: `amount * price / 10^18` + pub fn in_eth(&self, amount: TokenAmount) -> Ether { + // Compute (amount * price) / 10^18 + // Use saturating operations to avoid overflow + let product = amount.0.saturating_mul(self.0.0); + let eth_amount = product / U256::from(1_000_000_000_000_000_000u64); // 10^18 + Ether(eth_amount) + } +} + +/// A solution score in native token. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, + Display, + derive_more::Add, + derive_more::Sub, + serde::Serialize, + serde::Deserialize, +)] +#[serde(transparent)] +pub struct Score(pub Ether); + +impl Score { + /// Create a new score, returning an error if it's zero. + pub fn new(ether: Ether) -> Result { + if ether.0.is_zero() { + Err(ZeroScore) + } else { + Ok(Self(ether)) + } + } + + /// Get the inner Ether value. + pub fn get(&self) -> &Ether { + &self.0 + } + + pub fn saturating_add_assign(&mut self, other: Self) { + self.0 = self.0.saturating_add(other.0); + } +} + +impl num::Saturating for Score { + fn saturating_add(self, v: Self) -> Self { + Self(self.0.saturating_add(v.0)) + } + + fn saturating_sub(self, v: Self) -> Self { + Self(self.0.saturating_sub(v.0)) + } +} + +impl num::CheckedSub for Score { + fn checked_sub(&self, v: &Self) -> Option { + self.0.checked_sub(v.0).map(Score) + } +} + +/// Error returned when a score is zero. +#[derive(Debug, thiserror::Error)] +#[error("the solver proposed a 0-score solution")] +pub struct ZeroScore; + +/// 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, serde::Serialize, serde::Deserialize)] +pub struct DirectedTokenPair { + pub sell: TokenAddress, + pub buy: TokenAddress, +} + +/// 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, PartialOrd, Ord)] +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) + } +} + +impl serde::Serialize for OrderUid { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Serialize as hex string with 0x prefix + let hex_string = format!("0x{}", hex::encode(self.0)); + serializer.serialize_str(&hex_string) + } +} + +impl<'de> serde::Deserialize<'de> for OrderUid { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let s = s.strip_prefix("0x").unwrap_or(&s); + let decoded = hex::decode(s).map_err(serde::de::Error::custom)?; + if decoded.len() != 56 { + return Err(serde::de::Error::custom(format!( + "expected 56 bytes, got {}", + decoded.len() + ))); + } + let mut bytes = [0u8; 56]; + bytes.copy_from_slice(&decoded); + Ok(OrderUid(bytes)) + } +} + +/// Order side (buy or sell). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Side { + Buy, + Sell, +} diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs new file mode 100644 index 0000000000..ddd528d096 --- /dev/null +++ b/crates/winner-selection/src/solution.rs @@ -0,0 +1,82 @@ +//! 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. + +use { + crate::primitives::{OrderUid, Price, Side, TokenAddress, TokenAmount}, + alloy::primitives::Address, + std::collections::HashMap, +}; + +/// 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, serde::Serialize, serde::Deserialize)] +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, +} + +/// 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, serde::Serialize, serde::Deserialize)] +pub struct Order { + /// Unique order identifier (56 bytes). + pub uid: OrderUid, + + /// Sell token address. + pub sell_token: TokenAddress, + + /// Buy token address. + pub buy_token: TokenAddress, + + /// Limit amount of sell token (from original order parameters). + /// + /// This is the maximum amount the user is willing to sell. + pub sell_amount: TokenAmount, + + /// Limit amount of buy token (from original order parameters). + /// + /// This is the minimum amount the user wants to receive. + pub buy_amount: TokenAmount, + + /// Amount of sell token that left the user's wallet (including fees). + /// + /// This is the actual executed amount in this solution. + pub executed_sell: TokenAmount, + + /// Amount of buy token the user received (after fees). + /// + /// This is the actual amount the user got in this solution. + pub executed_buy: TokenAmount, + + /// Order side (Buy or Sell). + /// + /// Determines how surplus is calculated. + pub side: Side, +} From e6d60f708d7d6e585780b9c9753ed0a5b550e4a2 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 24 Dec 2025 18:14:54 +0000 Subject: [PATCH 02/11] Better logic --- crates/winner-selection/src/arbitrator.rs | 255 ++++++++++++++-------- crates/winner-selection/src/solution.rs | 7 + 2 files changed, 167 insertions(+), 95 deletions(-) diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index f6277f460f..e0ca20576e 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -31,43 +31,50 @@ impl Arbitrator { /// Takes solutions and auction context, returns a ranking with winners. pub fn arbitrate( &self, - mut solutions: Vec, + solutions: Vec, context: &AuctionContext, ) -> Ranking where FeePolicy: AsRef<[u8]>, // Placeholder - will be refined when integrating { - // Compute scores and filter out invalid solutions - let scores_by_solution = self.compute_scores(&mut solutions, context); + let partitioned = self.partition_unfair_solutions(solutions, context); + let filtered_out = partitioned.discarded; + let ranked = self.mark_winners(partitioned.kept); - // Compute total scores for sorting - let total_scores: HashMap = scores_by_solution - .iter() - .map(|(id, scores_by_pair)| { - let total = scores_by_pair - .values() - .copied() - .reduce(Score::saturating_add) - .unwrap_or_default(); - (*id, total) - }) - .collect(); + Ranking { + filtered_out, + ranked, + } + } + + /// Removes unfair solutions from the set of all solutions. + fn partition_unfair_solutions( + &self, + mut solutions: Vec, + context: &AuctionContext, + ) -> PartitionedSolutions + where + FeePolicy: AsRef<[u8]>, + { + // Discard all solutions where we can't compute the aggregate scores + // accurately because the fairness guarantees heavily rely on them. + let scores_by_solution = self.compute_scores_by_solution(&mut solutions, context); // Sort by score descending - solutions.sort_by_key(|solution| { - std::cmp::Reverse(total_scores.get(&solution.id).copied().unwrap_or_default()) - }); + solutions.sort_by_key(|solution| std::cmp::Reverse(solution.score.unwrap_or_default())); let baseline_scores = compute_baseline_scores(&scores_by_solution); // Partition into fair and unfair solutions - let (fair, unfair): (Vec<_>, Vec<_>) = solutions.into_iter().partition_map(|solution| { - let aggregated_scores = match scores_by_solution.get(&solution.id) { - Some(scores) => scores, - None => return Either::Right(solution), // Filtered out - }; - - // Only filter out unfair solutions with more than one token pair + let (kept, discarded): (Vec<_>, Vec<_>) = solutions.into_iter().partition_map(|solution| { + let aggregated_scores = scores_by_solution + .get(&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. if aggregated_scores.len() == 1 || aggregated_scores.iter().all(|(pair, score)| { baseline_scores @@ -81,91 +88,117 @@ impl Arbitrator { } }); - // Pick winners from fair solutions - let winner_indices = self.pick_winners(fair.iter()); - let ranked: Vec<_> = fair + PartitionedSolutions { kept, discarded } + } + + /// Picks winners and marks all solutions. + fn mark_winners(&self, solutions: Vec) -> Vec { + let winner_indices = self.pick_winners(solutions.iter()); + + solutions .into_iter() .enumerate() .map(|(index, solution)| RankedSolution { - solution, is_winner: winner_indices.contains(&index), + solution, }) - .collect(); - - Ranking { - filtered_out: unfair, - ranked, - } + .collect() } - /// Compute scores for all solutions. - fn compute_scores( + /// Computes the `DirectedTokenPair` scores 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: &mut [Solution], + solutions: &mut Vec, context: &AuctionContext, - ) -> HashMap> { - let mut scores = HashMap::new(); + ) -> HashMap> + where + FeePolicy: AsRef<[u8]>, + { + let mut scores = HashMap::default(); + + solutions.retain_mut( + |solution| match self.score_by_token_pair(solution, context) { + Ok(score) => { + let total_score = score + .values() + .fold(Score::default(), |acc, s| acc.saturating_add(*s)); + scores.insert(solution.id, score); + solution.score = Some(total_score); + true + } + Err(err) => { + tracing::warn!( + solution_id = solution.id, + ?err, + "discarding solution where scores could not be computed" + ); + false + } + }, + ); - for solution in solutions.iter() { - let mut solution_scores = HashMap::new(); + scores + } - for order in &solution.orders { - // Only score orders that contribute to the solution's score - if !context.contributes_to_score(&order.uid) { - continue; - } + /// 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> + where + FeePolicy: AsRef<[u8]>, + { + let mut scores: HashMap = HashMap::default(); - // Compute score for this order - match self.compute_order_score(order, solution, context) { - Ok(score) => { - let pair = DirectedTokenPair { - sell: order.sell_token.as_erc20(self.weth), - buy: order.buy_token.as_erc20(self.weth), - }; - solution_scores - .entry(pair) - .or_insert(Score::default()) - .saturating_add_assign(score); - } - Err(err) => { - tracing::warn!( - ?order.uid, - ?err, - "failed to compute score for order, skipping solution" - ); - // Return empty scores for this solution (will be filtered out) - scores.insert(solution.id, HashMap::new()); - continue; - } - } + for order in &solution.orders { + if !context.contributes_to_score(&order.uid) { + continue; } - scores.insert(solution.id, solution_scores); + let score = self.compute_order_score(order, solution, context)?; + + let token_pair = DirectedTokenPair { + sell: order.sell_token.as_erc20(self.weth), + buy: order.buy_token.as_erc20(self.weth), + }; + + scores + .entry(token_pair) + .or_default() + .saturating_add_assign(score); } - scores + Ok(scores) } - /// Compute score for a single order. + /// 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. /// - /// Score = surplus + protocol_fees (converted to native token). + /// Denominated in NATIVE token. fn compute_order_score( &self, order: &Order, solution: &Solution, context: &AuctionContext, ) -> Result { - // Get native prices for the tokens - let _native_price_sell = context - .native_prices - .get(&order.sell_token) - .context("missing native price for sell token")?; let native_price_buy = context .native_prices .get(&order.buy_token) .context("missing native price for buy token")?; - // Get uniform clearing prices (native prices of the tokens) let uniform_sell_price = solution .prices .get(&order.sell_token) @@ -175,24 +208,48 @@ impl Arbitrator { .get(&order.buy_token) .context("missing uniform clearing price for buy token")?; - // Calculate surplus over limit price - let surplus = self.calculate_surplus(order, uniform_sell_price, uniform_buy_price)?; - - // TODO: Calculate protocol fees from fee policies - // For now, just return the surplus - // let fees = self.calculate_protocol_fees(order, context)?; + // Calculate surplus in surplus token (buy token for sell orders, sell token for + // buy orders) + #[allow(clippy::let_and_return)] + let surplus_in_surplus_token = { + let user_surplus = + self.calculate_surplus(order, uniform_sell_price, uniform_buy_price)?; + + // TODO: Add protocol fees from fee policies + // let fees: U256 = self.protocol_fees(order, context)? + // .into_iter() + // .try_fold(U256::ZERO, |acc, fee| { + // acc.checked_add(fee).context("overflow adding fees") + // })?; + // user_surplus.checked_add(fees).context("overflow adding fees to surplus")? + + // For now, just use user surplus without fees + user_surplus + }; - // Convert surplus to native token based on order side let score_eth = match order.side { - Side::Sell => { - // Surplus for sell orders is in buy token, convert directly to ETH - native_price_buy.in_eth(TokenAmount(surplus)) - } + // `surplus` of sell orders is already in buy tokens so we simply convert it to ETH + Side::Sell => native_price_buy.in_eth(TokenAmount(surplus_in_surplus_token)), Side::Buy => { - // Surplus for buy orders is in sell token, first convert to buy token amount - // then to ETH using: buy_amount = surplus * buy_limit / sell_limit - let surplus_in_buy_tokens = - surplus.saturating_mul(order.buy_amount.0) / order.sell_amount.0; + // `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.0) + .checked_div(U512::from(order.sell_amount.0)) + .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. native_price_buy.in_eth(TokenAmount(surplus_in_buy_tokens)) } }; @@ -334,6 +391,14 @@ fn compute_baseline_scores( baseline_scores } +/// Result of partitioning solutions into fair and unfair. +struct PartitionedSolutions { + /// Solutions that passed fairness checks. + kept: Vec, + /// Solutions that were filtered out as unfair. + discarded: Vec, +} + /// A solution with its ranking status. #[derive(Debug, Clone)] pub struct RankedSolution { diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs index ddd528d096..9962a98ee3 100644 --- a/crates/winner-selection/src/solution.rs +++ b/crates/winner-selection/src/solution.rs @@ -35,6 +35,13 @@ pub struct Solution { /// These are the prices at which all orders trading these tokens are /// settled. pub prices: HashMap, + + /// Total score for this solution. + /// + /// This is computed during winner selection and is not part of the + /// minimal data sent to the Pod Service. + #[serde(skip)] + pub score: Option, } /// Minimal order data needed for winner selection. From c12bb12ab9e13f2abc8da1770b7a2ff8f4f2b792 Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 24 Dec 2025 20:51:59 +0000 Subject: [PATCH 03/11] Protocol fees --- crates/winner-selection/src/arbitrator.rs | 378 +++++++++++++++++++--- crates/winner-selection/src/auction.rs | 13 +- crates/winner-selection/src/lib.rs | 1 + crates/winner-selection/src/primitives.rs | 24 ++ 4 files changed, 370 insertions(+), 46 deletions(-) diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index e0ca20576e..58483ab0f5 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -7,8 +7,18 @@ use { crate::{ auction::AuctionContext, - primitives::{DirectedTokenPair, Price, Score, Side, TokenAmount, WrappedNativeToken}, + primitives::{ + DirectedTokenPair, + FeePolicy, + Price, + Quote, + Score, + Side, + TokenAmount, + WrappedNativeToken, + }, solution::{Order, Solution}, + util::U256Ext, }, alloy::primitives::{Address, U256}, anyhow::{Context, Result}, @@ -29,14 +39,7 @@ 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 - where - FeePolicy: AsRef<[u8]>, // Placeholder - will be refined when integrating - { + pub fn arbitrate(&self, solutions: Vec, context: &AuctionContext) -> Ranking { let partitioned = self.partition_unfair_solutions(solutions, context); let filtered_out = partitioned.discarded; let ranked = self.mark_winners(partitioned.kept); @@ -48,14 +51,11 @@ impl Arbitrator { } /// Removes unfair solutions from the set of all solutions. - fn partition_unfair_solutions( + fn partition_unfair_solutions( &self, mut solutions: Vec, - context: &AuctionContext, - ) -> PartitionedSolutions - where - FeePolicy: AsRef<[u8]>, - { + context: &AuctionContext, + ) -> PartitionedSolutions { // Discard all solutions where we can't compute the aggregate scores // accurately because the fairness guarantees heavily rely on them. let scores_by_solution = self.compute_scores_by_solution(&mut solutions, context); @@ -109,14 +109,11 @@ impl Arbitrator { /// 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( + fn compute_scores_by_solution( &self, solutions: &mut Vec, - context: &AuctionContext, - ) -> HashMap> - where - FeePolicy: AsRef<[u8]>, - { + context: &AuctionContext, + ) -> HashMap> { let mut scores = HashMap::default(); solutions.retain_mut( @@ -151,14 +148,11 @@ impl Arbitrator { /// it will return a map like: /// (A, B) => 15 /// (B, C) => 5 - fn score_by_token_pair( + fn score_by_token_pair( &self, solution: &Solution, - context: &AuctionContext, - ) -> Result> - where - FeePolicy: AsRef<[u8]>, - { + context: &AuctionContext, + ) -> Result> { let mut scores: HashMap = HashMap::default(); for order in &solution.orders { @@ -188,11 +182,11 @@ impl Arbitrator { /// Follows CIP-38 as the base of the score computation. /// /// Denominated in NATIVE token. - fn compute_order_score( + fn compute_order_score( &self, order: &Order, solution: &Solution, - context: &AuctionContext, + context: &AuctionContext, ) -> Result { let native_price_buy = context .native_prices @@ -210,21 +204,23 @@ impl Arbitrator { // Calculate surplus in surplus token (buy token for sell orders, sell token for // buy orders) - #[allow(clippy::let_and_return)] let surplus_in_surplus_token = { let user_surplus = self.calculate_surplus(order, uniform_sell_price, uniform_buy_price)?; - // TODO: Add protocol fees from fee policies - // let fees: U256 = self.protocol_fees(order, context)? - // .into_iter() - // .try_fold(U256::ZERO, |acc, fee| { - // acc.checked_add(fee).context("overflow adding fees") - // })?; - // user_surplus.checked_add(fees).context("overflow adding fees to surplus")? + // Add protocol fees from fee policies + let fees = self.protocol_fees( + order, + context, + ClearingPrices { + sell: uniform_sell_price.0.0, + buy: uniform_buy_price.0.0, + }, + )?; - // For now, just use user surplus without fees user_surplus + .checked_add(fees) + .context("overflow adding fees to surplus")? }; let score_eth = match order.side { @@ -303,6 +299,291 @@ impl Arbitrator { } } + /// Calculate total protocol fees for an order. + /// + /// Returns the total fee in the surplus token. + fn protocol_fees( + &self, + order: &Order, + context: &AuctionContext, + uniform_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 custom_prices = + self.calculate_custom_prices_from_executed(order, uniform_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, &custom_prices, &uniform_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 { + custom_prices = self.calculate_custom_prices(order, total_fee, uniform_prices)?; + } + } + + Ok(total_fee) + } + + /// Calculate a single protocol fee based on policy type. + fn protocol_fee( + &self, + order: &Order, + policy: &FeePolicy, + custom_prices: &ClearingPrices, + _uniform_prices: &ClearingPrices, + ) -> Result { + use crate::primitives::FeePolicy; + + 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, *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, *max_volume_factor)?; + Ok(surplus_fee.min(volume_fee)) + } + FeePolicy::Volume { factor } => self.volume_fee(order, *factor), + } + } + + /// Calculate surplus over limit price using custom clearing prices. + fn surplus_over_limit_price(&self, order: &Order, prices: &ClearingPrices) -> Result { + self.surplus_over( + order, + prices, + PriceLimits { + sell: order.sell_amount.0, + buy: order.buy_amount.0, + }, + ) + } + + /// Calculate surplus over arbitrary price limits. + fn surplus_over( + &self, + order: &Order, + prices: &ClearingPrices, + limits: PriceLimits, + ) -> Result { + match order.side { + Side::Buy => { + // Scale limit sell to support partially fillable orders + let limit_sell = limits.sell.saturating_mul(order.executed_buy.0) / limits.buy; + + let sold = order.executed_buy.0.saturating_mul(prices.buy) / prices.sell; + + limit_sell + .checked_sub(sold) + .context("negative surplus (unfair trade)") + } + Side::Sell => { + // Scale limit buy to support partially fillable orders (ceiling division) + let limit_buy = order + .executed_sell + .0 + .saturating_mul(limits.buy) + .saturating_add(limits.sell - U256::from(1u64)) // Ceiling division + / limits.sell; + + let bought = order + .executed_sell + .0 + .saturating_mul(prices.sell) + .saturating_add(prices.buy - U256::from(1u64)) // Ceiling division + / prices.buy; + + bought + .checked_sub(limit_buy) + .context("negative surplus (unfair trade)") + } + } + } + + /// 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, + ) -> Result { + let adjusted_quote = self.adjust_quote_to_order_limits(order, quote)?; + match self.surplus_over(order, prices, adjusted_quote) { + Ok(surplus) => Ok(surplus), + Err(_) => Ok(U256::ZERO), // No improvement is not an error + } + } + + /// Adjust quote amounts to be comparable with order limits. + fn adjust_quote_to_order_limits(&self, order: &Order, quote: &Quote) -> Result { + match order.side { + Side::Sell => { + // Quote buy amount after fees + let quote_buy_amount = quote + .buy_amount + .checked_sub(quote.fee.saturating_mul(quote.buy_amount) / quote.sell_amount) + .context("quote fee exceeds buy amount")?; + + // Scale to order's sell amount + let scaled_buy_amount = + quote_buy_amount.saturating_mul(order.sell_amount.0) / quote.sell_amount; + + // Use max to handle out-of-market orders + let buy_amount = order.buy_amount.0.max(scaled_buy_amount); + + Ok(PriceLimits { + sell: order.sell_amount.0, + buy: buy_amount, + }) + } + Side::Buy => { + // Quote sell amount including fees + let quote_sell_amount = quote + .sell_amount + .checked_add(quote.fee) + .context("overflow adding quote fee")?; + + // Scale to order's buy amount + let scaled_sell_amount = + quote_sell_amount.saturating_mul(order.buy_amount.0) / quote.buy_amount; + + // Use min to handle out-of-market orders + let sell_amount = order.sell_amount.0.min(scaled_sell_amount); + + Ok(PriceLimits { + sell: sell_amount, + buy: order.buy_amount.0, + }) + } + } + } + + /// Calculate surplus fee as a cut of surplus. + /// + /// Uses adjusted factor: fee = surplus * factor / (1 - factor) + fn surplus_fee(&self, surplus: U256, factor: f64) -> Result { + // 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)) + .context("overflow calculating surplus fee") + } + + /// Calculate volume fee as a cut of trade volume. + fn volume_fee(&self, order: &Order, factor: f64) -> Result { + // 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 => order.executed_buy.0, + Side::Buy => order.executed_sell.0, + }; + + 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) + .context("overflow calculating volume fee") + } + + /// 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, + uniform_prices: ClearingPrices, + ) -> Result { + self.calculate_custom_prices(order, U256::ZERO, uniform_prices) + } + + /// 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, + uniform_prices: ClearingPrices, + ) -> Result { + let sell_amount = self.sell_amount(order, &uniform_prices)?; + let buy_amount = self.buy_amount(order, &uniform_prices)?; + + Ok(ClearingPrices { + sell: match order.side { + Side::Sell => buy_amount + .checked_add(protocol_fee) + .context("overflow adding protocol fee to buy amount")?, + Side::Buy => buy_amount, + }, + buy: match order.side { + Side::Sell => sell_amount, + Side::Buy => sell_amount + .checked_sub(protocol_fee) + .context("protocol fee exceeds sell amount")?, + }, + }) + } + + /// Calculate effective sell amount (what left user's wallet). + fn sell_amount(&self, order: &Order, prices: &ClearingPrices) -> Result { + Ok(match order.side { + Side::Sell => order.executed_sell.0, + Side::Buy => order.executed_buy.0.saturating_mul(prices.buy) / prices.sell, + }) + } + + /// Calculate effective buy amount (what user received). + fn buy_amount(&self, order: &Order, prices: &ClearingPrices) -> Result { + Ok(match order.side { + Side::Sell => { + order + .executed_sell + .0 + .saturating_mul(prices.sell) + .saturating_add(prices.buy - U256::from(1u64)) // Ceiling division + / prices.buy + } + Side::Buy => order.executed_buy.0, + }) + } + /// Pick winners based on directional token pairs. fn pick_winners<'a>(&self, solutions: impl Iterator) -> HashSet { let mut already_swapped_token_pairs = HashSet::new(); @@ -432,3 +713,24 @@ impl Ranking { .map(|r| &r.solution) } } + +/// 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, +} diff --git a/crates/winner-selection/src/auction.rs b/crates/winner-selection/src/auction.rs index a4128f60a3..a59f13f21b 100644 --- a/crates/winner-selection/src/auction.rs +++ b/crates/winner-selection/src/auction.rs @@ -5,7 +5,7 @@ //! and is the same for all solutions. use { - crate::primitives::{OrderUid, Price, TokenAddress}, + crate::primitives::{FeePolicy, OrderUid, Price, TokenAddress}, alloy::primitives::Address, std::collections::{HashMap, HashSet}, }; @@ -15,10 +15,7 @@ use { /// 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. -/// -/// Generic over `FeePolicy` type to allow both autopilot and driver to use -/// their own fee policy types without requiring conversions. -pub struct AuctionContext { +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. @@ -39,7 +36,7 @@ pub struct AuctionContext { pub native_prices: HashMap, } -impl AuctionContext { +impl AuctionContext { /// Check if an order contributes to the solution's score. /// /// An order contributes to score if: @@ -53,8 +50,8 @@ impl AuctionContext { } } -/// Default implementation for contexts without fee policies. -impl Default for AuctionContext<()> { +/// Default implementation for empty contexts. +impl Default for AuctionContext { fn default() -> Self { Self { fee_policies: HashMap::new(), diff --git a/crates/winner-selection/src/lib.rs b/crates/winner-selection/src/lib.rs index 5de6ac4379..b7c970e9a8 100644 --- a/crates/winner-selection/src/lib.rs +++ b/crates/winner-selection/src/lib.rs @@ -9,6 +9,7 @@ pub mod arbitrator; pub mod auction; pub mod primitives; pub mod solution; +pub mod util; // Re-export key types for convenience pub use { diff --git a/crates/winner-selection/src/primitives.rs b/crates/winner-selection/src/primitives.rs index a35594363f..46b778b2a3 100644 --- a/crates/winner-selection/src/primitives.rs +++ b/crates/winner-selection/src/primitives.rs @@ -260,3 +260,27 @@ pub enum Side { Buy, Sell, } + +/// Protocol fee policy. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +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, serde::Serialize, serde::Deserialize)] +pub struct Quote { + pub sell_amount: U256, + pub buy_amount: U256, + pub fee: U256, + pub solver: Address, +} From decca807938a0bb32273ae7218214a70f9b9234e Mon Sep 17 00:00:00 2001 From: ilya Date: Wed, 24 Dec 2025 20:52:21 +0000 Subject: [PATCH 04/11] Missing file --- crates/winner-selection/src/util.rs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 crates/winner-selection/src/util.rs 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) + } +} From 9a91a652d190fe8f3ee2067e6687144f5453bcd1 Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 25 Dec 2025 15:25:45 +0000 Subject: [PATCH 05/11] Migrate autopilot --- Cargo.lock | 1 + Cargo.toml | 1 + crates/autopilot/Cargo.toml | 1 + .../domain/competition/winner_selection.rs | 525 +++++++----------- crates/winner-selection/src/arbitrator.rs | 324 +++++------ 5 files changed, 372 insertions(+), 480 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3de6ae39f0..a4a93febc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1263,6 +1263,7 @@ dependencies = [ "url", "vergen", "web3", + "winner-selection", ] [[package]] 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/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index fbadf69e9e..34ab888412 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -27,25 +27,20 @@ use { crate::domain::{ self, - OrderUid, - auction::{ - Prices, - order::{self, TargetAmount}, - }, - competition::{Participant, Ranked, Score, Solution, Unranked}, + auction::order, + competition::{Participant, Ranked, Score, Solution, TradedOrder, Unranked}, eth::{self, WrappedNativeToken}, fee, - settlement::{ - math, - transaction::{self, ClearingPrices}, - }, }, - anyhow::{Context, Result}, - itertools::{Either, Itertools}, - num::Saturating, - std::collections::{HashMap, HashSet}, + std::collections::HashMap, + winner_selection as ws, }; +pub struct Arbitrator { + pub max_winners: usize, + pub weth: WrappedNativeToken, +} + /// Implements auction arbitration in 3 phases: /// 1. filter unfair solutions /// 2. mark winners @@ -60,357 +55,242 @@ impl Arbitrator { 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(Ranked::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(), None); + 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.solution().score()), - ) - }); - Ranking { - filtered_out, - ranked, + let ws_ranking = self.ws_arbitrator().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 mut participant = participant_by_key + .remove(&key) + .expect("every ranked solution has a matching participant"); + let score = ws_solution + .score + .expect("winner selection should compute scores"); + participant.set_score(ws_score_to_domain(score)); + filtered_out.push(participant.rank(Ranked::FilteredOut)); } - } - /// Removes unfair solutions from the set of all solutions. - fn partition_unfair_solutions( - &self, - mut 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 scores_by_solution = compute_scores_by_solution(&mut participants, auction); - participants.sort_by_key(|participant| { - std::cmp::Reverse( - // we use the computed score to not trust the score provided by solvers - participant.solution().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) + let mut ranked = Vec::with_capacity(ws_ranking.ranked.len()); + for ranked_solution in ws_ranking.ranked { + let key = SolutionKey::from(&ranked_solution.solution); + let mut participant = participant_by_key + .remove(&key) + .expect("every ranked solution has a matching participant"); + let score = ranked_solution + .solution + .score + .expect("winner selection should compute scores"); + participant.set_score(ws_score_to_domain(score)); + let rank = if ranked_solution.is_winner { + Ranked::Winner } else { - Either::Right(p) - } - }); - PartitionedSolutions { - kept: fair, - discarded: unfair, + Ranked::NonWinner + }; + ranked.push(participant.rank(rank)); } - } - /// 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 => Ranked::Winner, - false => Ranked::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 ws_ranking = to_ws_ranking(ranking); + self.ws_arbitrator() + .compute_reference_scores(&ws_ranking) + .into_iter() + .map(|(solver, score)| (solver, ws_score_to_domain(score))) + .collect() + } - let solutions_without_solver = ranking - .ranked - .iter() - .filter(|p| p.driver().submission_address != solver) - .map(|p| p.solution()); - let winner_indices = self.pick_winners(solutions_without_solver.clone()); - - let score = solutions_without_solver - .enumerate() - .filter(|(index, _)| winner_indices.contains(index)) - .filter_map(|(_, solution)| solution.score) - .reduce(Score::saturating_add) - .unwrap_or_default(); - reference_scores.insert(solver, score); + fn ws_arbitrator(&self) -> ws::Arbitrator { + ws::Arbitrator { + max_winners: self.max_winners, + weth: ws_weth(self.weth), } - - reference_scores } +} - /// 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; - } +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)| (to_ws_token(*token), to_ws_price(*price))) + .collect(), + } +} - 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_solution(solution: &Solution, score: Option) -> ws::Solution { + ws::Solution { + id: solution.id(), + solver: solution.solver(), + orders: solution + .orders() + .iter() + .map(|(uid, order)| to_ws_order(*uid, order)) + .collect(), + prices: solution + .prices() + .iter() + .map(|(token, price)| (to_ws_token(*token), to_ws_price(*price))) + .collect(), + score: score.map(domain_score_to_ws), } } -/// 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; - } +fn to_ws_order(uid: domain::OrderUid, order: &TradedOrder) -> ws::Order { + ws::Order { + uid: ws::OrderUid(uid.0), + sell_token: to_ws_token(order.sell.token), + buy_token: to_ws_token(order.buy.token), + sell_amount: to_ws_amount(order.sell.amount), + buy_amount: to_ws_amount(order.buy.amount), + executed_sell: to_ws_amount(order.executed_sell), + executed_buy: to_ws_amount(order.executed_buy), + side: to_ws_side(order.side), } - baseline_directional_scores } -/// 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: &mut Vec>, - auction: &domain::Auction, -) -> ScoresBySolution { - let auction = Auction::from(auction); - let mut scores = HashMap::default(); - - participants.retain_mut(|p| match score_by_token_pair(p.solution(), &auction) { - Ok(score) => { - let total_score = score - .values() - .fold(Score::default(), |acc, score| acc.saturating_add(*score)); - scores.insert( - SolutionKey { - driver: p.driver().submission_address, - solution_id: p.solution().id, - }, - score, - ); - p.set_score(total_score); - true - } - Err(err) => { - tracing::warn!( - driver = p.driver().name, - ?err, - solution = ?p.solution(), - "discarding solution where scores could not be computed" - ); - false - } - }); +fn to_ws_side(side: order::Side) -> ws::Side { + match side { + order::Side::Buy => ws::Side::Buy, + order::Side::Sell => ws::Side::Sell, + } +} - scores +fn to_ws_token(token: eth::TokenAddress) -> ws::TokenAddress { + ws::TokenAddress(token.0) } -/// 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; - } +fn to_ws_amount(amount: eth::TokenAmount) -> ws::TokenAmount { + ws::TokenAmount(amount.0) +} - 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)); - } - Ok(scores) +fn to_ws_price(price: domain::auction::Price) -> ws::Price { + ws::Price(ws::Ether(price.get().0)) } -pub struct Arbitrator { - pub max_winners: usize, - pub weth: WrappedNativeToken, +fn ws_score_to_domain(score: ws::Score) -> Score { + Score(eth::Ether(score.0.0)) } -/// 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, +fn domain_score_to_ws(score: Score) -> ws::Score { + ws::Score(ws::Ether(score.get().0)) } -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()) +fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { + let ranked = ranking + .ranked + .iter() + .map(|participant| ws::arbitrator::RankedSolution { + solution: to_ws_solution(participant.solution(), participant.solution().score), + is_winner: participant.is_winner(), + }) + .collect(); + + let filtered_out = ranking + .filtered_out + .iter() + .map(|participant| to_ws_solution(participant.solution(), participant.solution().score)) + .collect(); + + ws::Ranking { + filtered_out, + ranked, } } -impl<'a> From<&'a domain::Auction> for Auction<'a> { - fn from(original: &'a domain::Auction) -> 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(), - } +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(), + }, } } -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -struct DirectedTokenPair { - sell: eth::TokenAddress, - buy: eth::TokenAddress, +fn ws_weth(weth: WrappedNativeToken) -> ws::WrappedNativeToken { + let token: eth::TokenAddress = weth.into(); + ws::WrappedNativeToken::from(token.0) } -/// Key to uniquely identify every solution. -#[derive(PartialEq, Eq, std::hash::Hash)] +#[derive(Clone, Copy, Hash, Eq, PartialEq)] struct SolutionKey { - driver: eth::Address, + solver: 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; +impl From<&Solution> for SolutionKey { + fn from(solution: &Solution) -> Self { + Self { + solver: solution.solver(), + solution_id: solution.id(), + } + } +} -/// Mapping from solution to `DirectionalScores` for all solutions -/// of the auction. -type ScoresBySolution = HashMap; +impl From<&ws::Solution> for SolutionKey { + fn from(solution: &ws::Solution) -> Self { + Self { + solver: solution.solver, + solution_id: solution.id, + } + } +} pub struct Ranking { /// Solutions that were discarded because they were malformed @@ -449,11 +329,6 @@ impl Ranking { } } -struct PartitionedSolutions { - kept: Vec>, - discarded: Vec>, -} - #[cfg(test)] mod tests { use { diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index 58483ab0f5..90de368987 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -10,7 +10,6 @@ use { primitives::{ DirectedTokenPair, FeePolicy, - Price, Quote, Score, Side, @@ -24,7 +23,10 @@ use { anyhow::{Context, Result}, itertools::{Either, Itertools}, num::Saturating, - std::collections::{HashMap, HashSet}, + std::{ + cmp::Reverse, + collections::{HashMap, HashSet}, + }, }; /// Auction arbitrator responsible for selecting winning solutions. @@ -42,7 +44,14 @@ impl Arbitrator { pub fn arbitrate(&self, solutions: Vec, context: &AuctionContext) -> Ranking { let partitioned = self.partition_unfair_solutions(solutions, context); let filtered_out = partitioned.discarded; - let ranked = self.mark_winners(partitioned.kept); + let mut ranked = self.mark_winners(partitioned.kept); + + ranked.sort_by_key(|ranked_solution| { + ( + Reverse(ranked_solution.is_winner), + Reverse(ranked_solution.solution.score.unwrap_or_default()), + ) + }); Ranking { filtered_out, @@ -61,14 +70,17 @@ impl Arbitrator { let scores_by_solution = self.compute_scores_by_solution(&mut solutions, context); // Sort by score descending - solutions.sort_by_key(|solution| std::cmp::Reverse(solution.score.unwrap_or_default())); + solutions.sort_by_key(|solution| Reverse(solution.score.unwrap_or_default())); 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(&solution.id) + .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 @@ -113,7 +125,7 @@ impl Arbitrator { &self, solutions: &mut Vec, context: &AuctionContext, - ) -> HashMap> { + ) -> ScoresBySolution { let mut scores = HashMap::default(); solutions.retain_mut( @@ -122,7 +134,13 @@ impl Arbitrator { let total_score = score .values() .fold(Score::default(), |acc, s| acc.saturating_add(*s)); - scores.insert(solution.id, score); + scores.insert( + SolutionKey { + solver: solution.solver, + solution_id: solution.id, + }, + score, + ); solution.score = Some(total_score); true } @@ -163,8 +181,8 @@ impl Arbitrator { let score = self.compute_order_score(order, solution, context)?; let token_pair = DirectedTokenPair { - sell: order.sell_token.as_erc20(self.weth), - buy: order.buy_token.as_erc20(self.weth), + sell: order.sell_token, + buy: order.buy_token, }; scores @@ -193,30 +211,22 @@ impl Arbitrator { .get(&order.buy_token) .context("missing native price for buy token")?; - let uniform_sell_price = solution + let _uniform_sell_price = solution .prices .get(&order.sell_token) .context("missing uniform clearing price for sell token")?; - let uniform_buy_price = solution + 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.calculate_surplus(order, uniform_sell_price, uniform_buy_price)?; - - // Add protocol fees from fee policies - let fees = self.protocol_fees( - order, - context, - ClearingPrices { - sell: uniform_sell_price.0.0, - buy: uniform_buy_price.0.0, - }, - )?; + 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) @@ -250,53 +260,7 @@ impl Arbitrator { } }; - Score::new(score_eth).context("zero score") - } - - /// Calculate user surplus over limit price. - /// - /// Returns surplus in the "surplus token" (buy token for sell orders, sell - /// token for buy orders). - fn calculate_surplus( - &self, - order: &Order, - uniform_sell_price: &Price, - uniform_buy_price: &Price, - ) -> Result { - match order.side { - Side::Sell => { - // For sell orders, surplus = bought - limit_buy - // bought = executed_sell * uniform_sell / uniform_buy - let bought = order.executed_sell.0.saturating_mul(uniform_sell_price.0.0) - / uniform_buy_price.0.0; - - // Scale limit buy for partially fillable orders - let limit_buy = order - .executed_sell - .0 - .saturating_mul(order.buy_amount.0) - .saturating_add(order.sell_amount.0 - U256::from(1u64)) // Ceiling division - / order.sell_amount.0; - - bought - .checked_sub(limit_buy) - .context("negative surplus (unfair trade)") - } - Side::Buy => { - // For buy orders, surplus = limit_sell - sold - // sold = executed_buy * uniform_buy / uniform_sell - let sold = order.executed_buy.0.saturating_mul(uniform_buy_price.0.0) - / uniform_sell_price.0.0; - - // Scale limit sell for partially fillable orders - let limit_sell = - order.executed_buy.0.saturating_mul(order.sell_amount.0) / order.buy_amount.0; - - limit_sell - .checked_sub(sold) - .context("negative surplus (unfair trade)") - } - } + Ok(Score(score_eth)) } /// Calculate total protocol fees for an order. @@ -306,7 +270,7 @@ impl Arbitrator { &self, order: &Order, context: &AuctionContext, - uniform_prices: ClearingPrices, + base_prices: &ClearingPrices, ) -> Result { let policies = context .fee_policies @@ -315,12 +279,11 @@ impl Arbitrator { .unwrap_or_default(); let mut total_fee = U256::ZERO; - let mut custom_prices = - self.calculate_custom_prices_from_executed(order, uniform_prices)?; + 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, &custom_prices, &uniform_prices)?; + let fee = self.protocol_fee(order, policy, ¤t_prices)?; total_fee = total_fee .checked_add(fee) @@ -328,7 +291,7 @@ impl Arbitrator { // Update custom prices for next iteration (except last iteration) if i != 0 { - custom_prices = self.calculate_custom_prices(order, total_fee, uniform_prices)?; + current_prices = self.calculate_custom_prices(order, total_fee, base_prices)?; } } @@ -341,10 +304,7 @@ impl Arbitrator { order: &Order, policy: &FeePolicy, custom_prices: &ClearingPrices, - _uniform_prices: &ClearingPrices, - ) -> Result { - use crate::primitives::FeePolicy; - + ) -> MathResult { match policy { FeePolicy::Surplus { factor, @@ -352,7 +312,7 @@ impl Arbitrator { } => { 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, *max_volume_factor)?; + let volume_fee = self.volume_fee(order, custom_prices, *max_volume_factor)?; Ok(surplus_fee.min(volume_fee)) } FeePolicy::PriceImprovement { @@ -363,15 +323,15 @@ impl Arbitrator { 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, *max_volume_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, *factor), + 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) -> Result { + fn surplus_over_limit_price(&self, order: &Order, prices: &ClearingPrices) -> MathResult { self.surplus_over( order, prices, @@ -388,37 +348,45 @@ impl Arbitrator { order: &Order, prices: &ClearingPrices, limits: PriceLimits, - ) -> Result { + ) -> MathResult { + let executed = match order.side { + Side::Buy => order.executed_buy.0, + Side::Sell => order.executed_sell.0, + }; + match order.side { Side::Buy => { // Scale limit sell to support partially fillable orders - let limit_sell = limits.sell.saturating_mul(order.executed_buy.0) / limits.buy; - - let sold = order.executed_buy.0.saturating_mul(prices.buy) / prices.sell; - - limit_sell - .checked_sub(sold) - .context("negative surplus (unfair trade)") + 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 = order - .executed_sell - .0 - .saturating_mul(limits.buy) - .saturating_add(limits.sell - U256::from(1u64)) // Ceiling division - / limits.sell; - - let bought = order - .executed_sell - .0 - .saturating_mul(prices.sell) - .saturating_add(prices.buy - U256::from(1u64)) // Ceiling division - / prices.buy; - - bought - .checked_sub(limit_buy) - .context("negative surplus (unfair trade)") + 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) } } } @@ -431,27 +399,42 @@ impl Arbitrator { order: &Order, prices: &ClearingPrices, quote: &Quote, - ) -> Result { + ) -> 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(_) => Ok(U256::ZERO), // No improvement is not an error + 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) -> Result { + 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.saturating_mul(quote.buy_amount) / quote.sell_amount) - .context("quote fee exceeds 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.saturating_mul(order.sell_amount.0) / quote.sell_amount; + let scaled_buy_amount = quote_buy_amount + .checked_mul(order.sell_amount.0) + .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.0.max(scaled_buy_amount); @@ -466,11 +449,14 @@ impl Arbitrator { let quote_sell_amount = quote .sell_amount .checked_add(quote.fee) - .context("overflow adding quote fee")?; + .ok_or(MathError::Overflow)?; // Scale to order's buy amount - let scaled_sell_amount = - quote_sell_amount.saturating_mul(order.buy_amount.0) / quote.buy_amount; + let scaled_sell_amount = quote_sell_amount + .checked_mul(order.buy_amount.0) + .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.0.min(scaled_sell_amount); @@ -486,7 +472,7 @@ impl Arbitrator { /// Calculate surplus fee as a cut of surplus. /// /// Uses adjusted factor: fee = surplus * factor / (1 - factor) - fn surplus_fee(&self, surplus: U256, factor: f64) -> Result { + 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. @@ -496,11 +482,11 @@ impl Arbitrator { // fee = surplus_after_fee * factor / (1 - factor) surplus .checked_mul_f64(factor / (1.0 - factor)) - .context("overflow calculating surplus fee") + .ok_or(MathError::Overflow) } /// Calculate volume fee as a cut of trade volume. - fn volume_fee(&self, order: &Order, factor: f64) -> Result { + 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. // @@ -508,8 +494,8 @@ impl Arbitrator { // Buy: fee = traded_sell_amount * factor / (1 + factor) let executed_in_surplus_token = match order.side { - Side::Sell => order.executed_buy.0, - Side::Buy => order.executed_sell.0, + Side::Sell => self.buy_amount(order, prices)?, + Side::Buy => self.sell_amount(order, prices)?, }; let adjusted_factor = match order.side { @@ -519,18 +505,17 @@ impl Arbitrator { executed_in_surplus_token .checked_mul_f64(adjusted_factor) - .context("overflow calculating volume fee") + .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, - uniform_prices: ClearingPrices, - ) -> Result { - self.calculate_custom_prices(order, U256::ZERO, uniform_prices) + fn calculate_custom_prices_from_executed(&self, order: &Order) -> ClearingPrices { + ClearingPrices { + sell: order.executed_buy.0, + buy: order.executed_sell.0, + } } /// Calculate custom clearing prices excluding protocol fees. @@ -540,48 +525,53 @@ impl Arbitrator { &self, order: &Order, protocol_fee: U256, - uniform_prices: ClearingPrices, - ) -> Result { - let sell_amount = self.sell_amount(order, &uniform_prices)?; - let buy_amount = self.buy_amount(order, &uniform_prices)?; + 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) - .context("overflow adding protocol fee to buy amount")?, + .ok_or(MathError::Overflow)?, Side::Buy => buy_amount, }, buy: match order.side { Side::Sell => sell_amount, Side::Buy => sell_amount .checked_sub(protocol_fee) - .context("protocol fee exceeds sell amount")?, + .ok_or(MathError::Negative)?, }, }) } /// Calculate effective sell amount (what left user's wallet). - fn sell_amount(&self, order: &Order, prices: &ClearingPrices) -> Result { - Ok(match order.side { - Side::Sell => order.executed_sell.0, - Side::Buy => order.executed_buy.0.saturating_mul(prices.buy) / prices.sell, - }) + fn sell_amount(&self, order: &Order, prices: &ClearingPrices) -> MathResult { + match order.side { + Side::Sell => Ok(order.executed_sell.0), + Side::Buy => order + .executed_buy + .0 + .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) -> Result { - Ok(match order.side { - Side::Sell => { - order + fn buy_amount(&self, order: &Order, prices: &ClearingPrices) -> MathResult { + match order.side { + Side::Sell => order .executed_sell .0 - .saturating_mul(prices.sell) - .saturating_add(prices.buy - U256::from(1u64)) // Ceiling division - / prices.buy - } - Side::Buy => order.executed_buy.0, - }) + .checked_mul(prices.sell) + .ok_or(MathError::Overflow)? + .checked_ceil_div(&prices.buy) + .ok_or(MathError::DivisionByZero), + Side::Buy => Ok(order.executed_buy.0), + } } /// Pick winners based on directional token pairs. @@ -641,7 +631,7 @@ impl Arbitrator { let score = solutions_without_solver .enumerate() .filter(|(index, _)| winner_indices.contains(index)) - .map(|(_, _solution)| Score::default()) // TODO: Get actual scores + .filter_map(|(_, solution)| solution.score) .reduce(Score::saturating_add) .unwrap_or_default(); @@ -653,9 +643,7 @@ impl Arbitrator { } /// Compute baseline scores (best single-pair solutions). -fn compute_baseline_scores( - scores_by_solution: &HashMap>, -) -> HashMap { +fn compute_baseline_scores(scores_by_solution: &ScoresBySolution) -> ScoreByDirection { let mut baseline_scores = HashMap::default(); for scores in scores_by_solution.values() { @@ -734,3 +722,29 @@ struct PriceLimits { /// 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. +type ScoreByDirection = HashMap; + +/// Mapping from solution to `DirectionalScores` for all solutions. +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, +} From 39b5ca926f354a53281e9c8c24d859eeaebb8f24 Mon Sep 17 00:00:00 2001 From: ilya Date: Thu, 25 Dec 2025 18:32:12 +0000 Subject: [PATCH 06/11] Simplified --- Cargo.lock | 5 - .../domain/competition/winner_selection.rs | 93 ++---- crates/autopilot/src/run_loop.rs | 2 +- crates/autopilot/src/shadow.rs | 6 +- crates/winner-selection/Cargo.toml | 7 +- crates/winner-selection/src/arbitrator.rs | 77 +++-- crates/winner-selection/src/auction.rs | 6 +- crates/winner-selection/src/lib.rs | 12 +- crates/winner-selection/src/primitives.rs | 272 ++++-------------- crates/winner-selection/src/solution.rs | 25 +- 10 files changed, 134 insertions(+), 371 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4a93febc4..35997cac20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8119,12 +8119,7 @@ version = "0.1.0" dependencies = [ "alloy", "anyhow", - "derive_more 1.0.0", - "hex", "itertools 0.14.0", - "num", - "serde", - "serde_json", "thiserror 1.0.61", "tracing", ] diff --git a/crates/autopilot/src/domain/competition/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index 34ab888412..a3af8874a9 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -36,10 +36,7 @@ use { winner_selection as ws, }; -pub struct Arbitrator { - pub max_winners: usize, - pub weth: WrappedNativeToken, -} +pub struct Arbitrator(ws::Arbitrator); /// Implements auction arbitration in 3 phases: /// 1. filter unfair solutions @@ -49,6 +46,14 @@ pub struct Arbitrator { /// 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, @@ -66,7 +71,7 @@ impl Arbitrator { solutions.push(solution); } - let ws_ranking = self.ws_arbitrator().arbitrate(solutions, &context); + 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 { @@ -77,7 +82,7 @@ impl Arbitrator { let score = ws_solution .score .expect("winner selection should compute scores"); - participant.set_score(ws_score_to_domain(score)); + participant.set_score(Score(eth::Ether(score))); filtered_out.push(participant.rank(Ranked::FilteredOut)); } @@ -91,7 +96,7 @@ impl Arbitrator { .solution .score .expect("winner selection should compute scores"); - participant.set_score(ws_score_to_domain(score)); + participant.set_score(Score(eth::Ether(score))); let rank = if ranked_solution.is_winner { Ranked::Winner } else { @@ -110,19 +115,12 @@ impl Arbitrator { /// rewards for the winning solvers. pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap { let ws_ranking = to_ws_ranking(ranking); - self.ws_arbitrator() + self.0 .compute_reference_scores(&ws_ranking) .into_iter() - .map(|(solver, score)| (solver, ws_score_to_domain(score))) + .map(|(solver, score)| (solver, Score(eth::Ether(score)))) .collect() } - - fn ws_arbitrator(&self) -> ws::Arbitrator { - ws::Arbitrator { - max_winners: self.max_winners, - weth: ws_weth(self.weth), - } - } } fn to_ws_context(auction: &domain::Auction) -> ws::AuctionContext { @@ -149,7 +147,7 @@ fn to_ws_context(auction: &domain::Auction) -> ws::AuctionContext { native_prices: auction .prices .iter() - .map(|(token, price)| (to_ws_token(*token), to_ws_price(*price))) + .map(|(token, price)| (token.0, price.get().0)) .collect(), } } @@ -166,52 +164,28 @@ fn to_ws_solution(solution: &Solution, score: Option) -> ws::Solution { prices: solution .prices() .iter() - .map(|(token, price)| (to_ws_token(*token), to_ws_price(*price))) + .map(|(token, price)| (token.0, price.get().0)) .collect(), - score: score.map(domain_score_to_ws), + score: score.map(|score| score.get().0), } } fn to_ws_order(uid: domain::OrderUid, order: &TradedOrder) -> ws::Order { ws::Order { uid: ws::OrderUid(uid.0), - sell_token: to_ws_token(order.sell.token), - buy_token: to_ws_token(order.buy.token), - sell_amount: to_ws_amount(order.sell.amount), - buy_amount: to_ws_amount(order.buy.amount), - executed_sell: to_ws_amount(order.executed_sell), - executed_buy: to_ws_amount(order.executed_buy), - side: to_ws_side(order.side), - } -} - -fn to_ws_side(side: order::Side) -> ws::Side { - match side { - order::Side::Buy => ws::Side::Buy, - order::Side::Sell => ws::Side::Sell, + 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, + }, } } -fn to_ws_token(token: eth::TokenAddress) -> ws::TokenAddress { - ws::TokenAddress(token.0) -} - -fn to_ws_amount(amount: eth::TokenAmount) -> ws::TokenAmount { - ws::TokenAmount(amount.0) -} - -fn to_ws_price(price: domain::auction::Price) -> ws::Price { - ws::Price(ws::Ether(price.get().0)) -} - -fn ws_score_to_domain(score: ws::Score) -> Score { - Score(eth::Ether(score.0.0)) -} - -fn domain_score_to_ws(score: Score) -> ws::Score { - ws::Score(ws::Ether(score.get().0)) -} - fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { let ranked = ranking .ranked @@ -263,11 +237,6 @@ fn to_ws_fee_policy(policy: fee::Policy) -> ws::primitives::FeePolicy { } } -fn ws_weth(weth: WrappedNativeToken) -> ws::WrappedNativeToken { - let token: eth::TokenAddress = weth.into(); - ws::WrappedNativeToken::from(token.0) -} - #[derive(Clone, Copy, Hash, Eq, PartialEq)] struct SolutionKey { solver: eth::Address, @@ -1146,10 +1115,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/run_loop.rs b/crates/autopilot/src/run_loop.rs index 104cd3d30b..79e5ab9d12 100644 --- a/crates/autopilot/src/run_loop.rs +++ b/crates/autopilot/src/run_loop.rs @@ -129,7 +129,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, } } diff --git a/crates/autopilot/src/shadow.rs b/crates/autopilot/src/shadow.rs index 74985b3c64..5e721ef344 100644 --- a/crates/autopilot/src/shadow.rs +++ b/crates/autopilot/src/shadow.rs @@ -56,10 +56,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 index 13dfac13cb..22ab379444 100644 --- a/crates/winner-selection/Cargo.toml +++ b/crates/winner-selection/Cargo.toml @@ -4,13 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -alloy = { workspace = true, features = ["serde"] } -serde = { workspace = true } -serde_json = { workspace = true } -num = { workspace = true } +alloy = { workspace = true } itertools = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } -derive_more = { workspace = true } tracing = { workspace = true } -hex = "0.4" diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index 90de368987..06fd020a9d 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -7,22 +7,13 @@ use { crate::{ auction::AuctionContext, - primitives::{ - DirectedTokenPair, - FeePolicy, - Quote, - Score, - Side, - TokenAmount, - WrappedNativeToken, - }, + primitives::{DirectedTokenPair, FeePolicy, Quote, Side, as_erc20, price_in_eth}, solution::{Order, Solution}, util::U256Ext, }, alloy::primitives::{Address, U256}, anyhow::{Context, Result}, itertools::{Either, Itertools}, - num::Saturating, std::{ cmp::Reverse, collections::{HashMap, HashSet}, @@ -34,7 +25,7 @@ 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: WrappedNativeToken, + pub weth: Address, } impl Arbitrator { @@ -133,7 +124,7 @@ impl Arbitrator { Ok(score) => { let total_score = score .values() - .fold(Score::default(), |acc, s| acc.saturating_add(*s)); + .fold(U256::ZERO, |acc, s| acc.saturating_add(*s)); scores.insert( SolutionKey { solver: solution.solver, @@ -170,8 +161,8 @@ impl Arbitrator { &self, solution: &Solution, context: &AuctionContext, - ) -> Result> { - let mut scores: HashMap = HashMap::default(); + ) -> Result> { + let mut scores: HashMap = HashMap::default(); for order in &solution.orders { if !context.contributes_to_score(&order.uid) { @@ -185,10 +176,8 @@ impl Arbitrator { buy: order.buy_token, }; - scores - .entry(token_pair) - .or_default() - .saturating_add_assign(score); + let entry = scores.entry(token_pair).or_default(); + *entry = entry.saturating_add(score); } Ok(scores) @@ -205,7 +194,7 @@ impl Arbitrator { order: &Order, solution: &Solution, context: &AuctionContext, - ) -> Result { + ) -> Result { let native_price_buy = context .native_prices .get(&order.buy_token) @@ -235,7 +224,7 @@ impl Arbitrator { let score_eth = match order.side { // `surplus` of sell orders is already in buy tokens so we simply convert it to ETH - Side::Sell => native_price_buy.in_eth(TokenAmount(surplus_in_surplus_token)), + 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 @@ -249,18 +238,18 @@ impl Arbitrator { use alloy::primitives::{U512, ruint::UintTryFrom}; let surplus_in_buy_tokens = surplus_in_surplus_token - .widening_mul(order.buy_amount.0) - .checked_div(U512::from(order.sell_amount.0)) + .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. - native_price_buy.in_eth(TokenAmount(surplus_in_buy_tokens)) + price_in_eth(*native_price_buy, surplus_in_buy_tokens) } }; - Ok(Score(score_eth)) + Ok(score_eth) } /// Calculate total protocol fees for an order. @@ -336,8 +325,8 @@ impl Arbitrator { order, prices, PriceLimits { - sell: order.sell_amount.0, - buy: order.buy_amount.0, + sell: order.sell_amount, + buy: order.buy_amount, }, ) } @@ -350,8 +339,8 @@ impl Arbitrator { limits: PriceLimits, ) -> MathResult { let executed = match order.side { - Side::Buy => order.executed_buy.0, - Side::Sell => order.executed_sell.0, + Side::Buy => order.executed_buy, + Side::Sell => order.executed_sell, }; match order.side { @@ -431,16 +420,16 @@ impl Arbitrator { // Scale to order's sell amount let scaled_buy_amount = quote_buy_amount - .checked_mul(order.sell_amount.0) + .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.0.max(scaled_buy_amount); + let buy_amount = order.buy_amount.max(scaled_buy_amount); Ok(PriceLimits { - sell: order.sell_amount.0, + sell: order.sell_amount, buy: buy_amount, }) } @@ -453,17 +442,17 @@ impl Arbitrator { // Scale to order's buy amount let scaled_sell_amount = quote_sell_amount - .checked_mul(order.buy_amount.0) + .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.0.min(scaled_sell_amount); + let sell_amount = order.sell_amount.min(scaled_sell_amount); Ok(PriceLimits { sell: sell_amount, - buy: order.buy_amount.0, + buy: order.buy_amount, }) } } @@ -513,8 +502,8 @@ impl Arbitrator { /// Custom prices are derived from what was actually executed. fn calculate_custom_prices_from_executed(&self, order: &Order) -> ClearingPrices { ClearingPrices { - sell: order.executed_buy.0, - buy: order.executed_sell.0, + sell: order.executed_buy, + buy: order.executed_sell, } } @@ -549,10 +538,9 @@ impl Arbitrator { /// 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.0), + Side::Sell => Ok(order.executed_sell), Side::Buy => order .executed_buy - .0 .checked_mul(prices.buy) .ok_or(MathError::Overflow)? .checked_div(prices.sell) @@ -565,12 +553,11 @@ impl Arbitrator { match order.side { Side::Sell => order .executed_sell - .0 .checked_mul(prices.sell) .ok_or(MathError::Overflow)? .checked_ceil_div(&prices.buy) .ok_or(MathError::DivisionByZero), - Side::Buy => Ok(order.executed_buy.0), + Side::Buy => Ok(order.executed_buy), } } @@ -588,8 +575,8 @@ impl Arbitrator { .orders .iter() .map(|order| DirectedTokenPair { - sell: order.sell_token.as_erc20(self.weth), - buy: order.buy_token.as_erc20(self.weth), + sell: as_erc20(order.sell_token, self.weth), + buy: as_erc20(order.buy_token, self.weth), }) .collect(); @@ -603,7 +590,7 @@ impl Arbitrator { } /// Compute reference scores for winning solvers. - pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap { + pub fn compute_reference_scores(&self, ranking: &Ranking) -> HashMap { let mut reference_scores = HashMap::default(); for ranked_solution in &ranking.ranked { @@ -632,7 +619,7 @@ impl Arbitrator { .enumerate() .filter(|(index, _)| winner_indices.contains(index)) .filter_map(|(_, solution)| solution.score) - .reduce(Score::saturating_add) + .reduce(|acc, score| acc.saturating_add(score)) .unwrap_or_default(); reference_scores.insert(solver, score); @@ -732,7 +719,7 @@ struct SolutionKey { /// Scores of all trades in a solution aggregated by the directional /// token pair. -type ScoreByDirection = HashMap; +type ScoreByDirection = HashMap; /// Mapping from solution to `DirectionalScores` for all solutions. type ScoresBySolution = HashMap; diff --git a/crates/winner-selection/src/auction.rs b/crates/winner-selection/src/auction.rs index a59f13f21b..607e015f6a 100644 --- a/crates/winner-selection/src/auction.rs +++ b/crates/winner-selection/src/auction.rs @@ -5,8 +5,8 @@ //! and is the same for all solutions. use { - crate::primitives::{FeePolicy, OrderUid, Price, TokenAddress}, - alloy::primitives::Address, + crate::primitives::{FeePolicy, OrderUid}, + alloy::primitives::{Address, U256}, std::collections::{HashMap, HashSet}, }; @@ -33,7 +33,7 @@ pub struct AuctionContext { /// 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, + pub native_prices: HashMap, } impl AuctionContext { diff --git a/crates/winner-selection/src/lib.rs b/crates/winner-selection/src/lib.rs index b7c970e9a8..110ffcea79 100644 --- a/crates/winner-selection/src/lib.rs +++ b/crates/winner-selection/src/lib.rs @@ -15,16 +15,6 @@ pub mod util; pub use { arbitrator::{Arbitrator, Ranking}, auction::AuctionContext, - primitives::{ - DirectedTokenPair, - Ether, - OrderUid, - Price, - Score, - Side, - TokenAddress, - TokenAmount, - WrappedNativeToken, - }, + primitives::{Address, DirectedTokenPair, OrderUid, Side, U256}, solution::{Order, Solution}, }; diff --git a/crates/winner-selection/src/primitives.rs b/crates/winner-selection/src/primitives.rs index 46b778b2a3..ee7963c270 100644 --- a/crates/winner-selection/src/primitives.rs +++ b/crates/winner-selection/src/primitives.rs @@ -1,206 +1,35 @@ //! Primitive types for winner selection. -// Re-export alloy primitives -pub use alloy::primitives::{Address as EthAddress, U256 as EthU256}; -use { - alloy::primitives::{Address, U256}, - derive_more::{Display, From, Into}, -}; +pub use alloy::primitives::{Address, U256}; /// Native token constant (ETH on mainnet, XDAI on Gnosis) -pub const NATIVE_TOKEN: TokenAddress = TokenAddress(Address::repeat_byte(0xee)); +pub const NATIVE_TOKEN: Address = Address::repeat_byte(0xee); -/// An ERC20 token address. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - From, - Into, - serde::Serialize, - serde::Deserialize, -)] -#[serde(transparent)] -pub struct TokenAddress(pub Address); - -impl TokenAddress { - /// If the token is ETH/XDAI, return WETH/WXDAI, converting it to ERC20. - pub fn as_erc20(self, wrapped: WrappedNativeToken) -> Self { - if self == NATIVE_TOKEN { - wrapped.into() - } else { - self - } - } -} - -/// ERC20 representation of the chain's native token (WETH on mainnet, WXDAI on -/// Gnosis). -#[derive(Debug, Clone, Copy, From, Into, serde::Serialize, serde::Deserialize)] -#[serde(transparent)] -pub struct WrappedNativeToken(pub TokenAddress); - -impl From
for WrappedNativeToken { - fn from(value: Address) -> Self { - WrappedNativeToken(value.into()) - } -} - -/// An ERC20 token amount. -#[derive( - Debug, - Default, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - From, - Into, - serde::Serialize, - serde::Deserialize, -)] -#[serde(transparent)] -pub struct TokenAmount(pub U256); - -/// An amount denominated in the native token (ETH/XDAI). -#[derive( - Debug, - Default, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - From, - Into, - Display, - derive_more::Add, - derive_more::Sub, - serde::Serialize, - serde::Deserialize, -)] -#[serde(transparent)] -pub struct Ether(pub U256); - -impl Ether { - pub const ZERO: Self = Self(U256::ZERO); - - pub fn saturating_add(self, other: Self) -> Self { - Self(self.0.saturating_add(other.0)) - } - - pub fn saturating_sub(self, other: Self) -> Self { - Self(self.0.saturating_sub(other.0)) - } - - pub fn checked_add(self, other: Self) -> Option { - self.0.checked_add(other.0).map(Self) - } - - pub fn checked_sub(self, other: Self) -> Option { - self.0.checked_sub(other.0).map(Self) - } -} - -/// A price for converting token amounts to native token (ETH/XDAI). -#[derive( - Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, From, serde::Serialize, serde::Deserialize, -)] -#[serde(transparent)] -pub struct Price(pub Ether); - -impl Price { - /// Convert a token amount to ETH using this price. - /// - /// Formula: `amount * price / 10^18` - pub fn in_eth(&self, amount: TokenAmount) -> Ether { - // Compute (amount * price) / 10^18 - // Use saturating operations to avoid overflow - let product = amount.0.saturating_mul(self.0.0); - let eth_amount = product / U256::from(1_000_000_000_000_000_000u64); // 10^18 - Ether(eth_amount) +/// 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 } } -/// A solution score in native token. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - PartialOrd, - Ord, - Default, - Display, - derive_more::Add, - derive_more::Sub, - serde::Serialize, - serde::Deserialize, -)] -#[serde(transparent)] -pub struct Score(pub Ether); - -impl Score { - /// Create a new score, returning an error if it's zero. - pub fn new(ether: Ether) -> Result { - if ether.0.is_zero() { - Err(ZeroScore) - } else { - Ok(Self(ether)) - } - } - - /// Get the inner Ether value. - pub fn get(&self) -> &Ether { - &self.0 - } - - pub fn saturating_add_assign(&mut self, other: Self) { - self.0 = self.0.saturating_add(other.0); - } -} - -impl num::Saturating for Score { - fn saturating_add(self, v: Self) -> Self { - Self(self.0.saturating_add(v.0)) - } - - fn saturating_sub(self, v: Self) -> Self { - Self(self.0.saturating_sub(v.0)) - } -} - -impl num::CheckedSub for Score { - fn checked_sub(&self, v: &Self) -> Option { - self.0.checked_sub(v.0).map(Score) - } +/// 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) } -/// Error returned when a score is zero. -#[derive(Debug, thiserror::Error)] -#[error("the solver proposed a 0-score solution")] -pub struct ZeroScore; - /// 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, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Hash, Eq, PartialEq)] pub struct DirectedTokenPair { - pub sell: TokenAddress, - pub buy: TokenAddress, + pub sell: Address, + pub buy: Address, } /// A unique identifier for an order. @@ -209,7 +38,7 @@ pub struct DirectedTokenPair { /// - 32 bytes: order digest (hash of order parameters) /// - 20 bytes: owner address /// - 4 bytes: valid until timestamp -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct OrderUid(pub [u8; 56]); impl OrderUid { @@ -222,47 +51,46 @@ impl OrderUid { } } -impl serde::Serialize for OrderUid { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - // Serialize as hex string with 0x prefix - let hex_string = format!("0x{}", hex::encode(self.0)); - serializer.serialize_str(&hex_string) - } -} - -impl<'de> serde::Deserialize<'de> for OrderUid { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let s = s.strip_prefix("0x").unwrap_or(&s); - let decoded = hex::decode(s).map_err(serde::de::Error::custom)?; - if decoded.len() != 56 { - return Err(serde::de::Error::custom(format!( - "expected 56 bytes, got {}", - decoded.len() - ))); - } - let mut bytes = [0u8; 56]; - bytes.copy_from_slice(&decoded); - Ok(OrderUid(bytes)) - } -} +// impl serde::Serialize for OrderUid { +// fn serialize(&self, serializer: S) -> Result +// where +// S: serde::Serializer, +// { +// // Serialize as hex string with 0x prefix +// let hex_string = format!("0x{}", hex::encode(self.0)); +// serializer.serialize_str(&hex_string) +// } +// } +// +// impl<'de> serde::Deserialize<'de> for OrderUid { +// fn deserialize(deserializer: D) -> Result +// where +// D: serde::Deserializer<'de>, +// { +// let s = String::deserialize(deserializer)?; +// let s = s.strip_prefix("0x").unwrap_or(&s); +// let decoded = hex::decode(s).map_err(serde::de::Error::custom)?; +// if decoded.len() != 56 { +// return Err(serde::de::Error::custom(format!( +// "expected 56 bytes, got {}", +// decoded.len() +// ))); +// } +// let mut bytes = [0u8; 56]; +// bytes.copy_from_slice(&decoded); +// Ok(OrderUid(bytes)) +// } +// } /// Order side (buy or sell). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "lowercase")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Side { Buy, Sell, } /// Protocol fee policy. -#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum FeePolicy { /// Fee as a percentage of surplus over limit price. Surplus { factor: f64, max_volume_factor: f64 }, @@ -277,7 +105,7 @@ pub enum FeePolicy { } /// Quote data for price improvement fee calculation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Quote { pub sell_amount: U256, pub buy_amount: U256, diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs index 9962a98ee3..944aae2062 100644 --- a/crates/winner-selection/src/solution.rs +++ b/crates/winner-selection/src/solution.rs @@ -4,8 +4,8 @@ //! making them small enough to efficiently send to/from the Pod Service. use { - crate::primitives::{OrderUid, Price, Side, TokenAddress, TokenAmount}, - alloy::primitives::Address, + crate::primitives::{OrderUid, Side}, + alloy::primitives::{Address, U256}, std::collections::HashMap, }; @@ -16,7 +16,7 @@ use { /// 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, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone)] pub struct Solution { /// Solution ID from solver (unique per solver). pub id: u64, @@ -34,14 +34,13 @@ pub struct 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, + pub prices: HashMap, /// Total score for this solution. /// /// This is computed during winner selection and is not part of the /// minimal data sent to the Pod Service. - #[serde(skip)] - pub score: Option, + pub score: Option, } /// Minimal order data needed for winner selection. @@ -51,36 +50,36 @@ pub struct Solution { /// (what actually happened in this solution). /// /// Estimated size: ~225 bytes per order. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone)] pub struct Order { /// Unique order identifier (56 bytes). pub uid: OrderUid, /// Sell token address. - pub sell_token: TokenAddress, + pub sell_token: Address, /// Buy token address. - pub buy_token: TokenAddress, + 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: TokenAmount, + 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: TokenAmount, + 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: TokenAmount, + 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: TokenAmount, + pub executed_buy: U256, /// Order side (Buy or Sell). /// From bccdc95bd1050735a3ed43cbe3b39f53d8d76a03 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 26 Dec 2025 15:08:09 +0000 Subject: [PATCH 07/11] Adopt Unscored --- .../autopilot/src/domain/competition/mod.rs | 13 +---- .../src/domain/competition/participant.rs | 56 ++++++++++++++----- .../domain/competition/winner_selection.rs | 38 +++++++------ crates/autopilot/src/infra/persistence/mod.rs | 2 +- crates/autopilot/src/run_loop.rs | 12 ++-- crates/autopilot/src/shadow.rs | 8 +-- 6 files changed, 77 insertions(+), 52 deletions(-) diff --git a/crates/autopilot/src/domain/competition/mod.rs b/crates/autopilot/src/domain/competition/mod.rs index b2ac1f2204..9f007df7e2 100644 --- a/crates/autopilot/src/domain/competition/mod.rs +++ b/crates/autopilot/src/domain/competition/mod.rs @@ -12,7 +12,7 @@ mod participation_guard; pub mod winner_selection; pub use { - participant::{Participant, Ranked, Unranked}, + participant::{Participant, RankType, Ranked, Scored, Unscored}, participation_guard::SolverParticipationGuard, }; @@ -25,10 +25,6 @@ pub struct Solution { solver: Address, orders: HashMap, prices: auction::Prices, - /// Score computed by the autopilot based on the solution - /// of the solver. - // TODO: refactor this to compute the score in the constructor - score: Option, } impl Solution { @@ -43,7 +39,6 @@ impl Solution { solver, orders, prices, - score: None, } } @@ -55,12 +50,6 @@ impl Solution { self.solver } - pub fn score(&self) -> Score { - self.score.expect( - "this function only gets called after the winner selection populated this value", - ) - } - pub fn order_ids(&self) -> impl Iterator + std::fmt::Debug { self.orders.keys() } diff --git a/crates/autopilot/src/domain/competition/participant.rs b/crates/autopilot/src/domain/competition/participant.rs index 766ea9b9c4..3432ef34e4 100644 --- a/crates/autopilot/src/domain/competition/participant.rs +++ b/crates/autopilot/src/domain/competition/participant.rs @@ -12,8 +12,21 @@ pub struct Participant { } #[derive(Clone)] -pub struct Unranked; -pub enum Ranked { +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, @@ -24,39 +37,56 @@ impl Participant { &self.solution } - pub fn set_score(&mut self, score: Score) { - self.solution.score = Some(score); - } - pub fn driver(&self) -> &Arc { &self.driver } } -impl Participant { +impl Participant { pub fn new(solution: Solution, driver: Arc) -> Self { Self { solution, driver, - state: Unranked, + state: Unscored, } } - pub fn rank(self, rank: Ranked) -> Participant { - Participant:: { - state: rank, + pub fn with_score(self, score: Score) -> Participant { + Participant { solution: self.solution, driver: self.driver, + state: Scored { score }, + } + } +} + +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, + }, } } } impl Participant { + pub fn score(&self) -> Score { + self.state.score + } + pub fn is_winner(&self) -> bool { - matches!(self.state, Ranked::Winner) + matches!(self.state.rank_type, RankType::Winner) } pub fn filtered_out(&self) -> bool { - matches!(self.state, Ranked::FilteredOut) + matches!(self.state.rank_type, RankType::FilteredOut) } } diff --git a/crates/autopilot/src/domain/competition/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index a3af8874a9..d473295a33 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -28,7 +28,7 @@ use { crate::domain::{ self, auction::order, - competition::{Participant, Ranked, Score, Solution, TradedOrder, Unranked}, + competition::{Participant, RankType, Ranked, Score, Solution, TradedOrder, Unscored}, eth::{self, WrappedNativeToken}, fee, }, @@ -57,7 +57,7 @@ impl Arbitrator { /// Runs the entire auction mechanism on the passed in solutions. pub fn arbitrate( &self, - participants: Vec>, + participants: Vec>, auction: &domain::Auction, ) -> Ranking { let context = to_ws_context(auction); @@ -76,33 +76,39 @@ impl Arbitrator { 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 mut participant = participant_by_key + let participant = participant_by_key .remove(&key) .expect("every ranked solution has a matching participant"); let score = ws_solution .score .expect("winner selection should compute scores"); - participant.set_score(Score(eth::Ether(score))); - filtered_out.push(participant.rank(Ranked::FilteredOut)); + filtered_out.push( + participant + .with_score(Score(eth::Ether(score))) + .rank(RankType::FilteredOut), + ); } let mut ranked = Vec::with_capacity(ws_ranking.ranked.len()); for ranked_solution in ws_ranking.ranked { let key = SolutionKey::from(&ranked_solution.solution); - let mut participant = participant_by_key + let participant = participant_by_key .remove(&key) .expect("every ranked solution has a matching participant"); let score = ranked_solution .solution .score .expect("winner selection should compute scores"); - participant.set_score(Score(eth::Ether(score))); - let rank = if ranked_solution.is_winner { - Ranked::Winner + let rank_type = if ranked_solution.is_winner { + RankType::Winner } else { - Ranked::NonWinner + RankType::NonWinner }; - ranked.push(participant.rank(rank)); + ranked.push( + participant + .with_score(Score(eth::Ether(score))) + .rank(rank_type), + ); } Ranking { @@ -191,7 +197,7 @@ fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { .ranked .iter() .map(|participant| ws::arbitrator::RankedSolution { - solution: to_ws_solution(participant.solution(), participant.solution().score), + solution: to_ws_solution(participant.solution(), Some(participant.score())), is_winner: participant.is_winner(), }) .collect(); @@ -199,7 +205,7 @@ fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { let filtered_out = ranking .filtered_out .iter() - .map(|participant| to_ws_solution(participant.solution(), participant.solution().score)) + .map(|participant| to_ws_solution(participant.solution(), Some(participant.score()))) .collect(); ws::Ranking { @@ -310,7 +316,7 @@ mod tests { Price, order::{self, AppDataHash}, }, - competition::{Participant, Solution, TradedOrder, Unranked}, + competition::{Participant, Solution, TradedOrder, Unscored}, eth::{self, TokenAddress}, }, infra::Driver, @@ -1058,7 +1064,7 @@ mod tests { // winners before non-winners std::cmp::Reverse(a.is_winner()), // high score before low score - std::cmp::Reverse(a.solution().score()) + std::cmp::Reverse(a.score()) ))); assert_eq!(winners.len(), self.expected_winners.len()); for (actual, expected) in winners.iter().zip(&self.expected_winners) { @@ -1218,7 +1224,7 @@ mod tests { solver_address: Address, trades: Vec<(OrderUid, TradedOrder)>, prices: Option>, - ) -> Participant { + ) -> Participant { // The prices of the tokens do not affect the result but they keys must exist // for every token of every trade let prices = prices.unwrap_or({ diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index b15421d350..b72faf2019 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -228,7 +228,7 @@ impl Persistence { is_winner: participant.is_winner(), filtered_out: participant.filtered_out(), score: number::conversions::alloy::u256_to_big_decimal( - &participant.solution().score().get().0, + &participant.score().get().0, ), orders: participant .solution() diff --git a/crates/autopilot/src/run_loop.rs b/crates/autopilot/src/run_loop.rs index 79e5ab9d12..9a57dc6528 100644 --- a/crates/autopilot/src/run_loop.rs +++ b/crates/autopilot/src/run_loop.rs @@ -10,7 +10,7 @@ use { Solution, SolutionError, SolverParticipationGuard, - Unranked, + Unscored, winner_selection::{self, Ranking}, }, eth::{self, TxId}, @@ -495,7 +495,7 @@ impl RunLoop { .map(|(index, participant)| SolverSettlement { solver: participant.driver().name.clone(), solver_address: participant.solution().solver(), - score: Some(Score::Solver(participant.solution().score().get().0)), + score: Some(Score::Solver(participant.score().get().0)), ranking: index + 1, orders: participant .solution() @@ -610,7 +610,7 @@ impl RunLoop { async fn fetch_solutions( &self, auction: &domain::Auction, - ) -> Vec> { + ) -> Vec> { let request = solve::Request::new( auction, &self.trusted_tokens.all(), @@ -662,7 +662,7 @@ impl RunLoop { &self, driver: Arc, request: solve::Request, - ) -> Vec> { + ) -> Vec> { let start = Instant::now(); let result = self.try_solve(Arc::clone(&driver), request).await; let solutions = match result { @@ -1135,7 +1135,7 @@ pub mod observe { use { crate::domain::{ self, - competition::{Unranked, winner_selection::Ranking}, + competition::{Unscored, winner_selection::Ranking}, }, std::collections::HashSet, }; @@ -1168,7 +1168,7 @@ pub mod observe { ); } - pub fn solutions(solutions: &[domain::competition::Participant]) { + pub fn solutions(solutions: &[domain::competition::Participant]) { if solutions.is_empty() { tracing::info!("no solutions for auction"); } diff --git a/crates/autopilot/src/shadow.rs b/crates/autopilot/src/shadow.rs index 5e721ef344..a0188b501c 100644 --- a/crates/autopilot/src/shadow.rs +++ b/crates/autopilot/src/shadow.rs @@ -11,7 +11,7 @@ use { crate::{ domain::{ self, - competition::{Participant, Score, Unranked, winner_selection}, + competition::{Participant, Score, Unscored, winner_selection}, eth::WrappedNativeToken, }, infra::{ @@ -135,7 +135,7 @@ impl RunLoop { let total_score = ranking .winners() - .map(|p| p.solution().score()) + .map(|p| p.score()) .reduce(Score::saturating_add) .unwrap_or_default(); @@ -177,7 +177,7 @@ impl RunLoop { /// Runs the solver competition, making all configured drivers participate. #[instrument(skip_all)] - async fn competition(&self, auction: &domain::Auction) -> Vec> { + async fn competition(&self, auction: &domain::Auction) -> Vec> { let request = solve::Request::new(auction, &self.trusted_tokens.all(), self.solve_deadline); futures::future::join_all( @@ -198,7 +198,7 @@ impl RunLoop { driver: Arc, request: solve::Request, auction_id: i64, - ) -> Vec> { + ) -> Vec> { let solutions = match self.fetch_solutions(&driver, request).await { Ok(response) => { Metrics::get() From 4921a0f275d110036be88964e966315be4ef2fd1 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 26 Dec 2025 15:44:11 +0000 Subject: [PATCH 08/11] Unscored types --- .../domain/competition/winner_selection.rs | 56 ++++--- crates/winner-selection/src/arbitrator.rs | 148 +++++++++--------- crates/winner-selection/src/lib.rs | 2 +- crates/winner-selection/src/solution.rs | 123 ++++++++++++++- 4 files changed, 222 insertions(+), 107 deletions(-) diff --git a/crates/autopilot/src/domain/competition/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index d473295a33..e68b1a4bae 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -66,7 +66,7 @@ impl Arbitrator { for participant in participants { let key = SolutionKey::from(participant.solution()); - let solution = to_ws_solution(participant.solution(), None); + let solution = to_ws_solution(participant.solution()); participant_by_key.insert(key, participant); solutions.push(solution); } @@ -79,9 +79,7 @@ impl Arbitrator { let participant = participant_by_key .remove(&key) .expect("every ranked solution has a matching participant"); - let score = ws_solution - .score - .expect("winner selection should compute scores"); + let score = ws_solution.score(); filtered_out.push( participant .with_score(Score(eth::Ether(score))) @@ -91,15 +89,12 @@ impl Arbitrator { let mut ranked = Vec::with_capacity(ws_ranking.ranked.len()); for ranked_solution in ws_ranking.ranked { - let key = SolutionKey::from(&ranked_solution.solution); + 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 - .solution - .score - .expect("winner selection should compute scores"); - let rank_type = if ranked_solution.is_winner { + let score = ranked_solution.score(); + let rank_type = if ranked_solution.is_winner() { RankType::Winner } else { RankType::NonWinner @@ -158,22 +153,21 @@ fn to_ws_context(auction: &domain::Auction) -> ws::AuctionContext { } } -fn to_ws_solution(solution: &Solution, score: Option) -> ws::Solution { - ws::Solution { - id: solution.id(), - solver: solution.solver(), - orders: solution +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(), - prices: solution + solution .prices() .iter() .map(|(token, price)| (token.0, price.get().0)) .collect(), - score: score.map(|score| score.get().0), - } + ) } fn to_ws_order(uid: domain::OrderUid, order: &TradedOrder) -> ws::Order { @@ -196,16 +190,26 @@ fn to_ws_ranking(ranking: &Ranking) -> ws::Ranking { let ranked = ranking .ranked .iter() - .map(|participant| ws::arbitrator::RankedSolution { - solution: to_ws_solution(participant.solution(), Some(participant.score())), - is_winner: participant.is_winner(), + .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(), Some(participant.score()))) + .map(|participant| { + to_ws_solution(participant.solution()) + .with_score(participant.score().get().0) + .rank(ws::RankType::FilteredOut) + }) .collect(); ws::Ranking { @@ -258,11 +262,11 @@ impl From<&Solution> for SolutionKey { } } -impl From<&ws::Solution> for SolutionKey { - fn from(solution: &ws::Solution) -> Self { +impl From<&ws::Solution> for SolutionKey { + fn from(solution: &ws::Solution) -> Self { Self { - solver: solution.solver, - solution_id: solution.id, + solver: solution.solver(), + solution_id: solution.id(), } } } diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index 06fd020a9d..80f48aafdd 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -8,7 +8,7 @@ use { crate::{ auction::AuctionContext, primitives::{DirectedTokenPair, FeePolicy, Quote, Side, as_erc20, price_in_eth}, - solution::{Order, Solution}, + solution::{Order, RankType, Ranked, Scored, Solution, Unscored}, util::U256Ext, }, alloy::primitives::{Address, U256}, @@ -32,17 +32,20 @@ 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 { + pub fn arbitrate( + &self, + solutions: Vec>, + context: &AuctionContext, + ) -> Ranking { let partitioned = self.partition_unfair_solutions(solutions, context); - let filtered_out = partitioned.discarded; + 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(|ranked_solution| { - ( - Reverse(ranked_solution.is_winner), - Reverse(ranked_solution.solution.score.unwrap_or_default()), - ) - }); + ranked.sort_by_key(|solution| (Reverse(solution.is_winner()), Reverse(solution.score()))); Ranking { filtered_out, @@ -53,15 +56,16 @@ impl Arbitrator { /// Removes unfair solutions from the set of all solutions. fn partition_unfair_solutions( &self, - mut solutions: Vec, + 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 scores_by_solution = self.compute_scores_by_solution(&mut solutions, context); + 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.unwrap_or_default())); + solutions.sort_by_key(|solution| Reverse(solution.score())); let baseline_scores = compute_baseline_scores(&scores_by_solution); @@ -69,8 +73,8 @@ impl Arbitrator { 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, + solver: solution.solver(), + solution_id: solution.id(), }) .expect("every remaining solution has an entry"); @@ -95,15 +99,19 @@ impl Arbitrator { } /// Picks winners and marks all solutions. - fn mark_winners(&self, solutions: Vec) -> Vec { + fn mark_winners(&self, solutions: Vec>) -> Vec> { let winner_indices = self.pick_winners(solutions.iter()); solutions .into_iter() .enumerate() - .map(|(index, solution)| RankedSolution { - is_winner: winner_indices.contains(&index), - solution, + .map(|(index, solution)| { + let rank_type = if winner_indices.contains(&index) { + RankType::Winner + } else { + RankType::NonWinner + }; + solution.rank(rank_type) }) .collect() } @@ -114,39 +122,38 @@ impl Arbitrator { /// depend on these scores being accurate. fn compute_scores_by_solution( &self, - solutions: &mut Vec, + solutions: Vec>, context: &AuctionContext, - ) -> ScoresBySolution { - let mut scores = HashMap::default(); + ) -> (Vec>, ScoresBySolution) { + let mut scores_by_solution = HashMap::default(); + let mut scored_solutions = Vec::new(); - solutions.retain_mut( - |solution| match self.score_by_token_pair(solution, context) { + 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.insert( + scores_by_solution.insert( SolutionKey { - solver: solution.solver, - solution_id: solution.id, + solver: solution.solver(), + solution_id: solution.id(), }, score, ); - solution.score = Some(total_score); - true + scored_solutions.push(solution.with_score(total_score)); } Err(err) => { tracing::warn!( - solution_id = solution.id, + solution_id = solution.id(), ?err, "discarding solution where scores could not be computed" ); - false } - }, - ); + } + } - scores + (scored_solutions, scores_by_solution) } /// Returns the total scores for each directed token pair of the solution. @@ -157,14 +164,14 @@ impl Arbitrator { /// it will return a map like: /// (A, B) => 15 /// (B, C) => 5 - fn score_by_token_pair( + fn score_by_token_pair( &self, - solution: &Solution, + solution: &Solution, context: &AuctionContext, ) -> Result> { let mut scores: HashMap = HashMap::default(); - for order in &solution.orders { + for order in solution.orders() { if !context.contributes_to_score(&order.uid) { continue; } @@ -189,10 +196,10 @@ impl Arbitrator { /// Follows CIP-38 as the base of the score computation. /// /// Denominated in NATIVE token. - fn compute_order_score( + fn compute_order_score( &self, order: &Order, - solution: &Solution, + solution: &Solution, context: &AuctionContext, ) -> Result { let native_price_buy = context @@ -201,11 +208,11 @@ impl Arbitrator { .context("missing native price for buy token")?; let _uniform_sell_price = solution - .prices + .prices() .get(&order.sell_token) .context("missing uniform clearing price for sell token")?; let _uniform_buy_price = solution - .prices + .prices() .get(&order.buy_token) .context("missing uniform clearing price for buy token")?; @@ -562,7 +569,10 @@ impl Arbitrator { } /// Pick winners based on directional token pairs. - fn pick_winners<'a>(&self, solutions: impl Iterator) -> HashSet { + fn pick_winners<'a, T: 'a>( + &self, + solutions: impl Iterator>, + ) -> HashSet { let mut already_swapped_token_pairs = HashSet::new(); let mut winners = HashSet::default(); @@ -572,7 +582,7 @@ impl Arbitrator { } let swapped_token_pairs: HashSet = solution - .orders + .orders() .iter() .map(|order| DirectedTokenPair { sell: as_erc20(order.sell_token, self.weth), @@ -594,7 +604,7 @@ impl Arbitrator { let mut reference_scores = HashMap::default(); for ranked_solution in &ranking.ranked { - let solver = ranked_solution.solution.solver; + let solver = ranked_solution.solver(); if reference_scores.len() >= self.max_winners { return reference_scores; @@ -602,23 +612,24 @@ impl Arbitrator { if reference_scores.contains_key(&solver) { continue; } - if !ranked_solution.is_winner { + if !ranked_solution.is_winner() { continue; } // Compute score without this solver - let solutions_without_solver = ranking + let solutions_without_solver: Vec<_> = ranking .ranked .iter() - .filter(|s| s.solution.solver != solver) - .map(|s| &s.solution); + .filter(|s| s.solver() != solver) + .collect(); - let winner_indices = self.pick_winners(solutions_without_solver.clone()); + let winner_indices = self.pick_winners(solutions_without_solver.iter().copied()); let score = solutions_without_solver + .iter() .enumerate() .filter(|(index, _)| winner_indices.contains(index)) - .filter_map(|(_, solution)| solution.score) + .map(|(_, solution)| solution.score()) .reduce(|acc, score| acc.saturating_add(score)) .unwrap_or_default(); @@ -649,43 +660,32 @@ fn compute_baseline_scores(scores_by_solution: &ScoresBySolution) -> ScoreByDire /// Result of partitioning solutions into fair and unfair. struct PartitionedSolutions { - /// Solutions that passed fairness checks. - kept: Vec, - /// Solutions that were filtered out as unfair. - discarded: Vec, -} - -/// A solution with its ranking status. -#[derive(Debug, Clone)] -pub struct RankedSolution { - pub solution: Solution, - pub is_winner: bool, + /// 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. - pub filtered_out: Vec, - /// Solutions that passed fairness checks, ordered by score. - pub ranked: Vec, + /// 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(|r| r.is_winner) - .map(|r| &r.solution) + 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(|r| !r.is_winner) - .map(|r| &r.solution) + pub fn non_winners(&self) -> impl Iterator> { + self.ranked.iter().filter(|s| !s.is_winner()) } } diff --git a/crates/winner-selection/src/lib.rs b/crates/winner-selection/src/lib.rs index 110ffcea79..3064b39e11 100644 --- a/crates/winner-selection/src/lib.rs +++ b/crates/winner-selection/src/lib.rs @@ -16,5 +16,5 @@ pub use { arbitrator::{Arbitrator, Ranking}, auction::AuctionContext, primitives::{Address, DirectedTokenPair, OrderUid, Side, U256}, - solution::{Order, Solution}, + solution::{Order, RankType, Ranked, Scored, Solution, Unscored}, }; diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs index 944aae2062..2d5482659c 100644 --- a/crates/winner-selection/src/solution.rs +++ b/crates/winner-selection/src/solution.rs @@ -17,7 +17,7 @@ use { /// /// Estimated size: ~1.7KB for a solution with 5 orders and 10 unique tokens. #[derive(Debug, Clone)] -pub struct Solution { +pub struct Solution { /// Solution ID from solver (unique per solver). pub id: u64, @@ -36,11 +36,122 @@ pub struct Solution { /// settled. pub prices: HashMap, - /// Total score for this solution. - /// - /// This is computed during winner selection and is not part of the - /// minimal data sent to the Pod Service. - pub score: Option, + /// State marker (score and ranking information). + state: State, +} + +/// Solution that hasn't been scored yet. +#[derive(Debug, Clone)] +pub struct Unscored; + +/// Solution with a computed score. +#[derive(Debug, Clone)] +pub struct Scored { + pub score: U256, +} + +/// Solution with ranking information. +#[derive(Debug, Clone)] +pub struct Ranked { + pub rank_type: RankType, + pub score: U256, +} + +/// The type of ranking assigned to a solution. +#[derive(Debug, Clone, Copy)] +pub enum RankType { + Winner, + NonWinner, + FilteredOut, +} + +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 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, + } + } + + /// Add a score to this solution. + pub fn with_score(self, score: U256) -> Solution { + Solution { + id: self.id, + solver: self.solver, + orders: self.orders, + prices: self.prices, + state: Scored { score }, + } + } +} + +impl Solution { + /// Get the score. + pub fn score(&self) -> U256 { + self.state.score + } + + /// Rank this solution. + pub fn rank(self, rank_type: RankType) -> Solution { + Solution { + id: self.id, + solver: self.solver, + orders: self.orders, + prices: self.prices, + state: Ranked { + rank_type, + score: self.state.score, + }, + } + } +} + +impl Solution { + /// Get the score. + pub fn score(&self) -> U256 { + self.state.score + } + + /// Check if this solution is a winner. + pub fn is_winner(&self) -> bool { + matches!(self.state.rank_type, RankType::Winner) + } + + /// Check if this solution was filtered out. + pub fn is_filtered_out(&self) -> bool { + matches!(self.state.rank_type, RankType::FilteredOut) + } } /// Minimal order data needed for winner selection. From 86c7140753a1e65e5ea48c3f8c551c00e681c20f Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 26 Dec 2025 17:07:29 +0000 Subject: [PATCH 09/11] Extract to traits --- .../src/domain/competition/participant.rs | 76 +++---------- .../domain/competition/winner_selection.rs | 2 + crates/autopilot/src/infra/persistence/mod.rs | 1 + crates/autopilot/src/run_loop.rs | 1 + crates/autopilot/src/shadow.rs | 1 + crates/winner-selection/src/arbitrator.rs | 1 + crates/winner-selection/src/lib.rs | 1 + crates/winner-selection/src/solution.rs | 101 +++++------------ crates/winner-selection/src/state.rs | 106 ++++++++++++++++++ 9 files changed, 157 insertions(+), 133 deletions(-) create mode 100644 crates/winner-selection/src/state.rs 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 e68b1a4bae..aa98119294 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -32,6 +32,7 @@ use { eth::{self, WrappedNativeToken}, fee, }, + ::winner_selection::state::{RankedItem, ScoredItem, UnscoredItem}, std::collections::HashMap, winner_selection as ws, }; @@ -335,6 +336,7 @@ mod tests { collections::HashMap, hash::{DefaultHasher, Hash, Hasher}, }, + winner_selection::state::RankedItem, }; const DEFAULT_TOKEN_PRICE: u128 = 1_000; diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index b72faf2019..f633316dc8 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, diff --git a/crates/autopilot/src/run_loop.rs b/crates/autopilot/src/run_loop.rs index 9a57dc6528..befee5e214 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, diff --git a/crates/autopilot/src/shadow.rs b/crates/autopilot/src/shadow.rs index a0188b501c..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, diff --git a/crates/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index 80f48aafdd..7e7f3aeb50 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -9,6 +9,7 @@ use { 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}, diff --git a/crates/winner-selection/src/lib.rs b/crates/winner-selection/src/lib.rs index 3064b39e11..d3b6598491 100644 --- a/crates/winner-selection/src/lib.rs +++ b/crates/winner-selection/src/lib.rs @@ -9,6 +9,7 @@ pub mod arbitrator; pub mod auction; pub mod primitives; pub mod solution; +pub mod state; pub mod util; // Re-export key types for convenience diff --git a/crates/winner-selection/src/solution.rs b/crates/winner-selection/src/solution.rs index 2d5482659c..3fb8d9f22d 100644 --- a/crates/winner-selection/src/solution.rs +++ b/crates/winner-selection/src/solution.rs @@ -3,11 +3,17 @@ //! 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}, + 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. /// @@ -40,31 +46,6 @@ pub struct Solution { state: State, } -/// Solution that hasn't been scored yet. -#[derive(Debug, Clone)] -pub struct Unscored; - -/// Solution with a computed score. -#[derive(Debug, Clone)] -pub struct Scored { - pub score: U256, -} - -/// Solution with ranking information. -#[derive(Debug, Clone)] -pub struct Ranked { - pub rank_type: RankType, - pub score: U256, -} - -/// The type of ranking assigned to a solution. -#[derive(Debug, Clone, Copy)] -pub enum RankType { - Winner, - NonWinner, - FilteredOut, -} - impl Solution { /// Get the solution ID. pub fn id(&self) -> u64 { @@ -87,6 +68,25 @@ impl Solution { } } +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( @@ -103,55 +103,6 @@ impl Solution { state: Unscored, } } - - /// Add a score to this solution. - pub fn with_score(self, score: U256) -> Solution { - Solution { - id: self.id, - solver: self.solver, - orders: self.orders, - prices: self.prices, - state: Scored { score }, - } - } -} - -impl Solution { - /// Get the score. - pub fn score(&self) -> U256 { - self.state.score - } - - /// Rank this solution. - pub fn rank(self, rank_type: RankType) -> Solution { - Solution { - id: self.id, - solver: self.solver, - orders: self.orders, - prices: self.prices, - state: Ranked { - rank_type, - score: self.state.score, - }, - } - } -} - -impl Solution { - /// Get the score. - pub fn score(&self) -> U256 { - self.state.score - } - - /// Check if this solution is a winner. - pub fn is_winner(&self) -> bool { - matches!(self.state.rank_type, RankType::Winner) - } - - /// Check if this solution was filtered out. - pub fn is_filtered_out(&self) -> bool { - matches!(self.state.rank_type, RankType::FilteredOut) - } } /// Minimal order data needed for winner selection. diff --git a/crates/winner-selection/src/state.rs b/crates/winner-selection/src/state.rs new file mode 100644 index 0000000000..b53ca35f55 --- /dev/null +++ b/crates/winner-selection/src/state.rs @@ -0,0 +1,106 @@ +//! 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; + fn filtered_out(&self) -> bool { + self.is_filtered_out() + } +} + +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() + } +} From 768b6318e9397a7b0e211d02844454eda88c3ccd Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 26 Dec 2025 18:01:57 +0000 Subject: [PATCH 10/11] Fix docs --- .../domain/competition/winner_selection.rs | 9 +- crates/autopilot/src/domain/settlement/mod.rs | 134 +++++++++++++++++- .../src/domain/settlement/trade/math.rs | 67 +-------- .../src/domain/settlement/trade/mod.rs | 5 - crates/winner-selection/src/arbitrator.rs | 30 ++-- crates/winner-selection/src/primitives.rs | 31 ---- 6 files changed, 152 insertions(+), 124 deletions(-) diff --git a/crates/autopilot/src/domain/competition/winner_selection.rs b/crates/autopilot/src/domain/competition/winner_selection.rs index aa98119294..85b34b9472 100644 --- a/crates/autopilot/src/domain/competition/winner_selection.rs +++ b/crates/autopilot/src/domain/competition/winner_selection.rs @@ -34,7 +34,7 @@ use { }, ::winner_selection::state::{RankedItem, ScoredItem, UnscoredItem}, std::collections::HashMap, - winner_selection as ws, + winner_selection::{self as ws, state::WithState}, }; pub struct Arbitrator(ws::Arbitrator); @@ -95,15 +95,10 @@ impl Arbitrator { .remove(&key) .expect("every ranked solution has a matching participant"); let score = ranked_solution.score(); - let rank_type = if ranked_solution.is_winner() { - RankType::Winner - } else { - RankType::NonWinner - }; ranked.push( participant .with_score(Score(eth::Ether(score))) - .rank(rank_type), + .rank(ranked_solution.state().rank_type), ); } 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/winner-selection/src/arbitrator.rs b/crates/winner-selection/src/arbitrator.rs index 7e7f3aeb50..bdb0c39a74 100644 --- a/crates/winner-selection/src/arbitrator.rs +++ b/crates/winner-selection/src/arbitrator.rs @@ -79,10 +79,11 @@ impl Arbitrator { }) .expect("every remaining solution has an entry"); - // Only keep solutions where each order execution is at least as good as + // 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, + // 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 @@ -99,7 +100,8 @@ impl Arbitrator { PartitionedSolutions { kept, discarded } } - /// Picks winners and marks all solutions. + /// 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()); @@ -117,7 +119,7 @@ impl Arbitrator { .collect() } - /// Computes the `DirectedTokenPair` scores for all solutions and discards + /// 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. @@ -569,11 +571,19 @@ impl Arbitrator { } } - /// Pick winners based on directional token pairs. + /// 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(); @@ -641,7 +651,9 @@ impl Arbitrator { } } -/// Compute baseline scores (best single-pair solutions). +/// 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(); @@ -719,10 +731,12 @@ struct SolutionKey { } /// Scores of all trades in a solution aggregated by the directional -/// token pair. +/// 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. +/// Mapping from solution to `DirectionalScores` for all solutions +/// of the auction. type ScoresBySolution = HashMap; type MathResult = std::result::Result; diff --git a/crates/winner-selection/src/primitives.rs b/crates/winner-selection/src/primitives.rs index ee7963c270..81db4739f3 100644 --- a/crates/winner-selection/src/primitives.rs +++ b/crates/winner-selection/src/primitives.rs @@ -51,37 +51,6 @@ impl OrderUid { } } -// impl serde::Serialize for OrderUid { -// fn serialize(&self, serializer: S) -> Result -// where -// S: serde::Serializer, -// { -// // Serialize as hex string with 0x prefix -// let hex_string = format!("0x{}", hex::encode(self.0)); -// serializer.serialize_str(&hex_string) -// } -// } -// -// impl<'de> serde::Deserialize<'de> for OrderUid { -// fn deserialize(deserializer: D) -> Result -// where -// D: serde::Deserializer<'de>, -// { -// let s = String::deserialize(deserializer)?; -// let s = s.strip_prefix("0x").unwrap_or(&s); -// let decoded = hex::decode(s).map_err(serde::de::Error::custom)?; -// if decoded.len() != 56 { -// return Err(serde::de::Error::custom(format!( -// "expected 56 bytes, got {}", -// decoded.len() -// ))); -// } -// let mut bytes = [0u8; 56]; -// bytes.copy_from_slice(&decoded); -// Ok(OrderUid(bytes)) -// } -// } - /// Order side (buy or sell). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Side { From fef8999356fe6815a5a6f9af2fb48754aa727067 Mon Sep 17 00:00:00 2001 From: ilya Date: Fri, 26 Dec 2025 18:31:21 +0000 Subject: [PATCH 11/11] Nits --- crates/autopilot/src/infra/persistence/mod.rs | 2 +- crates/autopilot/src/run_loop.rs | 2 +- crates/winner-selection/src/state.rs | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/autopilot/src/infra/persistence/mod.rs b/crates/autopilot/src/infra/persistence/mod.rs index f633316dc8..bd1273f910 100644 --- a/crates/autopilot/src/infra/persistence/mod.rs +++ b/crates/autopilot/src/infra/persistence/mod.rs @@ -227,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 befee5e214..dd96f43bca 100644 --- a/crates/autopilot/src/run_loop.rs +++ b/crates/autopilot/src/run_loop.rs @@ -515,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/winner-selection/src/state.rs b/crates/winner-selection/src/state.rs index b53ca35f55..9c0e961a56 100644 --- a/crates/winner-selection/src/state.rs +++ b/crates/winner-selection/src/state.rs @@ -82,9 +82,6 @@ pub trait RankedItem: WithState> { fn score(&self) -> Score; fn is_winner(&self) -> bool; fn is_filtered_out(&self) -> bool; - fn filtered_out(&self) -> bool { - self.is_filtered_out() - } } impl RankedItem for T