Crypto OS
Technical Crypto OS第一阶段 · Blockchain Developer Mental Model

T5 · EVM Account Model

EOA 和合约账户,差别到底在哪?

练习的能力
BuilderProtocol Literacy
动手
用 RPC 直接读一个合约的某个 storage slot,和它的 getter 返回值比对。
AI Lab
让 AI 计算一段代码的 Gas 构成,自己用实际交易回执核对差多少。

一个现实问题

产品要做一个团队金库:三个人里有两个同意才能出钱。

技术方案会上出现两种说法。一种说「用一个多签钱包地址收钱就行」,另一种说「部署一个金库合约」。你去看这两个方案的产出物,发现从外面看一模一样:都是 0x 开头的 40 个十六进制字符,都能收钱,都能在区块浏览器上查到余额。

于是你问了三个问题,没人能干脆地回答:

  • 这个地址能不能自己发起一笔交易?
  • 往这个地址转钱,会不会失败?
  • 这个地址里的规则,谁能改?

再往下想,还有一个更基础的问题:**你部署的那份合约代码,和它记住的那些数据,到底存在什么地方?**你能像读数据库一样把它们读出来吗?

从外面看长得一样的两个地址,差别到底在哪?

思想实验

把整条链想成一台全球唯一的计算机,它只有一张巨大的表。

表的 key 是地址,value 只有四个字段:

nonce         一个计数器
balance       余额
codeHash      一段代码的摘要
storageRoot   这个地址自己那张小表的摘要

全部的区别就在于 codeHash 这一格是不是空的。

空的,这个地址就是一把钥匙对应的位置,它的主人能用私钥签名发起交易,它自己那张小表也永远是空的。

不空的,这个地址上挂着一段代码和一张属于它的小表。它不能自己发起任何事,但当有人调用它时,那段代码会跑,跑的过程中可以读写那张小表。

现在推三件事:

**第一件:这台计算机没有时钟。**没有 cron,没有定时器,没有后台线程。它的全部生命就是「有人发来一笔交易 → 执行 → 状态变了 → 停下来等下一笔」。在两笔交易之间,它完全静止。

第二件:这台计算机上的所有计算都要付钱。因为全世界每个节点都要把同一段代码跑一遍并存下结果,所以每一个操作都有价码。而其中最贵的,是往那张小表里写东西——因为写进去的东西要被所有节点永久保存

**第三件:只有第一种地址能付钱。**合约账户没有私钥,签不了名,所以任何一条执行链的起点,必然是某个有私钥的人签了一笔交易。合约再复杂,也只是被这条链拽着动。

现在回到开头:那两个方案不是「一样的东西」。它们在这张表里的第三格完全不同,于是在「能不能自己动」「钱进来时会不会有代码跑」「规则写在哪儿」这三件事上全都不同。

你来决定

需求:一个存款合约,要给存款人每天发一次利息。你怎么实现?

观察结果

四个选项的共同结构,用一句话收:每一次状态变化,都必须有一个持有私钥的人签了一笔交易,并为这次变化付钱。

方案谁触发谁付费引入了什么依赖
合约自己定时不存在
谁交互谁结算下一个来交互的人那个倒霉蛋无,但结算时机不可控
自己跑定时任务你的服务一个必须在线的中心化服务
用户自己领用户用户无,但用户要知道并愿意付费

从这张表里能读出两条更一般的结论:

**一、「谁付费」是一个产品设计问题,不是技术细节。**你把成本放在哪一方,直接决定了功能的形态。想把用户的费用降为零,你就得自己承担,于是你就有了热钱包和运维。

二、合约的数据结构是被 Gas 逼出来的。「遍历所有用户」在传统后端里是一行循环,在这里是一笔会失败的交易。所有链上协议看起来奇怪的设计——累计指数、惰性结算、Merkle 名单——都是同一个约束下的产物。

建立模型

一张表,四个字段:

