Skip to content

Conversation

@m-sz
Copy link
Contributor

@m-sz m-sz commented Nov 10, 2025

Sell=buy token initiative

The ability to swap the same token pairs unlocks the potential for CoW to generally become a transaction relay service. This feature will allow users to execute pre- and post- interaction hooks as gasless transactions. The overall implementation has certain caveats:

  • Each settlement has to go through the settlement contract anyway, so the sell=buy token transactions would require users to send X amount of sell token and receive X - cost of the token back. It is not realistic, with the current state of GPv2 contract for the user to just send the cost amount and save on gas this way.
  • The solvers can really compete only by batching to save on gas costs
  • There is little incentive for this feature to be supported by other solvers, but having this support in baseline solver could generally be enough.

Lift restrictions on sell=buy token

Based the changes on https://github.com/cowprotocol/services/pull/2513/files, adapted to current state of the codebase.

  • Adds a command-line argument to the orderbook API to allow for same sell and buy token swaps. The SameBuyAndSell token error is not raised in that case
  • Adds e2e test case that checks if sell=buy quoting and orders are possible
  • Liquidity fetching happens only if the sell and buy tokens are different
  • Relaxes the invariant on Tokens struct to allow for same token pairs
  • Minor tweak to Justfile to allow for custom filtering and extra args to nextest

Based on input from @fhenneke:

  • Adds a short-circuit behavior for solver to return empty route in case sell=buy
  • Returns sell and buy assets directly when solving for empty route

