Solana 指令自省

本文介绍了Solana程序如何通过指令自省(instruction introspection)读取同一交易中其他指令的内容。

指令内省(Instruction introspection)使 Solana 程序能够在同一笔交易中读取除自身以外的指令。

通常,一个程序只能读取以自身为目标的指令。Solana 运行时将每条指令路由到指令中指定的程序。

一个 Solana 交易可以包含多条指令,每条指令都以不同的程序为目标。例如,程序 A 可能收到指令 Ax,而程序 B 在同一交易中收到指令 Bx。通过内省,程序 B 可以读取指令 AxBx 的内容。

例如,假设你希望确保与你的 DeFi 程序的任何交互都必须首先在同一交易中向你的 treasury 转账 0.5 SOL。你可以通过内省指令来执行此规则,如果所需的 0.5 SOL 转账指令未包含在与你的程序交互的指令之前,则拒绝整个交易。

在本文中,我们将学习内省的工作原理以及如何在你的 Solana 程序中实现它。

交易和指令

在我们了解指令内省之前,让我们详细回顾一下交易和指令。

Solana 交易是一个具有两个字段的结构体:一个消息(message)和签署它的签名(signatures)。该消息包含一个按顺序执行的指令(instructions)数组。

一个高级图表,说明 Solana 交易的组成部分。它显示了一个交易由一个“消息(Message)”组成,其中包含指令,以及一个签署该消息的“签名数组(Array of signatures)”。

下面的代码(直接来自 Solana SDK)显示了一个交易的结构体表示:

pub struct Transaction {
    pub signatures: Vec<Signature>,
    pub message: Message,
}

交易消息

交易消息(transaction message)包含指令列表,以及指令将共同访问的所有帐户密钥的并集。它还包含运行时需要的一些附加数据,例如最近的区块哈希(recent block hash)和消息头(message header)。

pub struct Message {
    pub instructions: Vec<Instruction>,
    pub account_keys: Vec<Address>,
    pub recent_blockhash: Hash,
    pub header: MessageHeader,
}

以下是每个组件的详细分解:

  • 指令(Instructions):每个指令是对链上程序的一次调用。一个指令包含三个组件:

    • 程序 ID(Program ID):具有被调用指令的业务逻辑的程序的地址。
    • 帐户(Accounts):进入交易 帐户密钥(account keys) 的索引。这些索引将指令映射到它需要读取或写入的特定帐户。
    • 指令数据(Instruction data):一个字节数组,用于指定要在程序上调用的函数以及指令所需的任何参数。
  • 帐户密钥(Account keys):这是每个指令中列出的所有帐户的并集。
  • 最近的区块哈希(Recent blockhash):一个最近的区块哈希,将交易与一个短时间窗口的插槽(slots)绑定并防止重放。
  • 消息头(Message header):它指定有多少帐户签署了交易,以及哪些帐户是只读的,哪些是可写的。

指令结构体(Instruction struct)

以下是 Instruction 结构体定义,来自 GitHub 上的 Solana 源代码

pub struct Instruction {
    /// Pubkey of the program that executes this instruction.
    pub program_id: Pubkey,
    /// Metadata describing accounts that should be passed to the program.
    pub accounts: Vec<AccountMeta>,
    /// Opaque data passed to the program for its own interpretation.
    pub data: Vec<u8>,
}

pub struct AccountMeta {
    /// An account's public key.
    pub pubkey: Pubkey,
    // True if the instruction requires a signature for this pubkey
    /// in the transaction's signatures list.
    pub is_signer: bool,
    /// True if the account data or metadata may be mutated during program execution.
    pub is_writable: bool,
}

指令使用的每个帐户都由 AccountMeta 类型表示,它存储帐户的公钥以及签名者和可写标志。

交易和指令之间关系的总结

为了将所有内容放在一起,下图显示了一个交易、一个消息和指令之间的关系。

一个 交易(Transaction) 包含一个签名列表和一个消息。一个 消息(Message) 包含一个头部、帐户密钥列表、一个最近的区块哈希和一个指令列表。一个 指令(Instruction) 包含一个程序 ID,它使用的帐户(这索引到消息结构体中的 帐户密钥(account keys) 列表)和指令数据。

