一、两类账户
以太坊回到了银行式的记账:地址 → 状态 的一张大表。但它有两种账户:
① 外部账户(EOA, Externally Owned Account)
由私钥控制,⭐ 只有它能发起交易
② 合约账户
由代码控制,没有私钥
不能主动做任何事——只能被调用
⭐ 这条规则值得反复强调:链上的一切都始于某个 EOA 发起的交易。
打个比方
合约像一台自动售货机:它的逻辑完全确定,投币就出货,从不打瞌睡。
但它不会自己启动。它没有闹钟,也没有员工。必须有人走过来按下按钮——而且这个人还得自己出电费(Gas)。
⚠️ 这一条最容易被忽略,而它的实际后果很大:一个合约里写着"每天 12 点分红",链上没有任何东西会在 12 点叫醒它。必须有人在 12 点发一笔交易去调用它,并且付钱。
这就是"keeper"“自动化机器人"这类角色存在的原因,也是很多协议设计里被漏算的一笔成本——你以为写下的是一条规则,实际上你写下的是一个"如果有人愿意付钱来触发"的规则。
没有"定时任务"、没有"到点自动执行"、没有后台线程。
一个合约里写着"每天 12 点分红",
⚠️ 也必须有人在 12 点发一笔交易去调用它,并且付 Gas。
这就是"keeper”、“自动化机器人"这类角色存在的原因,也是很多协议设计里被忽略的成本。
二、账户状态的四个字段
type Account struct {
Nonce uint64 // ⭐ 三重职责,见第三节
Balance *big.Int // 余额,单位 wei(1 ETH = 10¹⁸ wei)
StorageRoot [32]byte // 该账户存储的 MPT 根(第 12 讲)
CodeHash [32]byte // 代码的 keccak256
}
区分两类账户只看后两个字段:
EOA: StorageRoot = 空树根,CodeHash = keccak256("")
合约账户: CodeHash ≠ keccak256("")
⚠️ 由此得到一个安全上的坑:
// ⚠️ 常见的"只允许 EOA 调用"的写法:
require(msg.sender.code.length == 0);
// 它挡不住:合约在【构造函数执行期间】code.length 仍然是 0
// ⟹ 攻击者可以在部署合约的同时发起攻击调用,绕过这个检查
三、nonce 的三重职责
⭐ 售货机那个比方在这里还有一层:机器不会自己动,所以"这次投币是不是刚才那一次"必须由外面的人负责说清楚。 nonce 就是这个编号。
nonce 是一个只增不减的计数器,它同时承担三件事:
① 防重放
没有 nonce 会怎样?
Alice 签了"转 10 ETH 给 Bob"
⚠️ Bob 可以把这笔已上链的交易一遍遍重新广播
⟹ 每次都是合法签名,Alice 被反复扣款
有了 nonce:
交易里写明 nonce = 5
链上执行后 Alice 的 nonce 变成 6
⟹ 再提交同一笔交易,nonce 对不上,直接拒绝
② 定序
同一个账户的交易必须严格按 nonce 递增顺序执行:
待处理池里有 nonce = 5, 6, 7 三笔
⟹ 必须先执行 5,再 6,再 7
⚠️ 这带来了队头阻塞(head-of-line blocking):
nonce=5 的交易 Gas 价格给低了,一直没被打包
⟹ nonce=6、7 即使给了高价也无法执行
⟹ 整个账户卡死
⭐ 解法:用相同的 nonce=5 重发一笔更高 Gas 价格的交易("替换"),
通常是发一笔 0 ETH 给自己的空交易把 5 顶掉。
这正是钱包里"加速"和"取消"按钮的原理。
③ 派生合约地址
见第五节。
四、跨链重放与 EIP-155
nonce 防住了同一条链上的重放。但跨链呢?
2016 年 ETH/ETC 分叉后,两条链的状态完全相同:
同样的地址、同样的余额、同样的 nonce
⟹ ⚠️ Alice 在 ETH 上发的一笔交易,签名在 ETC 上同样有效
⟹ 任何人把它复制到 ETC 上广播,Alice 在 ETC 上的币也被转走了
这在当时造成了大量真实损失。EIP-155 的修复是把 chainID 纳入签名:
签名内容 = keccak256(RLP(nonce, gasPrice, gasLimit, to, value, data, chainID, 0, 0))
↑
⭐ chainID 进了哈希
⭐ 于是同一笔交易在不同链上的签名不同,天然无法重放。 第 7 讲第五节讲过,chainID 最终被编码进签名的 v 值里。
⚠️ 但这只保护了 EOA 交易。合约内部的签名验证(EIP-712 那类)必须自己处理 chainID,否则同样会被跨链重放——这是审计里的常见发现(第 35 讲)。
五、合约地址是怎么算出来的
CREATE:由部署者和 nonce 决定
地址 = keccak256( RLP([部署者地址, 部署者当时的 nonce]) )[12:]
⭐ 完全确定:知道谁部署、他当时 nonce 多少,就能算出地址。但你必须先知道 nonce,而 nonce 会变。
CREATE2:由部署者、盐和代码决定
地址 = keccak256( 0xff ‖ 部署者地址 ‖ salt ‖ keccak256(初始化代码) )[12:]
⭐ 不含 nonce ⟹ 地址完全可预测,且与部署时机无关。
这带来一个很有用的能力,反事实部署(counterfactual deployment):
你可以先算出合约地址,向它转账、把它写进其他合约,
⚠️ 而这个合约此刻根本还不存在。
需要时再部署即可——地址一定对得上。
账户抽象钱包(第 25 讲)大量使用这个技巧:
用户拿到地址就能收款,直到第一次发交易时才真正部署合约,省下部署 Gas。
历史上的坑:CREATE2 加上 SELFDESTRUCT,曾经允许在同一个地址上先后部署完全不同的代码:
① CREATE2 部署合约 A(用户审计了 A,认为安全)
② 调用 SELFDESTRUCT 销毁 A
③ ⚠️ 用同样的 salt 再次 CREATE2,部署恶意合约 B
⟹ 地址没变,代码变了。所有"我信任这个地址"的假设全部失效
坎昆升级的 EIP-6780 把 SELFDESTRUCT 限制为"只在同一笔交易内部署的合约才真正销毁”,基本堵死了这条路。但教训仍然成立:“地址不变 ⟹ 代码不变"在过去不是一个安全的假设,而很多协议曾经这样假设。
六、UTXO 与账户模型的完整对比
| 维度 | UTXO | 账户 |
|---|---|---|
| 状态表示 | 一堆未花费输出 | 地址 → 状态的映射 |
| 余额 | 算出来的 | 直接存着 |
| 防重放 | 天然(花过就没了) | 靠 nonce |
| 并行验证 | 天然支持(读写集显式) | 困难(执行前不知道碰哪些状态) |
| 隐私 | 较好(新地址无天然关联) | 差(账户就是身份) |
| 共享状态合约 | 极难 | 自然 |
| 交易体积 | 大(要列出所有输入输出) | 小 |
| 状态膨胀 | UTXO 集合只增不减 | 账户与存储槽只增不减 |
| 轻客户端查余额 | 要扫全链找属于你的 UTXO | 一个 Merkle 证明搞定 |
| 无状态验证 | 友好 | 需要额外机制(第 13 讲) |
⭐ 一句话概括这个权衡:
UTXO 把状态切成互不相干的小块,换来了并行和隐私,代价是无法表达共享状态。 账户模型把状态集中成一张大表,换来了合约能力,代价是天然串行。
为什么以太坊必须选账户模型
一个 AMM 池子的储备是全局共享的:
UTXO 下:池子是一个 UTXO
⟹ Alice 和 Bob 同时交易,两人都想花掉同一个 UTXO
⟹ ⚠️ 只有一个能成功,另一个必须重试
⟹ 高并发下几乎不可用
账户下:池子是一个合约账户,两笔交易顺序执行,各自更新余额 ✅
⭐ “共享可变状态"是 DeFi 的基础,而 UTXO 模型在设计上就排斥它。 这不是实现难度问题,是模型的本质差异。
七、两个混合方案
Solana:账户模型 + 强制预声明
Solana 用账户模型,但要求每笔交易预先声明它会读写哪些账户:
交易头里写明:
读账户: [A, B]
写账户: [C]
⟹ ⭐ 调度器可以据此判断哪些交易互不冲突,从而并行执行(Sealevel)
⭐ 这等于把 UTXO 的"显式读写集"优点移植到了账户模型上。
⚠️ 代价有两个:
① 编程模型复杂:开发者必须提前列出所有可能触碰的账户,
而这在动态调用(合约调合约)下很难精确知道
② 声明错了交易就失败,或者只能保守地多声明 ⟹ 并行度下降
Cardano:eUTXO
扩展 UTXO:给输出附加数据(datum),给花费附加校验脚本(validator):
⟹ 可以表达有状态的合约,同时保留 UTXO 的并行性
⚠️ 但"多人同时与一个池子交互"的冲突问题仍然存在,
需要额外的批处理设计(batcher)来缓解
八、Go 实现
package account
import (
"errors"
"math/big"
"golang.org/x/crypto/sha3"
)
type Address [20]byte
type Account struct {
Nonce uint64
Balance *big.Int
StorageRoot [32]byte
CodeHash [32]byte
}
type State map[Address]*Account
type Tx struct {
From Address
To Address
Value *big.Int
Nonce uint64
GasLimit uint64
GasPrice *big.Int
ChainID uint64 // ⭐ EIP-155:进入签名哈希,防跨链重放
}
// Apply 执行一笔转账。检查顺序很重要:
// nonce 和余额的检查必须在任何状态修改之前完成。
func (s State) Apply(tx *Tx) error {
from, ok := s[tx.From]
if !ok {
return errors.New("account: 发送方不存在")
}
// ① nonce 必须严格等于当前值 —— 这一条同时防重放和保证顺序
if tx.Nonce != from.Nonce {
return errors.New("account: nonce 不匹配")
}
// ② 余额必须覆盖转账额 + 最大可能的 Gas 费用
// 必须按 GasLimit 预扣,而不是按实际消耗——执行前无法知道实际消耗
maxGasCost := new(big.Int).Mul(new(big.Int).SetUint64(tx.GasLimit), tx.GasPrice)
total := new(big.Int).Add(tx.Value, maxGasCost)
if from.Balance.Cmp(total) < 0 {
return errors.New("account: 余额不足")
}
// ③ 修改状态
from.Nonce++
from.Balance.Sub(from.Balance, tx.Value)
to, ok := s[tx.To]
if !ok { // 收款方不存在则创建 —— 这就是状态增长的来源之一
to = &Account{Balance: new(big.Int)}
s[tx.To] = to
}
to.Balance.Add(to.Balance, tx.Value)
return nil
}
// CreateAddress 实现 CREATE:keccak256(rlp([sender, nonce]))[12:]
func CreateAddress(sender Address, nonce uint64) Address {
h := sha3.NewLegacyKeccak256()
h.Write(rlpEncodeList(sender[:], rlpEncodeUint(nonce)))
var out Address
copy(out[:], h.Sum(nil)[12:])
return out
}
// Create2Address 实现 CREATE2:keccak256(0xff ‖ sender ‖ salt ‖ keccak256(initCode))[12:]
// 不含 nonce ⟹ 地址与部署时机无关,可以提前算出来。
func Create2Address(sender Address, salt [32]byte, initCode []byte) Address {
ih := sha3.NewLegacyKeccak256()
ih.Write(initCode)
codeHash := ih.Sum(nil)
h := sha3.NewLegacyKeccak256()
h.Write([]byte{0xff})
h.Write(sender[:])
h.Write(salt[:])
h.Write(codeHash)
var out Address
copy(out[:], h.Sum(nil)[12:])
return out
}
九、本讲小结
- 两类账户:EOA 由私钥控制,合约账户由代码控制。⭐ 链上一切都始于某个 EOA 的交易——没有定时任务,“自动执行"必须有人付 Gas 去触发。
- 账户状态四个字段:nonce、balance、storageRoot、codeHash。用
code.length == 0判断 EOA 挡不住构造函数期间的合约。 - ⭐ nonce 一个字段承担三件事:防重放、定序、派生 CREATE 地址。
- 严格递增带来队头阻塞:低价的 nonce=5 卡住,后面全卡。解法是用同一 nonce 重发更高价的交易顶掉它——这就是钱包"加速/取消"的原理。
- 跨链重放是真实发生过的损失(2016 ETH/ETC)。EIP-155 把 chainID 纳入签名哈希解决了它。但合约内部的签名验证必须自己处理 chainID。
- CREATE 地址 =
keccak(rlp([sender, nonce]))[12:];CREATE2 =keccak(0xff‖sender‖salt‖keccak(initcode))[12:]。 - CREATE2 不含 nonce ⟹ 地址可预测 ⟹ 反事实部署:先给一个还不存在的合约转账,用时才部署。账户抽象钱包大量使用。
- CREATE2 + SELFDESTRUCT 曾允许同一地址换代码,EIP-6780 基本堵死。但"地址不变 ⟹ 代码不变"曾经不是安全假设,而很多协议这样假设过。
- 核心权衡:UTXO 把状态切碎,换来并行和隐私,代价是无法表达共享状态;账户模型集中状态,换来合约能力,代价是天然串行。
- AMM 池子的储备是全局共享的,UTXO 下多人并发会互相冲突——这是模型的本质差异,不是实现难度。
- Solana 把"显式读写集"移植到账户模型上换取并行;代价是开发者必须提前声明所有会触碰的账户,动态调用下很难精确。
思考题
- 一个合约写着"每月 1 号自动分红”。链上实际会发生什么?谁来付 Gas?如果没人调用会怎样?
- 为什么
require(msg.sender.code.length == 0)挡不住合约?写出攻击者的完整步骤。 - nonce 同时做三件事。如果把"防重放"和"定序"拆成两个独立字段,会带来什么好处和坏处?
- 你的 nonce=5 交易卡住了,nonce=6、7 都在池子里。请写出解除阻塞的具体操作,并说明为什么要用 0 ETH 自转。
- EIP-155 之前,一笔 ETH 主网交易能被重放到哪些链上?需要满足什么条件?
- 一个合约用 EIP-712 验证链下签名但没有把 chainID 放进域分隔符。攻击者能做什么?
- 用 CREATE2 公式说明反事实部署:给出"先收款、后部署"的完整时间线,并指出如果初始化代码改了会怎样。
- 为什么 UTXO 模型很难实现 AMM?请具体描述两个用户同时交易时会发生什么。
- Solana 要求预声明读写账户。如果一个合约会根据链上数据动态决定调用哪个合约,开发者该怎么声明?这会怎样影响并行度?
- 上面
Apply里为什么必须按GasLimit而不是实际消耗来预扣余额?