The test case test_execute_same_sell_and_buy_token currently fails because despite creating an order with the same sell and buy token (the deployed token's address is 0x68b1d87f95878fe05b998f19b66f4baba5de1aed), the buy token becomes 0x5fbdb2315678afecb367f032d93f642f64180aa3 when order is considered for solving in an auction. Working on resolving that.

@m-sz m-sz force-pushed the sell=buy branch 2 times, most recently from de09f52 to 858ac73 Compare November 12, 2025 15:54
@m-sz m-sz marked this pull request as ready for review November 12, 2025 15:55
@m-sz m-sz requested a review from a team as a code owner November 12, 2025 15:55
@squadgazzz
Copy link
Contributor

I think this PR needs to be converted to a draft, since more changes are expected. @m-sz , is that correct?

@m-sz
Copy link
Contributor Author

m-sz commented Nov 13, 2025

Yep, had a bunch of 1:1s and will be adding feature flag to the whole change to control it in orderbook.

@m-sz m-sz marked this pull request as draft November 13, 2025 17:10
@m-sz m-sz marked this pull request as ready for review November 14, 2025 16:54
@m-sz
Copy link
Contributor Author

m-sz commented Nov 14, 2025

I have updated the PR with CLI argument to orderbook to allow for same sell and buy tokens.
Changes to baseline solver, to support those transactions will come in a separate PR.

@github-actions
Copy link

This pull request has been marked as stale because it has been inactive a while. Please update this pull request or it will be automatically closed.

@m-sz m-sz marked this pull request as draft December 3, 2025 10:50
@m-sz m-sz force-pushed the sell=buy branch 2 times, most recently from c3a04a2 to 405acc6 Compare December 5, 2025 14:24
@m-sz m-sz marked this pull request as ready for review December 5, 2025 14:25
@squadgazzz
Copy link
Contributor

What happens if we place a buy order with the same tokens? Do we need to support it? We should either decline such orders or cover this case with e2e tests.

@extrawurst
Copy link
Contributor

extrawurst commented Dec 5, 2025

@squadgazzz for now sell order is enough for s&b, buy-orders only relevant for a later use-case of programmatic orders (see discussion).
@m-sz lets just block buy-orders for now.

Comment on lines +464 to +467
let bad_token_detector = Arc::new(bad_token_detector);
let sanitized_estimator = SanitizedPriceEstimator {
inner: Arc::new(wrapped_estimator),
bad_token_detector: Arc::new(bad_token_detector),
bad_token_detector: bad_token_detector.clone(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this change required?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Further down in the sell=buy unit tests I reuse the bad token detector.

Comment on lines +173 to +181
Some(route) if !route.is_empty() => {
// how many units of buy_token are bought for one unit of sell_token
// (buy_amount / sell_amount).
let price = self.native_token_price_estimation_amount.to_f64_lossy()
/ route.input().amount.to_f64_lossy();
/ route
.input()
.expect("route is not empty")
.amount
.to_f64_lossy();
Copy link
Contributor

Choose a reason for hiding this comment

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

Emptiness validation seems redundant here. It would make sense to check the route.input() value, or just map it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure! Thanks.

Comment on lines +204 to +238
if !route.is_empty() {
interactions = route
.segments
.iter()
.map(|segment| {
solution::Interaction::Liquidity(Box::new(
solution::LiquidityInteraction {
liquidity: segment.liquidity.clone(),
input: segment.input,
output: segment.output,
// TODO does the baseline solver know about this
// optimization?
internalize: false,
},
))
})
.collect();
gas = route.gas() + self.solution_gas_offset;
input = route.input().expect("route is not empty");
output = route.output().expect("route is not empty");
} else {
// Route is empty in case of sell and buy tokens being the same, as there
// is no need to figure out the liquidity for such pair.
//
// The input and output of the solution can be set directly to the
// respective sell and buy tokens 1 to 1.
interactions = Vec::default();
gas = eth::Gas(U256::zero()) + self.solution_gas_offset;

(input, output) = match order.side {
order::Side::Sell => (order.sell, order.buy),
order::Side::Buy => (order.buy, order.sell),
};
output.amount = input.amount;
}
Copy link
Contributor

@squadgazzz squadgazzz Dec 8, 2025

Choose a reason for hiding this comment

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

This logic also looks a bit awkward.

Suggested change
if !route.is_empty() {
interactions = route
.segments
.iter()
.map(|segment| {
solution::Interaction::Liquidity(Box::new(
solution::LiquidityInteraction {
liquidity: segment.liquidity.clone(),
input: segment.input,
output: segment.output,
// TODO does the baseline solver know about this
// optimization?
internalize: false,
},
))
})
.collect();
gas = route.gas() + self.solution_gas_offset;
input = route.input().expect("route is not empty");
output = route.output().expect("route is not empty");
} else {
// Route is empty in case of sell and buy tokens being the same, as there
// is no need to figure out the liquidity for such pair.
//
// The input and output of the solution can be set directly to the
// respective sell and buy tokens 1 to 1.
interactions = Vec::default();
gas = eth::Gas(U256::zero()) + self.solution_gas_offset;
(input, output) = match order.side {
order::Side::Sell => (order.sell, order.buy),
order::Side::Buy => (order.buy, order.sell),
};
output.amount = input.amount;
}
interactions = route
.segments
.iter()
.map(|segment| {
solution::Interaction::Liquidity(Box::new(
solution::LiquidityInteraction {
liquidity: segment.liquidity.clone(),
input: segment.input,
output: segment.output,
// TODO does the baseline solver know about this
// optimization?
internalize: false,
},
))
})
.collect();
if !interactions.is_empty() {
gas = route.gas() + self.solution_gas_offset;
input = route.input().expect("route is not empty");
output = route.output().expect("route is not empty");
} else {
// Route is empty in case of sell and buy tokens being the same, as there
// is no need to figure out the liquidity for such pair.
//
// The input and output of the solution can be set directly to the
// respective sell and buy tokens 1 to 1.
gas = eth::Gas(U256::zero()) + self.solution_gas_offset;
(input, output) = match order.side {
order::Side::Sell => (order.sell, order.buy),
order::Side::Buy => (order.buy, order.sell),
};
output.amount = input.amount;
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, let's avoid this style with declaring empty variables. Not really an idiomatic approach.

let interactions;
let gas;
let (input, mut output);

Comment on lines +398 to +399
fn is_empty(&self) -> bool {
self.segments.is_empty()
Copy link
Contributor

Choose a reason for hiding this comment

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

This function can go away then.

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.

Just a first look. I'll try to do another deeper review later.

Comment on lines -245 to -246
/// Creates a new instance of [`Tokens`], verifying that the input buy and
/// sell tokens are distinct.
Copy link
Contributor

Choose a reason for hiding this comment

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

I would like to get some feedback from solvers how they would expect this to work. Specifically to ask them about using the regular quoting logic vs. building something into the orderbook which only needs the native price of the tokens and a simulation to get the gas amount.

.context("summary buy token is missing")?;

if *sell_token_lost >= sell_token_lost_limit || *buy_token_lost >= buy_token_lost_limit {
if (!sell_token_lost.is_zero() && *sell_token_lost >= sell_token_lost_limit)
Copy link
Contributor

Choose a reason for hiding this comment

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

This change is not needed anymore, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When the whole E2E test suite is run, the test onchain_settlement_without_liquidity from buffers.rs seems to hit this, probably due to the change in quote verification?

Comment on lines +177 to +181
/ route
.input()
.expect("route is not empty")
.amount
.to_f64_lossy();
Copy link
Contributor

Choose a reason for hiding this comment

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

We can simply use order.buy.token, no?

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 it ought to be the sell token, as per the comments.

input = route.input().expect("route is not empty");
output = route.output().expect("route is not empty");
} else {
// Route is empty in case of sell and buy tokens being the same, as there
Copy link
Contributor

Choose a reason for hiding this comment

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

Probably nicer to make this an early return.

@squadgazzz
Copy link
Contributor

squadgazzz commented Dec 8, 2025

@m-sz please also update https://github.com/gnosis/solvers, so none of the solvers waste the third-party API requests on sending orders that can't be solved. So each solver needs to filter out such orders in advance.

let gas;
let (input, mut output);

if !route.is_empty() {
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 probably need to have an if here anyway we can probably also leave the routing logic completely untouched (i.e. revert changes for allowing empty route segments). Rather than having the if after calling route() here we could instead have the if sell == buy and only call route() in the other case, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's correct. I am fine with making this change

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.

6 participants