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:
- **「钱包地址」和「代币账户」是两个不同的账户。**前者的 owner 是系统程序,装原生币;后者的 owner 是代币程序,装某一种代币。用户界面上把它们合成一个显示,代码里必须分开。
- data 是裸字节,怎么解释完全靠 owner。所以一个程序必须校验传进来的账户的 owner 是不是自己。不校验,攻击者就能构造一个自己控制的、字节布局伪装成合法数据的账户塞给你。这是这条链上最常见的漏洞类型。
- **executable 的账户是只读的。**程序改不了自己,也没有属于自己的可写空间。
一笔交易长什么样:
Transaction
├─ signatures[] 签名列表
└─ message
├─ accounts[] 本次要碰的所有账户,标注签名者与可写
├─ recent_blockhash 有效期凭证,过期即失效(T3 讲过它和 nonce 的差别)
└─ instructions[] 一条或多条指令
├─ program_id 调用哪个程序
├─ accounts[] 这条指令用到 accounts 里的哪几个
└─ data 参数字节accounts 数组是这条链的核心。它不是一个优化提示,是强制的、参与共识的一部分。没列出来的账户,程序在执行时根本碰不到。
并行是怎么发生的:
- 每笔交易声明自己的读写集
- 调度器按写集是否重叠分组
- 不重叠的组同时执行
- 重叠的排队
那个没有私钥的推导地址:
推导规则是:把若干个种子字节串、程序地址、以及一个固定的常量字符串拼起来做哈希。
拼出来的结果有大约一半的概率落在椭圆曲线上——也就是说会对应一个私钥,那就不安全了。所以规则里还有一个额外的字节:从 255 开始往下试,找到第一个让结果落在曲线外的值。
落在曲线外意味着:这个地址在数学上不存在对应的私钥,没有任何人能凭私钥给它签名。唯一能「代表」它签名的,是推导出它的那个程序,在自己的执行过程中。
这就是第三个选项里那句「所有权由代码保证」的技术含义。
关于存储押金:
账户要按自己的字节大小存够一个最低金额,否则会被回收。这个金额可以在创建前查出来。关闭账户时这笔钱退还给指定的接收方。
它和 Gas 不是一回事:Gas 为计算付费,这个是为占用存储押金。一笔交易的手续费和它写了多少数据关系不大,但账户开多大,押多少钱是刚性的。
它叫什么
这条链上唯一的存储单元。一个地址加上 lamports、owner、data、executable 四个字段。
「一切皆账户」:钱包是账户,代币余额是账户,程序是账户,程序的配置是账户。它比 T5 的账户概念宽得多——EVM 的账户对应「一个地址」,这里的账户更接近「一个文件」。
executable 为 true 的账户,里面装着代码。它没有属于自己的可写存储,是一个纯函数:输入是一组账户和一段参数,输出是对这些账户的修改。
这是本章标题那个问题的答案:程序不存数据,因为它在设计上就没有地方存。
账户的一个字段,指向有权修改这个账户 data 的那个程序。
它不是「这笔钱属于谁」。一个代币账户的 owner 是代币程序,而「谁能花这笔钱」是记在 data 里的另一个字段。这两个概念被混淆的次数,可能比这条链上任何一个概念都多。
由种子和程序地址确定性推导出来的、落在椭圆曲线之外因而没有私钥的地址。
它同时解决两件事:程序如何寻址自己的数据(不需要索引表),以及程序如何持有资产(没有任何人能用私钥拿走)。
它在功能上对应 T5 里的映射寻址,但性质不同:映射里的位置只是一个槽号,PDA 是一个一等公民地址,可以直接被别的程序引用。
一次对某个程序的调用:调哪个程序、用到哪几个账户、参数是什么。
一笔交易可以包含多条指令,它们要么全部成功,要么全部回滚。这让「创建账户 + 初始化 + 转账」可以打包成一次原子操作——这是 EVM 上要靠合约包装才能做到的事。
账户按大小必须存够的最低 lamports 数额。存够了就不会被回收,关闭账户时可以取回。
名字叫租金,实际行为更接近押金。它把「状态占用」这件事显式地计了价,而 T5 的模型里,写入的状态是买断的、永远不退的。
动手
**全程在 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
分三步,第三步最容易暴露问题。
第一步:
用一张表对比 EVM 与 Solana 的账户模型,维度包括:
数据放在哪、如何寻址到某个用户的数据、谁有权修改、
存储怎么计费、能否并行执行。
每一行都要说明「为什么会这样」,不要只描述「是这样」。
第二步:
解释为什么 Solana 能并行而 EVM 不能。
明确说出并行的前提条件,以及什么情况下并行会退化成串行。
第三步:
给我一个具体例子:两笔交易,一对能并行,一对不能。
把它们的账户列表完整写出来,标注每个账户是只读还是可写。第三步是检验点。前两步模型很容易答得漂亮,第三步要它落到具体账户列表上,就会露馅——常见错误是把只读和可写标反,或者漏掉那些必须出现在列表里的程序账户。
拿到答案之后,把它列的账户和你在 Lab 里 solana account 看到的真实字段对一遍。
另外一个专门的提问,用来抓最贵的那类错误:
如果我的程序收到一个账户参数,但没有校验它的 owner,
攻击者能做什么?给我一个完整的攻击步骤。这是这条链上最常见的漏洞类型,下面「真实案例」里有一个真实的后果。模型对它的描述通常是对的,但它自己生成的示例代码里经常照样漏掉这个校验——**会说和会写是两件事,这一点对模型和对人一样成立。**T10 会把账户校验完整讲一遍。
AI 说完之后,你必须自己验证
- 它有没有说程序的代码和它的数据存在同一个账户里,这是错的
- 它有没有把 PDA 说成「私钥由程序保管」,PDA 根本没有私钥
- 它有没有把 owner 说成「这笔钱的主人」,owner 是有权修改 data 的程序
- 它说的并行条件是否准确:读读可以并行,只要写集重叠就必须串行
- 它给的 CLI 子命令、参数和 RPC 方法名真实存在,你能在官方文档里查到
- lamports 与 SOL 的换算有没有搞错,相差 10 的 9 次方
- 它有没有把「交易进了区块」当成「指令成功」,这里的交易同样有失败状态
- 它对 Rent 的描述是押金可退,还是按时间扣除的租金
真实案例
某个稳定币项目的程序在接收账户参数时,漏掉了对其中一个账户的合法性校验。
攻击者构造了一个自己控制的、字节布局看起来完全合法的假账户塞进去,程序照单全收,按它的内容铸出了大量代币。协议资金被掏空。
根因就是本章那句话:data 是裸字节,一个账户是不是「真的那个账户」,只能靠程序自己校验 owner 和地址推导关系。
在 EVM 上这类错误不太出现,因为数据在合约自己的 storage 里,别人塞不进来。这是两套模型各自的攻击面差异,不是谁更安全的问题。
一次高热度的铸造活动中,所有人的交易都要写同一个计数器账户。
写集全部重叠,调度器无法分组,并行完全失效。大量交易因为区块哈希过期而失败,用户反复重试,进一步加剧拥堵。
这件事的教训对任何在这条链上做高并发的人都适用:**设计数据结构时,先问「所有用户会不会写同一个账户」。**会,就要想办法把它拆开——分片计数器、每用户独立账户、或者把写操作挪到链下聚合后批量提交。
和 T5 对照着看很有意思:EVM 上「所有人写同一个槽」只是贵,这里是直接把这条链的核心优势关掉。
一个团队做空投,脚本里对几万个地址循环发转账指令。大部分失败了。
原因是绝大多数接收地址从来没有持有过这种代币,也就没有对应的代币账户。转账指令找不到目标,直接回滚。
正确做法是在同一笔交易里先创建再转账(多条指令是原子的),并且想清楚这笔押金由谁出:由空投方出,几万个账户的押金是一笔真实的开销;由用户出,那就不叫空投了。
这个坑几乎每个做 Solana 支付的团队都会踩一次,因为它在 EVM 上完全不存在——那边给任何地址转代币都不需要事先准备什么。
账户在创建时就要定好大小。某个协议后来要给用户数据加一个字段,发现所有已存在的账户都装不下。
结果是要么写一个迁移程序,让每个用户自己来做一次扩容并补上押金差额;要么新旧两种布局在程序里长期共存。两条路都很难受。
**这是「数据结构在部署时就冻结」的代价。**设计账户布局时留出预留字节,是这条链上的一个基本习惯——听起来很土,但它能省掉一次痛苦的迁移。
改一个变量
调度器无法判断两笔交易会不会冲突,只能一笔一笔按顺序执行。
**你就得到了 T5 的模型。**这不是一句俏皮话:那套模型不是因为设计者没想到并行,是因为「改了哪些状态只有执行完才知道」这个性质让调度在原理上做不到。
反过来也成立:EVM 上有一个让交易提前声明访问列表的机制,但它是可选的,而且只影响计价,不用于调度。把它变成强制并接入调度器,就是这一章的模型。
你会开一个大账户,把所有用户的数据放进去,程序按偏移量寻址。
三件事会同时发生:账户有大小上限,装不下就得再开一个;所有写操作都重叠,完全串行;每次修改一个用户的数据,整个账户都要被标成可写,阻塞所有其他人。
这是从 EVM 迁移过来的工程师最常犯的架构错误,而且它在测试环境里完全暴露不出来——只有用户多了才会显形。
就回到了 T5 的买断制。短期看差别不大,长期看差别很大:没有任何激励让人去清理不再需要的账户,状态只增不减。
可退的押金给了一个反向激励:用完就关掉,把钱拿回来。这是一个用经济手段解决技术问题的例子——状态膨胀不是靠限制解决的,是靠定价解决的。
仍然串行。调度器只看账户粒度的读写标记,不看你实际改了 data 里的哪几个字节。
这意味着并行的粒度是账户,不是字段。你能获得多少并行度,完全取决于你把数据拆成了几个账户。
数据结构设计在这里直接等于性能设计,这一点比 EVM 上强烈得多。T10 写程序时你会反复回到这个判断。
带走的问题
这一章和 T5 合起来解决的是同一件事:**把「链上的数据存在哪、谁能改」这个问题的答案,从模糊的直觉变成可以画出来的结构。**两套模型看起来差别很大,但都在回答同样三个问题:怎么寻址、谁有权写、谁为存储付费。看懂这三问,第三套模型你自己就能读懂。
存储押金由谁付,在这里是一个逃不掉的产品问题。给新用户转代币要先建账户,这笔钱要么你出、要么用户出,没有第三种可能。很多 Solana 产品的新手引导流程,形状就是被这笔押金决定的。
一个 AI Agent 在这里的权限边界更清楚也更危险:交易必须列出所有会碰的账户,所以理论上你可以在签名前逐个检查它要动什么。但如果 Agent 直接拿着私钥自己构造交易,这个检查点就没了。把「账户列表审核」做成一个强制环节,是这套模型送给你的一个天然安全机制,不用白不用。T33 会展开。
本章自测
因为要并行。
并行的前提是调度器能提前知道两笔交易会不会冲突;提前知道的前提是交易声明了自己要碰哪些账户;而「声明账户」这件事只有在数据是一个个独立、可被外部指名的账户时才有意义。
如果数据藏在程序内部,调用方无法指名,调度器也就无从判断。代码与数据分离,是并行的结构前提,不是风格选择。
寻址方式很像——都是把一个 key 和一个命名空间拼起来做哈希。但有两个关键差别。
一、PDA 是一等公民地址,可以直接被别的程序引用、可以持有资产、可以出现在交易的账户列表里。mapping 里的位置只是某个合约内部的一个槽号。
二、PDA 没有私钥,只有推导它的那个程序能代表它签名。这让「程序持有资产」成为一种由数学保证的性质。
**有权修改这个账户 data 的那个程序。**不是「这笔钱的主人」。
你的钱包账户的 owner 是系统程序;你的代币账户的 owner 是代币程序。至于「谁能动这笔代币」,是写在 data 里的另一个字段。
这两个概念混淆会直接导致漏洞:程序如果不校验传进来的账户 owner 是不是自己,攻击者就能塞一个伪造的账户进来。
任何两笔交易的可写账户集合有交集时,它们必须串行。
典型场景:全局计数器、单一流动性池、热门铸造活动的状态账户、任何「所有用户都要写同一个地方」的设计。
注意粒度是账户不是字段:哪怕两笔交易改的是同一个账户里完全不同的字节,也一样串行。所以数据拆得够不够细,直接决定了你能拿到多少并行度。
没有标准答案,检查这几件事:
- 有没有从「为什么」讲起——代码与数据分离是为了并行,不是为了好看。
- 有没有把 owner 和「钱的主人」区分清楚,这是最容易误导人的一点。
- 有没有说清 PDA 既是寻址手段又是所有权手段,而 mapping 只是寻址。
- 有没有提到押金可退和 Gas 买断的差别,以及它背后的状态膨胀问题。
- 有没有指出并行的粒度是账户,因此数据结构设计等于性能设计。
- 有没有警告那个最常见的迁移错误:把 mapping 思路搬成一个大账户。
- 有没有说清两边共同的那三个问题——怎么寻址、谁有权写、谁为存储付费。
能讲到第 7 条,说明你已经不是在记两套 API,而是在用一套框架读任何一条链了。这正是这一阶段的目标。
一句话带走
Solana 把代码与数据彻底分开,交易必须提前声明它会碰哪些账户。