目标

  1. 了解Solana账户模型和交易的模型

    • 掌握Solana账户的四个关键字段:数据、可执行标志、余额和所有者
    • 理解Solana与ETH账户模型的区别及其优势
    • 了解程序账户、数据账户和PDA的概念和用途
  2. 深入交易的结构,理解Solana交易底层字段

    • 掌握交易的版本类型(Legacy和Version 0)及其区别
    • 了解地址查找表(ALT)的作用和使用场景
    • 熟悉交易结构中的签名、消息、账户权限和指令的组成
  3. 能读懂Solscan上的所有内容,包括每个字段的含义和提供的trace

    • 解析交易元数据、执行结果和日志信息
    • 理解账户变化、余额变化和计算单元消耗
    • 掌握内部指令的含义和调用关系
  4. 能读懂Solana底层的交易执行后的trace,了解交易执行的情况

    • 理解交易确认的三个级别:单次确认、乐观确认和最终确认
    • 掌握交易日志的解读方法和常见问题排查思路
    • 了解交易费用计算和计算单元消耗原理
  5. 了解链上程序的基本概念,包括 IDL, PDA, CPI, 当前调用上下文、PDA签名

    • IDL:合约接口描述语言及其在客户端调用中的应用
    • PDA:程序派生地址的生成原理和安全机制
    • CPI:跨程序调用的基本原理和实现方式
    • 程序调用上下文和账户权限管理
    • 无私钥签名机制的实现原理

总的来说理论和实际结合,知识点都给出了实际的运行数据和程序。

1. Solana 账户模型

初学者须知:Solana的账户模型与以太坊等区块链不同,它采用了"一切皆账户"的设计理念。在Solana上,智能合约也是账户,用户余额也是账户,数据存储也是账户。这种设计使得Solana能够实现高度的并行化处理。

Solana Account Model | Solana

每个账户都有一个唯一地址(32 字节 Ed25519 公钥)作为标识。单个账户最多可存储 10 MB 数据,账户的数据包括以下字段:

image-20250518222233864

  • 数据(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):临时账户,用于合约的部署或升级过程中存储字节码。一旦部署完成,数据会被转移到可执行数据账户,缓冲区账户会被关闭。

image-20250518222255401

那如何避免可执行数据账户被修改呢,程序账户中的元数据决定的。

1.4 数据账户(Data Accounts)

上一节的程序账户指向的Executable Data Account 和这一节的数据账户是相同的结构,关键在于字段 executable = true 或者false。可执行的数据账户里存的是BPF代码,而且还存在限制,data 大小是固定的,不能修改。但是普通数据账户是可以通过支付租金,realloc 进行扩展或缩小。

智能合约的"无状态性"设计,意味着它需要依赖其他账户存储链上的状态数据,这些账户被称为 数据账户(Data Accounts)

创建过程:数据账户的创建由系统程序完成,开发者需要指定账户的存储空间和初始 SOL 押金(租金)。系统程序将数据账户的所有权转移给目标程序(智能合约)。智能合约通过调用初始化方法,对数据账户进行写入操作,存储特定的状态信息。

数据账户可以存储任意数据结构,但存储空间有限(最大 10 MB)。数据账户的数据只能由其所有者(即目标程序)修改,其他程序或用户无法直接更改。

下图表示所有权的关系

image-20250518222310713

根据上面账户模型,我们可以知道程序应该是如何被调用和执行的。首先程序应该花租金,通过系统程序部署在链上。然后要和程序交互,就要定义至少3个部分,第一个部分程序的逻辑,第二部部分数据账户的结构,第三部分程序读取数据账户时的结构或者上下文。

真实的例子,程序账户,读者应该能理解其中的字段了:https://solscan.io/account/675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8

image-20250518222330746

2. 交易和指令的结构

初学者须知:Solana交易是不可变的指令集合,一旦提交就必须整体成功或失败。了解交易结构对于调试问题和优化执行至关重要。交易大小限制和账户包含方式都会影响应用设计决策。

2.1 交易的版本

Solana 目前支持的交易版本实际上只有两种:

  1. Legacy 版本:较旧的交易格式,没有额外的功能特性
  2. 版本 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
solana address-lookup-table get 2Kzkpo4uvvZLCysuJY8mhjVDr4LLDJnXfBJJs5TEmRxH

