eth_call 状态覆盖集是 JSON-RPC 请求中可选的第四个参数,允许你针对临时修改的区块链状态执行调用。它从不持久化更改,非常适合模拟假设场景,如大额代币余额或模拟所有者。本指南解释了每个覆盖字段,展示了如何计算存储槽,并提供了可运行的 viem 和 ethers 示例。
什么是 eth_call 状态覆盖集?
标准的 eth_call 方法针对当前区块链状态执行只读调用。但如果你想测试当某个地址持有百万代币或所有者是其他人时合约的行为,该怎么办?发送真实交易既昂贵又不可逆。解决方案是状态覆盖集,它是 eth_call 请求对象中可选的第四个参数。它是一个地址到覆盖对象的映射,客户端仅针对该次调用应用。覆盖是临时的——它们从不触及持久化状态,并且在调用返回后立即丢弃。
此功能受 geth、reth 和 Erigon 等主要以太坊客户端支持,并由大多数代理 eth_call 的 RPC 提供商公开。它不属于 eth_sendRawTransaction——你不能使用覆盖来改变真实交易中的状态。官方规范见 Ethereum Execution APIs 和 geth JSON-RPC 文档。
每个地址的覆盖对象最多可包含五个字段:balance、nonce、code、state 和 stateDiff。每个字段允许你操作该地址的账户或合约的不同方面。我们将详细讨论每个字段,并提供具体示例和注意事项。
balance:设置地址的 ETH 余额,单位为 wei(十六进制或十进制)。nonce:设置账户的交易计数。code:替换合约地址的字节码,有效地模拟其逻辑。state:覆盖合约的特定存储槽。stateDiff:对存储槽应用差异,类似于state,但客户端之间存在细微差异(请根据客户端验证)。
状态覆盖集在底层是如何工作的
当你发送带有状态覆盖集的 eth_call 时,客户端会创建一个临时的内存中 trie,该 trie 从当前状态的副本开始。然后它将你的覆盖应用于该副本。调用针对此修改后的 trie 执行,并返回结果。由于 trie 是临时的,因此不会将任何更改写入磁盘或广播到网络。这就是状态覆盖非常适合“假设”分析和测试的原因。
state 和 stateDiff 字段操作存储槽。存储槽是合约存储 trie 中的 256 位键。对于简单的公共变量,槽号通常只是一个整数(例如,第一个变量为槽 0)。对于映射,槽使用 keccak256(abi.encode(key, uint256(slot))) 计算。我们将在示例部分展示如何计算。
一个关键的陷阱:stateDiff 字段在不同客户端中的实现不同。在 geth 中,它被视为临时集,在调用后提交,但其他客户端可能以不同方式解释。始终在目标客户端或提供商上验证行为。如有疑问,请使用 state,它更通用。
- 覆盖应用于状态 trie 的临时副本,而不是规范副本。
- 调用执行时就像覆盖的状态是真实的一样,包括合约逻辑和错误处理。
- 状态更改不消耗 gas,但调用本身可能需要 gas 估算。
- 状态覆盖不会持久化,也不能在
eth_sendRawTransaction中使用。
状态覆盖模拟的常见用例
开发人员通常在三种场景中使用状态覆盖。首先,模拟大额代币余额:你想测试需要调用者持有最低数量 ERC-20 代币的交换或转账保护。无需实际转移代币,你可以覆盖代币合约中你的地址的余额映射。
其次,模拟所有者或特权角色:许多合约具有限制某些读取函数的 onlyOwner 修饰符。要以所有者身份测试读取路径,你可以将合约的所有者存储槽覆盖为你的地址,或者将合约的代码覆盖为对 isOwner() 返回 true 的模拟。
第三,测试清算逻辑:去中心化借贷协议通常依赖价格预言机。通过覆盖价格馈送合约的存储价格,你可以模拟价格暴跌并验证你的清算机器人是否正确触发——所有这些都无需分叉网络或等待真实价格下跌。
- 无需持有代币即可测试代币门控功能。
- 无需更改真实所有者即可模拟仅所有者调用。
- 操纵预言机价格以测试清算阈值。
- 隔离调试复杂的多合约交互。
可运行示例:模拟代币余额和存储覆盖
下面是一个使用 viem (v2.x) 的完整 Node.js 脚本,演示了三件事:(1) 使用普通 eth_call 读取地址的 ETH 余额,(2) 使用状态覆盖重新运行相同的调用,赋予该地址巨额 ETH 余额,以及 (3) 覆盖 ERC-20 合约中的存储槽以模拟代币余额。该脚本并排打印两个结果。
我们将使用以太坊主网上的 USDC 合约(地址 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48)和一个假设的用户地址。余额映射的存储槽使用 keccak256(abi.encode(userAddress, uint256(0))) 计算,因为该映射是第一个状态变量(槽 0)。
在运行之前,安装 viem 并确保你有一个 RPC 端点。你可以使用任何公共或私有端点,但请注意,某些提供商可能不支持状态覆盖——请查看提供商的文档。对于可靠的端点,请考虑 OnFinality 以太坊 RPC 或你自己的节点。
// npm install viem
import { createPublicClient, http, keccak256, encodeAbiParameters, parseEther, hexToBigInt } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.public.blastapi.io'), // replace with your endpoint
});
const user = '0xYourAddressHere'; // replace with your address
const usdc = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
// 1. Plain eth_call to read ETH balance
const ethBalancePlain = await client.getBalance({ address: user });
console.log('Plain ETH balance:', ethBalancePlain.toString());
// 2. eth_call with state override to set ETH balance to 1000 ETH
const ethBalanceOverridden = await client.getBalance({
address: user,
stateOverride: [
{
address: user,
balance: parseEther('1000'),
},
],
});
console.log('Overridden ETH balance:', ethBalanceOverridden.toString());
// 3. Override USDC balance (storage slot 0 mapping)
// Compute slot: keccak256(abi.encode(user, uint256(0)))
const slot = keccak256(encodeAbiParameters(
[{ type: 'address' }, { type: 'uint256' }],
[user, 0n]
));
// Simulate a call to balanceOf(user) with override
const balanceOfSelector = '0x70a08231'; // balanceOf(address)
const callData = balanceOfSelector + user.slice(2).padStart(64, '0');
const result = await client.call({
account: user,
to: usdc,
data: callData,
stateOverride: [
{
address: usdc,
state: {
[slot]: '0x' + (1000000n * 10n ** 6n).toString(16).padStart(64, '0'), // 1,000,000 USDC (6 decimals)
},
},
],
});
console.log('Simulated USDC balance:', hexToBigInt(result.data).toString());
// Fill in the table below with your results.预期输出和结果表
当你运行脚本时,你应该看到类似于以下的输出(实际值取决于你的地址和端点):
覆盖后的 ETH 余额应恰好为 1000 ETH(1e21 wei)。模拟的 USDC 余额应为 1,000,000 USDC(1e12 基本单位,因为 USDC 有 6 位小数)。如果你看到不同的值,请检查你的槽计算和小数位数。
在下面的表格中记录你自己的结果以进行验证:
- | 调用 | 普通结果 | 覆盖结果 | 预期 |
- |------|--------------|-------------------|----------|
- | ETH 余额 | ... | ... | 1000 ETH |
- | USDC balanceOf | ... | ... | 1,000,000 USDC |
Plain ETH balance: 1234567890123456789
Overridden ETH balance: 1000000000000000000000
Simulated USDC balance: 1000000000000000000000000
计算映射和数组的存储槽
上面的示例使用标准的 Solidity 映射槽公式:对于在槽 p 处声明的映射,键 k 的值存储在 keccak256(abi.encode(k, uint256(p)))。这在 Solidity 文档 中有定义。对于槽 0 处的映射,公式简化为 keccak256(abi.encode(key, uint256(0)))。
对于动态数组,长度存储在槽 p 处,索引 i 处的元素位于 keccak256(abi.encode(uint256(p))) + i。对于嵌套映射或结构体,你递归应用公式。始终验证目标合约的槽布局,因为编译器优化可能会改变它。
如果你不确定槽号,可以使用 cast storage(Foundry)或 eth_getStorageAt 等工具检查当前状态。例如,要查找合约的所有者槽,你可以读取槽 0 并查看它是否与预期的所有者地址匹配。
- 映射槽:
keccak256(abi.encode(key, uint256(slot))) - 数组元素:
keccak256(abi.encode(uint256(slot))) + index - 始终通过合约源代码或检查存储来确认槽布局。
常见故障排除
状态覆盖可能因多种原因失败。最常见的是你的 RPC 提供商不支持 stateOverride 参数。某些提供商会剥离它或返回错误。如果你收到类似 missing value for required argument 4 的错误,你的提供商可能不支持它。请尝试其他提供商或运行你自己的节点。
另一个问题是错误的槽计算。如果你覆盖了错误的槽,调用将返回意外结果。仔细检查槽号和编码。此外,确保你使用了正确的合约和用户地址。
最后,请注意 stateDiff 的客户端特定行为。如前所述,geth 对待它的方式与其他客户端不同。如果你依赖 stateDiff,请在目标客户端上测试。为了最大兼容性,请改用 state。
- 提供商不支持状态覆盖:切换到你可以控制的节点或明确支持它的提供商。
- 存储槽不正确:使用
eth_getStorageAt或合约源代码进行验证。 - 数据类型错误:确保余额以 wei 为单位,并且值是十六进制编码的。
stateDiff不起作用:改用state,或查看客户端文档。
状态覆盖与分叉:应该使用哪种?
状态覆盖不是模拟假设状态的唯一方法。你还可以使用分叉(例如,Hardhat 主网分叉)来创建区块链的本地副本,然后自由修改状态。分叉更强大,因为它们允许你发送交易并测试状态更改,但它们需要运行本地节点,并且对于简单的只读检查来说速度较慢。
状态覆盖非常适合快速、无状态的模拟,当你只需要测试单个调用或一小部分调用时。它们在生产环境中也很有用,因为你无法承担分叉的成本。但是,它们仅限于只读调用——你不能模拟更改状态的交易。
不带覆盖的普通 eth_call 是当你只需要读取当前状态时的最简单选择。当你不需要修改任何内容时使用它。下表总结了权衡:
- | 方法 | 优点 | 缺点 | 最适合 |
- |--------|------|------|----------|
- | 状态覆盖 | 快速,无需设置,无状态持久化 | 仅只读,提供商支持各异 | 快速假设检查,生产调试 |
- | 分叉 | 完全控制,可以发送交易 | 需要本地节点,速度较慢 | 复杂测试,集成测试 |
- | 普通 eth_call | 简单,普遍支持 | 无法修改状态 | 读取当前状态 |
后续步骤和进一步阅读
既然你了解了状态覆盖,你可以将它们应用到自己的测试和调试工作流程中。为了加深你的知识,请探索 OnFinality Learn hub 上的相关主题。例如,了解当你的模拟调用失败时如何解码 revert 原因,或者如何查询历史数据以了解过去的状态。如果你要进行大量调用,请查看 JSON-RPC 批处理最佳实践 以提高效率。
在选择 RPC 提供商时,请考虑 RPC 定价 和 API 服务 选项。有关提供商的比较,请参阅 RPC Assistant 指南:选择以太坊 RPC API。如果你遇到速率限制,请阅读有关 以太坊 RPC 速率限制和 429 的内容。
最后,始终参考官方的 Ethereum Execution APIs 规范 和 geth 文档 以获取有关状态覆盖的最新详细信息。
- 尝试覆盖
code以模拟合约的逻辑。 - 在你的测试套件中使用状态覆盖,以避免在简单情况下进行分叉。
- 与社区分享你的发现——状态覆盖未被充分利用。