06 · 账户模型:EOA vs 合约账户、nonce、状态
第二层"以太坊 & 智能合约"开篇。01–05 已经搭好"全网共识的账本 + 网络"。 这一篇开始进以太坊特有的部分——账本里到底装什么。比特币只装"谁有多少币"; 以太坊装"谁有多少币 + 一堆程序的当前数据"。这条升级带出 EOA / 合约账户 / nonce / 存储槽 这套词,而它们正是项目里
MultiPoolArbitrageur/LiquidationBot/dealUsdc/0x6bdcvs0x65A8全部代码和实证的地面。结构见README.md§1。
0. 一句话本质
以太坊把账本"一行"从"地址 → 余额"升级成"地址 → { 余额, nonce, 代码, 存储 }"。 由此地址分两类:
- EOA(外部账户):私钥控制;只有余额和 nonce;唯一能从外面发起交易的角色。
- 合约账户:部署时一次性写入代码;之后不能自己发起交易,只能被调用时按代码执行;有自己的存储。
整本账本 = 全部地址 → 它的 record 的映射 = 状态(state)。 每笔交易 = 一次状态转移;新状态的封条 = 03 的 state root。
1. 怎么产生的:它解决什么问题
01–05 留了一组以太坊层面才需要答的问题:
- 比特币只记"谁有多少币",以太坊为什么要换模型? —— 要让链上跑程序(智能合约), 光记币不够,还要记每个程序的状态数据(池子里有多少 USDC、谁欠 Aave 多少、…)。
- "地址"到底承载什么? EOA 只是个户名,合约地址承载一段代码 + 一堆状态。
- nonce 有什么用? 三件事:防重放 / 强制按序执行 / 决定 CREATE 部署合约的地址。
- 为什么
forge script部署两次同一个合约,得到的地址可预测? —— 由 deployer + nonce 哈希决定。 dealUsdc怎么"凭空"给地址塞 USDC?为什么槽位是 9? —— Solidity 存储槽布局规则。- 为什么合约不能像 EOA 那样发起交易? —— 没有私钥;账户类型从根上设计如此。
0x6bdc6 笔入款、0x65A8407k 笔——这些数字来自哪个字段? —— 它们的 nonce 和接收的内部转账。
→ 这一篇答全部。
2. 它本质在做的"那一件事"
把"账本一行"从"币数"扩成"一个对象 { balance, nonce, code, storage }", 让"程序的状态"成为账本的一等公民;再用账户类型(有无代码、有无私钥)划分谁能动作、谁只能响应。
整本账本是一张巨大的 address → 上述对象 的映射;以太坊每块用 Merkle-Patricia trie
把这张映射哈希成一个 32 字节的 state root 写进区块头(03 §4.5)——
"账本现在长什么样"就被这一根封死了。
3. 类比:村庄升级——既有村民也有"公共售货机"
01–05 的村庄里只有村民。以太坊版村庄添了一类设施:
┌── 普通村民 = EOA ──────────────────┐
│ 户名(地址)由私章(私钥)推出 │
│ 账本记:余额 + nonce │
│ 唯一能"主动发起交易"的角色 │
│ 没有代码 / 没有存储 │
└──────────────────────────────────────┘
┌── 公共售货机 = 合约账户 ──────────────┐
│ 地址由"部署它的村民 + 部署时的 nonce" │
│ 确定(CREATE) │
│ 账本记:余额 + nonce + 代码 + 存储 │
│ 代码部署后**不可改**(写在墙上的死规则) │
│ 存储**可改**(机器内部计数器、库存) │
│ **不会自己动作**——必须被村民投币才执行 │
│ 可投币让售货机 A 再去投币售货机 B(合约 │
│ 调合约,但起点永远是 EOA) │
└──────────────────────────────────────┘
关键对照:
| 项 | EOA | 合约账户 |
|---|---|---|
| 控制 | 私钥 | 自己的代码(部署时一次性写死) |
| 能主动发交易? | ✅ 是 | ❌ 不能(无私钥) |
| 有代码? | ❌ | ✅ |
| 有存储? | ❌ | ✅ |
| nonce 含义 | 已发出交易数 | 已 CREATE 子合约数 |
| 例子 | 你的 MetaMask 地址、Anvil acct #0、0x6bdc、0x65A8 |
Uniswap V2 pair、Aave V3 Pool、我们的 MultiPoolArbitrageur |
一句话:EOA 是动作的发起者,合约是被动的反应器。两者都是账本上的"一行", 但能干的事不同。账户抽象(AA / EIP-4337)模糊了一点这条边界,但底层 EVM 模型 仍然是这样(见 §7 误区)。
4. 核心关键点(5 条)
- 两种账户,一套地址空间。同一段 20 字节地址,可能是 EOA、可能是合约、也可能是
空的(还没人用过)。判断方法:
code.length > 0⇒ 合约。 - EOA = 余额 + nonce;合约 = 余额 + nonce + 代码 + 存储。 合约的 代码不可改(一次性),存储随调用而变。
- nonce 的三个作用(同一字段,根据账户类型语义略变):
- 防重放:同一笔交易的 nonce 被消耗后不能再上链。
- 强制按序:EOA 的交易必须按 nonce 递增进块。
- 决定合约地址:
CREATE 地址 = keccak256(rlp(deployer, deployer_nonce))[-20:]。 这就是我们项目里forge script DeployMultiPool部署地址可预测的原因 (见 §6 + §8)。
- 存储 = 每个合约自己的小 key-value 数据库。索引是 32 字节"槽位"。
Solidity 编译器把变量布局成槽:定长变量按声明顺序占槽;mapping 则用
slot(key) = keccak256(abi.encode(key, mapping所在槽))散列定位。 这就是dealUsdc写 USDC 余额时用keccak256(abi.encode(addr, 9))的根据 (USDC 的 balances mapping 在槽 9)。 - 全局状态 = 全部账户 record 的集合。每笔交易是一次状态转移,区块头里的 state root 把转移后的整张映射封死 → 与 03 的 Merkle-Patricia trie 接上。 "以太坊是一个世界状态机"指的就是这个。
5. 必须先认识的前置名词(小词典)
| 名词 | 一句话 | 类比 |
|---|---|---|
| EOA (Externally Owned Account) | 私钥控制的普通地址 | 普通村民户头 |
| 合约账户 (Contract Account) | 部署在链上的程序 | 公共售货机 |
| bytecode / 代码 | 合约部署后写死在链上的 EVM 字节码 | 售货机墙上死规则 |
| codeHash | bytecode 的哈希;EOA = empty hash | 规则的指纹 |
| storage / 存储 | 合约自己的 32B → 32B key-value 库 | 售货机内部账本 |
| storage slot 槽位 | 存储中的一个 32B 位置 | 内部账本的一行 |
| storageRoot | 该合约存储的 Merkle 根,提交到全局 state root | 售货机内部账本的封条 |
| nonce | EOA:已发交易数;合约:已 CREATE 子合约数;用于防重放、排序、地址派生 | 序号 / 流水号 |
| state(世界状态) | address → {balance, nonce, code, storage} 全集 |
整个账本现在的快照 |
| state root | 全部状态的 Merkle 根(03 §4.5) | 整本账的总封条 |
| CREATE / CREATE2 | 部署合约的两种地址派生方式 | 售货机选址:CREATE 随 nonce / CREATE2 可预先决定 |
| EIP-55 校验和地址 | 大小写混写防错地址 | 户名上的防错字母 |
| receive() / fallback() | 合约接收纯 ETH / 未匹配函数时的入口 | 售货机的"投币口"和"默认处理" |
| selfdestruct | 销毁合约(Cancun 后基本无效,不依赖) | 拆售货机(基本禁用) |
| 账户抽象 (AA, EIP-4337) | 让合约也能"发起"交易(通过 bundler 中转 UserOp) | 智能售货机也能主动行动(仍需外部触发器) |
6. 端到端:部署一个合约 + 调一次它
把 02(签名)+ 03(状态根)+ 04(出块)+ 05(节点)+ 本篇的账户模型全串起来。
以我们项目里 forge script DeployMultiPool → pnpm demo 这条路径为例:
6.1 部署
准备:deployer EOA = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
(Anvil acct #0,PRIVATE_KEY=0xac09…,参 02 §3.2)
它的 nonce 在新 Anvil fork 启动后 = 0
① 构造 tx:{ from=deployer, to=null(部署), data=init字节码, value=0, nonce=0 }
② 02 签名 → 广播 → mempool(05 §3.2)
③ 出块(04 §6)→ EVM 执行 init 字节码:
- 计算合约地址:A = keccak256(rlp(deployer, 0))[-20:]
⇒ 0xDda8187E31Fb73112B650aF9261a956aFc822B51(项目日志里就是这个)
- 运行 init code → 返回 runtime bytecode
- 写入:account[A].code = runtime;account[A].codeHash = keccak(runtime)
- 现在 A 是一个合约账户,余额 0、存储 0、nonce 0
④ deployer 的 nonce 变成 1
⑤ state root(03 §4.5)随之更新;新区块头封条这一切
为什么我们每次新 Anvil fork 重启重部署,得到的合约地址都是同一个? 因为 deployer 同一个、nonce 同一起点(0)⇒ keccak 同样的输入 ⇒ 同样的地址。 这是部署"确定性"的全部秘密。
6.2 调用(例:搞个 swap)
① 用户 EOA 发 tx:{ to=合约地址, data=ABI编码的函数调用, value=0或ETH, nonce=用户当前 }
② 出块 / EVM 加载该合约的 code → 跳到对应函数 → 执行
③ 函数里可能:
- 读自己 storage:sload(slot)
- 改自己 storage:sstore(slot, value)
- 调另一个合约(CALL/STATICCALL/DELEGATECALL)→ 嵌套执行
④ 全程任何 require/revert 失败 → 整次 EVM 调用"全无效果"地回滚
(我们 Arbitrageur "失败只亏 gas" 就是这条;这条**原子性**承诺由 EVM 保证)
⑤ 成功 → 改后的状态写回 → 新 state root → 新块封条
→ "合约自己不发交易"在这里是字面真的:合约里的代码只在 ②–④ 内执行, 触发者永远是某个 EOA 在最外层签了 ① 那笔 tx。
7. 常见误区(逐条拍死)
| 误区 | 实情 |
|---|---|
| "合约也能像 EOA 那样主动发交易" | 不能。所有 tx 都由 EOA 在最外层签发。AA / EIP-4337 看起来打破了这点,但底层是 bundler 把 UserOp 包装成 EOA 发出的 tx;EVM 层模型未变 |
| "合约代码可以升级" | 不能。代码部署后不可变。"可升级合约"是设计模式(代理合约 + 实现合约),通过 DELEGATECALL 调底层实现合约;要升级就部一个新实现并改代理指向。规则没变,是套了壳 |
| "地址就是公钥" / "地址有 EOA 和合约两种长得不同" | 都不是。地址都是 20 字节,EOA 由 末20(Keccak(pubkey)) 来(02 §3.2),合约由 keccak(rlp(deployer,nonce))[-20:] 来。长得一样,靠 code.length 区分 |
| "EOA 也有存储" | 没有。EOA 只有 balance + nonce。这就是为什么我们 TS watcher 用 _LAST_MID_DIFF Map 记状态,而合约可以用 storage |
| "nonce 是 OG random 数" | 不是。nonce 是单调递增的整数(每发一笔 tx +1)。02 §5 提到的"签名 nonce 一次性随机"是另一回事(ECDSA k 值),名字撞车容易混 |
| "向合约转 ETH 永远能成功" | 仅当合约写了 receive() 或 fallback() payable。没有则 revert,ETH 被退回(消耗 gas 不退) |
| "同一地址在不同链是不同账户" | EOA 不是:同一私钥 → 同一地址,所有 EVM 链上是同一户名。 合约也不是:能否在多链部署到同地址取决于部署方式(CREATE 看 nonce,CREATE2 可跨链一致)。0x65A8 跨 33 链就是同一 EOA |
| "合约部署一定要消耗 ETH" | 是的,部署费 gas + init code 执行费;部署者必须有 ETH 付 gas(我们项目用 Anvil acct #0,预置 10000 ETH) |
| "selfdestruct 还能用" | Cancun 升级后基本失效(不再清代码/转余额),别在新代码里依赖 |
8. 与本项目实践的对应
把我们已经写过、跑过的代码,逐条接回这一篇的模型:
new Wallet(PRIVATE_KEY, provider)(watcher 各处)= 拿一个 EOA。 Anvil acct #0 私钥0xac09…⇒ 地址0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, EOA 派生路径见 02 §3.2,无 storage,符合 §4.2。forge script DeployMultiPool反复部署得到0xDda8187E31Fb73112B650aF9261a956aFc822B51= §3 售货机选址 + §6.1 端到端。CREATE 地址 = keccak256(rlp(0xf39F…, 0))[-20:], 确定性可预测。另一组日志里出现0x0392…= 同一 deployer 但同一 fork 内已部署过一次, nonce=1 时的派生结果。MultiPoolArbitrageur.sol/LiquidationBot.sol= 合约账户。代码不可改、 存储(pair 地址、债务参数等)部署后可变;它们不能自己发起arb()/liquidate(), 必须有 EOA 调用。这就是我们所有 demo.ts/test 里要有 deployer EOA 触发的原因。dealUsdc在 demo.ts 写 USDC 存储:slot = keccak256(abi.encode(recipient, 9))——直接对应 §4.4 "mapping 的槽 = keccak(key, 所在槽)"。USDC 的balances在 FiatTokenV2_2 实现的槽 9, Foundrydeal与 AnvilsetStorageAt都靠这条规则给地址"凭空"塞钱。require(...)revert 的"原子失败只亏 gas" = §6.2 第 ④ 步 EVM 原子回滚承诺。 Arbitrageur / LiquidationBot / FlashLoanHelloWorld 的安全性都建在这一条之上。0x6bdc...链上实证 = 6 笔全入款 = §4.3 nonce 等于"自己发出的 tx 数"—— 这地址 nonce 始终 0,所以理论上它从未部署过合约(CREATE 至少要 nonce++)。0x65A8...4 年 33 链 407k tx = EOA、跨链同地址(§7 误区第 7 条); 它一直作为 deployer 在多链播种合约也完全说得通——但仅看地址仍不能推策略。
→ 读完 06,回头看 demo.ts / 部署日志 / dealUsdc 的 keccak256(abi.encode(...)),
从"copy 来的咒语"升级为"知道每个槽 / 每个地址为什么是那个值"。
9. 自检问题(答得出才算过)
- EOA 和合约账户在以太坊状态映射
address → {balance, nonce, code, storage}里各占了哪几格?哪些为空? - 合约能不能"自己"发起一笔交易?AA / EIP-4337 改变了这一点吗,本质上?
- nonce 在 EOA 上、在合约上、在签名场景里(02 §5)分别是什么意思?三个 nonce 名字撞车,怎么不混?
- 我们项目
forge script DeployMultiPool两次跑得到的合约地址有时一样有时不一样。规则是什么?什么时候会一样? dealUsdc凭什么能"凭空"给地址塞 USDC?这能不能改主网(不是 fork)?- EOA 有没有 storage?我们 TS 里为什么用 Map 维护
_LAST_MID_DIFF/_seenHashes,而 Solidity 合约里能直接写状态变量? - 同一 EOA 地址
0x65A8…出现在 33 条链——这是巧合还是必然?同一合约地址呢? require(condition)失败时为什么说"整次调用全无效果"?这条承诺由谁保证?
── Q&A / 更正记录区(按日期追加)──
规范见
README.md§0。质疑/反馈/纠错按日期累积,不抹错误轨迹。
-
(2026-05-20 建立。暂无 Q&A。)
-
(2026-07-26) 问:详细讲一下 Merkle Patricia Trie 的具体结构和查找过程。 答(补充 state root 背后 MPT 的结构细节,完整版见
03-区块与链式结构.md同日条目,未更正原文):- state trie 的 key 是
Keccak256(address)(Secure Trie),value 是账户的 RLP[nonce, balance, storageRoot, codeHash];每个合约账户的storageRoot又指向它自己独立的一棵 storage trie(key =Keccak256(存储槽编号))——这解释了为什么"整本账本"(§1 提到的address → 账户对象映射)在底层实际是两层嵌套的 MPT:外层 state trie 存账户,内层 storage trie 存每个合约的变量。 - 改一个账户余额/一个存储槽,只需沿该 key 对应路径重算到根,不影响其他账户/其他合约的子树——这是"每块都要更新几千条状态还能实时算完"的工程基础。
完整原始记录见
../total/Q&A/2026-07-26.mdQ3。
- state trie 的 key 是