EIP-2930 – 以太坊访问列表

本文介绍了以太坊的EIP-2930访问列表交易,通过预先声明访问的合约和存储槽,可以减少跨合约调用的gas消耗。文章详细解释了EIP-2930的工作原理、gas费用的计算以及如何实现访问列表交易,并提供了代码示例和gas节省的具体案例。

介绍

以太坊访问列表交易通过提前声明将访问哪些合约和存储槽,从而在跨合约调用中节省 gas。每个被访问的存储槽最多可以节省 100 gas。

引入这个 EIP 的动机是为了减轻 EIP 2929 中的破坏性变化,该 EIP 增加了冷存储访问的成本。EIP 2929 修正了对于存储访问操作的低估价,这可能导致拒绝服务攻击。然而,增加冷存储访问成本使一些智能合约出现了问题,因此引入了 EIP 2930: 可选访问列表 来缓解这种情况。

为了解决这些合约的问题,EIP 2930 被引入,允许将存储槽“预热”。EIP 2929 和 EIP 2930 是相连的,这并非巧合。

作者

本文由 Jesse Raymond (LinkedIn, Twitter) 共同撰写,他是一名在 RareSkills 的区块链研究员。支持像这样的高质量免费文章,并了解更多高级以太坊开发概念,请参见我们的 Solidity Bootcamp

工作原理

EIP-2930 交易的执行方式与其他交易相同,不同之处在于冷存储成本是以折扣方式提前支付,而不是在执行 SLOAD 操作时支付。

它不需要对 Solidity 代码进行任何修改,纯粹是客户端的指定。

费用预先支付了存储槽的冷访问,以便在实际执行时,仅需支付热费。当存储键提前确定时,以太坊节点客户端可以预取存储值,从而使计算和存储访问之间能够一些并行化。

EIP-2930 并不阻止访问列表之外的存储访问;将地址-存储组合放入访问列表并不是一个承诺。然而,结果将是无目的地预付冷存储加载费用。

以较低的费用访问

根据 EIP 2930,柏林硬分叉将账户访问操作码(如 BALANCE、所有 CALLEXT*)的“冷”成本提高至 2600,并将状态访问操作码(SLOAD)的“冷”成本从 800 提高至 2100,同时将两者的“热”成本降低至 100。

然而,EIP-2930 的额外好处是降低了交易成本,因为该交易享有 200 gas 的折扣。

