返回 SC 笔记
SC Day 40

Solana/Anchor - SPL Token + Associated Token Account + Token 铸造程序

### 一、什么是 SPL Token?

2026-05-10
第二阶段:框架实战
SolanaAnchorSPLTokenATAMintTokenProgramCPI

日期: 2026-05-10 方向: Solana 阶段: 第二阶段:框架实战 标签: #Solana #Anchor #SPLToken #ATA #Mint #TokenProgram #CPI


今日目标

类型内容
学习深入理解 SPL Token 架构:Mint Account、Token Account、ATA 的关系
实操用 Anchor 编写一个完整的 Token 铸造程序(创建 mint、创建 ATA、铸造代币)
产出Token 铸造程序 + SPL Token vs ERC20 对比分析 + 架构图

核心概念

一、什么是 SPL Token?

SPL (Solana Program Library) Token 是 Solana 上的代币标准,类似于 Ethereum 上的 ERC20。但两者的架构设计有根本性差异。

Ethereum ERC20:
  每个代币 = 一个独立的智能合约
  USDC 合约、WETH 合约、UNI 合约... 各自独立
  每个合约内部维护 mapping(address => uint256) balances

Solana SPL Token:
  所有代币共用一个 Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA)
  每种代币 = 一个 Mint Account (记录总量、精度等)
  每个用户持仓 = 一个独立的 Token Account (存储余额)

为什么这样设计?

ERC20 的问题:
  - 每发一个代币就要部署一个合约 → Gas 高昂
  - 每个合约的实现可能不同 → 安全风险(恶意 transfer 函数)
  - 需要逐个审计每个代币合约

SPL Token 的优势:
  - 一个程序处理所有代币 → 经过充分审计,高度安全
  - 发币 = 创建一个 Mint Account → 不需要写任何代码
  - 所有代币的 transfer/approve/burn 行为完全一致
  - 钱包和 DEX 可以统一处理所有 SPL Token

二、SPL Token 的三层账户结构

SPL Token 系统由三种关键的 Account 组成:

                    SPL Token 架构
                    ═══════════════

┌────────────────────────┐
│      Mint Account      │  ← 代币的"身份证"
│  ───────────────────── │
│  supply: 1,000,000     │  总供应量
│  decimals: 6           │  精度 (USDC 是 6)
│  mint_authority: PDA   │  谁能铸造新代币
│  freeze_authority: ... │  谁能冻结账户
│  owner: Token Program  │  由 Token Program 管理
└────────────────────────┘
           │
           │ 一个 Mint 可以有多个 Token Account
           │
     ┌─────┴──────┐
     ▼            ▼
┌──────────┐  ┌──────────┐
│ Token    │  │ Token    │  ← 用户的"代币钱包"
│ Account  │  │ Account  │
│ ──────── │  │ ──────── │
│ mint: X  │  │ mint: X  │  关联到哪个 Mint
│ owner:   │  │ owner:   │  谁拥有这个账户
│  Alice   │  │  Bob     │
│ amount:  │  │ amount:  │  余额
│  500     │  │  300     │
└──────────┘  └──────────┘

     │
     │ 特殊类型: Associated Token Account (ATA)
     ▼
┌──────────────────────────────┐
│  ATA = 确定性推导的 Token Account  │
│  地址 = f(wallet, mint, token_program)  │
│  每个 (wallet, mint) 对有且仅有一个 ATA  │
└──────────────────────────────┘

三、Mint Account 详解

Mint Account 是代币的元数据,类似于 ERC20 合约本身:

// Mint Account 数据结构 (简化)
pub struct Mint {
    pub mint_authority: COption<Pubkey>,   // 谁能铸造新代币 (可为 None → 无法增发)
    pub supply: u64,                       // 当前总供应量
    pub decimals: u8,                      // 精度位数
    pub is_initialized: bool,              // 是否已初始化
    pub freeze_authority: COption<Pubkey>, // 谁能冻结账户 (合规需求)
}

// Mint Account 大小: 82 bytes

Mint Authority 的重要性

mint_authority 控制谁能增发代币:

场景 1: mint_authority = 某个多签钱包
  → 团队/DAO 可以增发代币(如奖励分发)
  → 用户需要信任 authority 不会无限增发

场景 2: mint_authority = PDA
  → 只有程序逻辑可以增发(如 Staking 奖励)
  → 增发规则被代码锁定,无法人为干预

场景 3: mint_authority = None (已放弃)
  → 没有人能再增发 → 供应量固定
  → 类似于 BTC 的固定供应

PM 视角: 代币的"去中心化程度"取决于 authority 的设置
  authority 是多签? 还是 PDA? 还是已放弃?
  → 这直接影响用户对项目的信任度

