preface

At present, in DeFi, there are many products specializing in DEX trading and aggregation. The following are some of them:

As can be seen, these platforms have aggregated many DEX’s, including the DEX of AMM (automatic market maker) mode and the DEX of Orderbook mode. The main function is to integrate the dispersed liquidity of various DEX’s together, and provide the optimal price, the best depth and the clear and concise interface.

These days I also wrote a DEX trade aggregator, pure contract. However, the functionality is relatively simple, only UniswapV2 and SushiSwap are aggregated, and it is only possible to find the best transaction price from the two platforms to implement each transaction. Even though it was a simple trade aggregator, it made a lot of mistakes, which revealed some blind spots in my knowledge. I would like to share some of my experiences and conclusions in this process.

Technology research

Since UniswapV2 and SushiSwap are being accessed at the contract level, the first step is to investigate how to access them.

UniswapV2’s contract is divided into two projects:

  • uniswap-v2-core
  • uniswap-v2-periphery

The core of UnisWap-V2-Core has three contracts:

  • UniswapV2ERC20: Uni-V2 token contract
  • UniswapV2Factory: Factory contract
  • UniswapV2Pair: indicates a matching contract

The Uni-V2 Token contract is the LP Token contract, while the factory contract is used to create the matching contract, which maintains the liquidity pool for each coin pair. In addition, the matching contract inherits the UniswapV2ERC20 contract, which is also the LP Token contract.

Uniswap-v2-periphery, as it is known, has three main contracts:

  • UniswapV2Migrator: Migration contract used to migrate v1 liquidity to V2
  • UniswapV2Router01: Indicates an old routing contract
  • UniswapV2Router02: New version of the routing contract. This is what’s used now

The conversions, liquidity additions, and other operations on the Uniswap front end are actually done through interaction with the routing contract, so this routing contract is also the entry contract for our aggregate trade into Uniswap. The following page describes Router02 in the official documentation:

  • Docs.uniswap.org/protocol/V2…

SushiSwap fully uses the UniswapV2Router02 contract as the entry point to SushiSwap, but the contract address is different from Uniswap’s.

However, after investigating the routing contract, it was found that the route between the two currencies to be exchanged was actually passed from the outside to the routing contract. In Uniswap, the path selection algorithm implementation is encapsulated in the front-end SDK, but my aggregator has to find the optimal path itself in the contract, which is the first problem.

The optimal path

Sometimes there is no direct matching liquidity pool between the two currencies selected by the user for exchange, tokenA and tokenB. However, as long as there is another currency, tokenC, and there is a liquid pool for tokenA and tokenC, and for tokenB and tokenC, then if you change tokenA to tokenC, and then change tokenC to tokenB, TokenA and tokenB can complete the exchange, so tokenA > tokenC > tokenB forms a path of exchange between tokenA and tokenB.

There may be more than one such path, for example, there may be tokenA > tokenD > tokenB, or even tokenA > tokenC > tokenD > tokenB. Of course, if tokenA and tokenB have directly paired liquidity pools, then tokenA > tokenB is also a path.

Because the liquidity of each pool is different, when you specify the number of currencies, say 100 TokenAs, the number of TokenBs that will be exchanged in each path will be different. For users, they naturally want to exchange as many tokens as possible. Therefore, among these paths, the one with the largest number of exchange results can be the optimal path.

Some people may fall into the trap of thinking that the optimal path should be the shortest path, when in fact, the shortest path is not necessarily the optimal path. For example, ETH-WBTC actually has a directly matched liquidity pool, so the shortest path is ETH > WBTC. However, in the interface query, the optimal path is ETH > USDC > WBTC. See the following figure:

But with so many currencies, how can you efficiently find an optimal path?

Find the optimal path

The first step in finding the optimal path is to identify all potential paths. However, there are so many currencies, it is impossible to combine all currencies for path combination, especially at the contract level, the efficiency is too low. In fact, if you look at the Uniswap front page and select a token, you will see a list of several commonly used tokens, as shown below:

As you can see, these are the most mainstream tokens, all of which are paired with one or more of these tokens to form a liquidity pool. Therefore, just use these tokens as the intermediate currency for the path combination, rather than the entire token.

In addition, the path should not be too long. The longest path should be tokenA > tokenC > tokenD > tokenB.

