讨论 区块链 Solana 指令内省(Instruction Introspection)

Solana 指令内省(Instruction Introspection)

Joe 发表于    阅读:39    回复:0

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

通常情况下,一个程序只能读取发送给它自己的那条指令。Solana 运行时会将每条指令路由到该指令中指定的目标程序。

一笔 Solana 交易可以包含多条指令,每条指令可分别调用不同的程序。例如,在同一笔交易中,程序 A 可能收到指令 Ax,而程序 B 收到指令 Bx。通过指令内省,程序 B 就能读取指令 Ax 和 Bx 的全部内容。

举个例子:假设你希望确保任何与你的 DeFi 程序交互的操作,都必须在同一笔交易中先向你的金库地址转账 0.5 SOL。你可以通过内省机制检查交易中的所有指令,如果在调用你程序的指令之前没有包含这笔 0.5 SOL 的转账指令,就拒绝整个交易。

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

交易与指令

在深入探讨指令内省之前,我们先详细回顾一下 Solana 中交易和指令的结构。

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

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

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

交易消息(Transaction Message)

交易消息包含待执行的指令列表,以及所有指令所需访问账户公钥的并集。此外,它还包含运行时所需的其他数据,例如最近的区块哈希(recent blockhash)和消息头(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):指向交易消息中账户公钥列表的索引。这些索引将指令与其需要读取或写入的具体账户关联起来。

    • 指令数据(Instruction Data):一个字节数组,用于指定要调用程序中的哪个函数,以及该函数所需的参数。

  • 账户公钥列表(Account Keys):这是所有指令所引用账户公钥的并集。

  • 最近区块哈希(Recent Blockhash):一个近期生成的区块哈希,用于将交易绑定到一个短暂的时间窗口(slot 范围),防止重放攻击。

  • 消息头(Message Header):指明有多少个账户签署了该交易,以及哪些账户是只读的、哪些是可写的。

指令结构体(Instruction Struct)

以下是来自 Solana GitHub 源码中的 Instruction 结构体定义:

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 类型表示,其中包含账户的公钥,以及是否为签名者(signer)和是否可写(writable)的标志位。

交易与指令关系总结

综合来看,下图展示了交易、消息和指令之间的关系:

  • 一笔 交易(Transaction) 包含一组签名和一个消息(Message)。

  • 消息(Message) 包含一个消息头、账户公钥列表、最近区块哈希,以及一组指令。

  • 每条 指令(Instruction) 包含程序 ID、所使用的账户(这些账户以索引形式指向消息中的账户公钥列表),以及指令数据。

通过指令 Sysvar 实现内省

接下来,我们通过 Solana 的 Sysvar 账户来了解内省是如何工作的。

Sysvar 是一种特殊的只读账户,其中包含由 Solana 运行时动态维护的数据,用于向程序暴露网络内部状态。我们实际上是直接从该账户读取数据——而不是通过跨程序调用(CPI)去调用某个程序。

本系列前一篇文章已介绍过多种 Sysvar 类型。如需深入了解,请阅读《Solana Sysvars 详解》。

指令内省利用 instruction Sysvar 账户 来访问当前交易中所有指令的序列化向量(包括 program_id、accounts 和 data)。例如,在包含多条指令的交易中,某个程序不仅可以读取自身对应的指令,还能读取并分析交易中的任意其他指令。

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

与 Solana 中的普通账户不同,instruction Sysvar 账户不会持久化存储数据;它仅在交易执行期间存在,一旦交易完成就会被清除。

instruction 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 提供了多个辅助函数,用于访问 instruction sysvar 账户中的序列化指令。不过,SDK 并未提供一个能一次性返回所有指令的函数,而是仅提供了按指定索引反序列化单条指令的函数。

你当然也可以手动读取并反序列化 sysvar 账户中的指令列表,但这种方式容易出错,因此建议使用 SDK 提供的函数进行反序列化。

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

  • load_current_index_checked:程序可通过此函数获知自己在交易指令列表中的索引位置,从而根据相对位置查找其他指令。

  • load_instruction_at_checked:加载指定索引处的指令,并将其反序列化为 Instruction 结构体。一旦通过 load_current_index_checked 获取了当前指令的索引,就可以使用此函数来内省前面或后面的指令。我们将在本文后续章节中演示具体用法。

