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

T6 · Solana Account Model

为什么 Solana 的程序自己不存数据?

练习的能力
BuilderProtocol Literacy
动手
在 Devnet 上创建一个账户并写入数据,再用 CLI 读出它的 owner、lamports 与 data。
AI Lab
让 AI 对比 EVM 与 Solana 的账户模型,再让它说明为什么 Solana 能并行执行。

一个现实问题

你在做一个跨链资产看板。EVM 那半边已经写完了——T5 教过你:找到代币合约,算出 balanceOf 映射里那个地址对应的槽,读出来就是余额。逻辑很清爽:数据在合约里,合约按地址索引。

现在轮到 Solana。你照搬思路:找到那个代币的程序地址,去它里面找存着所有人余额的表。

**找不到。**那个程序地址里一个字节的余额数据都没有,它只有代码。

你换个方向,去看一个用户的钱包地址。这次更奇怪:这个地址下面挂着七八个你从没见过的地址,每一个里面只装着一种代币,而且这些地址你的用户从来没有创建过、也不认识。

更麻烦的是产品那边的需求:给一个新用户转一笔代币过去,你发现这笔转账会失败——目标那边根本没有能装这种代币的地方,你得先花钱给他建一个。

三个问题摆在这:代币余额到底存在哪?那些凭空冒出来的地址是谁生的?为什么给一个地址转账需要先「建」点什么?

思想实验

把 T5 那台全球计算机拆开。

T5 的模型是:**一张大表,地址是 key,value 里同时装着代码和数据。**代码和它的数据绑在一起,谁也离不开谁。

现在把它拆成两半:

代码搬到只读的地方去。一段程序被部署之后就是一个只能读、不能写的东西。它没有任何属于自己的存储空间。它是一个纯函数

**数据散落成一个个独立的文件。**每个「文件」有自己的地址,里面装着:

lamports      这个文件里放了多少钱
owner         哪一段程序有权修改它的内容
data          一串裸字节,怎么解释由 owner 那段程序说了算
executable    它是不是一段可执行的代码

注意 owner 这一格。它不是「谁拥有这笔钱」,而是**「谁有权改这串字节」**。你的钱包能签名,但你的签名并不能直接改写一个代币账户里的数字——只有代币程序能改。你的签名只是授权它去改。

现在推下去:

第一步:程序既然没有存储,它怎么知道去改哪个文件?只能由调用方告诉它。于是一笔交易里必须列出这次要碰的所有文件的地址

第二步:既然每笔交易都提前列出了自己要碰哪些文件、是读还是写,那么两笔交易只要写的文件不重叠,就可以同时跑。

这就是全部的秘密。并行不是靠更快的机器换来的,是靠「强制提前声明」换来的。

第三步:可是文件地址从哪儿来?如果每个用户的积分账户都是随机生成的地址,那程序下次怎么找到它?必须有一种办法,让「这个程序 + 这个用户」能确定性地推导出同一个地址——而且这个地址不能有私钥,否则任何人拿着私钥就能绕过程序乱改。

把这三步连起来,开头那三个问题就全有答案了:余额存在一个个独立的文件里;那些凭空冒出来的地址是被确定性推导出来的;转账前要先「建」的,就是那个装代币的文件本身——因为它要占用存储,所以得有人为它付钱。

你来决定

一个程序要记住每个用户的积分。数据存在哪?

观察结果

四个选项都在回答同一个问题:怎么给数据寻址。

EVM(T5)Solana
数据放在哪合约自己的 storage一个个独立账户
怎么索引到某个用户映射:key 和槽号拼起来哈希推导地址:种子和程序地址拼起来哈希
谁能改这份数据那个合约的代码账户的 owner 字段指向的程序
谁为存储付费调用者付 Gas,一次性创建者在账户里存够钱,关闭时可取回
能否并行不能,要执行完才知道碰了什么能,交易提前声明了读写集

两边的哈希寻址其实长得非常像——都是「把一个 key 和一个命名空间拼起来做哈希」。

真正的分叉在最后两行:

**一、付费模型不同。**EVM 是买断制:写进去付一次钱,永久保存,不退。Solana 是押金制:按账户大小存一笔钱进去,关掉账户时能取回。后者把「状态膨胀」这件事显式定价了。

