How to arbitrage with Solidity

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

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!

Ropsten Transaction Hash (Txhash) Details | 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.