一个代码片段,显示 Solana 交易的嵌套 Rust 数据结构。它展示了顶层 Transaction 结构体由签名和一个消息组成。Message 结构体包含一个头部、帐户密钥、最近的 block_hash 和一个指令向量。它还显示了 Instruction 结构体持有 program_id、帐户和数据。

使用指令 Sysvar 进行指令内省

让我们首先检查 Solana Sysvar 帐户,来讨论内省是如何工作的。

一个 sysvar 是一个特殊的只读帐户,它包含由 Solana 运行时维护的动态更新的数据,并将内部网络状态暴露给程序。我们实际上是从这个帐户读取数据——我们没有对一个程序进行 CPI 调用。

我们在本系列的前一篇文章中讨论了不同类型的 Sysvar。要了解更多关于它们的信息,请阅读文章“Solana Sysvars Explained

指令内省使用指令 Sysvar 帐户来访问当前交易的序列化指令向量(program_id、帐户和数据)。例如,在一个包含多个指令的交易中,一个程序可以读取和分析任何指令,而不仅仅是当前指令。

这个动画展示了一个指令内省场景,其中,当指令 1 正在执行时,程序可以读取指令 2 和指令 3 的内容。

与 Solana 中的常规帐户不同,指令 Sysvar 帐户不存储数据;它仅在交易的生命周期内填充,并在执行完成后清除。

指令 Sysvar 帐户地址是 Sysvar1nstructions1111111111111111111111111。它包含当前交易中所有指令的序列化列表。每个条目都包含程序 ID、帐户和指令数据,就像我们之前看到的那样。以下是每个反序列化指令的 Rust 结构体,与之前重现的相同:

pub struct Instruction {
    /// Pubkey of the program that executes this instruction
    pub program_id: Pubkey,

    /// Metadata describing accounts that should be passed to the program
    pub accounts: Vec<AccountMeta>,

    /// Opaque data passed to the program for its own interpretation
    pub data: Vec<u8>,
}

Solana Rust SDK 提供了几个辅助函数来访问指令 sysvar 帐户中的序列化指令。但是,SDK 没有提供返回所有指令的单个函数;相反,它只提供反序列化特定索引处的单个指令的函数。

你仍然可以手动读取和反序列化 sysvar 帐户中的指令列表,但是这样做容易出错,因此,应该使用 SDK 反序列化指令。

以下是 Solana Rust SDK 为内省提供的两个关键辅助函数:

  1. load_current_index_checked – 程序可以使用此辅助函数来了解它们在交易列表中的索引,然后通过它们的相对位置查找另一个指令。
  2. load_instruction_at_checked – 加载特定索引处的指令,并将其反序列化为一个 Instruction 结构体。一旦你使用 load_current_index_checked 函数获得了当前索引,你就可以使用此函数来内省较早或较晚的指令。我们将在本文后面的章节中看到如何做到这一点。

首先,要理解这些辅助函数是如何工作的,让我们看看指令 sysvar 帐户的布局。它被组织成三个区域:

  1. 头部(header)
  2. 指令(instructions)
  3. 当前正在执行的指令的索引(index)

1. 头部区域

头部指定了交易中的指令数量和指令偏移量(指向指令开始的位置)。下图显示了一个具有 2 个指令的头部,因此有两个偏移量:一个从内存位置 6 开始,另一个从内存位置 20 开始。

一个 sysvar 头部内存布局的图表。前 2 个字节将 num_instructions 表示为 u16。接下来是一个 u16 值的数组,表示每个指令起点的字节偏移量,在本例中为 6 和 20。

2. 指令区域

指令区域从偏移量指示的字节位置开始(下图中的红色框只是偏移量的视觉标记,而不是实际的内存位置)。从该位置开始,它包含帐户元数据、程序 ID、指令数据的长度,最后是指令数据本身。如果我们有多个指令,则每个指令都会重复此结构。

一个图表,显示了来自 Solana 指令 Sysvar 帐户的指令区域的示例布局。它使用一个红色框来视觉上指示偏移量指向的位置,标记指令的开始。然后布局显示了帐户信息字段(包含 num_accounts、account.meta 和 account.pubkey),然后是 program_id、data_len 和数据本身。