Lookup Table Address: 2Kzkpo4uvvZLCysuJY8mhjVDr4LLDJnXfBJJs5TEmRxH
Authority: 9RAufBfjGQjDfrwxeyKmZWPADHSb8HcoqCdrmpqvCr1g
Deactivation Slot: None (still active)
Last Extended Slot: 316103504
Address Table Entries:

Index Address
0 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
1 C4LzKsC9rmAEnr8MohSmz82oc3EUuSWidASubPhJPZAX
2 A5PEzX4vSy4k9mQAaL3SHGdp4rMiKcom3UC8D5DVkmbd
3 C3hgpaU9er7Uy7cuMUMCBbwyAfUexQa5wrJrJqPRt5XE
4 5F76B9a5pyv3iV7xS15fu8FTE2EVXVA8sG436VFkGE5e
...

在addressTableLookups 指定了的账户,会拼接到 account keys 后面,同样的用指令索引。参考后面的交易结构。

2.2 交易的结构

对于已经确认过的交易,也就是历史交易,会包括 slot, 区块时间、交易版本(Legacy 或者是 version 0)。最重要的是交易体和元信息。交易体主要是,参考https://solana.com/zh/docs/core/transactions

  • 签名(Signatures):交易签名数组,每个签名 64 字节
  • 消息(Message):交易的实际内容,包含所有执行信息
