您正在查看: Surou 发布的文章

Solana核心概念-跨程序调用 (CPI)

跨程序调用(CPI)是指一个程序调用另一个程序的指令(instruction)。这种机制允许 Solana 程序的可组合性。 这种机制允许 Solana 程序的可组合性。

你可以将指令视为程序向网络公开的 API 端点,而 CPI 则是一个 API 内部调用另一个 API。

当一个程序发起对另一个程序的跨程序调用(CPI)时:

  • 调用程序(A)从初始交易中获得的签名者(signer)权限扩展到被调用程序(B)
  • 被调用程序(B)可以进一步对其它程序进行 CPI,最多深度为 4(例如 B->C,C->D)
  • 程序可以代表从其程序 ID 派生的 PDAs 进行“签名”

Solana 程序运行时定义了一个名为 max_invoke_stack_height 的常量,其值设定为 5。 这表示程序指令调用堆栈的最大高度。 堆栈高度从交易指令的 1 开始,每次程序调用另 一个指令时增加 1。 此设置有效地将 CPI 的调用深度限制为 4。

关键点

  • CPI 使 Solana 程序指令能够直接调用另一个程序的指令。
  • 调用程序的签名者权限扩展到被调用程序。
  • 在进行 CPI 时,程序可以代表从其自身程序 ID 派生的 PDAs 进行“签名”。
  • 被调用程序可以对其它程序进行额外的 CPI,最多深度为 4。

如何编写 CPI

编写 CPI 指令遵循与构建添加到交易中的 instruction 相同的模式。在底层,每个 CPI 指令必须指定以下信息:

  • 程序地址:指定被调用的程序
  • 账户:列出指令读取或写入的每个账户,包括其它程序
  • 指令数据:指定要调用的程序上的指令,以及指令所需的任何附加数据(函数参数)

根据你要调用的程序,可能有一些 crate 提供了用于构建指令的辅助函数。 然后,程序使 用solana_program crate 中的以下函数之一执行 CPI:

  • invoke —— 当没有 PDA 签名者时使用
  • invoke_signed —— 当调用程序需要使用从其程序 ID 派生的 PDA 进行签名时使用

基础 CPI

invoke函数用于进行不需要 PDA 签名者的 CPI。 在进行 CPI 时,提供给调用程序的签名者自动扩 展到被调用程序。

pub fn invoke(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>]
) -> Result<(), ProgramError>

这是一个在 Solana Playground 上的示例程序,该程序使用invoke函数调用系统程序上的转账指令。你也可以参 考基础 CPI 指南了解更多细节。 你也可 以参考基础 CPI 指南了解更多细节。

带 PDA 签名者的 CPI

invoke_signed函 数用于进行需要 PDA 签名者的 CPI。 用于派生签名者 PDA 的种子作为signer_seeds传 递给invoke_signed函数。

你可以参考程序派生地址页面了解 PDA 的派生方式。