地址 → {
  nonce         EOA:已发出的交易数;合约:已创建的合约数
  balance       以 wei 计的原生币余额
  codeHash      空 → EOA;非空 → 合约账户
  storageRoot   这个地址的 storage 树的根
}

两种账户的对照:

EOA合约账户
怎么产生一个私钥推导出来,不需要上链由一笔交易部署出来
能否主动发起交易能,这是唯一的起点不能
有代码吗没有
有 storage 吗没有
nonce 的含义发出去的交易序号创建过的合约个数
收到原生币一定成功可能失败,取决于它的代码
规则谁能改私钥持有人看合约怎么写,可能没人能改,也可能一个地址说了算

最后两行是开头那个金库问题的答案。往合约地址转钱可能失败这一点尤其值得记住:如果它没有接收原生币的逻辑,你的转账会回滚,而手续费照扣(T3 讲过)。

storage 长什么样:

每个合约有自己独立的地址空间,编号从 0 开始,每个槽 32 字节。布局规则是确定的:

变量类型它在哪个槽
定长变量按声明顺序从 0 排,能塞进同一个 32 字节的会被打包在一起
动态数组声明位置那个槽存长度,元素从「那个槽号的哈希」开始连续排
映射声明位置那个槽空着,某个 key 的值存在「key 和槽号拼起来再哈希」的位置

**这意味着链上没有「私有变量」。**Solidity 里的 private 只约束其他合约的代码不能读它,它挡不住任何人在链外直接按槽号把它读出来。下面「真实案例」有一个因此损失惨重的例子。

Gas 的结构:

一笔交易的费用由几块组成,从贵到便宜大致是:

写一个从零变非零的 storage 槽     最贵,因为所有节点要永久多存一份
写一个已经非零的 storage 槽       贵,但明显便宜于上一种
读一个 storage 槽                 中等;同一笔交易里第二次读同一个槽便宜很多
在 memory 里计算                   便宜,交易结束就丢掉
读 calldata                        最便宜,它本来就在交易里
交易基础开销 + 每字节 calldata 费  固定部分,和你的代码无关

三条能直接指导写代码的推论:

  1. 省 Gas 的第一优先级永远是「少写 storage」,其次是「少读 storage」,最后才是优化计算逻辑。把一个循环从 O(n) 优化到 O(log n),可能还不如把一次 storage 写入挪到循环外面。
  2. **同一笔交易里,第一次碰某个槽和后面几次的价格差很多。**这是 T8 会展开的「冷热访问」,它让 Gas 优化变成一件必须实测的事。
  3. **具体的数字会随着协议升级而变,所以不要背。**要背的是相对关系,以及「用 eth_estimateGas 和真实回执去量」这个习惯。

它叫什么

EOA外部拥有账户

由私钥控制的账户。地址是公钥的摘要(T2 讲过),不需要任何上链操作就存在——你可以给一个从未被使用过的地址转账。

它是所有执行的唯一起点:链上每一条调用链,往上追都能追到一个 EOA 签的名。

Contract Account合约账户

由一笔部署交易创建、带着代码和 storage 的账户。没有私钥,不能主动发起交易。

它能做的事只有一件:被调用时执行自己的代码。代码在部署后通常不可更改——「升级」实际上是靠代理模式把调用转发到另一个地址,T8 会讲这件事怎么把人坑到。

Nonce序号

这个词在 EOA 和合约账户上指两件不同的事:EOA 上是已发出的交易数(T3 那个让交易卡住的东西),合约账户上是它创建过的合约数。

顺带解释了一件事:合约创建的新合约,地址是由创建者地址和它的 nonce推导出来的,所以是可预测的。

Storage Slot存储槽

合约自己那张表里的一格,编号 32 字节,内容也是 32 字节。

它是链上最贵的资源,也是唯一在交易结束后还留下来的东西。理解 Gas 的第一步,是理解为什么写它这么贵:因为全世界每个节点都要永久保存这 32 字节。

Gas燃料

对计算量和存储占用的计价单位。每个操作有固定的 Gas 价码,你付的钱等于「消耗的 Gas」乘以「单位价格」。

它的本质不是收费,是给一台共享计算机的资源定价,防止有人用一个死循环让全世界一起卡住。T3 讲了它的两个上限参数怎么各自把你坑到。

World State全局状态

所有账户那张大表的总和。区块头里存的是它的摘要(状态根),而不是它本身。

这解释了 T1 里那句话:状态不是被「写」进链里的,它是把所有交易依次执行一遍推导出来的结果。链上只承诺了这个结果的摘要。

动手

动手用 RPC 直接读一个合约的 storage slot,和它的 getter 比对任意 RPC 端点 + curl + 一个 keccak256 工具0 元,全部是只读查询

**全程只读,不发任何交易,不需要私钥,不涉及任何真实资产。**可以用测试网,也可以用主网的只读端点——读取不花钱。

先学会区分两种账户。

curl -s -X POST <你的-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode",
       "params":["<一个地址>","latest"]}'

返回 0x 就是 EOA,返回一长串字节码就是合约账户。这是唯一可靠的判别方法,从地址字符串本身看不出任何区别。

拿几个地址试:你自己的钱包、一个代币合约、一个你随便编的地址。注意最后一个也会正常返回 0x——一个从未存在过的地址和一个 EOA,在链上没有区别。

看看合约账户的 nonce 是什么意思。

curl -s -X POST <你的-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount",
       "params":["<一个合约地址>","latest"]}'

找一个会创建子合约的工厂类合约来试,你会看到一个非零的值。它不是「这个合约发过多少交易」(合约发不了交易),而是它创建过多少个合约

读一个合约的 slot 0。

curl -s -X POST <你的-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt",
       "params":["<一个代币合约地址>","0x0","latest"]}'

返回 32 字节。找一个在区块浏览器上已验证源码的代币合约,对照它的源码看第一个状态变量是什么,然后试着把这 32 字节解释成那个变量的值。

如果第一个变量是字符串或者几个短变量被打包在了一起,你会发现结果不直观——这正是要看的东西:storage 是按 32 字节的槽排的,不是按变量排的。

用 eth_call 读同一份数据的 getter。

curl -s -X POST <你的-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"<同一个代币合约地址>",
        "data":"<totalSupply 的四字节选择器>"},"latest"]}'

函数选择器是「函数签名字符串的 keccak256 的前 4 字节」。totalSupply() 的选择器是一个固定值,你可以自己算一遍来验证——这是本章第一次真正用到 T2 的哈希。

找到 totalSupply 存在哪个槽。

已验证源码的合约,按声明顺序数状态变量,第几个就大致是第几个槽(注意打包规则)。数出来之后,用 eth_getStorageAt 读那个槽,和上一步 eth_call 的结果比对。

**两个值应该完全一致。**一个是合约替你读的,一个是你绕过合约直接读的。

如果不一致,可能是你数错了槽,也可能这个合约用了代理模式——数据在代理的 storage 里,而你在读实现合约。这本身就是一个很好的发现。

读一个映射里的值,这是最有价值的一步。

balanceOf 是一个映射。某个地址的余额存在哪?规则是:

槽号 = keccak256( 左补零到32字节的地址  ++  左补零到32字节的映射声明槽号 )

两个 32 字节拼成 64 字节,做一次 keccak256。用任意 keccak256 工具算出来(如果你装了 Foundry,查一下 cast 有没有直接算映射槽号的子命令;没有也没关系,手动拼接再哈希一样)。

然后:

curl -s -X POST <你的-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt",
       "params":["<代币合约地址>","<你算出来的槽号>","latest"]}'

再用 eth_callbalanceOf 对比。两个值一致的那一刻,你就真正理解了合约存储是怎么回事。

对不上的常见原因:地址没转小写、没左补零到 32 字节、拼接顺序反了(是 key 在前、槽号在后)、或者这个合约的继承层次让你数错了槽号。挨个排查一遍,每一个都是一次有用的教训。

加一个区块标签,体会一下它的威力。

把上面任意一个查询的 latest 换成一个历史高度,你就读到了那个时刻的状态。

如果端点报错,说明它不是归档节点——这就是 T4 讲的那个成本陷阱在你自己手上发生一次。

AI Lab

AI Lab让 AI 拆解一段代码的 Gas 构成,你用真实回执核对差多少Level 2 · AI Copilot

分三步。

第一步:
这段 Solidity 代码被调用一次,请把它的 Gas 消耗拆成:
交易基础开销、calldata 成本、storage 读、storage 写、内存与计算、日志。
每一项给出数字和依据,最后给一个总数。
注明你用的是哪个协议版本的定价。

第二步:
这段代码第一次被同一个地址调用,和第二次被调用,
Gas 会差多少?差在哪几项?为什么?

第三步:
给我三种改写方案,每种说明省下多少,以及牺牲了什么。

然后去测:在测试网上把这段代码部署了、调用两次、把两次的回执拉下来。

curl -s -X POST <你的测试网-RPC-地> \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["<哈希>"]}'

gasUsed,和它给的总数对比。

**这个 Lab 的重点是第二步和那个差值。**Gas 定价被多次协议升级改过(冷热访问的引入、退款额度的大幅削减),而模型学到的是各个版本混在一起的语料。它给出的数字经常是几个版本的拼接,看起来很专业,加起来对不上。

拿到差值之后,把真实回执贴回去问它「差在哪一项」。它第二次的回答通常准确得多——因为你给了它一个可验证的锚点,而不是让它凭记忆背数字。

这就是让模型算 Gas 的正确用法:用它来生成假设,用链来验证假设。

AI 说完之后,你必须自己验证

  • 它给的每个操作的 Gas 数字,是不是最近一次协议升级之后的值,还是旧版本的旧价
  • 它有没有区分「从零写入一个槽」和「修改一个非零的槽」,两者价格差很多
  • 它有没有算冷访问与热访问的差别,同一笔交易里第二次碰同一个槽要便宜得多
  • 它算的 calldata 成本有没有区分零字节和非零字节
  • 它有没有把退款上限算错,或者用了一个已经被移除的退款规则
  • 它给的 opcode 名、编译器选项或测试框架的用法真实存在,你能在官方文档里查到
  • 它估算的总量和你实际回执里的 gasUsed 差了多少,差超过一成就要问它差在哪

真实案例

代理合约的存储冲突

升级模式的做法是:用户调用代理地址,代理把调用转发给实现合约,代码来自实现合约,storage 用的是代理自己的

问题在于两边的变量都是从槽 0 开始排的。代理自己要存「实现合约地址在哪」,如果它就存在槽 0,而实现合约的第一个变量也在槽 0,那么实现合约里一次普通的赋值,就会把代理指向的实现地址改掉

行业的解法是把代理的关键变量放在一个由固定字符串哈希出来的伪随机槽号上,让碰撞概率低到可以忽略。这是一个标准化的方案,所有主流代理实现都遵守。

记住这个例子,它是「storage 是地址的属性,不是代码的属性」最直接的证明。

private 不是私有的

有合约把一个随机种子、一个口令哈希、或者一份尚未公布的名单存在标记为 private 的状态变量里,以为外部读不到。

任何人一条 eth_getStorageAt 就能读出来。private给编译器看的可见性修饰符,它约束的是其他合约的代码,不是链外的读取。

这类问题在链上抽奖、链上竞猜、盲拍类的合约里反复出现,后果是有人能提前知道结果并据此下注。链上没有秘密,需要保密就得用承诺加揭示这类结构。

Gas 退款被做成了一种代币,又被一次升级废掉

早期的 EVM 对「清空一个 storage 槽」给出可观的退款。于是有人做出这样的东西:在 Gas 便宜时批量写入大量无意义的 storage,在 Gas 贵时清空它们换取退款——相当于把便宜时候的 Gas 存起来。