image-20250518222439672
1
2
3
4
5
6
pub struct Message {
pub header: MessageHeader,
pub account_keys: Vec<Pubkey>,
pub recent_blockhash: Hash,
pub instructions: Vec<CompiledInstruction>,
}
image-20250518222504385
  • 消息头(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
2
3
4
5
pub struct CompiledInstruction {
pub program_id_index: u8,
pub accounts: Vec<u8>,
pub data: Vec<u8>,
}

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个账户 (从 6WLBpdFzC5td9H62ZdAFmMS5hrK12sTCs1BjdmKZK2epHx7Q2g7KPRB9J2b7yS1gk8K9X9aPT2XzZMhUVjFYcTYg): 可写 + 不需要签名
  • 最后7个账户 (从 11111111111111111111111111111111J2nUHEAgZFRyuJbFjdqPrAa9gyWDuc7hErtDQHPhsYRp): 只读 + 不需要签名。

3. 交易的流程

Solana 的交易执行流程主要分为以下几个阶段:

  1. 交易构造:用户创建交易,指定目标程序、相关账户和指令数据,并用私钥签名。
  2. 交易提交:钱包或应用程序将交易提交到 Solana 节点(RPC 服务器)。
  3. 交易验证:节点检查交易的签名、账户权限和余额等。
  4. 交易执行:按指令调用目标程序,并在 Solana 的并行运行时中执行。
  5. 交易确认:如果交易成功执行并最终写入区块,返回交易哈希作为确认。

3.1 准备

签名(Signatures):交易需要由 发送方钱包 使用私钥签名,以证明交易的合法性。 一个交易可以包含多个签名(例如,多重签名交易)。

消息(Message):描述交易的细节,包括目标程序(Program ID)、相关账户及操作指令。

账户列表(Account Keys):指定交易涉及的所有账户,包括程序账户、数据账户、用户账户等。在交易执行时,这些账户会被预加载到内存,使 Solana 能够并行处理交易,而不会因为未预先声明的账户访问而引发冲突。

消息中的指令,是对程序的具体调用请求,包含:

  • 目标程序(Program ID):需要调用的智能合约。
  • 账户列表:程序需要访问的账户。
  • 指令数据(Instruction Data):具体操作的参数或方法标识。
image-20250518222624081

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 上,交易的确认由 多个层级 决定,主要包括:

  1. 单次确认(Single Confirmation)
  2. 乐观确认(Optimistic Confirmation)
  3. 最终确认(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 基本结构

image-20250518223540100

line 4 的 id 就是程序的公钥,也是程序的ID。 #[derive(Accounts)] 是交互的上下文,因为不需要额外控制,所以为空即可。因为不需要数据的输入,我们没有定义数据账户。

4.2 解读指令

https://solscan.io/tx/56SyMzu5DfoPK8xwNytg5iH6s1K4KGhWwDitwp3F8FvAPn9HGAi9LuySJvLUQrXxZhHKKFVR9fbUPo7Um2mE4Jag?cluster=devnet

看部署结果,大概消耗了2.54 sol。下面是相关账户字段

image-20250518223554241
  1. 4T3p是我的地址,资金来源。
  2. Nyy8 是程序账户,程序ID。
  3. FHnS 是程序的可执行数据账户,存储BPF字节码。
  4. Qfu4 是缓冲区账户。
  5. System Program 是 系统程序(用于账户创建和资金转移)
  6. BPF Upgradeable Loader 负责加载和管理 BPF 智能合约
  7. Sysvar: Clock 是系统时钟账户,获取时间戳
  8. Sysvar: Rent 是租金计算账户(用于确定账户是否需要支付租金)

下面是指令字段

📌 指令 1:创建程序账户

1
2
3
4
5
6
7
8
9
10
{
"info": {
"lamports": 1398960,
"newAccount": "Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV",
"owner": "BPFLoaderUpgradeab1e11111111111111111111111",
"source": "4T3phH9RxrwXJGftJfGk2G8iC6JXCvRyZF2vjMycJera",
"space": 36
},
"type": "createAccount"
}
  • 指令类型:系统程序 createAccount
  • 创建 Nyy8 作为 程序账户(Program Account)。
  • 账户所有者设为 BPF Upgradeable Loader,表示它是一个可升级合约。
  • 我的4T3p调用的
  • 分配 36 字节的空间,这只是程序账户的元数据,实际代码会存储在 Program Data Account。

📌 指令 2:使用 BPF Upgradeable Loader 部署程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"info": {
"authority": "4T3phH9RxrwXJGftJfGk2G8iC6JXCvRyZF2vjMycJera",
"bufferAccount": "Qfu4TnxzsWD52zkLpHrNMKe81K5sEAvHTQE3h2xCtYq",
"clockSysvar": "SysvarC1ock11111111111111111111111111111111",
"maxDataLen": 365136,
"payerAccount": "4T3phH9RxrwXJGftJfGk2G8iC6JXCvRyZF2vjMycJera",
"programAccount": "Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV",
"programDataAccount": "FHnSpFSK8tuy51NKK37yEEAECb2ujmav88JCGcKRuksg",
"rentSysvar": "SysvarRent111111111111111111111111111111111",
"systemProgram": "11111111111111111111111111111111"
},
"type": "deployWithMaxDataLen"
}
  • 从缓冲区账户 (Qfu4) 读取 BPF 代码。
  • 创建 FHnS 作为可执行数据账户,用于存放 BPF 代码。
  • 将 BPF 代码写入 Program Data Account,然后把 Program Account 设置为 executable = true,让它变成可执行合约。
  • Authority 由 4T3p 设定,表示该账户有权限升级合约。

📌 指令 2.1(内部指令):创建 Program Data Account

1
2
3
4
5
6
7
8
9
10
{
"info": {
"lamports": 2542550640,
"newAccount": "FHnSpFSK8tuy51NKK37yEEAECb2ujmav88JCGcKRuksg",
"owner": "BPFLoaderUpgradeab1e11111111111111111111111",
"source": "4T3phH9RxrwXJGftJfGk2G8iC6JXCvRyZF2vjMycJera",
"space": 365181
},
"type": "createAccount"
}
  • 指令类型:系统程序 createAccount
  • 创建 Program Data Account(FHnS),存放合约的 BPF 代码。
  • 转移 2.54 SOL 以支付存储租金,因为 Solana 账户必须存足够的 SOL 以维持存活。
  • 分配 365181 字节的空间。

4.3 ETH开发者FAQ

❓我们首先疑惑,为什么需要 365KB 这么大来存储极端简单的 hello world 程序。如果是类似EVM的字节码不可能这么大。

  1. Solana BPF 程序的存储对齐。Solana 要求程序代码存储在 1KB 对齐的存储块中,所以即使你的代码实际只有 150KB,最终可能会被填充到 365KB。这类似于 操作系统的页面对齐,有助于 高效的内存访问和程序加载。
  2. Rust 编译的 BPF 代码体积较大。由于 Rust 的安全性和泛型特性,编译出来的 BPF 代码往往比手写汇编代码要大。
  3. 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代码调用。

image-20250518223629813

https://solscan.io/tx/5wYU6EX9sbfQT9GBPz7kyeQXpsfjuP9UhFRvcECv1hPHapHVaoTsPACmendiV3TgQEiNyacvRxzDiviJompZqcQ7?cluster=devnet

由于没有验证合约,所以参数没有解码,从执行结果上知道,我们的日志成功打印了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ solana confirm -v 5wYU6EX9sbfQT9GBPz7kyeQXpsfjuP9UhFRvcECv1hPHapHVaoTsPACmendiV3TgQEiNyacvRxzDiviJompZqcQ7
RPC URL: <https://api.devnet.solana.com>
Default Signer: Playground Wallet
Commitment: confirmed

Transaction executed in slot 358613949:
Block Time: 2025-02-04T02:43:29+08:00
Version: legacy
Recent Blockhash: 9sJABN5sM6chgkmpVKDxuHK4GYXqMmdx8mMwRQWxcc9t
Signature 0: 5wYU6EX9sbfQT9GBPz7kyeQXpsfjuP9UhFRvcECv1hPHapHVaoTsPACmendiV3TgQEiNyacvRxzDiviJompZqcQ7
Account 0: srw- 4T3phH9RxrwXJGftJfGk2G8iC6JXCvRyZF2vjMycJera (fee payer)
Account 1: -r-x Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV
Instruction 0
Program: Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV (1)
Data: [149, 118, 59, 220, 196, 127, 161, 179]
Status: Ok
Fee: ◎0.000005
Account 0 balance: ◎17.4550854 -> ◎17.4550804
Account 1 balance: ◎0.00139896
Log Messages:
Program Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV invoke [1]
Program log: Instruction: Hello
Program log: Hello, World!
Program Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV consumed 443 of 200000 compute units
Program Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV success

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 包含以下主要部分:

  1. name:合约的名称
  2. instructions:定义了智能合约支持的所有函数(方法)
  3. accounts:定义了智能合约需要访问的账户结构
  4. types:定义复杂数据结构(类似 Solidity 的 struct)
  5. events:定义事件(类似 Solidity 的 event)
  6. errors:定义错误类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"version": "0.1.0",
"name": "hello_world",
"instructions": [
{
"name": "sendMessage",
"accounts": [
{ "name": "user", "isMut": true, "isSigner": true },
{ "name": "messageAccount", "isMut": true, "isSigner": false }
],
"args": [{ "name": "message", "type": "string" }]
}
],
"accounts": [
{
"name": "MessageAccount",
"type": {
"kind": "struct",
"fields": [{ "name": "message", "type": "string" }]
}
}
]
}

