2024 年中更新:随着 Dencun 升级,calldata 优化的影响已大不如前,因为大多数 L2 上的交易现在都存储在 blobs 中,而不是 calldata 中。我们保留本文仅供历史参考。
在 L2 上开发应用程序时,大部分 gas 成本来自 calldata。因此,L2 的 gas 优化重点在于最小化该成本。
本文将探讨 calldata 优化的工作原理,提供一些示例,并讨论针对特定链的技术。
前提条件
作者信息
本文由 Rati Montreewat(LinkedIn,Twitter)撰写,他是一名区块链工程师,也是 L2 calldata 优化工具 Solid Grinder 的作者,同时还是 RareSkills Solidity Bootcamp 的校友。
Calldata 的成本
以太坊对 calldata 的每个字节收费,零字节收取 Gtxdatazero,非零字节收取 Gtxdatanonzero,分别为 4 gas 和 16 gas,如黄皮书所示:

Layer 2 将 calldata 提交到 Layer 1,因此它们必须支付 Layer 1 的 calldata 成本。此外,Layer 2 还会收取额外的“安全费”。
在数学上,Layer 2 交易的总 gas 定义为:

L1 的 gas 通常占据总 gas 成本(L1 + L2 gas)的 90% 到 99%。需要注意的是,这些数字在很大程度上取决于 L1 的网络 congestion(拥堵情况)。
不同 L2 链的不同规则
尽管 L2 上消耗的大部分 gas 确实来自数据/安全部分,但同一套智能合约在不同的 L2 上可能会产生不同的 gas 结果。这是因为不同的 L2 链(如 Arbitrum/ Optimism/ Starknet 等)使用不同的规则和公式来计算在 L1 成本之上,还要向用户收取多少 calldata 费用。因此,如果某种 gas 优化方法在一条 L2 链上产生了最优结果,这并不意味着它在其他 L2 链上也能产生相同的最优结果。
此外,随着客户端和以太坊生态系统的不断成熟,这些规则也会不断演进。举例来说,EIP4844(即 Proto-Danksharding)将使 L2 数据/安全组件的 gas 变得更便宜,从而使 L2 执行部分变得更加重要,这可能会导致 L2 执行费用的计算方式发生变化,以反映适当的激励机制和经济模型。
以下是不同 L2 交易 gas 的计算方式:
Arbitrum
以下是 Arbitrum 用于计算交易 gas 成本的公式:

ExecutionFee 的计算方式与在 EVM 链上的交易计算方式类似,不同之处在于它受 PriceFloor 的限制。
Arbitrum 会在将 calldata 提交到 L1 之前,尝试使用 Brotli algorithm 对其进行压缩。
Optimism
Optimism 在 calldata 收费方面有着略微不同的模型:

