完整实战项目——代币发行与管理系统
现在让我们把学到的知识整合成一个完整的项目。这个项目将实现一个代币发行平台,支持创建代币、铸造、转账和销毁等功能。
通过这个项目,你将学会如何:
- 设计合理的账户结构和状态管理
- 实现多个相互关联的指令
- 使用 PDA 进行权限管理
- 编写完整的客户端代码
项目结构
一个良好的项目结构是可维护代码的基础。Anchor 项目通常采用模块化的组织方式,将指令、状态和错误定义分离到不同的文件中。这种结构在项目规模增长时尤其重要。
spl_token_manager/ ├── programs/ │ └── spl_token_manager/ │ └── src/ │ ├── lib.rs │ ├── instructions/ │ │ ├── mod.rs │ │ ├── initialize_mint.rs │ │ ├── mint_tokens.rs │ │ ├── transfer_tokens.rs │ │ └── burn_tokens.rs │ ├── state/ │ │ ├── mod.rs │ │ └── token_config.rs │ └── errors.rs ├── tests/ │ └── spl_token_manager.ts └── Anchor.toml
合约实现
在开始编写具体的指令之前,我们需要定义好状态结构和错误类型。良好的状态设计是整个程序的骨架,而清晰的错误定义则是调试和用户体验的关键。
状态设计的考量
我们设计了一个 TokenConfig 结构来存储代币的配置信息。这个账户由 PDA 控制,作为 Mint 的实际权限持有者。这种设计有几个好处:
- 中心化的配置管理:所有代币相关的业务逻辑配置都在一个账户中,方便查询和更新
- 灵活的权限控制:通过程序逻辑控制铸造行为,而不是简单地将权限交给某个钱包
- 可扩展性:未来可以轻松添加新的配置项,如白名单、铸造限额等
错误定义的最佳实践
清晰的错误信息不仅帮助开发者调试,也能提供更好的用户体验。每个错误都应该有明确的语义,让调用者知道问题出在哪里以及如何解决。
首先定义状态和错误:
rust// state/token_config.rs use anchor_lang::prelude::*; #[account] pub struct TokenConfig { pub authority: Pubkey, pub mint: Pubkey, pub total_minted: u64, pub max_supply: u64, pub bump: u8, } impl TokenConfig { pub const LEN: usize = 8 + 32 + 32 + 8 + 8 + 1; } // errors.rs use anchor_lang::prelude::*; #[error_code] pub enum TokenError { #[msg("超过最大供应量限制")] ExceedsMaxSupply, #[msg("无效的权限")] InvalidAuthority, #[msg("数量必须大于零")] InvalidAmount, }
主程序文件:
rust// lib.rs use anchor_lang::prelude::*; pub mod errors; pub mod instructions; pub mod state; use instructions::*; declare_id!("YourProgramIdHere"); #[program] pub mod spl_token_manager { use super::*; pub fn initialize_mint( ctx: Context<InitializeMint>, decimals: u8, max_supply: u64, ) -> Result<()> { instructions::initialize_mint::handler(ctx, decimals, max_supply) } pub fn mint_tokens(ctx: Context<MintTokens>, amount: u64) -> Result<()> { instructions::mint_tokens::handler(ctx, amount) } pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> { instructions::transfer_tokens::handler(ctx, amount) } pub fn burn_tokens(ctx: Context<BurnTokens>, amount: u64) -> Result<()> { instructions::burn_tokens::handler(ctx, amount) } }
初始化 Mint 的指令:
rust// instructions/initialize_mint.rs use anchor_lang::prelude::*; use anchor_spl::token::{Mint, Token}; use crate::state::TokenConfig; #[derive(Accounts)] pub struct InitializeMint<'info> { #[account(mut)] pub authority: Signer<'info>, #[account( init, payer = authority, space = TokenConfig::LEN, seeds = [b"config", mint.key().as_ref()], bump, )] pub config: Account<'info, TokenConfig>, #[account( init, payer = authority, mint::decimals = decimals, mint::authority = config, mint::freeze_authority = config, )] pub mint: Account<'info, Mint>, pub system_program: Program<'info, System>, pub token_program: Program<'info, Token>, } pub fn handler(ctx: Context<InitializeMint>, decimals: u8, max_supply: u64) -> Result<()> { let config = &mut ctx.accounts.config; config.authority = ctx.accounts.authority.key(); config.mint = ctx.accounts.mint.key(); config.total_minted = 0; config.max_supply = max_supply; config.bump = ctx.bumps.config; msg!("代币初始化完成"); msg!("Mint 地址: {}", ctx.accounts.mint.key()); msg!("小数位数: {}", decimals); msg!("最大供应量: {}", max_supply); Ok(()) }
铸造代币的指令:
rust// instructions/mint_tokens.rs use anchor_lang::prelude::*; use anchor_spl::token::{mint_to, Mint, MintTo, Token, TokenAccount}; use crate::errors::TokenError; use crate::state::TokenConfig; #[derive(Accounts)] pub struct MintTokens<'info> { #[account(mut)] pub authority: Signer<'info>, #[account( mut, seeds = [b"config", mint.key().as_ref()], bump = config.bump, has_one = authority @ TokenError::InvalidAuthority, has_one = mint, )] pub config: Account<'info, TokenConfig>, #[account(mut)] pub mint: Account<'info, Mint>, #[account( mut, token::mint = mint, )] pub destination: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, } pub fn handler(ctx: Context<MintTokens>, amount: u64) -> Result<()> { require!(amount > 0, TokenError::InvalidAmount); let config = &mut ctx.accounts.config; // 检查是否超过最大供应量 let new_total = config.total_minted.checked_add(amount) .ok_or(TokenError::ExceedsMaxSupply)?; require!(new_total <= config.max_supply, TokenError::ExceedsMaxSupply); // 使用 PDA 签名进行 CPI 调用 let mint_key = ctx.accounts.mint.key(); let seeds = &[ b"config", mint_key.as_ref(), &[config.bump], ]; let signer_seeds = &[&seeds[..]]; mint_to( CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), MintTo { mint: ctx.accounts.mint.to_account_info(), to: ctx.accounts.destination.to_account_info(), authority: ctx.accounts.config.to_account_info(), }, signer_seeds, ), amount, )?; config.total_minted = new_total; msg!("铸造 {} 个代币到 {}", amount, ctx.accounts.destination.key()); Ok(()) }
TypeScript 客户端
链上程序只是应用的一半,另一半是客户端代码。一个好的客户端不仅要能正确调用链上指令,还要处理各种边缘情况,提供良好的用户体验。
在 Solana 开发中,客户端通常使用 TypeScript 和 @solana/web3.js 库。Anchor 提供了额外的工具,可以根据 IDL(接口定义语言)自动生成类型安全的客户端代码,大大减少了样板代码。
以下测试代码展示了如何:
- 初始化 Provider 和 Program 实例
- 计算 PDA 地址
- 构建和发送交易
- 处理 ATA 的创建
- 验证链上状态
typescriptimport * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { SplTokenManager } from "../target/types/spl_token_manager"; import { Keypair, PublicKey, SystemProgram, LAMPORTS_PER_SOL, } from "@solana/web3.js"; import { TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync, createAssociatedTokenAccountInstruction, } from "@solana/spl-token"; describe("spl_token_manager", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.SplTokenManager as Program<SplTokenManager>; const authority = provider.wallet as anchor.Wallet; // 生成新的 Mint keypair const mintKeypair = Keypair.generate(); // 派生 config PDA const [configPda] = PublicKey.findProgramAddressSync( [Buffer.from("config"), mintKeypair.publicKey.toBuffer()], program.programId ); // 计算 ATA 地址 const userAta = getAssociatedTokenAddressSync( mintKeypair.publicKey, authority.publicKey ); const DECIMALS = 6; const MAX_SUPPLY = 1_000_000_000_000; // 1,000,000 代币(6位小数) it("初始化 Mint", async () => { const tx = await program.methods .initializeMint(DECIMALS, new anchor.BN(MAX_SUPPLY)) .accounts({ authority: authority.publicKey, config: configPda, mint: mintKeypair.publicKey, systemProgram: SystemProgram.programId, tokenProgram: TOKEN_PROGRAM_ID, }) .signers([mintKeypair]) .rpc(); console.log("初始化交易签名:", tx); console.log("Mint 地址:", mintKeypair.publicKey.toBase58()); }); it("创建 ATA 并铸造代币", async () => { // 先创建 ATA const createAtaIx = createAssociatedTokenAccountInstruction( authority.publicKey, userAta, authority.publicKey, mintKeypair.publicKey ); // 铸造 1000 个代币 const mintAmount = 1000 * Math.pow(10, DECIMALS); const tx = await program.methods .mintTokens(new anchor.BN(mintAmount)) .accounts({ authority: authority.publicKey, config: configPda, mint: mintKeypair.publicKey, destination: userAta, tokenProgram: TOKEN_PROGRAM_ID, }) .preInstructions([createAtaIx]) .rpc(); console.log("铸造交易签名:", tx); // 验证余额 const balance = await provider.connection.getTokenAccountBalance(userAta); console.log("用户代币余额:", balance.value.uiAmount); }); it("转账代币", async () => { // 创建另一个用户 const recipient = Keypair.generate(); // 空投一些 SOL 给接收者(用于支付租金) const airdropSig = await provider.connection.requestAirdrop( recipient.publicKey, LAMPORTS_PER_SOL ); await provider.connection.confirmTransaction(airdropSig); // 计算接收者的 ATA const recipientAta = getAssociatedTokenAddressSync( mintKeypair.publicKey, recipient.publicKey ); // 创建接收者的 ATA const createRecipientAtaIx = createAssociatedTokenAccountInstruction( authority.publicKey, recipientAta, recipient.publicKey, mintKeypair.publicKey ); // 转账 100 个代币 const transferAmount = 100 * Math.pow(10, DECIMALS); const tx = await program.methods .transferTokens(new anchor.BN(transferAmount)) .accounts({ from: userAta, to: recipientAta, authority: authority.publicKey, tokenProgram: TOKEN_PROGRAM_ID, }) .preInstructions([createRecipientAtaIx]) .rpc(); console.log("转账交易签名:", tx); // 验证余额 const recipientBalance = await provider.connection.getTokenAccountBalance(recipientAta); console.log("接收者代币余额:", recipientBalance.value.uiAmount); }); });