这个 IDL 代表的功能

  • 合约名称: hello_world
  • 方法 sendMessage
  • 需要两个账户:
    • user:用户账户(需要签名)
    • messageAccount:存储消息的账户
  • 需要 一个参数:message(字符串类型)
  • accounts:定义了 MessageAccount 结构体,用于存储消息。

Anchor 提供了 TypeScript SDK,可以用 IDL 自动生成智能合约调用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Program, AnchorProvider, web3 } from "@project-serum/anchor";

// 读取 IDL
import idl from "./hello_world.json";

// 连接 Solana 钱包
const provider = AnchorProvider.env();
const programId = new web3.PublicKey(idl.metadata.address);
const program = new Program(idl, programId, provider);

// 调用合约方法
await program.rpc.sendMessage("Hello, Solana!", {
accounts: {
user: provider.wallet.publicKey,
messageAccount: messageAccountPubkey,
},
});

在build那一块,还有一个 IDL initialize。 在 Anchor 框架中,每个智能合约都可以新创建一个数据账户,存储它的 IDL,这样客户端(如前端应用)可以自动查询合约的调用方法,而不需要提前知道函数签名。下面的交易就把 IDL 写入了程序可执行数据,同时也是初始化合约:

https://solscan.io/tx/56iyiL1K5LJ6ieToWsEaPZABTWoJ1jhf3GTdPvDZ3VQ8CQ1QTd5gJ4SU8EurhuGzm9nmZkthNXoLjmDe8rVtKDr?cluster=devnet

image-20250518223856453
1
2
3
4
5
6
7
8
9
10
11
12
$ anchor idl fetch Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV
{
"version": "0.1.0",
"name": "hello_world",
"instructions": [
{
"name": "hello",
"accounts": [],
"args": []
}
]
}

这就是IDL存储的账户 https://solscan.io/account/5FM1QHSWeVZVx3v37CezJcXv4VJstN1F7jmGoLwbt7Ck?cluster=devnet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ solana account 5FM1QHSWeVZVx3v37CezJcXv4VJstN1F7jmGoLwbt7Ck --output json

