文章通过一个最小可运行的 Anchor 示例,系统讲解了 Solana 开发里最容易混淆的账户模型:PDA、seeds、bump、账户约束以及客户端如何自动补全账户。文中用 Rust 实现 create/update/close 三个指令,说明账户空间分配、字符串长度校验、bump 持久化、权限约束与关闭退款机制;再用 TypeScript 脚本演示 PDA 推导、调用流程和部署运行步骤。整体重点是帮助读者理解 Anchor 在链上账户校验与客户端推导之间的真实工作方式。
当你开始使用 Anchor 在 Solana 上开发时,最难的部分并不是 Rust,而是理解 accounts、PDAs、bumps 和 client 是如何协同工作的。
在这篇文章中,我们将通过一个最小化的 Anchor program,演示如何创建、更新和关闭一个由用户拥有的 PDA,然后再用 TypeScript 脚本端到端地调用它。在这个过程中,你会学到 Anchor 如何派生 accounts、什么时候需要手动传递它们、为什么存储 bump 很重要,以及 client 如何根据你的 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 中。
create_user(name): 创建并初始化 PDA,写入 owner、name、created_at、bump。update_name(new_name): 只有 owner 可以在预分配的限制内修改 name。close_user(): 关闭 PDA,并将 rent 退还给 owner。declare_id!("<YOUR-PROGRAM-ID>");
#[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
}
UserAccount::INIT_SPACE。require!(name.as_bytes().len() <= 32, …))。PDA 派生方式:
find_program_address(["user", authority_pubkey], program_id)seeds = [b"user", authority.key().as_ref()], bump = user_account.bump
注意:
bump是 Anchor/SDK 找到的那个单字节值,用来让地址落到 ed25519 曲线之外(因此它可以由 program 拥有)。你把它存起来,这样之后就能在 constraints 中重新派生 PDA。
重要派生概览
#[account]:这个写在 struct 上方的 derivation 表示 这个 struct 是一个链上 account(在我们的例子中是 UserAccount),它包含:lamports、owner、data……
#[account(...)]:写在每个 struct 的字段上方(这些 struct 是通过 #derive(Accounts) 派生的),这个 derivation 表示这个 field 是一个 account,并声明它的 constraints。
#[derive(Accounts)]:这个 macro 告诉 Anchor:“这个 struct 描述了调用该 instruction 所需的 accounts”。
Anchor 用它来:
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
impl UserAccount 中所声明)。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。
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。
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(())
}
将此文件保存为 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 粘贴到:
programs/solana_accounts/src/lib.rs: declare_id("...")Anchor.toml: [programs.devnet].solana_accounts=""solana-keygen pubkey target/deploy/solana_accounts-keypair.json:会打印生成的 programID。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!
- 原文链接: medium.com/@andrey_obruc...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!