Advanced MultiSwap: How to better 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. We have previously looked at 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

You can find it here. But this time we are going to upgrade it. Instead of having to deploy a new contract for every MultiSwap, we can build a generic MultiSwap contract based on the same concept as before. Then you deploy the contract only once and use it for any arbitraging you like.

Buy High Sell Low Meme

To do that, we will still use the same trade from the previous MultiSwap as example. So how does this new advanced MultiSwap look like?

Advanced MultiSwap

Solidity Meme
// SPDX-License-Identifier: MIT
pragma solidity 0.8.10;

contract AdvancedMultiTrade {    
  function swap(address[] memory tos, bytes[] memory data) external payable {
    require(tos.length > 0 && tos.length == data.length, "Invalid input");

    for(uint256 i; i < tos.length; i++) {
      (bool success,bytes memory returndata) = tos[i].call{value: address(this).balance, gas: gasleft()}(data[i]);
      require(success, string(returndata));
    }
  }

  receive() payable external {}
}
That's all folks

Wow that was easy. How does that work?

What happens here is in Solidity we can call other contracts directly only given the address and the transaction data. In other words, we can have a smart contract where we don't have to predefine which functions on which DEX's are called. Now we can simply pass this information from the outside!

We simply iterate over all target addresses and use the low-level call: tos[i].call{value: address(this).balance, gas: gasleft()}(data[i])

Note that we pass address(this).balance as value, so any ETH in the contract will always be fully forwarded to the call. And we use gasleft() to forward any remaining gas.

That's great, but how can you use this???

We did indeed shift the complexity from the contract code itself onto how you interact with it. What you need to know is

  • which contract addresses do I want to call
  • which function on that address I want to call
  • and which parameters I want to pass to that function.

And to make matters worse, all of this needs to be in low level encoded data.

The address itself should be easy to get. In our old MultiSwap example for Ropsten on Banchor that was 0xb3fa5dcf7506d146485856439eb5e401e0796b5d. Now we need to figure out which function to call. On a low-level call this is done from the 4-bytes function selector.

One little trick on how to get that is adding the following function to your contract:

function getSelector(string calldata _func) external pure returns (bytes4) {
    return bytes4(keccak256(bytes(_func)));
}

And now simply call the function with the function name and parameters:

getSelector("convertByPath(address[],uint256,uint256,address,address,uint256)")

Great, now we have the function selector which is 0xb77d239b. Now last we need to encode the data for the function parameters.

Once you fully automate your arbitrage, you will want to do this with your favorite choice of library, like Web3.js, Web3.py or ethers.js. Now for testing, you could use this handy online tool here.

Enter the function parameter types separated by comma as well as the values.

  • address[],uint256,uint256,address,address,uint256
  • [0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE,0x1aCE5DD13Ba14CA42695A905526f2ec366720b13,0xF35cCfbcE1228014F66809EDaFCDB836BFE388f5],1000,1,0x0000000000000000000000000000000000000000,0x0000000000000000000000000000000000000000,0

See the previous MultiSwap example for the details on this here.

ABI Encode

Now we the data required for our first call. Simply copy & paste the encoded string after our function selector and we have the first data field complete:

tos: ["0xb3fa5dcf7506d146485856439eb5e401e0796b5d"]
data: ["0xb77d239b00000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000001ace5dd13ba14ca42695a905526f2ec366720b13000000000000000000000000f35ccfbce1228014f66809edafcdb836bfe388f5"]

Constructing the other two calls

The other two calls to SushiSwap and Uniswap follow the exact same principle. 

getSelector("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)")

will give us the function selector 0x38ed1739. And for the parameter encoding we use:

  • uint256,uint256,address[],address,uint256
  • 5000000000000000000,2476572156145166857,[0xF35cCfbcE1228014F66809EDaFCDB836BFE388f5,0x9108Ab1bb7D054a3C1Cd62329668536f925397e5],0x2f0cd179a4f3d47eac5a8d22ccb5d76621212616

Note that as recipient address we used the MultiSwap address itself (0x2f0cd179a4f3d47eac5a8d22ccb5d76621212616). Obviously you'll only have this once the contract is deployed, not beforehand. And we of course need it to be the contract itself as recipient, so we can use the funds for the last swap.

