eth_getStorageAt 返回给定槽位存储的原始 32 字节字,但 Solidity 会将多个变量打包到一个槽位中,并使用 keccak256 派生映射和动态数组元素的槽位,因此直接读取槽 0 常常会返回意外的值。本文解释了 Solidity 文档中记录的布局规则,展示了如何计算声明顺序槽位和派生槽位,并提供了可运行的 Node.js 示例,用于掩码和移位打包值。文章还涵盖了解码布尔值、地址、有符号整数和长字符串,使用 eth_call 通过公共视图函数验证解码结果,代理存储布局的风险,以及与 eth_getProof 的权衡。目标是将槽位算术从猜测转变为经过检查、可复现的方法。
为什么直接读取槽 0 会返回意外数据
以太坊 JSON-RPC 规范将 eth_getStorageAt 定义为接受一个 32 字节的位置量、一个区块标签,并返回一个 32 字节的数据字。它不返回类型化值、变量名或任何分隔符来告诉你一个 Solidity 变量在哪里结束、下一个在哪里开始。该方法是对合约存储的原始窗口,而不是字段感知的 getter。
Solidity 文档中关于状态变量在存储中的布局规定,状态变量按声明顺序放置在 32 字节的槽位中,当多个值可以容纳时,它们会从低位开始打包到一个槽位中。这意味着你想要的变量可能与其他几个变量共享槽 0,因此返回的字必须进行掩码和移位,而不是作为数字读取。
这是最常见的混淆来源:开发者读取槽 0,看到一个很大的整数,就认为 RPC 端点出错了。实际上,端点返回的正是合约存储的内容;读取者只是没有考虑到打包。权威规则见 Solidity 文档 https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html,方法语义见以太坊 JSON-RPC 规范 https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getstorageat。
- eth_getStorageAt 返回 32 字节的十六进制字符串,从不返回解码后的 Solidity 值。
- 打包意味着一个槽位可以容纳多个变量,从低位端开始排序。
- 响应中没有字段分隔符,因此解码是你的责任。
操作层面的打包规则
Solidity 关于状态变量存储布局的文档规定了打包规则。值从槽位的低位端开始打包。打包组中第一个声明的变量占据最低有效字节,后续变量向上填充。因此,一个 uint128 后跟另一个 uint128 会返回一个 32 字节的字,其低 16 字节是第一个变量,高 16 字节是第二个变量。
由于偏移量取决于声明顺序,重新排列声明会改变打包组中的每个偏移量。一个 uint8、uint8、uint128 序列会打包到一个槽位中,第一个 uint8 在最低字节,第二个在下一个字节,uint128 在上部 16 字节。如果你将 uint128 移到前面,两个 uint8 值会移到更高的字节,你旧的掩码会静默地返回错误的数字。
实际后果是,任何硬编码的掩码或移位都与特定的声明顺序和编译器版本绑定。应将其视为派生值,每当合约源代码更改时都必须重新计算,而不是稳定的常量。
- 打包组中第一个声明的变量 = 最低有效字节。
- 后续变量向同一槽位的最高有效端填充。
- 重新排列声明会使之前计算的每个偏移量失效。
映射和动态数组的槽位算术
Solidity 存储布局参考定义了派生方式。映射和动态数组不会将元素存储在计数的槽位中。映射占据一个仅保存种子的槽位,元素的槽位是 keccak256(abi.encode(key, slot))。动态数组占据一个保存其长度的槽位,元素的槽位是 keccak256(abi.encode(slot)) + index。这些槽位是派生的,不是计数的,因此你不能通过将声明槽位加一来找到它们。
派生过程是确定性的,并且可以在 JavaScript 中使用 keccak256 实现来复现。关键细节是映射键和槽位号在哈希之前都编码为 32 字节值,因此 uint256 键和 uint256 槽位作为两个 32 字节字连接。对于动态数组,基础槽位单独哈希,然后将索引加到得到的大整数上。
这就是为什么在声明槽位读取映射会返回零或种子,而不是你期望的值。声明槽位是元数据;元素位于依赖于键的哈希派生位置。
- 映射元素槽位 = keccak256(abi.encode(key, slot))。
- 动态数组元素槽位 = keccak256(abi.encode(slot)) + index。
- 两者都是派生的,因此无法通过简单加法到达。
按 Solidity 类型解码 32 字节返回值
无论变量类型如何,eth_getStorageAt 都返回 32 字节的十六进制字符串,因此解码取决于你期望的 Solidity 类型。布尔值为 true 时是 0x00...01,为 false 时是 0x00...00。地址占据低 20 字节,因此有意义的值是最后 40 个十六进制字符。有符号整数需要二进制补码解释,这意味着设置了高位的值表示负数。
长度超过 31 字节的字符串和字节使用长度加数据的方案:槽位保存长度字段,实际数据位于从基础槽位的 keccak256 派生的单独槽位中。单次 eth_getStorageAt 调用无法检索完整字符串;你必须读取长度,然后读取数据槽位,再组装字节。
对于 31 字节或更短的值,Solidity 将数据存储在槽位本身中,最后一个字节的最低位用作标志,因此短字符串可以在一次调用中读取,但仍需要仔细解码。最安全的方法是对照已知合约进行解码,并用公共视图函数确认。
- bool:0x00...01 或 0x00...00。
- address:低 20 字节,最后 40 个十六进制字符。
- 有符号整数:二进制补码解释。
- 长字符串/字节:一个槽位保存长度,数据在 keccak256 派生的槽位中。
可运行的 Node.js 示例:读取槽位并解码打包对
以下示例读取一个槽位,将结果转换为 BigInt,并使用显式掩码和移位解码一个打包的 uint128/uint128 对。它打印原始十六进制和解码后的值,以便你可以对照已知合约检查算术。将 RPC URL 和合约地址替换为你自己的值。
掩码使用 (1n << 128n) - 1n 来隔离低 128 位,移位使用 >> 128n 来提取高 128 位。这反映了文档中记录的打包规则,即第一个声明的变量占据低位字节。
const { ethers } = require('ethers');
async function main() {
const provider = new ethers.JsonRpcProvider('https://your-rpc-endpoint');
const address = '0xYourContractAddress';
const slot = 0;
const raw = await provider.send('eth_getStorageAt', [address, '0x' + slot.toString(16), 'latest']);
console.log('raw:', raw);
const word = BigInt(raw);
const mask128 = (1n << 128n) - 1n;
const low = word & mask128;
const high = word >> 128n;
console.log('low (first declared):', low.toString());
console.log('high (second declared):', high.toString());
}
main().catch(console.error);可运行的 Node.js 示例:派生映射元素槽位
此示例使用 keccak256 实现在 JavaScript 中派生映射元素槽位并读取它。它使用 ethers 将键和槽位编码为 32 字节值,连接它们,并对结果进行哈希。相同的模式适用于动态数组,即对基础槽位进行哈希并加上索引。
该模板有意保持显式,以便你可以将其适配到任何键类型。对于槽位 3 上具有地址键的映射,编码后的键左填充到 32 字节,槽位也是 32 字节,然后对连接结果应用 keccak256。
const { ethers } = require('ethers');
async function main() {
const provider = new ethers.JsonRpcProvider('https://your-rpc-endpoint');
const address = '0xYourContractAddress';
const mappingSlot = 3;
const key = '0xYourKeyAddress';
const encoded = ethers.concat([
ethers.zeroPadValue(key, 32),
ethers.zeroPadValue('0x' + mappingSlot.toString(16), 32)
]);
const elementSlot = ethers.keccak256(encoded);
console.log('element slot:', elementSlot);
const raw = await provider.send('eth_getStorageAt', [address, elementSlot, 'latest']);
console.log('raw value:', raw);
console.log('decoded:', BigInt(raw).toString());
}
main().catch(console.error);使用公共视图函数验证解码结果
发现布局错误的最快方法是通过公共视图函数使用 eth_call 读取相同的值并进行比较。如果合约暴露了 getter,调用它并将返回值与解码后的槽位进行比较。不匹配意味着你的槽位算术、掩码或移位有误,或者合约使用了具有不同存储布局的代理。
这将关于布局的假设转变为经过检查的事实。它还能捕获编译器版本差异和改变打包的优化效果。eth_call 状态覆盖模拟页面涵盖了相关的模拟技术,eth_getProof 与账户/存储证明页面解释了如何在不信任端点的情况下验证存储。
为了可复现的检查,将原始槽位值、解码后的值和视图函数结果记录在表格中。如果它们不一致,在更改掩码之前检查声明顺序和编译器版本。
- 使用 eth_call 调用公共 getter 并与你的解码结果比较。
- 不匹配表明槽位、掩码、移位或代理布局错误。
- 为每次检查记录原始值、解码值和期望值。
代理存储布局的风险
在代理模式中,实现的声明顺序与代理的存储不匹配。可读槽位由代理的布局决定,代理布局通常为管理员、实现地址和其他代理状态保留前几个槽位。槽位算术必须从已部署的存储布局中获取,而不是从最新的实现源代码中获取。
这是一种常见的失败模式:开发者读取实现源代码,计算槽 0,却得到代理管理员地址而不是预期的变量。修复方法是从代理的编译器输出或经过验证的存储布局工具获取存储布局,并考虑任何间隙或保留槽位。
如果你正在与代理集成,请将存储布局视为已部署合约接口的一部分。RPC 端点指南(RPC Assistant)和API 服务页面描述了如何连接到部署这些合约的网络。
- 代理布局而非实现源代码决定可读槽位。
- 为管理员和实现保留的槽位会移动每个变量。
- 使用已部署代理的经过验证的存储布局输出。
与 eth_getProof 和存储证明的权衡
eth_getProof 返回相同的值,并附带一个 Merkle 证明,可以在不信任端点的情况下验证,代价是更重的响应和额外的验证逻辑。eth_getStorageAt 更轻量、更简单,但它信任端点返回所请求槽位的正确值。
对于只读仪表板和调试,eth_getStorageAt 通常足够。对于不能信任单个提供者的应用程序,eth_getProof 是更强的选择。eth_getProof 与账户/存储证明文章详细介绍了验证工作流程。
一个实用的模式是在开发期间使用 eth_getStorageAt,并在必须独立验证正确性的生产路径中切换到 eth_getProof。RPC 定价页面可以帮助你估算两种方法之间的成本差异。
- eth_getStorageAt:轻量、简单、信任端点。
- eth_getProof:更重、无需信任端点即可验证。
- 根据是否需要独立验证来选择。
排查常见的槽位读取失败
当槽位读取返回意外值时,首先检查声明顺序。如果变量与其他变量共享槽位,应用正确的掩码和移位。如果变量是映射或动态数组元素,验证你是用 keccak256 派生槽位而不是计数。如果合约是代理,确认你使用的是代理的存储布局。
另一个常见问题是区块标签选择。在 'latest' 读取返回当前状态,而在历史区块读取返回该区块的状态。如果值最近发生了变化,历史读取可能返回旧值。还要确认位置是 32 字节量;短十六进制字符串可能被某些提供者拒绝或误解。
最后,检查编译器版本和优化设置。打包行为有文档记录,但可能因编译器版本而异,因此对一个构建有效的解码可能对另一个构建失败。从当前编译器输出重新派生布局。
- 验证声明顺序和打包组。
- 确认映射/数组槽位是 keccak256 派生的。
- 检查代理布局和区块标签。
- 在编译器或优化更改后重新派生布局。
用结果表衡量端点的行为
eth_getStorageAt 的提供者行为有文档记录,但在历史状态可用性、速率限制和错误格式等方面因提供者而异。要衡量你自己的端点,请对已知合约运行一小组读取,并将结果记录在表格中。这将提供者的声明转变为你的集成中观察到的事实。
使用具有已知存储布局的合约,读取声明顺序槽位、打包槽位和映射元素槽位,并将每个与公共视图函数进行比较。记录原始十六进制、解码值、期望值和往返时间。在 'latest' 和历史区块重复以观察状态可用性。
下表是一个模板,供你填写自己的测量结果。不要将任何单个提供者的数字视为通用;重点是为你自己的环境建立基线。
- 列:槽位类型、原始十六进制、解码值、期望值、区块标签、往返时间。
- 行:声明槽位、打包槽位、映射元素槽位、历史读取。
- 将每行的解码值与 eth_call getter 结果进行比较。
基于槽位的集成的局限性和后续步骤
槽位号是编译器实现细节,可能因编译器版本和优化而改变。任何硬编码的槽位都是维护负担,每当合约重新编译时都必须重新派生。将槽位算术视为构建时产物,而不是运行时常量。
对于生产集成,在可用时优先使用公共视图函数,使用 eth_getStorageAt 进行诊断以及用于没有 getter 的合约,并在需要独立验证时使用 eth_getProof。OnFinality Learn 中心收集了相关指南,eth_getProof 与账户/存储证明、eth_call 状态覆盖模拟、使用 eth_getTransactionCount 进行 EVM nonce 管理和使用 eth_getLogs 进行以太坊事件主题过滤文章涵盖了相邻的读取模式。
要开始使用,请从网络页面连接到以太坊端点,对你控制的合约运行上述示例,并将结果记录在测量表中。RPC 端点指南(RPC Assistant)和API 服务页面解释了如何为此工作流配置和管理端点。
- 每次重新编译或编译器升级后重新派生槽位。
- 在可用时优先使用视图函数;使用存储读取进行诊断。
- 在需要独立验证时使用 eth_getProof。