以太坊的每笔交易都会访问若干账户和存储槽,EVM对首次访问的冷账户收取较高gas,而后续对同一账户的访问按暖账户计费。EIP2930通过引入带访问列表的交易类型,允许交易在广播前声明这些将被访问的地址和槽位,把冷访问的升温过程从执行阶段提前到交易准备阶段。对React前端应用而言,迁移到EIP2930不只是替换一个交易字段,它会影响Gas估算、交易签名、错误处理以及用户界面上的费用预估。

一、EIP2930的计费模型与访问列表的实际收益
在传统类型0交易中,交易执行过程中第一次触碰某个地址需要支付COLD_ACCOUNT_ACCESS_COST,当前EVM定义为2600 gas,如果是存储槽冷访问则需要支付COLD_SLOAD_COST,当前为2100 gas。后续同一地址或槽位的访问变为暖访问,成本大幅降低到100 gas左右。如果一笔交易需要和多个合约交互,例如DeFi聚合器先查报价、再授权、再兑换、再转账,这些合约地址和存储槽在整个执行期间会反复被访问,前端如果把这些地址提前放入访问列表,就可以避免在执行阶段逐个支付冷访问费用。
EIP2930交易在交易本身增加accessList数组,每个元素包含一个地址和一组存储槽。节点在执行前会按访问列表预付费:每个地址收取2400 gas,每个存储槽收取1900 gas。随后这些地址和槽位被标记为暖,实际执行时不再收取冷访问费用。这样做的好处是当同一地址被多次访问时,预付费只收一次,后续都按暖访问价格,总体gas会下降。不过要注意,如果访问列表里包含了很多根本不会被执行的地址或槽位,这些预付费就白付了,反而会增加成本。
可以用一个简单公式估算收益:假设交易中访问N个唯一外部账户,每个账户至少被访问2次。传统方式每个冷账户2600加上后续100左右的暖访问,总成本约2600N + 100M;EIP2930方式为2400N + 100M,每个账户节省200 gas。如果N很大,节省可观。但如果每个地址只访问一次,2400只比2600节省200,收益有限。更明显的是存储槽:冷槽2100变为暖槽100,但访问列表预付费为1900,节省幅度不大。所以访问列表最适用于批量合约交互、多步DeFi操作、Layer2跨链桥接等场景。
二、在React应用中生成访问列表并构造交易
React应用通常通过ethers.js或web3.js与以太坊节点通信。如果使用ethers.js v5.6以上版本,TransactionRequest中已经支持accessList字段,并且提供了provider.send方法可以调用eth_createAccessList这个非标准但被主流节点支持的RPC方法。该方法会模拟交易执行,返回本次执行实际会访问的地址和存储槽列表,作为推荐访问列表。前端拿到这个列表后可以稍作裁剪,再附加到交易对象中。
下面是一段基于ethers.js的React Hook示例,它先调用eth_createAccessList生成推荐列表,再构建带访问列表的交易。代码中使用了provider.send直接与RPC通信,需要确保连接的节点支持该方法,例如Geth、Nethermind、Erigon的较新版本。
// 使用 ethers.js 生成访问列表并构造 EIP2930 交易
import { ethers } from 'ethers';
async function buildTxWithAccessList(provider, from, to, data, value) {
// 先模拟交易,获取访问列表
const txForSimulation = {
from,
to,
data,
value: value || '0x0',
};
const accessList = await provider.send('eth_createAccessList', [
txForSimulation,
'latest',
]);
// 构造正式的 EIP2930 交易
const feeData = await provider.getFeeData();
const gasLimit = await provider.estimateGas({
from,
to,
data,
value,
accessList: accessList.accessList,
});
const tx = {
type: 1, // EIP-2930 交易类型
from,
to,
data,
value,
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
chainId: (await provider.getNetwork()).chainId,
accessList: accessList.accessList,
};
return tx;
}
代码中先构造一个用于模拟的交易对象,通过eth_createAccessList获取节点建议的访问列表。这个模拟交易不会真正上链,但会按当前状态执行一遍,记录所有被触达的地址和存储槽。然后把它附加到正式交易的accessList字段,并且设置type为1。注意type为1的交易在EIP1559之前就存在,它使用gasPrice而不是maxFeePerGas。这里为了兼容性同时展示了EIP1559费用字段,实际使用时要根据网络情况选择gasPrice或maxFeePerGas。
如果用户钱包使用MetaMask,注意当前MetaMask对type 1交易的支持情况。在生产环境中,很多React应用仍然通过Web3Provider和JSON-RPC签名交易,签名后的序列化会包含accessList。也可以通过钱包的eth_sendTransaction直接发送带访问列表的交易,但部分钱包可能不会把accessList传递给节点,这时需要回到自定义签名方式。
三、迁移React应用的步骤与Gas估算注意事项
把现有React应用迁移到EIP2930,第一步是检测当前交易构造链路。如果你使用的是Uniswap等前端类似的库,交易对象通常已经在SDK内部生成,需要确认SDK是否暴露accessList参数。如果使用自己封装的交易构造函数,可以把上一节的buildTxWithAccessList集成到提交交易的Hook中。第二步是处理Gas估算。因为访问列表会影响交易的gas使用量,估算时必须把accessList一并传给estimateGas,否则估算结果偏高或偏低,用户可能会看到不准确的费用。
第三步是处理费用预估界面。React组件中通常会显示预计Gas费,迁移后需要在发送前调用eth_createAccessList并进行一次模拟,这会增加RPC请求次数,可能让用户等待时间变长。建议在用户点击确认交易时异步生成访问列表,同时展示加载状态。也可以在后台提前为高频交易类型缓存访问列表,因为同一合约交互的访问列表往往稳定,除非合约内部逻辑发生状态分支。
第四步是错误处理。部分RPC节点可能不支持eth_createAccessList,调用会返回方法不存在的错误。此时React前端应降级为普通交易,不携带accessList,或者使用空列表并设置type为1,但空列表没有实际收益。同时要注意交易签名后的序列化,如果使用的是ethers.Wallet签名,签名结果中的accessList需要正确编码。一个常见错误是忘记在序列化交易时包含accessList,导致节点按旧格式解析失败。
下面给出一个React Hook的简化实现,用于管理带访问列表的交易发送和状态展示:
import { useState } from 'react';
import { ethers } from 'ethers';
export function useEIP2930Transaction(provider) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function sendTransactionWithAccessList(txParams) {
setLoading(true);
setError(null);
try {
const accessList = await provider.send('eth_createAccessList', [
{
from: txParams.from,
to: txParams.to,
data: txParams.data,
value: txParams.value || '0x0',
},
'latest',
]);
const gasLimit = await provider.estimateGas({
...txParams,
accessList: accessList.accessList,
});
const feeData = await provider.getFeeData();
const signedTx = await provider.getSigner().sendTransaction({
...txParams,
type: 1,
gasLimit,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
accessList: accessList.accessList,
});
return signedTx;
} catch (err) {
setError(err.message || String(err));
throw err;
} finally {
setLoading(false);
}
}
return { sendTransactionWithAccessList, loading, error };
}
该Hook假设provider是ethers.js的JsonRpcProvider或Web3Provider,并且签名器可用。生产代码还需要处理用户取消、交易被拒绝、网络切换等状态。注意useState和错误信息在UI中的展示,避免把原始错误直接抛出给用户。
四、访问列表优化的误区和实际测试结果
把访问列表等同于降低Gas是一个常见误区。访问列表的收益取决于交易执行的路径是否与预声明一致以及是否存在重复访问。如果一笔简单的ETH转账只涉及两个地址,访问列表带来的节省可能只有几百gas,而构造访问列表本身还需要额外RPC模拟开销。在某些情况下,预付费的2400 gas比冷账户的2600 gas省得有限,如果访问列表包含错误项或过多项,甚至可能增加交易体积和成本。
实际测试中,一个典型的ERC20转账加访问列表后Gas变化不明显,因为ERC20转账主要访问两个账户和一个存储槽,冷访问成本占比不高。而一个聚合器交互涉及多个池子、多个代币合约、多次余额查询时,访问列表可以节省数千gas。根据社区测试,某些复杂交易在声明了约20个地址和30个存储槽后,Gas降低了5%到15%。不过要注意,这些测试结果受EIP2929之后的gas计费规则影响,具体数值需结合当前网络参数验证。
另一个容易被忽略的问题是访问列表的交易体积。每个访问项包含20字节地址和最多若干个32字节存储槽,这会让交易字节数增加,而交易数据本身每字节也会消耗gas。如果访问列表很大,额外数据成本可能抵消节省。所以生成推荐列表后应该进行裁剪,只保留被多次访问或者冷访问成本较高的项。eth_createAccessList返回的列表通常已经比较精简,但前端仍可以根据业务逻辑做二次过滤。
React应用迁移完成后,可以使用本地测试网进行A/B对比:分别发送带和不带访问列表的相同交易,记录实际gasUsed。测试时注意使用同一账户、同一区块高度和相同nonce,避免状态差异。如果发现gas没有下降,需要检查访问列表是否与模拟交易的路径一致,以及合约内部是否存在随机数或时间戳等导致执行路径变化的条件。
五、与EIP1559和未来交易类型的兼容性
EIP2930的交易类型编号为1,而EIP1559的交易类型编号为2。React应用如果已经迁移到EIP1559,使用type 2时也可以携带accessList字段,因为EIP1559交易扩展了EIP2930的访问列表概念,同时使用maxFeePerGas和maxPriorityFeePerGas。因此,前端不需要在type 1和type 2之间做二选一的痛苦抉择,可以选择type 2交易并附加accessList,既享受动态费用市场,又获得访问列表优化。
在ethers.js中,给type 2交易附加accessList十分简单,只需在交易对象中同时设置type为2和accessList数组即可。下面是一个最小示例:
const tx = {
type: 2,
from,
to,
data,
value,
gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
chainId,
accessList: [
{
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
storageKeys: [
'0x0000000000000000000000000000000000000000000000000000000000000000',
],
},
],
};
这个例子展示了访问列表的结构:address是20字节的账户地址,storageKeys是32字节的存储槽哈希数组。React前端不需要手动计算存储槽哈希,eth_createAccessList会返回完整的结构。但了解结构有助于调试和验证节点返回的数据。
未来以太坊可能会引入更多交易类型,例如EIP4844的blob交易类型为3,其同样继承访问列表机制。因此,在React应用中建立一套灵活的访问列表生成与附加逻辑,对后续升级也有帮助。建议把访问列表相关的代码封装成独立模块,例如createAccessListProvider、buildTxWithAccessList、estimateGasWithAccessList等,这样无论底层交易类型如何演进,前端调用层都能保持一致。

