Solana 合约开发基础
目标
-
了解Solana账户模型和交易的模型
- 掌握Solana账户的四个关键字段:数据、可执行标志、余额和所有者
- 理解Solana与ETH账户模型的区别及其优势
- 了解程序账户、数据账户和PDA的概念和用途
-
深入交易的结构,理解Solana交易底层字段
- 掌握交易的版本类型(Legacy和Version 0)及其区别
- 了解地址查找表(ALT)的作用和使用场景
- 熟悉交易结构中的签名、消息、账户权限和指令的组成
-
能读懂Solscan上的所有内容,包括每个字段的含义和提供的trace
- 解析交易元数据、执行结果和日志信息
- 理解账户变化、余额变化和计算单元消耗
- 掌握内部指令的含义和调用关系
-
能读懂Solana底层的交易执行后的trace,了解交易执行的情况
- 理解交易确认的三个级别:单次确认、乐观确认和最终确认
- 掌握交易日志的解读方法和常见问题排查思路
- 了解交易费用计算和计算单元消耗原理
-
了解链上程序的基本概念,包括 IDL, PDA, CPI, 当前调用上下文、PDA签名
- IDL:合约接口描述语言及其在客户端调用中的应用
- PDA:程序派生地址的生成原理和安全机制
- CPI:跨程序调用的基本原理和实现方式
- 程序调用上下文和账户权限管理
- 无私钥签名机制的实现原理
总的来说理论和实际结合,知识点都给出了实际的运行数据和程序。
1. Solana 账户模型
初学者须知:Solana的账户模型与以太坊等区块链不同,它采用了"一切皆账户"的设计理念。在Solana上,智能合约也是账户,用户余额也是账户,数据存储也是账户。这种设计使得Solana能够实现高度的并行化处理。
每个账户都有一个唯一地址(32 字节 Ed25519 公钥)作为标识。单个账户最多可存储 10 MB 数据,账户的数据包括以下字段:
- 数据(data):一个字节数组,存储账户的状态或程序代码。
- 是否可执行(executable):布尔值,指示账户是否包含可执行程序代码。
- 余额(lamports):账户中存储的 SOL 数量,单位为 lamport(1 SOL = 10⁹ lamports)。lamport是Solana的最小货币单位,类似于以太坊的wei或比特币的聪。它以计算机科学家Leslie Lamport命名,用于支付交易费用和作为账户租金。
- 所有者(owner):账户的程序所有者,只有该程序可以修改账户数据或减少账户余额。
所有权概念解释:在Solana中,所有权(ownership)决定了谁可以修改账户数据。只有账户的所有者(通常是一个程序)才能修改该账户的数据或减少其余额。例如,如果一个账户的所有者是Token程序,那么只有Token程序才能修改该账户中存储的代币余额。普通用户无法直接修改账户数据,必须通过调用拥有该账户的程序来间接修改。这与以太坊的模型有显著区别,在以太坊中,合约拥有自己的存储空间并直接控制其内部状态。
我们可以设想,它和ETH的账户模型的区别在哪里,在于这个账户模型里没有字节码。那么破除了智能合约+存储空间的存储模型,一个智能合约可以有很多个存储空间。那么这有什么好处呢,以ERC20为例子,我们可以把 balance[user]=amount
这个值存储在每个 user 的账户里,那么改变多个user的值的时候,就能够并行化处理了。因为存储空间单独属于每个用户。
与以太坊的对比:
- 以太坊:每个合约有单一的存储空间,所有用户数据存储在同一个合约状态中
- Solana:一个程序(合约)可以控制多个数据账户,每个用户的数据可以存储在单独的账户中
- 并行执行:Solana的设计使得不相关的交易可以并行处理,显著提高了吞吐量
1.1 Native Programs
除了上面简单提到的智能合约程序之外,如果需要统一执行的模型,我们还需要一些辅助的程序。比如,给持有 ERC20 代币的用户分配新的空白account,这叫做初始化。再比如给智能合约加载用户的数据。这些功能的实现,就由 Native Program 完成了。因为是预先在节点客户端代码里定义的,所以叫做「原生程序」
Native Programs 是 Solana 的内置程序,相当于区块链运行时的一部分,为开发者提供了基础功能支持。这些程序直接嵌入 Solana 的验证器实现中,保证了运行效率和安全性。
- 系统程序(System Program):账户管理。
- BPFLoader 程序:BPFLoader 程序是 Solana 的"程序管理器",专门用于处理自定义智能合约的部署和升级。其主要职责包括:加载程序:将编译好的程序代码上传到链上。管理更新:允许合约开发者对程序进行升级。执行代码:验证和执行程序代码,确保安全性和一致性。
- Sysvar 账户:存储 Solana 网络状态的特殊账户,如当前区块高度或时间戳。Sysvar 账户减少了链上数据查询的复杂度,为开发者提供了高效的状态读取方式。
1.2 系统程序(System Program)
系统程序(System Program)是 Solana 的原生程序之一,负责管理和初始化账户。可以理解为是一个"账户管理员",负责创建账户、分配空间、设置账户的所有权。比如,用户的钱包账户默认由系统程序拥有,其 SOL 余额由系统程序管理。用户通过私钥控制账户,但账户的核心行为(如交易费用支付)由系统程序负责。
- 新账户创建:只有系统程序可以创建新账户。在创建账户时,用户需要指定账户的空间大小(以字节计)和初始 SOL 余额。
- 空间分配:系统程序为账户分配存储空间。一旦创建,账户的大小是固定的,不能动态扩展。
- 程序所有权分配:系统程序创建账户后,可以将账户的所有权转移给其他程序(如自定义智能合约)。
- 系统账户:默认情况下,所有新账户的所有者是系统程序。只有由系统程序拥有的账户可以用于支付交易费用。
这里所有权比较有意思,一个程序可以对应多个数据账户,那么这个权限就需要一个字段来表示。“所有权” 是一种程序和账户之间的权限关系,owner字段就记录了拥有该账户的程序的公共密钥,定义了谁有权修改账户中的数据或操作账户的余额。
1.3 智能合约(Programs)
在 Solana 中,智能合约被称为 程序(Programs),是链上的可执行代码,用于实现复杂的业务逻辑和交互流程。提到程序和数据是分离,但是程序本身又做了数据和元信息的分离,这样能够对程序进行升级。一笔升级合约的交易,先创建一个缓冲区账户,然后将新字节码从缓冲区账户移动到 可执行数据账户,同时保持 Program ID 不变。这样一次性替换而不是修改,不能修改的原因是,每个账户的数据是 预分配的、定长的,不允许动态修改大小。
程序账户(Program Account):程序的核心账户,存储合约的元数据(如更新权限)。其地址通常被称为 Program ID,用于标识和调用该程序。
可执行数据账户(Executable Data Account):存储编译后的合约字节码(可执行代码)。标记为可执行(executable = true)。
缓冲区账户(Buffer Account):临时账户,用于合约的部署或升级过程中存储字节码。一旦部署完成,数据会被转移到可执行数据账户,缓冲区账户会被关闭。
那如何避免可执行数据账户被修改呢,程序账户中的元数据决定的。
1.4 数据账户(Data Accounts)
上一节的程序账户指向的Executable Data Account 和这一节的数据账户是相同的结构,关键在于字段 executable = true 或者false。可执行的数据账户里存的是BPF代码,而且还存在限制,data 大小是固定的,不能修改。但是普通数据账户是可以通过支付租金,realloc 进行扩展或缩小。
智能合约的"无状态性"设计,意味着它需要依赖其他账户存储链上的状态数据,这些账户被称为 数据账户(Data Accounts)。
创建过程:数据账户的创建由系统程序完成,开发者需要指定账户的存储空间和初始 SOL 押金(租金)。系统程序将数据账户的所有权转移给目标程序(智能合约)。智能合约通过调用初始化方法,对数据账户进行写入操作,存储特定的状态信息。
数据账户可以存储任意数据结构,但存储空间有限(最大 10 MB)。数据账户的数据只能由其所有者(即目标程序)修改,其他程序或用户无法直接更改。
下图表示所有权的关系
根据上面账户模型,我们可以知道程序应该是如何被调用和执行的。首先程序应该花租金,通过系统程序部署在链上。然后要和程序交互,就要定义至少3个部分,第一个部分程序的逻辑,第二部部分数据账户的结构,第三部分程序读取数据账户时的结构或者上下文。
真实的例子,程序账户,读者应该能理解其中的字段了:https://solscan.io/account/675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8

