Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/driver/src/domain/competition/solution/fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl Fulfillment {
),
};

Fulfillment::new(order, executed, fee).map_err(Into::into)
Fulfillment::new(order, executed, fee, self.haircut_fee()).map_err(Into::into)
}

/// Computed protocol fee in surplus token.
Expand Down
3 changes: 3 additions & 0 deletions crates/driver/src/domain/competition/solution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ impl Solution {
},
jit.executed(),
Fee::Dynamic(jit.fee()),
// JIT orders don't get haircut because they supply private
// liquidity which should not prone to negative slippage.
eth::U256::ZERO,
)
.map_err(error::Solution::InvalidJitTrade)?,
);
Expand Down
96 changes: 71 additions & 25 deletions crates/driver/src/domain/competition/solution/trade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,19 +73,10 @@ impl Trade {
&self,
prices: &ClearingPrices,
) -> Result<eth::TokenAmount, error::Math> {
let before_fee = match self.side() {
order::Side::Sell => self.executed().0,
order::Side::Buy => self
.executed()
.0
.checked_mul(prices.buy)
.ok_or(Math::Overflow)?
.checked_div(prices.sell)
.ok_or(Math::DivisionByZero)?,
};
Ok(eth::TokenAmount(
before_fee.checked_add(self.fee().0).ok_or(Math::Overflow)?,
))
match self {
Trade::Fulfillment(fulfillment) => fulfillment.sell_amount(prices),
Trade::Jit(jit) => jit.sell_amount(prices),
}
}

/// The effective amount the user received after all fees.
Expand All @@ -95,17 +86,10 @@ impl Trade {
&self,
prices: &ClearingPrices,
) -> Result<eth::TokenAmount, error::Math> {
let amount = match self.side() {
order::Side::Buy => self.executed().0,
order::Side::Sell => self
.executed()
.0
.checked_mul(prices.sell)
.ok_or(Math::Overflow)?
.checked_ceil_div(&prices.buy)
.ok_or(Math::DivisionByZero)?,
};
Ok(eth::TokenAmount(amount))
match self {
Trade::Fulfillment(fulfillment) => fulfillment.buy_amount(prices),
Trade::Jit(jit) => jit.buy_amount(prices),
}
}

