简介
Ethereum 访问列表(access list)交易通过提前声明将要访问的合约和存储槽(storage slots),可以在跨合约调用中节省 gas。每个被访问的存储槽最多可节省 100 gas。
引入此 EIP 的动机是为了缓解 EIP 2929 中的破坏性更改,该提案提高了冷存储访问(cold storage access)的成本。EIP 2929 纠正了定价过低的存储访问操作,这些操作可能会导致拒绝服务攻击。然而,增加冷存储访问成本破坏了一些智能合约,因此引入了 EIP 2930: Optional Access Lists 来缓解这一问题。
为了修复这些受影响的合约,引入了 EIP 2930,使存储槽能够被“预热(pre-warmed)”。EIP 2929 和 EIP 2930 编号相邻并非巧合。
作者
本文由 RareSkills 的区块链研究员 Jesse Raymond(LinkedIn,Twitter)共同撰写。为了支持此类免费的高质量文章,并学习更多高级 Ethereum 开发概念,请参阅我们的 Solidity Bootcamp。
工作原理
EIP-2930 交易的执行方式与任何其他交易相同,唯一的区别在于,冷存储成本是以折扣价提前支付的,而不是在执行 SLOAD 操作期间支付。
它不需要对 Solidity 代码进行任何修改,并且完全在客户端指定。
该费用预付了存储槽的冷访问费用,以便在实际执行期间只支付热访问(warm access)费用。当提前已知存储键(storage keys)时,Ethereum 节点客户端可以预取存储值,从而允许在计算和存储访问之间进行一定的并行化。
EIP-2930 并不阻止访问列表之外的存储访问;将地址与存储的组合放入访问列表中并不意味着承诺一定会使用它。然而,这样做的结果将是毫无意义地预付冷存储加载的费用。
降低访问收费
根据 EIP 2930,Berlin 硬分叉将账户访问操作码(例如 BALANCE、所有的 CALL 和 EXT\*)的“冷”成本提高到 2600,并将状态访问操作码(SLOAD)的“冷”成本从 800 提高到 2100,同时将两者的“热”成本降低至 100。
然而,由于交易享有 200 gas 的折扣,EIP-2930 还具有降低交易成本的额外优势。
因此,该交易不需要分别为 CALL 和 SLOAD 支付 2600 和 2100 gas,其冷访问仅需要 2400 和 1900 gas,而随后的热访问仅需花费 100 gas。
实现访问列表交易
在本节中,我们将实现一个访问列表,将典型交易与 EIP-2930 交易进行比较,并提供一些 gas 基准测试。
让我们来看看我们将要调用的合约。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Calculator {
uint public x = 20;
uint public y = 20;
function getSum() public view returns (uint256) {
return x + y;
}
}
contract Caller {
Calculator calculator;
constructor(address \_calc) {
calculator = Calculator(\_calc);
}
// call the getSum function in the calculator contract
function callCalculator() public view returns (uint sum) {
sum = calculator.getSum();
}
}
我们将使用以下脚本在本地 hardhat 节点上部署并与这些合约进行交互。
import { ethers } from "hardhat";
async function main() {
const [user] = await ethers.getSigners();
const data = "0xf4acc7b5"; // function selector for `callCalculator()`
const Calculator = await ethers.getContractFactory("Calculator");
const calculator = await Calculator.deploy();
await calculator.deployed();
console.log(`Calc contract deployed to ${calculator.address}`);
const Caller = await ethers.getContractFactory("Caller");
const caller = await Caller.deploy(calculator.address);
await caller.deployed();
console.log(`Caller contract deployed to ${caller.address}`);
const tx1 = {
from: user.address,
to: caller.address,
data: data,
value: 0,
type: 1,
accessList: [
{
address: calculator.address,
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000000000000000000000000000001",
],
},
],
};
const tx2 = {
from: user.address,
to: caller.address,
data: data,
value: 0,
};
console.log("============== transaction with access list ==============");
const txCall = await user.sendTransaction(tx1);
const receipt = await txCall.wait();
console.log(
`gas cost for tx with access list: ${receipt.gasUsed.toString()}`
);
console.log("============== transaction without access list ==============");
const txCallNA = await user.sendTransaction(tx2);
const receiptNA = await txCallNA.wait();
console.log(
`gas cost for tx without access list: ${receiptNA.gasUsed.toString()}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
位于访问列表正上方、值为 1 的 type 字段,指定了该交易是一个访问列表交易。
accessList 是一个对象数组,其中包含该交易将要访问的地址和存储槽。
代码中定义的存储槽或 storageKeys 必须是 32 字节的值;这就是为什么我们在那里有很多前导零的原因。
我们使用值为 0 和 1 的 32 字节值作为存储键,因为我们通过 Caller 合约调用的 getSum 函数确切地访问了 Calculator 合约中的这些存储槽。具体来说,x 位于存储槽 0 中,而 y 位于存储槽 1 中。
结果
我们得到以下输出
Compiled 1 Solidity file successfully
Calc contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
Caller contract deployed to 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
============== transaction with access list ==============
gas cost for tx with access list: 30934
============== transaction without access list ==============
gas cost for tx without access list: 31234
我们可以看到我们节省了 300 gas(无论优化器设置如何,这都是成立的)。
对外部合约的调用节省了 200 gas,两次存储访问各节省 200 gas,潜在的总节省达到 600 gas。然而,仍然必须支付热访问的费用,外部调用和两个存储变量都有一次热访问,这三个操作每次消耗 100 gas。因此,净节省为 300 gas。
具体来说,在我们的示例中,公式的计算方式如下:
如果没有访问列表,访问成本本应是 2600 + 2100 2 = 6800 gas。
但由于我们为访问列表预付了 2400 + 1900 2 = 6200 gas,所以我们仅需为热访问支付 100 + 100 2 = 300 gas。因此我们总共支付了 6200 + 300 = 6500 gas,而原本我们会花费 6800 gas,从而净节省了 300 gas。
获取访问列表交易的存储槽
Go-Ethereum (geth) 客户端提供了 eth_createAccessList RPC 方法,方便确定存储槽(例如请参见 web3.js API)。
通过这个 RPC 方法,客户端会确定被访问的存储槽并返回访问列表。
我们也可以在 foundry 中通过 cast access-list 命令使用此 RPC 方法,该命令在后台调用 eth_createAccessList 并返回访问列表。
让我们尝试下面的示例;我们将与(Göerli 网络中的)UniswapV2 工厂合约交互,调用 “allPairs” 函数,该函数会根据传入的索引从数组中返回一个交易对合约。
我们在分叉的 Göerli 测试网中运行以下命令。
cast access-list 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f "allPairs(uint256)" 0
这将会返回该交易的访问列表,如果执行成功,它在我们的终端中看起来会是这样的。
gas used: 27983 // amount of gas used by the transaction
access-list:
- address: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f // address of the uniswapv2 factory
keys:
0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b // slot of the pair address
0x0000000000000000000000000000000000000000000000000000000000000003 // slot of the array length
使用访问列表浪费 gas 的示例
如果存储槽计算错误,那么交易将会为访问列表支付押金,而无法从中获得任何收益。在下面的示例中,我们将对一个错误计算的 ethereum 访问列表交易进行基准测试。
接下来的基准测试将为存储槽 1 预付款项,而实际使用的却是存储槽 0。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract Wrong {
uint256 private x = 1;
function getX() public view returns (uint256) {
return x;
}
}
让我们来测试一下。我们将使用带有错误存储槽的访问列表来调用 getX() 函数,然后将其与未指定访问列表的普通交易进行比较。
这是在本地 hardhat 节点中部署和运行合约的脚本。
import { ethers } from "hardhat";
async function main() {
const [user] = await ethers.getSigners();
const data = "0x5197c7aa"; // function selector for the `getX` function
const Slot = await ethers.getContractFactory("Wrong");
const slot = await Slot.deploy();
await slot.deployed();
console.log(`Slot contract deployed to ${slot.address}`);
const badtx = {
from: user.address,
// to: calculator.address,
to: slot.address,
data: data,
value: 0,
type: 1,
accessList: [
{
address: slot.address,
storageKeys: [
"0x0000000000000000000000000000000000000000000000000000000000000001", // wrong slot number
],
},
],
};
const badTxResult = await user.sendTransaction(badtx);
const badTxReceipt = await badTxResult.wait();
console.log(
`gas cost for incorrect access list: ${badTxReceipt.gasUsed.toString()}`
);
const normaltx = {
from: user.address,
// to: calculator.address,
to: slot.address,
data: data,
value: 0,
};
const normalTxResult = await user.sendTransaction(normaltx);
const normalTxReceipt = await normalTxResult.wait();
console.log(
`gas cost for tx without access list: ${normalTxReceipt.gasUsed.toString()}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
结果如下
Slot contract deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3
gas cost for incorrect access list: 27610
gas cost for tx without access list: 23310
尽管我们的存储槽是错误的,交易仍然成功执行了。然而,与使用计算错误的访问列表相比,不使用访问列表反而会更便宜。
存储槽不确定时不要使用访问列表
上一节给我们的启示是,当访问的存储槽不确定时,不应使用访问列表。
例如,如果我们基于特定的区块编号来确定存储槽的编号,那么该存储槽通常是无法预测的。
另一个例子是依赖于交易发生时间的存储槽。某些 ERC-721 实现会将所有者地址推入数组,并使用数组索引来识别 NFT 的所有权。因此,代币的存储槽取决于用户铸造的顺序,而这是无法预测的。
访问列表什么时候能节省 gas?
每当进行跨合约调用时,请考虑使用访问列表交易
进行跨合约调用通常会产生额外的 2600 gas,但是使用访问列表交易只需花费 2400 gas,并且由于对合约访问进行了预热,因此后续仅收取 100 gas。这意味着净成本从 2600 降至 2500。
这也同样适用于访问另一个合约中的存储变量。冷访问通常需要花费 2100 gas,但访问列表交易只需支付 1900 gas 即可预热存储槽,从而净节省 100 gas。
对于常见的跨合约调用,我们提供了更多访问列表交易的示例,例如:
- 访问 Chainlink oracle 中的价格,
- proxy 对实现合约执行 delegatecall
- 通过合约间调用进行 ERC-20 转账
上述示例都可以在此 repo 中找到。
何时不要使用访问列表交易
直接调用智能合约不会收取“额外费用”,这已经包含在所有交易都必须支付的 21,000 gas 中。因此,对于仅访问一个智能合约的交易而言,访问列表无法提供任何好处。
结论
当跨合约调用的地址和存储槽可被预测时,EIP-2930 Ethereum 访问列表交易是一种每个存储槽最多可节省 200 gas 的快捷方式。而在未进行任何跨合约调用,或地址与存储槽对不确定时,则不应使用它。
了解更多
有关更高级的 Solidity 概念,请参阅我们的 Solidity Bootcamp。
首发于 2023 年 3 月 27 日