跳转至主要内容

实现 Calldata 优化的精简 ABI

二层网络
中级
Ori Pomerantz
2022年4月1日
21 分钟阅读 minute read

引言

在本文中,你将了解乐观卷叠、乐观卷叠上的交易成本,以及不同的成本结构如何要求我们针对不同于以太坊主网上的因素进行优化。 您还将学习如何实现这种优化。

充分披露

我是 Optimism(opens in a new tab) 组织的一名全职员工,因此本文中的示例将在乐观卷叠上运行。 但是,本文解释的技术应该同样适用于其他卷叠。

术语

讨论卷叠时,术语“一层网络” (L1) 是指主网,即以太坊生产网络。 术语“二层网络” (L2) 是指卷叠及任何其他系统,它们依赖一层网络获得安全性且大部分处理都在链下进行。

如何能够进一步降低二层网络交易的费用?

乐观卷叠必须保留每笔历史交易的记录,以便任何人都能够检查这些交易并验证当前状态是否正确。 将数据输入以太坊主网的最便宜方法是将其写为 calldata。 该解决方案获得 Optimism(opens in a new tab)Arbitrum(opens in a new tab) 的采纳。

二层网络的交易费用

二层网络的交易费用包括两部分:

  1. 二层网络处理费用,通常非常便宜
  2. 一层网络存储费用,与主网燃料费用相关

撰写本文时,在乐观卷叠上,二层网络燃料费用是 0.001 Gwei。 另一方面,一层网络的燃料费用约为 40 gwei。 点击此处可以查看当前价格(opens in a new tab)

calldata 一个字节的费用为 4 个燃料单位(如果值为零)或 16 个燃料单位(如果值是任何其他值)。 以太坊虚拟机上最昂贵的操作之一是写入存储。 将 32 字节的字写入二层网络存储的最高费用为 22100 个燃料单位。 目前,该费用是 22.1 gwei。 因此,如果我们可以仅保存 calldata 零字节,就能够将大约 200 个字节写入存储,并且仍然可以获利。

应用程序二进制接口

绝大多数交易都是从外部所有的帐户访问合约。 大多数合约都是用 Solidity 编写的,并根据应用程序二进制接口(opens in a new tab)解释其数据字段。

但是,应用程序二进制接口是为一层网络设计的,在一层网络上,calldata 一个字节的费用大约与四次算术运算相同,而在二层网络上,calldata 一个字节的费用超过一千次算术运算的费用。 例如,此处提供一个 ERC-20 转账交易(opens in a new tab)。 calldata 包括以下部分:

部分长度字节浪费的字节浪费的燃料所需字节所需燃料
函数选择器40-3348116
零值124-15124800
目标地址2016-350020320
金额3236-67176415240
总计68160576

注释:

  • 函数选择器:合约有不到 256 个函数,所以可以用一个字节区分它们。 这些字节通常为非零字节,因此花费 16 个 燃料单位(opens in a new tab)
  • 零值:这些字节始终为零,因为 20 字节的地址不需要 32 字节的字来保存它。 保存零值的字节费用为 4 个燃料单位(见黄皮书(opens in a new tab),附录 G 第 27 页的 Gtxdatazero 的值)。
  • 金额:如果我们假设在这个合约中 decimals 为 18(正常值)且我们转账的最大代币数量为 1018,我们得到的最大金额是 1036。 25615 > 1036,所以 15 个字节就足够了。

一层网络上,160 个燃料单位的浪费通常可以忽略不计。 一笔交易至少要花费 21,000 个燃料单位(opens in a new tab),所以多出 0.8% 关系不大。 然而在二层网络上,情况有所不同。 几乎全部交易费用都用于写入一层网络。 除了交易 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 Gives the caller 1000 tokens to play with
3 */
4 function faucet() external {
5 _mint(msg.sender, 1000);
6 } // function faucet
复制

可以点击此处查看部署此合约的示例(opens in a new tab)

CalldataInterpreter.sol

这是指示应使用较短的 calldata 调用交易的合约(opens in a new tab)。 我们逐行学习它。

1//SPDX-License-Identifier: Unlicense
2pragma solidity ^0.8.0;
3
4
5import { OrisUselessToken } from "./Token.sol";
复制

我们需要代币函数以便知道如何调用它。