四、Token Account 详解

Token Account 存储某个用户对某种代币的余额:

// Token Account 数据结构 (简化)
pub struct TokenAccount {
    pub mint: Pubkey,            // 哪种代币
    pub owner: Pubkey,           // 谁拥有 (可以转账)
    pub amount: u64,             // 余额
    pub delegate: COption<Pubkey>,  // 委托 (类似 ERC20 的 approve)
    pub state: AccountState,     // 状态 (Initialized/Frozen)
    pub is_native: COption<u64>, // 是否是 wrapped SOL
    pub delegated_amount: u64,   // 委托额度
    pub close_authority: COption<Pubkey>, // 谁能关闭此账户
}

// Token Account 大小: 165 bytes

关键理解:一个用户可以有多个 Token Account

Alice 持有 USDC:
  Alice 可以有多个存储 USDC 的 Token Account:
  - Token Account #1: 500 USDC (随机地址)
  - Token Account #2: 300 USDC (ATA 地址)
  - Token Account #3: 200 USDC (另一个随机地址)

  但哪个是"官方的"? → ATA!

五、Associated Token Account (ATA) 详解

ATA 解决了一个关键问题:如何确定性地找到某个用户对某种代币的账户地址?

问题:
  Alice 要给 Bob 转 USDC
  Alice 需要知道 Bob 的 USDC Token Account 地址
  但 Bob 可能还没有创建 Token Account!

没有 ATA 的世界:
  1. Bob 手动创建 Token Account (随机地址)
  2. Bob 把地址告诉 Alice
  3. Alice 转账到这个地址
  → 麻烦! 用户体验差!

有了 ATA 的世界:
  1. ATA 地址 = findProgramAddress([wallet, token_program, mint], ata_program)
  2. Alice 知道 Bob 的钱包地址 + USDC 的 Mint 地址
  3. Alice 可以直接计算出 Bob 的 ATA 地址
  4. 如果 ATA 不存在 → Alice 可以帮 Bob 创建(付 rent)
  → 简单! 就像 Ethereum 直接给地址转账一样

ATA 的确定性:
  每个 (wallet, mint) 对 → 恰好一个 ATA 地址
  所有客户端都能独立计算出来 → 无需额外的地址发现机制

ATA 地址推导

import { getAssociatedTokenAddressSync } from "@solana/spl-token";

// 计算 ATA 地址
const ata = getAssociatedTokenAddressSync(
  mintAddress,       // 代币的 Mint 地址
  walletAddress,     // 用户的钱包地址
  false,             // allowOwnerOffCurve: false = 普通钱包, true = PDA
  TOKEN_PROGRAM_ID,
  ASSOCIATED_TOKEN_PROGRAM_ID
);

// ATA 的 seeds:
// [walletAddress, TOKEN_PROGRAM_ID, mintAddress]
// Program: ASSOCIATED_TOKEN_PROGRAM_ID

六、SPL Token vs ERC20 完整对比

维度ERC20 (Ethereum)SPL Token (Solana)
代币创建部署一个新的智能合约创建一个 Mint Account
成本~$50-$500 (部署合约 Gas)~$0.002 (创建 Account rent)
代码需要写 Solidity 合约不需要写任何代码
安全性每个合约可能有不同的 bug所有代币共用审计过的程序
余额存储合约内 mapping独立的 Token Account
转账调用token.transfer(to, amount)Token Program CPI
授权机制approve + transferFromdelegate + CPI transfer
地址发现代币合约地址 + balanceOf(user)Mint 地址 + ATA 推导
冻结功能需要额外实现内置 freeze_authority
精度通常 18 decimals自定义 (USDC=6, SOL=9)
代币元数据合约内 name()/symbol()Metaplex Metadata 标准
可升级性取决于 proxy 模式Token Program 不可变

深层差异

ERC20 的灵活性 vs SPL Token 的安全性:

ERC20 可以做到但 SPL Token 标准程序不能:
  - 自定义转账逻辑(如转账税、自动销毁)
  - Rebasing(如 aToken/stETH 余额自动变化)
  - 自定义 hook(如转账时自动做某些操作)
  → ERC20 更灵活,但也更危险

SPL Token 可以做到但 ERC20 不容易:
  - 原生冻结功能(合规需求)
  - 原子性多 Token 操作(一个 Transaction 多个 Instruction)
  - 并行转账(不同 Token Account 可以并行修改)
  → SPL Token 更安全、更高效,但灵活性受限

Token-2022 (Token Extensions):
  Solana 推出了 Token-2022 程序,增加了:
  - Transfer Fee (转账税)
  - Confidential Transfers (隐私转账)
  - Transfer Hook (自定义转账逻辑)
  - Non-Transferable (灵魂绑定代币)
  → 逐步弥补灵活性差距

