Contents

Yul Smart Contract for Uniswap Arbitrage

June 13, 2022 | 6 min read

Some time ago I was building Ethereum arbitrage bot for Uniswap-based DEXes. The arbitrage method was to use flash swaps including triangular swaps and to use private transactions by Flashbots.

Using Solidity

I’ve built first version using Solidity. There a lot of examples how to do it, e.g. this. But the contract failed to work because competing contracts always set gas price higher: to such value that this contract will be unprofitable. So the contract wasn’t run even once.

Optimizing by using Yul

While decompiling competing contracts I’ve found that their calls don’t meet Solidity ABI. These contract weren’t using Solidity: they were using low-level languages Yul (Julia) or Yul+.

I’ve rewritten the contract from Solidity to Yul. Thanks to @0xmebius for inspiration.

uniswap_swapper.yul
/*
1. packing of call input:
- input data length:
        8: withdraw
        66: 2-swap
        96: 3-swap
- call ether value:
    bytes[0]:
        bits[0]: if out0 (vs out1) non-zero in pair0
        bits[1]: if out0 (vs out1) non-zero in pair1
        bits[2]: if out0 (vs out1) non-zero in pair2
- swap2 data:
        8b  last pair output amount == pair0 input amount + profit (up to 18 tokens total)
        20b pair0 address - 320gas
        8b  pair0 input amount (up to 18 tokens total)
        20b pair1 address - 320gas
        10b pair1 input amount == pair0 output amount (up to 1_208_925 tokens)
        --
        66b input data total - up to 1056 gas
- swap3 data:
        ...swap2 data
        20b pair2 address - 320gas
        10b pair2 input amount == pair1 output amount (up to 1_208_925 tokens)
        --
        96b input data total - up to 1056 gas

2. restictions to take into account in caller:
  - 10b max output amount for pair0 (buy)
  - 8b max weth input amount (up to 18 weth total) to pair0 (sell)
  - 8b max weth output amount (up to 18 weth total) from pair1 (buy)
  - need to sell WETH first
  - WETH sell amount <= contract's weth amount
*/

object "UniswapOptimized" {
    code {
        codecopy(0, dataoffset("runtime"), datasize("runtime"))
        setimmutable(0, "owner", caller())
        return(0, datasize("runtime"))
    }
    object "runtime" {
        code {
            // TODO: check block hash last X bits for reducing cost of miss on uncled txs
            if eq(loadimmutable("owner"), caller()) {
                switch calldatasize()
                case 96 /* swap3 */ {
                    let pair0Addr := shr(96, calldataload(8)) // extract 20b addr
                    let pair1Addr := shr(96, calldataload(36)) // extract 20b addr
                    let pair2Addr := shr(96, calldataload(66)) // extract 20b addr
                    transferWeth(pair0Addr, shr(192, calldataload(28)))
                    uniswap(pair0Addr, pair1Addr, and(callvalue(), 0x1), shr(176, calldataload(56)))
                    uniswap(pair1Addr, pair2Addr, and(callvalue(), 0x2), shr(176, calldataload(86)))
                    uniswap(pair2Addr, address(), and(callvalue(), 0x4), shr(192, calldataload(0)))
                }
                case 66 /* swap2 */ {
                    let pair0Addr := shr(96, calldataload(8)) // extract 20b addr
                    let pair1Addr := shr(96, calldataload(36)) // extract 20b addr
                    transferWeth(pair0Addr, shr(192, calldataload(28)))
                    uniswap(pair0Addr, pair1Addr, and(callvalue(), 0x1), shr(176, calldataload(56)))
                    uniswap(pair1Addr, address(), and(callvalue(), 0x2), shr(192, calldataload(0)))
                }
                case 8 /* withdraw */ {
                    widthdrawWeth() // transferWeth eats additional 30 gas for swap2/swap3 operations
                }
                default {
                    revert(0, 0)
                }
            }

            function widthdrawWeth() {
                mstore(0, shl(224, sig"function transfer(address to, uint value) external returns (bool)"))
                mstore(4, loadimmutable("owner"))
                mstore(36, shr(192, calldataload(0)) /* extract 8b = uint64 */)
                if iszero(call(gas(), 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, 0 /* wei */, 0 /* in pos */, 68 /* in len */, 0 /* out pos */, 0 /* out size */)) {
                    revert(0, 0)
                }
            }

            function transferWeth(toAddr, amount) {
                const weth := 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
                mstore(0, shl(224, sig"function transfer(address to, uint value) external returns (bool)"))
                mstore(4, toAddr)
                mstore(36, amount)
                if iszero(call(gas(), weth, 0 /* wei */, 0 /* in pos */, 68 /* in len */, 0 /* out pos */, 0 /* out size */)) {
                    revert(0, 0)
                }
            }

            function uniswap(pairAddr, toAddr, isToken0OutAppliedMask, tokenOutAmount) {
                switch isToken0OutAppliedMask
                case 0x0 {
                    swap(pairAddr, 0, tokenOutAmount, toAddr)
                }
                default {
                    swap(pairAddr, tokenOutAmount, 0, toAddr)
                }
            }

            function swap(pairAddr, amount0Out, amount1Out, toAddr) {
                // build swap call input
                mstore(0, shl(224, sig"function swap(uint amount0Out, uint amount1Out, address to, bytes data)"))
                mstore(4, amount0Out)
                mstore(36 /* 4+32 */, amount1Out)
                mstore(68 /* 4+32+32 */, toAddr)
                mstore(100 /* 4+32+32+32 */, 128 /* position of where length of "bytes data" is stored from first arg (excluding func signature) */)
                mstore(132, 0x0) // length of "bytes data"
                if iszero(call(gas(), pairAddr, 0 /* wei */, 0 /* in pos */, 164 /* in size */, 0 /* out pos */, 0 /* out size */)) {
                    revert(0, 0)
                }
            }
        }
    }
}