{
"pubkey": "5FM1QHSWeVZVx3v37CezJcXv4VJstN1F7jmGoLwbt7Ck",
"account": {
"lamports": 2394240,
"data": [
"GEZivzqQe54zP1pfirECcDdGqmQMSSKrf+6UjiGboWnSBMxnCs/vR1YAAAB4nFWKQQqAIBBF7zLrENt6lZAQkxJsBsaxFuLdm9q1+++/1+FKXDMhOLBmNhYmwHAmxSOVQutNXDY9M1bhFkXTCm7pv0p9iJEayuu8Eu/fGn48MYwgrwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"base64"
],
"owner": "Nyy8sfuyeXq15QHiwaM1eKcVLnQ95EDdXdZ6XujeiHV",
"executable": false,
"rentEpoch": 18446744073709551615
}
}

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的实际应用场景

  1. 确定性地址生成:为每个用户生成唯一且可预测的账户地址
  2. 跨程序权限:允许一个程序代表另一个程序签名和操作账户
  3. 数据关联:将相关数据组织在一起,例如用户A在项目B中的仓位数据
  4. 省去交易签名:用户只需授权程序一次,后续操作可由程序通过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调用:

  1. 用户交易程序调用SPL Token程序转移用户代币
  2. 用户交易程序调用流动性池程序执行兑换
  3. 流动性池程序再次调用SPL Token程序发送交换后的代币

NFT铸造过程

  1. NFT铸造程序调用System Program创建新的Metadata账户
  2. NFT铸造程序调用Token-Metadata程序初始化NFT元数据
  3. 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 传递的,Context,包含当前指令需要的账户。

2️⃣ Accounts 结构解析

SolTransfer 结构定义了所有需要的账户信息

1
2
3
4
5
6
7
8
#[derive(Accounts)]
pub struct SolTransfer<'info> {
#[account(mut)]
sender: Signer<'info>, // 交易发起者,必须是签名者
#[account(mut)]
recipient: SystemAccount<'info>, // 接收 SOL 的账户
system_program: Program<'info, System>, // 系统程序
}
  • 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
2
3
4
5
pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
let from_pubkey = ctx.accounts.sender.to_account_info();
let to_pubkey = ctx.accounts.recipient.to_account_info();
let program_id = ctx.accounts.system_program.to_account_info();
}

4️⃣ CpiContext::new 创建调用上下文

CpiContext::new 用于构造 CPI(跨程序调用)所需的 调用上下文,它告诉 Solana:

  1. 我要调用哪个合约(Program ID)?
  2. 我要传递哪些账户?
  3. 我是否需要 PDA 作为签名者?(可选)
1
2
3
4
5
6
7
let cpi_context = CpiContext::new(
program_id, // 要调用的合约(System Program)
Transfer { // System Program 的 SOL 转账指令
from: from_pubkey, // 付款账户
to: to_pubkey, // 接收账户
},
);

5️⃣执行指令

1
2
// 执行转账
transfer(cpi_context, amount)?;

回顾IDL知识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"version": "0.1.0",
"name": "cpi",
"instructions": [
{
"name": "solTransfer",
"accounts": [
{ "name": "sender", "isMut": true, "isSigner": true },
{ "name": "recipient", "isMut": true, "isSigner": false },
{ "name": "systemProgram", "isMut": false, "isSigner": false }
],
"args": [{ "name": "amount", "type": "u64" }]
}
]
}

https://solscan.io/tx/3vzVz8EHT5DQYgzbJmW8GbjZCST8LbadsVD37fzCFNKKiDEWXdnokqbtn7DVdN86AdKCgdesDBa9FPX6WvAfgiwi?cluster=devnet 调用后就成功转账了。

除了上面的创建上下文的调用方式,可以直接组装指令的形式,然后调用,比如:

1
2
3
4
5
6
7
8
9
10
11
pub fn sol_transfer_two(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
let from_pubkey = ctx.accounts.sender.to_account_info();
let to_pubkey = ctx.accounts.recipient.to_account_info();
let program_id = ctx.accounts.system_program.to_account_info();

let instruction =
&system_instruction::transfer(&from_pubkey.key(), &to_pubkey.key(), amount);

invoke(instruction, &[from_pubkey, to_pubkey, program_id])?;
Ok(())
}

