第二层"以太坊 & 智能合约"开篇。01–05 已经搭好"全网共识的账本 + 网络"。 这一篇开始进以太坊特有的部分——账本里到底装什么。比特币只装"谁有多少币"; 以太坊装"谁有多少币 + 一堆程序的当前数据"。这条升级带出 EOA / 合约账户 / nonce / 存储槽 这套词,而它们正是项目里 MultiPoolArbitrageur / LiquidationBot / dealUsdc / 0x6bdc vs 0x65A8 全部代码和实证的地面。结构见 README.md §1。


0. 一句话本质

以太坊把账本"一行"从"地址 → 余额"升级成"地址 → { 余额, nonce, 代码, 存储 }"。 由此地址分两类

  • EOA(外部账户):私钥控制;只有余额和 nonce;唯一能从外面发起交易的角色
  • 合约账户:部署时一次性写入代码;之后不能自己发起交易,只能被调用时按代码执行;有自己的存储。

整本账本 = 全部地址 → 它的 record 的映射 = 状态(state)。 每笔交易 = 一次状态转移;新状态的封条 = 03 的 state root。


1. 怎么产生的:它解决什么问题

01–05 留了一组以太坊层面才需要答的问题:

  1. 比特币只记"谁有多少币",以太坊为什么要换模型? —— 要让链上跑程序(智能合约), 光记币不够,还要记每个程序的状态数据(池子里有多少 USDC、谁欠 Aave 多少、…)。
  2. "地址"到底承载什么? EOA 只是个户名,合约地址承载一段代码 + 一堆状态。
  3. nonce 有什么用? 三件事:防重放 / 强制按序执行 / 决定 CREATE 部署合约的地址。
  4. 为什么 forge script 部署两次同一个合约,得到的地址可预测? —— 由 deployer + nonce 哈希决定。
  5. dealUsdc 怎么"凭空"给地址塞 USDC?为什么槽位是 9? —— Solidity 存储槽布局规则。
  6. 为什么合约不能像 EOA 那样发起交易? —— 没有私钥;账户类型从根上设计如此。
  7. 0x6bdc 6 笔入款、0x65A8 407k 笔——这些数字来自哪个字段? —— 它们的 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、0x6bdc0x65A8 Uniswap V2 pair、Aave V3 Pool、我们的 MultiPoolArbitrageur

一句话:EOA 是动作的发起者,合约是被动的反应器。两者都是账本上的"一行", 但能干的事不同。账户抽象(AA / EIP-4337)模糊了一点这条边界,但底层 EVM 模型 仍然是这样(见 §7 误区)。


4. 核心关键点(5 条)

  1. 两种账户,一套地址空间。同一段 20 字节地址,可能是 EOA、可能是合约、也可能是 空的(还没人用过)。判断方法:code.length > 0 ⇒ 合约。
  2. EOA = 余额 + nonce合约 = 余额 + nonce + 代码 + 存储。 合约的 代码不可改(一次性),存储随调用而变
  3. nonce 的三个作用(同一字段,根据账户类型语义略变):
    • 防重放:同一笔交易的 nonce 被消耗后不能再上链。
    • 强制按序:EOA 的交易必须按 nonce 递增进块。
    • 决定合约地址CREATE 地址 = keccak256(rlp(deployer, deployer_nonce))[-20:]。 这就是我们项目里 forge script DeployMultiPool 部署地址可预测的原因 (见 §6 + §8)。
  4. 存储 = 每个合约自己的小 key-value 数据库。索引是 32 字节"槽位"。 Solidity 编译器把变量布局成槽:定长变量按声明顺序占槽;mapping 则用 slot(key) = keccak256(abi.encode(key, mapping所在槽)) 散列定位。 这就是 dealUsdc 写 USDC 余额时用 keccak256(abi.encode(addr, 9)) 的根据 (USDC 的 balances mapping 在槽 9)。
  5. 全局状态 = 全部账户 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 DeployMultiPoolpnpm 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, Foundry deal 与 Anvil setStorageAt 都靠这条规则给地址"凭空"塞钱。
  • 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 / 部署日志 / dealUsdckeccak256(abi.encode(...)), 从"copy 来的咒语"升级为"知道每个槽 / 每个地址为什么是那个值"。


9. 自检问题(答得出才算过)

  1. EOA 和合约账户在以太坊状态映射 address → {balance, nonce, code, storage} 里各占了哪几格?哪些为空?
  2. 合约能不能"自己"发起一笔交易?AA / EIP-4337 改变了这一点吗,本质上?
  3. nonce 在 EOA 上、在合约上、在签名场景里(02 §5)分别是什么意思?三个 nonce 名字撞车,怎么不混?
  4. 我们项目 forge script DeployMultiPool 两次跑得到的合约地址有时一样有时不一样。规则是什么?什么时候会一样?
  5. dealUsdc 凭什么能"凭空"给地址塞 USDC?这能不能改主网(不是 fork)?
  6. EOA 有没有 storage?我们 TS 里为什么用 Map 维护 _LAST_MID_DIFF / _seenHashes,而 Solidity 合约里能直接写状态变量?
  7. 同一 EOA 地址 0x65A8… 出现在 33 条链——这是巧合还是必然?同一合约地址呢?
  8. 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.md Q3。