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 + transferFrom | delegate + 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 - Tokens | Token 操作代码示例集合 |
| Metaplex Token Metadata | 代币元数据标准 |
| ATA 文档 | Associated Token Account 详解 |