代码实战

Token 铸造程序:完整 Anchor 实现

项目结构

token-factory/
├── programs/token-factory/src/
│   └── lib.rs              ← 程序逻辑
├── tests/
│   └── token-factory.ts    ← TypeScript 测试
├── Anchor.toml
└── Cargo.toml

programs/token-factory/src/lib.rs

use anchor_lang::prelude::*;
use anchor_spl::{
    associated_token::AssociatedToken,
    token::{self, Mint, MintTo, Token, TokenAccount, Transfer},
};

declare_id!("TkF1111111111111111111111111111111111111111");

#[program]
pub mod token_factory {
    use super::*;

    /// Step 1: 创建新的代币 Mint
    /// mint_authority 设置为 PDA → 只有程序能铸造
    pub fn create_token(
        ctx: Context<CreateToken>,
        decimals: u8,
    ) -> Result<()> {
        msg!("Token created!");
        msg!("Mint address: {}", ctx.accounts.mint.key());
        msg!("Decimals: {}", decimals);
        msg!(
            "Mint authority (PDA): {}",
            ctx.accounts.mint_authority.key()
        );

        // Anchor 的 init 约束已经自动:
        // 1. 创建 Mint Account
        // 2. 调用 Token Program 的 initialize_mint
        // 3. 设置 decimals 和 mint_authority
        // 我们不需要手动做任何事!

        // 保存元数据到我们自己的 PDA
        let token_info = &mut ctx.accounts.token_info;
        token_info.mint = ctx.accounts.mint.key();
        token_info.creator = ctx.accounts.creator.key();
        token_info.decimals = decimals;
        token_info.total_minted = 0;
        token_info.bump = ctx.bumps.token_info;
        token_info.mint_authority_bump = ctx.bumps.mint_authority;

        Ok(())
    }

    /// Step 2: 给用户铸造代币
    /// 自动创建 ATA(如果不存在)
    pub fn mint_tokens(
        ctx: Context<MintTokens>,
        amount: u64,
    ) -> Result<()> {
        require!(amount > 0, ErrorCode::ZeroAmount);

        // 更新铸造统计
        let token_info = &mut ctx.accounts.token_info;
        token_info.total_minted = token_info.total_minted
            .checked_add(amount)
            .ok_or(ErrorCode::Overflow)?;

        msg!("Minting {} tokens to {}", amount, ctx.accounts.recipient.key());

        // CPI: 调用 Token Program 的 mint_to
        // 使用 PDA (mint_authority) 作为签名者
        let mint_key = ctx.accounts.mint.key();
        let seeds = &[
            b"mint_authority",
            mint_key.as_ref(),
            &[ctx.accounts.token_info.mint_authority_bump],
        ];
        let signer_seeds = &[&seeds[..]];

        let cpi_ctx = CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            MintTo {
                mint: ctx.accounts.mint.to_account_info(),
                to: ctx.accounts.recipient_ata.to_account_info(),
                authority: ctx.accounts.mint_authority.to_account_info(),
            },
            signer_seeds,
        );

        token::mint_to(cpi_ctx, amount)?;

        msg!(
            "Minted! Total supply now: {}",
            ctx.accounts.mint.supply + amount
        );

        Ok(())
    }

    /// Step 3: 代币转账
    /// 从发送者的 ATA 转到接收者的 ATA
    pub fn transfer_tokens(
        ctx: Context<TransferTokens>,
        amount: u64,
    ) -> Result<()> {
        require!(amount > 0, ErrorCode::ZeroAmount);

        msg!(
            "Transferring {} tokens from {} to {}",
            amount,
            ctx.accounts.sender.key(),
            ctx.accounts.recipient.key()
        );

        // CPI: 调用 Token Program 的 transfer
        // 发送者(Signer)作为 authority
        let cpi_ctx = CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            Transfer {
                from: ctx.accounts.sender_ata.to_account_info(),
                to: ctx.accounts.recipient_ata.to_account_info(),
                authority: ctx.accounts.sender.to_account_info(),
            },
        );

        token::transfer(cpi_ctx, amount)?;

        Ok(())
    }
}

// ==================== 账户结构 ====================

/// 代币元数据(存储在 PDA 中)
#[account]
pub struct TokenInfo {
    pub mint: Pubkey,              // 32: 关联的 Mint 地址
    pub creator: Pubkey,           // 32: 谁创建了这个代币
    pub decimals: u8,              // 1:  精度
    pub total_minted: u64,         // 8:  总铸造量
    pub bump: u8,                  // 1:  token_info PDA bump
    pub mint_authority_bump: u8,   // 1:  mint_authority PDA bump
}
// Space: 8 + 32 + 32 + 1 + 8 + 1 + 1 = 83

