-
Notifications
You must be signed in to change notification settings - Fork 152
feat(driver): Add haircut configuration for conservative solver bidding #4049
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
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This pull request introduces a 'haircut' feature for conservative solver bidding, which is a great addition for making bids more realistic. The implementation is solid, with new logic for applying haircuts, configuration options, and comprehensive tests. I've provided a few suggestions to improve efficiency, readability, and test robustness. Overall, this is a well-executed feature.
jmg-duarte
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM approving as my comments don't really block the merge
m-sz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
| .orders() | ||
| .iter() | ||
| .find(|order| order.uid == fulfillment.order.0) | ||
| // TODO this error should reference the UID |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this TODO for follow-up PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // Convert prices to domain types (mutable for haircut adjustment) | ||
| let mut prices: HashMap<eth::Address, eth::U256> = solution |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having the driver adjust these prices after the fact will be very confusing for solvers as this can turn a working solution to a reverting one. On slack you mentioned that the original idea of adjusting the orders we feed to the solver (to make them "harder" to solve) to make it so they don't have to be adjusted after the fact had some complications. Can you point them out more clearly please?
I think before we alter the approach to something that will end up being confusing for solvers we should see if we can address these complications somehow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding changing the order amounts in advance. The only thing we can do is to decrease the sell amount for sell orders and increase the buy amount for buy orders. This leads to the executed amount change, which leads to failing validations. To avoid validations from failing, we would need to fake the executed amounts, which might be tricky and I don't feel confidence it would work correctly. Basically, the provided solution by the solver will lead to using less user tokens.
Why can't we adjust the buy amount for sell orders is because the OKX API operates only with the input sell amount, it doesn't accept a buy amount for sell orders, only slippage tolerance. If we adjust the buy amount on our side, nothing changes from the OKX API request side.
I hope that clarifies things.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
which leads to failing validations
Can you please link which code is failing in that case? It's hard to reason about this when I don't exactly know which code you are talking about.
In general I still don't understand why decreasing order amounts causes issues here but not for the volume fee. Why does a volume fee of 2 bps work today and 1 bps volume fee + 1 bps of haircut wouldn't?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does a volume fee of 2 bps work today and 1 bps volume fee + 1 bps of haircut wouldn't?
Volume fee correction works with the buy amount for sell orders and sell amount for buy orders. As I mentioned before, this won't take any effect in case of the OKX API, since the API would work only with the sell amount for sell orders + slippage tolerance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is how the volume fee is applied:
services/crates/driver/src/infra/solver/dto/auction.rs
Lines 94 to 109 in 3d38ad0
| Side::Buy => { | |
| // reduce sell amount by factor | |
| available.sell.amount = available | |
| .sell | |
| .amount | |
| .apply_factor(1.0 / (1.0 + factor)) | |
| .unwrap_or_default(); | |
| } | |
| Side::Sell => { | |
| // increase buy amount by factor | |
| available.buy.amount = available | |
| .buy | |
| .amount | |
| .apply_factor(1.0 / (1.0 - factor)) | |
| .unwrap_or_default(); | |
| } |
Here is the validation of the executed amount:
| order::Partial::No => executed_with_fee == order.target(), |
If we reduce the sell amount for a sell order, this fails, and we need to fake it, which looks suspicious. Also, the solver's interaction will work with the reduced sell amount. I assume the end user will pay less?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the only data for a given order that gets passed into any dex api. It's always the target amount. If we were to change the other amount the DEX API (i.e. the HTTP request we send to OKX) could not be different.
If we reduce the sell amount for a sell order, this fails, and we need to fake it
Why would we do it any other way than the volume already does it - by increasing the buy amount rather than reducing the sell amount?
So far my understanding is this (using made up numbers):
user places an order selling 100 USDC for 100 Dai
driver makes limit price "harder" to make room for volume fee => 100 USDC for 100.02 Dai
driver applies haircut (confusing name because the solver is tricked into computing even better solutions) to make it even harder in case the solver is prone to negative slippage => 100 USDC for 105.02 Dai
solver computes a solution 100 USDC => 106 Dai
now the solution has 6 Dai surplus where we can easily take 2 BPS volume fee and suffer ~6 Dai negative slippage before the solver has to pay out of its own pocket
Fill or kill orders should not be a problem because the executed amount will always be 100%. The wiggle room to pay for negative slippage and volume fees is in the extra buy tokens we make the solver find. For the solver slippage accounting it doesn't matter whether the solver leaves n$ for of sell_token or n$ worth of buy token in the settlement contract.
If I'm still missing some important detail I think we should have a call where you walk me through the problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why would we do it any other way than the volume already does it - by increasing the buy amount rather than reducing the sell amount?
make it even harder in case the solver is prone to negative slippage => 100 USDC for 105.02 Dai
solver computes a solution 100 USDC => 106 Dai
I don't know how else to explain that the OKX API works only with input amounts. For a sell order, it is the sell amount. If we change the buy amount/limit from 100 to 106 dai, the OKX API won't be aware of it, since you send only 100 USDC input amount in the request. It will return the same response. Basically, we can only ask the API to sell 100 USDC for whatever limit with a slippage of X bps.
I don't really understand how the OKX API response should be changed if we change the buy limit amount from 100 to 106 dai.
If I'm still missing some important detail I think we should have a call where you walk me through the problem.
That is a good idea, since I feel the same. I think you are talking about the abstract model, where adjusting limit amounts should affect the solution, but that won't have any effect in case of the OKX API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The OKX API will do the same it always did. The difference is that the solver will think the limit price of the order is a lot higher than it actually is. That means it will simply not consider some orders as sufficiently solved that were okay before. It would only return solutions when there is so much surplus (compared to the real limit price not the one we faked) that it can incur a good chunk of negative slippage before it has to pay for the difference.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, thanks a lot for explaining that! I hope I got it correctly and this PR should already follow that logic, right? The PR description should probably be updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After some discussions, we can probably proceed with the current approach. More context: https://cowservices.slack.com/archives/C0375NV72SC/p1768820146158859?thread_ts=1767111252.905049&cid=C0375NV72SC
| order.sell.token.as_erc20(weth).into(); | ||
| let buy_token: eth::Address = | ||
| order.buy.token.as_erc20(weth).into(); | ||
| competition::solution::haircut::apply_to_clearing_prices( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not relevant for OKX but wouldn't this apply the haircut multiple times if you have multiple orders trading the same tokens?
I think this hints at the opportunity to no longer have a single uniform clearing price per token.
Would it be correct/equivalent to apply the haircut by increasing the fee of the order? I think that way you can apply the haircut to similar orders without adjusting the original clearing price vector multiple times.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, good catch! That is definitely a bug. Will think about a proper handling here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think 4bd752f3bdb5a43fbc54dab7d7785a81221009e2 does not fix this issue fully.
Now we only apply the haircut once but let's consider a solution with 2 orders: (tokenA -> tokenB, tokenB->tokenC). The logic will apply the haircut to the prices of tokenA and tokenB which effectively changes the prices for the first order twice. I believe we need to leave the uniform clearing prices untouched and adjust the amounts for each order individually. I think this can be achieved by increasing the solver computed fee for the given order.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sorry, I pushed the commit too soon. Here is another attempt to fix this b1a4179 (this PR).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That actually required more changes. Updated the PR.
| }, | ||
| jit.executed(), | ||
| Fee::Dynamic(jit.fee()), | ||
| eth::U256::ZERO, // JIT orders don't get haircut |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to explain the reasoning here. I guess the argument is that JIT orders are usually used to supply private liquidity which is not prone to negative slippage?
| Trade::Fulfillment(fulfillment) => fulfillment.sell_amount(prices), | ||
| Trade::Jit(jit) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we now defer to the logic inside Fulfillment::sell_amount() it seems like it would make sense to do the same for the Jit type?
| // 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(fulfillment) = solution.trades().iter().find_map(|trade| match trade { | ||
| solution::Trade::Fulfillment(f) if f.haircut_fee() > eth::U256::ZERO => Some(f), | ||
| _ => None, | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't all this logic what Trade::sell_amount(clearing_prices) is supposed to do already? Could this not be reused here?
Not sure if this is the case but wouldn't it be equivalent to use order's sell amount (including haircut) and the order's buy amount as the clearing prices instead of adjusting the clearing prices of the solution?
|
|
||
| // Calculate haircut fee for conservative bidding. | ||
| // This reduces reported surplus without affecting executed amounts. | ||
| let haircut_fee = if haircut_bps > 0 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need the if haircut_bps > 0 here? Wouldn't this correctly evaluate to 0 if haircut_bps is 0?
| "invalid order UID specified in fulfillment".to_owned() | ||
| ))? | ||
| .clone(); | ||
| let prices: HashMap<eth::Address, eth::U256> = solution |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there anything new in this function besides computing the haircut_fee? For such complex functions IMO it's best to avoid unnecessary changes to keep the diff minimal.
| let mut prices: Vec<eth::U256> = clearing_prices | ||
| .values() | ||
| .map(|v| v.as_str().unwrap().parse::<eth::U256>().unwrap()) | ||
| .collect(); | ||
| prices.sort(); | ||
| let (price_low, price_high) = (prices[0], prices[1]); | ||
| // Note: in our test setup, sell token has lower price, so: | ||
| // buy_amount = sell_amount * (price_low / price_high) | ||
| let buy_amount_no_haircut = sell_amount * price_low / price_high; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic to extract the needed value from the solution is identical for both cases, no? Would be good to have this as a separate function to avoid code duplication and give this process a name.
| let haircut_bps = if buy_amount_no_haircut > eth::U256::ZERO { | ||
| let ratio = buy_amount_with_haircut * eth::U256::from(10000) / buy_amount_no_haircut; | ||
| eth::U256::from(10000) - ratio | ||
| } else { | ||
| eth::U256::ZERO | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this should always be the same non-zero value I think it makes more sense to keep the code clean and do a simple division to let this panic if the amounts don't make sense.
| // The haircutted amount should be less than the original | ||
| assert!( | ||
| buy_amount_with_haircut < buy_amount_no_haircut, | ||
| "Haircut should reduce buy amount: {} >= {}", | ||
| buy_amount_with_haircut, | ||
| buy_amount_no_haircut | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why have this assertion if it's a strictly less precise version of the next one?
|
|
||
| // The haircutted amount should be approximately 2% less | ||
| // Allow 1% tolerance for rounding and other factors (between 1% and 3% haircut) | ||
| let lower_bound = buy_amount_no_haircut * eth::U256::from(97) / eth::U256::from(100); // 97% |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could use is_approx_eq().
| let balance_after = token_b.balanceOf(trader_a.address()).call().await.unwrap(); | ||
| balance_after.checked_sub(balance_before).unwrap() >= 5u64.eth() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test doesn't give much confidence that the haircut logic was applied correctly IMO.
You could assert that the expected haircut fee remains in the settlement contract as positive slippage.
Description
Based on experience with the OKX solver, we observed that it tend to report too optimistic estimates that can't materialize on-chain.
The haircut feature allows solvers to proactively reduce their reported economics by a configurable percentage, making bids more conservative and reducing the risk of over-promising.
Changes
How it works
The haircut reduces the reported surplus by adding a haircut_fee to the fulfillment:
The actual on-chain execution remains unchanged - only the competition scoring is affected, making the solver's bid more conservative.
How to test
New driver and e2e tests.