beautifulremi / Solana - 1

Created Tue, 24 Mar 2026 16:15:35 +0800 Modified Wed, 25 Mar 2026 11:49:45 +0000
8292 Words

账户

在以太坊等链上,账户通常指“余额”。但在 Solana 中,账户更像是一个具有元数据的 KV 存储节点

每个账户都有一个 32 字节的地址(通常是 Ed25519 公钥),其内部包含以下核心字段:

字段说明CS 类比
lamports账户的余额(1 SOL = $10^9$ lamports)。账户的“可用额度”。
data存储的字节数组(最大 10MiB)。文件内容(可以是代码,也可以是自定义数据)。
owner拥有该账户的程序 ID文件所有者/权限。只有 Owner 能修改此数据。
executable布尔值,标识该账户是否存有可执行代码。Linux 中的 +x 可执行权限位。
rent_epoch用于维护租金相关的历史字段(目前大多已逻辑弃用)。-

在 Solana 中分为这两种账户:

  • 程序账户(Program Account): executabletrue。它只存逻辑,不存用户状态。

  • 数据账户(Data Account): executablefalse。它只存状态,不存代码。

这样分离是有好处的,分离后,逻辑(代码)是只读的。多个不同的交易可以同时调用同一个“程序”,只要它们修改的是不同的“数据账户”,网络就可以实现并行处理,而不会产生冲突。

程序账户

在早期的区块链设计中,代码部署后往往是“死”的,地址和代码绑定。但在 Solana 中,为了让程序可以升级,它将“身份(地址)”与“内容(二进制代码)”分开了。

在 Solana 升级到 BPF Loader v3 之后,程序账户的设计变得稍微复杂了一些,主要是为了支持程序升级

Program Account

  • 角色:它是程序的**“门面”或“入口”**。

  • 地址:这就是我们在开发时用到的 Program ID

  • 数据内容 (data):在 Loader-v3 下,这个账户的 data 字段里并不直接存储 BPF 字节码

  • 它存储的是一个指向“第二层”账户的地址(指针),以及一些元数据。

在下图中,可以看到一个加载器程序被用来部署一个程序账户。程序账户的 data 包含可执行的程序代码。

这里的 Loader (加载器) 实际上是一个特殊的系统程序(内核级程序)。

  • 所有权:所有程序账户的 Owner 都是 BPF Loader

  • 执行逻辑:当一个交易请求执行某个 Program ID 时,Solana 运行时会:

    1. 查看该 Program ID 账户。

    2. 根据里面的指针找到关联的 Program Data Account

    3. 提取 BPF 字节码并将其加载到虚拟机(JIT 编译或解释执行)。

Program Data Account

  • 角色:它是程序的“真正仓库”。

  • 数据内容 (data):这里存储着真正的可执行二进制代码(BPF 字节码)。

  • 额外字段

    • 升级权限 (Upgrade Authority):谁有权修改这段代码。

    • 最后修改槽位 (Last Modified Slot):代码最后更新的时间戳(Slot)。

使用 loader-v3 部署的程序,其 data 字段中不包含程序代码。相反,其 data 指向一个单独的 程序数据账户,该账户包含程序代码。

数据账户

数据账户是一个广义概念。只要 executable 字段为 false,它就是数据账户。它本质上就是一块链上堆内存

Program State Account

程序状态账户,这是数据账户的一种特殊身份。也就是专门为一个程序存放业务数据的账户。它的 owner 字段指向对应的程序 ID。

创建一个存放状态的账户不是一步到位的,而是一个权限转移的过程。

首先系统账户调用 System Program(系统程序),告诉它:“我要创建一个 100 字节的新账户,地址是 XYZ,我出钱(付租金)。”

系统程序在链上划拨出这块空间。此时,这个新账户的 owner 还是系统程序。

然后系统程序将该账户的 owner 字段从自己改为你的自定义程序 ID。程序接管账户后,根据你定义的结构体(Struct),往 data 字段里填入初始值。

System Account

这是最基础的账户类型。它的 owner 是系统程序,原生钱包地址就是一个系统账户。系统账户可以发送交易、支付手续费(Base Fee),并作为“母体”去创建其他账户。

所有钱包账户都是系统账户,这使它们能够支付交易费用。

指令

结构体