// ==================== 指令账户约束 ====================

/// CreateToken: 创建新的 Mint + TokenInfo PDA
#[derive(Accounts)]
#[instruction(decimals: u8)]
pub struct CreateToken<'info> {
    /// Mint Account — 代币的"身份"
    #[account(
        init,
        payer = creator,
        mint::decimals = decimals,
        mint::authority = mint_authority,  // PDA 作为 mint authority
    )]
    pub mint: Account<'info, Mint>,

    /// Mint Authority PDA — 控制铸造权限
    /// CHECK: PDA 只用于签名,不存储数据
    #[account(
        seeds = [b"mint_authority", mint.key().as_ref()],
        bump
    )]
    pub mint_authority: UncheckedAccount<'info>,

    /// Token Info PDA — 存储自定义元数据
    #[account(
        init,
        payer = creator,
        space = 8 + 32 + 32 + 1 + 8 + 1 + 1,
        seeds = [b"token_info", mint.key().as_ref()],
        bump
    )]
    pub token_info: Account<'info, TokenInfo>,

    /// 创建者(支付 rent)
    #[account(mut)]
    pub creator: Signer<'info>,

    /// 必需的系统程序
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
}

/// MintTokens: 铸造代币到用户的 ATA
#[derive(Accounts)]
pub struct MintTokens<'info> {
    /// Mint Account
    #[account(mut)]  // mut 因为 supply 会增加
    pub mint: Account<'info, Mint>,

    /// Mint Authority PDA
    /// CHECK: PDA 签名验证
    #[account(
        seeds = [b"mint_authority", mint.key().as_ref()],
        bump = token_info.mint_authority_bump
    )]
    pub mint_authority: UncheckedAccount<'info>,

    /// Token Info PDA
    #[account(
        mut,
        seeds = [b"token_info", mint.key().as_ref()],
        bump = token_info.bump,
        has_one = creator  // 只有创建者能铸造
    )]
    pub token_info: Account<'info, TokenInfo>,

    /// 接收者的 ATA — init_if_needed 自动创建
    #[account(
        init_if_needed,                         // 如果 ATA 不存在则创建
        payer = creator,                        // 创建者支付 rent
        associated_token::mint = mint,          // 关联到这个 Mint
        associated_token::authority = recipient, // 属于 recipient
    )]
    pub recipient_ata: Account<'info, TokenAccount>,

    /// 接收者(不需要签名!创建者可以给任何人铸造)
    /// CHECK: 任何有效的公钥都可以作为接收者
    pub recipient: UncheckedAccount<'info>,

    /// 创建者/铸造者
    #[account(mut)]
    pub creator: Signer<'info>,

    /// 必需的程序
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
}

/// TransferTokens: 代币转账
#[derive(Accounts)]
pub struct TransferTokens<'info> {
    pub mint: Account<'info, Mint>,

    /// 发送者的 ATA
    #[account(
        mut,
        associated_token::mint = mint,
        associated_token::authority = sender,
    )]
    pub sender_ata: Account<'info, TokenAccount>,

    /// 接收者的 ATA — 自动创建
    #[account(
        init_if_needed,
        payer = sender,
        associated_token::mint = mint,
        associated_token::authority = recipient,
    )]
    pub recipient_ata: Account<'info, TokenAccount>,

    /// 发送者(必须签名)
    #[account(mut)]
    pub sender: Signer<'info>,

    /// 接收者
    /// CHECK: 任何有效公钥
    pub recipient: UncheckedAccount<'info>,

    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
}

// ==================== 错误码 ====================

#[error_code]
pub enum ErrorCode {
    #[msg("Amount must be greater than zero")]
    ZeroAmount,
    #[msg("Arithmetic overflow")]
    Overflow,
}

代码关键设计解析

1. Mint Authority 使用 PDA

/// CHECK: PDA 只用于签名,不存储数据
#[account(
    seeds = [b"mint_authority", mint.key().as_ref()],
    bump
)]
pub mint_authority: UncheckedAccount<'info>,
为什么用 PDA 作为 mint_authority?

方案 A: 用用户钱包作为 authority
  → 这个用户可以随时铸造无限代币
  → 中心化风险!

方案 B: 用 PDA 作为 authority
  → 只有程序逻辑能触发铸造
  → 可以在程序中添加限制条件:
    - 只有 creator 能触发
    - 有上限检查
    - 有时间锁
    - 需要多签
  → 去中心化和可控的平衡

方案 C: 放弃 authority (set to None)
  → 永远无法增发
  → 适用于固定供应的代币

2. CPI (Cross-Program Invocation) 与 PDA 签名

