SC Day 42
Solana/Anchor - Vault程序 + CPI(跨程序调用) + Token存取
### 1. CPI(Cross-Program Invocation)跨程序调用
2026-05-12
第二阶段:框架实战 (41-48)SolanaAnchorCPIVaultSPLTokenPDA
日期: 2026-05-12 方向: Solana 阶段: 第二阶段:框架实战 (41-48) 标签: #Solana #Anchor #CPI #Vault #SPLToken #PDA
今日目标
- 理解 CPI(Cross-Program Invocation) 的原理与安全模型
- 使用 Anchor 实现 Vault 程序:接受 SPL Token 存入和提取
- 掌握 PDA 签名(signer seeds)在 CPI 中的使用
- 理解 CPI 深度限制和安全注意事项
核心概念
1. CPI(Cross-Program Invocation)跨程序调用
CPI 是 Solana 程序之间互相调用的机制。这类似于以太坊中合约调用另一个合约,但 Solana 的模型有本质区别。
Solana vs EVM 的程序调用对比
| 维度 | Solana CPI | EVM 合约调用 |
|---|---|---|
| 状态访问 | 必须显式传入所有账户 | 可以访问任意存储槽 |
| 权限传递 | signer 权限可传递给被调用程序 | msg.sender 改变为调用合约 |
| 深度限制 | 最大 4 层 CPI 嵌套 | 理论无限(受 Gas 限制) |
| 原子性 | 整个交易原子性 | 同一交易原子性 |
| 费用 | 计算单元(CU)统一计费 | 每次 CALL 消耗 Gas |
CPI 的两种形式
// 1. invoke() - 简单调用,权限从原始交易继承
solana_program::program::invoke(
&instruction, // 要执行的指令
&[account1, account2], // 涉及的账户
)?;
// 2. invoke_signed() - PDA 签名调用
// 当需要 PDA 作为 signer 时使用
solana_program::program::invoke_signed(
&instruction,
&[account1, account2],
&[&[b"vault", user.key.as_ref(), &[bump]]], // signer seeds
)?;
2. PDA 签名机制
PDA(Program Derived Address)没有私钥,但程序可以用 invoke_signed 让 PDA "签名"。原理:
PDA = findProgramAddress([seed1, seed2, ...], program_id)
= hash(seed1, seed2, ..., bump, program_id)
当程序调用 invoke_signed 并提供正确的 seeds + bump 时,
Solana runtime 验证这些 seeds 确实能派生出该 PDA,
从而允许该 PDA 作为 signer。
这个机制让 PDA 能够:
- 持有 Token(作为 Token Account 的 owner)
- 授权 Token 转账(作为 Token 转账的 authority)
- 签署任意指令
3. SPL Token 程序
SPL Token 是 Solana 上的标准代币程序,类似于 ERC20。核心概念:
Mint Account → 代币的定义(总量、decimals、mint authority)
Token Account → 持有代币的账户(关联某个 Mint 和某个 Owner)
Associated Token Account (ATA) → 确定性地址的 Token Account
Vault 程序需要通过 CPI 调用 SPL Token 程序来实现代币转账。
代码实战
项目结构
vault-program/
├── Anchor.toml
├── Cargo.toml
├── programs/
│ └── vault/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── tests/
└── vault.ts
Vault 程序完整实现
// programs/vault/src/lib.rs
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer};
declare_id!("Vault111111111111111111111111111111111111111");
#[program]
pub mod vault {
use super::*;
/// 初始化 Vault:创建 vault 状态账户和 vault token 账户
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let vault_state = &mut ctx.accounts.vault_state;
vault_state.authority = ctx.accounts.authority.key();
vault_state.token_mint = ctx.accounts.token_mint.key();
vault_state.vault_token_account = ctx.accounts.vault_token_account.key();
vault_state.total_deposited = 0;
vault_state.bump = ctx.bumps.vault_state;
vault_state.token_account_bump = ctx.bumps.vault_token_account;
msg!("Vault initialized for mint: {}", vault_state.token_mint);
Ok(())
}
/// 存入 Token 到 Vault
pub fn deposit(ctx: Context<Deposit>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
// 记录存款信息
let deposit_record = &mut ctx.accounts.deposit_record;
deposit_record.depositor = ctx.accounts.depositor.key();
deposit_record.amount = deposit_record.amount.checked_add(amount)
.ok_or(VaultError::MathOverflow)?;
deposit_record.last_deposit_time = Clock::get()?.unix_timestamp;
// 通过 CPI 调用 SPL Token 程序进行转账
// 从用户的 Token Account → Vault 的 Token Account
let cpi_accounts = Transfer {
from: ctx.accounts.depositor_token_account.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.depositor.to_account_info(),
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
token::transfer(cpi_ctx, amount)?;
// 更新 vault 总存款
let vault_state = &mut ctx.accounts.vault_state;
vault_state.total_deposited = vault_state.total_deposited
.checked_add(amount)
.ok_or(VaultError::MathOverflow)?;
// 发出事件
emit!(DepositEvent {
depositor: ctx.accounts.depositor.key(),
amount,
total_deposited: vault_state.total_deposited,
timestamp: Clock::get()?.unix_timestamp,
});
msg!("Deposited {} tokens", amount);
Ok(())
}
/// 从 Vault 提取 Token(PDA 签名 CPI)
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
require!(amount > 0, VaultError::ZeroAmount);
let deposit_record = &mut ctx.accounts.deposit_record;
require!(
deposit_record.amount >= amount,
VaultError::InsufficientBalance
);
// 更新存款记录
deposit_record.amount = deposit_record.amount.checked_sub(amount)
.ok_or(VaultError::MathOverflow)?;
// 通过 CPI 进行转账:Vault Token Account → 用户 Token Account
// 关键:vault_state PDA 是 vault_token_account 的 authority
// 需要用 invoke_signed(PDA 签名)
let vault_state = &ctx.accounts.vault_state;
let authority_key = vault_state.authority;
// 构建 PDA signer seeds
let seeds = &[
b"vault_state".as_ref(),
authority_key.as_ref(),
&[vault_state.bump],
];
let signer_seeds = &[&seeds[..]];
let cpi_accounts = Transfer {
from: ctx.accounts.vault_token_account.to_account_info(),
to: ctx.accounts.depositor_token_account.to_account_info(),
authority: ctx.accounts.vault_state.to_account_info(), // PDA 作为 authority
};
let cpi_program = ctx.accounts.token_program.to_account_info();
let cpi_ctx = CpiContext::new_with_signer(
cpi_program,
cpi_accounts,
signer_seeds, // 提供 PDA seeds 让 runtime 验证签名
);
token::transfer(cpi_ctx, amount)?;
// 更新 vault 总存款
let vault_state_mut = &mut ctx.accounts.vault_state;
vault_state_mut.total_deposited = vault_state_mut.total_deposited
.checked_sub(amount)
.ok_or(VaultError::MathOverflow)?;
emit!(WithdrawEvent {
depositor: ctx.accounts.depositor.key(),
amount,
remaining: deposit_record.amount,
timestamp: Clock::get()?.unix_timestamp,
});
msg!("Withdrawn {} tokens", amount);
Ok(())
}
/// 查看 Vault 信息(只读)
pub fn get_vault_info(ctx: Context<GetVaultInfo>) -> Result<()> {
let vault = &ctx.accounts.vault_state;
msg!("Vault authority: {}", vault.authority);
msg!("Token mint: {}", vault.token_mint);
msg!("Total deposited: {}", vault.total_deposited);
Ok(())
}
}
// ===== 账户结构 =====
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub authority: Signer<'info>,
pub token_mint: Account<'info, Mint>,
#[account(
init,
payer = authority,
space = 8 + VaultState::INIT_SPACE,
seeds = [b"vault_state", authority.key().as_ref()],
bump
)]
pub vault_state: Account<'info, VaultState>,
#[account(
init,
payer = authority,
token::mint = token_mint,
token::authority = vault_state, // PDA 作为 token account 的 authority
seeds = [b"vault_token", authority.key().as_ref()],
bump
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(mut)]
pub depositor: Signer<'info>,
#[account(
mut,
seeds = [b"vault_state", vault_state.authority.as_ref()],
bump = vault_state.bump,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
init_if_needed,
payer = depositor,
space = 8 + DepositRecord::INIT_SPACE,
seeds = [b"deposit", vault_state.key().as_ref(), depositor.key().as_ref()],
bump
)]
pub deposit_record: Account<'info, DepositRecord>,
#[account(
mut,
constraint = depositor_token_account.owner == depositor.key(),
constraint = depositor_token_account.mint == vault_state.token_mint,
)]
pub depositor_token_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = vault_token_account.key() == vault_state.vault_token_account,
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub depositor: Signer<'info>,
#[account(
mut,
seeds = [b"vault_state", vault_state.authority.as_ref()],
bump = vault_state.bump,
)]
pub vault_state: Account<'info, VaultState>,
#[account(
mut,
seeds = [b"deposit", vault_state.key().as_ref(), depositor.key().as_ref()],
bump,
constraint = deposit_record.depositor == depositor.key(),
)]
pub deposit_record: Account<'info, DepositRecord>,
#[account(
mut,
constraint = depositor_token_account.owner == depositor.key(),
constraint = depositor_token_account.mint == vault_state.token_mint,
)]
pub depositor_token_account: Account<'info, TokenAccount>,
#[account(
mut,
constraint = vault_token_account.key() == vault_state.vault_token_account,
)]
pub vault_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct GetVaultInfo<'info> {
pub vault_state: Account<'info, VaultState>,
}
// ===== 数据结构 =====
#[account]
#[derive(InitSpace)]
pub struct VaultState {
pub authority: Pubkey, // 32 bytes
pub token_mint: Pubkey, // 32 bytes
pub vault_token_account: Pubkey, // 32 bytes
pub total_deposited: u64, // 8 bytes
pub bump: u8, // 1 byte
pub token_account_bump: u8, // 1 byte
}
#[account]
#[derive(InitSpace)]
pub struct DepositRecord {
pub depositor: Pubkey, // 32 bytes
pub amount: u64, // 8 bytes
pub last_deposit_time: i64, // 8 bytes
}
// ===== 事件 =====
#[event]
pub struct DepositEvent {
pub depositor: Pubkey,
pub amount: u64,
pub total_deposited: u64,
pub timestamp: i64,
}
#[event]
pub struct WithdrawEvent {
pub depositor: Pubkey,
pub amount: u64,
pub remaining: u64,
pub timestamp: i64,
}
// ===== 错误码 =====
#[error_code]
pub enum VaultError {
#[msg("Amount must be greater than zero")]
ZeroAmount,
#[msg("Insufficient balance for withdrawal")]
InsufficientBalance,
#[msg("Math overflow")]
MathOverflow,
}
CPI 调用流程图解
用户发起 deposit 交易
│
▼
┌──────────────────┐
│ Vault 程序 │
│ (我们写的程序) │
│ │
│ 1. 验证账户 │
│ 2. 更新状态 │
│ 3. 调用 CPI ─────┼──► ┌──────────────────┐
│ │ │ SPL Token 程序 │
│ │ │ (系统程序) │
│ │ │ │
│ │ │ 执行 token::transfer │
│ │ │ from → to │
│ │ └──────────────────┘
│ 4. 发出事件 │
└──────────────────┘
用户发起 withdraw 交易
│
▼
┌──────────────────┐
│ Vault 程序 │
│ │
│ 1. 验证账户 │
│ 2. 构建 PDA seeds│
│ 3. CPI + signer ─┼──► ┌──────────────────┐
│ seeds │ │ SPL Token 程序 │
│ │ │ │
│ │ │ 验证 PDA 签名 │
│ │ │ 执行转账 │
│ │ └──────────────────┘
│ 4. 更新状态 │
└──────────────────┘
TypeScript 测试
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Vault } from "../target/types/vault";
import {
createMint,
mintTo,
getOrCreateAssociatedTokenAccount,
getAccount,
} from "@solana/spl-token";
import { assert } from "chai";
describe("vault", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Vault as Program<Vault>;
const authority = provider.wallet as anchor.Wallet;
let tokenMint: anchor.web3.PublicKey;
let userTokenAccount: anchor.web3.PublicKey;
let vaultState: anchor.web3.PublicKey;
let vaultTokenAccount: anchor.web3.PublicKey;
before(async () => {
// 创建测试用 SPL Token
tokenMint = await createMint(
provider.connection,
authority.payer,
authority.publicKey,
null,
6 // 6 decimals
);
// 创建用户的 Token Account 并铸造代币
const ata = await getOrCreateAssociatedTokenAccount(
provider.connection,
authority.payer,
tokenMint,
authority.publicKey
);
userTokenAccount = ata.address;
// 铸造 1000 Token 给用户
await mintTo(
provider.connection,
authority.payer,
tokenMint,
userTokenAccount,
authority.publicKey,
1_000_000_000 // 1000 tokens (6 decimals)
);
// 推导 PDA 地址
[vaultState] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault_state"), authority.publicKey.toBuffer()],
program.programId
);
[vaultTokenAccount] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from("vault_token"), authority.publicKey.toBuffer()],
program.programId
);
});
it("初始化 Vault", async () => {
await program.methods
.initialize()
.accounts({
authority: authority.publicKey,
tokenMint: tokenMint,
vaultState: vaultState,
vaultTokenAccount: vaultTokenAccount,
})
.rpc();
const vault = await program.account.vaultState.fetch(vaultState);
assert.equal(vault.authority.toString(), authority.publicKey.toString());
assert.equal(vault.totalDeposited.toNumber(), 0);
console.log("Vault initialized successfully");
});
it("存入 500 Token", async () => {
const depositAmount = new anchor.BN(500_000_000); // 500 tokens
// 推导 deposit record PDA
const [depositRecord] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("deposit"),
vaultState.toBuffer(),
authority.publicKey.toBuffer(),
],
program.programId
);
// 监听事件
const listener = program.addEventListener("depositEvent", (event) => {
console.log("Deposit event:", {
depositor: event.depositor.toString(),
amount: event.amount.toNumber(),
totalDeposited: event.totalDeposited.toNumber(),
});
});
await program.methods
.deposit(depositAmount)
.accounts({
depositor: authority.publicKey,
vaultState: vaultState,
depositRecord: depositRecord,
depositorTokenAccount: userTokenAccount,
vaultTokenAccount: vaultTokenAccount,
})
.rpc();
// 验证 Vault Token Account 余额
const vaultAccount = await getAccount(
provider.connection,
vaultTokenAccount
);
assert.equal(Number(vaultAccount.amount), 500_000_000);
// 验证 vault state
const vault = await program.account.vaultState.fetch(vaultState);
assert.equal(vault.totalDeposited.toNumber(), 500_000_000);
await program.removeEventListener(listener);
});
it("提取 200 Token", async () => {
const withdrawAmount = new anchor.BN(200_000_000); // 200 tokens
const [depositRecord] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("deposit"),
vaultState.toBuffer(),
authority.publicKey.toBuffer(),
],
program.programId
);
await program.methods
.withdraw(withdrawAmount)
.accounts({
depositor: authority.publicKey,
vaultState: vaultState,
depositRecord: depositRecord,
depositorTokenAccount: userTokenAccount,
vaultTokenAccount: vaultTokenAccount,
})
.rpc();
// 验证余额
const vaultAccount = await getAccount(
provider.connection,
vaultTokenAccount
);
assert.equal(Number(vaultAccount.amount), 300_000_000); // 500 - 200
const record = await program.account.depositRecord.fetch(depositRecord);
assert.equal(record.amount.toNumber(), 300_000_000);
});
it("超额提取应失败", async () => {
const withdrawAmount = new anchor.BN(999_000_000); // 超过存款
const [depositRecord] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("deposit"),
vaultState.toBuffer(),
authority.publicKey.toBuffer(),
],
program.programId
);
try {
await program.methods
.withdraw(withdrawAmount)
.accounts({
depositor: authority.publicKey,
vaultState: vaultState,
depositRecord: depositRecord,
depositorTokenAccount: userTokenAccount,
vaultTokenAccount: vaultTokenAccount,
})
.rpc();
assert.fail("Should have thrown error");
} catch (err) {
assert.include(err.message, "InsufficientBalance");
}
});
});
CPI 安全注意事项
1. 验证被调用程序的 Program ID
// 危险:不验证 token_program 是否真的是 SPL Token 程序
pub token_program: AccountInfo<'info>,
// 安全:Anchor 自动验证
pub token_program: Program<'info, Token>, // 确保是官方 Token 程序
2. 权限传递的安全性
CPI 权限规则:
1. signer 权限可以通过 CPI 传递给被调用程序
2. PDA 签名只在"拥有" PDA 的程序内有效
3. 被调用程序无法伪造非其 PDA 的签名
3. CPI 深度限制
Solana CPI 最大深度: 4 层
Program A → CPI → Program B → CPI → Program C → CPI → Program D (OK)
Program A → CPI → ... → Program E (第5层,失败!)
实际开发中一般不超过 2 层 CPI
4. 计算单元预算
每个 CPI 调用消耗额外的计算单元
默认计算预算: 200,000 CU
Token Transfer CPI: ~3,000-5,000 CU
复杂 CPI 链可能需要请求更多计算预算:
// 在交易中添加 ComputeBudgetInstruction
ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 })
关键要点总结
- CPI 是 Solana 可组合性的基础: 程序之间通过 CPI 互相调用,就像 DeFi 乐高积木
- PDA 签名是 Solana 独特的权限机制: 程序可以让其 PDA "代签",实现无私钥的自动化操作
- Anchor 的 CpiContext 大幅简化 CPI 代码: 相比原生 Solana,Anchor 减少约 60% 的样板代码
- 所有账户必须显式传入: 这是 Solana 和 EVM 最大的设计差异,提高了并行性但增加了开发复杂度
- Token 操作一定通过 CPI: 你的程序不能直接修改 Token Account,必须通过调用 SPL Token 程序
常见误区
误区1: "PDA 有私钥,只是程序知道"
纠正: PDA 完全没有私钥。它是通过 seeds 确定性派生的地址,且保证不在 ed25519 曲线上。invoke_signed 不是"用私钥签名",而是 runtime 验证 seeds 能派生出该地址。
误区2: "CPI 和普通函数调用一样"
纠正: CPI 有严格的安全边界。被调用程序只能操作传入的账户,不能访问调用者的内部状态。权限也有严格规则——程序只能为自己的 PDA 签名。
误区3: "Token Account 的 owner 就是用户钱包"
纠正: Token Account 的 owner 字段是指有权转出代币的 authority。在 Vault 场景中,vault token account 的 owner 是 PDA,不是任何用户钱包。这让程序可以控制代币的转出逻辑。
面试关联
Q: "Solana 的 CPI 机制和以太坊的合约调用有什么区别?"
参考回答:
- 账户模型差异: Solana CPI 需要显式传入所有账户,EVM 可以直接读写 storage
- 权限模型: Solana 用 signer seeds 实现 PDA 签名,EVM 用 msg.sender 传递调用者身份
- 并行性: Solana 的显式账户列表让 runtime 可以并行执行不冲突的交易
- 深度限制: Solana 限制 4 层,EVM 无限(受 Gas 限制)
- 实际影响: Solana 需要更多前端工作(构建完整账户列表),但执行效率更高
Q: "如何在 Solana 上实现类似以太坊 approve + transferFrom 的模式?"
参考回答: Solana 的做法不同。不使用 approve 模式,而是:
- 用户签名交易 → 权限通过 CPI 传递给 Token 程序
- 或者用
delegate+transfer_checked实现类似效果 - 更常见的模式是让用户直接 transfer 到程序的 PDA 账户
参考资源
- Anchor CPI 文档 - 官方 CPI 教程
- SPL Token 程序源码 - Token 程序实现
- Solana Cookbook - CPI - CPI 实操指南
- Paulx Escrow Tutorial - 经典 Escrow 教程
- Anchor by Example - Anchor 示例集合