在solana中,指令是对特定程序进行的一次带有参数和权限声明的异步远程过程调用(RPC)。

一个 Instruction 结构体非常精简:

字段技术含义比喻
program_id要运行的代码地址。可执行文件的路径(如 /usr/bin/python)。
accounts一个 AccountMeta 数组,列出所有会被读写的账户。文件描述符列表(告诉 OS 你要打开哪些文件)。
data一个 u8 字节数组(Buffer)。函数参数(argv 里的内容)。

账户元数据

在以太坊里,你不需要提前声明你要动哪些账户,但 Solana 强制要求你列出所有账户,并标明权限:

pub struct AccountMeta {
    pub pubkey: Pubkey,      // 账户地址
    pub is_signer: bool,    // 是否提供了签名(权限证明)
    pub is_writable: bool,  // 是否会被修改(写锁)
}

这里的重点是 is_writable ,涉及到一个核心调度算法问题。

  • 传统链(如 EVM):像单线程 CPU。每个交易按顺序执行,因为不知道它们是否会冲突。

  • Solana:像多核 GPU/多线程 CPU。

    • 如果指令 A 标明写账户 1,指令 B 标明写账户 2,调度器(Sealevel)发现它们没有写冲突,就会直接把它们扔到不同的核心上并行执行

    • 这就是为什么 Solana 的 TPS 能达到数万的原因——它把区块链变成了并行计算。

下图展示了一个包含单个指令的交易。指令的 accounts 数组包含两个账户的元数据:

示例

示例中,SOL 从一个账户转移到另一个账户,

  • program_id: 指向 System Program(系统程序)。它是内核的一部分,负责处理原生 SOL。

  • accounts:

    • 发送方账户is_signer = true (必须有私钥的授权), is_writable = true (要扣钱)。

    • 接收方账户is_signer = false, is_writable = true (要加钱)。

  • data: 包含两部分:

    • 函数序号:比如 2 代表转账操作(Transfer)。

    • 参数:经过序列化后的 1,000,000,000 (1 SOL 的 lamports 值)。

一个交易(Transaction)可以包含多个指令(Instruction)。如果其中任何一个指令失败(比如钱不够了),整个交易都会回滚。

交易发送后,系统程序处理转账指令并更新两个账户的 lamport 余额。

交易

可以把交易视为一个装有多种表单的信封。每个表单都是一条指令,告诉网络该做什么。发送交易就像邮寄信封,以便处理这些表单。

交易是原子性的:如果单条指令失败,整个交易将失败,并且不会发生任何更改。

一个 Transaction 由两部分组成:

  1. Signatures (签名数组):证明谁授权了这个交易。第一个签名者通常是付费方(Payer)

  2. Message (消息):这是交易的实质内容。

这里需要注意的是,交易限制在 1232 字节。这是为了保证在互联网上快速传输,Solana 遵循 IPv6 的最小 MTU(最大传输单元) 限制(1280 字节)。

1280 字节 - 40 字节(IPv6 头部)- 8 字节(碎屑)= 1232 字节

签名

Solana 使用的是 Ed25519 曲线。每个签名固定为 64 字节。交易中包含一个 signatures: Vec<Signature>。这个数组的大小必须等于 MessageHeader 中定义的 num_required_signatures

签名数组里的签名顺序,必须严格对应 account_keys 数组中前几个“需要签名”的账户。

  • 如果 num_required_signatures 是 3,那么 account_keys[0], account_keys[1], account_keys[2] 必须分别是这三个签名的公钥。

  • 验证流程:Solana 节点在收到交易后,会并行验证这些签名。它会从 account_keys 取出公钥,从 signatures 取出签名,然后对 Message 进行校验。只要有一个校验失败,整笔交易直接丢弃。

数组中的第一个签名(index 0)具有特殊的地位:

  1. 支付手续费:该签名对应的账户(即 account_keys[0])被认定为 Fee Payer。这笔交易消耗的 lamports 会直接从这个账户扣除。

  2. 交易身份(TxID):在以太坊里,交易哈希是整个数据包的 Hash。但在 Solana 中,第一个签名本身就是这笔交易的唯一 ID

这意味着不需要对整个包做二次 Hash 运算来生成 ID,直接复用第一个签名,进一步压榨了性能。

Message