2. 交易和指令的结构
初学者须知:Solana交易是不可变的指令集合,一旦提交就必须整体成功或失败。了解交易结构对于调试问题和优化执行至关重要。交易大小限制和账户包含方式都会影响应用设计决策。
2.1 交易的版本
Solana 目前支持的交易版本实际上只有两种:
- Legacy 版本:较旧的交易格式,没有额外的功能特性
- 版本 0(Version 0):新增了对地址查找表(Address Lookup Tables)的支持
https://solana.com/zh/developers/guides/advanced/versions 指出当使用 RPC 请求获取交易数据时,必须通过 maxSupportedTransactionVersion
选项指定您的应用程序支持的最高交易版本,否则如果返回了版本 0 的交易,而您没有设置支持该版本,请求就会失败。
ALT 允许交易引用存储在链上的账户地址,而不必在每笔交易中包含完整的地址。过去每个地址是完整 32 字节,版本 0 交易可以使用短索引(通常只需 1 字节)。实际完整的交易存储在特殊的帐户里,叫做Address Lookup Table Account。这样可以实现重复使用,减小交易大小。同时由于交易大小有限制(通常是1232字节,来源于 IPv6 MTU 大小(1280 字节)减去网络头部(48 字节)),这直接限制了一个交易可以包含的账户数量,32字节变成1字节,能够处理的账户数量从几十上升到几百。
https://solscan.io/tx/3TJB6skkXLyDKxxjREX3tGcMt1DHJxnWoK74KPkoDwNUniXJuhXhKbNrHp4qrqeZofD9gQH7gpX7jfi5RBRxV7uH 里就用到了 https://solscan.io/account/2Kzkpo4uvvZLCysuJY8mhjVDr4LLDJnXfBJJs5TEmRxH ,里面有256个帐户。
1 | solana address-lookup-table get 2Kzkpo4uvvZLCysuJY8mhjVDr4LLDJnXfBJJs5TEmRxH |
在addressTableLookups 指定了的账户,会拼接到 account keys 后面,同样的用指令索引。参考后面的交易结构。
2.2 交易的结构
对于已经确认过的交易,也就是历史交易,会包括 slot, 区块时间、交易版本(Legacy 或者是 version 0)。最重要的是交易体和元信息。交易体主要是,参考https://solana.com/zh/docs/core/transactions
- 签名(Signatures):交易签名数组,每个签名 64 字节
- 消息(Message):交易的实际内容,包含所有执行信息