pub fn custom_prices(
Expand Down Expand Up @@ -135,13 +119,19 @@ pub struct Fulfillment {
/// order.
executed: order::TargetAmount,
fee: Fee,
/// Additional fee for conservative bidding (haircut). Applied on top of
/// the regular fee to reduce reported surplus without affecting executed
/// amounts. Expressed in the order's target token (sell token for sell
/// orders, buy token for buy orders).
haircut_fee: eth::U256,
}

impl Fulfillment {
pub fn new(
order: competition::Order,
executed: order::TargetAmount,
fee: Fee,
haircut_fee: eth::U256,
) -> Result<Self, error::Trade> {
// If the order is partial, the total executed amount can be smaller than
// the target amount. Otherwise, the executed amount must be equal to the target
Expand Down Expand Up @@ -179,6 +169,7 @@ impl Fulfillment {
order,
executed,
fee,
haircut_fee,
})
} else {
Err(error::Trade::InvalidExecutedAmount)
Expand Down Expand Up @@ -215,6 +206,11 @@ impl Fulfillment {
}
}

/// Returns the haircut fee for conservative bidding.
pub fn haircut_fee(&self) -> eth::U256 {
self.haircut_fee
}

/// The effective amount that left the user's wallet including all fees.
pub fn sell_amount(&self, prices: &ClearingPrices) -> Result<eth::TokenAmount, error::Math> {
let before_fee = match self.order.side {
Expand All @@ -227,8 +223,26 @@ impl Fulfillment {
.checked_div(prices.sell)
.ok_or(Math::DivisionByZero)?,
};

// haircut_fee is denominated in the order's target token (sell token for
// sell orders, buy token for buy orders). Convert to sell token for buy
// orders.
let haircut_in_sell_token = match self.order.side {
order::Side::Sell => self.haircut_fee,
order::Side::Buy => self
.haircut_fee
.checked_mul(prices.buy)
.ok_or(Math::Overflow)?
.checked_div(prices.sell)
.ok_or(Math::DivisionByZero)?,
};

Ok(eth::TokenAmount(
before_fee.checked_add(self.fee().0).ok_or(Math::Overflow)?,
before_fee
.checked_add(self.fee().0)
.ok_or(Math::Overflow)?
.checked_add(haircut_in_sell_token)
.ok_or(Math::Overflow)?,
))
}

Expand Down Expand Up @@ -463,6 +477,38 @@ impl Jit {
pub fn fee(&self) -> order::SellAmount {
self.fee
}

/// The effective amount that left the user's wallet including all fees.
pub fn sell_amount(&self, prices: &ClearingPrices) -> Result<eth::TokenAmount, Math> {
let before_fee = match self.order.side {
Side::Sell => self.executed.0,
Side::Buy => self
.executed
.0
.checked_mul(prices.buy)
.ok_or(Math::Overflow)?
.checked_div(prices.sell)
.ok_or(Math::DivisionByZero)?,
};
Ok(eth::TokenAmount(
before_fee.checked_add(self.fee.0).ok_or(Math::Overflow)?,
))
}

/// The effective amount the user received after all fees.
pub fn buy_amount(&self, prices: &ClearingPrices) -> Result<eth::TokenAmount, Math> {
let amount = match self.order.side {
Side::Buy => self.executed.0,
Side::Sell => self
.executed
.0
.checked_mul(prices.sell)
.ok_or(Math::Overflow)?
.checked_ceil_div(&prices.buy)
.ok_or(Math::DivisionByZero)?,
};
Ok(eth::TokenAmount(amount))
}
}

/// The amounts executed by a trade.
Expand Down
56 changes: 51 additions & 5 deletions crates/driver/src/domain/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,10 @@ pub struct Quote {

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

Ok(Self {
clearing_prices: solution
.clearing_prices()
.into_iter()
.map(|(token, amount)| (token.into(), amount))
.collect(),
clearing_prices,
pre_interactions: solution.pre_interactions().to_vec(),
interactions: solution
.interactions()
Expand All @@ -62,6 +60,52 @@ impl Quote {
.collect(),
})
}

/// Compute clearing prices for the quote.
///
/// Uses uniform clearing prices from the solution, adjusted for haircut
/// when enabled. This uses the same approach as settlement encoding:
/// `custom_prices()` which internally uses `sell_amount()` and
/// `buy_amount()` to include the haircut in the effective trade
/// amounts.
fn compute_clearing_prices(
solution: &competition::Solution,
) -> Result<HashMap<eth::Address, eth::U256>, Error> {
// Start with uniform clearing prices
let mut prices: HashMap<eth::Address, eth::U256> = solution
.clearing_prices()
.into_iter()
.map(|(token, amount)| (token.into(), amount))
.collect();

// Quote competitions contain only a single order (see `fake_auction()`),
// so there's at most one fulfillment in the solution.
// Apply haircut adjustment to prices if there's a fulfillment with non-zero
// haircut.
if let Some(trade) = solution.trades().iter().find(|trade| match trade {
solution::Trade::Fulfillment(f) => f.haircut_fee() > eth::U256::ZERO,
_ => false,
}) {
let sell_token: eth::Address = trade.sell().token.into();
let buy_token: eth::Address = trade.buy().token.into();
let uniform_clearing = solution::trade::ClearingPrices {
sell: *prices
.get(&sell_token)
.ok_or(QuotingFailed::ClearingSellMissing)?,
buy: *prices
.get(&buy_token)
.ok_or(QuotingFailed::ClearingBuyMissing)?,
};
let custom_prices = trade
.custom_prices(&uniform_clearing)
.map_err(|_| QuotingFailed::Math)?;

prices.insert(sell_token, custom_prices.sell);
prices.insert(buy_token, custom_prices.buy);
}

Ok(prices)
}
}

/// An order which needs to be quoted.
Expand Down Expand Up @@ -338,6 +382,8 @@ pub enum QuotingFailed {
ClearingBuyMissing,
#[error("solver returned no solutions")]
NoSolutions,
#[error("math error computing custom prices")]
Math,
}

#[derive(Debug, thiserror::Error)]
Expand Down
1 change: 1 addition & 0 deletions crates/driver/src/infra/config/file/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ pub async fn load(chain: Chain, path: &Path) -> infra::Config {
file::AtBlock::Latest => liquidity::AtBlock::Latest,
file::AtBlock::Finalized => liquidity::AtBlock::Finalized,
},
haircut_bps: solver_config.haircut_bps,
}
}))
.await,
Expand Down
7 changes: 7 additions & 0 deletions crates/driver/src/infra/config/file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,13 @@ struct SolverConfig {
/// before the driver starts dropping new `/solve` requests.
#[serde(default = "default_settle_queue_size")]
settle_queue_size: usize,

/// Haircut in basis points (0-10000). Applied to solver-reported
/// economics to make bids more conservative by adjusting clearing prices
/// to report lower surplus. Useful for solvers prone to negative slippage.
/// Default: 0 (no haircut).
#[serde(default)]
haircut_bps: u32,
}

#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)]
Expand Down
1 change: 1 addition & 0 deletions crates/driver/src/infra/observe/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ pub fn quoted(solver: &solver::Name, order: &quote::Order, result: &Result<Quote
quote::Error::QuotingFailed(quote::QuotingFailed::NoSolutions) => {
"NoSolutions"
}
quote::Error::QuotingFailed(quote::QuotingFailed::Math) => "MathError",
quote::Error::DeadlineExceeded(_) => "DeadlineExceeded",
quote::Error::Blockchain(_) => "BlockchainError",
quote::Error::Solver(solver::Error::Http(_)) => "SolverHttpError",
Expand Down
Loading
Loading