1contract CalldataInterpreter {
2
3 OrisUselessToken public immutable token;
复制

我们作为代理的代币的地址。

1
2 /**
3 * @dev Specify the token address
4 * @param tokenAddr_ ERC-20 contract address
5 */
6 constructor(
7 address tokenAddr_
8 ) {
9 token = OrisUselessToken(tokenAddr_);
10 } // constructor
显示全部
复制

代币地址是我们需要指定的唯一参数。

1 function calldataVal(uint startByte, uint length)
2 private please return (uint)
复制

从 calldata 中读取一个值。

1 uint _retVal;
2
3 require(length < 0x21,
4 "calldataVal length limit is 32 bytes");
5
6 require(length + startByte <= msg.data.length,
7 "calldataVal trying to read beyond calldatasize");
复制

我们将一个 32 字节(256 位)的字加载到内存中,并删除不属于我们所需字段的字节。 该算法不适用于长度超过 32 个字节的值,当然我们不能读取 calldata 末尾以后。 在一层网络上,可能有必要忽略这些测试以节省燃料,但在二层网络上,燃料非常便宜,可以进行我们能想到的任何完整性检查。

1 assembly {
2 _retVal := calldataload(startByte)
3 }
复制

我们本可以将调用中的数据复制到 fallback()(见下文),但使用以太坊虚拟机汇编语言 Yul(opens in a new tab) 更加简便。

此处,我们使用 CALLDATALOAD 操作码(opens in a new tab)将字节 startBytestartByte+31 读取入栈。 一般来说,Yul 语言中操作码的语法是 <opcode name>(<first stack value, if any>,<second stack value, if any>...)

1
2 _retVal = _retVal >> (256-length*8);
复制

只有最重要的 length 字节是该字段的一部分,所以我们进行右移(opens in a new tab)操作,去除其他值。 带来的额外好处是将值移动到字段右边,因此它是值本身而不是值乘以 256x

1
2 return _retVal;
3 }
4
5
6 fallback() external {
复制

当对 Solidity 合约的调用不匹配任何函数签名时,它会调用 fallback() 函数(opens in a new tab)(假设有此函数)。 在 CalldataInterpreter 的情况下,任何调用都会调用该函数,因为没有其他 externalpublic 函数。

1 uint _func;
2
3 _func = calldataVal(0, 1);
复制

读取 calldata 的第一个字节,它显示函数。 此处可能没有函数,原因有两个:

  1. pureview 函数不会改变状态,也不会消耗燃料(在链下调用时)。 尝试降低它们的燃料费用没有意义。
  2. 依赖 msg.sender(opens in a new tab) 的函数。 msg.sender 的值将成为 CalldataInterpreter 而不是调用者的地址。

遗憾的是,考虑到 ERC-20 规范(opens in a new tab),这样就只剩下一个函数 transfer。 我们只能使用两个函数:transfer(因为我们可以调用 transferFrom)和 faucet(因为我们可以将代币转账回任何调用者)。

1
2 // Call the state changing methods of token using
3 // information from the calldata
4
5 // faucet
6 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 }
复制

总的来说,一次转账需要使用 calldata 的 35 个字节:

部分长度字节
函数选择器10
目标地址321-32
金额233-34
1 } // fallback
2
3} // contract CalldataInterpreter
复制

test.js

此 JavaScript 单元测试(opens in a new tab)展示了如何使用此机制(以及如何验证它是否正常运作)。 本文假设你了解 chai(opens in a new tab)ethers(opens in a new tab) 并且只解释专门适用于此合约的部分。

1const { expect } = require("chai");
2
3describe("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)
9
10 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)
14
15 const signer = await ethers.getSigner()
显示全部
复制

我们首先部署两个合约。

1 // Get tokens to play with
2 const faucetTx = {

我们不能使用经常使用的高级函数(例如 token.faucet())来创建交易,因为我们没有遵循应用程序二进制接口。 相反,我们必须自己构建交易然后发送它。

1 to: cdi.address,
2 data: "0x01"

我们需要为交易提供两个参数:

  1. to,目标地址。 这是 calldata 解释器合约。
  2. data,要发送的 calldata。 在水龙头调用的情况下,数据是单字节 0x01
1
2 }
3 await (await signer.sendTransaction(faucetTx)).wait()

我们调用签名者的 sendTransaction 方法(opens in a new tab),因为我们已经指定了目标地址 (faucetTx.to),我们需要对交易进行签名。

1// Check the faucet provides the tokens correctly
2expect(await token.balanceOf(signer.address)).to.equal(1000)

在此处,我们验证余额。 view 函数不需要节省燃料,我们只需正常运行它们即可。

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 tokens
2const 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()
2
3 // Check that we have 256 tokens less
4 expect (await token.balanceOf(signer.address)).to.equal(1000-256)
5
6 // And that our destination got them
7 expect (await token.balanceOf(destAddr)).to.equal(256)
8 }) // it
9}) // describe
显示全部

示例

如果不想自己运行,要查看这些文件的运行情况,请点击以下链接:

  1. OrisUselessToken(opens in a new tab) 部署到地址 0x950c753c0edbde44a74d3793db738a318e9c8ce8(opens in a new tab)
  2. CalldataInterpreter(opens in a new tab) 部署到地址 0x16617fea670aefe3b9051096c0eb4aeb4b3a5f55(opens in a new tab)
  3. 调用 faucet()(opens in a new tab)
  4. 调用 OrisUselessToken.approve()(opens in a new tab)。 此调用必须直接转到代币合约,因为处理依赖于 msg.sender
  5. 调用 transfer()(opens in a new tab)

