EIP-2930 访问列表交易详解:预热存储槽省 Gas
目录
- 一、什么是访问列表交易
- 二、历史背景:从 EIP-2929 说起
- 三、工作原理
- 四、Gas 成本结构:冷访问 vs 热访问
- 五、交易类型 0x01 与结构
- 六、实际省 Gas 案例(逐步计算)
- 七、如何创建访问列表
- 八、关键陷阱
- 九、什么时候该用、什么时候不该用
- 十、总结
- 十一、动手练习项目:访问列表 Gas 实验台 AccessListBench
一、什么是访问列表交易
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,600 | 2,400 | 100 |
| 存储访问(SLOAD) | 2,100 | 1,900 | 100 |
要点:访问列表给首次访问提供 200 gas 折扣(账户 2600→2400,存储 2100→1900),之后该项的热访问只要 100 gas。所以单看”预付折扣”是省 200,但因为预付后变成热访问,实际收益要结合后续访问次数算。
五、交易类型 0x01 与结构
访问列表交易用 type 0x01。结构示例:
{
type: 1,
accessList: [
{
address: <合约地址>,
storageKeys: [ /* 32 字节的存储槽值 */ ]
}
]
}
存储键必须是 32 字节。比如状态变量 x 在槽 0x0000...0000、y 在槽 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,不只是单元测试)
- 基准对比:用
forge test --gas-report或脚本,分别用普通交易和 type-1 交易调用callSum,记录两者 Gas,验证差约 300; - 生成访问列表:用
cast access-list <caller> "callSum(address)" <calc地址> --rpc-url $SEPOLIA,把输出的地址和存储键记录下来; - 正确列表省 Gas:用生成的列表发交易,对比无列表版本,确认省 Gas;
- 错误列表陷阱:故意填一个错误的存储键(如槽 5),发交易,验证 Gas 反而增加,复现 27,610 vs 23,310 的现象;
- 不可预测槽:对
getData(随机 k)(槽由 keccak256 算出且 k 运行时才定)尝试用列表,论证为何无法预先正确声明。
Foundry 单元测试(辅助)
test_GetSumCorrect:getSum 返回 18;test_CrossContractCall:Caller.callSum 正确返回;- 用
vm.txGasPrice/gas 计量记录跨合约调用的存储访问次数(注释分析冷热账)。
Sepolia 部署与验证步骤
- 部署 Calculator 和 Caller;
- 用
cast access-list对callSum生成访问列表,观察返回的 storageKeys(应包含槽 0 和 1); - 用
cast send --access-list <文件>发带列表的交易,记录 Gas; - 发一笔普通交易对比,确认省 300 左右;
- 用错误的 storageKeys 再发一次,观察 Gas 反增。
进阶挑战(可选)
- 对一个真实的代理合约(你 Module 6 的 ERC-1967 代理)生成访问列表,预热 implementation 槽,对比 delegatecall 的 Gas;
- 写注释回答:为什么”预热”能省 Gas 却又可能更贵?从节点如何处理冷热访问的角度解释这个权衡。