pub fn invoke_signed(
    instruction: &Instruction,
    account_infos: &[AccountInfo<'_>],
    signers_seeds: &[&[&[u8]]]
) -> Result<(), ProgramError>

运行时使用授予调用程序的权限来确定可以扩展到被调用程序的权限。 在此上下文中,权 限指的是签名者和可写账户。 例如,如果调用程序正在处理的指令包含签名者或可写账 户,那么调用程序可以调用另一个也包含了该签名者和/或可写账户的指令。

虽然 PDAs 没有私钥 ,但它们仍然可以通过 CPI 在指令中充当签名者。为了验证 PDA 是从调用程序派生的,生成 PDA 所用的种子必须作 为signers_seeds包含在内。 为了验证 PDA 是从调用程序派生的,生成 PDA 所用的种子 必须作为signers_seeds包含在内。

当 CPI 被处理时,Solana 运行时使用signers_seeds和调用程序的program_id 进 行内部调用create_program_address。 如果找到有效的 PDA,该地址 将被添加为有效签名者 。

这是一个在 Solana Playground 上的示例程序,该程序使用invoke_signed函数调用系统程序上的转账指令,并带有 PDA 签名者。 你可以参 考带 PDA 签名者的 CPI 指南了 解更多细节。

Solana核心概念-程序派生地址 (PDA)

程序派生地址 (PDAs) 为 Solana 上的开发者提供了两个主要用例:

  • 确定性账户地址: PDAs 提供了一种机制,通过组合可选的“种子”(预定义输入)和 特定的程序 ID 来确定性地派生一个地址。
  • 启用程序签名: Solana 运行时允许程序为从其程序 ID 派生的 PDAs “签名”。

你可以将 PDAs 视为一种通过预定义的一组输入(例如字符串、数字和其他账户地址)在链 上创建类似哈希映射结构的方法。
这种方法的好处是消除了跟踪确切地址的需要。 相反,你只需记住用于派生地址的特定输 入。

重要的是要理解,简单地派生一个程序派生地址(PDA)并不会自动在该地址创建一个链上 账户。具有 PDA 作为链上地址的账户必须通过用于派生地址的程序显式创建。 你可以将派 生 PDA 视为在地图上找到一个地址。 仅仅拥有一个地址并不意味着在该位置有构建内任何 内容。

本节将介绍派生 PDAs 的详细信息。 有关程序如何使用 PDAs 进行签名的详细信息将 在跨程序调用(CPIs) 一节中介绍,因为它需要这两个概念的上 下文。

关键点

  • PDAs 是使用用户定义的种子、一个 bump 种子和程序 ID 的组合确定性派生的地址。
  • PDAs 是落在 Ed25519 曲线之外的地址,没有对应的私钥。
  • Solana 程序可以以编程方式代表使用其程序 ID 派生的 PDA 进行“签名”。
  • 派生 PDA 并不会自动创建链上账户。
  • 使用 PDA 作为地址的账户必须通过 Solana 程序中的专用指令显式创建。

什么是 PDA

PDAs 是确定性派生的地址,看起来像标准的公钥,但没有关联的私钥。 这意味着没有外部 用户可以为该地址生成有效的签名。 然而,Solana 运行时允许程序以编程方式为 PDAs“签 名”而无需私钥。

作为背景,Solana Keypairs 是 Ed25519 曲线(椭圆曲线加密)上的点,具有公钥和对应的私钥。 我们通常使用公钥作 为新链上账户的唯一 ID,并使用私钥进行签名。

PDA 是一个通过预定义的一组输入故意派生到 Ed25519 曲线之外的点。 一个不在 Ed25519 曲线上的点没有有效的对应私钥,不能用于加密操作(签名)。

然后,PDA 可以用作链上账户的地址(唯一标识符),提供一种轻松存储、映射和获取程序 状态的方法。

如何派生 PDA

派生 PDA 需要 3 个输入。

  • 可选种子:用于派生 PDA 的预定义输入(例如字符串、数字、其他账户地址)。这 些输入被转换为字节缓冲区。 这些输入被转换为字节缓冲区。
  • Bump 种子:一个附加输入(值在 255-0 之间),用于保证生成有效的 PDA(曲线 外)。 生成 PDA 时,将 bump 种子(从 255 开始)附加到可选种子,以将点“推离 ”Ed25519 曲线。 bump 种子有时被称为“nonce”。
  • 程序 ID:PDA 派生自的程序地址。 这也是可以代表 PDA“签名”的程序。

下面的示例包括链接到 Solana Playground,你可以在浏览器编辑器中运行这些示例。

FindProgramAddress

要派生 PDA,我们可以使用 findProgramAddressSync 方法,该方法来自 @solana/web3.js。其他编程语言 (例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。其他编程语 言(例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。

使用findProgramAddressSync方法时,我们传入:

  • 转换为字节缓冲区的预定义可选种子,以及
  • 用于派生 PDA 的程序 ID(地址)

一旦找到有效的 PDA,findProgramAddressSync将返回派生 PDA 的地址(PDA)和 bump 种子。

下面的示例在没有提供任何可选种子的情况下派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");

const [PDA, bump] = PublicKey.findProgramAddressSync([], programId);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你可以在 Solana Playground 上 运行此示例。 PDA 和 bump 种子的输出将始终相同:

Running client...
  client.ts:
    PDA: Cu7NwqCXSmsR5vgGA3Vw9uYVViPi3kQvkbKByVQ8nPY9
    Bump: 255

下面的示例添加了一个可选种子"helloWorld"。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

const [PDA, bump] = PublicKey.findProgramAddressSync(
  [Buffer.from(string)],
  programId,
);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你也可以在 Solana Playground 上运行此示例。 PDA 和 bump 种子的输出将始终相同:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
Bump: 254

注意,bump 种子是 254。这意味着 255 派生了 Ed25519 曲线 上的一个点,并不是一个有效的 PDA。
findProgramAddressSync返回的 bump 种子是给定可选种子和程序 ID 组合的第一个值 (在 255-0 之间),该值派生了一个有效的 PDA。

这个第一个有效的 bump 种子被称为“规范 bump”。 为了程序安全,建议在使用 PDAs 时 仅使用规范 bump。

CreateProgramAddress

在底层,findProgramAddressSync将迭代地将附加的 bump 种子(nonce)附加到种子缓 冲区,并调用 createProgramAddressSync 方法。bump 种子从 255 开始,每次减少 1,直到找到有效的 PDA(曲线外)

你可以通过使用createProgramAddressSync并显式传入 254 的 bump 种子来复制前面的 示例

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";
const bump = 254;

const PDA = PublicKey.createProgramAddressSync(
  [Buffer.from(string), Buffer.from([bump])],
  programId,
);

console.log(`PDA: ${PDA}`);

规范 Bump 在 Solana Playground 上运行上述示例。给定相同的种子和程序 ID,PDA 输出将与前一个匹配:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X

规范 Bump

“规范 bump”是指派生有效 PDA 的第一个 bump 种子(从 255 开始,每次减少 1)。 为了 程序安全,建议仅使用从规范 bump 派生的 PDAs。

以之前的示例为参考,下面的示例尝试使用从 255 到 0 的每个 bump 种子派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

// Loop through all bump seeds for demonstration
for (let bump = 255; bump >= 0; bump--) {
  try {
    const PDA = PublicKey.createProgramAddressSync(
      [Buffer.from(string), Buffer.from([bump])],
      programId,
    );
    console.log("bump " + bump + ": " + PDA);
  } catch (error) {
    console.log("bump " + bump + ": " + error);
  }
}

Solana Playground 上运行该 示例,你应该会看到以下输出:

Running client...
  client.ts:
    bump 255: Error: Invalid seeds, address must fall off the curve
    bump 254: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
    bump 253: GBNWBGxKmdcd7JrMnBdZke9Fumj9sir4rpbruwEGmR4y
    bump 252: THfBMgduMonjaNsCisKa7Qz2cBoG1VCUYHyso7UXYHH
    bump 251: EuRrNqJAofo7y3Jy6MGvF7eZAYegqYTwH2dnLCwDDGdP
    bump 250: Error: Invalid seeds, address must fall off the curve
    bump 249: Es18AFZ4N7U4aBKTVvq5GzpUxWseFzxxQuvpZaxne38Y
    bump 248: 8VzFABVVp8RRTzhmCpeMgYo8t3CYERtVpi646BabfV18
    ...

正如预期的那样,bump seed 255 会抛出错误,第一个导出有效 PDA 的 bump seed 是 254。

但是,请注意 bump seeds 253-251 都会导出具有不同地址的有效 PDA。 这意味着在给定 相同的可选种子和 programId 的情况下,具有不同值的 bump seed 仍然可以导出有效的 PDA。

在构建 Solana 程序时,建议包括安全检查,以验证传递给程序的 PDA 是使用规范的 bump 导出的。如果不这样做,可能会引入漏洞,允许向程序提供意外的账户。

创建 PDA 账户

这个在 Solana Playground 上的示例程序演示了如何使用 PDA 作为新账户的地址来创建账户。 示例程序是使用 Anchor 框架编写的。

在 lib.rs 文件中,你会发现以下程序,其中包括一个使用 PDA 作为账户地址创建新账 户的指令。 新账户存储了 user 的地址和用于导出 PDA 的 bump seed。

use anchor_lang::prelude::*;

declare_id!("75GJVCJNhaukaa2vCCqhreY31gaphv7XTScBChmr1ueR");

#[program]
pub mod pda_account {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let account_data = &mut ctx.accounts.pda_account;
        // store the address of the `user`
        account_data.user = *ctx.accounts.user.key;
        // store the canonical bump
        account_data.bump = ctx.bumps.pda_account;
        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(
        init,
        // set the seeds to derive the PDA
        seeds = [b"data", user.key().as_ref()],
        // use the canonical bump
        bump,
        payer = user,
        space = 8 + DataAccount::INIT_SPACE
    )]
    pub pda_account: Account<'info, DataAccount>,
    pub system_program: Program<'info, System>,
}

#[account]

#[derive(InitSpace)]
pub struct DataAccount {
    pub user: Pubkey,
    pub bump: u8,
}

用于导出 PDA 的种子包括硬编码字符串 data 和指令中提供的 user 账户的地址。 Anchor 框架会自动导出规范的 bump seed。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

init 约束指示 Anchor 调用系统程序,使用 PDA 作为地址创建新账户。 在底层,这是 通过 CPI 完成的。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

在上述 Solana Playground 链接中的测试文件 (pda-account.test.ts) 中,你会找到等 效的 Javascript 代码来导出 PDA。

const [PDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("data"), user.publicKey.toBuffer()],
  program.programId,
);

然后发送一个交易来调用 initialize 指令,使用 PDA 作为地址创建一个新的链上账 户。 交易发送后,使用 PDA 来获取在该地址创建的链上账户。

it("Is initialized!", async () => {
  const transactionSignature = await program.methods
    .initialize()
    .accounts({
      user: user.publicKey,
      pdaAccount: PDA,
    })
    .rpc();

  console.log("Transaction Signature:", transactionSignature);
});

it("Fetch Account", async () => {
  const pdaAccount = await program.account.dataAccount.fetch(PDA);
  console.log(JSON.stringify(pdaAccount, null, 2));
});

请注意,如果使用相同的 user 地址作为种子多次调用 initialize 指令,则交易将失 败。 这是因为在导出的地址上已经存在一个账户。

Solana 核心概念-程序

在 Solana 生态系统中,“智能合约”被称为程序。 每 个程序都是一个链上账户,存储可执行逻 辑,组织成特定的函数,称 为指令(instructions) 。

关键点

  • 程序是包含可执行代码的链上账户。 此代码组织成不同函数,称为指令。
  • 程序是无状态的,但可以包含创建新账户的指令,用于存储和管理程序状态。
  • 程序可以由升级权限更新。 当升级权限设置为 null 时,程序变为不可变。
  • 可验证的构建使用户能够验证链上程序是否与公开的源代码相匹配。

编写 Solana 程序

Solana 程序主要用 Rust 编程语言编写,开发有两 种常见方法:

  • Anchor:专为 Solana 程序开发设计的框架。它提供了一种更快、更简单的编写程序的方式,使用 Rust 宏来显著减少样板代码。对于初学者,建议从 Anchor 框架开始。
  • Native Rust:这种方法涉 及在不使用任何框架的情况下用 Rust 编写 Solana 程序。 它提供了更多的灵活性,但 也增加了复杂性。

更新 Solana 程序

链上程序可以由指定为“升级权限”的账 户直接修改 ,通常是最初部署程序的账户。
如果升级权限被 撤销并设置为None,程序将变为不可变,无法再更新。

可验证的程序

确保链上代码的完整性和可验证性至关重要。 可验证的构建确保部署在链上的可执行代码 可以由任何第三方独立验证,以匹配其公开的源代码。 此过程增强了透明度和信任,使得 能够检测源代码与部署程序之间的差异。

Solana 开发者社区推出了支持可验证构建的工具,使开发者和用户能够验证链上程序是否准确反映其公开共享的源代码。

  • 搜索已验证的程序:要快速检查已验证的程序,用户可以在 SolanaFM 浏览器上搜索程序地址并导航到“Verification”选项 卡。 在这里查 看一个已验证程序的示例。
  • 验证工具: Ellipsis Labs 的Solana 可验证构建 CLI 使用户能够根据已发布的源代码独立验证链上程序。
  • Anchor 中对可验证构建的支持:Anchor 提供了对可验证构建的内置支持。 详情请 参阅 Anchor 文档

伯克利包过滤器 (BPF)

Solana 利用 LLVM 编译器基础设施将程序编译 成可执行和链接格式 (ELF) 文件。 这些文件包括一个修改版 的伯克利包过滤器 (eBPF) 字节码,用于Solana 程序,称为“Solana 字节码格式” (sBPF)。

使用 LLVM 使 Solana 能够潜在支持任何可以编译到 LLVM 的 BPF 后端的编程语言。 这显 著增强了 Solana 作为开发平台的灵活性。

Solana 核心概念-费用

Solana 区块链有几种不同类型的费用和成本,这些费用和成本是使用无许可网络所需的。 它们可以分为几种特定类型:

  • 交易费用 - 验证者处理交易/指令的费用
  • 优先费用 - 提高交易处理顺序的可选费用
  • 租金 - 保持链上数据存储的保留余额

交易费用

在 Solana 区块链上的链上程序中处理逻辑(指令)所支付的小额费用称为“交易费用”。
当每个交易 (包含一个或多 个指令 )通过网络发送时,它会由当前的 验证者领导者处理。一旦确认为全局状态交易,这个 交易费用 将支付给网络,以帮助支 持 Solana 区块链的经济设计。

交易费用不同于账户数据存储押金费租金 。虽然交易费用是支付给 Solana 网 络处理指令的费用,但租金押金是保存在账户中以在区块链上存储其数据并可回收的费 用。 如上所述,每笔交易费用的固定比例被 燃烧(销毁)。这是为了巩固 SOL 的经 济价值,从而维持网络的安全性。与完全销毁交易费用的方案不同,领导者仍然有动力在 其时隙(创建区块的机会)中包含尽可能多的交易。

目前,Solana 的基本交易费用设定为每个签名 5k lamports 的固定值。 在这个基本费用 之上,可以添加任何额外的优先费用 。

为什么要支付交易费用?

交易费用在 Solana 的经济设计中提供了许多好处,主要包 括:

  • 为验证者网络提供补偿,以支付处理交易所需的 CPU/GPU 计算资源
  • 通过引入交易的实际成本来减少网络垃圾邮件
  • 通过每笔交易的协议捕获最低费用金额,为网络提供长期经济稳定性

基本经济设计

许多区块链网络(包括比特币和以太坊)依赖于通胀性 基于协议的奖励 来在短期内保护 网络。从长期来看,这些网络将越来越依赖 交易费用 来维持安全性。
在 Solana 上也是如此。具体来说: 具体而言:

  • 每笔交易费用的固定比例(最初为 50%)被 燃烧(销毁),其余部分归当前处理交易 的领导者所有。
  • 预定的全球通胀率 为奖励提 供了一个来源,分配给 Solana 验证者 。

费用收取

交易需要至少有一个账户签署交易并且是可写的。 这些 可写签名账户 首先在账户列表 中序列化,并且第一个总是用作“费用支付者”。

在处理任何交易指令之前,费用支付者账户 的余额将被扣除以 支付交易费用。 如果费用支付者余额不足以支付交易费用,交易处理将停止并导致交易失 败。

如果余额足够,费用将被扣除,交易的指令将开始执行。 如果任何指令导致错误,交易处 理将停止,并最终在 Solana 账本中记录为失败交易。 对于这些失败的交易,费用仍然由 运行时收取。

如果任何指令返回错误或违反运行时限制,所有账户更改除了交易费用扣除将被回 滚。 这是因为验证者网络已经花费了计算资源来收集交易并开始初步处理。

和以太坊基本一致

费用分配

交易费用 被部分销毁 ,剩余费用由生成包含相应交易的区块的验证者收取。 具体来 说,50%被销毁 ,50%分配给 生成区块的验证者。

为什么要销毁部分费用?

如上所述,每笔交易费用的固定比例被 燃烧(销毁)。 这是为了巩固 SOL 的经济价 值,从而维持网络的安全性。 与完全销毁交易费用的方案不同,领导者仍然有动力在其时 隙(创建区块的机会)中包含尽可能多的交易。
销毁的费用还可以帮助防止恶意验证者审查交易,通过 在分叉考虑实施。

攻击示例

在具有恶意或审查领导者 的历史证明(PoH) 分叉的情况下

  • 由于审查导致的费用损失,我们预计总销毁费用将 少于 可比的诚实分叉
  • 如果审查领导者要补偿这些丢失的协议费用,他们将不得不自己替换其分叉上的销毁费用
  • 从而可能减少最初的审查动机

计算交易费用

给定交易的完整费用基于两个主要部分计算

  • 每个签名的静态设定基本费用,以及
  • 交易期间使用的计算资源,以“计算单元”为 单位衡量

由于每个交易可能需要不同数量的计算资源,每个交易作为 计算预算 的一部分被分配了 每个交易的最大 计算单元 数量。

计算预算

在一个交易中链上执行的所有操作都需要消耗不同数量的计算资源(计算成本),这些资源 由验证者在处理时消耗。消耗这些资源的最小单位称为 "计算单元"。 为了防止滥用计算 资源,每个交易被分配一个“计算预算”。该预算指定了有 关计算单元的详细信息,包括:

  • 与交易可能执行的不同类型操作相关的计算成本(每个操作消耗的计算单元),
  • 交易可以消耗的最大计算单元数量(计算单元限制),
  • 以及交易必须遵守的操作界限(如账户数据大小限制)

当交易消耗其整个计算预算(计算预算耗尽)或超过某个界限(如尝试超 过最大调用堆栈深度或最大加载账户数 据大小限制)时,运行时将停止交易处理并返回错误。导致交易失败且没有状态更改(除了 交易费用被收取 )。 导致交易失败且没有状态更改(除了交易费用 被收取 )。

账户数据大小限制

一个交易可以通过包含一个 SetLoadedAccountsDataSizeLimit 指令来指定它允许加载的 账户数据的最大字节数(不超过运行时的绝对最大值)。 如果没有提供 SetLoadedAccountsDataSizeLimit,交易将默认使用运行时的 MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES值。

可以使用 ComputeBudgetInstruction::set_loaded_accounts_data_size_limit 函数来 创建这个指令:

let instruction = ComputeBudgetInstruction::set_loaded_accounts_data_size_limit(100_000);

计算单元

在一个交易中链上执行的所有操作都需要消耗不同数量的计算资源(计算成本),这些资源 由验证者在处理时消耗。消耗这些资源的最小单位称为 "计算单元"。 消耗这些资源的最 小单位称为 "计算单元"。

当一个交易被处理时,每个在链上执行的指令都会逐步消耗计算单元(消耗预算)。 由于 每个指令执行的逻辑不同(写入账户、cpi、执行系统调用等),每个指令可能会消 耗不同数量的 计算单元。

每个交易都有一个 计算单元限制,可以是运行时设置的默认限 制,也可以是显式请求的更高限制。一旦交易超过其计算单元限制,其处理将停止,导致交 易失败。 一旦交易超过其计算单元限制,其处理将停止,导致交易失败。

以下是一些常见的会产生计算成本的操作:

  • 执行指令
  • 在程序之间传递数据
  • 执行系统调用
  • using sysvars
  • 使用 msg! 宏记录日志
  • 记录公钥
  • 创建程序地址(PDA)
  • 跨程序调用(CPI)
  • 加密操作

对于 跨程序调用,被调用的指令继承其父级的计算预算和限制。 如果一个被调用的指令消耗了交易的剩余预算,或超过了限制,整个调用链和顶层交易处 理将停止。

对于 跨程序调用,被调用的指令继承其父级的计算预算和限制。 如果一个被调用的指令消耗了交易的剩余预算,或超过了限制,整个调用链和顶层交易处 理将停止。

你可以在 Solana 运行时的 ComputeBudget 中找到更多关于所有消耗计算单元的操作的详细信息。

计算单元限制

每个交易都有一个最大计算单元数(CU)称为 "计算单元限制"。 每个交易,Solana 运 行时有一个绝对最大计算单元限制为 140 万 CU 并设置了一个默认请求的最大限制为 每个指令 20 万 CU。

一个交易可以通过包含一个 SetComputeUnitLimit 指令来请求一个更具体和优化的计算 单元限制。 可以是更高或更低的限制。 但它永远不能请求超过每个交易的绝对最大限制。

虽然交易的默认计算单元限制在大多数情况下适用于简单交易,但它们通常不够优化(对于 运行时和用户)。对于更复杂的交易,如调用执行多个 CPI 的程序,你可能需要为交易请 求更高的计算单元限制。 对于更复杂的交易,如调用执行多个 CPI 的程序,你可能需要为 交易请求更高的计算单元限制。

为你的交易请求最佳计算单元限制对于帮助你支付更少的交易费用和更好地在网络上调度你 的交易至关重要。 钱包、dApps 和其他服务应确保其计算单元请求是最佳的,以提供尽可 能好的用户体验。

计算单元价格

当一个交易希望支付更高的费用以提升其处理优先级时,它可以设置一个 "计算单元价 格"。 这个价格与 计算单元限制 结合使用,将用于确定交易的 优先级费用。

默认情况下,没 有设置计算单元价格因 此没有额外的优先级费用。

优先级费用

作为计算预算的一部分,运行时支持交易支付一个可选费用,称 为 "优先级费用"。支付这个额外费用有助于提升交易在处理时的优先级,从而实现更快 的执行时间。

如何计算优先级费用

一个交易的优先级费用通过将其计算单元限制乘以计算单元价格(以 微 lamports 为单位)来计算。这些值可以通过包含以下计算预算指令在每个交易中设置一 次:这些值可以通过包含以下计算预算指令在每个交易中设置一次:

  • SetComputeUnitLimit - 设置交易可以消耗的最大计算单元数
  • SetComputeUnitPrice - 设置交易愿意支付的额外费用以提升其优先级

如果没有提供 SetComputeUnitLimit 指令,将使用 默认计算单元限制。
如果没有提供 SetComputeUnitPrice 指令,交易将默认没有额外的提升费用和最低优先 级(即没有优先级费用)。

如何设置优先级费用

一个交易的优先级费用通过包含一个 SetComputeUnitPrice 指令来设置,并可选地包含 一个 SetComputeUnitLimit 指令。 运行时将使用这些值来计算优先级费用,这将用于在 区块内优先处理给定的交易。

你可以通过其 Rust 或 @solana/web3.js 函数来创建这些指令。 然后可以将每个指令包含 在交易中并像正常一样发送到集群。 另请参阅下面 的最佳实践。

与 Solana 交易中的其他指令不同,计算预算指令不需要任何账户。包含多个相同类型 指令的交易将失败。 包含多个相同类型指令的交易将失败。

Rust

Rust 的 solana-sdk crate 包含在 ComputeBudgetInstruction 中的函数,用于设置 compute unit limit 和 compute unit price 的指令

let instruction = ComputeBudgetInstruction::set_compute_unit_limit(300_000);
let instruction = ComputeBudgetInstruction::set_compute_unit_price(1);

Javascript

The @solana/web3.js library includes functions within the ComputeBudgetProgram class to craft instructions for setting the compute unit limit and compute unit price:

const instruction = ComputeBudgetProgram.setComputeUnitLimit({
  units: 300_000,
});
const instruction = ComputeBudgetProgram.setComputeUnitPrice({
  microLamports: 1,
});

优先费用最佳实践

以下是关于优先费用最佳实践的一般信息。 你还可以在本指南中找到更多关 于如何请求最佳计算的 详细信息,包括如何模拟交易以确定其大致计算使用量。

请求最少的计算单元

交易应请求执行所需的最少计算单元以最小化费用。 还要注意,当请求的计算单元数量超 过实际消耗的计算单元数量时,费用不会调整。

获取最近的优先费用

在将交易发送到集群之前,你可以使用 getRecentPrioritizationFees RPC 方法获取节点处理的最近区块中支付的优先费用列表。
然后你可以使用这些数据来估算适当的优先费用,以便 (a) 更好地确保交易被集群处理, 并且 (b) 最小化支付的费用。

租金

存入每个 Solana 账户 以保持其关联数据在链上可用的费用 称为“租金”。 此费用在每个账户的正常 lamport 余额中扣留,并在账户关闭时可回收。

租金不同于交易费用 。 租金是“支付”(在账户中扣留)以保持 数据存储在 Solana 区块链上,并且可以回收。 而交易费用是支付给网络上处 理指令 的费用。

所有账户都需要保持足够高的 lamport 余额(相对于其分配的空间)以成 为租金豁免并留在 Solana 区块链上。任何试图将账户余额减少到其相应 的租金豁免最低余额以下的交易将失败(除非余额减少到正好为零)。 Solana 上的新账户 (和程序)必须初始化足够的 lamports 以成为 租金豁免。情况并非总是如此。以 前,运行时会定期自动从低于其 租金豁免的最低余额 的每个账户中收取费用。最终将这 些账户减少到零余额并从全局状态中垃圾回收它们(除非手动补充)。

当账户的所有者不再希望将此数据保留在链上并在全局状态中可用时,所有者可以关闭账户 并回收租金存款。

这是通过将账户的全部 lamport 余额提取(转移)到另一个账户(即你的钱包)来完成 的。 通过将账户余额减少到正好为 0,运行时将在“ 垃圾回收 ”过程中从网络中删除账户及其关联数据。

租金费率

Solana 的租金费率在整个网络范围内设置,主要基于运行时设置的“ 每字节每年 lamports”。 目前,租金费率是一个静态金额,并存储 在Rent sysvar 中。

此租金费率用于计算在账户内扣留的租金确切金额,以分配给账户的空间(即可以存储在账 户中的数据量)。账户分配的空间越多,扣留的租金存款就越高。 账户分配的空间越多, 扣留的租金存款就越高。

租金豁免

账户必须维持高于链上存储其各自数据所需的最低金额的 Lamport 余额。这被称为“免租”,该余额被称为“免租最低余额”。
Solana 上的新账户(和程序)必须初始化足够的 lamports 以成为 租金豁免。情 况并非总是如此。以前,运行时会定期自动从低于其 租金豁免的最低余额 的每个账户 中收取费用。 最终将这些账户减少到零余额并从全局状态中垃圾回收它们(除非手动补 充)。

在创建新账户的过程中,你必须确保存入足够的 lamports 以超过此最低余额。 任何低于 此最低阈值的金额将导致交易失败。

每次账户余额减少时,运行时都会检查账户是否仍高于此租金豁免的最低余额。由于审查导 致的费用损失,我们预计总销毁费用将少于可比的诚实分叉

账户成为租金豁免的具体最低余额取决于区块链的当前租金费率和账户希望 分配的存储空间量(账户大小)。 因此,建议使 用getMinimumBalanceForRentExemption RPC 端点来计算给定账户大小的具体余额。

所需的租金存款金额也可以通过 solana rent CLI 子命令估 算:

solana rent 15000

# output
Rent per byte-year: 0.00000348 SOL
Rent per epoch: 0.000288276 SOL
Rent-exempt minimum: 0.10529088 SOL

垃圾回收

未保持大于零的 lamport 余额的账户将在称为垃圾回收的过程中从网络中删除。 此过程有 助于减少网络范围内不再使用/维护的数据存储。

在交易成功将账户余额减少到正好为 0 后,垃圾回收会由运行时自动进行。 任何试图将 账户余额减少到其租金豁免最低余额以下(不正好为零)的交易将失败。

重要的是要注意垃圾回收在交易执行完成之后发生。 如果有一个指令通过将账户余额 减少到零来“关闭”账户,则可以在同一交易中通过后续指令“重新打开”账户。 如果账户状 态在“关闭”指令中未被清除,则后续的“重新打开”指令将具有相同的账户状态。 这是一个 安全问题,因此了解垃圾回收生效的确切时间非常重要。

即使账户已从网络中删除(通过垃圾回收),它仍可能有与其地址相关的交易(无论是过去 的历史还是未来的交易)。 即使 Solana 区块浏览器可能显示“找不到账户”类型的消息, 你仍然可以查看与该账户相关的交易历史。

你可以阅读验证 器已实现的提案了 解更多关于垃圾回收的信息。

Solana 核心概念-交易和指令

交易和指令

在 Solana上,我们发送 交易与网络交互。 交 易包括一个或多个指令, 每个交易代表一个待 处理的特定操作。 指令的执行逻辑是存储在部署到 Solana 网络的 programs 上。每个程序存储自己的一组指令。
以下是关于交易执行方式的关键细节:

  • 执行顺序:如果一个交易包括多个指令,这些指令将按照它们添加到交易中的顺序进行处 理。
  • 原子性:交易是原子的,意味着它要么完全完成并且所有指令都成功处理,要么完全失 败。 如果交易中的任何指令失败,则不会执行任何指令。

为简单起见,可以将交易视为请求处理一个或多个指令。

你可以将交易想象成一个信封,其中每个指令是您填写并放入信封中的文件。 然后我们发 出信封来处理文档,就像在网络上发送一个交易来处理我们的指令一样。

关键要点

  • Solana 交易由与网络上各种程序进行交互的指令组成,其中每个指令代表一个特定操 作。
  • 每个指令指定执行指令的程序、指令所需的账户以及指令执行所需的数据。
  • 交易中的指令按照它们列出的顺序进行处理。
  • 交易是原子的,意味着要么所有指令都成功处理,要么整个交易失败。
  • 交易的最大大小为1232字节。

基本示例

以下是代表从发送方向接收方转移 SOL 的单个指令的交易的图示。
Solana 上的个人“钱包”是由系统程序 拥有的账 户。 作为 Solana 账户模型 的一部分,只有拥有帐户的程序才允 许修改帐户上的数据。

因此,从“钱包”账户转移 SOL 需要发送一个交易来调用 System Program 上的转移指令。

发送者账户必须包含在交易上作为签名者(is_signner),以批准扣除他们的 lamport 余 额。 发送者和接收方的账户必须是可变的(is_wrable),因为指令修改了两个账户的 lamport 余额。

交易一旦发送,系统程序将被调用来处理传输的指令。 然后,系统程序相应更新的发送者 和接受者账户的 lamport 余额。

简单 SOL 转移

这是一个使用 SystemProgram.transfer 方法构建SOL转移指令的 Solana Playground示例

// 定义转账金额
const transferAmount = 0.01; // 0.01 SOL

// 创建一个转账指令,从 wallet_1 到 wallet_2
const transferInstruction = SystemProgram.transfer({
  fromPubkey: sender.publicKey,
  toPubkey: receiver.publicKey,
  lamports: transferAmount * LAMPORTS_PER_SOL, // Convert transferAmount to lamports
});

//  添加转账指令到新交易
const transaction = new Transaction().add(transferInstruction);

运行脚本并检查记录到控制台的交易详细信息。在下面的部分中,我们将详细介绍发生的情 况。 在下面的部分,我们过一遍运行时发生什么的细节。

  client.ts:
$     sender prebalance: 14.32573752
    receiver prebalance: 0


    sender postbalance: 14.31573252
    receiver postbalance: 0.01


    Transaction Signature: https://explorer.solana.com/tx/2qRf4gscQamiiAUmXqoqqU1u1BVnGRBNvSetgjEHVhBDVeRdbR92KNMobFn4WQuuVjq5Np4uLKLp1wGLNY5eusuF?cluster=devnet

交易

Solana 的交易包 括:

pub struct Transaction {
    /// A set of signatures of a serialized [`Message`], signed by the first
    /// keys of the `Message`'s [`account_keys`], where the number of signatures
    /// is equal to [`num_required_signatures`] of the `Message`'s
    /// [`MessageHeader`].
    ///
    /// [`account_keys`]: Message::account_keys
    /// [`MessageHeader`]: crate::message::MessageHeader
    /// [`num_required_signatures`]: crate::message::MessageHeader::num_required_signatures
    // NOTE: Serialization-related changes must be paired with the direct read at sigverify.
    #[wasm_bindgen(skip)]
    #[serde(with = "short_vec")]
    pub signatures: Vec<Signature>, // 签名

    /// The message to sign.
    #[wasm_bindgen(skip)]
    pub message: Message, // 消息
}
  1. 签名: 包含在交易中的签名数组。
  2. 消息: 要原子处理的指令列表。

交易消息的结构包括:

  • 消息头:指定签名者和只读账户的数量。
  • 账户地址:指令在交易中所 需的账户地址数组。
  • 最新的 Blockhash:作为交易的时间 戳。
  • 指令:要执行的指令数组。

交易大小

Solana网络遵循最大传输单元(MTU)大小为1280字节,与 IPv6 MTU大小约束一致,以确保快速可 靠地通过 UDP 传输集群信息。 在计算必要的标头后(IPv6的40字节和8字节的片段 头),1232 字节仍可用于数据包, 例如序列化交易。

这意味着 Solana 交易的总大小限制为 1232 字节。签名和消息的组合不能超过此限制。

  • 签名:每个签名需要64字节。签名的数量可以根据交易的要求而变化。 签名数量可以不 同,取决于交易的要求。
  • 消息:消息包括指令、账户和附加元数据,每个账户需要32字节。 账户加上元数据的组 合大小可以根据交易中包含的指令而变化。

消息头

消息头具 体规定了交易账户地址数组中包含的账户的权限。 它由三个字节组成,每个字节含有一个 u8 整数,它们共同规定:

pub struct MessageHeader {
    /// The number of signatures required for this message to be considered
    /// valid. The signers of those signatures must match the first
    /// `num_required_signatures` of [`Message::account_keys`].
    // NOTE: Serialization-related changes must be paired with the direct read at sigverify.
    pub num_required_signatures: u8,

    /// The last `num_readonly_signed_accounts` of the signed keys are read-only
    /// accounts.
    pub num_readonly_signed_accounts: u8,

    /// The last `num_readonly_unsigned_accounts` of the unsigned keys are
    /// read-only accounts.
    pub num_readonly_unsigned_accounts: u8,
}
  1. 交易所需的签名数量。
  2. 需要签名的只读账户地址的数量。
  3. 不需要签名的只读账户地址的数量。

紧凑数组格式

在交易消息的上下文中,紧凑数组指的是以以下格式序列化的数组:

  1. 数组的长度,编码为 compact-u16。
  2. 编码长度后按顺序列出数组的各个项。

这种编码方法用于指定交易消息中 的账户地址和指令数 组的长度。

账户地址数组

交易消息包括一个数组,其中包含所 有账户地址 ,这些地址是交易内指令所需的。

pub account_keys: Vec<Pubkey>,

该数组以一个 compact-u16 编码开 始,后跟按账户权限排序的地址。 消息头中的元数据用于确定每个部分中的账户数量。

  • 可写且签名者账户
  • 只读且签名者的账户
  • 可写且非签名者的账户
  • 只读且非签名者的账户

最近的块哈希

所有交易都包括一 个最近的区块哈希, 用作交易的时间戳。 区块哈希用于防止重复和消除过时的交易。

交易的区块哈希的最大年龄为150个区块(假设每个区块时间为400毫秒,约1分钟)。 如果 交易的区块哈希比最新的区块哈希旧150个区块,那么它被视为已过期。 这意味着在特定时 间范围内未处理的交易将永远不会被执行。

你可以使用getLatestBlockhash RPC方法来获 取当前的区块哈希以及区块哈希将有效的最后一个区块高度。 以下是一个 在Solana Playground 上的示例。

所以不能和以太坊一样,预先离线生成很多交易,因为以太坊只对比nonce没有过期

指令数组

交易消息包括一个包含所 有请求处理的指令的 数组。 交易消息中的指令采用以下格 式:CompiledInstruction

与账户地址数组类似,这个紧凑数组以一 个compact-u16编码开始,后跟一个 指令数组。数组中的每个指令指定以下信息: 对于每个指令所需的每个账户,必须指定以 下信息:

  1. 程序ID:标识将处理指令的链上程序。这表示为指向账户地址数组中的一个账户地 址的u8索引。 这是一个 u8 索引,指向帐户地址数组中的帐户地址。
  2. 账户地址索引的紧凑数组:指向每个指令所需的账户地址数组的u8索引数组。
  3. 不透明u8数据的紧凑数组:特定于被调用程序的u8字节数组。 此数据指定要在程序 上调用的指令,以及指令需要的任何附加数据(例如函数参数)。

示例交易结构

以下是包括单个 SOL转账 指令的交易结构示 例。 它显示了消息细节,包括头部、账户密钥、区块哈希和指令,以及交易的签名。

  • header:包括用于指定accountKeys数组中的读/写和签名者权限的数据。
  • accountKeys:包括交易中所有指令的账户地址。
  • recentBlockhash:交易创建时包含的区块哈希。
  • instructions:包括交易中所有指令。 每个指令中的account和programIdIndex通 过索引引用accountKeys数组。
  • signatures:包括交易中指令所需的所有签名。 通过使用相应账户的私钥对交易消息 进行签名来创建签名。
"transaction": {
    "message": {
      "header": {
        "numReadonlySignedAccounts": 0,
        "numReadonlyUnsignedAccounts": 1,
        "numRequiredSignatures": 1
      },
      "accountKeys": [
        "3z9vL1zjN6qyAFHhHQdWYRTFAcy69pJydkZmSFBKHg1R",
        "5snoUseZG8s8CDFHrXY2ZHaCrJYsW457piktDmhyb5Jd",
        "11111111111111111111111111111111"
      ],
      "recentBlockhash": "DzfXchZJoLMG3cNftcf2sw7qatkkuwQf4xH15N5wkKAb",
      "instructions": [
        {
          "accounts": [
            0,
            1
          ],
          "data": "3Bxs4NN8M2Yn4TLb",
          "programIdIndex": 2,
          "stackHeight": null
        }
      ],
      "indexToProgramIds": {}
    },
    "signatures": [
      "5LrcE2f6uvydKRquEJ8xp19heGxSvqsVbcqUeFoiWbXe8JNip7ftPQNTAVPyTK7ijVdpkzmKKaAQR7MWMmujAhXD"
    ]
  }

指令

一 个指令是 对链上执行特定操作的请求,也是程序中最小 的连续执行逻辑单元。

构建要添加到交易中的指令时,每个指令必须包括以下信息:

  • 程序地址:指定被调用的程序。
  • 账户:列出每个指令读取或写入的每个账户,包括其他程序,使用 AccountMeta 结构。
  • 指令数据:一个字节数组,指定要在程序上调用 的指令处理程序,以及指令处理程序所需 的任何附加数据(函数参数)。

账户元数据

对于每个指令所需的每个账户,必须指定以下信息:

  • pubkey:账户的链上地址
  • is_signer:指定账户是否在交易中作为签名者
  • is_writable:指定账户数据是否将被修改

这些信息被称 为账户元数据

pub struct AccountMeta {
    /// An account's public key.
    pub pubkey: Pubkey,
    /// True if an `Instruction` requires a `Transaction` signature matching `pubkey`.
    pub is_signer: bool,
    /// True if the account data or metadata may be mutated during program execution.
    pub is_writable: bool,
}

通过指定指令所需的所有账户,以及每个账户是否可写,可以并行处理交易。
例如,两个不包含写入相同状态的账户的交易可以同时执行。

示例指令结构

以下是一个 SOL 转账指令结构的示例,详 细说明了指令所需的账户密钥、程序 ID 和数据。

  • keys:包括每个指令所需的AccountMeta (账户元数据)。
  • programId:包含执行指令的程序地址。
  • data:指令数据,作为字节缓冲区
{
  "keys": [
    {
      "pubkey": "3z9vL1zjN6qyAFHhHQdWYRTFAcy69pJydkZmSFBKHg1R",
      "isSigner": true,
      "isWritable": true
    },
    {
      "pubkey": "BpvxsLYKQZTH42jjtWHZpsVSa7s6JVwLKwBptPSHXuZc",
      "isSigner": false,
      "isWritable": true
    }
  ],
  "programId": "11111111111111111111111111111111",
  "data": [2,0,0,0,128,150,152,0,0,0,0,0]
}

扩展示例

构建程序指令的详细信息通常由客户端库抽象掉。 但是,如果没有可用的库,你总是可以 手动构建指令。

手动SOL转账

这是一个 Solana Playground 示 例,展示了如何手动构建 SOL 转账指令:

// 定义转账金额
const transferAmount = 0.01; // 0.01 SOL

// 系统程序转移指令的指令索引
const transferInstructionIndex = 2;

// 为要传递给传输指令的数据创建一个缓冲区
const instructionData = Buffer.alloc(4 + 8); // uint32 + uint64
// 将指令索引写入缓冲区
instructionData.writeUInt32LE(transferInstructionIndex, 0);
// 将转帐金额写入缓冲区
instructionData.writeBigUInt64LE(BigInt(transferAmount * LAMPORTS_PER_SOL), 4);

// 手动创建一个传输指令,用于将SOL从发送方传输到接收方
const transferInstruction = new TransactionInstruction({
  keys: [
    { pubkey: sender.publicKey, isSigner: true, isWritable: true },
    { pubkey: receiver.publicKey, isSigner: false, isWritable: true },
  ],
  programId: SystemProgram.programId,
  data: instructionData,
});

// 将转账指令添加到新交易中
const transaction = new Transaction().add(transferInstruction);

在背后,使用 SystemProgram.transfer 方法 的简单例子在功能上等同于上面更详 细的示例。

// 定义转账金额
const transferAmount = 0.01; // 0.01 SOL

// 创建一个转账指令,从 wallet_1 到 wallet_2
const transferInstruction = SystemProgram.transfer({
  fromPubkey: sender.publicKey,
  toPubkey: receiver.publicKey,
  lamports: transferAmount * LAMPORTS_PER_SOL, // Convert transferAmount to lamports
});

//  添加转账指令到新交易
const transaction = new Transaction().add(transferInstruction);

SystemProgram.transfer方法简单地隐藏了为每个指令所需的账户创建指令 数据缓冲区和AccountMeta的细节。