3. 当前正在执行的指令的索引

最后,当前正在执行的指令的索引存储在 Sysvar 布局的末尾。

一个图表,显示了指令 sysvar 帐户的头部、指令和当前指令索引区域。

如果程序知道当前正在执行的指令的索引,它可以获得相对于它的其他指令。

访问指令

现在我们已经了解了数据是如何在 Sysvar 帐户中布局的,让我们看一个实际的例子。我们将使用两个用于内省的辅助方法:load_current_index_checkedload_instruction_at_checked 来访问交易中的指令。为了本文的目的,我们将使用一个基本的转账交易。

我们的示例程序将验证一个系统转账指令是否在其自身指令之前。只有满足此条件,交易才会成功。

Transaction:
├── Instruction 0: System Transfer (user pays X lamports)
└── Instruction 1: This program (verifies the payment)

设置程序

要跟着学习,你应该设置一个 Solana 开发环境,如果你还没有设置,请阅读 本系列的第一篇文章

初始化一个新的 Anchor 应用程序:

anchor init instruction-introspection

更新 program/src/Cargo.toml 中的依赖项,以包含 bincode ( bincode=1.3.3 )。我们将使用 bincode 库来反序列化系统指令:

//... toml 文件的其余内容

[dependencies]
anchor-lang = "0.31.1"
**bincode = "1.3.3" # 添加此行**

我们将为这个项目使用 Devnet。在你的根目录中创建一个 .env 文件,并添加以下 provider 和 wallet 导出:

export ANCHOR_PROVIDER_URL=https://api.devnet.solana.com
export ANCHOR_WALLET=~/.config/solana/id.json

同时更新 Anchor.toml 文件以使用 devnet provider 和 wallet。

[provider]
cluster = "https://api.devnet.solana.com"
wallet = "~/.config/solana/id.json"

此外,因为你需要在 Devnet 上支付费用的 SOL,运行 solana airdrop 2 以获得 2 个 SOL,这对于这个例子来说绰绰有余。

导入

现在,我们将导入我们将用于此示例的 Anchor 依赖项,以替换 program/src/lib.rs 文件中的代码。重要的是,我们从 sysvar::instructions 导入 load_instruction_at_checkedload_current_index_checked

use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
         system_program,
         sysvar::instructions::{
                load_instruction_at_checked,
                load_current_index_checked
         },
         system_instruction::SystemInstruction,
};

然后我们将声明程序 ID 并添加一个 verify_transfer 函数,它将:

  1. 获取当前指令索引以了解当前正在执行的交易的位置。
  2. 通过使用链上 Solana Rust SDK 反序列化 sysvar 帐户中的指令列表来加载上一个指令。
  3. 通过检查程序 ID 是否与系统程序匹配,然后解析指令数据以确认转账金额是否与预期金额匹配,来验证加载的指令是否是系统转账指令。
  4. 验证指令中涉及的帐户数量是否为 2。
  5. 最后,我们将定义 sysvar 帐户的结构体。

请参见下面的完整代码。我们添加了注释来注释上面列出的步骤:

use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
    system_program,
    sysvar::instructions::{load_instruction_at_checked, load_current_index_checked},
    system_instruction::SystemInstruction,
};