由于 Solana 追求极致的吞吐量,Message 的设计非常紧凑,采用了“索引化(Indexing)”的思路,而不是冗余地重复存储地址。

在 Rust 代码中,Message 包含以下四个关键字段:

字段作用技术特征
header权限声明。定义了账户列表中哪些是签名者,哪些是只读。3 个字节的固定长度。
account_keys账户全集。本交易涉及到的所有公钥(地址)列表。扁平化的 Pubkey 数组。
recent_blockhash时间戳/随机数。防止交易重放并限制有效期。32 字节哈希。
instructions执行逻辑。具体要调用的程序及其参数。CompiledInstruction 数组。

为了节省空间,Solana 不在指令里存公钥,而是把所有公钥存在 account_keys 数组里,并利用 header 的三个数字来划分权限边界,下面会讲到。

因为索引使用的是 u8,所以一个交易涉及的唯一账户总数不能超过 256 个。但这对于 1232 字节的 MTU 限制来说已经绰绰有余了。

recent_blockhash 的作用是,由于 Solana 没有以太坊那种递增的 nonceblockhash 确保了即使你发送两笔完全一样的转账,只要 hash 不同,它们就是两个不同的交易。

同时,它相当于一个“保质期”。Solana 只缓存最近 150 个区块的哈希值(约 1 分钟)。如果你发送了一个交易但网络拥堵,1 分钟后这个交易就会失效,保证了账本状态不会被无限挂起的旧交易攻击。

这里的 instructionsCompiledInstruction(编译后指令)。它不直接存 Pubkey,而是存 u8 类型的索引。

例如,一个转账指令的结构可能如下:

  • program_id_index: 1 (代表 account_keys[1] 是系统程序)

  • accounts: [0, 2] (代表 account_keys[0] 发钱,account_keys[2] 收钱)

  • data: [2, 0, 0, 0, ...] (转账函数的序列化数据)

account_keys 必须严格按照以下顺序排列:

  1. 签名者 + 可写 (Signed & Writable)

  2. 签名者 + 只读 (Signed & Read-only)

  3. 非签名者 + 可写 (Unsigned & Writable)

  4. 非签名者 + 只读 (Unsigned & Read-only)

header 就像三把“刻度尺”,其存储了以下三种数据:

  • num_required_signatures:告诉系统,数组前 $N$ 个账户是签名者。

  • num_readonly_signed_accounts:在签名者里,最后那几个是只读的。

  • num_readonly_unsigned_accounts:在非签名者里,最后那几个是只读的。

这就像是数组的切片(Slicing)。通过三个整数索引,内核就能瞬间知道每一个账户的读写权限,从而决定是否可以与其他交易并行。

系统在得知了 headeraccountkey 之后,就可以判断出四种权限:

Recent Blockhash

在 Solana 中,recent_blockhash 扮演着双重角色:时间戳唯一标识符

防重放攻击 (Replay Protection)

与以太坊使用递增的 nonce 不同,Solana 记录最近处理过的交易哈希。

  • 逻辑:如果你发送了一笔转账,网络会记录这个交易的签名。如果你试图再次发送完全相同的交易,网络会发现这个哈希已经存在于“已处理”列表中,从而直接拒绝。

  • 唯一性:即使是两笔金额、对象完全一样的转账,只要它们使用了不同的 recent_blockhash,它们就是合法的独立交易。

自动过期机制

  • 生存周期:区块哈希大约在 150 个区块后过期。按照平均 400ms 一个区块计算: $$150 \times 400\text{ms} = 60,000\text{ms} = 1\text{ minute}$$

  • 设计意图:这保证了验证者不需要永久存储所有的交易哈希来防重放,只需要存储最近一分钟的即可。这也意味着如果你的交易在一分钟内没被打包,它将永远失效,你必须重新获取哈希并再次签名。

指令

这是 Message 结构体中最节省空间的部分。你在代码里构建的直观指令(包含 32 字节的公钥),在打包时会被“编译”成这种索引格式。