你可以把蓝色下划线的部分看作是以太坊的收费,而红色下划线的部分则是 Optimism 的利润空间。
Calldata 的优化方法
决定 calldata 组件所需 gas 数量的关键因素是 calldata 的大小,而这由 ABI encoding 规则规定。具体而言,即根据 Solidity 官方文档 定义的 ABI(Application Binary Interface,应用程序二进制接口)。
获得 calldata 格式直观感受的最佳方式是通过示例。
首先,让我们安装 cast(一个与 EVM 交互的工具包),并使用 Foundryup 作为工具链安装器:
curl -L https://foundry.paradigm.xyz | bash
foundryup
然后我们使用以下 cast 命令,它展示了 Solidity 如何对带有参数的函数进行编码:
cast calldata "addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256)" 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 1200000000000000000000 2500000000000000000000 1000000000000000000000 2000000000000000000000 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 100
结果包含总计 520 个十六进制字符 = 520/2 = 260 字节:
0xe8e33700000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000410d586a20a4c000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000003635c9adc5dea0000000000000000000000000000000000000000000000000006c6b935b8bbd400000000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead0000000000000000000000000000000000000000000000000000000000000064
如你所见,calldata 的前 4 个字节是函数签名(addLiquidity(address,..))的 Keccak256 哈希的前 4 个字节。在 function selector 之后,接下来的每 32 个字节块是函数参数。如果参数短于 32 字节,则默认用额外的零进行“左填充(left padded)”以凑齐 32 字节。
为了便于说明,这些 calldata 块可以拆分如下:
- 0xe8e33700 作为 function selector
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead 作为 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 的 address
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead 作为 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 的 address
- 0000000000000000000000000000000000000000000000410d586a20a4c00000 作为 1200000000000000000000 的 uint256
- 0000000000000000000000000000000000000000000000878678326eac900000 作为 2500000000000000000000 的 uint256
- 00000000000000000000000000000000000000000000003635c9adc5dea00000 作为 1000000000000000000000 的 uint256
- 00000000000000000000000000000000000000000000006c6b935b8bbd400000 作为 2000000000000000000000 的 uint256
- 000000000000000000000000deaddeaddeaddeaddeaddeaddeaddeaddeaddead 作为 0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD 的 address
- 0000000000000000000000000000000000000000000000000000000000000064 作为 64 的 uint256
有很多技术可以在不丢失信息的情况下减少 calldata 的总字节数。其核心概念是尝试以紧凑的方式编码 calldata,以便使用尽可能少的 calldata 字节。随后,这些编码后的数据会在稍后被解码为可用格式。
与压缩 calldata 所节省的 gas 相比,解压缩 calldata 的开销通常可以忽略不计。
这里讨论的技巧并非在所有可能的场景下都有效。所节省的字节量在很大程度上取决于智能合约中的具体业务逻辑。
绕过最常用的字节
如果我们知道函数签名或函数参数的准确值,我们可以将它们硬编码为常量,以便在后续需要时使用。例如,由于我们的函数集合是有限的,我们不需要完整的四个字节来标识它们。
我们可以使用工厂模式设计这套智能合约,使工厂能够针对方法和参数的每种组合部署一个独特的合约。提供以下一些 gas 优化示例:
函数签名
通过在合约中仅使用 fallback 函数,我们可以节省 4 个字节的 calldata:
fallback() external payable {
// business logic
}
函数参数
通过从函数中移除一个参数,我们可以节省 32 个字节的 calldata。
例如,ERC20 合约的 address 可以被硬编码为常量,并从函数中移除。这可能会节省总共 20 个非零字节(与 address 的大小相同)和 12 个零字节(填充到完整的 32 字节)。
address public constant USDC = <address>;
function TEST() external {
// business logic using USDC
}
如果你感到好奇并希望了解更多有关实践中的实现细节,你可以查看以下拥有有趣设计的项目:
使用地址表缓存地址
AddressTable 可以被视为一个缓存数据库,它使用 id 来存储之前注册的地址。
例如,用户首先注册地址,然后该地址会自动映射到一个 id。之后,用户只需使用该 id 而不是完整的地址即可。这会使 calldata 的大小大幅减小,从 20 个字节缩减到只需几个字节。
在底层,这个表只是一个智能合约,用于存储地址和索引之间的映射。它还具有使用相关映射 id 查找已注册地址的功能。
这一设计被 Arbitrum 所采用并实现。其接口如下:
interface ArbAddressTable {
/**
* @notice Check whether an address exists in the address table
* @param addr address to check for presence in table
* @return true if address is in table
*/
function addressExists(address addr) external view returns (bool);
/**
* @notice compress an address and return the result
* @param addr address to compress
* @return compressed address bytes
*/
function compress(address addr) external returns (bytes memory);
/**
* @notice read a compressed address from a bytes buffer
* @param buf bytes buffer containing an address
* @param offset offset of target address
* @return resulting address and updated offset into the buffer (revert if buffer is too short)
*/
function decompress(bytes calldata buf, uint256 offset)
external
view
returns (address, uint256);
/**
* @param addr address to lookup
* @return index of an address in the address table (revert if address isn't in the table)
*/
function lookup(address addr) external view returns (uint256);
/**
* @param index index to lookup address
* @return address at a given index in address table (revert if index is beyond end of table)
*/
function lookupIndex(uint256 index) external view returns (address);
/**
* @notice Register an address in the address table
* @param addr address to register
* @return index of the address (existing index, or newly created index if not already registered)
*/
function register(address addr) external returns (uint256);
/**
* @return size of address table (= first unused index)
*/
function size() external view returns (uint256);
}
然而,其实现是一个用 Go 编写的预编译合约(precompile contract)。你可以在这里查看 OffchainLabs 的 git repository。它旨在成为一个单一的通用地址表,任何人都可以注册并使用它。
如果你想查看另一个用 Solidity 编写的实现及其应用。Solid Grinder 的这个 git repository 包含 UniswapV2 的修改版本,其中采用了其自己的 address table。
数据序列化
Data Serialization(数据序列化)的工作原理是将参数序列化和反序列化为具有适当数据大小的正确类型。
例如,如果我们选择通过发送 uint40(5 字节)而不是 uint256 类型的参数(如时间段)来缩减 calldata,那么应该在正确的偏移量处对 calldata 进行切片,并且其结果(在移除零字节之后)能够在接下来的步骤中被正确使用。
让我们在这里再来看看 Solid Grinder 的实现。这个合约是一个很好的学习起点:

这个解码器函数是特定于 Uniswapv2 应用程序的,它是通过 Solid Grinder 的 CLI 根据原始未优化的函数生成的。在这个例子中,即为 UniswapV2Router02。基本上,你可以尝试并按照快速入门(Quick Start)这里的详细步骤进行操作。
权衡
上述 calldata gas 优化技巧最明显的权衡在于可读性和复杂性。例如,
在智能合约中添加编码和解码逻辑,并显式移除函数参数,不仅会使通过 Etherscan 直接与合约交互的用户感到困惑,还会让那些希望在你修改后的智能合约之上进行开发的开发者更难开展工作,从而降低了可组合性——而这正是无许可世界(permissionless world)的独特优势。
总结
如前所述,calldata gas 优化是一个较新的话题,但随着 Layer 2/Rollup 技术变得更加主流,它在近期将变得越来越具有相关性。此外,目前仍然没有明确的标准和实践。本文仅仅提供并提出了一些可能的设计决策和方法。在这个范式中,还有重塑和创新的巨大空间。
参考资料
- https://docs.arbitrum.io/arbos/l1-pricing#l1-fee-collection
- https://docs.arbitrum.io/stylus/reference/opcode-hostio-pricing#opcode-costs
- https://github.com/OffchainLabs/arbitrum-tutorials/tree/master/packages/address-table
- https://community.optimism.io/docs/developers/build/transaction-fees/
- https://scopelift.co/blog/calldata-optimizooooors
- https://github.com/clabby/op-kompressor
- https://github.com/Ratimon/solid-grinder
首次发布于 2024 年 1 月 30 日