**二、并行与否,取决于调度器能不能提前知道冲突。**EVM 没法提前知道(T5 最后一节讲过),所以只能串行。Solana 强制你说出来,所以能分组并行。

这一条是理解整章的钥匙:Solana 的所有别扭之处——要列账户、要建账户、要推导地址——都是为了换那一件事。

建立模型

一个账户的完整字段:

address       32 字节的地址(就是公钥本身,不像 EVM 要再哈希一次)
lamports      余额,1 SOL = 10 的 9 次方 lamports
owner         有权修改 data 的那个程序
data          裸字节,格式由 owner 定义
executable    true 表示这是一段程序

三个推论,每一个都对应一类真实的 bug:

  1. **「钱包地址」和「代币账户」是两个不同的账户。**前者的 owner 是系统程序,装原生币;后者的 owner 是代币程序,装某一种代币。用户界面上把它们合成一个显示,代码里必须分开。
  2. data 是裸字节,怎么解释完全靠 owner。所以一个程序必须校验传进来的账户的 owner 是不是自己。不校验,攻击者就能构造一个自己控制的、字节布局伪装成合法数据的账户塞给你。这是这条链上最常见的漏洞类型。
  3. **executable 的账户是只读的。**程序改不了自己,也没有属于自己的可写空间。

一笔交易长什么样:

Transaction
 ├─ signatures[]              签名列表
 └─ message
      ├─ accounts[]           本次要碰的所有账户,标注签名者与可写
      ├─ recent_blockhash     有效期凭证,过期即失效(T3 讲过它和 nonce 的差别)
      └─ instructions[]       一条或多条指令
           ├─ program_id      调用哪个程序
           ├─ accounts[]      这条指令用到 accounts 里的哪几个
           └─ data            参数字节

accounts 数组是这条链的核心。它不是一个优化提示,是强制的、参与共识的一部分。没列出来的账户,程序在执行时根本碰不到。

并行是怎么发生的:

  1. 每笔交易声明自己的读写集
  2. 调度器按写集是否重叠分组
  3. 不重叠的组同时执行
  4. 重叠的排队
读和读可以并行,读和写不行,写和写更不行。热点账户会让并行退化成串行。

那个没有私钥的推导地址:

推导规则是:把若干个种子字节串、程序地址、以及一个固定的常量字符串拼起来做哈希。

拼出来的结果有大约一半的概率落在椭圆曲线上——也就是说会对应一个私钥,那就不安全了。所以规则里还有一个额外的字节:从 255 开始往下试,找到第一个让结果落在曲线外的值。

落在曲线外意味着:这个地址在数学上不存在对应的私钥,没有任何人能凭私钥给它签名。唯一能「代表」它签名的,是推导出它的那个程序,在自己的执行过程中。

这就是第三个选项里那句「所有权由代码保证」的技术含义。

关于存储押金:

账户要按自己的字节大小存够一个最低金额,否则会被回收。这个金额可以在创建前查出来。关闭账户时这笔钱退还给指定的接收方。

它和 Gas 不是一回事:Gas 为计算付费,这个是为占用存储押金。一笔交易的手续费和它写了多少数据关系不大,但账户开多大,押多少钱是刚性的

它叫什么

Account账户

这条链上唯一的存储单元。一个地址加上 lamports、owner、data、executable 四个字段。

「一切皆账户」:钱包是账户,代币余额是账户,程序是账户,程序的配置是账户。它比 T5 的账户概念宽得多——EVM 的账户对应「一个地址」,这里的账户更接近「一个文件」。

Program程序

executable 为 true 的账户,里面装着代码。它没有属于自己的可写存储,是一个纯函数:输入是一组账户和一段参数,输出是对这些账户的修改。

这是本章标题那个问题的答案:程序不存数据,因为它在设计上就没有地方存。

Owner所有者

账户的一个字段,指向有权修改这个账户 data 的那个程序

它不是「这笔钱属于谁」。一个代币账户的 owner 是代币程序,而「谁能花这笔钱」是记在 data 里的另一个字段。这两个概念被混淆的次数,可能比这条链上任何一个概念都多。

