T10 · Solana Program
没有合约存储,程序怎么记住状态?
- 练习的能力
- Builder
- 动手
- 用 Anchor 写一个计数器程序,部署到 Devnet,用客户端调用并读回状态。
- AI Lab
- 让 AI 审查你的账户校验逻辑,自己验证它指出的每一条是否真的存在。
一个现实问题
你打算把 T7 那个押金合约搬到 Solana 上。第一行就卡住了。
你想写 balanceOf[用户] += 金额,但程序里没有存储。没有状态变量,没有 mapping,翻遍文档也找不到「这份程序自己的数据放在哪」。
接着是第二个意外:调用方在发交易时,必须提前列出这笔交易会读写哪些账户。不是程序去找数据,是调用方把数据递给程序。
第三个意外紧接着来:既然账户是调用方递进来的,那他递错一个会怎样?他把别人的账户递给你,你照着改,会发生什么?
三个意外指向同一件事:Solana 上,「这块数据是谁的」不是链替你保证的,是你自己要证明的。这一章就是在回答:状态放在哪,以及怎么证明它是对的。
思想实验
把一个 Solana 程序想成一个没有硬盘的纯函数。
它只有代码,没有私有存储。每次被调用时,调用者会递给它一叠文件夹(账户),函数读这些文件夹、改这些文件夹,然后结束。文件夹不属于函数,函数只是被授权去改它们。
每个文件夹上贴着四样信息:
地址 这个文件夹放在哪
owner 哪个程序有权修改它的内容
data 里面装的字节
lamports 里面存了多少钱(同时也是它的「租金押金」)关键的一条:只有 owner 那个程序能修改文件夹的内容。 别的程序可以读,但改不了。这是整个模型的地基。
现在推演两个问题。
问题一:调用者可以递错文件夹吗?
可以,而且非常容易。你的程序要「给用户的计数器加一」,用户递给你的却是另一个人的计数器。函数照着改了,因为对函数来说,它只看到一叠字节。
所以:程序必须自己检查「你递给我的这个文件夹,确实是我认识的那一个」。 在 EVM 上,balanceOf[msg.sender] 这个表达式本身就保证了你只能碰自己的那一格;在 Solana 上,这个保证要你亲手写出来。
问题二:既然文件夹是被递进来的,程序怎么创建一个「只属于自己」的文件夹?
答案是让文件夹的地址从程序 ID 和一段自己定的标签推导出来。这样的地址有两个性质:任何人都能算出它是哪个地址(所以客户端知道该递哪一个),但没有人拥有它的私钥(所以只有派生它的那个程序能代表它行动)。
于是 EVM 里的 balanceOf[用户] 在 Solana 上变成了「用 [标签, 用户地址] 派生出来的那个账户」。mapping 的每一个格子,在这里都是一个独立的、可寻址的账户。
- 账户由调用方传入
- 所以程序必须自己校验
- 派生地址让归属可以被推导和验证
你来决定
你要写一个最简单的计数器:每个用户有自己的计数,调用 increment 加一。怎么保证「用户只能加自己的」?
观察结果
四个选项摆在一起,能看出 Solana 的账户模型是怎么同时决定安全和性能的:
| 做法 | 归属由什么保证 | 能否转让 | 并行度 | 典型用途 |
|---|---|---|---|---|
| 不校验 | 什么都不保证 | — | — | 只会出现在事故报告里 |
| 数据里记 authority | 程序在运行时比对 | 能 | 高 | 需要转让所有权的资源 |
| 地址派生 | 推导过程本身 | 不能 | 高 | 一人一份的账本、配置、金库 |
| 全局单例 | 不区分归属 | — | 低,写操作互相阻塞 | 全局配置、统计 |
一条必须刻进肌肉记忆的结论:
在 EVM 上,链替你保证了「你只能动自己那一格」。在 Solana 上,这句话是你自己写出来的一行校验。
这不是哪个模型更好的问题。它是一个交换:Solana 把「读写哪些数据」提前声明出来,换来了并行执行的能力;代价是校验责任从平台转移到了程序作者。
这也解释了为什么 Solana 的安全审计清单和 EVM 完全不同。EVM 审计的重点是重入、溢出、权限;Solana 审计的重点几乎全是账户校验的各种漏检。
建立模型
账户的五个字段
Account
├─ address 32 字节的地址
├─ owner 哪个程序能修改 data。普通钱包的 owner 是系统程序
├─ data 任意字节。程序自己决定怎么解释它
├─ lamports 余额。同时必须达到「免租门槛」,否则账户会被回收
└─ executable 这是一个程序,还是一份数据三类账户,对应三种角色:
| 类型 | owner | data 里是什么 | 谁能改 |
|---|---|---|---|
| 钱包账户 | 系统程序 | 空 | 持有私钥的人(签名) |
| 数据账户 | 你的程序 | 你定义的结构 | 只有你的程序 |
| 程序账户 | 加载器 | 可执行代码 | 升级权限持有者 |
账户校验清单
这是这一章最该被打印出来贴在显示器上的东西。每一个传入的账户,都要过一遍:
- owner 对吗? 这个账户的 owner 是不是我这个程序?不是,就说明数据不可信。
- 地址对吗? 如果它应该是一个派生账户,用同样的标签重新推导一遍,和传进来的地址比对。
- 签名了吗? 需要授权的账户,是不是真的在这笔交易里签了名?只检查地址不检查签名,等于没检查。
- 类型对吗? 两种不同结构的账户,字节长度可能一样。必须有一个标记区分,否则攻击者可以把 A 当 B 传进来。
- 是不是同一个? 需要两个不同账户的地方,检查它们的地址不相等。很多「从自己转给自己」的漏洞就出在这里。
- 可写吗? 需要修改的账户,调用方有没有把它标成可写。
漏掉任何一条,都对应着一类真实存在过的攻击。第 3 条和第 1 条是历史上被利用最多的两条。
派生地址与程序间调用
派生地址:用程序 ID 加上一组自定的标签,算出一个落在曲线之外的地址——也就是说,数学上不存在对应的私钥。没有人能用私钥代表它签名,但派生出它的那个程序可以在调用时代表它「签名」。
这一条性质有两个直接用途:
- 把它当 mapping 的格子用:
[b"counter", 用户地址]就是那个用户的计数器。 - 把它当金库用:资产存在一个派生账户里,只有程序逻辑能把钱转出去,任何人拿私钥都不行。
程序间调用:一个程序在执行过程中调用另一个程序。调用时可以带上自己派生账户的标签,让被调用方相信「这确实是我这个程序授权的」。转代币、创建账户、调用其他协议,全都走这条路径。
用框架把校验变成声明
手写上面那六条检查,又长又容易漏。主流的 Rust 框架把它们变成了声明式的约束,写在账户结构上:
use anchor_lang::prelude::*;
// 部署前用 `anchor keys list` 拿到真实的程序 ID 替换这里,不要照抄
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
#[program]
pub mod counter {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
Ok(())
}
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
// 生产代码用 checked_add,溢出时返回错误而不是回绕
counter.count = counter.count.checked_add(1).unwrap();
Ok(())
}
}
#[account]
pub struct Counter {
pub authority: Pubkey, // 32 字节
pub count: u64, // 8 字节
}
#[derive(Accounts)]
pub struct Initialize<'info> {
// init 由本程序创建这个账户,并成为它的 owner
// payer 谁出钱付免租押金
// space 8 字节的类型标记 + 32 + 8
// seeds/bump 地址由固定标签和 user 推导,任何人都能算出来,但只有本程序能写
#[account(
init,
payer = user,
space = 8 + 32 + 8,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>, // Signer 这个类型本身就强制了「必须签名」
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Increment<'info> {
// seeds/bump 重新推导一遍地址,和传进来的比对
// has_one 账户数据里的 authority 必须等于下面那个 authority 账户
#[account(
mut,
seeds = [b"counter", authority.key().as_ref()],
bump,
has_one = authority
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}这段代码里,六条校验被框架接管了五条:
Account这个包装类型自动检查 owner 是本程序,以及类型标记匹配(那多出来的 8 字节就是类型标记)。seeds加bump自动做地址重新推导与比对。Signer类型自动检查签名。mut声明可写。has_one做字段与账户的一致性比对。
但框架接管的是「你写出来的那些约束」,不是「你忘了写的那些」。 上面的 increment 如果去掉 has_one 和 seeds,它仍然能编译、能跑、能通过所有正常测试——然后被任何人拿去改别人的计数器。这一点在 AI Lab 里会被专门验证。
它叫什么
Solana 上一切数据的载体:钱包、代币余额、程序、配置,全都是账户。
四个关键字段:地址、owner(哪个程序能改)、data(字节内容)、lamports(余额兼免租押金)。记住「只有 owner 程序能改 data」这一条,一半的困惑会消失。
Solana 上的「合约」。它是无状态的:只有代码,没有自己的存储。
所有状态都在它拥有的那些数据账户里,而这些账户必须由调用方在交易中显式列出。
由程序 ID 和一组标签推导出来的地址,落在椭圆曲线之外,没有对应的私钥。
两个用途:当作可寻址的存储格子(替代 mapping),以及当作只有程序逻辑能动的金库。派生它的程序可以在程序间调用时代表它签名。
一个程序调用另一个程序。转代币、创建账户、和其他协议交互都走它。
调用时可以带上自己派生账户的标签,让被调用方确信授权来自本程序。被调用的程序是外部代码,要按不可信来对待。
证明「调用方递进来的这个账户,确实是我期望的那一个」。
六条:owner、地址推导、签名、类型标记、互不相同、可写。这是 Solana 安全的核心,几乎所有事故都是这六条里漏了一条。
账户必须持有不低于某个门槛的 lamports,才会被永久保留,否则会被回收。门槛和账户大小成正比。
它的实际含义是:链上存储要付押金,押金归账户所有者,关掉账户时可以退回。 这个设计在 T11 会变成「谁为代币账户付钱」的问题。
动手
这个 Lab 的目的不是写出复杂逻辑,而是完整走一遍 Solana 的开发闭环,并且亲手验证账户校验确实在起作用。
装好工具链,初始化项目。
solana --version
anchor --version
anchor init counter版本会一直变,不要抄任何写死版本号的教程,以你装上的那一版的文档为准。
把上面那段程序敲进去,拿到你自己的程序 ID。
anchor build
anchor keys list # 输出你这个项目的程序 ID把输出的 ID 填回 declare_id! 和配置文件里,再 build 一次。这一步几乎人人都会忘一次,忘了的表现是部署之后调用报「程序 ID 不匹配」。
在本地跑测试。 先写两个用例:正常初始化并加一,以及用第二个钱包去 increment 第一个钱包的计数器。
第二个用例必须失败。 如果它通过了,说明你的约束没写对——这比第一个用例重要得多。
切到 Devnet,领水,部署。
solana config set --url devnet
solana airdrop 2
anchor deploy水龙头有频率限制,领不到就换个方式再试。全程 Devnet,不要用任何持有真实资产的钱包。
用客户端调用并读回状态。 在客户端用同样的标签算出计数器账户地址,发一笔 initialize,再发几笔 increment,然后把账户拉下来解码,确认 count 对得上。
注意这里的关键体验:客户端是「算」出账户地址的,不是「查」出来的。 这就是派生地址在工程上的价值。
用命令行直接看那个账户。
solana account <你算出来的账户地址>你会看到 owner 是你的程序 ID,data 是一段字节。前 8 字节是类型标记,接着 32 字节是 authority,最后 8 字节是计数——手动对一遍,账户模型就真正落地了。
做一次破坏性实验。 把 Increment 里的 has_one 和 seeds 两行注释掉,重新部署,再跑一次第三步那个「用别人的钱包改别人计数器」的用例。
这次它会成功。 看到这一幕,你就永远不会再把账户校验当成样板代码了。
AI Lab
把你的程序完整交给模型,分三步问。
第一步:
这是一个 Solana 程序。逐个指令、逐个账户列出:
它期望这个账户是什么、代码里实际做了哪些校验、缺了哪些校验。
用表格输出,不要给修复建议。
第二步:
挑出你认为最严重的那一条,写出一个具体的攻击:
攻击者用什么账户、按什么顺序发什么指令、最终得到什么。
要具体到每个账户传什么,不要用抽象描述。
第三步:
写一个测试,它在当前代码上必须成功执行攻击,在修好之后必须失败。第一步模型通常能给出一份像模像样的清单。危险的地方在于它会自信地列出框架已经做掉的检查,比如「缺少 owner 校验」——而你用的账户包装类型本来就自动做了。被这类误报带着改代码,会让你写出一堆冗余检查,同时对真正的漏检毫无察觉。
反过来它也会漏。最常被漏的两类是:两个本该不同的账户被传成同一个;以及应该可写的账户没有被标成可写。
第二步和第三步是这个 Lab 的核心。上一章说过一次,这里再说一遍,因为它在 Solana 上更重要:一个能在坏代码上跑成功的攻击测试,是唯一能证明漏洞真实存在的东西。 模型写不出这个测试,说明前面那条「漏洞」多半不存在。
最后一件事:把它给的每一个约束名都去文档里搜一遍。模型编造约束名的概率,比你想象的高得多,而编出来的那个名字往往语义上完全合理——这正是它难被发现的原因。编不过的会被编译器拦下,编得过但语义不对的才最危险。
AI 说完之后,你必须自己验证
- 它指出的每一条漏洞,你都写出了一个真的能成功的攻击交易来证明;证不出来的就是它编的
- 它用到的每一个约束名和宏,你都在框架文档里查到了;模型很擅长编出听起来很合理的约束名
- 它有没有把框架已经自动做掉的检查说成漏洞,比如 owner 检查和类型标记检查
- 它有没有漏掉「两个账户可能是同一个」和「账户需要可写」这两类检查
- 它给的修复代码改完之后能编译,而且你那个攻击用例从成功变成失败
- 编译通过和测试通过是两件事,测试通过和安全是第三件事,不要混为一谈
真实案例
Solana 上被利用得最多的一类漏洞,简单到令人难以置信:程序检查了「这个账户的地址等于管理员地址」,但没有检查「这个账户在这笔交易里签了名」。
攻击者只要在交易里列出管理员的地址(这是公开信息,任何人都能填),就能通过检查。
这类漏洞在框架里几乎不可能出现,因为签名者必须声明成对应的类型。但在手写的程序里,它出现过很多次。地址相等不等于本人到场。
两个不同用途的账户,字节长度恰好相同。程序直接把传进来的字节按期望的结构解码,不检查这到底是哪一类账户。
攻击者把一个「配置账户」当成「金库账户」传进去,让程序按错误的结构解释它,从而读出或写入完全不该动的字段。
这就是框架在每个账户前面加 8 字节类型标记的原因。手写程序时,这 8 个字节要自己加,而且要自己检查。
桥的程序需要读取一个由运行时维护的特殊系统账户,来验证「这笔交易里确实包含了一条有效的签名校验指令」。
程序读了这个账户,但没有检查它的地址确实是那个系统账户。攻击者构造了一个自己控制的账户,内容伪装成签名校验的结果,递了进去。
桥相信了这份伪造的证明,铸出了没有任何抵押的资产,损失以亿美元计。
根因就是本章校验清单的第 2 条:地址没有被验证。 这条清单上的每一行,都对应着这样一个真实的数字。
一个「把资金从账户 A 转到账户 B」的指令,没有检查 A 和 B 不是同一个账户。
攻击者把同一个账户同时传成来源和目标。程序先读出余额、减去金额写回,再读出余额、加上金额写回——第二次写覆盖了第一次,结果是钱凭空多了出来。
这类问题在 EVM 上也存在,但在 Solana 上格外常见,因为账户是外部传进来的,「它们是两个不同的东西」这个假设完全没有依据。
改一个变量
攻击者部署一个自己的程序,创建一个字节布局完全一样、authority 字段写着他自己的账户,然后递给你。
你的程序读出来一切正常:结构对、authority 对、签名也对。于是它接受了一份完全由攻击者控制的状态。
这就是为什么校验清单的第 1 条排在第一位:先确认这份数据可信,再讨论数据里写了什么。
计数器从「每人一个」变成了「全网一个」。
功能上还能用,但两件事同时发生:归属关系消失了(谁都能加),以及所有写它的交易必须排队串行。
账户的切分粒度,同时决定了安全边界和并行度。 这是 Solana 设计里最需要提前想清楚的一件事——它比 EVM 上的存储布局更难事后修改。
交易有大小上限,账户列表也算在里面,太多会直接装不下。
更麻烦的是写锁:一笔交易要写的账户越多,和别人冲突的概率越高,并行优势就被吃掉了。
常见的应对是拆指令、用查找表压缩账户列表,或者重新设计账户结构。如果你发现自己需要传入很多账户,通常说明状态的切分方式需要重新想。
你需要在跨程序调用时带上金库派生账户的标签,让代币程序相信这次转账是本程序授权的。
这条路径打开的那一刻,金库的安全性就完全等于「哪些代码路径能走到这次调用」。任何一个能触发它的入口,只要校验漏了一条,金库就是敞开的。
这也是为什么 Solana 程序审计时,第一件事是把所有「带签名的跨程序调用」找出来,挨个往回追调用链。
带走的问题
为什么需要 Blockchain?Solana 的答案里有一条很具体:把「会碰哪些数据」提前声明出来,就能并行执行。这是一次明确的工程取舍——换来吞吐,代价是校验责任转移给了程序作者。理解一条链,就是理解它做了哪些这样的交换。
谁承担风险?在 Solana 上,风险高度集中在程序作者身上。EVM 里链替你挡掉的那一类错误,这里没人替你挡。所以「账户校验清单」不是最佳实践,是交付前的必过项。
AI 错误时谁承担损失?这一章的 AI Lab 给了一个很小但很真实的样本:模型指出的漏洞可能不存在,编造的约束名可能编译不过、也可能编译得过。让模型审查安全代码是有价值的,但价值全部来自你逐条验证的那一步,而不是它的结论本身。
本章自测
在一个个独立的账户里。程序拥有这些账户(它是它们的 owner),但账户本身是独立的实体,有自己的地址和余额。
调用时,客户端必须把这笔交易会读写的账户全部列在交易里。程序拿到的是一叠账户引用,不是一块私有内存。
正因为账户是被传进来的,程序必须自己校验它们是不是期望的那些。
因为它意味着没有任何人能绕过程序逻辑来动这个账户。
普通账户由私钥控制,谁拿到私钥谁说了算。PDA 落在曲线之外,数学上不存在对应的私钥,唯一能代表它行动的是派生出它的那个程序,而程序的行为是公开的代码。
所以金库要用 PDA:资产的控制权从「一把钥匙」变成了「一段所有人都能审计的逻辑」。
不是。框架把校验从「手写代码」变成了「写声明」,但没写的声明等于没做的检查。
一个缺了归属约束的指令照样能编译、能部署、能通过所有正常路径的测试。框架帮你做的是 owner 检查和类型标记检查这类固定动作,业务层面的归属关系仍然要你自己声明出来。
判断方法:把每个账户过一遍那六条清单,问「这一条是谁保证的」。答不上来的就是漏洞。
因为交易里的账户列表是调用方随便填的。任何人都能把管理员的公开地址填进去。
真正的授权凭证是签名:这个账户在这笔交易上签了字。只检查地址不检查签名,等于看到名字就放行。
在框架里用专门的签名者类型可以避免这类错误;手写程序时必须显式检查签名标志位。
没有标准答案,检查这几件事:
- 每个账户的 owner 是谁保证的?是框架的包装类型,还是你自己写的检查?
- 每个派生账户,程序有没有在运行时用同样的标签重新推导一遍?
- 每个需要授权的账户,是不是真的要求了签名,而不只是比对地址?
- 两个本该不同的账户,有没有可能被传成同一个?
- 有没有哪个账户,你其实说不清「为什么它必须是这一个」?
- 所有带签名的跨程序调用,能被哪些入口触发?把调用链画出来。
第 5 条最值钱:说不清的账户就是还没想清楚的设计,它比明确写错的检查更危险。
一句话带走
PDA 是程序自己的地址空间,CPI 是程序之间的调用方式。