结构字段:

  1. program_id_index (1 字节)

    • 它不是程序地址,而是一个 指针(Index)

    • 逻辑:它指向 Message 结构体中全局 account_keys 数组的某个位置。例如,如果值为 5,说明处理这条指令的程序地址存放在 account_keys[5]

  2. accounts (u8 索引数组)

    • 这同样是一组 指针

    • 作用:列出此指令需要读写的所有账户。

    • 性能点:假设一条指令涉及 10 个账户,如果存公钥需要 $32 \times 10 = 320$ 字节;而使用 u8 索引只需要 $1 \times 10 = 10$ 字节。

  3. data (字节数组)

    • 这是传递给程序的原始二进制数据(Payload)。

    • 它包含了你要调用的具体函数名(通常是前 4 或 8 个字节的哈希/序列化 ID)以及参数(如转账数量)。

交易费用

Solana 的交易费用(Transaction Fee)由两部分组成:必须支付的基础费用自选的优先费用

Base Fee

基础费用非常固定,其设计初衷是覆盖验证者进行签名验证(Ed25519 这种高强度数学运算)的硬件成本。

  • 计算规则每个签名 5,000 lamports

    • 如果你只有一个签名(绝大多数转账),费用就是 5,000 lamports ($0.000005$ SOL)。

    • 如果有 2 个签名者(如多签或创建账户),费用就是 10,000 lamports。

  • 付费人:由交易的第一个签名者(Fee Payer)支付。

  • 分配规则 (50/50 拆分)

    • 50% 销毁 (Burned):这部分 SOL 直接消失。这是一种通缩机制,用来抵消系统增发。

    • 50% 给验证者:支付给当前打包你交易的验证者。

Prioritization Fee

优先费用是可选的。当你急着抢 NFT 或者在市场波动剧烈时,你需要通过加钱来让验证者“插队”处理你的交易。

  • 计算公式: $$\text{Prioritization Fee} = \text{CU Limit} \times \text{CU Price}$$

    • CU Limit (计算单元限制):你的程序需要消耗多少算力。默认一个指令是 200,000 CU。

    • CU Price (计算单元单价):你愿意为单位算力付多少钱(单位是 micro-lamports,即 $10^{-6}$ lamports)。

  • 分配规则 (100% 给验证者)

    • 根据 SIMD-0096 提案,优先费用不再销毁,而是 100% 支付给验证者。这极大激励了验证者去处理那些高价值(给钱多)的交易。

Priority

验证者并不是简单按给钱多少排队,而是按 “单位成本的回报率” 排序。

$$\text{Priority} = \frac{\text{Prioritization Fee} + \text{Base Fee}}{1 + \text{CU Limit} + \text{Signature CUs} + \text{Write-lock CUs}}$$

分母实际上是一个代价函数。验证者处理你的交易需要付出以下“资源成本”:

  1. CU Limit:代码运行消耗的 CPU 时间。

  2. Signature CUs:验证签名消耗的算力(通常一个签名约 720 CU)。

  3. Write-lock CUs:锁定账户(防止冲突)造成的系统开销(每个可写账户约 300 CU)。

如果你的代码写得烂(消耗 CU 多)、锁定的账户多,你的 Priority 分数就会降低。如果你想让交易更快成交,要么多加钱,要么优化代码减少 CU 消耗。

计算单元限制

计算单元是衡量一笔交易消耗了多少网络资源(主要是 CPU 运行时间)的指标。

  • 默认配置:为了防止某个程序恶意耗尽节点资源,Solana 给每条指令默认分配了 200,000 CU,一笔交易总上限通常是 140 万 CU

  • 资源类型:除了 CPU 指令,内存使用、账户加载等都会消耗 CU。

计算单元价格

Solana 手续费设计中有非常独特的一点:

优先费用的扣除是基于你“申请”了多少 CU,而不是你“实际用”了多少 CU

假设一个简单的转账指令实际只消耗了 5,000 CU

  • 场景 A (默认情况):你没有设置限制,系统默认分配 200,000 CU。如果你设置了 $10$ 微 lamports 的单价,你将为 $200,000$ 个单位付钱。

  • 场景 B (优化后):你主动通过 SetComputeUnitLimit 告诉系统:“我只要 6,000 CU”。同样的价格,你付的钱只有场景 A 的 3%,但你的交易优先级反而可能更高。

因为优先费用总额是:

$$Prioritization\ Fee = CU\ Limit \times CU\ Price$$

  • CU Limit:你申请的额度。

  • CU Price:你愿意为每个单位支付的微 lamports ($10^{-6}$ lamports)。

