理解 Anchor 账户:seeds、bump、PDA 以及客户端的实际工作方式

文章通过一个最小可运行的 Anchor 示例,系统讲解了 Solana 开发里最容易混淆的账户模型:PDA、seeds、bump、账户约束以及客户端如何自动补全账户。文中用 Rust 实现 create/update/close 三个指令,说明账户空间分配、字符串长度校验、bump 持久化、权限约束与关闭退款机制;再用 TypeScript 脚本演示 PDA 推导、调用流程和部署运行步骤。整体重点是帮助读者理解 Anchor 在链上账户校验与客户端推导之间的真实工作方式。

理解 Anchor Accounts

当你开始使用 Anchor 在 Solana 上开发时,最难的部分并不是 Rust,而是理解 accounts、PDAs、bumps 和 client 是如何协同工作的。

在这篇文章中,我们将通过一个最小化的 Anchor program,演示如何创建、更新和关闭一个由用户拥有的 PDA,然后再用 TypeScript 脚本端到端地调用它。在这个过程中,你会学到 Anchor 如何派生 accounts、什么时候需要手动传递它们、为什么存储 bump 很重要,以及 client 如何根据你的 constraints 自动填充一切。

什么是 Anchor,以及它如何在 Rust 中表示 accounts 和 constraints

Anchor 是 Solana programs 中最流行的框架。你可以把它看作: 。这里我们只关注最有意思的部分:program 在链上** 的行为。

Rust 代码

use anchor_lang::prelude::*;

declare_id!("<YOUR-PROGRAM-ID>");

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

    /// 为用户创建一个 PDA,并存储他们的 name + 创建时间。
    pub fn create_user(ctx: Context<CreateUser>, name: String) -> Result<()> {
        // 以 BYTES(UTF-8)为单位强制限制最大长度。表情符号会占用多个 bytes。
        require!(
            name.as_bytes().len() <= UserAccount::MAX_NAME,
            ErrorCode::NameTooLong
        );

        let user = &mut ctx.accounts.user_account;
        user.owner = ctx.accounts.authority.key();
        user.name = name.clone(); // 因为我们按 MAX_NAME 预留了大小,所以可以放得下
        user.created_at = Clock::get()?.unix_timestamp;
        user.bump = ctx.bumps.user_account;

        msg!("✅ Created user PDA: {}", user.key());
        msg!("   Owner: {}", user.owner);
        msg!("   Name: {}", name);
        msg!("   Created at: {}", user.created_at);
        Ok(())
    }

    /// 更新 name 字段(只有 owner 可以执行)。
    /// 只要 new_name.len() ≤ MAX_NAME,就不需要 realloc。
    pub fn update_name(ctx: Context<UpdateUser>, new_name: String) -> Result<()> {
        require!(
            new_name.as_bytes().len() <= UserAccount::MAX_NAME,
            ErrorCode::NameTooLong
        );

        let user = &mut ctx.accounts.user_account;
        msg!("✏️ Updating user: {}", user.key());
        msg!("   Old name: {}", user.name);
        user.name = new_name.clone();
        msg!("   New name: {}", new_name);
        Ok(())
    }

    /// 关闭 account,并将 rent 退还给 authority(owner)。
    pub fn close_user(_ctx: Context<CloseUser>) -> Result<()> {
        msg!("🧹 Closed user PDA and refunded rent.");
        Ok(())
    }
}

#[account]
#[derive(InitSpace)]
pub struct UserAccount {
    pub owner: Pubkey,              // 32
    #[max_len(32)]
    pub name: String,               // 4 + up to 32 bytes
    pub created_at: i64,            // 8
    pub bump: u8,                   // 1
}

impl UserAccount {
    pub const MAX_NAME: usize = 32;
    // 初始化时要分配的总空间:
    // 8(discriminator)+ Anchor 根据 struct 计算出的 INIT_SPACE
    pub const SPACE: usize = 8 + Self::INIT_SPACE;
}

#[derive(Accounts)]
pub struct CreateUser<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    /// PDA: seeds = ["user", authority]
    #[account(
        init,
        payer = authority,
        space = UserAccount::SPACE,
        seeds = [b"user", authority.key().as_ref()],
        bump
    )]
    pub user_account: Account<'info, UserAccount>,

    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct UpdateUser<'info> {
    pub authority: Signer<'info>,

    #[account(
        mut,
        constraint = user_account.owner == authority.key(),
        seeds = [b"user", authority.key().as_ref()],
        bump = user_account.bump
    )]
    pub user_account: Account<'info, UserAccount>,
}

#[derive(Accounts)]
pub struct CloseUser<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    #[account(
        mut,
        close = authority,
        constraint = user_account.owner == authority.key(),
        seeds = [b"user", authority.key().as_ref()],
        bump = user_account.bump
    )]
    pub user_account: Account<'info, UserAccount>,
}

