MultiSwap: How to arbitrage with Solidity

Making multiple swaps across different decentralized exchanges in a single transaction

If you want maximum arbitrage performance, you need to swap tokens between exchanges in a single transaction. Or maybe you just want to save gas on certain swaps you perform regularly. Or maybe you have your own custom use case for swapping between decentralized exchanges. And of course maybe you are just here for the learning aspect.

Whatever your reason may be, MultiSwap is a great way to combine knowledge into one contract. Let's try to do a MultiSwap that looks like this:

  1. Buy BNT on Bancor with ETH
  2. Sell the BNT for INJ on SushiSwap
  3. Sell the INJ for DAI on Uniswap 3

So how can we achieve this?

Arbitrage Meme

Do it manually first!

First we want to try all the trades manually. And since we are in the testing phase, we will do everything on a testnet which has deployed contracts for each protocol we want to use. In our case this happens to be only on Ropsten.

  • If a similar token to what you want to trade doesn't exist on the testnet, simply deploy one yourself via Remix.
  • If a token pool on a DEX doesn't exist yet on the testnet, create it yourself.

1. Banchor: ETH -> BNT

So first go to the Banchor app and swap your funds on the Ropsten network from ETH to BNT. After swapping click on the Etherscan transaction. 

Banchor Etherscan

You will easily find the function name and passed parameters in the Etherscan transaction. Keep this in mind and also the documentation always helps.

Banchor App
SushiSwap

2. SushiSwap: BNT -> INJ

Then go to the SushiSwap app and swap your tokens from BNT to INJ.

Again note down the function name and parameters from the Etherscan transaction. SushiSwap is based on Uniswap 2, so you will also find more detailed explanations how it works in my previous blog post here.

3. Uniswap: INJ -> DAI

And lastly go to the Uniswap app and swap your tokens from INJ to DAI.

Again note down the function name and parameters from the Etherscan transaction. You will also find more detailed explanations how Uniswap 3 works in my previous blog post here.


Uniswap

Now lets use Solidity.

Fun Begins Meme

With the previous information, creating the trade logic is straight-forward. You first trade on Bancor, use the received funds to trade on SushiSwap and again use the received funds to trade on Uniswap.

1. Trading on Bancor

IBancorNetwork private constant bancorNetwork = IBancorNetwork(0xb3fa5DcF7506D146485856439eb5e401E0796B5D);
address private constant BANCOR_ETH_ADDRESS = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
address private constant BANCOR_ETHBNT_POOL = 0x1aCE5DD13Ba14CA42695A905526f2ec366720b13;
address private constant BNT = 0xF35cCfbcE1228014F66809EDaFCDB836BFE388f5;

function _tradeOnBancor(uint256 amountIn, uint256 amountOutMin) private {
  bancorNetwork.convertByPath{value: msg.value}(_getPathForBancor(), amountIn, amountOutMin, address(0), address(0), 0);
}
  
function _getPathForBancor() private pure returns (address[] memory) {
    address[] memory path = new address[](3);
    path[0] = BANCOR_ETH_ADDRESS;
    path[1] = BANCOR_ETHBNT_POOL;
    path[2] = BNT;
    
    return path;
}

Our function to trade on Banchor is basically self-explanatory. We obtained the addresses for the path and bancor network from our example transaction.

2. Trading on Sushi

IUniswapV2Router02 private constant sushiRouter = IUniswapV2Router02(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506);
address private constant INJ = 0x9108Ab1bb7D054a3C1Cd62329668536f925397e5;

function _tradeOnSushi(uint256 amountIn, uint256 amountOutMin, uint256 deadline) private {
    address recipient = address(this);
      
    sushiRouter.swapExactTokensForTokens(
        amountIn,
        amountOutMin,
        _getPathForSushiSwap(),
        recipient,
        deadline
    );
}

function _getPathForSushiSwap() private pure returns (address[] memory) {
    address[] memory path = new address[](2);
    path[0] = BNT;
    path[1] = INJ;
    
    return path;
}

Then we can use swapExactTokensForTokens to swap BNT to INJ. The path simply consists of the tokens. We received the router address from our example transaction.

3. Trading on Uniswap