Note some optimizations here:

  • using calldatasize() to switch between functions (swap2, swap3, withdraw) instead of using 4-byte function selector. It works because different functions are guaranteed to have different call data size;
  • using 3 bits of Ethereum value callvalue() to pass configuration. We’re sending extra money to contract, but it’s cheaper than gas costs for extra bytes;
  • dense bit packing of call data, e.g. shr(96, calldataload(36));
  • using shortened 8-10-byte integers instead of 32-byte, see restictions to take into account in caller for details;

Calling contract

Building the contract call input looks like:

export const strip0xFromHex = (s: string): string => {
  const prefix = "0x"
  if (s.length < prefix.length || s.slice(0, prefix.length) !== prefix || s.length % 2 !== 0) {
    throw new Error(`invalid hex string ${s} of len ${s.length}`)
  }
  return s.slice(prefix.length)
}

export const strip0xFromAddr = (s: string): string => {
  const ret = strip0xFromHex(s)
  if (ret.length !== 20 * 2) {
    throw new Error(`invalid hex address ${s} of len ${s.length}`)
  }
  return ret
}

export const decimalToHex = (d: BigNumberish, padding: number) => {
  let hex = strip0xFromHex(BigNumber.from(d).toHexString()).toLowerCase()

  while (hex.length < padding) {
    hex = "0" + hex
  }

  return hex
}

export const buildUniswapSwapperInput = (params: TriangularSwapParams): UniswapSwapperInput => {
  let data = ""
  const isLen3 = params.pairAddrs.length === 3

  // 8b of last pair output amount
  data += decimalToHex(params.outAmounts[isLen3 ? 2 : 1], 8 * 2)

  // pair0 addr and 8b input amount
  data += strip0xFromAddr(params.pairAddrs[0]).toLowerCase()
  data += decimalToHex(params.pair0InAmount, 8 * 2)

  // pair1 addr and 10b input amount
  data += strip0xFromAddr(params.pairAddrs[1]).toLowerCase()
  data += decimalToHex(params.outAmounts[0], 10 * 2)

  if (isLen3) {
    // pair2 addr and 10b input amount
    data += strip0xFromAddr(params.pairAddrs[2]).toLowerCase()
    data += decimalToHex(params.outAmounts[1], 10 * 2)
  }

  if (data.length !== (isLen3 ? 96 : 66) * 2) {
    throw new Error(`invalid string writing: len of buffer is ${data.length}`)
  }

  return {
    data: `0x${data.toString()}`,
    value: BigNumber.from(
      ((params.needOutputToken0[0] ? 1 : 0) << 0) |
        ((params.needOutputToken0[1] ? 1 : 0) << 1) |
        ((isLen3 && params.needOutputToken0[2] ? 1 : 0) << 2)
    ),
  }
}

Optimizing using access lists

To be more gas-effective I’ve added using of very basic access lists.

export const buildAccessListForSwap = (params: TriangularSwapParams): ethersutils.AccessListish => {
  // TODO: add token transfer storage keys by using geth tracing or special rpc
  const uniswapV2PairStorageKeys = [
    "0x0000000000000000000000000000000000000000000000000000000000000006",
    "0x0000000000000000000000000000000000000000000000000000000000000007",
    "0x0000000000000000000000000000000000000000000000000000000000000008",
    "0x0000000000000000000000000000000000000000000000000000000000000009",
    "0x000000000000000000000000000000000000000000000000000000000000000a",
    "0x000000000000000000000000000000000000000000000000000000000000000c",
  ]
  return params.pairAddrs.map((pairAddr) => ({
    address: pairAddr,
    storageKeys: uniswapV2PairStorageKeys,
  }))
}

Results

Gas usage for 2-path swap became 128k and for 3-path swap 178k. With all these optimization I’ve earned ~$10 :) That was fun.


© 2022

Hi, my name is Denis Isaev and I'm a senior engineering manager at Yandex.

Contents