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数据:

  1. 拿到input data:从交易收据里取input字段
  2. 解析函数选择器:前4字节,去4byte.directory查一下
  3. 解码参数:根据ABI规范,逐段解析
  4. 读取事件日志:交易收据里的logs字段,包含事件数据
  5. 关联存储变化:看stateDiff,确认哪些存储槽位变了

我记得有一次分析一个闪电贷攻击,就是通过对比交易前后的存储变化,找到了攻击者修改的slot。嗯,那种感觉,就像侦探找到了关键证据。

核心工具链:

  • ethers.jsInterface.parseTransaction() 解析input
  • web3.pycontract.decode_function_input()
  • cast(foundry):cast calldata-decode 命令行解码
  • 自己写脚本:用abi.decode() 手动解析

3.5 知识体系总览

下面这张图,是我自己总结的EVM数据模型全景。你把它存下来,以后分析任何合约,都按这个框架来:

EVM链数据模型全景图 账户模型(Account Model) 外部账户(EOA) 合约账户 Merkle Patricia Trie 存储布局(Storage Layout) 基本类型 slot 0,1,2... 动态数组 keccak256(slot) 映射(mapping) keccak256(key+slot) 结构体 连续slot ABI编码与解码 函数选择器(4字节) 参数编码(32字节对齐) 事件日志解码 三层模型:账户 → 存储 → ABI,逐层深入

你看,从账户模型到存储布局,再到ABI编码,这是一条完整的数据链路。我建议你每次分析一个新协议时,都先画一遍这个图——把合约的存储布局标出来,把关键事件的ABI写出来。嗯,这样做几次,你就发现链上数据不再是天书了。

我的习惯:forge inspect查看合约的存储布局,用cast storage读取具体slot,用cast logs解析事件。这三个命令,基本能解决90%的链上数据问题。

好了,这一章的内容就到这里。记住,数据模型是链上分析的基石。你把这个搞透了,后面分析任何DeFi协议,都只是套公式而已。


公众号:蓝海资料掘金营,微信deep3321