// PDA 签名 CPI 的完整流程
let mint_key = ctx.accounts.mint.key();
let seeds = &[
    b"mint_authority",           // seed 1: 字符串标识
    mint_key.as_ref(),           // seed 2: mint 地址
    &[ctx.accounts.token_info.mint_authority_bump],  // bump
];
let signer_seeds = &[&seeds[..]];

let cpi_ctx = CpiContext::new_with_signer(
    ctx.accounts.token_program.to_account_info(),  // 被调用的程序
    MintTo {                                        // 指令参数
        mint: ctx.accounts.mint.to_account_info(),
        to: ctx.accounts.recipient_ata.to_account_info(),
        authority: ctx.accounts.mint_authority.to_account_info(),
    },
    signer_seeds,  // PDA "签名"
);

token::mint_to(cpi_ctx, amount)?;
CPI 执行过程:

1. 我们的程序准备 seeds + bump
2. 调用 token::mint_to (这是 Anchor 对 Token Program 的封装)
3. Token Program 收到请求:
   "请铸造 amount 个代币到 recipient_ata"
   "authority 是 mint_authority"
4. Token Program 验证:
   - mint_authority 是否是 mint 的 authority? ✓
   - mint_authority 是否签了名?
     → 检查 signer_seeds: SHA256(seeds, bump, calling_program_id) == mint_authority? ✓
5. 验证通过 → 铸造代币 → supply += amount

3. init_if_needed 约束

#[account(
    init_if_needed,                         // 如果不存在则创建
    payer = creator,                        // 谁付 rent
    associated_token::mint = mint,          // ATA 关联的 mint
    associated_token::authority = recipient, // ATA 属于谁
)]
pub recipient_ata: Account<'info, TokenAccount>,
init_if_needed 的行为:

如果 ATA 已存在:
  → 直接使用,不创建
  → 验证 mint 和 owner 是否匹配

如果 ATA 不存在:
  → 自动创建 ATA
  → payer 支付 rent (约 0.002 SOL)
  → 设置 mint 和 owner

注意: 使用 init_if_needed 需要在 Cargo.toml 中启用 feature:
  anchor-lang = { version = "...", features = ["init-if-needed"] }

安全考虑:
  init_if_needed 可能导致重放攻击(某些场景下)
  但对于 ATA 创建来说是安全的,因为 ATA 地址是确定性的