IUniswapRouter private constant uniswapRouter = IUniswapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564);
address private constant DAI = 0xaD6D458402F60fD3Bd25163575031ACDce07538D;

function _tradeOnUniswap(uint256 amountIn, uint256 amountOutMin, uint256 deadline) private {
    address tokenIn = INJ;
    address tokenOut = DAI;
    uint24 fee = 3000;
    address recipient = msg.sender;
    uint160 sqrtPriceLimitX96 = 0;
    
    ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams(
        tokenIn,
        tokenOut,
        fee,
        recipient,
        deadline,
        amountIn,
        amountOutMin,
        sqrtPriceLimitX96
    );
    
    uniswapRouter.exactInputSingle(params);
    uniswapRouter.refundETH();
    
    // refund leftover ETH to user
    (bool success,) = msg.sender.call{ value: address(this).balance }("");
    require(success, "refund failed");
}

4. Bringing it all together

We need to approve the SushiSwap contract to use our BNT and the Uniswap contract to use our INJ. It's more gas-efficient to do this only once on the deployment, so put it in your constructor:

constructor() {
  IERC20(BNT).safeApprove(address(sushiRouter), type(uint256).max);
  IERC20(INJ).safeApprove(address(uniswapRouter), type(uint256).max);
}

Now we have everything we need. Let's create a multiSwap function.

function multiSwap(uint256 deadline, uint256 amountOutMinUniswap) external payable {
    uint256 amountOutMinBancor = 1;
    uint256 amountOutMinSushiSwap = 1;

    _tradeOnBancor(msg.value, amountOutMinBancor);
    _tradeOnSushi(IERC20(BNT).balanceOf(address(this)), amountOutMinSushiSwap, deadline);
    _tradeOnUniswap(IERC20(INJ).balanceOf(address(this)), amountOutMinUniswap, deadline);
}

As you can see swapping the tokens now is easy. For Bancor and SushiSwap we don't care how many tokens we receive, so we put the min values to 1. The only thing that matters is how many DAI tokens we receive in the last swap. This value is passed from the outside as well as the deadline as a UNIX timestamp. If you don't care when the trade is executed, simply pass a high deadline value.

Now how do you obtain a reasonable amountOutMinUniswap value? To help us here we can create a second function only meant to be called as view function:


// meant to be called as view function
function multiSwapPreview() external payable returns(uint256) {
    uint256 daiBalanceUserBeforeTrade = IERC20(DAI).balanceOf(msg.sender);
    uint256 deadline = block.timestamp + 300;
    
    uint256 amountOutMinBancor = 1;
    uint256 amountOutMinSushiSwap = 1;
    uint256 amountOutMinUniswap = 1;
    
    _tradeOnBancor(msg.value, amountOutMinBancor);
    _tradeOnSushi(IERC20(BNT).balanceOf(address(this)), amountOutMinSushiSwap, deadline);
    _tradeOnUniswap(IERC20(INJ).balanceOf(address(this)), amountOutMinUniswap, deadline);
    
    uint256 daiBalanceUserAfterTrade = IERC20(DAI).balanceOf(msg.sender);
    return daiBalanceUserAfterTrade - daiBalanceUserBeforeTrade;
}

But note that we didn't declare this as a view function, but do not call this function on-chain. It's still meant to be called as a view function, but since it's using non-view functions under the hood to compute the result, it's not possible to declare it a view function itself (Solidity feature request?). Use for example Web3's call() functionality to read the result in the frontend.

Now we can call multiSwapPreview in our frontend. To increase the chances that the transaction won't get reverted, we can decrease the estimated amount of DAI received by a little bit:

const estimatedDAI = (await myContract.multiSwapPreview({ value: ethAmount }).call())[0];
const amountOutMinUniswap = estimatedDAI * 0.96;

Now we only need one transaction for the whole swap!

MultiSwap Etherscan

You can find a fully working example for the trade here. Once you have mastered it on a testnet, you can repeat the process for mainnet. If you dont want to spend extra ETH for the manual transactions, you can inspect the transaction data and contract address before submitting anything. You mostly just need to change the contract address.

Money Saving Meme

Markus Waas

Solidity Developer

More great blog posts from Markus Waas

© 2024 Solidity Dev Studio. All rights reserved.

This website is powered by Scrivito, the next generation React CMS.