PDA程序派生地址

由种子和程序地址确定性推导出来的、落在椭圆曲线之外因而没有私钥的地址。

它同时解决两件事:程序如何寻址自己的数据(不需要索引表),以及程序如何持有资产(没有任何人能用私钥拿走)。

它在功能上对应 T5 里的映射寻址,但性质不同:映射里的位置只是一个槽号,PDA 是一个一等公民地址,可以直接被别的程序引用。

Instruction指令

一次对某个程序的调用:调哪个程序、用到哪几个账户、参数是什么。

一笔交易可以包含多条指令,它们要么全部成功,要么全部回滚。这让「创建账户 + 初始化 + 转账」可以打包成一次原子操作——这是 EVM 上要靠合约包装才能做到的事。

Rent租金 / 存储押金

账户按大小必须存够的最低 lamports 数额。存够了就不会被回收,关闭账户时可以取回。

名字叫租金,实际行为更接近押金。它把「状态占用」这件事显式地计了价,而 T5 的模型里,写入的状态是买断的、永远不退的。

动手

动手在 Devnet 创建账户并写入数据,再用 CLI 和 RPC 把它读出来solana CLI + spl-token CLI + curl0 元,Devnet 的币从水龙头领

**全程在 Devnet,测试币没有任何价值,不涉及任何真实资产。**不要把主网的密钥文件用在这里,新建一个专用的。

指向 Devnet,建一个新钱包,领点币。

solana config set --url https://api.devnet.solana.com
solana-keygen new --outfile ~/solana-lab.json
solana config set --keypair ~/solana-lab.json
solana address
solana airdrop 2
solana balance

水龙头有频率限制,领不到就换个时间或者换个水龙头。

看一眼自己这个账户长什么样。

solana account $(solana address)

四个字段都在:lamports、owner、data、executable。

注意 owner 不是你,是系统程序。这一步就能纠正大多数人的第一个误解:owner 是「谁能改这串字节」,不是「这笔钱是谁的」。

看一个程序账户,对比差别。

solana account <任意一个程序的地>

executable 是 true,data 很长(那是编译后的代码),lamports 只够付存储押金。

把它和上一步的输出并排看。同一种数据结构,两种完全不同的角色——这就是「一切皆账户」的字面意思。

查一下押金是多少。

solana rent 0
solana rent 165
solana rent 10000

金额随大小线性增长。记住这个量级,它决定了「给用户建一个账户」这件事要花多少钱,也决定了谁来付这笔钱是一个真实的产品问题。

创建一个真正存了数据的账户。

spl-token create-token
spl-token create-account <上一步输出的 MINT>
spl-token mint <MINT> 100
spl-token accounts

第一条创建了一个 mint 账户(这种代币本身的定义)。第二条创建了一个属于你的代币账户——这就是开头那个「凭空冒出来的地址」,它是由你的钱包地址和 mint 地址推导出来的。

第三条往里写了数字。注意:改这个数字的不是你,是代币程序,你只是签名授权了它。

把这个代币账户的原始字节读出来。

solana account <上面 create-account 输出的账户地>

看三件事:

  • owner代币程序,不是你。你签名能授权它动,但你自己改不了这串字节。
  • data 是一串固定长度的裸字节,里面依次编码着 mint 地址、持有人地址、数量等字段。
  • lamports 正好是这个大小所需的押金。

**这一步是整个 Lab 的核心。**你亲眼看到了:余额不在代币程序里,它在一个 owner 指向代币程序的独立账户里。

用 JSON-RPC 再读一遍,因为你的后端只能这么读。

curl -s https://api.devnet.solana.com -X POST \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getAccountInfo",
       "params":["<那个代币账户地址>",{"encoding":"base64"}]}'

curl -s https://api.devnet.solana.com -X POST \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getBalance","params":["<你的钱包地址>"]}'

getAccountInfo 返回的 data 是 base64,解码出来就是那串裸字节。怎么把它解释成余额,完全取决于你知不知道代币程序的布局——这就是「data 是裸字节」的实际后果。

getBalance 返回的单位是 lamports,不是 SOL。这是最常见的单位错误,差 10 的 9 次方。

做一次会失败的转账,体会开头那个问题。

solana-keygen new --outfile ~/solana-lab-2.json --no-bip39-passphrase
spl-token transfer <MINT> 1 <第二个钱包的地>

它会告诉你目标方没有对应的代币账户。加上创建目标账户的选项(查一下 spl-token transfer --help)再试一次,这次会成功,并且从你的余额里扣掉一笔押金

**这就是「给一个新用户转代币需要先花钱建个地方」的完整闭环。**做支付、做空投、做提现的人都必须在产品设计阶段处理它:这笔押金由谁出。

AI Lab

AI Lab让 AI 对比两套账户模型,再让它解释并行的前提条件Level 2 · AI Copilot

分三步,第三步最容易暴露问题。

第一步:
用一张表对比 EVM 与 Solana 的账户模型,维度包括:
数据放在哪、如何寻址到某个用户的数据、谁有权修改、
存储怎么计费、能否并行执行。
每一行都要说明「为什么会这样」,不要只描述「是这样」。

第二步:
解释为什么 Solana 能并行而 EVM 不能。
明确说出并行的前提条件,以及什么情况下并行会退化成串行。

第三步:
给我一个具体例子:两笔交易,一对能并行,一对不能。
把它们的账户列表完整写出来,标注每个账户是只读还是可写。

第三步是检验点。前两步模型很容易答得漂亮,第三步要它落到具体账户列表上,就会露馅——常见错误是把只读和可写标反,或者漏掉那些必须出现在列表里的程序账户。

拿到答案之后,把它列的账户和你在 Lab 里 solana account 看到的真实字段对一遍。

另外一个专门的提问,用来抓最贵的那类错误:

如果我的程序收到一个账户参数,但没有校验它的 owner,
攻击者能做什么?给我一个完整的攻击步骤。

这是这条链上最常见的漏洞类型,下面「真实案例」里有一个真实的后果。模型对它的描述通常是对的,但它自己生成的示例代码里经常照样漏掉这个校验——**会说和会写是两件事,这一点对模型和对人一样成立。**T10 会把账户校验完整讲一遍。

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

  • 它有没有说程序的代码和它的数据存在同一个账户里,这是错的
  • 它有没有把 PDA 说成「私钥由程序保管」,PDA 根本没有私钥
  • 它有没有把 owner 说成「这笔钱的主人」,owner 是有权修改 data 的程序
  • 它说的并行条件是否准确:读读可以并行,只要写集重叠就必须串行
  • 它给的 CLI 子命令、参数和 RPC 方法名真实存在,你能在官方文档里查到
  • lamports 与 SOL 的换算有没有搞错,相差 10 的 9 次方
  • 它有没有把「交易进了区块」当成「指令成功」,这里的交易同样有失败状态
  • 它对 Rent 的描述是押金可退,还是按时间扣除的租金

真实案例

少校验了一个账户,被无限铸币2022 年

某个稳定币项目的程序在接收账户参数时,漏掉了对其中一个账户的合法性校验。

攻击者构造了一个自己控制的、字节布局看起来完全合法的假账户塞进去,程序照单全收,按它的内容铸出了大量代币。协议资金被掏空。

根因就是本章那句话:data 是裸字节,一个账户是不是「真的那个账户」,只能靠程序自己校验 owner 和地址推导关系。

在 EVM 上这类错误不太出现,因为数据在合约自己的 storage 里,别人塞不进来。这是两套模型各自的攻击面差异,不是谁更安全的问题。

热门铸造期间,并行退化成串行

一次高热度的铸造活动中,所有人的交易都要写同一个计数器账户。

写集全部重叠,调度器无法分组,并行完全失效。大量交易因为区块哈希过期而失败,用户反复重试,进一步加剧拥堵。

这件事的教训对任何在这条链上做高并发的人都适用:**设计数据结构时,先问「所有用户会不会写同一个账户」。**会,就要想办法把它拆开——分片计数器、每用户独立账户、或者把写操作挪到链下聚合后批量提交。

和 T5 对照着看很有意思:EVM 上「所有人写同一个槽」只是贵,这里是直接把这条链的核心优势关掉。