验证者在排序时,会计算你的“性价比”评分:

$$Priority = \frac{Prioritization\ Fee + Base\ Fee}{1 + CU\ Limit + \text{其他系统开销}}$$

如果你把 CU Limit 设得很小(刚好够用),而 CU Price 设得较高,你的交易在验证者的待处理队列里就会排得非常靠前。

程序

特性

在 Solana 中,我们不叫“智能合约”,而叫“程序”,这更符合它作为分布式操作系统的定位。

Solana 程序最重要的一点核心特性是无状态。因为逻辑与数据分离,程序本身不存储任何变量(如余额、计数器)。它就像一个“纯函数”,所有的状态读取和写入都必须通过交易传入的账户来完成。

Solana 程序的性能之所以高,是因为它运行在一种高度优化的虚拟机上。

  • LLVM 与 ELF:当你编写好 Rust 代码后,Solana 工具链会使用 LLVM 将其编译为 ELF (Executable and Linkable Format) 文件。这是 Linux 系统中标准的执行文件格式。

  • sBPF (Solana Berkeley Packet Filter):ELF 文件中包含的是 sBPF 字节码

BPF 最初用于 Linux 内核过滤数据包,其特点是极快、安全、且在沙盒中运行。Solana 对其进行了扩展(sBPF),使其能处理复杂的智能合约逻辑。

在编写程序时,有两种方法:

特性Anchor 框架原生 Rust (Native)
开发速度极快(大量宏简化代码)较慢(手动处理所有细节)
安全性高(内置了许多防攻击的安全检查)需要开发者对安全漏洞有极深理解
样板代码少(自动生成 IDL 和账户解析)多(需要手写账户校验和序列化逻辑)
适用人群99% 的开发者,尤其是初学者对性能有极致追求或做底层创新的专家

更新

不同于以太坊合约默认的“部署即永久”,Solana 程序默认是可更新的

  • 升级权限 (Upgrade Authority):部署时,你会拥有一个权限。只要你有这个权限,就可以通过发送交易来更换链上的字节码,而 Program ID(地址)保持不变

  • 不可篡改化:如果你希望程序彻底去中心化,可以将权限设置为 None。一旦设置为 None,该程序将永远无法更改,类似于“焊死了盖子”。

在验证上,通过 Anchor 或专门的 CLI 工具,可以在确定性的环境下编译代码。如果生成的字节码哈希值与链上一致,就证明了代码的真实性。这对于 DeFi 等需要极高安全信任的项目至关重要。

程序派生地址(PDA)

定义

在传统的区块链世界,地址通常成对出现:公钥(地址) + 私钥(签名权)。 但在 Solana 中,PDA 是一类特殊的地址:

  • 没有私钥:没有任何人(包括开发者)拥有这个地址的私钥。

  • 曲线外(Off-curve):从数学上讲,Ed25519 是一条椭圆曲线。正常的钱包地址是这条曲线上的一个“点”。而 PDA 被设计成不在这条曲线上的点。

  • 程序掌控:虽然没有私钥,但 Solana 运行时赋予了派生它的程序(Program)一种特权,即程序可以代表这个 PDA 进行“虚拟签名”

PDA的应用可以解决以下两个问题:

A. 自动化的状态映射(类似数据库的主键)

在 AI 开发中,你可能会用 Map<UserID, UserProfile> 来存用户信息。 在 Solana 上,如果你想存每个用户在你程序里的数据,你怎么找那个账户地址?

  • 笨办法:让用户在前端手动输入一个地址。

  • PDA 办法:使用 [ "profile", 用户公钥 ] 作为种子(Seeds)派生一个 PDA。

  • 结果:只要知道用户公钥和“profile”字符串,程序随时能算出一模一样的 PDA 地址。你不需要在数据库里记地址,代码逻辑本身就是数据库索引

B. 程序自主权(Trustless Custody)

如果你写一个去中心化交易所(DEX),需要一个账户来存用户的押金。

  • 如果存进开发者的钱包,那是“跑路风险”。

  • 如果存进一个 PDA,由于没有私钥,谁也取不走。只有当程序逻辑(代码)判断满足条件时,程序才能签署指令把钱划走。

派生