在你控制目标合约时降低费用

如果你确实在控制目标合约,则可以创建绕过 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 address
2 address owner;
3
4 // The CalldataInterpreter address
5 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 set the address for the proxy (the CalldataInterpreter).
3 * Can only be called once by the owner
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");
8
9 proxy = _proxy;
10 } // function setProxy
显示全部
复制

代理具有特权访问权限,因为它可以绕过安全检查。 为了确保我们可以信任代理,我们只让 owner 调用此函数,而且只调用一次。 一旦 proxy 有一个真实的值(非零值),这个值就不能改变,所以即使所有者决定耍无赖,或者它的助记符被泄露,我们仍然是安全的。

1 /**
2 * @dev Some functions may only be called by the proxy.
3 */
4 modifier onlyProxy {
复制

这是一个 modifier 函数(opens in a new tab),它修改其他函数的工作方式。

1 require(msg.sender == proxy);
复制

首先,核实我们被代理而非任何其他调用者调用。 如果不是,revert

1 _;
2 }
复制

如果是这样,运行我们修改的函数。

1 /* Functions that allow the proxy to actually proxy for accounts */
2
3 function transferProxy(address from, address to, uint256 amount)
4 public virtual onlyProxy() returns (bool)
5 {
6 _transfer(from, to, amount);
7 return true;
8 }
9
10 function approveProxy(address from, address spender, uint256 amount)
11 public virtual onlyProxy() returns (bool)
12 {
13 _approve(from, spender, amount);
14 return true;
15 }
16
17 function transferFromProxy(
18 address spender,
19 address from,
20 address to,
21 uint256 amount
22 ) public virtual onlyProxy() returns (bool)
23 {
24 _spendAllowance(from, spender, amount);
25 _transfer(from, to, amount);
26 return true;
27 }
显示全部
复制

这三项操作通常要求信息直接来自转移代币或批准限额的实体。 此处,我们有一个代理版本执行这些操作,此代理:

  1. onlyProxy() 修改,因此没有任何其他方可以控制它们。
  2. 获取通常为 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 }
9
10 // approve
11 if (_func == 3) {
12 token.approveProxy(
13 msg.sender,
14 address(uint160(calldataVal(1, 20))),
15 calldataVal(21, 2)
16 );
17 }
18
19 // transferFrom
20 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)
2
3// Need two signers to verify allowances
4const signers = await ethers.getSigners()
5const signer = signers[0]
6const poorSigner = signers[1]
复制

要检查 approve()transferFrom(),我们需要第二个签名者。 我们称它为 poorSigner,因为它没有获得我们的任何代币(当然它确实需要以太币)。

1// Transfer tokens
2const 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 transferFrom
2const approveTx = {
3 to: cdi.address,
4 data: "0x03" + poorSigner.address.slice(2, 42) + "00FF",
5}
6await (await signer.sendTransaction(approveTx)).wait()
7
8const destAddr2 = "0xE1165C689C0c3e9642cA7606F5287e708d846206"
9
10const 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()
15
16// Check the approve / transferFrom combo was done correctly
17expect(await token.balanceOf(destAddr2)).to.equal(255)
显示全部
复制

测试两个新函数。 请注意,transferFromTx 需要两个地址参数:限额的提供者和接收者。

示例

如果你不想自己运行,要查看这些文件的运行情况,请点击以下链接:

  1. OrisUselessToken-2(opens in a new tab) 部署到地址 0xb47c1f550d8af70b339970c673bbdb2594011696(opens in a new tab)
  2. CalldataInterpreter(opens in a new tab) 部署到地址 0x0dccfd03e3aaba2f8c4ea4008487fd0380815892(opens in a new tab)
  3. 调用 setProxy()(opens in a new tab)
  4. 调用 faucet()(opens in a new tab)
  5. 调用 transferProxy()(opens in a new tab)
  6. 调用 approveProxy()(opens in a new tab)
  7. 调用 transferFromProxy()(opens in a new tab)。 请注意,此调用的地址不同于其他调用,即由 poorSigner 而不是 signer 调用。

总结

Optimism(opens in a new tab)Arbitrum(opens in a new tab) 组织都在不断寻找方法,以期减小写入一层网络的 calldata 的长度并且因而降低交易费用。 然而,作为寻求通用解决方案的基础设施提供商,我们的能力有限。 你是去中心化应用程序开发者,具备特定的应用程序知识,因此你可以在通用解决方案中比我们更好地优化 calldata。 希望本文能帮助你找到满足你需求的理想解决方案。

上次修改时间: @cuijia(opens in a new tab), 2023年8月15日

本教程对你有帮助吗?