给没有代币账户的地址转账,全部失败

一个团队做空投,脚本里对几万个地址循环发转账指令。大部分失败了。

原因是绝大多数接收地址从来没有持有过这种代币,也就没有对应的代币账户。转账指令找不到目标,直接回滚。

正确做法是在同一笔交易里先创建再转账(多条指令是原子的),并且想清楚这笔押金由谁出:由空投方出,几万个账户的押金是一笔真实的开销;由用户出,那就不叫空投了。

这个坑几乎每个做 Solana 支付的团队都会踩一次,因为它在 EVM 上完全不存在——那边给任何地址转代币都不需要事先准备什么。

账户大小定死之后,协议升级卡住

账户在创建时就要定好大小。某个协议后来要给用户数据加一个字段,发现所有已存在的账户都装不下。

结果是要么写一个迁移程序,让每个用户自己来做一次扩容并补上押金差额;要么新旧两种布局在程序里长期共存。两条路都很难受。

**这是「数据结构在部署时就冻结」的代价。**设计账户布局时留出预留字节,是这条链上的一个基本习惯——听起来很土,但它能省掉一次痛苦的迁移。

改一个变量

如果交易不需要提前声明账户列表

调度器无法判断两笔交易会不会冲突,只能一笔一笔按顺序执行。

**你就得到了 T5 的模型。**这不是一句俏皮话:那套模型不是因为设计者没想到并行,是因为「改了哪些状态只有执行完才知道」这个性质让调度在原理上做不到。

反过来也成立:EVM 上有一个让交易提前声明访问列表的机制,但它是可选的,而且只影响计价,不用于调度。把它变成强制并接入调度器,就是这一章的模型。

如果你把 EVM 的映射思路直接搬过来

你会开一个大账户,把所有用户的数据放进去,程序按偏移量寻址。

三件事会同时发生:账户有大小上限,装不下就得再开一个;所有写操作都重叠,完全串行;每次修改一个用户的数据,整个账户都要被标成可写,阻塞所有其他人。

这是从 EVM 迁移过来的工程师最常犯的架构错误,而且它在测试环境里完全暴露不出来——只有用户多了才会显形。

如果押金改成不可退的一次性费用

就回到了 T5 的买断制。短期看差别不大,长期看差别很大:没有任何激励让人去清理不再需要的账户,状态只增不减。

可退的押金给了一个反向激励:用完就关掉,把钱拿回来。这是一个用经济手段解决技术问题的例子——状态膨胀不是靠限制解决的,是靠定价解决的。

如果两笔交易都要写同一个账户,但只是读的部分不同

仍然串行。调度器只看账户粒度的读写标记,不看你实际改了 data 里的哪几个字节。

这意味着并行的粒度是账户,不是字段。你能获得多少并行度,完全取决于你把数据拆成了几个账户。

数据结构设计在这里直接等于性能设计,这一点比 EVM 上强烈得多。T10 写程序时你会反复回到这个判断。

带走的问题

1
它解决什么问题?

这一章和 T5 合起来解决的是同一件事:**把「链上的数据存在哪、谁能改」这个问题的答案,从模糊的直觉变成可以画出来的结构。**两套模型看起来差别很大,但都在回答同样三个问题:怎么寻址、谁有权写、谁为存储付费。看懂这三问,第三套模型你自己就能读懂。

5
谁在支付?

存储押金由谁付,在这里是一个逃不掉的产品问题。给新用户转代币要先建账户,这笔钱要么你出、要么用户出,没有第三种可能。很多 Solana 产品的新手引导流程,形状就是被这笔押金决定的。

16
Agent 有什么权限?

一个 AI Agent 在这里的权限边界更清楚也更危险:交易必须列出所有会碰的账户,所以理论上你可以在签名前逐个检查它要动什么。但如果 Agent 直接拿着私钥自己构造交易,这个检查点就没了。把「账户列表审核」做成一个强制环节,是这套模型送给你的一个天然安全机制,不用白不用。T33 会展开。

本章自测

一句话带走

Solana 把代码与数据彻底分开,交易必须提前声明它会碰哪些账户。

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

本页目录