And that will give us the second call data:

tos: ["0x1b02da8cb0d097eb8d57a175b88c7d8b47997506"]
data: ["0x38ed173900000000000000000000000000000000000000000000000000005af3107a4000000000000000000000000000000000000000000000000000000000b67bfdb61700000000000000000000000000000000000000000000000000000000000000a00000000000000000000000002f0cd179a4f3d47eac5a8d22ccb5d76621212616000000000000000000000000000000000000000000000000000000006705933a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000f35ccfbce1228014f66809edafcdb836bfe388f50000000000000000000000009108ab1bb7d054a3c1cd62329668536f925397e5"]

And the same thing for the Uniswap call:

getSelector("exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))")

will give us the function selector 0x414bf389. And for the parameter encoding we use:

  • address,address,uint24,address,uint256,uint256,uint256,uint160
  • 0x9108Ab1bb7D054a3C1Cd62329668536f925397e5,0xaD6D458402F60fD3Bd25163575031ACDce07538D,3000,0x15ae150d7dC03d3B635EE90b85219dBFe071ED35,1728418389,5000,1,0

Note that since this is the last call, we used our own address as recipient, not the MultiSwap smart contract address.

Which will give us the third and final call data:

tos: ["0xe592427a0aece92de3edee1f18e0157c05861564"]
data: ["0x414bf3890000000000000000000000009108ab1bb7d054a3c1cd62329668536f925397e5000000000000000000000000ad6d458402f60fd3bd25163575031acdce07538d0000000000000000000000000000000000000000000000000000000000000bb800000000000000000000000015ae150d7dc03d3b635ee90b85219dbfe071ed350000000000000000000000000000000000000000000000000000000067059255000000000000000000000000000000000000000000000000000000000000138800000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000"]<br>

And lastly merge all three calls into one:

tos: ["0xb3fa5dcf7506d146485856439eb5e401e0796b5d","0x1b02da8cb0d097eb8d57a175b88c7d8b47997506","0xe592427a0aece92de3edee1f18e0157c05861564"]
data: ["0xb77d239b00000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0000000000000000000000001ace5dd13ba14ca42695a905526f2ec366720b13000000000000000000000000f35ccfbce1228014f66809edafcdb836bfe388f5","0x38ed173900000000000000000000000000000000000000000000000000005af3107a4000000000000000000000000000000000000000000000000000000000b67bfdb61700000000000000000000000000000000000000000000000000000000000000a00000000000000000000000002f0cd179a4f3d47eac5a8d22ccb5d76621212616000000000000000000000000000000000000000000000000000000006705933a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000f35ccfbce1228014f66809edafcdb836bfe388f50000000000000000000000009108ab1bb7d054a3c1cd62329668536f925397e5","0x414bf3890000000000000000000000009108ab1bb7d054a3c1cd62329668536f925397e5000000000000000000000000ad6d458402f60fd3bd25163575031acdce07538d0000000000000000000000000000000000000000000000000000000000000bb800000000000000000000000015ae150d7dc03d3b635ee90b85219dbfe071ed350000000000000000000000000000000000000000000000000000000067059255000000000000000000000000000000000000000000000000000000000000138800000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000"]

What about the token.approve?

Good catch. We are indeed requiring the token approvals from the contract to the DEX contract. How you will integrate those the best will depend on your use case. If you already know which tokens you will exclusively trade, just put them into the constructor:

import {IERC20, SafeERC20} from "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/utils/SafeERC20.sol";

contract AdvancedMultiTrade {
    using SafeERC20 for IERC20;
    
    constructor() {
        IERC20(0xF35cCfbcE1228014F66809EDaFCDB836BFE388f5).safeApprove(address(0x1b02dA8Cb0d097eB8D57A175b88c7D8b47997506), type(uint256).max);
        IERC20(0x9108Ab1bb7D054a3C1Cd62329668536f925397e5).safeApprove(address(0xE592427A0AEce92De3Edee1F18E0157C05861564), type(uint256).max);
    }
}

But of course those approvals are also just calls itself. So you could just make them part of the function call arrays. 

Lastly, just pass all calls to the contract (make sure to pass enough ETH as well for our example) and we only need one transaction for the whole swap!

MultiSwap Advanced Tx

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.