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.
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.
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:
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
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:
>>> 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:
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 =
5: length =
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!
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 later
Calls the UniswapV2Pair contract
swap()function with the encoded byte string
Inside the callback function, use
_abi_decode()to pull out the input parameters from the pair contract, and the original encoded byte string
Decode the byte string into the original payload format (address, bytes, message value) values
Feed those payloads into