#[error_code]
pub enum ErrorCode {
    #[msg("Name too long (max 32 bytes).")]
    NameTooLong,
}

概览

它存储的内容: 一个按钱包划分的 UserAccount,位于由 ["user", authority] 派生出来的 PDA 中。

Instructions

  • create_user(name): 创建并初始化 PDA,写入 ownernamecreated_atbump
  • update_name(new_name): 只有 owner 可以在预分配的限制内修改 name。
  • close_user(): 关闭 PDA,并将 rent 退还给 owner。

Program ID

declare_id!("<YOUR-PROGRAM-ID>");
  • Anchor 会将这个值 写入二进制文件
  • 在运行时,它 必须等于 你正在调用的链上 program account,否则你会得到 DeclaredProgramIdMismatch
  • 在 build/deploy 之前,确保 declare_id!Anchor.toml 和你的 client 都使用 同一个 pubkey。

这个 account:布局和大小

#[account]
#[derive(InitSpace)]
pub struct UserAccount {
    pub owner: Pubkey,              // 32
    #[max_len(32)]
    pub name: String,               // 4 + up to 32 bytes
    pub created_at: i64,            // 8
    pub bump: u8,                   // 1
}
  • Anchor 会计算 UserAccount::INIT_SPACE
  • 初始化时,需要分配 8 + UserAccount::INIT_SPACE(额外的 8 是 discriminator)。
  • 在运行时强制校验长度require!(name.as_bytes().len() <= 32, …))。
  • 这样可以避免 “serialize to unexpected length” 失败。
  • 记住: emojis/多字节字符会按多个 bytes 计算。

PDAs、seeds 和 bump

PDA 派生方式:

  • PDA = find_program_address(["user", authority_pubkey], program_id)
  • 为什么需要 bump: 它是一个单字节的 nonce,用来强制派生地址 脱离 ed25519 曲线,从而使它可以由 program 拥有。
  • 将 bump 存到 account 中,这样你就可以在 constraints 中引用它:
seeds = [b"user", authority.key().as_ref()], bump = user_account.bump

注意: bump 是 Anchor/SDK 找到的那个单字节值,用来让地址落到 ed25519 曲线之外(因此它可以由 program 拥有)。你把它存起来,这样之后就能在 constraints 中重新派生 PDA。

重要派生概览

#[account]:这个写在 struct 上方的 derivation 表示 这个 struct 是一个链上 account(在我们的例子中是 UserAccount),它包含:lamportsownerdata……

#[account(...)]:写在每个 struct 的字段上方(这些 struct 是通过 #derive(Accounts) 派生的),这个 derivation 表示这个 field 是一个 account,并声明它的 constraints。

#[derive(Accounts)]:这个 macro 告诉 Anchor:“这个 struct 描述了调用该 instruction 所需的 accounts”。

Anchor 用它来:

  • 反序列化 account 输入
  • 强制执行 constraints(seeds、mutability、ownership)
  • 自动派生 PDAs
  • 在进入你的 handler 之前执行运行时校验

Instruction: create_user

对象概览

#[derive(Accounts)]
pub struct CreateUser<'info> {
    #[account(mut)]
    pub authority: Signer<'info>,

    /// PDA: seeds = ["user", authority]
    #[account(
        init,
        payer = authority,
        space = UserAccount::SPACE,
        seeds = [b"user", authority.key().as_ref()],
        bump
    )]
    pub user_account: Account<'info, UserAccount>,

    pub system_program: Program<'info, System>,
}

pub authority: Signer<'info>:这个 field 表示该 instruction 的 调用者,而 Signer 表示这个 account 必须对 transaction 签名。它带有 #[account(mut)],因为这个 signer 将 支付 account 创建费用,所以余额会发生变化。

pub user_account: Account<'info, UserAccount>:这是我们正在创建的 PDA account。它是链上的数据结构,会存储在 UserAccount 中声明的内容。

Notes

  • init:创建 PDA 并将 data 初始化为零。
  • payer:由 authority 提供 rent。
  • space:使用 8 + INIT_SPACE(如 impl UserAccount 中所声明)。
  • seeds:使用常量字符串 “user” 和 authority 地址。
  • bump:Anchor 知道如何自动填充它(用于找到曲线外的地址)。

pub system_program: Program<'info, System>:任何 init 在底层都必须调用 system program,它会 分配新的 account、分配所有权,并从 payer 转移 lamports。这个 field 是 account 创建、lamport 转账、PDA 初始化 所必需的。

Handler 概览