declare_id!("BxQuawTcvJkT2JM1qKeW6wyM4i5VCuM122v9tVsSrmwm");

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

    pub fn verify_transfer(ctx: Context<VerifyTransfer>, expected_amount: u64) -> Result<()> {
        // 步骤 1:获取当前指令索引以了解我们的位置
        **let current_ix_index = load_current_index_checked(&ctx.accounts.instruction_sysvar)?;**
        msg!("当前正在执行的指令索引:{}", current_ix_index);

        // 步骤 2:加载上一个指令
        let transfer_ix = load_instruction_at_checked(
            (current_ix_index - 1) as usize,
            &ctx.accounts.instruction_sysvar
        ).map_err(|_| error!(ErrorCode::MissingInstruction))?;

        // 步骤 3:验证它是否是系统程序指令
        require_keys_eq!(transfer_ix.program_id, system_program::ID, ErrorCode::NotSystemProgram);

        // 步骤 4:解析系统指令数据
        let system_ix = bincode::deserialize(&transfer_ix.data)
            .map_err(|_| error!(ErrorCode::InvalidInstructionData))?;

        match system_ix {
            SystemInstruction::Transfer { lamports } => {
                require_eq!(lamports, expected_amount, ErrorCode::IncorrectAmount);
                msg!("✅ 验证了 {} lamports 的转移", lamports);
            }
            _ => return Err(error!(ErrorCode::NotTransferInstruction)),
        }

        // 步骤 5:验证转账中涉及的帐户
        require_gte!(transfer_ix.accounts.len(), 2, ErrorCode::InsufficientAccounts);

        let from_account = &transfer_ix.accounts[0];
        let to_account = &transfer_ix.accounts[1];

        require!(from_account.is_signer, ErrorCode::FromAccountNotSigner);
        require!(from_account.is_writable, ErrorCode::FromAccountNotWritable);
        require!(to_account.is_writable, ErrorCode::ToAccountNotWritable);

        msg!("✅ 转账帐户配置正确");
        msg!("来自:{}", from_account.pubkey);
        msg!("至:{}", to_account.pubkey);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct VerifyTransfer<'info> {
    /// CHECK: 这是一个指令 sysvar 帐户
    #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)]
    pub instruction_sysvar: AccountInfo<'info>,
}

以下是我们使用的错误代码,你应该添加到同一个文件中:

#[error_code]
pub enum ErrorCode {
    /// 当尝试加载一个交易中不存在的索引处的指令时抛出
    ///(例如,尝试访问 0 时的索引 -1)
    #[msg("交易中缺少所需的指令")]
    MissingInstruction,

    /// 当上一个指令的 program_id 与系统程序不匹配时抛出
    /// 确保我们只验证实际的系统程序指令
    #[msg("指令不是来自系统程序")]
    NotSystemProgram,

    /// 当 bincode 无法将指令数据反序列化为 SystemInstruction 时抛出
    /// 表明指令数据格式错误或已损坏
    #[msg("指令数据格式无效")]
    InvalidInstructionData,

    /// 当 SystemInstruction 变体不是 Transfer 时抛出
    ///(例如,它是 CreateAccount、Allocate 或其他系统指令类型)
    #[msg("指令不是转账")]
    NotTransferInstruction,

    /// 当转账中的实际 lamports 金额与 expected_amount 不相等时抛出
    /// 防止抢先交易或不正确的支付金额
    #[msg("转账金额与预期金额不匹配")]
    IncorrectAmount,

    /// 当转账指令的帐户少于 2 个时抛出
    /// 有效的转账至少需要 [from, to] 帐户
    #[msg("转账指令的帐户不足")]
    InsufficientAccounts,

    /// 当转账中的“from”帐户未签名交易时抛出
    /// 防止未经授权的转账
    #[msg("From 帐户不是签名者")]
    FromAccountNotSigner,

    /// 当“from”帐户未标记为可写时抛出
    /// 这是必需的,因为帐户余额将被扣除
    #[msg("From 帐户不可写")]
    FromAccountNotWritable,

    /// 当“to”帐户未标记为可写时抛出
    /// 这是必需的,因为帐户余额将被记入
    #[msg("To 帐户不可写")]
    ToAccountNotWritable,
}

在上面的代码中,我们获得了我们当前的指令索引,使用该 ID 加载了上一个指令以进行检查。我们能够通过将当前索引减 1 来加载它,因为指令是按顺序排列的。

现在,让我们构建、部署程序,并使用 JavaScript 与它进行交互。

运行 anchor build && anchor deploy 来构建和部署项目。你应该看到一个类似于下面的输出,表明它已成功部署:

image.png

使用 Typescript 与程序代码交互

创建一个简单的 Typescript 脚本,将 1 SOL 转账到我们程序的地址。

