实现 Calldata 优化的精简 ABI
简介
在本文中,你将了解乐观卷叠、其上的交易成本,以及不同的成本结构如何要求我们针对不同于以太坊主网的因素进行优化。 你还将学习如何实现这种优化。
完全披露
我是 Optimismopens in a new tab 的全职员工,因此本文中的示例将在 Optimism 上运行。 但是,本文解释的技术应该同样适用于其他卷叠。
术语
讨论卷叠时,术语“一层网络”(L1) 用于指代主网,即生产环境的以太坊网络。 术语“二层网络”(L2) 用于指代卷叠或任何其他依赖 L1 保证安全性但大部分处理在链下进行的系统。
如何进一步降低 L2 交易的成本?
乐观卷叠必须保留每笔历史交易的记录,以便任何人都能检查这些交易并验证当前状态是否正确。 将数据输入以太坊主网的最便宜方法是将其写为 calldata。 Optimismopens in a new tab 和 Arbitrumopens in a new tab 都选择了这个解决方案。
L2 交易成本
L2 交易的成本由两部分组成:
- L2 处理,通常极其便宜
- L1 存储,与主网 gas 成本挂钩
在我撰写本文时,Optimism 上的 L2 gas 成本为 0.001 Gwei。 另一方面,L1 gas 成本约为 40 gwei。 你可在此处查看当前价格opens in a new tab。
一个字节的 calldata 如果是零,成本是 4 gas,如果为其他任何值,成本是 16 gas。 以太坊虚拟机上最昂贵的操作之一是写入存储。 将一个 32 字节的字写入 L2 存储的最大成本是 22100 gas。 目前,这是 22.1 gwei。 因此,如果我们能节省 calldata 的一个零字节,就能向存储写入约 200 个字节,而且仍然划算。
ABI
绝大多数交易都是从外部所有的帐户访问合约。 大多数合约都用 Solidity 编写,并根据应用程序二进制接口 (ABI)opens in a new tab 来解释它们的数据字段。
然而,ABI 是为 L1 设计的,在 L1 上一个字节的 calldata 成本约等于四次算术运算,而不是 L2,在 L2 上一个字节的 calldata 成本超过一千次算术运算。 calldata 的划分如下:
| 部分 | 长度 | 字节 | 浪费的字节 | 浪费的 gas | 必要字节 | 必要 gas |
|---|---|---|---|---|---|---|
| 函数选择器 | 4 | 0-3 | 3 | 48 | 1 | 16 |
| 零 | 12 | 4-15 | 12 | 48 | 0 | 0 |
| 目标地址 | 20 | 16-35 | 0 | 0 | 20 | 320 |
| 数量 | 32 | 36-67 | 17 | 64 | 15 | 240 |
| 总计 | 68 | 160 | 576 |
解释:
- 函数选择器:合约的函数少于 256 个,因此我们可以用单个字节来区分它们。 这些字节通常是非零的,因此花费 16 gasopens in a new tab。
- 零:这些字节总是零,因为一个 20 字节的地址不需要一个 32 字节的字来容纳它。
值为零的字节花费 4 gas(参见黄皮书opens in a new tab,附录 G,
第 27 页,
Gtxdatazero的值)。 - 数量:如果我们假设在这个合约中,
decimals是 18(正常值),并且我们转移的代币最大数量是 1018,那么我们得到的最大数量是 1036。 25615 > 1036,所以十五个字节足够了。
在 L1 上浪费 160 gas 通常可以忽略不计。 一笔交易至少要花费 21,000 gasopens in a new tab,所以额外的 0.8% 并不重要。
然而,在 L2 上,情况就不同了。 交易的几乎全部成本都是将其写入 L1 的成本。
除了交易 calldata,还有 109 字节的交易头(目标地址、签名等)。
因此,总成本为 109*16+576+160=2480,我们浪费了其中的约 6.5%。
当你无法控制目标时降低成本
假设您无法控制目标合约,您仍然可以使用类似于这个opens in a new tab的解决方案。 让我们来看看相关文件。
Token.sol
这是目标合约opens in a new tab。
它是一个标准的 ERC-20 合约,带有一个额外的功能。
这个 faucet 函数让任何用户都能获得一些代币来使用。
它会使生产 ERC-20 合约无用,但当 ERC-20 仅用于方便测试时,它会让生活变得更轻松。
1 /**2 * @dev 给调用者 1000 个代币用于测试3 */4 function faucet() external {5 _mint(msg.sender, 1000);6 } // function faucetCalldataInterpreter.sol
这是交易应该用较短 calldata 调用的合约opens in a new tab。 我们逐行来过一遍。
1//SPDX-License-Identifier: Unlicense2pragma solidity ^0.8.0;345import { OrisUselessToken } from "./Token.sol";我们需要代币函数来知道如何调用它。
1contract CalldataInterpreter {23 OrisUselessToken public immutable token;我们作为其代理的代币地址。
12 /**3 * @dev 指定代币地址4 * @param tokenAddr_ ERC-20 合约地址5 */6 constructor(7 address tokenAddr_8 ) {9 token = OrisUselessToken(tokenAddr_);10 } // constructor显示全部代币地址是我们需要指定的唯一参数。
1 function calldataVal(uint startByte, uint length)2 private pure returns (uint) {从 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");我们将把一个 32 字节(256 位)的字加载到内存中,并移除不属于我们想要字段的字节。 这个算法不适用于长于 32 字节的值,当然我们也不能读取超过 calldata 末尾的数据。 在 L1 上,为了节省 gas,可能需要跳过这些测试,但在 L2 上 gas 非常便宜,这使得我们可以进行任何能想到的健全性检查。
1 assembly {2 _retVal := calldataload(startByte)3 }我们本可以从对 fallback() 的调用中复制数据(见下文),但使用 Yulopens in a new tab(EVM 的汇编语言)会更容易。
这里我们使用 CALLDATALOAD 操作码opens in a new tab 将从 startByte 到 startByte+31 的字节读入堆栈。
一般来说,Yul 中操作码的语法是 <opcode name>(<first stack value, if any>,<second stack value, if any>...)。
12 _retVal = _retVal >> (256-length*8);只有最高有效位的 length 字节是该字段的一部分,所以我们右移opens in a new tab以去除其他值。
这样做还有一个额外的好处,就是将值移动到字段的右侧,这样它就是值本身,而不是值乘以 256某个数。
12 return _retVal;3 }456 fallback() external {当对 Solidity 合约的调用与任何函数签名都不匹配时,它会调用 fallback() 函数opens in a new tab(如果存在的话)。
对于 CalldataInterpreter 来说,任何 调用都会到这里,因为没有其他 external 或 public 函数。
1 uint _func;23 _func = calldataVal(0, 1);读取 calldata 的第一个字节,它告诉我们是哪个函数。 一个函数在这里不可用的原因有两个:
pure或view函数不改变状态,也不花费 gas(当在链下调用时)。 试图降低它们的 gas 成本是没有意义的。- 依赖于
msg.senderopens in a new tab 的函数。msg.sender的值将是CalldataInterpreter的地址,而不是调用者的地址。
不幸的是,查看 ERC-20 规范opens in a new tab,这只剩下一个函数 transfer。
这让我们只剩下两个函数:transfer(因为我们可以调用 transferFrom)和 faucet(因为我们可以把代币转回给我们调用的任何人)。
12 // Call the state changing methods of token using3 // information from the calldata45 // faucet6 if (_func == 1) {调用 faucet(),它没有参数。
1 token.faucet();2 token.transfer(msg.sender,3 token.balanceOf(address(this)));4 }调用 token.faucet() 后,我们得到了代币。 然而,作为代理合约,我们不需要代币。
调用我们的 EOA(外部拥有账户)或合约才需要。
所以我们把所有的代币都转移给调用我们的人。
1 // transfer (assume we have an allowance for it)2 if (_func == 2) {转移代币需要两个参数:目标地址和数量。
1 token.transferFrom(2 msg.sender,我们只允许调用者转移他们拥有的代币
1 address(uint160(calldataVal(1, 20))),目标地址从字节 #1 开始(字节 #0 是函数)。 作为一个地址,它的长度是 20 字节。
1 calldataVal(21, 2)对于这个特定的合约,我们假设任何人想要转移的最大代币数量可以容纳在两个字节内(小于 65536)。
1 );2 }总的来说,一次转账需要 35 字节的 calldata:
| 部分 | 长度 | 字节 |
|---|---|---|
| 函数选择器 | 1 | 0 |
| 目标地址 | 32 | 1-32 |
| 数量 | 2 | 33-34 |
1 } // fallback23} // contract CalldataInterpretertest.js
这个 JavaScript 单元测试opens in a new tab向我们展示了如何使用这个机制(以及如何验证它是否正确工作)。 我假设你了解 chaiopens in a new tab 和 ethersopens in a new tab,只解释专门适用于该合约的部分。
1const { expect } = require("chai");23describe("CalldataInterpreter", function () {4 it("Should let us use tokens", async function () {5 const Token = await ethers.getContractFactory("OrisUselessToken")6 const token = await Token.deploy()7 await token.deployed()8 console.log("Token addr:", token.address)910 const Cdi = await ethers.getContractFactory("CalldataInterpreter")11 const cdi = await Cdi.deploy(token.address)12 await cdi.deployed()13 console.log("CalldataInterpreter addr:", cdi.address)1415 const signer = await ethers.getSigner()显示全部我们首先部署这两个合约。
1 // Get tokens to play with2 const faucetTx = {我们不能使用我们通常会使用的高级函数(例如 token.faucet())来创建交易,因为我们没有遵循 ABI。
相反,我们必须自己构建交易,然后发送它。
1 to: cdi.address,2 data: "0x01"我们需要为交易提供两个参数:
to,目标地址。 这是 calldata 解释器合约。data,要发送的 calldata。 在 faucet 调用的情况下,数据是单个字节,0x01。
12 }3 await (await signer.sendTransaction(faucetTx)).wait()我们调用签名者的 sendTransaction 方法opens in a new tab,因为我们已经指定了目标(faucetTx.to),并且我们需要对交易进行签名。
1// Check the faucet provides the tokens correctly2expect(await token.balanceOf(signer.address)).to.equal(1000)这里我们验证余额。
在 view 函数上没有节省 gas 的必要,所以我们正常运行它们。
1// Give the CDI an allowance (approvals cannot be proxied)2const approveTX = await token.approve(cdi.address, 10000)3await approveTX.wait()4expect(await token.allowance(signer.address, cdi.address)).to.equal(10000)给 calldata 解释器一个额度,以便能够进行转账。
1// Transfer tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}创建一个转账交易。 第一个字节是 “0x02”,后面是目标地址,最后是数量(0x0100,即十进制的 256)。
1 await (await signer.sendTransaction(transferTx)).wait()23 // Check that we have 256 tokens less4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)56 // And that our destination got them7 expect (await token.balanceOf(destAddr)).to.equal(256)8 }) // it9}) // describe显示全部当您确实控制目标合约时降低成本
如果您确实控制目标合约,您可以创建绕过 msg.sender 检查的函数,因为它们信任 calldata 解释器。
您可以在 control-contract 分支中看到这个工作原理的示例opens in a new tab。
如果合约只响应外部交易,我们可以只用一个合约。 然而,这会破坏可组合性。 最好有一个响应正常 ERC-20 调用的合约,和另一个响应具有短调用数据的交易的合约。
Token.sol
在这个例子中,我们可以修改 Token.sol。
这让我们有一些只有代理可以调用的函数。
以下是新的部分:
1 // The only address allowed to specify the CalldataInterpreter address2 address owner;34 // The CalldataInterpreter address5 address proxy = address(0);ERC-20 合约需要知道授权代理的身份。 但是,我们不能在构造函数中设置这个变量,因为我们还不知道它的值。 这个合约首先被实例化,因为代理在其构造函数中期望得到代币的地址。
1 /**2 * @dev Calls the ERC20 constructor.3 */4 constructor(5 ) ERC20("Oris useless token-2", "OUT-2") {6 owner = msg.sender;7 }创建者(称为 owner)的地址存储在这里,因为那是唯一允许设置代理的地址。
1 /**2 * @dev 设置代理(CalldataInterpreter)的地址。3 * 只能由所有者调用一次4 */5 function setProxy(address _proxy) external {6 require(msg.sender == owner, "Can only be called by owner");7 require(proxy == address(0), "Proxy is already set");89 proxy = _proxy;10 } // function setProxy显示全部代理拥有特权访问权限,因为它可以绕过安全检查。
为了确保我们能信任代理,我们只让 owner 调用这个函数,而且只能调用一次。
一旦 proxy 有一个真实的值(非零),那个值就不能改变,所以即使所有者决定变得恶意,或者其助记词被泄露,我们仍然是安全的。
1 /**2 * @dev Some functions may only be called by the proxy.3 */4 modifier onlyProxy {这是一个修饰符函数opens in a new tab,它修改其他函数的运作方式。
1 require(msg.sender == proxy);首先,验证我们是被代理调用的,而不是其他人。
如果不是,则 revert。
1 _;2 }如果是,则运行我们修改的函数。
1 /* Functions that allow the proxy to actually proxy for accounts */23 function transferProxy(address from, address to, uint256 amount)4 public virtual onlyProxy() returns (bool)5 {6 _transfer(from, to, amount);7 return true;8 }910 function approveProxy(address from, address spender, uint256 amount)11 public virtual onlyProxy() returns (bool)12 {13 _approve(from, spender, amount);14 return true;15 }1617 function transferFromProxy(18 address spender,19 address from,20 address to,21 uint256 amount22 ) public virtual onlyProxy() returns (bool)23 {24 _spendAllowance(from, spender, amount);25 _transfer(from, to, amount);26 return true;27 }显示全部这三个操作通常要求消息直接来自转移代币或批准额度的实体。 这里我们有一个这些操作的代理版本,它:
- 被
onlyProxy()修改,所以其他人不允许控制它们。 - 将通常是
msg.sender的地址作为额外参数获取。
CalldataInterpreter.sol
calldata 解释器与上面的几乎相同,只是代理的函数接收一个 msg.sender 参数,并且 transfer 不需要额度。
1 // transfer (no need for allowance)2 if (_func == 2) {3 token.transferProxy(4 msg.sender,5 address(uint160(calldataVal(1, 20))),6 calldataVal(21, 2)7 );8 }910 // approve11 if (_func == 3) {12 token.approveProxy(13 msg.sender,14 address(uint160(calldataVal(1, 20))),15 calldataVal(21, 2)16 );17 }1819 // transferFrom20 if (_func == 4) {21 token.transferFromProxy(22 msg.sender,23 address(uint160(calldataVal( 1, 20))),24 address(uint160(calldataVal(21, 20))),25 calldataVal(41, 2)26 );27 }显示全部Test.js
之前的测试代码和这个测试代码之间有几处变化。
1const Cdi = await ethers.getContractFactory("CalldataInterpreter")2const cdi = await Cdi.deploy(token.address)3await cdi.deployed()4await token.setProxy(cdi.address)我们需要告诉 ERC-20 合约信任哪个代理
1console.log("CalldataInterpreter addr:", cdi.address)23// Need two signers to verify allowances4const signers = await ethers.getSigners()5const signer = signers[0]6const poorSigner = signers[1]为了检查 approve() 和 transferFrom(),我们需要第二个签名者。
我们称它为 poorSigner,因为它没有得到我们的任何代币(当然,它确实需要有 ETH)。
1// Transfer tokens2const destAddr = "0xf5a6ead936fb47f342bb63e676479bddf26ebe1d"3const transferTx = {4 to: cdi.address,5 data: "0x02" + destAddr.slice(2, 42) + "0100",6}7await (await signer.sendTransaction(transferTx)).wait()因为 ERC-20 合约信任代理(cdi),所以我们不需要额度来中继转账。
1// approval and transferFrom2const approveTx = {3 to: cdi.address,4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",5}6await (await signer.sendTransaction(approveTx)).wait()78const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"910const transferFromTx = {11 to: cdi.address,12 data: "0x04" + signer.address.slice(2, 42) + destAddr2.slice(2, 42) + "00FF",13}14await (await poorSigner.sendTransaction(transferFromTx)).wait()1516// Check the approve / transferFrom combo was done correctly17expect(await token.balanceOf(destAddr2)).to.equal(255)显示全部测试这两个新函数。
请注意,transferFromTx 需要两个地址参数:额度的提供者和接收者。
结论
Optimismopens in a new tab 和 Arbitrumopens in a new tab 都在寻找方法来减少写入 L1 的 calldata 大小,从而降低交易成本。 然而,作为寻找通用解决方案的基础设施提供商,我们的能力是有限的。 作为 dapp 开发者,您拥有特定于应用的知识,这使您能够比我们在通用解决方案中更好地优化您的 calldata。 希望这篇文章能帮助您找到满足您需求的理想解决方案。
点击此处查看我的更多作品opens in a new tab。
页面最后更新: 2025年8月22日