派生函数 find_program_address(seeds, program_id) 的工作原理如下:

  1. 输入:你提供的一组 种子 (Seeds)(如字符串 "vault")和你的 程序 ID

  2. 拼接:系统把这些内容拼在一起,进行哈希运算。

  3. 曲线碰撞检查

    • 如果算出来的结果正好落在 Ed25519 曲线内(即它存在对应的私钥),这是不合法的 PDA。

    • 寻找 Bump:系统会引入一个叫 bump 的数字(从 255 开始往下减),把它也塞进种子序列里重新哈希。

  4. 最终结果:直到找到一个哈希值落在曲线之外,函数才会返回这个地址和那个成功的 bump

生命周期

需要注意的是,派生(Derive) $\neq$ 创建(Create)

  • 第一步:派生。这是一个纯数学计算,在前端(JS)或合约里(Rust)都能算。算出来的时候,链上可能根本没这个账户。

  • 第二步:创建。程序必须显式调用 System Program 并在指令里携带那个 bump 种子,申请把这个 PDA 地址正式写在账本上。

  • 第三步:签名。当程序需要操作这个账户(比如从里面转账)时,它使用 invoke_signed 函数,并传入最初派生时的种子和 bump。Runtime 验证后会认可这是 PDA 的合法授权。

跨程序调用(CPI)

定义

CPI 允许一个程序在执行过程中,直接触发另一个程序的指令。

  • 类比:这就像你在 Python 代码里用 requests.post() 调用了一个 API,或者在 C++ 里调用了一个 shared_library.so 中的函数。

  • 原子性:整个调用链是原子的。如果嵌套调用的任何一环失败,整个交易(包括最外层的调用)都会回滚。

当程序 A 调用程序 B 时,账户的权限是向下延伸的。如果原始交易中,用户 A 签名了(is_signer = true),那么当程序 A 调用程序 B 时,程序 B 同样认为用户 A 是签名者。程序 B 获得的权限不能超过程序 A 拥有的权限。例如,如果程序 A 收到的是一个只读账户,它无法在调用程序 B 时将其变为“可写”。

在进行 CPI 时,程序可以代表从其程序 ID 派生的PDA进行签名。这些签名者权限从调用程序扩展到被调用程序。因为 PDA 没有私钥,它无法在交易刚发起时签名。

  • 虚拟签名:程序通过 invoke_signed 函数发起 CPI。

  • 原理:程序提供派生 PDA 时用的 种子 (Seeds)Bump。Solana 运行时(Runtime)会现场计算:“这个账户确实是由这个程序用这些种子派生的吗?” 如果匹配,系统就会在这次 CPI 调用中给该账户打上 is_signer = true 的标记。

  • 意义:这允许程序“合法地”控制它名下的资产,而不需要持有任何私钥。

为了防止恶意程序造成死循环或资源枯竭,Solana 严格限制了嵌套调用的深度。

限制项当前值 (2026)备注
最大堆栈高度5 (或 9)包含顶层调用。即 A -> B -> C -> D -> E。
最大 CPI 深度4指从第一层开始往下跳的次数。
2026 最新进展SIMD-0268随着 Agave 4.1 等版本的推进,Solana 正在将此限制从 5 层提升至 9 层,以支持更复杂的 DeFi 乐高组合。

另外,solana默认情况下,禁止间接重入(即 A -> B -> A 是不允许的)。这从根源上杜绝了像以太坊历史上著名的 DAO 攻击那样的“重入漏洞”。程序不能在状态还没更新完时,被另一个程序绕回来再次修改。

例子

假设你写了一个“金库程序”(Vault Program),这个程序拥有一个 PDA 账户,里面存着 100 个 SOL。

如果你想把这 100 个 SOL 转给用户,你的程序不能直接修改 PDA 的余额(因为 PDA 的 Owner 通常是 System Program),你必须“求” System Program 来帮执行转账。

这个过程如下:

  1. 你的程序发起一个 CPI

  2. 目标程序是 System Program

  3. 指令是 Transfer

  4. 关键点:你使用 invoke_signed 函数,并传入派生 PDA 的种子(Seeds)

  5. Runtime 看到种子后,给这个 CPI 盖上一个“PDA 已签名”的虚拟戳。

结论:在这个场景下,你为了动用 PDA 的权利而调了 System Program,这就是一次标准的 CPI