因此,取而代之的是分别支付 2600 和 2100 gas 的 CALLSLOAD,该交易只需为冷访问支付 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);
    }

    // 调用 calculator 合约中的 getSum 函数
    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"; // `callCalculator()` 的函数选择器

  const Calculator = await ethers.getContractFactory("Calculator");
  const calculator = await Calculator.deploy();
  await calculator.deployed();

  console.log(`Calc 合约部署到 ${calculator.address}`);

  const Caller = await ethers.getContractFactory("Caller");
  const caller = await Caller.deploy(calculator.address);
  await caller.deployed();

  console.log(`Caller 合约部署到 ${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("==============  带访问列表的交易 ==============");
  const txCall = await user.sendTransaction(tx1);

  const receipt = await txCall.wait();

  console.log(
    `带访问列表交易的 gas 成本: ${receipt.gasUsed.toString()}`
  );

  console.log("==============  不带访问列表的交易 ==============");
  const txCallNA = await user.sendTransaction(tx2);

  const receiptNA = await txCallNA.wait();

  console.log(
    `不带访问列表交易的 gas 成本: ${receiptNA.gasUsed.toString()}`
  );

}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

type 部分中值为 1 的行在访问列表上方指定该交易是一个访问列表交易。

accessList 是一个包含交易将要访问的地址和存储槽的对象数组。

存储槽或 storageKeys 在代码中定义为 32 字节值;这就是为什么我们那里有很多前导零。

我们为零和一作为存储键提供 32 字节值,因为我们通过 Caller 合约调用的 getSum 函数确实访问了 Calculator 合约中的这两个存储槽。具体而言,x 在存储槽零中而 y 在存储槽一中。

结果

我们得到的输出如下:

编译 1 个 Solidity 文件成功
Calc 合约部署到 0x5FbDB2315678afecb367f032d93F642f64180aa3
Caller 合约部署到 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
==============  带访问列表的交易 ==============
带访问列表交易的 gas 成本: 30934
==============  不带访问列表的交易 ==============
不带访问列表交易的 gas 成本: 31234

我们可以看到节省了 300 gas(这无论优化器设置如何始终成立)。

对外部合约的调用节省了 200 gas,两个存储访问各节省了 200 gas,总节省潜力为 600 gas。然而,热访问仍需支付,并且外部调用和两个存储变量的热访问都需要 100 gas。所以,净节省为 300 gas。

具体来说,公式在我们的示例中如下所示:

没有访问列表时,访问成本 本应该 是 2600 + 2100 $\times$ 2 = 6800 gas。

但由于我们预付了 2400 + 1900 $\times$ 2 = 6200 gas 作为访问列表,我们只需支付 100 + 100 $\times$ 2 = 300 gas 用于热访问。则我们支付了 6200 + 300 = 6500 gas,而若没有访问列表则会消耗 6800 gas,从而实现净节省 300 gas。

获取访问列表交易的存储槽

Go-Ethereum (geth) 客户端有 eth_createAccessList rpc 方法,方便地确定存储槽(请参阅 web3.js api 示例)。

通过该 RPC 方法,客户端确定被访问的存储槽并返回访问列表。

我们也可以在 foundry 中使用此 RPC 方法,通过 cast access-list 命令,这在后台使用 eth_createAccessList 并返回访问列表。

下面让我们尝试一个示例;我们将通过调用 “allPairs” 函数与 UniswapV2 工厂合约(在 Göerli 网络中)进行互动,该函数返回基于传递索引的数组中的对合约。

我们在一个分叉的 Göerli 测试网络上运行以下命令。

cast access-list 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f "allPairs(uint256)" 0

如果成功的话,这将返回交易的访问列表,并在我们的终端中将如下所示。

gas used: 27983 // 交易使用的 gas 数量
access-list:
- address: 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f // uniswapv2 工厂的地址
  keys:
    0xc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b // 对合约的地址槽
    0x0000000000000000000000000000000000000000000000000000000000000003 // 数组长度槽

访问列表浪费 gas 的示例

如果存储槽被错误地计算,那么交易将支付访问列表的押金但不会获得任何好处。在以下示例中,我们将基准一个计算错误的 以太坊 访问列表交易。

以下基准会在实际使用存储槽 0 时预付存储槽 1 的费用。

// 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"; // `getX` 函数的选择器

  const Slot = await ethers.getContractFactory("Wrong");
  const slot = await Slot.deploy();
  await slot.deployed();

  console.log(`Slot 合约部署到 ${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", // 错误的槽号
        ],
      },
    ],
  };

  const badTxResult = await user.sendTransaction(badtx);
  const badTxReceipt = await badTxResult.wait();

  console.log(
    `错误访问列表的 gas 成本: ${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 成本: ${normalTxReceipt.gasUsed.toString()}`
  );
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

结果如下:

Slot 合约部署到 0x5FbDB2315678afecb367f032d93F642f64180aa3
错误访问列表的 gas 成本: 27610
不带访问列表交易的 gas 成本: 23310

尽管我们有错误的存储槽,但交易仍然成功通过,但使用错误计算的访问列表反而更贵。

不要在存储槽不是确定性的情况下使用访问列表

上一节的影响是,当访问的存储槽是非确定性时,不应使用访问列表。

例如,如果我们使用基于某个区块号确定的存储槽编号,存储槽通常是不可预测的。

另一个例子是存储槽依赖于交易发生的时间。有些 ERC-721 的实现将拥有者地址推入数组,并使用数组索引来识别 NFT 的所有权。因此,存储槽的地址取决于用户铸造的顺序,这是不可预测的。

访问列表何时节省 gas?

只要你进行跨合约调用,请考虑使用访问列表交易

进行跨合约调用通常会产生额外的 2600 gas,但使用访问列表交易的费用为 2400,并预热合约访问,只收取 100 gas,意味着净成本从 2600 降到 2500。

这同样适用于访问另一个合约中的存储变量。冷访问通常需要 2100 gas,但访问列表交易需支付 1900 gas 预热存储槽,从而节省了 100 gas。

我们提供进一步的访问列表交易示例,适用于常见的跨合约调用,例如:

在这个 repo 中。

何时不使用访问列表交易

直接调用智能合约没有“额外费用”,这包括在所有交易必须支付的 21000 gas 中。因此,对于仅访问一个智能合约的交易,访问列表并没有提供任何好处。

结论

EIP-2930 的以太坊访问列表交易是一种快速的方法,可以在可以预测跨合约调用的地址和存储槽时,每个存储槽节省最多 200 gas。当不进行跨合约调用或地址与存储槽组合不是确定性时,则不应使用。

了解更多

有关更多高级 Solidity 概念,请参见我们的 Solidity Bootcamp

最初发布于 2023 年 3 月 27 日

  • 原文链接: rareskills.io/post/eip-2...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/