In a word, the traversable paths of tokenA to tokenB include:

  • TokenA > tokenB: This path is valid only if there is a liquidity pool in which the two tokens are directly paired
  • TokenA > tokenC > tokenB: tokenC is one of the commonly used tokens and requires tokena-TokenC and tokenC-TokenB liquidity pools, respectively
  • TokenA > tokenC > tokenD > tokenB: TokenC and tokenD are two tokens in the list of commonly used tokens that require the three paired liquidity pools tokena-Tokenc, tokenc-Tokend, and tokend-Tokenb to be valid

Read the final price of each valid path, and then we know which path is the best.

Contract design

The design is also simple. The core contract class diagram is as follows:

And the instance relationship diagram is as follows:

Save a Dexs array in the Aggresp, use it to store the supported DEX, and when swap() is called, go through all the DEX, find out which one has the best price, and forward it to the Handler instance of that DEX to complete the exchange.

The UniswapV2Handler will keep a list of commonly used tokens. At each exchange, baseTokens are traversed, each valid path is assembled and the price is read, so as to query the optimal price and path, and then the exchange is completed by calling the routing contract.

The Aggresp And each Troll Handler use the ISwap interface to interact, so the caller can bypass the Aggresp and interact with a specific Handler as needed, and the Aggresp interface does not need to be changed, the Aggresp Aggresp interface is used in the Aggresp Handler interface.

The overall idea of contract design is roughly this, simple and easy to understand. But I think it’s important to share some of the mistakes I made in the implementation.

Limits on the view function

In the beginning, I wrote the following function to get all paths between two tokens, excluding third-level paths. But actually, there are some problems.

First, from business logic, tokenA > bases[I] > tokenB path, there is no check for pairing. TokenA -bases[I] and bases[I] -tokenb pairs should be checked to see if both pairs exist. GetPair (); if the pair does not have a zero address, it is a match; if not, the path is invalid. The example code is as follows:

if(factory.getPair(tokenA, bases[i]) ! = address(0) && factory.getPair(bases[i], tokenB) ! = address(0)) {
  paths.push([tokenA, bases[i], tokenB]);
}
Copy the code

Secondly, from the solidity level, the push() function of the array cannot be used in the view function because there is a fixed gas cost for calling the push function, whereas the view function does not produce gas, so it cannot be used. Therefore, using an array in the view function can only be assigned using a subscript, as shown in the following code:

address[] memory tempPath = new address[](3);
tempPath[0] = tokenIn;
tempPath[1] = baseTokens[i];
tempPath[2] = tokenOut;
Copy the code

Finally, return a two-dimensional array, which is not supported by default. To enable it, use ABIEncoderV2. You need to add the following directive to the contract:

pragma experimental ABIEncoderV2;
Copy the code

Experimental means it is still experimental, so it is recommended to use it sparingly because there may be unknown bugs.

Finally, I abandoned the function completely and put the logic of path traversal and price comparison in the same function.

Chain authorized transfer

The second mistake I encountered was authorization transfer, which was also caused by my lack of understanding of how authorization transfer works.

In my implementation, the exchange function has several layers of chained calls between different contracts. Suppose I write contracts A and B. Both contracts define swap(), and contract A calls swap() from contract A. The swap() function of the B contract calls the swap() function of the Uniswap routing contract.

A.swap() -> B.swap() -> Router.swap()
Copy the code

In router.swap (), the token’s transferFrom() function is called to transfer the caller’s MSG. Sender’s token to the Pair contract. Therefore, the caller also needs to authorize the contract before the exchange. At first I thought that all I had to do was authorize contract A, but of course the conversion failed. Later, I added chained authorization, where the caller authorizes A, A sublicenses B, and B sublicenses the Router, but it still failed. Finally, after truly understanding the principle of authorization transfer, the caller only needs to authorize A and B to Router, and add one step in contract A, and call the transferFrom() function of token to transfer the token of caller MSG. Sender to contract B, and the exchange of the whole chain will be successful.

First of all, what is the MSG. Sender for each step in the chain? For direct calls like this between contracts, MSG. Sender is the caller of the previous step, as shown in the figure below:

Router.swap() calls the token’s transferFrom() function to transfer the MSG. Sender token to the Pair contract, i.e., Router transfers the token from the B contract to the Pair contract, So B has to have the token in the contract to do the transfer. So where do the tokens for contract B come from? From the Caller, of course. As long as the Caller authorizes A, A then uses transferFrom to transfer the Caller’s token to B, thus solving the problem.

conclusion

Although this DEX transaction aggregator function is very simple, only query and exchange function, but it is very simple to expand, will be followed by access to UniswapV3, Bancor, DODO, and other functions can also be added to add liquidity, remove liquidity and other functions.


Scan the following QR code to follow the public account (public account name: Keegan Xiaosteel)