Short ABIs for Calldata Optimization
Introduction
In this article, you learn about optimistic rollups, the cost of transactions on them, and how that different cost structure requires us to optimize for different things than on the Ethereum Mainnet. You also learn how to implement this optimization.
Full disclosure
I'm a full time Optimism employee, so examples in this article will run on Optimism. However, the technique explained here should work just as well for other rollups.
Terminology
When discussing rollups, the term 'layer 1' (L1) is used for Mainnet, the production Ethereum network. The term 'layer 2' (L2) is used for the rollup or any other system that relies on L1 for security but does most of its processing off-chain.
How can we further reduce the cost of L2 transactions?
Optimistic rollups have to preserve a record of every historical transaction so that anybody will be able to go through them and verify that the current state is correct. The cheapest way to get data into the Ethereum Mainnet is to write it as calldata. This solution was chosen by both Optimism and Arbitrum.
Cost of L2 transactions
The cost of L2 transactions is composed of two components:
- L2 processing, which is usually extremely cheap
- L1 storage, which is tied to Mainnet gas costs
As I'm writing this, on Optimism the cost of L2 gas is 0.001 Gwei. The cost of L1 gas, on the other hand, is approximately 40 gwei. You can see the current prices here.
A byte of calldata costs either 4 gas (if it is zero) or 16 gas (if it is any other value). One of the most expensive operations on the EVM is writing to storage. The maximum cost of writing a 32-byte word to storage on L2 is 22100 gas. Currently, this is 22.1 gwei. So if we can save a single zero byte of calldata, we'll be able to write about 200 bytes to storage and still come out ahead.
The ABI
The vast majority of transactions access a contract from an externally-owned account. Most contracts are written in Solidity and interpret their data field per the application binary interface (ABI).
However, the ABI was designed for L1, where a byte of calldata costs approximately the same as four arithmetic operations, not L2 where a byte of calldata costs more than a thousand arithmetic operations. For example, here is an ERC-20 transfer transaction. The calldata is divided like this:
Section | Length | Bytes | Wasted bytes | Wasted gas | Necessary bytes | Necessary gas |
---|---|---|---|---|---|---|
Function selector | 4 | 0-3 | 3 | 48 | 1 | 16 |
Zeroes | 12 | 4-15 | 12 | 48 | 0 | 0 |
Destination address | 20 | 16-35 | 0 | 0 | 20 | 320 |
Amount | 32 | 36-67 | 17 | 64 | 15 | 240 |
Total | 68 | 160 | 576 |
Explanation:
- Function selector: The contract has less than 256 functions, so we can distinguish them with a single byte. These bytes are typically non-zero and therefore cost sixteen gas.
- Zeroes: These bytes are always zero because a twenty-byte address does not require a thirty-two-byte word to hold it.
Bytes that hold zero cost four gas (see the yellow paper, Appendix G,
p. 27, the value for
G
txdatazero
). - Amount: If we assume that in this contract
decimals
is eighteen (the normal value) and the maximum amount of tokens we transfer will be 1018, we get a maximum amount of 1036. 25615 > 1036, so fifteen bytes are enough.
A waste of 160 gas on L1 is normally negligible. A transaction costs at least 21,000 gas, so an extra 0.8% doesn't matter.
However, on L2, things are different. Almost the entire cost of the transaction is writing it to L1.
In addition to the transaction calldata, there are 109 bytes of transaction header (destination address, signature, etc.).
The total cost is therefore 109*16+576+160=2480
, and we are wasting about 6.5% of that.
Reducing costs when you don't control the destination
Assuming that you do not have control over the destination contract, you can still use a solution similar to this one. Let's go over the relevant files.
Token.sol {#token.sol}
This is the destination contract.
It is a standard ERC-20 contract, with one additional feature.
This faucet
function lets any user get some token to use.
It would make a production ERC-20 contract useless, but it makes life easier when an ERC-20 exists only to facilitate testing.
1 /**2 * @dev Gives the caller 1000 tokens to play with3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucet7નકલ કરો
You can see an example of this contract being deployed here.
CalldataInterpreter.sol {#calldatainterpreter.sol}
This is the contract that transactions are supposed to call with shorter calldata. Let's go over it line by line.
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";6નકલ કરો
We need the token function to know how to call it.
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;4નકલ કરો
The address of the token for which we are a proxy.
12 /**3 * @dev Specify the token address4 * @param tokenAddr_ ERC-20 contract address5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructor11બધું બતાવોનકલ કરો
The token address is the only parameter we need to specify.
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {3નકલ કરો
Read a value from the calldata.
1 uint _retVal;23 require(length < 0x21,4 "calldataVal length limit is 32 bytes");56 require(length + startByte <= msg.data.length,7 "calldataVal trying to read beyond calldatasize");8નકલ કરો
We are going to load a single 32-byte (256-bit) word to memory and remove the bytes that aren't part of the field we want. This algorithm doesn't work for values longer than 32 bytes, and of course we can't read past the end of the calldata. On L1 it might be necessary to skip these tests to save on gas, but on L2 gas is extremely cheap, which enables whatever sanity checks we can think of.
1 assembly {2 _retVal := calldataload(startByte)3 }4નકલ કરો
We could have copied the data from the call to fallback()
(see below), but it is easier to use Yul, the assembly language of the EVM.
Here we use the CALLDATALOAD opcode to read bytes startByte
to startByte+31
into the stack.
In general, the syntax of an opcode in Yul is <opcode name>(<first stack value, if any>,<second stack value, if any>...)
.
12 _retVal = _retVal >> (256-length*8);3નકલ કરો
Only the most significant length
bytes are part of the field, so we right-shift to get rid of the other values.
This has the added advantage of moving the value to the right of the field, so it is the value itself rather than the value times 256something.
12 return _retVal;3 }456 fallback() external {7નકલ કરો
When a call to a Solidity contract does not match any of the function signatures, it calls the fallback()
function (assuming there is one).
In the case of CalldataInterpreter
, any call gets here because there are no other external
or public
functions.
1 uint _func;23 _func = calldataVal(0, 1);4નકલ કરો
Read the first byte of the calldata, which tells us the function. There are two reasons why a function would not be available here:
- Functions that are
pure
orview
don't change the state and don't cost gas (when called off-chain). It makes no sense to try to reduce their gas cost. - Functions that rely on
msg.sender
. The value ofmsg.sender
is going to beCalldataInterpreter
's address, not the caller.
Unfortunately, looking at the ERC-20 specifications, this leaves only one function, transfer
.
This leaves us with only two functions: transfer
(because we can call transferFrom
) and faucet
(because we can transfer the tokens back to whoever called us).
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 if (_func == 1) {7નકલ કરો
A call to faucet()
, which doesn't have parameters.
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }5નકલ કરો
After we call token.faucet()
we get tokens. However, as the proxy contract, we do not need tokens.
The EOA (externally owned account) or contract that called us does.
So we transfer all of our tokens to whoever called us.
1 // transfer (assume we have an allowance for it)2 if (_func == 2) {3નકલ કરો
Transferring tokens requires two parameters: the destination address and the amount.
1 token.transferFrom(2 msg.sender,3નકલ કરો
We only allow callers to transfer tokens they own
1 address(uint160(calldataVal(1, 20))),2નકલ કરો
The destination address starts at byte #1 (byte #0 is the function). As an address, it is 20-bytes long.
1 calldataVal(21, 2)2