3. EVM链数据模型:账户模型、存储布局、ABI编码与解码
各位同学,今天我们来啃一块硬骨头——EVM链的数据模型。说实话,很多做链上数据分析的朋友,卡就卡在这一关。你想想看,如果连数据怎么存的、怎么读的都不清楚,那分析出来的结果你敢信吗?
我个人习惯把EVM数据模型拆成三个层次来理解:账户模型是骨架,存储布局是血肉,ABI编码是沟通语言。咱们一层层剥开。
3.1 账户模型:EVM世界的身份证
EVM链上有两种账户:外部账户(EOA)和合约账户。这个大家应该都懂,但我要强调的是——它们本质上都是状态数据库里的一个键值对。
核心要点:每个账户有四个字段——nonce、balance、storageRoot、codeHash。但真正存储数据的地方,是storageRoot指向的存储树。
我在项目中遇到过一个问题:有同学以为合约的存储是直接挂在账户下的一个大JSON。其实不是。EVM的存储是一棵Merkle Patricia Trie,每个存储槽位(slot)是一个256位的键,对应一个256位的值。说白了,就是一张巨大的哈希表。
| 字段 | 说明 | 我的经验 |
|---|---|---|
| nonce | 交易计数器 | EOA的nonce是连续的,合约的nonce从1开始 |
| balance | 余额(wei) | 注意精度,别直接用wei做展示 |
| storageRoot | 存储树的根哈希 | 这是分析合约状态的关键入口 |
| codeHash | 合约代码的哈希 | EOA的codeHash是空字符串的哈希 |
小技巧:用ethers.js读取合约存储时,记得用provider.getStorageAt(address, slot)。我曾经调试一个DeFi协议,发现余额对不上,最后发现是存储槽位算错了。
3.2 存储布局:Solidity的变量是怎么躺平的
嗯,这里要注意。Solidity的存储布局规则,说白了就是按声明顺序,从slot 0开始,每个变量占一个slot(32字节)。但有几个坑:
- 基本类型:uint256、address、bool等,各占一个slot
- 定长数组:比如
uint256[3],从slot p开始连续占3个slot - 动态数组:slot p存长度,实际元素从
keccak256(p)开始 - 映射(mapping):slot p是占位符,实际值在
keccak256(key . p)
我建议你记住这个公式:动态数组的元素位置 = keccak256(slot) + index。映射的值位置 = keccak256(key + slot)。这两个公式,我至少用过上百次。
避坑指南:我曾经分析一个Uniswap V2的池子,想读reserve数据。结果发现slot 8和slot 9存的是reserve0和reserve1,但中间还夹了一个slot存的是blockTimestampLast。如果你不知道这个布局,读出来的数据全是乱的。
来看一个实际例子。假设有个合约:
contract Example {
uint256 public totalSupply; // slot 0
address public owner; // slot 1
mapping(address => uint256) public balances; // slot 2
uint256[] public history; // slot 3
}
要读某个地址的balance,你得这样算:
// 地址 addr 的 balance 存储在 slot = keccak256(abi.encode(addr, 2))
// 注意:key在前,slot在后
bytes32 slot = keccak256(abi.encode(address, uint256(2)));
uint256 balance = uint256(provider.getStorageAt(contractAddr, slot));
核心公式:mapping的存储位置 = keccak256(abi.encode(key, slot))。记住,key和slot都是256位,要编码成32字节。
3.3 ABI编码与解码:链上数据的翻译官
ABI编码,说白了就是把高级语言的数据结构,翻译成EVM能理解的字节序列。反过来就是解码。我刚开始做链上分析时,最头疼的就是这个——明明看到一堆0x开头的十六进制,就是不知道什么意思。
ABI编码分两种:
- 函数选择器:取函数签名的keccak256前4字节。比如
transfer(address,uint256)→0xa9059cbb - 参数编码:按顺序编码,每个参数对齐到32字节
举个例子,调用transfer(0x123..., 100):
// 函数选择器
0xa9059cbb
// 第一个参数(address),左对齐补0
0000000000000000000000001234567890abcdef...
// 第二个参数(uint256),右对齐补0
0000000000000000000000000000000000000000000000000000000000000064
你看,是不是很直观?但动态类型就复杂了。比如transfer(address[], uint256),数组长度要单独编码。
实战技巧:我写了一个小工具,专门用来解析交易input data。你可以在etherscan上点"Decode Input Data",但有时候它解析不出来。这时候你就得手动算——先取前4字节查函数签名,然后按ABI规范逐段解析。
3.4 实战:从交易中提取关键数据
咱们来走一遍完整流程。假设你看到一笔交易,想提取里面的swap数据:
- 拿到input data:从交易收据里取
input字段 - 解析函数选择器:前4字节,去4byte.directory查一下
- 解码参数:根据ABI规范,逐段解析
- 读取事件日志:交易收据里的
logs字段,包含事件数据 - 关联存储变化:看
stateDiff,确认哪些存储槽位变了
我记得有一次分析一个闪电贷攻击,就是通过对比交易前后的存储变化,找到了攻击者修改的slot。嗯,那种感觉,就像侦探找到了关键证据。
核心工具链:
ethers.js:Interface.parseTransaction()解析inputweb3.py:contract.decode_function_input()cast(foundry):cast calldata-decode命令行解码- 自己写脚本:用
abi.decode()手动解析
3.5 知识体系总览
下面这张图,是我自己总结的EVM数据模型全景。你把它存下来,以后分析任何合约,都按这个框架来:
你看,从账户模型到存储布局,再到ABI编码,这是一条完整的数据链路。我建议你每次分析一个新协议时,都先画一遍这个图——把合约的存储布局标出来,把关键事件的ABI写出来。嗯,这样做几次,你就发现链上数据不再是天书了。
我的习惯:用forge inspect查看合约的存储布局,用cast storage读取具体slot,用cast logs解析事件。这三个命令,基本能解决90%的链上数据问题。
好了,这一章的内容就到这里。记住,数据模型是链上分析的基石。你把这个搞透了,后面分析任何DeFi协议,都只是套公式而已。
公众号:蓝海资料掘金营,微信deep3321