In a race to the bottom (of gas use, naturally), only the most skillful degen wins.
I have been chasing V2 arbs all week and mostly not getting them. Sometimes I get lucky and snake one that nobody has noticed, but the obvious ones slip through my fingers. Comparing my TX with the winner often reveals that they have lower gas usage, lower profit margins, and across the board less calldata.
An issue with the general-purpose payload executor is that all values are encoded with standard word lengths, which to the non-nerd means that each value is padded with a lot of empty space to fit into a length that the EVM can decode without knowing what is inside.
Because of this padding, decoding and executing these generic payloads takes more gas than a more optimized (read: single-purpose) function.
There is an upper limit on how competitive I can be with the bundle executor, so I’m exploring ways to decrease the size of my calldata. Smaller calldata, lower gas use, better chance to win more arbs. What’s not to like?
Packed Calldata
To begin, let’s play with our old friend eth_abi
on the console. It has a very helpful class of functions for encoding and decoding packed calldata. We first encountered packed calldata in the UniswapV3 lesson covering the Router when we pack-encoded the path
parameter.
Let’s imagine that we want to write a very optimized function to perform a swap at a UniswapV2 liquidity pool. Recall that the function swap
takes four arguments:
uint256 amount0Out
uint256 amount1Out
address to
bytes calldata
We know our to
destination (our smart contract), and we are not doing any sort of flash borrow, so the functionality of sending data back through calldata
is not necessary. So we only need inputs for the two amounts to provide all necessary arguments to the function. We also need to know the address of the pool, which brings our total variable count to three.
Encoding Calldata with eth_abi
What’s the most efficient way to encode this data? First let’s see how to encode it the standard way (inefficient but predictable):
>>> import eth_abi
>>> eth_abi.encode(['address','uint256','uint256'],['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',100_000,200_000]).hex()
'000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40'
I’ve used the WETH address as an example, nothing special.
You can see how the resulting bytestring is quite long, 96 bytes to be exact. Each value in the encoded bytestring takes a single word (32 bytes), and you can clearly see a lot of zero padding.
Encoding Packed Calldata with eth_abi.packed
Now let’s try the same thing using the encode_packed
function:
>>> eth_abi.packed.encode_packed(['address','uint256','uint256'],['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',100_000,200_000]).hex()
'c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000030d40'
You’ll notice that the first part has gotten shorter, but the second and third parts are the same as before. Why? Because we have specified that the value type is uint256
, the resulting bytestring must be long enough to hold the maximum possible value for that type. A uint256
value has 256 bits, which is 32 bytes, thus the bytestring will be 32 hex characters long. For small numbers, most of those characters will be zero.
If we want to improve the efficiency of these numbers, we have to make some choices about integer bit depth.
First of all, do we need all 256 bits? Turns out we don’t. If you read the V2 Pair contract, you’ll find that the reserve0
and reserve1
are stored as uint112
.
If the pool can only hold a maximum reserve values of 2*112-1
, we gain nothing from supporting numbers as high as 2**256-1
. So as an easy first pass, we can save some space by encoding our numbers as uint112
values:
>>> eth_abi.packed.encode_packed(['address','uint112','uint112'],['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',100_000,200_000]).hex()
'c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000186a00000000000000000000000030d40'
Now you can see that the second and third values have become shorter, and the zero padding is shrinking.
This calldata is now 48 bytes long, exactly half of our starting calldata length. Pretty good!
Calldata Gas Use
The Ethereum Virtual Machine imposes a cost of 4 gas for every byte set to zero in calldata, and 16 gas for every non-zero byte (reference: Ethereum Yellow Paper). It is therefore in our best interest to minimize calldata length to save gas, especially when it can be done without affecting the fidelity of the input itself.
Slicing Calldata In Vyper
Whenever you provide packed calldata to a function in Vyper (or Solidity), you must decode it manually. The standard method of joining separate values together into calldata is called ABI encoding, and that process results in the extra zeroes but is reproducible everywhere without extra effort.
Decoding in a smart contract is typically done by slicing of pieces of the calldata bytestring and converting those pieces to usable values.
Vyper’s slice function accepts a byte string, a starting position, and a length. As of now, the start position of the slice can vary, but the length must be constant. I have asked the Vyper devs if supporting variable length slices is possible, but do not have resolution yet.
If you are providing a uint112
value in your packed calldata, you know that the byte length is 14 (112 / 8). If it appears at the start of your packed calldata, the starting position is zero and the length is 14.
Storing this as a variable is done like this:
var112: uint112 = slice(data, 0, 14)
However you cannot send this uint112
value into an external function that expects a uint256
(as defined by the UniswapV2Pair contract interface).
Vyper provides conversion functions to transform values. Converting a uint112
to uint256
is done like this:
var256: uint256 = convert(var112, uint256)
If you want to skip the middleman variable (recommended), you can do the conversion with the slice nested into the 1st argument:
var256: uint256 = convert(slice(data,0,14), uint256)
Smart Contract Implementation
We will write a very simple smart contract that does one thing in two ways. It will have two functions that each accept a single bytestring, generated by eth_abi
, decode the bytestring into an address/calldata payload, then execute it via raw_call
.
Both payloads will execute a WETH swap for some other token via a UniswapV2 pool. I will not optimize any part of the function or do any ownership checks, and the only difference will be the input and the necessary decoding to work with these differing-length inputs.
At the end, we will compare gas use and see if the extra work of decoding the packed calldata offsets the gas savings.
Begin by writing a simple contract with these two functions, plus a constructor that will automatically wrap WETH on deployment:
packed_calldata.vy