TypeScript 测试

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { TokenFactory } from "../target/types/token_factory";
import {
  getAssociatedTokenAddressSync,
  getAccount,
  TOKEN_PROGRAM_ID,
  ASSOCIATED_TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { assert } from "chai";

describe("token-factory", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.TokenFactory as Program<TokenFactory>;
  const creator = provider.wallet;

  // 生成一个新的 Keypair 作为 Mint Account
  // (Mint 不是 PDA,而是一个随机生成的 Keypair)
  const mintKeypair = anchor.web3.Keypair.generate();
  const mint = mintKeypair.publicKey;

  // 计算 PDA 地址
  const [mintAuthority] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("mint_authority"), mint.toBuffer()],
    program.programId
  );

  const [tokenInfo] = anchor.web3.PublicKey.findProgramAddressSync(
    [Buffer.from("token_info"), mint.toBuffer()],
    program.programId
  );

  // 生成接收者
  const recipient = anchor.web3.Keypair.generate();

  const DECIMALS = 6; // 和 USDC 一样的精度

  it("creates a new token", async () => {
    const tx = await program.methods
      .createToken(DECIMALS)
      .accounts({
        mint: mint,
        mintAuthority: mintAuthority,
        tokenInfo: tokenInfo,
        creator: creator.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
        tokenProgram: TOKEN_PROGRAM_ID,
      })
      .signers([mintKeypair]) // Mint keypair 需要签名(因为它是 init 创建的)
      .rpc();

    console.log("Create token tx:", tx);
    console.log("Mint address:", mint.toBase58());

    // 验证 TokenInfo
    const info = await program.account.tokenInfo.fetch(tokenInfo);
    assert.equal(info.mint.toBase58(), mint.toBase58());
    assert.equal(info.creator.toBase58(), creator.publicKey.toBase58());
    assert.equal(info.decimals, DECIMALS);
    assert.equal(info.totalMinted.toNumber(), 0);

    console.log("Token created with decimals:", info.decimals);
  });

  it("mints tokens to creator", async () => {
    const amount = 1_000_000 * 10 ** DECIMALS; // 1,000,000 tokens

    // 计算 creator 的 ATA
    const creatorAta = getAssociatedTokenAddressSync(
      mint,
      creator.publicKey
    );

    const tx = await program.methods
      .mintTokens(new anchor.BN(amount))
      .accounts({
        mint: mint,
        mintAuthority: mintAuthority,
        tokenInfo: tokenInfo,
        recipientAta: creatorAta,
        recipient: creator.publicKey,
        creator: creator.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
        tokenProgram: TOKEN_PROGRAM_ID,
        associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
      })
      .rpc();

    console.log("Mint tokens tx:", tx);

    // 验证余额
    const ataAccount = await getAccount(provider.connection, creatorAta);
    assert.equal(
      Number(ataAccount.amount),
      amount
    );
    console.log("Creator balance:", Number(ataAccount.amount) / 10 ** DECIMALS);

    // 验证 total_minted
    const info = await program.account.tokenInfo.fetch(tokenInfo);
    assert.equal(info.totalMinted.toNumber(), amount);
  });

  it("mints tokens to another user", async () => {
    const amount = 500_000 * 10 ** DECIMALS;

    const recipientAta = getAssociatedTokenAddressSync(
      mint,
      recipient.publicKey
    );

    const tx = await program.methods
      .mintTokens(new anchor.BN(amount))
      .accounts({
        mint: mint,
        mintAuthority: mintAuthority,
        tokenInfo: tokenInfo,
        recipientAta: recipientAta,
        recipient: recipient.publicKey,
        creator: creator.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
        tokenProgram: TOKEN_PROGRAM_ID,
        associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
      })
      .rpc();

    console.log("Mint to recipient tx:", tx);

    // 验证接收者余额
    const ataAccount = await getAccount(provider.connection, recipientAta);
    assert.equal(
      Number(ataAccount.amount),
      amount
    );
    console.log(
      "Recipient balance:",
      Number(ataAccount.amount) / 10 ** DECIMALS
    );
  });

  it("transfers tokens between users", async () => {
    const amount = 100_000 * 10 ** DECIMALS;

    const creatorAta = getAssociatedTokenAddressSync(
      mint,
      creator.publicKey
    );
    const recipientAta = getAssociatedTokenAddressSync(
      mint,
      recipient.publicKey
    );

    // 转账前余额
    const beforeCreator = await getAccount(provider.connection, creatorAta);
    const beforeRecipient = await getAccount(provider.connection, recipientAta);

    const tx = await program.methods
      .transferTokens(new anchor.BN(amount))
      .accounts({
        mint: mint,
        senderAta: creatorAta,
        recipientAta: recipientAta,
        sender: creator.publicKey,
        recipient: recipient.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
        tokenProgram: TOKEN_PROGRAM_ID,
        associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
      })
      .rpc();

    console.log("Transfer tx:", tx);

    // 验证转账后余额
    const afterCreator = await getAccount(provider.connection, creatorAta);
    const afterRecipient = await getAccount(provider.connection, recipientAta);

    assert.equal(
      Number(afterCreator.amount),
      Number(beforeCreator.amount) - amount
    );
    assert.equal(
      Number(afterRecipient.amount),
      Number(beforeRecipient.amount) + amount
    );

    console.log(
      "Creator balance after:",
      Number(afterCreator.amount) / 10 ** DECIMALS
    );
    console.log(
      "Recipient balance after:",
      Number(afterRecipient.amount) / 10 ** DECIMALS
    );
  });

  it("fails to mint when not creator", async () => {
    const amount = 1000 * 10 ** DECIMALS;
    const imposter = anchor.web3.Keypair.generate();

    // 给冒充者一些 SOL (用于 tx 费用)
    const airdropSig = await provider.connection.requestAirdrop(
      imposter.publicKey,
      1 * anchor.web3.LAMPORTS_PER_SOL
    );
    await provider.connection.confirmTransaction(airdropSig);

    const imposterAta = getAssociatedTokenAddressSync(
      mint,
      imposter.publicKey
    );

    try {
      await program.methods
        .mintTokens(new anchor.BN(amount))
        .accounts({
          mint: mint,
          mintAuthority: mintAuthority,
          tokenInfo: tokenInfo,
          recipientAta: imposterAta,
          recipient: imposter.publicKey,
          creator: imposter.publicKey, // 冒充 creator
          systemProgram: anchor.web3.SystemProgram.programId,
          tokenProgram: TOKEN_PROGRAM_ID,
          associatedTokenProgram: ASSOCIATED_TOKEN_PROGRAM_ID,
        })
        .signers([imposter])
        .rpc();

      assert.fail("Should have thrown an error");
    } catch (err) {
      // has_one = creator 约束失败
      console.log("Expected: unauthorized mint attempt blocked");
    }
  });
});

SPL Token 操作的完整流程图

创建代币并铸造的完整流程:

用户 (Creator)
    │
    ├─── 1. create_token(decimals=6)
    │    │
    │    ├── 创建 Mint Account (随机 Keypair)
    │    │     ├── decimals: 6
    │    │     ├── supply: 0
    │    │     └── mint_authority: PDA("mint_authority", mint)
    │    │
    │    └── 创建 TokenInfo PDA
    │          ├── mint: 上面的 Mint 地址
    │          ├── creator: 用户公钥
    │          └── total_minted: 0
    │
    ├─── 2. mint_tokens(amount=1000000)
    │    │
    │    ├── 创建 Recipient ATA (init_if_needed)
    │    │     ├── mint: 上面的 Mint
    │    │     ├── owner: recipient 公钥
    │    │     └── amount: 0 (初始)
    │    │
    │    └── CPI: Token Program.mint_to()
    │          ├── 签名者: mint_authority PDA (invoke_signed)
    │          ├── mint.supply += 1000000
    │          └── recipient_ata.amount += 1000000
    │
    └─── 3. transfer_tokens(amount=100000)
         │
         ├── CPI: Token Program.transfer()
         │     ├── 签名者: sender (用户自己)
         │     ├── sender_ata.amount -= 100000
         │     └── recipient_ata.amount += 100000
         │
         └── 验证: sender_ata.amount >= amount

关键要点总结

SPL Token 核心知识清单

1. 三层结构:
   Mint Account → 代币身份 (supply, decimals, authority)
   Token Account → 用户持仓 (mint, owner, amount)
   ATA → 确定性的 Token Account (每个 wallet+mint 一个)

2. 权限模型:
   mint_authority → 谁能铸造
   freeze_authority → 谁能冻结
   Token Account owner → 谁能转账
   delegate → 被授权者(类似 ERC20 approve)

3. ATA 的重要性:
   → 确定性地址 = 不需要"注册"就能收款
   → 一个 (wallet, mint) 对只有一个 ATA
   → 客户端和程序都能独立计算 ATA 地址

4. CPI 铸造流程:
   → mint_authority 是 PDA → 程序通过 invoke_signed 签名
   → seeds + bump 就是 PDA 的"签名"
   → Token Program 验证签名后执行铸造

5. Rent:
   → Mint Account: ~0.0015 SOL
   → Token Account: ~0.002 SOL
   → 谁创建谁付 rent(或 payer 指定)

发币对比速查

在 Ethereum 上发币:
  1. 写 Solidity 合约 (至少 50 行)
  2. 编译 + 部署到链上 (~$50-500 gas)
  3. 验证合约源码 (在 Etherscan)
  4. 用户交互: token.transfer(to, amount)

在 Solana 上发币:
  1. 用 spl-token CLI:
     spl-token create-token --decimals 6
     → 返回 Mint 地址 (一条命令搞定!)
  2. 铸造: spl-token mint <mint> <amount>
  3. 成本: ~$0.002
  4. 用户交互: 通过 Token Program CPI

  或者用 Anchor (如我们今天的代码):
  → 可以添加自定义逻辑(权限控制、铸造上限等)

常见误区

误区 1: "SPL Token Account 就是用户的钱包"

✗ 错误: 用户的钱包地址 = Token Account 地址
✓ 正确: 它们是不同的东西

用户钱包 (System Account):
  → 存储 SOL
  → 地址就是用户的公钥

Token Account:
  → 存储 SPL Token 余额
  → 是一个独立的 Account,有自己的地址
  → owner 字段指向用户的钱包地址

类比:
  钱包地址 = 你的身份证号
  Token Account = 你在某个银行开的账户
  ATA = 你在某个银行的默认账户(确定性的)

误区 2: "Mint Account 需要用 PDA 创建"

✗ 错误: Mint Account 必须是 PDA
✓ 正确: Mint Account 通常是普通 Keypair

Mint Account 的地址:
  → 可以是随机 Keypair(最常见)
  → 也可以是 PDA(某些特殊场景)

Keypair Mint 的优点:
  → 地址唯一且不可预测
  → 创建时只需要签名

PDA Mint 的使用场景:
  → 需要确定性地址(如"每个 pool 对应一个 LP Token mint")
  → seeds = ["lp_mint", pool_address]
  → 任何人都能计算出 LP Token 的 Mint 地址

我们代码中:
  Mint → Keypair (随机)
  mint_authority → PDA (确定性)
  token_info → PDA (确定性)

误区 3: "SPL Token 不如 ERC20 灵活"

✗ 过度简化: SPL Token 什么自定义逻辑都做不了
✓ 正确理解: SPL Token 标准操作统一,自定义逻辑通过包装程序实现

方式 1: 包装程序 (Wrapper Program)
  → 用户不直接调用 Token Program
  → 而是调用你的程序 → 你的程序内部 CPI 调用 Token Program
  → 可以在 CPI 前后添加任何自定义逻辑
  → 这就是我们今天 token_factory 的模式!

