EIP-2930 访问列表交易详解:预热存储槽省 Gas

理解冷热访问成本、访问列表如何预声明地址与存储槽节省跨合约调用的 Gas 及其陷阱

7 分钟阅读
EIP-2930 访问列表交易详解:预热存储槽省 Gas

EIP-2930 访问列表交易详解:预热存储槽省 Gas

目录


一、什么是访问列表交易

EIP-2930 通过提前声明一笔交易将要访问哪些合约地址和存储槽,来节省跨合约调用的 Gas。

每个被访问的存储槽最多可省 100 gas。

核心思想:你在交易里附上一份”我待会要碰这些地址和这些存储槽”的清单(访问列表),节点提前把它们”预热”,于是真正执行时这些访问按”热价”收费而非”冷价”。

二、历史背景:从 EIP-2929 说起

EIP-2930 是为了缓解 EIP-2929 带来的破坏性变更。EIP-2929 提高了冷存储访问成本(防 DoS 攻击),导致一些原本能跑的合约 Gas 暴涨甚至失效。

EIP-2930 让开发者能”预热”存储槽,相当于给受影响的合约松绑——把涨上去的成本用预付折扣的方式部分抵消。

三、工作原理

访问列表交易和普通交易执行上完全一样,区别只是:冷访问成本以折扣价提前预付,而不是在执行时全价支付。这个机制不需要改任何 Solidity 代码——纯粹是客户端(发交易那一侧)的事。

附加好处:当存储键提前已知时,节点客户端可以预取这些值,让计算和存储访问之间有一定的并行化。

注意:列出某个”地址-存储槽”组合并不强制你必须使用它;没用上的条目只是白白浪费 Gas。

四、Gas 成本结构:冷访问 vs 热访问

Berlin 硬分叉确立了冷热两档定价:

操作类型冷访问用访问列表后热访问(后续)
账户访问(BALANCE、CALL、EXT* 等)2,6002,400100
存储访问(SLOAD)2,1001,900100

要点:访问列表给首次访问提供 200 gas 折扣(账户 2600→2400,存储 2100→1900),之后该项的热访问只要 100 gas。所以单看”预付折扣”是省 200,但因为预付后变成热访问,实际收益要结合后续访问次数算。

五、交易类型 0x01 与结构

访问列表交易用 type 0x01。结构示例:

{
  type: 1,
  accessList: [
    {
      address: <合约地址>,
      storageKeys: [ /* 32 字节的存储槽值 */ ]
    }
  ]
}

存储键必须是 32 字节。比如状态变量 x 在槽 0x0000...0000y 在槽 0x0000...0001

六、实际省 Gas 案例(逐步计算)

文章演示一个 Caller 合约调用 Calculator 合约的 getSum()(读 2 个存储变量):

  • 用访问列表:30,934 gas
  • 不用访问列表:31,234 gas
  • 净省:300 gas

逐步拆解(1 次账户访问 + 2 次存储访问):

  • 不用访问列表:2,600(账户冷)+ 2,100 × 2(两个存储槽冷)= 6,800 gas
  • 用访问列表:(2,400 + 1,900 × 2)(预付的折扣冷价)+ 100 × 3(三项变热后的热访问)= 6,500 + 300 = 6,500 gas

注意:6,500 是预付部分;实际执行时这三项已是热访问各 100 gas。两种算法殊途同归,差额 6,800 − 6,500 = 300 gas,正好是 3 项各省 100。

七、如何创建访问列表

自动生成:用 eth_createAccessList RPC 方法(Go-Ethereum、Web3.js 支持)。Foundry 提供 cast access-list 命令:

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

它返回 Gas 用量和完整的访问列表(地址 + 存储键)。实践中你通常不手算存储槽,而是用这个工具自动生成,再附到交易里。

八、关键陷阱

错误的存储槽

如果存储槽算错了,交易照样支付访问列表的预付押金,却得不到任何好处。文章基准:

  • 错误的访问列表:27,610 gas
  • 不用访问列表的普通交易:23,310 gas
  • 结果:用错了反而更贵(多花约 4,300 gas)

