Building a Web 3.0 system that will fail just like you planned — Part 2 — DEX aggregators
Let’s talk about a significant failure we suffered while using the DEX aggregator 0x (but it could happen with all the aggregators).
And, of course, how we solved it 🦾
In part 1, I talked about the importance of having well-planned fallbacks for your RPC server and why failing to do so would cost you a lot of money in your Web 3.0 development.
Why do we need DEX aggregators?
In ToK Labs, we've developed an automated market-making system to provide liquidity in the most profitable markets.
Swapping costs are a big part of our bottom line. We want to minimize it as much as possible.
As you know, there is a liquidity fragmentation problem where a token could be traded in multiple DEXs.
If, for example, $PENDLE is traded on the BNB chain on both Uniswap V3 and PancakeSwap V3, swapping in one of them will potentially yield higher costs than if I could have taken advantage of the liquidity in both.
This is happening due to something called price impact.
I won't dive into that, and this piece assumes you're familiar with it and the difference between it and slippage.
This is where DEX aggregators come in handy. Like the Uniswap router finds the best route within Uniswap, a DEX aggregator's goal is to find the best route between DEXs.
So, if I want to sell $50K worth of $PENDLE on the BNB chain, a DEX aggregator might split the trade between Uniswap V3 and PancakeSwap V3 to better protect us against price impact.
Here is an example of an aggregator performing a swap into multiple splits, multiple paths within each split, and between multiple DEXs on the BNB chain:
The top aggregators, by volume, can be found in this Dune Analytics dashboard.
There are also meta aggregators that look for the best swap among DEXs and aggregators. The most popular one is the DefiLlama meta aggregator.
How does it technically work?
Usually, these are the steps:
- You perform an API call with your trade parameters to the aggregator's swap API (the token to sell, the amount to sell, etc).
- The aggregator returns a response containing much information, but most importantly, the call data instructions, the “to” address, and “value” to perform the smart swap.
- You execute a transaction with these instructions (and make sure you approve allowances to the correct address so it can use your funds for the trade).
- That's it. The smart swap is done.
This means you put a high level of trust in the DEX aggregator. You're performing their instructions "no questions asked".
A bug in 0x caused a 70% price impact. Ouch.
0x has a very nice feature called price impact protection.
The idea is to specify the highest you will pay as price impact.
0x tries to calculate it during step #1 in the above steps, and if it's higher than the specified value, an error response is returned with no swap instructions.
So this is an off-chain protection.
This sounded like the perfect solution for us, so we didn't bother to plan for it to fail. This was a bad idea for two reasons:
- 0x specifies they'll try to estimate the price impact. No one said they guaranteed it.
- Trust no one; always plan to fail.
So what happened?
We were trying to swap $PENDLE on the BNB chain, and all of the liquidity was on PancakeSwap V3.
We didn't notice that during that time, 0x did not support V3 on BNB (don't worry, they added support since then).
That means that we're going to fail. Or at least that's what we hoped for.
Instead, 0x had a bug.
It turns out that on PancakeSwap V2, there was a pool with about $1 of $PENDLE liquidity, and 0x found it.
Not only did they not notify us of the VERY high price impact, but they found this pool and instructed us to go there.
The same thing happened if you used their UI —Matcha.
Since it was the V2 version, the liquidity is spread throughout the entire range of [-∞, ∞]. So we swapped a lot of money for basically nothing.
Well, not for nothing; we probably paid some arbitrage player's dinners for the whole year.
(By the way, on V3 that wouldn't have happened to us because the liquidity is only within a specified range provided by the LPs. We would have gotten an error that the swap could not be performed. But I don't have time to go down that rabbit hole with you now).
The funny thing is that 0x returned the correct numbers of the horrible trade they instructed us to perform.
We did not check these values before but saw them later in our logs.
That is something we've later used as our off-chain protection.
We'll dive into that in a minute.
Never again!
We couldn't let it happen again. So, we took action:
- We turned off the strategies running on BNB. Most of the liquidity there is on PancakeSwap V3, and since 0x did not support it, measures had to be taken.
- We added a first line of defense - off-chain validation.
- Then, we added a second line of defense - on-chain validation.
Off-chain validation
I don't trust the API response with the trade's instructions. But I do trust the numbers returned in this response. It looks something like this:
{
"to": "0x...",
"value": 0,
"data": "<encoded swap data>",
"sellAmount": 1234,
"buyAmount": 5678
}
The idea is simple.
- Get another independent on-chain source for the pair's price (that could be the on-chain price in the pool we're about to provide liquidity to).
- Calculate the minimum you're willing to get out of this trade based on this price.
- If the aggregator's result is lower than your min, fail the operation.
The off-chain validation has a significant flaw in its core.
It trusts something in the API response. That could be incorrect.
So why have it to begin with?
Because it will be correct 99% of the time, it saves us costly operations of performing an RPC call and potentially paying gas fees (if the gas estimation passes).
The following protection is the ultimate one if the first one fails.
On-chain validation
No matter how you spin it, you can never be truly protected when your protections are being done off-chain.
The solution is simple: we must perform our swaps from our contract and not directly use the aggregator's instructions.
Here is a method of doing it:
- As with the off-chain solution, get another independent on-chain source for the pair's price (the pool itself) and calculate the minimum you're willing to get out of this trade based on this price.
- In the contract, have the swap router address to work with (it will also be used for approving allowances to the router).
In our case, it was the 0x exchange, but it can be any other address — including DEXs and not just aggregators. - Check the balance of the token you're going to buy before the trade.
- Call the swap router with the swap instructions.
- Check the balance of the token you just bought after the trade.
- If the difference is less than the min you asked for, revert the transaction.
Here is an example contract called V3Utils.sol from a project I like very much (and have no ties with) — revert finance.
I allowed myself to delete rows of code to simplify the example.
function _swap(IERC20 tokenIn, IERC20 tokenOut, uint256 amountIn, uint256 amountOutMin, bytes memory swapData) internal returns (uint256 amountOutDelta) {
uint256 balanceOutBefore = tokenOut.balanceOf(address(this));
// get router specific swap data
(address allowanceTarget, bytes memory data) = abi.decode(swapData, (address, bytes));
// execute swap
(bool success,) = swapRouter.call(data);
if (!success) {
revert SwapFailed();
}
uint256 balanceOutAfter = tokenOut.balanceOf(address(this));
amountOutDelta = balanceOutAfter - balanceOutBefore;
// amountMin slippage check
if (amountOutDelta < amountOutMin) {
revert SlippageError(); // Don't be confused by the error's name. This is in fact includes also price impact.
}
// event for any swap with exact swapped value
emit Swap(address(tokenIn), address(tokenOut), amountOutDelta);
}
Implementing something like that will ensure you're protected against bad trades provided by the aggregator.
Is it even worth using DEX aggregators? Sounds like a lot of work
It depends on your use case.
Don't use DEX aggregators if you're making trades that 99% of the liquidity is on a single DEX.
Are you trading with small amounts? The aggregator's fees might make the trade not profitable (0x's free plan, for example, takes an extra 0.15% fee).
But, for many use cases, a DEX aggregator is a good idea.
Just plan it to fail the way that suits you.