要直接运行 Typescript 文件,你将使用 bun.js。如果你尚未安装它,你可以通过在终端上运行 curl -fsSL [https://bun.sh/install](https://bun.sh/install) | bash 来安装它。

创建一个 scripts/ 文件夹,添加一个 introspect.ts 文件,并将以下代码粘贴到其中。我添加了注释以帮助你理解代码中的思想流程。

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { SystemProgram, SYSVAR_INSTRUCTIONS_PUBKEY, Transaction, Keypair } from "@solana/web3.js";
import { CheckTransfer } from "../target/types/check_transfer";

async function main() {
  console.log("🚀 启动验证脚本...");

  // --- 设置连接和程序 ---
  // 配置客户端以使用本地集群。
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // 从工作区加载 Anchor 程序。
  const program = anchor.workspace.CheckTransfer as Program<CheckTransfer>;

  // --- 准备帐户和数据 ---
  // “payer”是签署和支付交易的钱包。
  const payer = provider.wallet.publicKey;
  // 一个新的、随机的密钥对,用作接收者。
  const recipient = Keypair.generate().publicKey;

  // 使用 anchor.BN 定义转账金额,以确保 u64 的安全性。
  const transferAmount = new anchor.BN(1_000_000_000); // 1 SOL

  console.log(`- 付款人:${payer}`);
  console.log(`- 接收人:${recipient}`);
  console.log(`- 金额:${transferAmount.toString()} lamports`);

  // --- 构建交易 ---
  // 交易是一个或多个指令的容器。
  const tx = new Transaction();

  // 指令 0:系统程序转账。
  // 这必须紧接在我们程序的指令之前。
  tx.add(
    SystemProgram.transfer({
      fromPubkey: payer,
      toPubkey: recipient,
      lamports: transferAmount.toNumber(), // 对于 1 SOL 是安全的
    })
  );

  // 指令 1:我们程序的验证指令。
  tx.add(
    await program.methods
      .verifyTransfer(transferAmount)
      .accounts({
        instructionSysvar: SYSVAR_INSTRUCTIONS_PUBKEY,
      })
      .instruction()
  );

  // --- 发送交易并验证结果 ---
  try {
    const sig = await provider.sendAndConfirm(tx);
    console.log("\n✅ 交易已确认!");
    console.log(`签名:${sig}`);

    // 获取交易详细信息以检查日志。
    const txInfo = await provider.connection.getTransaction(sig, {
      commitment: "confirmed",
      maxSupportedTransactionVersion: 0,
    });

    console.log("\n📄 程序日志:");
    console.log(txInfo?.meta?.logMessages?.join("\n"));

    // 检查日志中是否存在成功消息。
    const logs = txInfo?.meta?.logMessages;
    if (!logs || !logs.some(log => log.includes(`Verified transfer of ${transferAmount} lamports`))) {
        throw new Error("未找到验证日志消息!");
    }
    console.log("\n✅ 验证成功!");

  } catch (error) {
    console.error("\n❌ 交易失败!");
    console.error(error);
    process.exit(1); // 以非零错误代码退出
  }
}

// --- 脚本入口点 ---
main().then(
  () => process.exit(0),
  err => {
    console.error(err);
    process.exit(1);
  }
);

当我们使用 bun run script/introspect.ts 运行客户端代码时,我们应该看到它工作,并输出如下内容:

Solana 内省测试结果的终端截图。它确认内省已成功。

指令内省的预防措施:避免在检查期间使用绝对索引

从 sysvar 帐户中的绝对索引(如 0)加载指令可能会允许攻击者跨多个调用重用该指令。

例如,如果你的程序要求用户在同一交易中提款之前将资金转移到你的 treasury,则使用绝对索引可能会让攻击者在索引 0 处放置一个转账,然后进行多次提款,这些提款都针对该同一转账进行验证。

相反,使用相对指令索引,以确保转账紧接在提款指令之前发生,就像我们在示例中早些时候展示的那样。

 let transfer_ix = load_instruction_at_checked(
    (current_ix_index - 1) as usize,
     &ctx.accounts.instruction_sysvar
     )

这确保了被检查的指令是当前提款的正确转账,而不是交易中较早时候重用的转账。

本文是 Solana 教程系列 的一部分。

  • 原文链接: rareskills.io/post/solan...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/