Smart Contract Arbitrage — Calldata Pass-through, ABI Encode / Decode, Flexible Fallback
Take a Byte
As I completed my exploration of uniswapV2-style atomic arbitrage in March, I wrote a fairly intricate smart contract that implements the joeCall() callback for arbitrage through TraderJoe liquidity pools. The same contract could be used to implement the uniswapV2Call()
callback for SushiSwap, and pangolinCall()
for Pangolin.
I’m always learning, so even if I stop writing about a subject for a time you can be sure I’m still watching for ways to improve.
Last month I found a very interesting post called The 0 to 1 Guide for MEV by well-known searcher 0xmebius. There’s a ton of great info in there, so I highly recommend reading through it.
One point in particular stood out to me:
NEVER use storage. Access variables through memory and calldata only. Storage reads and writes are excruciatingly expensive gas wise and are almost always unnecessary.
I wasn’t sure immediately how to implement this advice, so I went back to the UniswapV2Pair contract on GitHub and re-read. One thing stood out to me that I had not noticed.
# LINE 172 if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call( msg.sender, amount0Out, amount1Out, data );
I’ve written about this line before, but only to highlight the importance of the call to swap()
receiving an appropriate input for data
that would trigger the callback function. I simply set it to b'x'
and went on my merry way, unaware that it could be used in very clever ways.
I never noticed that the input calldata was actually passed back to the calling contract!
I’m pleased to report that this is precisely the method that allows us to eliminate the use of storage variables. It took a lot of hacking around and learning about EVM, but we’ve done it fam.
Calldata
What the hell is calldata anyway? I explored it a bit in the previous two posts about the single-use and multi-payload bundle executor. Now let’s do a deeper dive to figure out what it is, how it’s built, and how we can manipulate it.
I’ve pieced a lot of this stuff together by reading through these resources:
I won’t attempt to cover everything here because it’s just too dense, but here is the high level summary:
Calldata is a long string of bytes with a predictable, deterministic structure. We are used to seeing familiar sounding names (functions) with inputs (named arguments with values). However EVM does not see any of that. It simply sees a signed transaction sent to an address, with calldata. The calldata allows EVM to determine which function is being executed and the data formats and order of inputs for that function.
Take the WAVAX deposit()
example, where this:
deposit()
becomes
'0xd0e30db0'
Calldata has a predictable format. It always begins with a 4-byte function selector. The function selector determined by taking the keccak256 hash of the function prototype (the name of the function with all input types, without names), and trimming all but the first 4 bytes.
As another example, let’s take the uniswapV2Call()
. To obtain the function selector, transform this
uniswapV2Call(address,uint256,uint256,bytes)
into
'0x10d1e85c'
Encoding Calldata
As for the calldata portion, we can use a very helpful Python module called eth_abi. I’ve used this previously to decode calldata, but it can be used to encode it as well.
Install it with pip install eth_abi
, then open a console to try it out.
Here let’s try encoding the calldata for uniswapV2Call using the following values:
address =
0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87
uint256 =
1000000
uint256 =
0
bytes =
b''
>>> import eth_abi
>>> eth_abi.encode_single(
'(address,uint256,uint256,bytes)',
(
'0x3194cBDC3dbcd3E11a07892e7bA5c3394048Cc87',
1_000_000,
0,
b'bowtieddevil'
)
).hex()
'0000000000000000000000003194cbdc3dbcd3e11a07892e7ba5c3394048cc8700000000000000000000000000000000000000000000000000000000000f424000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000c626f7774696564646576696c0000000000000000000000000000000000000000'
The length of this byte string is 384 characters (192 bytes, since a hex byte is represented by two characters). 192 is a perfect multiple of 32 (32 x 6 = 192).
You’ll see 32 bytes everywhere once you start digging into calldata. 32 bytes (equal to 256 bits) is the “word size” for the EVM, which means that all data structures and stack registers are 32 bytes wide. By default, all stored data structures shorter than 32 bytes are “padded” with extra zeroes to fit inside the word, and all data structures longer than 32 bytes are broken into 32 byte chunks.
The process of building calldata for a series of different inputs is called serialization.
Here are the 32 byte chunks of our calldata:
0000000000000000000000003194cbdc3dbcd3e11a07892e7ba5c3394048cc87
00000000000000000000000000000000000000000000000000000000000f4240
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000080
000000000000000000000000000000000000000000000000000000000000000c
626f7774696564646576696c0000000000000000000000000000000000000000
Chunk #1 should look familiar. It’s a 32-byte padded version of our address.
Chunk #2 is the hexadecimal representation of the number 1,000,000. You can verify it by entering int('00000000000000000000000000000000000000000000000000000000000f4240', 16)
in Python.
Chunk #3 is the hexadecimal representation of the number 0.
Chunk #4 is a marker for the “dynamic” data bytes
data type. A dynamic type may fit inside a 32 byte word, but it might not, depending on the length of the input. These structures begin with a 32 byte “offset” value where a single-word data type would appear in the calldata. Additional 32 byte words are appended at the offset value that specify:
The length of the value
The value itself
Chunks 4 through 6 are:
4: offset =
128
5: length =
12
6: value =
'626f7774696564646576696c'
(with end-padding to 32 bytes as needed)
You can convert the value of 626f7774696564646576696c
to an alphanumeric string similarly:
>>> bytes.fromhex('626f7774696564646576696c0000000000000000000000000000000000000000')
b'bowtieddevil\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
Working With Calldata in Vyper
Before proceeding, please note that a critical function _abi_decode()
is not available in Vyper 0.3.3 but was added three weeks ago. It will be part of version 0.3.4, which is not yet released.
Version 0.3.4 has some bugs that will be ironed out, but nothing that keeps us from using it now inside a virtual environment.
I recommend installing the latest Vyper inside a dedicated virtual environment using pip install git+https://github.com/vyperlang/vyper
Confirm that it’s installed with vyper --version
and then let’s build a new contract!
The Plan
We will build a contract that performs these steps:
Initiate our flash borrow arbitrage with a function that accepts an array of payloads with format (address, calldata byte string, message value)
Encodes a byte string using
_abi_encode()
that will be processed laterCalls the UniswapV2Pair contract
swap()
function with the encoded byte stringInside the callback function, use
_abi_decode()
to pull out the input parameters from the pair contract, and the original encoded byte stringDecode the byte string into the original payload format (address, bytes, message value) values
Feed those payloads into
raw_call()
Profit!