上面是利用已知的指令结构,填入对应字段组装的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub fn sol_transfer_three(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
let from_pubkey = ctx.accounts.sender.to_account_info();
let to_pubkey = ctx.accounts.recipient.to_account_info();
let program_id = ctx.accounts.system_program.to_account_info();

// Prepare instruction AccountMetas
let account_metas = vec![
AccountMeta::new(from_pubkey.key(), true),
AccountMeta::new(to_pubkey.key(), false),
];

// SOL transfer instruction discriminator
let instruction_discriminator: u32 = 2;

// Prepare instruction data
let mut instruction_data = Vec::with_capacity(4 + 8);
instruction_data.extend_from_slice(&instruction_discriminator.to_le_bytes());
instruction_data.extend_from_slice(&amount.to_le_bytes());

// Create instruction
let instruction = Instruction {
program_id: program_id.key(),
accounts: account_metas,
data: instruction_data,
};

// Invoke instruction
invoke(&instruction, &[from_pubkey, to_pubkey, program_id])?;
Ok(())
}

上面则是直接手动填充指令字节。但是注意,不能用完整的 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
2
3
4
5
6
#[account(
mut,
seeds = [b"pda", recipient.key().as_ref()],
bump,
)]
pda_account: SystemAccount<'info>,

相比一般的账户,添加了seeds, pda 作为计算基础,recipient 账户的公钥作为动态种子。bump:自动计算的值,保证 PDA 地址是有效的 Solana 账户地址。

1
2
3
4
5
6
7
8
9
10
11
12
let seed = to_pubkey.key();
let bump_seed = ctx.bumps.pda_account;
let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];

let cpi_context = CpiContext::new(
program_id,
Transfer {
from: from_pubkey,
to: to_pubkey,
},
)
.with_signer(signer_seeds);

程序解析的时候,计算这个signer_seeds。如果程序和声明的 seeds 和 bump 不匹配,就会生成错误的PDA,交易失败。

根据测试用例,我们首先计算的PDA,依赖钱包地址生成的。

交易:https://solscan.io/tx/5PL2Q4ALWKNSgz3htANjexPyZm43tfnT8XsTGgBwdV5EjKBXb4TKimUGz4JKhgZUMTXJhtK6Mg5KtNQd9pQyXLh4?cluster=devnet

PDA: https://solscan.io/account/AMtiaEmz3NhhV3KFbTkSXYP1AraCLdR9n4AiB3zHNTw?cluster=devnet

image-20250518231045670

可以发现PDA这里 isOnCurve 是 False,说明没有私钥控制。

❓为什么转给PDA时,我并没有看到创建PDA数据账户的指令?

我们提到系统程序控制PDA的生成,但是我们没看到createAccount指令。这是因为PDA 在 Solana 里默认是"懒创建(Lazy Initialization)"的!

在 Solana,账户的创建方式有两种:

  1. System Program::CreateAccount 用于创建新的可写账户,必须初始化 lamports 和 space。
  2. 直接向账户转账 SOL 只要账户不存在,Solana 允许它作为"空账户"存储 lamports 但不存储数据。

如果 PDA 账户没有存储数据(空账户),它可以直接接受 SOL,而不需要 createAccount!

❓为什么我的地址能控制PDA?

因为计算PDA时使用了我的地址和程序ID共同生成地址,程序验证的时候使用的是 to_pubkey.key(),那么这个PDA只能给我转账。也就是说,任何人可以转给PDA,但是这个PDA里的金额,只能由我转出。

1
2
3
4
const [PDA] = PublicKey.findProgramAddressSync(
[Buffer.from("pda"), wallet.publicKey.toBuffer()],
program.programId
);

❓那其他程序能控制这个地址吗?如果其他程序也能生成同样的PDA,是不是也可以控制了呢?

不能。可以简化PDA的计算公式 PDA = hash(seeds + Program ID) + bump ,这个哈希碰撞是几乎不可能发生的。

如果用invoke的方式调用,则是先创建指令,相比invoke多出了一个PDA签名。

1
2
3
4
let instruction =
&system_instruction::transfer(&from_pubkey.key(), &to_pubkey.key(), amount);

invoke_signed(instruction, &[from_pubkey, to_pubkey, program_id], signer_seeds)?;