Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rank by surplus driver V3 #2448

Merged
merged 32 commits into from
Mar 6, 2024
Merged

Rank by surplus driver V3 #2448

merged 32 commits into from
Mar 6, 2024

Conversation

sunce86
Copy link
Contributor

@sunce86 sunce86 commented Feb 29, 2024

Replacement for #2406 and #2421

Implements task 1 from #2382

Fixes #1494

Description

Implements surplus and protocol fees calculation, based on the solution (received from solvers) trades, which are in a settlement smart contract format. This ensures that these values are calculated after the protocol fee is already applied and the whole settlement is prepared for settling. This is important because the autopilot (and reward script) will use the same math so we are aligned 1:1.

The main calculation is in crates/driver/src/domain/competition/settled/mod.rs.

CIP38 scoring is currently called in a read only mode. The score is calculated but not used.

How to test

Tested locally with e2e tests. Surplus + protocol fee is always equal to legacy quality - surplus_fee which is expected.

@sunce86 sunce86 requested a review from a team as a code owner February 29, 2024 09:36
Copy link
Contributor

@fleupold fleupold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the re-definition of all relevant data types, I wonder if we cannot create the relevant struct from the Solution and avoid relying on legacy boundary code (which we are trying to remove)

crates/driver/src/boundary/settlement.rs Outdated Show resolved Hide resolved
crates/driver/src/boundary/settlement.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/auction.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/shared/src/encoded_settlement.rs Outdated Show resolved Hide resolved
Copy link
Contributor

@MartinquaXD MartinquaXD left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky to get an understanding of the overall change. Have to make another pass later today.

crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved

fn apply_factor(amount: eth::U256, factor: f64) -> Option<eth::U256> {
Some(
amount.checked_mul(eth::U256::from_f64_lossy(factor * 1000000000000000000.))?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could multiply using BigRational numbers. That way you should have accurate results without having to multiply the factor with a huge number.
Since I see you defining that function twice it might be a good idea to have that as a general helper function in our number crate.

crates/shared/src/encoded_settlement.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/auction.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
Comment on lines 103 to 107
let limit_buy = self
.executed
.0
.checked_mul(self.buy.amount.into())?
.checked_div(self.sell.amount.into())?;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: For consistency I would have expected a scaled limit buy amount.

Suggested change
let limit_buy = self
.executed
.0
.checked_mul(self.buy.amount.into())?
.checked_div(self.sell.amount.into())?;
let limit_buy = self
.buy
.amount
.0
.checked_mul(self.executed.into())?
.checked_div(self.sell.amount.into())?;

Comment on lines 152 to 188
match self.policies.first()? {
order::FeePolicy::Surplus {
factor,
max_volume_factor,
} => Some(eth::Asset {
token: match self.side {
Side::Sell => self.buy.token,
Side::Buy => self.sell.token,
},
amount: std::cmp::min(
{
// If the surplus after all fees is X, then the original
// surplus before protocol fee is X / (1 - factor)
apply_factor(self.surplus()?.amount.into(), factor / (1.0 - factor))?
},
{
// Convert the executed amount to surplus token so it can be compared with
// the surplus
let executed_in_surplus_token = match self.side {
Side::Sell => {
self.executed.0 * self.prices.custom.sell / self.prices.custom.buy
}
Side::Buy => {
self.executed.0 * self.prices.custom.buy / self.prices.custom.sell
}
};
apply_factor(
executed_in_surplus_token,
match self.side {
Side::Sell => max_volume_factor / (1.0 - max_volume_factor),
Side::Buy => max_volume_factor / (1.0 + max_volume_factor),
},
)?
},
)
.into(),
}),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this part of the code and the logic looks good.
(I just added as comment to indicate which parts I checked.)

Comment on lines 194 to 218
order::FeePolicy::Volume { factor } => Some(eth::Asset {
token: match self.side {
Side::Sell => self.buy.token,
Side::Buy => self.sell.token,
},
amount: {
// Convert the executed amount to surplus token so it can be compared with
// the surplus
let executed_in_surplus_token = match self.side {
Side::Sell => {
self.executed.0 * self.prices.custom.sell / self.prices.custom.buy
}
Side::Buy => {
self.executed.0 * self.prices.custom.buy / self.prices.custom.sell
}
};
apply_factor(
executed_in_surplus_token,
match self.side {
Side::Sell => factor / (1.0 - factor),
Side::Buy => factor / (1.0 + factor),
},
)?
}
.into(),
Copy link

@fhenneke fhenneke Mar 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part implies a volume based fee in the surplus token. If this is consistent with what the driver does, it is fine.
There is an ongoing discussion about how to charge volume based fees when we actually switch them on. This part might need to be revised (in a different PR) after that discussion.

Comment on lines 226 to 235
fn native_protocol_fee(
&self,
prices: &HashMap<eth::TokenAddress, auction::NormalizedPrice>,
) -> Option<eth::TokenAmount> {
big_rational_to_u256(
&(self.protocol_fee()?.amount.0.to_big_rational() * self.surplus_token_price(prices).0),
)
.map(Into::into)
.ok()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for the currently implemented volume fee in surplus token. If we go with a sell token based fee, this part would need to be revised. E.g. by adding a protocol_fee_token_price functionality.

crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
@sunce86 sunce86 changed the title DRAFT: Rank by surplus driver V3 Rank by surplus driver V3 Mar 1, 2024
@sunce86
Copy link
Contributor Author

sunce86 commented Mar 1, 2024

I pushed everything I wanted. Moving to review mode.

Copy link
Contributor

@fleupold fleupold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are getting close. I'm still somewhat worried about the amount of methods and dataypes we are introducing that future maintainers will have to understand, so. I'd like to explore if we can make a few more simplifications as to what we expose and keep most of the new logic private to the Scoring struct (currently called settled::Settlement)

crates/driver/src/domain/competition/auction.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
@@ -88,46 +88,51 @@ async fn surplus_fee_sell_order_capped_test(web3: Web3) {
max_volume_factor: 0.1,
};
// Without protocol fee:
// Expected executed_surplus_fee is 167058994203399
// Expected execution is 10000000000000000000 GNO for
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did those tests change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because of this code change: https://github.com/cowprotocol/services/pull/2448/files#diff-ceceb0ab17c45440702d1a3312aef5768ebad69027eac31792a581f9f486e0f4R159-R170

Here we skip adding the fee for sell orders.
This is decided to be done with solver team to remove the special case (deduction by observed_fee) when the volume based cap is calculation in post processing: https://github.com/cowprotocol/solver-rewards/blob/main/queries/orderbook/batch_rewards.sql#L89

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw we have an ongoing discussion again how to define trade volume more robustly and clearly.

Comment on lines 269 to 274
#[error("factor {1} multiplication with {0} failed")]
Factor(eth::U256, f64),
#[error("overflow error while calculating protocol fee")]
Overflow,
#[error("division by zero error while calculating protocol fee")]
DivisionByZero,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should avoid blindly adding a new error type for every potential call that could fail and instead ask us if we can group them in some sensible way. What is the advantage of having these strongly typed subtypes? I believe it's to then handle different types of errors differently (how are we realistically going to handle a TypeConversion differently from a DivisionByZero?)

Looking at Factor for instance, the only way this can happen is if the multiplication overflows (so really it's the same as Error::Overflow). I wonder if even the last three should be combined to something like Math(anyhow::Error).

I think by and large there are three types of error here:

  1. Math error (overflow, underflow, div by 0, etc)
  2. Malformed auctions (ie. missing price, type conversions)
  3. Limitations of the current implementation (multiple fee policies, unsupported policies)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factor also happens when the factor = 1.0

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did some cleanup, not sure if good enough

crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/solution/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
.iter()
.map(|trade| trade.score(prices))
.try_fold(eth::TokenAmount(eth::U256::zero()), |acc, score| {
score.map(|score| acc + score)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential overflow of U256

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think overflow here is not realistic. Also I think we don't want to cover all theoretically possible places with checked functions, but rather the ones that can realistically happen.

///
/// Denominated in NATIVE token
pub fn score(&self, prices: &auction::NormalizedPrices) -> Result<eth::TokenAmount, Error> {
Ok(self.native_surplus(prices)? + self.native_protocol_fee(prices)?)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential sum overflow

.ok_or(Error::Surplus(self.sell, self.buy))?
.amount;
let native_price = self.surplus_token_price(prices)?;
big_rational_to_u256(&(surplus.0.to_big_rational() * native_price.0))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential multiplication overflow

crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/settled/mod.rs Outdated Show resolved Hide resolved
) -> Result<eth::TokenAmount, Error> {
let protocol_fee = self.protocol_fee()?.amount;
let native_price = self.surplus_token_price(prices)?;
big_rational_to_u256(&(protocol_fee.0.to_big_rational() * native_price.0))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unchecked multiplication

@sunce86 sunce86 requested review from fleupold and MartinquaXD March 1, 2024 17:37
Copy link
Contributor

@fleupold fleupold left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me now (mostly nits from my side), thanks for working through all the changes.

I do think it would help this PR to not include the volume fee fixes that have been discussed, but rather base those on top of this PR so they can be reviewed separately.

crates/driver/src/domain/competition/auction.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/solution/fee.rs Outdated Show resolved Hide resolved
Comment on lines 145 to 163
sell: match trade.order().side {
order::Side::Sell => trade
.executed()
.0
.checked_mul(uniform_prices.sell)
.ok_or(Math::Overflow)?
.checked_div(uniform_prices.buy)
.ok_or(Math::DivisionByZero)?,
order::Side::Buy => trade.executed().0,
},
buy: match trade.order().side {
order::Side::Sell => trade.executed().0 + trade.fee().0,
order::Side::Buy => {
(trade.executed().0)
.checked_mul(uniform_prices.buy)
.ok_or(Math::Overflow)?
.checked_div(uniform_prices.sell)
.ok_or(Math::DivisionByZero)?
+ trade.fee().0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do we need this logic elsewhere? Maybe it would make sense to expose custom_clearing_prices(uniform_prices) as a method on Trade?

Then one could probably even think about merging the two Trade domain models (if they are semantically the same, since it looks like scoring::Trade is mainly derived from solution::Trade all that would be missing is to expose net_executed() amounts after fee on Trade). However, I believe this can also be done in a later refactoring.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then one could probably even think about merging the two Trade domain models

We slowly converged to calculating score on fulfillment trade. :) However, this was not my intention at first. Initially, I wanted to do scoring strictly on different type, a type that would be final after all driver operations done on the solution received from solver. But since we are moving away from driver making any changes to the solver solutions, I guess this approach makes sense until we are careful about adding any features that could alter the score.

crates/solvers/src/boundary/legacy.rs Outdated Show resolved Hide resolved
crates/driver/src/domain/competition/solution/scoring.rs Outdated Show resolved Hide resolved
.surplus_token_price(prices)?
.apply(self.protocol_fee()?.amount);
// normalize
Ok((protocol_fee.0 / *UNIT).into())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

crates/driver/src/domain/competition/solution/scoring.rs Outdated Show resolved Hide resolved
#[error("factor {1} multiplication with {0} failed")]
Factor(eth::U256, f64),
#[error(transparent)]
Math(#[from] super::Math),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we replace Factor and use Math in those cases (Factor can only happen in case of Overflow)?

Conceptually, this Math error is equivalent to solution::Error::Math, so I think it would even make sense to refactor our solution domain's error model. However I don't think this is blocking this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Factor also covers f64::inf case which helped me few times during testing.

Comment on lines +260 to +262
lazy_static::lazy_static! {
static ref UNIT: eth::U256 = eth::U256::from(1_000_000_000_000_000_000_u128);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove in favor of reusing ether_price, it's also defined in

const BASE: u128 = 10_u128.pow(18);

@fleupold fleupold mentioned this pull request Mar 5, 2024
5 tasks
sunce86 added a commit that referenced this pull request Mar 6, 2024
# Description
Extracted from #2448 to be a
separate PR.

In order to calculate the protocol fees based on the onchain settlement,
solver rewards script needs to make a weird adjustment for sell orders
(so that it's aligned with driver):
https://github.com/cowprotocol/solver-rewards/blob/main/queries/orderbook/batch_rewards.sql#L89
(deduct the observed fee).

In order to make the protocol fee calculation from autopilot side nice
and clean, we need to skip adding fee to sell orders.

## How to test
Updated protocol fee tests.
@sunce86
Copy link
Contributor Author

sunce86 commented Mar 6, 2024

I believe I addressed all relevant code review comments (will address more nits in a follow up).
Will merge to allow solver team to observe values in staging.

@sunce86 sunce86 merged commit dc20328 into main Mar 6, 2024
9 checks passed
@sunce86 sunce86 deleted the rank-by-surplus-driver branch March 6, 2024 12:43
@github-actions github-actions bot locked and limited conversation to collaborators Mar 6, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Don't rely on legacy code for solution scoring in driver
5 participants