返回 SC 笔记
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


今日目标

  1. 理解 CPI(Cross-Program Invocation) 的原理与安全模型
  2. 使用 Anchor 实现 Vault 程序:接受 SPL Token 存入和提取
  3. 掌握 PDA 签名(signer seeds)在 CPI 中的使用
  4. 理解 CPI 深度限制和安全注意事项

核心概念

1. CPI(Cross-Program Invocation)跨程序调用

CPI 是 Solana 程序之间互相调用的机制。这类似于以太坊中合约调用另一个合约,但 Solana 的模型有本质区别。

Solana vs EVM 的程序调用对比

维度Solana CPIEVM 合约调用
状态访问必须显式传入所有账户可以访问任意存储槽
权限传递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 })

关键要点总结

  1. CPI 是 Solana 可组合性的基础: 程序之间通过 CPI 互相调用,就像 DeFi 乐高积木
  2. PDA 签名是 Solana 独特的权限机制: 程序可以让其 PDA "代签",实现无私钥的自动化操作
  3. Anchor 的 CpiContext 大幅简化 CPI 代码: 相比原生 Solana,Anchor 减少约 60% 的样板代码
  4. 所有账户必须显式传入: 这是 Solana 和 EVM 最大的设计差异,提高了并行性但增加了开发复杂度
  5. 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 机制和以太坊的合约调用有什么区别?"

参考回答:

  1. 账户模型差异: Solana CPI 需要显式传入所有账户,EVM 可以直接读写 storage
  2. 权限模型: Solana 用 signer seeds 实现 PDA 签名,EVM 用 msg.sender 传递调用者身份
  3. 并行性: Solana 的显式账户列表让 runtime 可以并行执行不冲突的交易
  4. 深度限制: Solana 限制 4 层,EVM 无限(受 Gas 限制)
  5. 实际影响: Solana 需要更多前端工作(构建完整账户列表),但执行效率更高

Q: "如何在 Solana 上实现类似以太坊 approve + transferFrom 的模式?"

参考回答: Solana 的做法不同。不使用 approve 模式,而是:

  • 用户签名交易 → 权限通过 CPI 传递给 Token 程序
  • 或者用 delegate + transfer_checked 实现类似效果
  • 更常见的模式是让用户直接 transfer 到程序的 PDA 账户

参考资源