方式 2: Token-2022 Extensions
  → Transfer Fee: 每次转账自动收取费用
  → Transfer Hook: 转账时调用自定义程序
  → Confidential Transfer: 金额加密
  → Permanent Delegate: 永久授权(可用于资产冻结/回收)

实际上,SPL Token + 包装程序 可以实现 ERC20 的所有功能
只是实现方式从"在代币合约内"变成了"在外部程序中"

误区 4: "每次转账都需要创建 ATA"

✗ 错误: 每次转账前都要 init ATA
✓ 正确: ATA 只需要创建一次,之后可以重复使用

ATA 生命周期:
  1. 首次接收某种代币时创建(谁创建谁付 rent)
  2. 之后所有该代币的操作都用这个 ATA
  3. 如果不再需要 → 可以 close ATA → 回收 rent (SOL)

init_if_needed 的妙处:
  → 如果 ATA 存在 → 直接使用
  → 如果 ATA 不存在 → 自动创建
  → 调用者不需要判断 ATA 是否存在
  → 但 payer 需要有足够的 SOL 支付潜在的 rent

面试关联

Q1: 解释 Solana 上 SPL Token 和 Ethereum 上 ERC20 的区别

30 秒回答: ERC20 是每个代币一个独立合约,开发者需要写 Solidity 代码部署;SPL Token 是所有代币共用一个 Token Program,创建代币只需创建一个 Mint Account,不需要写任何合约代码。SPL Token 用 ATA (Associated Token Account) 实现确定性地址发现。核心区别是:ERC20 灵活但每个合约可能有不同的安全风险,SPL Token 行为统一且经过充分审计,但灵活性靠外部程序扩展。

追问: 这种设计差异对用户体验有什么影响?

ERC20 用户体验:
  → 转账: 直接调 token.transfer(to, amount)
  → 添加代币: 在钱包中"添加代币"(输入合约地址)
  → approve 问题: 很多操作需要先 approve 再交互(两步)

SPL Token 用户体验:
  → 转账: 需要知道/创建 ATA → 钱包抽象了这一层
  → 添加代币: 自动发现(钱包扫描所有 ATA)
  → 无需 approve: 用户直接签名授权(一步)
  → Rent 问题: 首次接收代币需要付 ~0.002 SOL 创建 ATA

PM 洞察:
  Solana 钱包(如 Phantom)做了大量 UX 封装
  → 自动创建 ATA、自动处理 rent
  → 用户感知不到底层的复杂性
  → 这就是"好的 UX = 隐藏复杂性"

Q2: 什么是 ATA?为什么 Solana 需要它?

ATA = Associated Token Account
  = 一个用户对一种代币的"默认"Token Account
  = 地址由 (wallet, mint, token_program) 确定性推导

为什么需要:
  Ethereum: address 就是 address,所有 ERC20 余额都"在"这个地址上
  Solana: 每种代币的余额在独立的 Token Account 中
         → 需要一种方式找到"某个用户的某种代币的 Account"
         → ATA 提供了这种确定性寻址能力

没有 ATA:
  "给 Bob 转 100 USDC" → "Bob 的 USDC Token Account 地址是什么?" → 需要问 Bob
有了 ATA:
  "给 Bob 转 100 USDC" → 计算 ATA(Bob, USDC) → 直接转

Q3: 如果让你设计一个 Solana 上的代币发行平台(类似 pump.fun),你会如何设计?

核心架构:
1. Factory Program — 创建代币
   → create_token(name, symbol, decimals, initial_supply)
   → Mint Authority = PDA (程序控制)
   → 自动创建 bonding curve pool

2. Bonding Curve — 自动定价
   → 类似 AMM,但价格随购买量上升
   → 买入 → 价格上涨,卖出 → 价格下跌
   → 达到阈值 → 自动迁移到 Raydium

3. ATA 自动创建
   → 用 init_if_needed
   → 买家不需要预先创建 Token Account

4. Metadata 集成
   → Metaplex Token Metadata
   → 存储 name/symbol/image URI

5. 安全:
   → mint_authority 放弃 → 创建者不能增发
   → LP 自动锁定 → 防止 rug pull

6. 费用模型:
   → 创建费: 固定 SOL (防止 spam)
   → 交易费: 1% 进入协议金库
   → LP 迁移时收取手续费

参考资源

资源说明
SPL Token 文档SPL Token Program 官方文档
Token-2022新一代 Token 标准及 Extensions
Anchor SPL 文档Anchor 中 SPL Token 的使用
Solana Cookbook - TokensToken 操作代码示例集合
Metaplex Token Metadata代币元数据标准
ATA 文档Associated Token Account 详解