1 | pub struct Message { |

-
消息头(Header)
:描述账户权限和签名要求。这里只定义了数量,那么还要排序机制来识别账户类型,所以账户地址是有顺序的。
- num_required_signatures:交易需要的签名数量
- num_readonly_signed_accounts:只读且需要签名的账户数量
- num_readonly_unsigned_accounts:只读且不需要签名的账户数量
-
账户地址(Account Keys):交易涉及的所有账户地址列表。[可写且是签名者的账户, 只读且是签名者的账户, 可写但不是签名者的账户, 只读且不是签名者的账户],Solana 可以将账户数组分成四个区域(可写+签名、只读+签名、可写+不签名、只读+不签名),好做并发处理。
-
最近区块哈希(Recent Blockhash):交易的时间戳。
-
编译指令(Compiled Instructions):要执行的指令列表。
-
address_table_lookups:这个是可选的,Legacy交易没有。可以有多个ALT账户,折合account keys 是独立的。每个账户会指明
- account_key: Pubkey: 查找表账户的公钥地址。
- writable_indexes: Vec
: 用于加载可写账户地址的索引列表。 - readonly_indexes: Vec
: 用于加载只读账户地址的索引列表
指令是最关键的,在account keys里,程序的索引,帐户的索引,以及输入参数。
1 | pub struct CompiledInstruction { |
2.3 RPC返回结果
什么是RPC:RPC(Remote Procedure Call,远程过程调用)是与Solana区块链交互的主要方式。开发者通过RPC接口发送交易和查询区块链状态。当你使用钱包发送SOL或调用智能合约时,底层都是通过RPC请求完成的。交易确认状态、账户数据查询和程序调用都通过RPC节点处理,理解RPC是开发Solana应用的基础。
交易执行后,还会有交易执行结果的元数据,包含了交易执行前后的状态变化以及执行过程中的详细信息。
- err: Option
: 交易执行是否出错,如果有错误则包含错误类型。为 None 表示交易成功执行。 - status: TransactionResult<()>: 注释标明此字段已弃用,保留是为了向后兼容
- fee: u64: 交易实际支付的费用(以 lamports 为单位)
- pre_balances / post_balances: Vec
: 交易执行前后所有相关账户的余额(以 lamports 为单位),顺序与交易的 account_keys 相同 - inner_instructions: OptionSerializer<Vec
>。 交易内部执行的指令(例如一个指令可能触发其他程序的指令) - log_messages: OptionSerializer<Vec
>, 交易执行过程中程序输出的日志消息,开发者常用这些日志进行调试。 - pre_token_balances / post_token_balances: OptionSerializer<Vec
> - 交易执行前后相关账户的代币余额
- 仅适用于涉及 SPL 代币的交易
- rewards: OptionSerializer
: 交易执行过程中分配的奖励(通常用于验证者奖励) - loaded_addresses: OptionSerializer
: 通过地址查找表加载的额外地址。版本 0 交易特有的字段 - return_data: OptionSerializer
: 程序执行返回的数据 - compute_units_consumed: OptionSerializer
: 交易执行消耗的计算单元数量。用于了解交易的计算复杂度
这里比较关键的是内部指令。当一个 Solana 程序(智能合约)执行时,它可以通过跨程序调用(Cross-Program Invocation, CPI)调用其他程序。这些被调用的指令就是"内部指令"。
index
: 表示触发这些内部指令的原始交易指令的索引。instructions
: 包含所有被触发的内部指令列表。
比较有趣的是,内部指令的嵌套关系主要通过 stackHeight
字段来表示,而不是通过 JSON 的物理结构。solscan上也没有体现嵌套。
看一个复杂的例子,https://gist.github.com/learnerLj/872c4831f0b6a8d93096f13105da1b89。
- 第1个账户 (
ERtGUg2LUTdWKQDpHGZSwZpuZMYvTVqE22Lub1XCmP1f
): 可写 + 需要签名(唯一需要签名的账户) - 第2-8个账户 (从
6WLBpdFzC5td9H62ZdAFmMS5hrK12sTCs1BjdmKZK2ep
到Hx7Q2g7KPRB9J2b7yS1gk8K9X9aPT2XzZMhUVjFYcTYg
): 可写 + 不需要签名 - 最后7个账户 (从
11111111111111111111111111111111
到J2nUHEAgZFRyuJbFjdqPrAa9gyWDuc7hErtDQHPhsYRp
): 只读 + 不需要签名。
3. 交易的流程
Solana 的交易执行流程主要分为以下几个阶段:
- 交易构造:用户创建交易,指定目标程序、相关账户和指令数据,并用私钥签名。
- 交易提交:钱包或应用程序将交易提交到 Solana 节点(RPC 服务器)。
- 交易验证:节点检查交易的签名、账户权限和余额等。
- 交易执行:按指令调用目标程序,并在 Solana 的并行运行时中执行。
- 交易确认:如果交易成功执行并最终写入区块,返回交易哈希作为确认。
3.1 准备
签名(Signatures):交易需要由 发送方钱包 使用私钥签名,以证明交易的合法性。 一个交易可以包含多个签名(例如,多重签名交易)。
消息(Message):描述交易的细节,包括目标程序(Program ID)、相关账户及操作指令。
账户列表(Account Keys):指定交易涉及的所有账户,包括程序账户、数据账户、用户账户等。在交易执行时,这些账户会被预加载到内存,使 Solana 能够并行处理交易,而不会因为未预先声明的账户访问而引发冲突。
消息中的指令,是对程序的具体调用请求,包含:
- 目标程序(Program ID):需要调用的智能合约。
- 账户列表:程序需要访问的账户。
- 指令数据(Instruction Data):具体操作的参数或方法标识。

3.2 发送交易
客户端将交易发送到本地的 RPC 节点(通常由钱包或 DApp 提供)。RPC 节点会将交易广播到整个 Solana 网络的验证器节点。验证通过的交易会被添加到候选区块中,等待领导节点(Leader Node)将其提交到区块链。
你可以看到当前的leader以及他们的位置 https://app.marinade.finance/network/ , 可以发现德国、法国、荷兰、美国东部是出现概率最高的。
3.3 调用合约(智能合约执行)
交易执行时,验证节点从链上加载交易涉及的所有账户数据(包括程序账户和数据账户)到内存中。账户数据包含程序代码(如果是程序账户)和存储的状态信息(如果是数据账户)。
Solana 的运行时调用目标合约的代码(程序账户中存储的字节码),并传入以下参数:
- 交易中指定的账户列表,所有涉及的账户必须被正确声明,并满足访问权限。
- 指令数据(包含调用方法及其参数)。
- 程序在运行时中执行逻辑,访问和修改相关账户的状态。
如果程序需要调用其他合约,可以通过 Cross-Program Invocation(CPI) 实现。当前程序暂停执行,将控制权转交给目标合约。完成后,返回执行结果,继续当前程序的逻辑。
3.3 返回结果与确认
交易确认与前端设计:理解Solana的确认级别对前端开发至关重要。例如,处理一笔支付时,你可能希望在显示"处理中"状态后,收到"乐观确认"(约1秒)时更新为"正在确认",最终在"最终确认"(约16秒)后更新为"已完成"。不同应用场景需要不同的确认级别,正确选择可以在安全性和用户体验间取得平衡。
程序执行完成后,将结果(如账户状态更新、事件触发)写入到内存中的账户数据。如果发生错误(如参数无效或余额不足),交易会被回滚,账户数据恢复到初始状态。成功的交易会被记录在区块链上,生成交易哈希(Transaction Hash)。失败的交易不会被记录,但错误信息会返回给客户端。
在 Solana 上,交易的确认由 多个层级 决定,主要包括:
- 单次确认(Single Confirmation)
- 乐观确认(Optimistic Confirmation)
- 最终确认(Final Confirmation)
不同的确认级别提供了不同的交易安全性保证,应用可以根据需求选择适当的确认方式。Solana 的 RPC 服务 允许开发者查询交易状态,并提供三种主要的 commitment 级别,与确认层级相对应:
确认级别 | 处理过程 | 特点 | 相关 RPC commitment |
---|---|---|---|
单次确认(Single Confirmation) | 1. 交易被提交到 Leader(当前区块生产者) 处理。2. Leader 节点打包交易,并将其包含在最新的区块中。3. 交易的区块被广播到网络,但尚未被其他验证者确认。 | 交易 已经进入区块,但未经过验证者共识确认。可能会因为 网络分叉 而被回滚。适用于低延迟、高频交易场景(如 DEX 下单)。 | processed |
乐观确认(Optimistic Confirmation) | 1. 交易所在的区块 已经被至少 1 个后续区块确认。2. 交易的成功率大幅提高,因为网络中大多数验证者已经接受了该区块。3. 回滚的可能性大幅降低,但仍然存在极端情况下的分叉风险。 | 交易大概率不会被回滚,适合普通 DeFi 交易(如借贷、流动性操作)。交易的确认速度很快,通常在 400ms - 1s 之间完成。适用于 中等安全性场景。 | confirmed |
最终确认(Final Confirmation) | 1. 交易被32 个后续区块确认(约 16 秒后)。2. 交易进入 区块历史(Ledger History),不可逆转。3. 由于 Solana 采用 Tower BFT 共识机制,一旦 32 个区块确认,意味着 整个网络都达成了一致。 | 交易100% 不可逆,不会因分叉被撤销。适用于提款、结算等高安全性操作。一般情况下,大约 16 秒 内可达最终确认。 | finalized |
4 链上程序
建议学习 anchor https://www.anchor-lang.com/ , 完成下面的示例: https://beta.solpg.io/tutorials/hello-anchor
4.1 基本结构

line 4 的 id 就是程序的公钥,也是程序的ID。 #[derive(Accounts)]
是交互的上下文,因为不需要额外控制,所以为空即可。因为不需要数据的输入,我们没有定义数据账户。
4.2 解读指令
看部署结果,大概消耗了2.54 sol。下面是相关账户字段:

- 4T3p是我的地址,资金来源。
- Nyy8 是程序账户,程序ID。
- FHnS 是程序的可执行数据账户,存储BPF字节码。
- Qfu4 是缓冲区账户。
- System Program 是 系统程序(用于账户创建和资金转移)
- BPF Upgradeable Loader 负责加载和管理 BPF 智能合约
- Sysvar: Clock 是系统时钟账户,获取时间戳
- Sysvar: Rent 是租金计算账户(用于确定账户是否需要支付租金)
下面是指令字段:
📌 指令 1:创建程序账户
1 | { |
- 指令类型:系统程序 createAccount
- 创建 Nyy8 作为 程序账户(Program Account)。
- 账户所有者设为 BPF Upgradeable Loader,表示它是一个可升级合约。
- 我的4T3p调用的
- 分配 36 字节的空间,这只是程序账户的元数据,实际代码会存储在 Program Data Account。
📌 指令 2:使用 BPF Upgradeable Loader 部署程序
1 | { |
- 从缓冲区账户 (Qfu4) 读取 BPF 代码。
- 创建 FHnS 作为可执行数据账户,用于存放 BPF 代码。
- 将 BPF 代码写入 Program Data Account,然后把 Program Account 设置为 executable = true,让它变成可执行合约。
- Authority 由 4T3p 设定,表示该账户有权限升级合约。
📌 指令 2.1(内部指令):创建 Program Data Account
1 | { |
- 指令类型:系统程序 createAccount
- 创建 Program Data Account(FHnS),存放合约的 BPF 代码。
- 转移 2.54 SOL 以支付存储租金,因为 Solana 账户必须存足够的 SOL 以维持存活。
- 分配 365181 字节的空间。
4.3 ETH开发者FAQ
❓我们首先疑惑,为什么需要 365KB 这么大来存储极端简单的 hello world 程序。如果是类似EVM的字节码不可能这么大。
- Solana BPF 程序的存储对齐。Solana 要求程序代码存储在 1KB 对齐的存储块中,所以即使你的代码实际只有 150KB,最终可能会被填充到 365KB。这类似于 操作系统的页面对齐,有助于 高效的内存访问和程序加载。
- Rust 编译的 BPF 代码体积较大。由于 Rust 的安全性和泛型特性,编译出来的 BPF 代码往往比手写汇编代码要大。
- BPF 代码还存在代码段等信息。代码段(.text):存储指令(占用最大)。数据段(.data):存储常量数据、全局变量。只读数据段(.rodata):存储只读字符串和常量。符号表(.symtab):用于调试和符号解析。
❓为什么 Solana 交易会有子指令(Inner Instructions)?
子指令(Inner Instructions)是 Solana 某些程序在执行过程中调用其他程序或系统指令时产生的内部指令。在 Solana 交易执行时:
- 外部指令(Top-level Instructions) 由用户提交的交易指定。
- 子指令(Inner Instructions) 是智能合约或 Solana 内部机制在执行外部指令时,调用其他指令 产生的。
在部署智能合约的交易中:用户调用 deployWithMaxDataLen 来部署 BPF 程序(外部指令)。这个过程内部需要 创建 Program Data Account,因此 BPF Upgradeable Loader 触发了 createAccount 子指令。 BPF Upgradeable Loader(合约管理程序) 调用了 System Program(系统程序)。
❓BPF 代码是什么?
BPF 是一种低级的字节码格式,最初用于操作系统内核中的网络数据包过滤(比如 tcpdump、eBPF)。Solana 采用BPF 作为智能合约执行格式,但进行了优化,以支持高效的并行执行。
对比项 | Solana BPF | 以太坊 EVM |
---|---|---|
执行方式 | 预编译的 BPF 字节码 | 逐条解释执行 EVM 字节码 |
性能 | 高效(基于 eBPF JIT 编译) | 慢(逐条解释执行) |
并行执行 | 支持(Sealevel) | 不支持(单线程) |
计算模型 | 账户模型,基于租金(Rent) | 账户模型,基于 Gas |
编译语言 | Rust, C, C++ | Solidity, Vyper |
程序存储 | Program Data Account(不可变) | 智能合约账户 |
❓Fee(交易费用)—— 为什么这笔交易消耗 0.00001 SOL?
Solana 交易费用由以下两部分组成:
- 基础交易费(Base Fee):Solana 的每笔交易至少需要 0.00001 SOL(10,000 lamports)。这个基础费用是所有交易的最低成本,无论计算复杂度如何,都必须支付。
- 计算单元消耗(Compute Units Consumed):Solana 交易的执行成本是基于 Compute Units(计算单元)计算的,每个计算单元的消耗会影响交易费用,但只要在基本限额内,就不会额外收费(除非动态费用模型生效)。目前该交易消耗了 2,670 个计算单元,但仍然处于最低交易费用标准 0.00001 SOL 内,因此不会额外增加费用。
优先级费用(Prioritization Fee)解释:
在网络拥堵时,Solana允许用户支付额外的优先级费用来提高交易被处理的优先级。这类似于以太坊的gas price机制,用户可以在基础费用之上支付额外的费用来"插队"。计算公式为:优先级费用 = 每计算单元的价格 × 消耗的计算单元数量
。例如,如果你设置每计算单元价格为1 micro-lamport (0.000001 lamport),消耗了100,000计算单元,那么优先级费用为0.1 lamport。
❓Compute Units Consumed(计算单元消耗)—— 为什么消耗 2,670 计算单元?
计算单元(CU,Compute Unit)是 Solana 交易的执行成本度量单位,类似于以太坊的 Gas。每个 Solana 交易都消耗一定数量的计算单元,用于执行智能合约、验证账户权限、读取数据等。计算单元决定了交易的执行复杂度,计算越复杂,消耗的 CU 越多。
默认每个交易最多可以使用 200,000 CU。如果一个交易消耗过多计算单元,它可能会因为超出限制而失败。Solana 提供 prioritization fee 机制,高价值交易可以支付更高费用,提高计算单元上限。
❓为什么提示 Transaction Version(交易版本)?
Solana 交易格式随着协议升级而不断演进,目前有 两种主要交易版本:
- Legacy(传统版本)。旧版本交易格式,兼容早期 Solana 交易规则。交易结构较简单,但缺少部分新功能(如可变费用)。
- V0(版本 0,现代化交易格式)。支持 Address Lookup Table(地址查找表),减少交易体积,提升效率。支持更复杂的动态费用结构,适用于高价值交易。
V0 版本的主要优势是"地址查找表"(减少交易大小),但合约部署涉及的账户较少,legacy 版本足够。如果你希望使用 V0 交易,可以在提交交易时,指定 --with-address-lookup-table 或者直接使用支持 V0 交易的 RPC 客户端。
4.4 调用合约
直接使用 playground里提供的ts代码调用。

由于没有验证合约,所以参数没有解码,从执行结果上知道,我们的日志成功打印了。
1 | $ solana confirm -v 5wYU6EX9sbfQT9GBPz7kyeQXpsfjuP9UhFRvcECv1hPHapHVaoTsPACmendiV3TgQEiNyacvRxzDiviJompZqcQ7 |
4.5 IDL
在以太坊(EVM)中,智能合约使用ABI(Application Binary Interface)规定消息的调用格式,例如函数选择器(Function Selector)和参数编码规则(ABI Encoding)。
那么,Solana如何规定智能合约的调用格式?Solana没有 ABI,而是使用了一种更灵活的方式来处理合约的调用数据。
- 在原生 Solana SDK(不使用 Anchor)中,调用合约的指令格式是完全手动定义的。
- 在Anchor 框架中,使用 IDL(Interface Description Language,接口描述语言) 来定义和标准化智能合约的调用格式。
IDL(接口描述语言)是Anchor 框架用来描述智能合约 API的标准格式。它类似于以太坊的 ABI,但更强大:
- IDL 让 Solana 智能合约具备结构化的 API,便于解析和调用。
- IDL 主要用于 Anchor 框架,但也可用于其他框架来生成合约调用代码。
- IDL 采用 JSON 格式,详细描述了合约的函数、账户结构和返回数据格式。
IDL 包含以下主要部分:
- name:合约的名称
- instructions:定义了智能合约支持的所有函数(方法)
- accounts:定义了智能合约需要访问的账户结构
- types:定义复杂数据结构(类似 Solidity 的 struct)
- events:定义事件(类似 Solidity 的 event)
- errors:定义错误类型
1 | { |
这个 IDL 代表的功能
- 合约名称: hello_world
- 方法 sendMessage
- 需要两个账户:
- user:用户账户(需要签名)
- messageAccount:存储消息的账户
- 需要 一个参数:message(字符串类型)
- accounts:定义了 MessageAccount 结构体,用于存储消息。
Anchor 提供了 TypeScript SDK,可以用 IDL 自动生成智能合约调用代码:
1 | import { Program, AnchorProvider, web3 } from "@project-serum/anchor"; |
在build那一块,还有一个 IDL initialize。 在 Anchor 框架中,每个智能合约都可以新创建一个数据账户,存储它的 IDL,这样客户端(如前端应用)可以自动查询合约的调用方法,而不需要提前知道函数签名。下面的交易就把 IDL 写入了程序可执行数据,同时也是初始化合约:

1 | $ anchor idl fetch Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV |
这就是IDL存储的账户 https://solscan.io/account/5FM1QHSWeVZVx3v37CezJcXv4VJstN1F7jmGoLwbt7Ck?cluster=devnet ,
1 | $ solana account 5FM1QHSWeVZVx3v37CezJcXv4VJstN1F7jmGoLwbt7Ck --output json |
4.6 PDA(Program Derived Address,程序派生地址)
上面存储IDL就是使用了PDA,程序控制这个地址。它由特定的种子(seeds)和程序ID(Program ID)通过确定性算法计算出来。PDA 允许程序生成可预测但安全的地址,用于存储状态数据,而无需用户手动创建账户或提供签名。
- PDA 是由种子(seeds)+ Program ID 确定性派生出的唯一地址。
- PDA 不是普通公私钥对生成的,它没有对应的私钥,只能由特定的 Solana 程序(Program)管理。
- PDA 账户只能由程序自身(Program)签名,而不能由外部私钥签名,这确保了只有合约代码能够修改 PDA。
这里有2个参数:
- 种子(Seeds):可以是静态值(如 “hello_world”)或动态值(如用户的公钥)。
- bump(防撞值):一个自动计算的额外字节,确保生成的地址不落在 Ed25519 曲线上,以防止被外部签名。
bump值详解:
bump值是一个0-255之间的数字,用于确保生成的PDA不在Ed25519椭圆曲线上(即没有对应的私钥)。为什么需要这个bump值?因为如果PDA落在了曲线上,理论上有人可以找到其私钥并直接签名,这会破坏PDA的安全模型。Solana在生成PDA时,从255开始递减bump值,直到找到一个不在曲线上的地址。在实践中,findProgramAddress函数会自动执行这个过程并返回找到的第一个有效bump值(通常称为"canonical bump")。
当程序需要验证PDA时,它会重新计算地址(使用相同的种子和bump值),确保请求来自合法的程序。这整个机制使得PDA可以"代表"程序进行签名操作,而无需实际私钥。
在 Solana 上,智能合约不能直接修改用户的账户数据,而是使用 PDA(Program Derived Address) 作为合约的状态存储。例如:
- 在 SPL Token 程序中,每个用户的 amount 存在 PDA 账户。
- 在 NFT 合约中,元数据(Metadata)存储在 PDA 账户。
- 在 DeFi 应用中,用户的流动性仓位信息存储在 PDA。
PDA的实际应用场景:
- 确定性地址生成:为每个用户生成唯一且可预测的账户地址
- 跨程序权限:允许一个程序代表另一个程序签名和操作账户
- 数据关联:将相关数据组织在一起,例如用户A在项目B中的仓位数据
- 省去交易签名:用户只需授权程序一次,后续操作可由程序通过PDA完成
4.7 CPI 跨程序调用
CPI解决的核心问题:CPI允许程序间合作,就像Web2中的API调用。这使得区块链上可以构建复杂的应用生态系统,一个程序可以利用另一个程序已经实现的功能。
在 Solana 上,每个智能合约(Program)都是独立的 BPF 程序,默认情况下,程序只能操作它自己拥有的账户,不能直接修改其他程序管理的账户。但 Solana 允许一个智能合约调用另一个智能合约的指令,这就是 CPI(跨程序调用)。
你的合约可以调用 系统程序(System Program) 的 transfer 指令进行 SOL 转账。你的合约可以调用 SPL Token Program,直接管理 SPL 代币账户(比如 mint、transfer)。你的合约可以与 其他 DeFi 合约 交互,实现组合式金融(Composability)。
CPI实际应用示例:
DEX与流动性池:当你在Jupiter等DEX上交换代币时,底层涉及多个程序间的CPI调用:
- 用户交易程序调用SPL Token程序转移用户代币
- 用户交易程序调用流动性池程序执行兑换
- 流动性池程序再次调用SPL Token程序发送交换后的代币
NFT铸造过程:
- NFT铸造程序调用System Program创建新的Metadata账户
- NFT铸造程序调用Token-Metadata程序初始化NFT元数据
- NFT铸造程序调用SPL Token程序铸造唯一代币
这种程序间协作能力是Solana生态系统强大的核心原因之一。
由于 CPI 允许一个合约调用另一个合约,这可能带来权限问题。Solana 采用 PDA(Program Derived Address)+ invoke_signed 作为签名方式,确保:
✅ 只有特定的合约可以执行某些操作。
✅ PDA 作为程序管理的账户,由特定合约控制。
4.8 调用上下文
Context的重要性:调用上下文是Solana程序安全性的基础。它明确规定了程序可以访问哪些账户、哪些账户需要签名以及哪些账户可以被修改。这种显式的账户访问控制是Solana能够实现并行处理的关键前提,也大大减少了智能合约漏洞的风险。
我们提到Solana的程序分成3个部分,程序逻辑,链上数据结构,调用上下文。下面来学习这个上下文。
在 Solana 上,每个交易都会传递一个调用上下文(Context),它包含:
- 当前交易涉及的所有账户信息(AccountInfo)。
- 交易签名者信息(Signer)。
- 程序调用的依赖(如 System Program)。
与以太坊的区别:在以太坊中,合约可以在执行过程中动态访问任何存储位置或调用任何合约,这限制了并行执行能力。而Solana要求预先声明所有需要访问的账户,这使运行时能够预先检测并发冲突,实现真正的并行处理。
1️⃣ Context
以这个项目为例子:https://beta.solpg.io/66df2751cffcf4b13384d35a
1 | pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> { |
调用上下文是通过 Context
2️⃣ Accounts 结构解析
SolTransfer 结构定义了所有需要的账户信息。
1 |
|
- Signer 表示这个账户必须提供交易签名,否则交易会失败。mut(可变)意味着交易可能会修改 sender 的 SOL 余额。
- SystemAccount 是普通 SOL 账户,只能存 SOL,不能存储其他数据。普通 SOL账户的管理者是 System Program。
- Program<'info, System> 代表 System Program,用于调用系统指令(如 transfer)。这个账户的 owner 必须是 BPFLoader,否则交易会失败。
3️⃣ 通过 Context 获取账户信息
在合约函数中,我们可以通过 ctx.accounts 访问传入的账户。 to_account_info() 的作用是转化成AccountInfo,Solana 底层账户对象,包含:key(公钥)、lamports(SOL 余额)、owner(账户所属的合约)、is_signer(是否是签名者)。
1 | pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> { |
4️⃣ CpiContext::new 创建调用上下文
CpiContext::new 用于构造 CPI(跨程序调用)所需的 调用上下文,它告诉 Solana:
- 我要调用哪个合约(Program ID)?
- 我要传递哪些账户?
- 我是否需要 PDA 作为签名者?(可选)
1 | let cpi_context = CpiContext::new( |
5️⃣执行指令
1 | // 执行转账 |
回顾IDL知识:
1 | { |
除了上面的创建上下文的调用方式,可以直接组装指令的形式,然后调用,比如:
1 | pub fn sol_transfer_two(ctx: Context<SolTransfer>, amount: u64) -> Result<()> { |
上面是利用已知的指令结构,填入对应字段组装的。
1 | pub fn sol_transfer_three(ctx: Context<SolTransfer>, amount: u64) -> Result<()> { |
上面则是直接手动填充指令字节。但是注意,不能用完整的 AccountInfo了,而是要用 AccountMeta。
指令的结构是:[方法 ID (4字节)] + [方法参数 (Borsh 编码)]。在Anchor中,指令的标识是用指令的位置索引。
4.9 invoke_signed
上面的例子,我们是要求 from_pubkey 是可签名的,也就是EOA。但是如果是程序控制的PDA,是没有自己的私钥的,也就是无法作为signer。那么需要另外的机制来实现。invoke_signed 允许 PDA在没有私钥的情况下作为签名者,执行合约调用。具体来说让Solana 根据 PDA 账户的 seeds 和 bump 验证身份。
代码例子参考:https://beta.solpg.io/66df2bd2cffcf4b13384d35b
1 |
|
相比一般的账户,添加了seeds, pda
作为计算基础,recipient 账户的公钥作为动态种子。bump:自动计算的值,保证 PDA 地址是有效的 Solana 账户地址。
1 | let seed = to_pubkey.key(); |
程序解析的时候,计算这个signer_seeds。如果程序和声明的 seeds 和 bump 不匹配,就会生成错误的PDA,交易失败。
根据测试用例,我们首先计算的PDA,依赖钱包地址生成的。
PDA: https://solscan.io/account/AMtiaEmz3NhhV3KFbTkSXYP1AraCLdR9n4AiB3zHNTw?cluster=devnet
可以发现PDA这里 isOnCurve
是 False,说明没有私钥控制。
❓为什么转给PDA时,我并没有看到创建PDA数据账户的指令?
我们提到系统程序控制PDA的生成,但是我们没看到createAccount指令。这是因为PDA 在 Solana 里默认是"懒创建(Lazy Initialization)"的!
在 Solana,账户的创建方式有两种:
- System Program::CreateAccount 用于创建新的可写账户,必须初始化 lamports 和 space。
- 直接向账户转账 SOL 只要账户不存在,Solana 允许它作为"空账户"存储 lamports 但不存储数据。
如果 PDA 账户没有存储数据(空账户),它可以直接接受 SOL,而不需要 createAccount!
❓为什么我的地址能控制PDA?
因为计算PDA时使用了我的地址和程序ID共同生成地址,程序验证的时候使用的是 to_pubkey.key()
,那么这个PDA只能给我转账。也就是说,任何人可以转给PDA,但是这个PDA里的金额,只能由我转出。
1 | const [PDA] = PublicKey.findProgramAddressSync( |
❓那其他程序能控制这个地址吗?如果其他程序也能生成同样的PDA,是不是也可以控制了呢?
不能。可以简化PDA的计算公式 PDA = hash(seeds + Program ID) + bump
,这个哈希碰撞是几乎不可能发生的。
如果用invoke的方式调用,则是先创建指令,相比invoke多出了一个PDA签名。
1 | let instruction = |