Calldata Optimizooooors: Saving Gas on L2s by Reducing Calldata
April 5, 2023 / Matt Solomon
Layer 2 (L2) networks share security with mainnet by publishing transaction data on Layer 1 (L1). As a result, L2 users still pay some L1 gas costs when executing transactions. Since L1 gas can be >25,000x more expensive than L2 gas, paying for L1 calldata dominates L2 transaction costs. With custom contracts that use less calldata than standard methods we significantly reduced transaction costs for users.
Last year, during the ETHOnline 2022 hackathon, we demonstrated the effectiveness of this approach by optimizing calldata for three real protocols running on Optimism. In this post, we'll introduce you to the techniques we used and the results we achieved during the hackathon. We'll also share our plans to build out production ready routers for the top gas-guzzling protocols used on Layer 2 protocols today, thanks to a grant from the Ethereum Foundation.
As mentioned, L2s publish all transaction data onto L1. A transaction to a contract, such as a token transfer or deposit into Aave, is composed of many parts:
- Sender's nonce.
- Gas limit.
- Gas price.
- ETH value being sent.
- Address of the contract being called, 20 bytes.
- Data to call on that contract, limited only by the total gas cost.
- Signature of the user sending the transaction.
Image via @VitalikButerin
Posting all of this data on L1 is where the bulk of L2 transaction costs come from.
If we can reduce the size of that transaction data, we can reduce user costs. But we really only have control over two of those parameters: the address of the contract being called, and the data to call on that contract. So let's leverage those.
The target address will always be 20 bytes, but we can change the target address to our own contract which is more efficient with it's calldata. By default in Solidity, calldata (inputs to functions) is ABI-encoded so it's very inefficient: if you want to pass a boolean of true into a method, that takes up 32 bytes even though it really only needs 1 bit. Similarly an address takes up 32 bytes even though 12 of those are wasted zero bytes!
We currently have calldata-optimized routers for three protocols: Aave, Superfluid, and Connext. For each, there is a factory contract which deploys the calldata-optimized routers for that protocol. That factory deploys a unique contract for every combination of methods and parameters that can be hardcoded.
For example, with Aave:
- Deposing ETH into Aave has a dedicated contract.
- Withdrawing ETH from Aave has a dedicated contract.
- Depositing USDC into Aave has a dedicated contract.
And so on. And similarly for Connext and Superfluid. Here's how this helps:
- You don't need to specify a function selector, which saves 4 bytes of calldata. By calling contract X, we know you're depositing USDC, and by calling contract Y, we know you're withdrawing USDC.
- You don't need to specify a token address, saving another 32 bytes (20 bytes of non-zero calldata). By calling contract X, we know you're interacting with USDC. Functions often take a recipient argument. For Aave the recipient is where the receipt tokens (on deposit) or the asset itself (on withdraw) gets sent, and for Connext it's where the bridged tokens should be sent. In cases like these, we assume the user wants to send the asset to themselves so a recipient address is not required. This removes an address from calldata and again saves 32 bytes (20 bytes of non-zero calldata).
Many protocols also require you to specify an amount of tokens, e.g. how many USDC do you want to deposit into Aave? To fully minimize calldata here, instead of specifying exact amounts, users specify amounts as percentages of their balance. If zero bytes of calldata are provided, the full user's balance is used.
Percentages are themselves implemented to minimize calldata as much as possible. Our contracts determine the percentage by inferring a denominator from the number of bytes passed in as calldata. For example:
- If one byte of calldata is provided the denominator is 255, which is the max value of a single byte.
- If two bytes of calldata are provided the denominator is 65,535, which is the max value of two bytes.
- And so on.
From there the routers compute the amount to use as
userBalance \* calldata / denominator. This lets you specify nearly any amount of tokens with just a few bytes of calldata. The tradeoff is precision: you may not be able to send exactly one token, and must tolerate a small deviation in the amount.
Some protocols may deem this tradeoff unacceptable. Others may require parameters that don't work with this pattern. One such case is Superfluid, which requires a flow rate input during flow creation. For Superfluid's flow rate (and for other inputs that can't be replaced with inferred percentages) the user specifies the amount as normal, but with all zero-padding removed. If you wanted to specify a value of 100 USDC, i.e.
100e6 with this method, the calldata would be
0x05f5e100. This is just 4 bytes, instead of the standard 32 bytes used by ABI-encoding.
There's one more option for amounts, too: simply reduce precision by reducing the number of decimals tokens have. For example, you probably don't need all 6 decimal places of USDC or all 18 decimal places of DAI, and usually one cent granularity is sufficient. So if someone passes in 0x64 to a USDC router, which is 100 in decimal, multiply by 1e6 and we know they wanted to deposit 100 USDC. We don't recommend this approach because it has a similar precision issue to the ratio approach, and figuring out how many decimals to truncate can be tricky and may need to be bespoke per-token.
Ethereum Foundation Grant for Production Routers
While the results we achieved during the hackathon were impressive, there is still work to be done to get contracts implementing these techniques up to the high standards required when managing real user funds. For this reason, we applied for and received a grant from the Ethereum Foundation to move the project forward.
In order to maximize the gas savings passed on to real users, we are analyzing the top gas-guzzling contracts on Optimism and Arbitrum. We've identified the top candidates for optimization and have begun prototyping routers for these protocols. We intend to bring 2-4 of these optimized router contracts to production, and hope to work with the protocol teams to see these optimizations integrated into their first party frontends.
If successful, we believe these techniques can cumulatively save users millions of dollars over time. We're grateful to the Ethereum Foundation for supporting these efforts to make DeFi more accessible and affordable for real users!
Get in Touch
If you are interested in having gas-optimized routers written for your protocol, please reach out to us!