这类代币一度有真实的交易市场。后来一次协议升级大幅削减了退款额度,这条路径当场失效。

**这件事的教训不是关于退款,是关于「建立在 Gas 定价细节上的商业模式」有多脆弱。**定价是共识规则的一部分,它会变。

没人来调用,清算就没发生

借贷协议的清算不是自动的。合约里写的是「满足条件时允许任何人来清算,并给清算者一份奖励」。

极端行情下曾出现过奖励不足以覆盖手续费的情况,于是没有人愿意来调用。头寸已经资不抵债,链上却什么都没发生,损失最终由协议承担。

这是「链上没有 cron」这件事最贵的一次演示。任何依赖「有人会来调用」的设计,都必须把那个人的收益算清楚。T22 会完整讲这套机制。

改一个变量

如果storage 免费

每个人都会把任意数据往链上塞,状态会无限膨胀,节点的磁盘和内存需求会迅速超过普通硬件,最后只剩下极少数机构能跑得起节点。

那时链就不再是「任何人都能独立验证」了。

所以 Gas 不只是收费机制,它是维持去中心化的一道闸门。这也解释了为什么关于「已经写入的状态要不要持续收费」的讨论会一直存在——一次性收费换永久存储,账其实是不平的。

如果EOA 也能带上代码

那么「发起交易的账户」和「有逻辑的账户」的边界就消失了:你的钱包地址可以在一笔交易里临时拥有代码,从而实现批量操作、代付手续费、限额与社交恢复。

这个方向的提案已经在推进,它会把本章那张对照表里的好几行改写掉。但底层的模型不变:还是那张表、那四个字段、那台没有时钟的计算机。

**这也是为什么要先学模型再学 API。**API 每年在变,这四个字段没变过。

如果一笔交易能并行执行

在这套模型下做不到。因为一笔交易要改哪些账户的哪些槽,必须真的执行一遍才知道(T1 讲过同一件事)。调度器没有办法提前判断两笔交易会不会冲突。

有一个可选的机制允许交易提前声明它会访问哪些地址和槽,但它只影响 Gas 计价,不是强制的,也不用于调度。

**如果把「提前声明」变成强制要求,并让调度器据此分组,你就得到了另一套模型。**那正是 T6 的内容。

如果合约代码可以随时被改写

所有「代码即规则」的承诺都会失效。用户和你交互时,没有办法确定下一秒的规则是什么。

现实是一个折中:代码不可变,但可以通过代理把调用转发到新地址。于是问题变成了谁有权切换那个地址——答案往往是一个多签,甚至一个单签地址。

**看一个协议是不是「去中心化」,看它的升级权限在谁手里,比看它的白皮书有用得多。**这是 T23 的核心问题之一。

带走的问题

5
谁在支付?

链上每一次状态变化都有人在付费,而且付费的必然是一个 EOA。这一章的四个选项其实是在回答「让谁付」:协议自己付(需要热钱包和运维)、下一个用户付(可能很贵)、受益人自己付(可能小到不值得领)。这个选择决定产品形态,不是实现细节。

9
谁承担风险?

storage 里的东西全世界可读,这意味着「把敏感数据放进 private 变量」的风险由你的用户承担,而且他们不会知道。同理,合约的升级权限落在谁手里,谁就掌握着所有存款人的风险敞口。这两件事都要在设计阶段写清楚,不能等审计时再说。

10
激励是什么?

Gas 是一套激励:它让浪费全网资源的行为变贵,让「有人愿意来调用」这件事必须自带收益。清算没人做、结算没人触发、名单没人领,根源都是这套激励在某个边界上失效了。设计任何依赖外部调用的机制时,先算那个人赚不赚钱。

本章自测

一句话带走

EVM 是一台全局状态机,账户是状态的索引,Storage 是最贵的那部分。

做完这一章的动手环节了?勾上它查看全部进度

本页目录