Solana Inner Instruction 与 Instruction 的执行关系
结论
#4 会先开始执行。#4.1 是 #4 执行期间通过 CPI(Cross-Program Invocation)同步调用的 inner instruction。
执行顺序如下:
- Solana Runtime 调用顶层 instruction #4。
- #4 对应的 pump.fun program 开始运行。
- 程序执行到
invoke()或invoke_signed()时,暂停当前逻辑,转去执行 #4.1。 - #4.1 成功返回后,程序继续执行 #4 剩余的代码。
- 如果任意一层调用失败,且程序没有处理该错误,整个交易都会回滚。
#4.1 无法脱离 #4 提前执行。区块浏览器将它列在 #4 下方,正是因为它属于 #4 的调用树。
为什么 #4 可以引用由 #4.1 创建的账户
账户地址已经确定,与账户已经初始化,是两个不同的状态。
调用 #4 时,执行期间可能访问的所有账户地址都必须包含在交易消息的账户列表中,或通过 Address Lookup Table 加载。inner instruction 无法在执行过程中向交易追加新的地址。
地址已知时,对应账户仍可能尚未初始化。例如,#4 可以接收一个尚未创建的 PDA,再通过 #4.1 调用 System Program 创建该账户:
#4 pump.fun program
├── 计算或校验目标 PDA
├── #4.1 CPI → System Program:创建 PDA 账户
├── #4.2 CPI → Token Program:初始化代币账户
└── 继续执行 pump.fun 的业务逻辑
因此,某个账户既出现在 Solscan 展示的 #4 输入账户中,又由 #4.1 创建,并不冲突。它的地址在构造交易时已经确定,创建或初始化操作发生在 #4.1。完成创建后,#4 可以继续读取或修改该账户。
如果该账户是 PDA,pump.fun program 通常会使用 invoke_signed(),根据 seeds 和 bump 代表 PDA 签名。PDA 本身没有私钥。
inner instruction 是怎样产生的
客户端只能直接构造顶层 instruction。inner instruction 由链上程序在运行期间发起,例如下面的 Rust 程序:
use solana_program::{
account_info::AccountInfo,
entrypoint::ProgramResult,
program::invoke,
system_instruction,
};
pub fn create_account<'a>(
payer: &AccountInfo<'a>,
new_account: &AccountInfo<'a>,
system_program: &AccountInfo<'a>,
lamports: u64,
space: u64,
owner: &solana_program::pubkey::Pubkey,
) -> ProgramResult {
let ix = system_instruction::create_account(
payer.key,
new_account.key,
lamports,
space,
owner,
);
invoke(
&ix,
&[
payer.clone(),
new_account.clone(),
system_program.clone(),
],
)
}
如果创建的是 PDA,则由拥有该 PDA 的程序使用 invoke_signed():
use solana_program::program::invoke_signed;
invoke_signed(
&ix,
&[
payer.clone(),
pda_account.clone(),
system_program.clone(),
],
&[&[
b"example",
authority.key.as_ref(),
&[bump],
]],
)?;
Runtime 记录这些 CPI 调用后,它们才会作为 inner instructions 出现在交易元数据中。
如何复现这笔交易
通常不需要手工构造 #4.1。客户端只需构造与原交易相同的顶层 instruction,pump.fun program 会根据程序代码和当前链上状态发起相应的 CPI。
1. 读取原始交易
通过 getTransaction 或 getParsedTransaction 获取以下信息:
- 顶层 instructions
- 完整账户列表
- instruction data
meta.innerInstructions- 日志
- Address Lookup Table 加载的地址
- 交易版本
可以用以下代码查看调用关系:
import { Connection } from "@solana/web3.js";
const connection = new Connection(
"https://api.mainnet-beta.solana.com",
"confirmed",
);
const signature =
"2FWNrPeZJ7XhgLHXHHeA6nnX68bkKh6TwZa3pGeHt7F9Nf4w29NAtLHYGjxA9dMWRiX1dYCeHF3WfaRJhFEvnXyh";
const tx = await connection.getParsedTransaction(signature, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (!tx) {
throw new Error("Transaction not found");
}
console.dir(tx.transaction.message.instructions, { depth: null });
console.dir(tx.meta?.innerInstructions, { depth: null });
console.dir(tx.meta?.logMessages, { depth: null });
meta.innerInstructions 中的 index 指向顶层 instruction 在交易消息中的索引。SDK 数组索引通常从 0 开始,区块浏览器显示的编号则可能从 1 开始,分析时要确认两者是否存在偏移。
日志中的调用深度也可以反映执行关系:
Program PumpProgramId invoke [1]
Program SystemProgramId invoke [2]
Program SystemProgramId success
Program PumpProgramId success
其中,invoke [1] 表示顶层调用,invoke [2] 表示该程序发起的 CPI。
2. 确定各账户的来源
逐个确认 #4 使用的账户类型:
- 用户钱包或付款账户
- 新生成的普通 Keypair
- 通过 seeds 推导的 PDA
- Associated Token Account
- System Program、Token Program 等程序账户
- sysvar 账户
- Address Lookup Table 中加载的账户
PDA 必须使用相同的 program ID 和 seeds 推导:
import { PublicKey } from "@solana/web3.js";
const pumpProgramId = new PublicKey("PUMP_PROGRAM_ID");
const [pda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("SEED_PREFIX"),
authority.toBuffer(),
],
pumpProgramId,
);
console.log({ pda: pda.toBase58(), bump });
SEED_PREFIX 和 seeds 的排列顺序由 pump.fun program 的接口决定。仅凭 Solscan 上的账户地址无法确定这些信息,需要查阅公开 IDL、SDK、程序源码或可靠的指令解析结果。
3. 重新编码顶层 instruction data
顶层 instruction data 必须符合目标程序的二进制接口。除了账户地址,还要正确编码以下内容:
- instruction discriminator
- 参数类型
- 字段顺序
- 数值的字节序
- 字符串或数组长度
- 可选字段
如果目标程序提供 Anchor IDL,可以通过 Anchor 的 methods API 构造。没有 IDL 时,需要按照公开 SDK、源码或已经确认的 ABI 手动序列化。
以下代码只是通用结构,不能代替 pump.fun 实际使用的参数编码:
import {
AccountMeta,
PublicKey,
TransactionInstruction,
} from "@solana/web3.js";
const programId = new PublicKey("PUMP_PROGRAM_ID");
const keys: AccountMeta[] = [
{
pubkey: payer.publicKey,
isSigner: true,
isWritable: true,
},
{
pubkey: targetPda,
isSigner: false,
isWritable: true,
},
{
pubkey: systemProgramId,
isSigner: false,
isWritable: false,
},
];
const data = Buffer.from("ENCODED_INSTRUCTION_DATA", "hex");
const pumpInstruction = new TransactionInstruction({
programId,
keys,
data,
});
账户顺序以及 isSigner、isWritable 标记都是程序接口的一部分。顺序错误或权限不足,通常会导致 instruction 执行失败。
4. 发送顶层 instruction
构造顶层 instruction 后,将其加入交易并签名:
import {
Connection,
Transaction,
sendAndConfirmTransaction,
} from "@solana/web3.js";
const transaction = new Transaction().add(pumpInstruction);
const signature = await sendAndConfirmTransaction(
connection,
transaction,
[payer],
{
commitment: "confirmed",
},
);
console.log(signature);
只要顶层程序进入相同的代码路径,就会自行产生对 System Program、Token Program 或其他程序的 inner instructions。客户端不能通过设置某个字段,将普通 instruction 指定为“#4.1”。
如果原交易使用 Versioned Transaction 或 Address Lookup Table,需要改用 TransactionMessage 和 VersionedTransaction 编译消息。
能否完全复制原交易
通常只能复现原交易的业务操作,无法在当前时间得到完全相同的交易结果或签名,主要有以下原因:
- 原交易的 recent blockhash 已过期。
- 账户余额、所有者和初始化状态可能已经改变。
- 原交易创建的账户可能已经存在。
- bonding curve、价格、滑点等状态可能发生变化。
- 新交易会使用不同的 blockhash,因此签名也会不同。
- 某些账户需要原始私钥签名。
- 程序可能已经升级。
- 指令行为可能依赖 slot、时间或其他 sysvar。
如果只是研究调用过程,可以先在本地验证器或测试环境中准备相近的账户状态,再提交相同格式的顶层 instruction。直接在主网重复发送涉及资产交易的指令,可能按当前价格真实成交,也可能因滑点、账户已经存在或状态不一致而失败。
常见误区
把 inner instruction 当作交易消息的一部分
meta.innerInstructions 是执行结果中的元数据,记录程序运行时实际发生的 CPI。它不是客户端预先写入交易消息的独立指令列表。
在 inner instruction 之前创建同一个账户
如果原程序会通过 CPI 创建账户,客户端再次添加 SystemProgram.createAccount(),可能触发 account already in use 错误,也可能改变程序预期的账户所有权和初始化流程。
只复制 Solscan 页面上的账户
复现 instruction 还需要原始 data、账户顺序、读写权限、签名权限和执行前的状态。区块浏览器的可视化结果适合用来分析交易,但不能代替完整的程序接口定义。
认为账户出现在输入列表中就已经存在
交易消息列出的是执行期间允许访问的地址。地址对应的账户在 instruction 开始时可能尚未初始化,之后再由 CPI 创建。地址已知和账户已经创建是两种不同的状态。
备注:内容仅供参考。