pub fn create_user(ctx: Context<CreateUser>, name: String) -> Result<()> {
        // 以 BYTES(UTF-8)为单位强制限制最大长度。表情符号会占用多个 bytes。
        require!(
            name.as_bytes().len() <= UserAccount::MAX_NAME,
            ErrorCode::NameTooLong
        );

        let user = &mut ctx.accounts.user_account;
        // 存储创建这个 profile 的钱包
        user.owner = ctx.accounts.authority.key();
        // 这会写入 Anchor 通过 #[max_len] 预留的固定分配空间
        user.name = name.clone(); // 因为我们按 MAX_NAME 预留了大小,所以可以放得下
        // Clock sysvar 包含当前 cluster 时间
        user.created_at = Clock::get()?.unix_timestamp;
        // 为什么要存 bump:
        //   我们使用的是:PDA = find_program_address(["user", authority], bump)
        //   我们不想以后再手动重新计算 bump
        //   update_name 和 close_user 会强制校验(后面会看到)
        user.bump = ctx.bumps.user_account;
        ...
}

Context<CreateUser>:为 CreateUser struct 中声明的所有 accounts 提供 经过验证的访问权限。Anchor 已经验证了所有 constraints,并创建/分配了所需的 accounts。

Instruction: update_name

对象概览

#[derive(Accounts)]
pub struct UpdateUser<'info> {
    // 与前面相同,这里为什么不是 mut?因为这里没有 lamports 变化
    pub authority: Signer<'info>,

    // mut - 因为我们修改了 name,如果你移除 mut,Solana 会拒绝这笔 transaction
    // 这是我们的访问控制规则,它确保只有 profile 的原始创建者可以更新它
    // seeds - 确保正确的自动派生
    // bump - 因为这个 PDA 之前是使用 ctx.bumps.user_account 初始化的。存储并重复使用 bump 可保证 PDA 的稳定性。
    #[account(
        mut,
        constraint = user_account.owner == authority.key(),
        seeds = [b"user", authority.key().as_ref()],
        bump = user_account.bump
    )]
    pub user_account: Account<'info, UserAccount>,
}

Handler 概览

pub fn update_name(ctx: Context<UpdateUser>, new_name: String) -> Result<()> {
        // 以 BYTES(UTF-8)为单位强制限制最大长度。表情符号会占用多个 bytes。
        require!(
            new_name.as_bytes().len() <= UserAccount::MAX_NAME,
            ErrorCode::NameTooLong
        );

        let user = &mut ctx.accounts.user_account;
        msg!("✏️ Updating user: {}", user.key());
        msg!("   Old name: {}", user.name);
        user.name = new_name.clone();
        msg!("   New name: {}", new_name);
        Ok(())
    }

这个 instruction 很直接,它会用 new_name 更新已经存在的 PDA 的 name。

Instruction: close_user

#[derive(Accounts)]
pub struct CloseUser<'info> {
    // 与前面相同,这次是 mut,因为退款后的余额会增加
    #[account(mut)]
    pub authority: Signer<'info>,

    // mut:我们正在修改(实际上是清零/关闭)这个 account。
    // close:告诉 Anchor 将 user_account 中的所有 lamports 退还给 authority,然后在 instruction 结束时关闭该 account。你不需要手动调用任何东西——Anchor 会在 account 的 “drop”(teardown)阶段执行关闭。
    // seeds:与前面相同
    // bump:与前面相同
    #[account(
        mut,
        close = authority,
        constraint = user_account.owner == authority.key(),
        seeds = [b"user", authority.key().as_ref()],
        bump = user_account.bump
    )]
    pub user_account: Account<'info, UserAccount>,
}

Handler 概览

// 一切都会自动完成
pub fn close_user(_ctx: Context<CloseUser>) -> Result<()> {
    msg!("🧹 Closed user PDA and refunded rent.");
    Ok(())
}

Client 端(TS)

将此文件保存为 scripts/solana_accounts.ts

注意: 要运行这段代码,我们需要导出 2 个环境变量:

export ANCHOR_PROVIDER_URL="http://127.0.0.1:8899"

export ANCHOR_WALLET="$HOME/.config/solana/id.json"

import * as anchor from "@coral-xyz/anchor";
import type { Program } from "@coral-xyz/anchor";
import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { SolanaAccounts } from "../target/types/solana_accounts";

async function ensureAirdrop(connection: anchor.web3.Connection, pubkey: PublicKey, min = 2 * LAMPORTS_PER_SOL) {
  const bal = await connection.getBalance(pubkey);
  if (bal >= min) return;
  const sig = await connection.requestAirdrop(pubkey, min);
  await connection.confirmTransaction(sig, "confirmed");
}