首先,为了理解这些辅助函数的工作原理,我们来看一下 instruction sysvar 账户的内部布局。它分为三个区域:

  1. 头部(Header)

  2. 指令列表(Instructions)

  3. 当前正在执行的指令索引(Index of the currently executing instruction)


1.头部区域
头部指定了交易中指令的数量以及指令偏移量(指向指令开始的位置)。下图展示了一个包含两条指令的交易的头部,因此有两个偏移量:一个从内存位置 6 开始,另一个从内存位置 20 开始。

2.指令区域
指令区域从偏移量所指示的字节位置开始(下图中的红色框仅为偏移量的视觉标记,并非实际内存位置)。从该位置起,它包含账户元数据、程序 ID、指令数据的长度,最后是指令数据本身。如果有多条指令,此结构会为每条指令重复一次。

3.当前正在执行的指令索引
最后,当前正在执行的指令的索引存储在 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 库来反序列化系统指令:

//... rest of toml file content

[dependencies]
anchor-lang = "0.31.1"
**bincode = "1.3.3" # add this**

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

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

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

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

此外,由于你需要一些 SOL 来支付 Devnet 上的费用,请运行 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 函数,该函数将:

  • 获取当前指令索引,以了解当前正在执行的交易的位置;

  • 使用链上 Solana Rust SDK 反序列化 sysvar 账户中的指令列表,加载前一条指令;

  • 通过检查程序 ID 是否匹配系统程序来验证所加载的指令是否为系统转账指令,然后解析指令数据以确认转账金额符合预期;

  • 验证该指令涉及的账户数量为 2;

  • 最后,我们将定义 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<()> {
        // Step 1: Get current instruction index to understand our position
        **let current_ix_index = load_current_index_checked(&ctx.accounts.instruction_sysvar)?;**
        msg!("Currently executing instruction index: {}", current_ix_index);

        // Step 2: Load the previous instruction
        let transfer_ix = load_instruction_at_checked(
            (current_ix_index - 1) as usize,
            &ctx.accounts.instruction_sysvar
        ).map_err(|_| error!(ErrorCode::MissingInstruction))?;

        // Step 3:  Verify it's a system program instruction
        require_keys_eq!(transfer_ix.program_id, system_program::ID, ErrorCode::NotSystemProgram);

        // Step 4: Parse the system instruction data
        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!("✅ Verified transfer of {} lamports", lamports);
            }
            _ => return Err(error!(ErrorCode::NotTransferInstruction)),
        }

        // Step 5: Verify accounts involved in the transfer
        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!("✅ Transfer accounts properly configured");
        msg!("From: {}", from_account.pubkey);
        msg!("To: {}", to_account.pubkey);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct VerifyTransfer<'info> {
    /// CHECK: This is the instruction sysvar account
    #[account(address = anchor_lang::solana_program::sysvar::instructions::ID)]
    pub instruction_sysvar: AccountInfo<'info>,
}

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

#[error_code]
pub enum ErrorCode {
    /// Thrown when attempting to load an instruction at an index that doesn't exist
    /// in the transaction (e.g., trying to access index -1 when current is 0)
    #[msg("Missing required instruction in transaction")]
    MissingInstruction,

    /// Thrown when the previous instruction's program_id doesn't match the System Program
    /// Ensures we're only validating actual system program instructions
    #[msg("Instruction is not from System Program")]
    NotSystemProgram,

    /// Thrown when bincode fails to deserialize the instruction data into SystemInstruction
    /// Indicates malformed or corrupted instruction data
    #[msg("Invalid instruction data format")]
    InvalidInstructionData,

    /// Thrown when the SystemInstruction variant is not Transfer
    /// (e.g., it's CreateAccount, Allocate, or another system instruction type)
    #[msg("Instruction is not a transfer")]
    NotTransferInstruction,

    /// Thrown when the actual lamports amount in the transfer doesn't equal expected_amount
    /// Protects against front-running or incorrect payment amounts
    #[msg("Transfer amount does not match expected amount")]
    IncorrectAmount,

    /// Thrown when the transfer instruction has fewer than 2 accounts
    /// A valid transfer requires at least [from, to] accounts
    #[msg("Transfer instruction has insufficient accounts")]
    InsufficientAccounts,

    /// Thrown when the 'from' account in the transfer didn't sign the transaction
    /// Prevents unauthorized transfers
    #[msg("From account is not a signer")]
    FromAccountNotSigner,

    /// Thrown when the 'from' account is not marked as writable
    /// Required because the account balance will be debited
    #[msg("From account is not writable")]
    FromAccountNotWritable,

