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.
/*
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.