不确定的存储槽

存储槽不可预测时绝不能用访问列表,比如:

  • 依赖区块号的槽;
  • NFT 合约里由铸造顺序决定的数组索引;
  • 任何运行时才确定的存储模式。

因为你提前列的槽和实际访问的槽对不上,预付的钱全打水漂。

九、什么时候该用、什么时候不该用

该用(“任何跨合约调用都可以考虑”):

  • Chainlink 预言机喂价(读固定槽);
  • 代理合约 delegatecall 到实现(实现地址槽固定);
  • 通过合约间调用的 ERC-20 转账;
  • 净效果:合约访问 2,600→2,500(2,400 预付 + 100 热),存储访问 2,100→2,000(1,900 + 100)。

不该用

  • 单合约交易(没有额外的跨合约冷访问可省);
  • 存储访问模式不可预测;
  • 预付成本超过实际节省。

十、总结

  • EIP-2930 用”提前声明要访问的地址/存储槽”换取冷访问折扣(每项省 200 预付,后续热访问 100);
  • 起源于 EIP-2929 提高冷访问成本后的补救;
  • 不改 Solidity 代码,纯客户端构造,type 0x01;
  • 跨合约调用、固定存储槽场景能省 Gas(如预言机、代理);
  • 槽算错或不可预测时反而更贵——务必用 cast access-list 等工具准确生成。

十一、动手练习项目:访问列表 Gas 实验台 AccessListBench

项目目标

搭一个能清楚对比”用 vs 不用访问列表”Gas 差异的实验环境,亲手用 cast access-list 生成列表,并复现”槽算错反而更贵”的陷阱。部署到 Sepolia。

合约要求

1. Calculator.sol(被调合约)

  • uint256 public a = 7; uint256 public b = 11;(槽 0、1);
  • getSum() external view returns (uint256):返回 a + b(读两个存储槽);
  • 再加一个 mapping(uint => uint) public data;getData(uint k),用于演示”不可预测槽”。

2. Caller.sol

  • callSum(address calc) external returns (uint256):跨合约调用 Calculator(calc).getSum()(产生 1 次账户冷访问 + 2 次存储冷访问)。

实验要求(用 Foundry + cast,不只是单元测试)

  1. 基准对比:用 forge test --gas-report 或脚本,分别用普通交易和 type-1 交易调用 callSum,记录两者 Gas,验证差约 300;
  2. 生成访问列表:用 cast access-list <caller> "callSum(address)" <calc地址> --rpc-url $SEPOLIA,把输出的地址和存储键记录下来;
  3. 正确列表省 Gas:用生成的列表发交易,对比无列表版本,确认省 Gas;
  4. 错误列表陷阱:故意填一个错误的存储键(如槽 5),发交易,验证 Gas 反而增加,复现 27,610 vs 23,310 的现象;
  5. 不可预测槽:对 getData(随机 k)(槽由 keccak256 算出且 k 运行时才定)尝试用列表,论证为何无法预先正确声明。

Foundry 单元测试(辅助)

  1. test_GetSumCorrect:getSum 返回 18;
  2. test_CrossContractCall:Caller.callSum 正确返回;
  3. vm.txGasPrice/gas 计量记录跨合约调用的存储访问次数(注释分析冷热账)。

Sepolia 部署与验证步骤

  1. 部署 Calculator 和 Caller;
  2. cast access-listcallSum 生成访问列表,观察返回的 storageKeys(应包含槽 0 和 1);
  3. cast send --access-list <文件> 发带列表的交易,记录 Gas;
  4. 发一笔普通交易对比,确认省 300 左右;
  5. 用错误的 storageKeys 再发一次,观察 Gas 反增。

进阶挑战(可选)

  • 对一个真实的代理合约(你 Module 6 的 ERC-1967 代理)生成访问列表,预热 implementation 槽,对比 delegatecall 的 Gas;
  • 写注释回答:为什么”预热”能省 Gas 却又可能更贵?从节点如何处理冷热访问的角度解释这个权衡。

💬 评论