    /// Thrown when the 'to' account is not marked as writable
    /// Required because the account balance will be credited
    #[msg("To account is not writable")]
    ToAccountNotWritable,
}

在上述代码中,我们获取了当前指令索引,并使用该索引加载前一条指令进行检查。由于指令是按顺序排列的,我们只需将当前索引减 1 即可加载前一条指令。

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

运行 anchor build && anchor deploy 以构建和部署项目。你应该看到类似如下的输出,表明部署成功:

使用 TypeScript 与程序代码交互
创建一个简单的 TypeScript 脚本,通过我们的程序向一个地址转账 1 SOL。

要直接运行 TypeScript 文件,你将使用 bun.js。如果你尚未安装,可以在终端运行 curl -fsSL 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("🚀 Starting verification script...");

  // --- Setup Connection and Program ---
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // Load the Anchor program from the workspace.
  const program = anchor.workspace.CheckTransfer as Program<CheckTransfer>;

  // --- Prepare Accounts and Data ---
  // The 'payer' is the wallet that signs and pays for the transaction.
  const payer = provider.wallet.publicKey;
  // A new, random keypair to act as the recipient.
  const recipient = Keypair.generate().publicKey;

  // Define the transfer amount using anchor.BN for u64 safety.
  const transferAmount = new anchor.BN(1_000_000_000); // 1 SOL

  console.log(`- Payer: ${payer}`);
  console.log(`- Recipient: ${recipient}`);
  console.log(`- Amount: ${transferAmount.toString()} lamports`);

  // --- Build the Transaction ---
  // A transaction is a container for one or more instructions.
  const tx = new Transaction();

  // Instruction 0: The System Program Transfer.
  // This must immediately precede our program's instruction.
  tx.add(
    SystemProgram.transfer({
      fromPubkey: payer,
      toPubkey: recipient,
      lamports: transferAmount.toNumber(), // Safe for 1 SOL
    })
  );

  // Instruction 1: Our program's verification instruction.
  tx.add(
    await program.methods
      .verifyTransfer(transferAmount)
      .accounts({
        instructionSysvar: SYSVAR_INSTRUCTIONS_PUBKEY,
      })
      .instruction()
  );

  // --- Send Transaction and Verify Outcome ---
  try {
    const sig = await provider.sendAndConfirm(tx);
    console.log("\n✅ Transaction confirmed!");
    console.log(`Signature: ${sig}`);

    // Fetch the transaction details to inspect the logs.
    const txInfo = await provider.connection.getTransaction(sig, {
      commitment: "confirmed",
      maxSupportedTransactionVersion: 0,
    });

    console.log("\n📄 Program Logs:");
    console.log(txInfo?.meta?.logMessages?.join("\n"));

    // Check for the success message in the logs.
    const logs = txInfo?.meta?.logMessages;
    if (!logs || !logs.some(log => log.includes(`Verified transfer of ${transferAmount} lamports`))) {
        throw new Error("Verification log message not found!");
    }
    console.log("\n✅ Verification successful!");

  } catch (error) {
    console.error("\n❌ Transaction failed!");
    console.error(error);
    process.exit(1); // Exit with a non-zero error code
  }
}

// --- Script Entrypoint ---
main().then(
  () => process.exit(0),
  err => {
    console.error(err);
    process.exit(1);
  }
);

当我们使用 bun run scripts/introspect.ts 运行客户端代码时,应看到类似如下的成功输出:

指令内省的注意事项:检查时避免使用绝对索引
从 sysvar 账户中通过绝对索引(例如 0)加载指令,可能允许攻击者在多次调用中复用该指令。

例如,如果你的程序要求用户在同一笔交易中先向你的金库转账再提现,使用绝对索引可能让攻击者在索引 0 处放置一次转账,然后进行多次提现,而所有提现都会验证通过,因为它们都引用了同一笔转账。

相反,应使用相对指令索引,以确保转账紧邻发生在提现指令之前,正如我们在前面示例中所展示的那样。

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

这可确保被检查的指令是当前提现对应的正确转账,而不是交易中更早位置被复用的转账。




原文:https://rareskills.io/post/solana-instruction-introspection

免责声明:本文为c2e Labs的第三方内容,仅供信息分享与传播之目的,不代表我们的立场或观点且不构成任何投资及应用建议。版权归原作者或来源方所有,如内容或素材有所争议请和我们取得联系。

我来评论