Skip to content

Conversation

@squadgazzz
Copy link
Contributor

@squadgazzz squadgazzz commented Jan 13, 2026

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

  • Adds a configurable haircut-bps parameter (0-10000 basis points) for solvers to make bids more conservative
  • Haircut is implemented as an additional fee (haircut_fee) stored in Fulfillment, affecting surplus calculation without modifying executed amounts or interaction calldata
  • Works for both quotes and auction orders

How it works

The haircut reduces the reported surplus by adding a haircut_fee to the fulfillment:

  • Calculation: haircut_fee = executed_amount * haircut_bps / 10000, denominated in the order's target token (sell token for sell orders, buy token for buy orders)
  • Effect on scoring: The haircut_fee increases the reported sell_amount(), which flows into custom_prices() used for settlement encoding. This makes the solver's bid appear less attractive in competition.
  • Effect on quotes: Clearing prices are adjusted to reflect the haircut:
    • Sell orders: Decreases sell token price → user sees fewer buy tokens in quote
    • Buy orders: Increases buy token price → user sees more sell tokens required

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.

@squadgazzz squadgazzz changed the title Solver solution haircut feat(driver): Add haircut configuration for conservative solver bidding Jan 13, 2026
@squadgazzz squadgazzz marked this pull request as ready for review January 14, 2026 14:28
@squadgazzz squadgazzz requested a review from a team as a code owner January 14, 2026 14:28
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a 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.

Copy link
Contributor

@jmg-duarte jmg-duarte left a 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

Copy link
Contributor

@m-sz m-sz left a 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
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines 37 to 38
// Convert prices to domain types (mutable for haircut adjustment)
let mut prices: HashMap<eth::Address, eth::U256> = solution
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

@squadgazzz squadgazzz Jan 15, 2026

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:

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?

Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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.

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 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.

Copy link
Contributor Author

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

@squadgazzz squadgazzz marked this pull request as draft January 19, 2026 10:56
@squadgazzz squadgazzz marked this pull request as ready for review January 19, 2026 11:40
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(
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

@MartinquaXD MartinquaXD Jan 20, 2026

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.

Copy link
Contributor Author

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).

Copy link
Contributor Author

@squadgazzz squadgazzz Jan 20, 2026

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
Copy link
Contributor

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?

Comment on lines +77 to +78
Trade::Fulfillment(fulfillment) => fulfillment.sell_amount(prices),
Trade::Jit(jit) => {
Copy link
Contributor

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?

Comment on lines +79 to +86
// 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,
}) {
Copy link
Contributor

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 {
Copy link
Contributor

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
Copy link
Contributor

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.

Comment on lines +115 to +123
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;
Copy link
Contributor

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.

Comment on lines +171 to +176
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
};
Copy link
Contributor

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.

Comment on lines +186 to +192
// 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
);
Copy link
Contributor

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%
Copy link
Contributor

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().

Comment on lines +1281 to +1282
let balance_after = token_b.balanceOf(trader_a.address()).call().await.unwrap();
balance_after.checked_sub(balance_before).unwrap() >= 5u64.eth()
Copy link
Contributor

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants