本文介绍了Solana程序如何通过指令自省(instruction introspection)读取同一交易中其他指令的内容。
指令内省(Instruction introspection)使 Solana 程序能够在同一笔交易中读取除自身以外的指令。
通常,一个程序只能读取以自身为目标的指令。Solana 运行时将每条指令路由到指令中指定的程序。
一个 Solana 交易可以包含多条指令,每条指令都以不同的程序为目标。例如,程序 A 可能收到指令 Ax
,而程序 B 在同一交易中收到指令 Bx
。通过内省,程序 B 可以读取指令 Ax
和 Bx
的内容。
例如,假设你希望确保与你的 DeFi 程序的任何交互都必须首先在同一交易中向你的 treasury 转账 0.5
SOL。你可以通过内省指令来执行此规则,如果所需的 0.5
SOL 转账指令未包含在与你的程序交互的指令之前,则拒绝整个交易。
在本文中,我们将学习内省的工作原理以及如何在你的 Solana 程序中实现它。
在我们了解指令内省之前,让我们详细回顾一下交易和指令。
Solana 交易是一个具有两个字段的结构体:一个消息(message)和签署它的签名(signatures)。该消息包含一个按顺序执行的指令(instructions)数组。
下面的代码(直接来自 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):每个指令是对链上程序的一次调用。一个指令包含三个组件:
指令结构体(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 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 为内省提供的两个关键辅助函数:
load_current_index_checked
– 程序可以使用此辅助函数来了解它们在交易列表中的索引,然后通过它们的相对位置查找另一个指令。load_instruction_at_checked
– 加载特定索引处的指令,并将其反序列化为一个 Instruction
结构体。一旦你使用 load_current_index_checked
函数获得了当前索引,你就可以使用此函数来内省较早或较晚的指令。我们将在本文后面的章节中看到如何做到这一点。首先,要理解这些辅助函数是如何工作的,让我们看看指令 sysvar 帐户的布局。它被组织成三个区域:
头部指定了交易中的指令数量和指令偏移量(指向指令开始的位置)。下图显示了一个具有 2 个指令的头部,因此有两个偏移量:一个从内存位置 6
开始,另一个从内存位置 20
开始。
指令区域从偏移量指示的字节位置开始(下图中的红色框只是偏移量的视觉标记,而不是实际的内存位置)。从该位置开始,它包含帐户元数据、程序 ID、指令数据的长度,最后是指令数据本身。如果我们有多个指令,则每个指令都会重复此结构。
最后,当前正在执行的指令的索引存储在 Sysvar 布局的末尾。
如果程序知道当前正在执行的指令的索引,它可以获得相对于它的其他指令。
现在我们已经了解了数据是如何在 Sysvar 帐户中布局的,让我们看一个实际的例子。我们将使用两个用于内省的辅助方法:load_current_index_checked
和 load_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_checked
和 load_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
函数,它将:
请参见下面的完整代码。我们添加了注释来注释上面列出的步骤:
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
来构建和部署项目。你应该看到一个类似于下面的输出,表明它已成功部署:
创建一个简单的 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
运行客户端代码时,我们应该看到它工作,并输出如下内容:
从 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!