Skip to content

Instantly share code, notes, and snippets.

@zemse
Last active December 14, 2023 10:17
Show Gist options
  • Save zemse/bd65a0c853fd66216ad758cd740b8a18 to your computer and use it in GitHub Desktop.
Save zemse/bd65a0c853fd66216ad758cd740b8a18 to your computer and use it in GitHub Desktop.
Calldata Optimisation

Calldata Optimisation

Reducing gas costs for L2 users by optimising calldata.

Motivation

Transaction fees on L2s like Optimism and Arbitrum involve paying the calldata gas costs for the batch submission on L1. And it accounts for good amount of gas fees.

For example, breakup of $4.19 Uniswap Trade on Arbitrum (explorer):

  • L1 Fixed Cost: $1.77
  • L1 Calldata Cost: $2.30
  • L2 Computation: $0.12

You can see that, the calldata cost is over 50%.

Abstract

On L1, calldata is not compact because decoding costs would be more. But on L2, where calldata is costly while computation is cheaper, calldata can be encoded in a compact way, without much worring about decoding costs. Now there are a lot of ways to do that, which might cause security as well as compatibility issues. This document proposes a way to do that.

Specification

  • Function compact selectors are 1 byte.
  • Parameters are closedly packed.
  • Normal solidity functions (according to ABI spec) should still be exposted to allow other contracts to CALL.
  • It is only intended an EOA to use such route to interact with contract using a compact calldata.

TODO: think about compact dynamic length stuff

Security Considerations

It is possible that a compact calldata might collide with the normal calldata. It should be ensured that the compact selector should not collide with any function selector's first byte.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
import {console} from 'hardhat/console.sol';
library Calldata {
function getUint8(uint offset) internal pure returns (uint8 val) {
assembly {
val := calldataload(offset)
val := shr(248, val) // 256-8
}
}
function getUint96(uint offset) internal pure returns (uint96 val) {
assembly {
val := calldataload(offset)
val := shr(160, val) // 256-96
}
}
function getAddress(uint offset) internal pure returns (address val) {
assembly {
val := calldataload(offset)
val := shr(96, val) // 256-160
}
}
}
uint8 constant TRANSFER_SELECTOR = 1;
uint8 constant BURN_SELECTOR = 160;
contract MyContract {
fallback(bytes calldata data) external returns (bytes memory) {
uint8 selector = Calldata.getUint8(0);
// NOTE: need to have selectors such that it does not collide
// with other functions.
if(selector == TRANSFER_SELECTOR) {
// 0x TRANSFER_SELECTOR + address + uint96
transfer(
Calldata.getAddress(1),
Calldata.getUint96(21)
);
} else if(selector == BURN_SELECTOR) {
// 0x BURN_SELECTOR + uint96
burn(
Calldata.getUint96(1)
);
} else {
revert("not found");
}
}
function transfer(address dest, uint96 amt) public {
// exec code
}
function burn(uint96 amt) public {
// exec code
}
}
error Collsion(uint8 compressed, bytes4 actual);
contract MyContractCollisionTest {
bytes4[] actualSelectors = [
MyContract.transfer.selector,
MyContract.burn.selector
];
uint8[] compressedSelectors = [
TRANSFER_SELECTOR,
BURN_SELECTOR
];
constructor() {
selectorCollisionTest();
}
function selectorCollisionTest() public {
for(uint i; i < actualSelectors.length; i++) {
for(uint j; j < compressedSelectors.length; j++) {
if(uint32(actualSelectors[i]) >> 24 == compressedSelectors[j]) revert Collsion(compressedSelectors[j], actualSelectors[i]);
}
}
}
}
@waynehoover
Copy link

waynehoover commented Dec 12, 2023

If the selector byte is BURN_SELECTOR wouldn't it be better for the calling EOA to encode the calldata packed? That is, wouldn't it make more sense for line 45 to be Calldata.getUint96(1) instead of Calldata.getUint96(21)?

@zemse
Copy link
Author

zemse commented Dec 14, 2023

Yeah just fixed this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment