关键词:以太坊、Account 模型、StateObject、合约存储、Go-Ethereum、私钥、StateDB、MPT
一、为什么用 Account 模型而不是 UTXO
与比特币的 UTXO 不同,以太坊采用 基于账户的状态机模型。简言之:
Account代表链上的参与者,既可以是普通用户(EOA),也可以是智能合约(Contract)。State是某个时刻账户数据的快照。- Transaction(交易)驱动状态机:一笔成功的交易会修改账户余额、Nonce、合约存储等字段,实现「当前状态 → 新状态」的转移。
二、Account 的两种角色
2.1 外部账户(EOA)
- 控制权:由私钥唯一确定。
- 作用:发起交易、触发合约创建、调用写函数。
- 数据组成:
Balance(ETH 余额)、Nonce(发送交易计数器),合约字段为空。
2.2 合约账户(Contract)
- 创建方式:由 EOA 发送部署交易后自动生成。
数据组成:
- 与 EOA 相同的
Balance、Nonce。 CodeHash:合约字节码哈希,链上不可变。Root:指向合约独立存储空间(Storage Trie)的 Merkle 根。
- 与 EOA 相同的
逻辑区分:
- 只读函数:无需发送交易,直接 RPC 调用。
- 写函数:必须构造交易并被打包后才生效。
👉 了解如何编写安全且省 Gas 的智能合约,立即深入更多实战技巧!
三、stateObject:Account 的内存映射
Go-Ethereum 把链上账户映射成内存结构 stateObject(源码:core/state/state_object.go)。关键字段:
| 字段 | 说明 |
|---|---|
address | 20 字节 ETH 地址 |
data | 公开的 types.StateAccount,含 Nonce、Balance、Root、CodeHash |
db | 指向 StateDB 的指针,方便在内存完成增删改查 |
trie / code / *Storage | Storage Trie、字节码及多层缓存 |
理解这些字段交互,就能看透「账户钱变少了、合约存了一个新 Map」在软件层面到底发生了什么。
四、StateDB:整个「世界状态」的调度器
- 定位:介于磁盘 LevelDB 与高层的
stateObject之间。 工作流程:
- 用
StateDB.GetOrNewStateObject(address)获取/新建 account。 stateObject.SetState(key, value)在 dirtyStorage 里做标记。- 当前区块所有交易完成后,统一把
pendingStorage刷回磁盘并更新全局WorldStateRoot。
- 用
StateDB 的设计减少了磁盘随机写,也让我们在调试时能快速做「回滚」到某一区块快照。
五、账户钥匙:从私钥到地址的完整推导
EOA 与 Contract 的根本区别是——谁握有私钥。任何外部账户的创建本质只依赖三件事:
- 随机生成 32 字节私钥:
ecdsa.GenerateKey(S256, rand.Reader) 从私钥→公钥→地址:
- 公钥由 secp256k1 曲线点乘得出 64 字节坐标。
- 地址则是 Keccak-256(公钥)[-20:]。
- Keystore 加密:用用户提供的密码把私钥加密后写文件,本地安全保存。
只要 私钥不泄露,你的原生 ETH 无人能转走。但请注意——ERC-20 或 NFT 本质是合约里的数值,安全性还取决于合约本身。👉 关注合约审计与防黑客的实战指南
六、合约存储(Storage)内幕
6.1 Slot:合约的「硬盘扇区」
- 每个合约可拥有 $2^{256}-1$ 个 slot,每 slot 32 字节。
- slot 寻址:
position = keccak256(key ∥ slotSlot),Solidity 全靠它来支持 Mapping、Array。 - Merkle Patricia Trie 保证各节点对 Storage 有一致的哈希视图。
6.2 代码到存储的映射示例
源码与调试过程(Remix + debug_storageRangeAt):
contract Storage {
uint256 public number;
mapping(address => uint256) public balances;
}uint256 number→ slot 0;balances[addr]→ slotkeccak256(addr ∥ 1)。
这一方法的优点是:不论合约规模如何,所有验证者都能保证同一 key 查询到同一 value。
6.3 变量打包:Gas 优化小细节
当连续出现不足 32 字节的定长变量(如 uint128, address, bool)时,编译器将把它们合并到同一 slot,减少存储槽位开销。但随之而来读写放大:哪怕只改 1 byte 也得重写整个 32 byte。
七、常见疑问 Quick FAQ
Q1:EOA 不存代码却为何仍有 CodeHash?
在 Go-Ethereum 里 stateObject.data.CodeHash 对 EOA 是一个空占位,等于 crypto.Keccak256(nil),用统一结构可降低复杂性。
Q2:为什么 Uniswap 的合约存储 balances 不直接用地址做 key?
如上所述,EVM 会把 address 和变量声明 slot 再行哈希,避免冲突且能在 Trie 上均匀分布,提升 Denial of Service 的门槛。
Q3:如何离线查看某账户实时余额?
直接查询 StateDB 对象可读到最终 Balance。离线环境下,可把 LevelDB 数据目录作为 --datadir 启动一个 pruning 节点,再调用 eth.getBalance() RPC。
Q4:合约升级是否能绕过「字节码不可变」?
不能。可升级方案实际上是将存储与逻辑分离:一个 Proxy 合约保存实际数据,delegatecall 到一个可替换的 Logic 合约地址;原字节码仍不能被修改。
Q5:为什么 32 字节变量反而比 uint8 便宜?
在一次操作中,读写任意长度≤32 字节都需花 20000/5000 gas;小于 32 字节的变量如果落在共享 slot 会触发额外 SLOAD/SSTORE,总和反而更高。
下一步预告
在系列第二篇,我们将深入 StateDB 结构与 stateObject 在交易执行中的生命周期,带你亲手“断点”追踪一次转账从 Transaction Pool 到链上收据的全过程。