L2 Optimizooooors: Perpetual Protocol and 1inch Network's Aggregation Protocol
June 20, 2023 / Alex Keating
In April, we began to build calldata-optimized routers for several gas guzzling protocols on Layer 2 thanks to a grant from the Ethereum Foundation. We picked 2 protocols: 1inch Network and Perpetual Protocol. Both of these protocols are top consumers of layer 1 ETH and provided many opportunities for optimization.
Perpetual Protocol allows users to create and manage perpetual swaps. The core functionalities we optimized were creating positions, closing positions and depositing tokens into their protocol. These pieces of functionality cover around 70% of their transactions on Optimism.For 1inch Network, we focused on optimizing their aggregation protocol which facilitates cost-efficient and secure swap transactions across multiple liquidity sources. We focused on optimizing their swap functionality for both their V4 and V5 aggregation protocol. These functions cover about 85% of their transactions on Optimism.
Before diving in, here's a quick refresher on why optimizing calldate reduces gas fees on layer 2. Layer 2 (L2) networks share security with mainnet and must publish transaction data to Layer 1 (L1). As a result, L2 users still pay some L1 gas fees when executing transactions. Since L1 gas fees are much more expensive than L2 gas fees we can get significant savings by decreasing the size of the transaction data that is published on L1. To learn more, checkout our previous post on calldata optimization.
Optimizing Routers
When optimizing a router we first look at the function signature to see which parameters or fields within a struct can either be removed or reduced in size. To demonstrate the techniques we used for optimization, we will take a look at Perpetual Protocol's openPositionFor
method.
Below is the method signature as it appears in the Perpetual Protocol codebase. Our router will ultimately call this method, but will require much less calldata from the originating transaction.
struct OpenPositionParams {
address baseToken;
bool isBaseToQuote;
bool isExactInput;
uint256 amount;
uint256 oppositeAmountBound;
uint256 deadline;
uint160 sqrtPriceLimitX96;
bytes32 referralCode;
}
function openPositionFor(address trader, OpenPositionParams memory params)
external
override
whenNotPaused
nonReentrant
checkDeadline(params.deadline)
returns (
uint256 base,
uint256 quote,
uint256 fee
)
Our first optimization opportunity allows us to save 4 bytes by having a contract with a fallback function rather than specify a function selector. Next, we can remove the need for a user to pass in the baseToken
address by having a separate open position contract for each token saving us 20 bytes. For example, if a user needs to open a VETH
position they will call the VETH router.
Moving on to isBaseToQuote
and isExactInput
, we can save 1 byte by creating an internal method that handles a combination of these two variables, and having the user pass in a uint8
to indicate which combinations of methods to use. It also allows us to handle other functions within our callback that take in a different number of parameters e.g. closing a position.
There are a couple different strategies for optimizing integers, but we went with changing amount
from a uint256
to a uint96
. We save 20 bytes of calldata, but prevent users from opening extremely large positions. A user cannot open a position greater than 79 billion dollars if a token is worth a dollar or 790 million dollars if a token is worth a single cent. We took a similar approach with deadline
. We reduced the size of the timestamp to a uint32
which has a max year of 2106, and saving us 28 bytes.
Lastly, we made the referralCode
an immutable variable within the contracts preventing users from being able to pass it in saving us another 32 bytes. With all of the variables we mentioned we also require a client to call the fallback with abi.encodePacked
saving us calldata used for padding. Our final fallback
looks like below.
/// @notice Creates or closes a position depending on the provided `funcId`. Calldata is
/// conditionally decoded based on the `funcId`.
fallback() external payable {
uint8 funcId = uint8(bytes1(msg.data[0:1]));
uint256 amount;
uint256 oppositeAmountBound;
uint256 deadline;
uint160 sqrtPriceLimitX96;
if (funcId != 5) {
sqrtPriceLimitX96 = uint160(bytes20(msg.data[1:21]));
deadline = uint256(uint32(bytes4(msg.data[21:25])));
amount = uint256(uint96(bytes12(msg.data[25:37])));
oppositeAmountBound = uint256(uint96(bytes12(msg.data[37:49])));
} else {
sqrtPriceLimitX96 = uint160(bytes20(msg.data[1:21]));
deadline = uint256(uint32(bytes4(msg.data[21:25])));
oppositeAmountBound = uint256(uint96(bytes12(msg.data[25:37])));
}
if (funcId == 1) _openShortOutput(amount, oppositeAmountBound, sqrtPriceLimitX96, deadline);
else if (funcId == 2) _openShortInput(amount, oppositeAmountBound, sqrtPriceLimitX96, deadline);
else if (funcId == 3) _openLongOutput(amount, oppositeAmountBound, sqrtPriceLimitX96, deadline);
else if (funcId == 4) _openLongInput(amount, oppositeAmountBound, sqrtPriceLimitX96, deadline);
else if (funcId == 5) _closePosition(oppositeAmountBound, sqrtPriceLimitX96, deadline);
else revert FunctionDoesNotExist();
}
The techniques used for this method in Perpetual Protocol are similar to those used for other methods, and for functions in 1inch as well.
Conclusion
Through these optimizations, we were able to save up to 35% on gas fees for some functions. This repo contains the perpetual calldata-optimized routers and this repo contains the 1inch routers.
While we've built and tested these routers with care, please note that they are unaudited and come with absolutely no guarantees about safety. Use them at your own risk. Information about savings from our benchmarks can be found at this spread sheet.
Get in Touch
If you are interested in having gas-optimized routers written for your protocol, please reach out to us!