(async () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // 使用 Anchor workspace(使用 target/ 下生成的 IDL/types)
  const program = anchor.workspace.solanaAccounts as Program<SolanaAccounts>;
  const wallet = provider.wallet as anchor.Wallet;

  // 确保我们有 SOL(在 localhost 上很有用)
  try { await ensureAirdrop(provider.connection, wallet.publicKey); } catch {}

  // PDA: seeds = ["user", authority]
  // seeds 必须与 program 的 #[account(seeds = [b"user", authority])] 匹配。来自 Rust!
  const [userPda] = PublicKey.findProgramAddressSync(
    [Buffer.from("user"), wallet.publicKey.toBuffer()],
    program.programId
  );

  console.log("Wallet:", wallet.publicKey.toBase58());
  console.log("Program:", program.programId.toBase58());
  console.log("User PDA:", userPda.toBase58());

  // 1) createUser
  //    可派生 accounts(PDAs)会被 Anchor 自动填充
  //    这里不需要传 programId 或 PDA!它会根据 context 自动推断。
  const sig1 = await program.methods
    .createUser("0xByteBeetle")
    .accounts({ authority: wallet.publicKey }) // 可派生 accounts 会被自动填充
    .rpc();
  console.log("createUser tx:", sig1);

  // 获取并打印已创建的 account
  const acct1 = await program.account.userAccount.fetch(userPda);
  console.log("After create:", {
    owner: acct1.owner.toBase58(),
    name: acct1.name,
    created_at: new Date(acct1.createdAt.toNumber() * 1000).toISOString(),
    bump: acct1.bump,
  });

  // 2) updateName
  const sig2 = await program.methods
    .updateName("bytebeetle")
    .accounts({ authority: wallet.publicKey })
    .rpc();
  console.log("updateName tx:", sig2);

  const acct2 = await program.account.userAccount.fetch(userPda);
  console.log("After update:", { name: acct2.name });

  // 3) closeUser
  const sig3 = await program.methods
    .closeUser()
    .accounts({ authority: wallet.publicKey })
    .rpc();
  console.log("closeUser tx:", sig3);

  try {
    await program.account.userAccount.fetch(userPda);
  } catch {
    console.log("PDA closed (fetch failed as expected).");
  }

  console.log("Done ✅");
})().catch((e) => {
  console.error(e);
  process.exit(1);
});

注意: 说明都写在代码里了。

一起运行它

在项目根目录打开一个新终端并运行:

  • solana config set --url https://api.devnet.solana.com:这会设置默认的 node RPC URL。
  • solana-keygen new -o ~/.config/solana/id.json:这会为这次测试生成一个 dummy wallet。
  • solana airdrop 1:这会给你的 dummy wallet 空投 1 SOL(部署和执行所需),如果不够,你可以使用这个 faucet
  • solana-keygen new -o target/deploy/solana_accounts-keypair.json --no-bip39-passphrase:这会为 program 生成 keypair。

将这个 pubkey 粘贴到:

  1. programs/solana_accounts/src/lib.rs: declare_id("...")
  2. Anchor.toml: [programs.devnet].solana_accounts=""
  • solana-keygen pubkey target/deploy/solana_accounts-keypair.json:会打印生成的 programID。
  • 将 pubkey 填入这两个地方:declare_id!("<YOUR-PROGRAM-ID>") 以及 Anchor.toml[programs.devnet] 下的 solana_accounts = <YOUR-PROGRAM-ID>

例如(你的地址会不同):

然后运行:

solana-keygen new -o target/deploy/solana_accounts-keypair.json --no-bip39-passphrase
solana-keygen pubkey target/deploy/solana_accounts-keypair.json
## 将这个 pubkey 粘贴到:
##   - programs/solana_accounts/src/lib.rs: declare_id!("...")
##   - Anchor.toml: [programs.devnet].solana_accounts = "..."

anchor clean
anchor build
anchor deploy

你应该会看到类似这样的内容:

https://solscan.io/account/Ga1UVR2AoZAazWCSZQUg7ZdKkpNXxLCXju4eN5YRrKQJ?cluster=devnet

然后运行

## 提醒:运行前需要先执行:
## export export ANCHOR_PROVIDER_URL="https://api.devnet.solana.com"
## export ANCHOR_WALLET="$HOME/.config/solana/id.json"

pnpm ts-node scripts/solana_accounts.ts

你应该会看到类似这样的内容:

https://solscan.io/account/Ga1UVR2AoZAazWCSZQUg7ZdKkpNXxLCXju4eN5YRrKQJ?cluster=devnet

注意: 你可以在运行 solana logs 的终端中监控链上日志。如果你的 RPC 遇到 rate limit 错误,尝试在脚本中逐个执行这些 methods!

资源

  • Anchor code 可以在 这里 找到
  • 原文链接: medium.com/@andrey_obruc...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Andrey Obruchkov
Andrey Obruchkov
江湖只有他的大名,没有他的介绍。