完整项目实战:个人金库

完整项目实战

项目概述:Solana 大学金库

我们将构建一个完整的金库程序,这是一个经典的 Solana 开发范例。它涵盖了 PDA、CPI、状态管理和错误处理。

功能需求:

  • 创建个人金库:每个用户只能拥有一个金库(基于 PDA 确定性地址)。
  • 存入 SOL:用户可以将 SOL 从钱包转入金库。
  • 提取 SOL:用户可以将 SOL 从金库转回钱包。
  • 安全性:只有金库的所有者(Authority)才能提款。

项目结构

solana-university-vault/ ├── Anchor.toml ├── Cargo.toml ├── programs/ │ └── vault/ │ ├── Cargo.toml │ └── src/ │ ├── lib.rs # 程序入口 │ ├── instructions/ # 指令逻辑拆分 │ │ ├── mod.rs │ │ ├── initialize.rs # 初始化指令 │ │ ├── deposit.rs # 存款指令 │ │ └── withdraw.rs # 提款指令 │ ├── state/ # 账户结构定义 │ │ ├── mod.rs │ │ └── vault.rs │ └── errors.rs # 自定义错误码 └── tests/ └── vault.ts # TypeScript 集成测试

核心代码实现

1. 状态定义 (state/vault.rs)

我们定义 Vault 账户结构。注意 seeds 的前缀定义,这对于前端派生地址非常重要。

rust
use anchor_lang::prelude::*; #[account] #[derive(InitSpace)] pub struct Vault { /// 金库所有者 pub authority: Pubkey, /// 总存款金额 (逻辑记账) pub total_deposits: u64, /// 创建时间戳 pub created_at: i64, /// PDA bump (存储它以节省后续计算) pub bump: u8, } impl Vault { pub const SEED_PREFIX: &'static [u8] = b"vault"; }

2. 初始化指令 (instructions/initialize.rs)

使用 init 约束自动创建账户并分配空间。

rust
use anchor_lang::prelude::*; use crate::state::Vault; #[derive(Accounts)] pub struct Initialize<'info> { #[account( init, payer = authority, space = 8 + Vault::INIT_SPACE, // 种子: "vault" + 用户公钥 seeds = [Vault::SEED_PREFIX, authority.key().as_ref()], bump )] pub vault: Account<'info, Vault>, #[account(mut)] pub authority: Signer<'info>, pub system_program: Program<'info, System>, } pub fn handler(ctx: Context<Initialize>) -> Result<()> { let vault = &mut ctx.accounts.vault; let clock = Clock::get()?; vault.authority = ctx.accounts.authority.key(); vault.total_deposits = 0; vault.created_at = clock.unix_timestamp; vault.bump = ctx.bumps.vault; msg!("金库已创建 | 所有者: {}", vault.authority); Ok(()) }

3. 存款指令 (instructions/deposit.rs)

存款本质上是将 SOL 从用户钱包转给 PDA。这是一个 CPI (跨程序调用) 操作,调用系统程序的 transfer

rust
use anchor_lang::prelude::*; use anchor_lang::system_program::{transfer, Transfer}; use crate::state::Vault; use crate::errors::VaultError; #[derive(Accounts)] pub struct Deposit<'info> { #[account( mut, seeds = [Vault::SEED_PREFIX, authority.key().as_ref()], bump = vault.bump, has_one = authority // 安全检查:调用者必须是金库主人 )] pub vault: Account<'info, Vault>, #[account(mut)] pub authority: Signer<'info>, pub system_program: Program<'info, System>, } pub fn handler(ctx: Context<Deposit>, amount: u64) -> Result<()> { require!(amount > 0, VaultError::InvalidDepositAmount); // CPI: System Program Transfer (User -> Vault PDA) let cpi_accounts = Transfer { from: ctx.accounts.authority.to_account_info(), to: ctx.accounts.vault.to_account_info(), }; let cpi_ctx = CpiContext::new(ctx.accounts.system_program.to_account_info(), cpi_accounts); transfer(cpi_ctx, amount)?; // 更新内部记账 let vault = &mut ctx.accounts.vault; vault.total_deposits = vault.total_deposits.checked_add(amount).ok_or(VaultError::Overflow)?; msg!("存款成功: {} lamports", amount); Ok(()) }

4. 提款指令 (instructions/withdraw.rs)

提款是从 PDA 转给用户。因为 PDA 没有私钥,我们需要使用 Program 签名 (即直接修改 lamports,或者使用 invoke_signed,但在这里直接修改 lamports 更简单且是 Anchor 推荐的做法,只要该账户归程序所有)。

重要细节:账户必须保留最低租金余额 (Rent Exempt Minimum)。如果提空了,账户可能会被销毁(除非显式处理 close)。我们的逻辑会保留最低租金。

rust
pub fn handler(ctx: Context<Withdraw>, amount: u64) -> Result<()> { let vault = &mut ctx.accounts.vault; // 1. 计算最大可提款金额 (当前余额 - 租金豁免门槛) let rent = Rent::get()?; let min_balance = rent.minimum_balance(vault.to_account_info().data_len()); let current_lamports = vault.to_account_info().lamports(); let available_amount = current_lamports.checked_sub(min_balance).unwrap_or(0); require!(amount <= available_amount, VaultError::InsufficientBalance); // 2. 执行转账 (直接修改 Lamports) // 因为 vault 账户的 Owner 是当前程序,所以我们有权修改它的 lamports **vault.to_account_info().try_borrow_mut_lamports()? -= amount; **ctx.accounts.authority.try_borrow_mut_lamports()? += amount; // 3. 更新记账 vault.total_deposits = vault.total_deposits.checked_sub(amount).ok_or(VaultError::Overflow)?; Ok(()) }

完整测试 (tests/vault.ts)

测试是验证逻辑的关键。我们使用 Mocha + Chai。

typescript
it("初始化金库", async () => { // 调用 Initialize await program.methods.initialize().accounts({...}).rpc(); // 验证状态 const vault = await program.account.vault.fetch(vaultAddress); expect(vault.authority.toString()).to.equal(authority.publicKey.toString()); }); it("非所有者无法操作金库", async () => { const hacker = anchor.web3.Keypair.generate(); try { await program.methods.withdraw(new BN(100)) .accounts({ vault: vaultAddress, authority: hacker.publicKey }) .signers([hacker]) .rpc(); expect.fail("应该失败"); } catch (e) { // 期望捕获 ConstraintHasOne 错误 expect(e.message).to.include("ConstraintHasOne"); } });

总结

这个项目展示了 Anchor 开发的核心模式:

  1. PDA 管理:使用 seedsbump 自动处理地址派生。
  2. 安全性:使用 has_one 约束防止未授权访问。
  3. 资金管理:处理 SOL 的流入(System Transfer)和流出(Lamports Modification)。
  4. 错误处理:使用 require! 和自定义 ErrorCode 进行防御性编程。
RUSTPlayground
EDITOR ACTIVE
Initializing RUST Environment...

个人金库模拟器

你的钱包 (Wallet)
10.0000 SOL
UserAuth...
系统就绪 (System Ready).
Vault 账户 (PDA)
Program Owned
未初始化 (Not Initialized)