Solana/Anchor - Counter 程序 + PDA (Program Derived Address)
### 一、什么是 PDA?为什么 Solana 必须有它?
日期: 2026-05-07 方向: Solana 阶段: 第二阶段:框架实战 标签: #Solana #Anchor #PDA #Counter #Seeds #Bump
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 深入理解 PDA 的推导原理、bump seed 机制、PDA 作为签名者的能力 |
| 实操 | 用 Anchor 实现一个 PDA-owned 的 Counter 程序(initialize/increment/decrement) |
| 产出 | 完整的 Counter 程序代码 + TypeScript 测试 + PDA 原理笔记 |
核心概念
一、什么是 PDA?为什么 Solana 必须有它?
在 Day 33 我们学过,Solana 的核心模型是代码与数据分离:Program 只有代码,Data Account 只有数据。这带来一个关键问题:
问题: 谁来"拥有"和管理状态账户?
场景: 一个 Counter 程序需要存储计数值
- 数据存在 Data Account 中
- 但这个 Data Account 的 authority 是谁?
- 如果是用户 → 用户可以随意修改数据,绕过程序逻辑
- 如果是程序 → 程序没有私钥,怎么签名?
解决方案: PDA (Program Derived Address)
- 一种特殊地址,由程序"派生"出来
- 不在 ed25519 曲线上 → 没有对应的私钥 → 没人能伪造签名
- 程序可以通过 CPI (Cross-Program Invocation) "代替" PDA 签名
PDA vs 普通密钥对
| 维度 | 普通 Keypair | PDA |
|---|---|---|
| 生成方式 | 随机生成私钥 → 派生公钥 | 由 seeds + program_id 确定性派生 |
| 在 ed25519 曲线上? | 是 | 否(关键区别!) |
| 有私钥? | 有 | 没有 |
| 谁能签名? | 持有私钥的人 | 只有派生它的程序(通过 CPI) |
| 地址确定性? | 随机 | 确定性(同样的 seeds → 同样的地址) |
| 用途 | 用户钱包、验证签名 | 程序拥有的账户、状态存储、权限控制 |
二、PDA 的推导过程
findProgramAddress 内部机制
PDA 的推导使用 SHA-256 哈希:
PDA = SHA256(seed1, seed2, ..., bump, program_id)
但这个哈希结果必须不在 ed25519 曲线上。如果恰好在曲线上(概率约 50%),就需要调整 bump 值重试。
推导过程(伪代码):
function findProgramAddress(seeds, programId):
for bump in 255..0: // 从 255 开始递减
hash = SHA256(seeds..., [bump], programId, "ProgramDerivedAddress")
if NOT on_ed25519_curve(hash): // 如果不在曲线上
return (hash, bump) // 找到了!返回地址和 bump
// 极不可能:256次都在曲线上
throw "Could not find PDA"
为什么从 255 开始?
bump = 255 → 计算 hash → 在曲线上? → 是 → 继续
bump = 254 → 计算 hash → 在曲线上? → 否 → 找到!返回 (address, 254)
关键点:
1. 从 255 开始 = "canonical bump"(规范 bump)
2. 找到的第一个有效 bump 就是 canonical bump
3. 总是使用 canonical bump → 确保唯一性和确定性
4. 统计上,大约 50% 的 bump 值有效,所以通常 1-2 次就找到了
用 TypeScript 验证
import { PublicKey } from "@solana/web3.js";
// 同样的 seeds + programId → 总是得到同样的 PDA
const [pda, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from("counter"), // seed 1: 字符串
userPubkey.toBuffer(), // seed 2: 用户公钥
],
programId // 派生它的程序
);
console.log("PDA:", pda.toBase58());
console.log("Bump:", bump); // 例如: 254
// 重要: 再次调用,得到完全相同的结果!
const [pda2, bump2] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), userPubkey.toBuffer()],
programId
);
assert(pda.equals(pda2)); // true — 确定性!
三、Seeds 设计模式
Seeds(种子)的选择是 PDA 设计中最重要的决策之一。它决定了数据的组织方式和访问模式。
常见的 Seeds 模式
模式 1: 全局单例(Global Singleton)
seeds = ["global_state"]
→ 整个程序只有一个这样的账户
→ 用途: 程序配置、全局计数器
模式 2: 用户关联(Per-User)
seeds = ["user_profile", user_pubkey]
→ 每个用户一个账户
→ 用途: 用户余额、用户设置
模式 3: 多维索引(Multi-Dimensional)
seeds = ["position", pool_pubkey, user_pubkey]
→ 每个(用户, 池子)对一个账户
→ 用途: LP 头寸、借贷仓位
模式 4: 可迭代(Iterable)
seeds = ["order", user_pubkey, &order_id.to_le_bytes()]
→ 用户的第 N 个订单
→ 用途: 订单列表、历史记录
Seeds 设计原则
1. 唯一性: seeds 组合必须唯一标识一个账户
✗ seeds = ["data"] → 只能有一个 data 账户
✓ seeds = ["data", user.key()] → 每用户一个
2. 确定性: 客户端必须能重新计算出同样的 PDA
✗ seeds = ["data", random_number] → 客户端不知道 random_number
✓ seeds = ["data", user.key()] → 客户端知道 user 公钥
3. 最小化: 只使用必要的 seeds
✗ seeds = ["data", user.key(), timestamp, name, ...] → 太多
✓ seeds = ["data", user.key()] → 足够区分
4. 类型安全: 避免歧义
✗ seeds = [user.key()] → 不同类型的账户可能冲突
✓ seeds = ["counter", user.key()] → 前缀区分类型
四、PDA 作为签名者
PDA 最强大的能力是程序可以代替 PDA 签名,这在 Cross-Program Invocation (CPI) 中至关重要。
场景: 程序需要从 PDA 拥有的 Token 账户中转出代币
普通流程 (需要私钥):
用户 → 调用 token::transfer(from, to, amount) → 需要 from 的签名
PDA 流程 (程序代签):
用户 → 调用 my_program::withdraw()
→ my_program 用 invoke_signed() 调用 token::transfer
→ 传入 seeds + bump 作为 "签名证明"
→ Token Program 验证: SHA256(seeds, bump, program_id) == PDA 地址 ✓
→ 转账成功!
// PDA 签名的 CPI 调用
let seeds = &[
b"vault",
user.key.as_ref(),
&[bump], // bump 是签名的关键部分
];
let signer_seeds = &[&seeds[..]];
// invoke_signed 让程序"代替" PDA 签名
invoke_signed(
&transfer_instruction,
&[vault_token_account, user_token_account, vault_pda],
signer_seeds, // 这就是 PDA 的"签名"
)?;
五、Anchor 中的 PDA 约束
Anchor 框架用声明式的宏大大简化了 PDA 的使用。
#[account] 约束详解
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init, // 创建新账户
payer = user, // 谁支付 rent
space = 8 + 8, // 账户大小: discriminator(8) + data
seeds = [b"counter", user.key().as_ref()], // PDA seeds
bump // Anchor 自动找到 canonical bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>, // 必须签名 + 支付 rent
pub system_program: Program<'info, System>, // 创建账户需要
}
约束拆解
| 约束 | 含义 | 如果不满足? |
|---|---|---|
init | 创建新账户(调用 System Program 的 create_account) | 如果账户已存在 → Error |
payer = user | user 支付账户的 rent 费用 | user 余额不足 → Error |
space = 8 + 8 | 账户需要 16 字节(8 discriminator + 8 数据) | 空间不够 → 序列化 Error |
seeds = [...] | PDA 的 seeds,Anchor 验证地址是否匹配 | 地址不匹配 → Error |
bump | Anchor 自动计算并验证 canonical bump | - |
Space 计算规则
Anchor Account Space 计算:
8 bytes → Anchor discriminator(自动加,唯一标识 account 类型)
+ 数据大小 → 根据字段类型计算
常见类型大小:
bool → 1 byte
u8/i8 → 1 byte
u16/i16 → 2 bytes
u32/i32 → 4 bytes
u64/i64 → 8 bytes
u128/i128 → 16 bytes
Pubkey → 32 bytes
String → 4 + len (4 bytes 存长度 + 实际字符)
Vec<T> → 4 + len * sizeof(T)
Option<T> → 1 + sizeof(T)
代码实战
Counter 程序:完整实现
这是一个经典的 Anchor 入门程序,但我们用 PDA 来管理状态,使其更接近真实项目。
项目结构
counter/
├── programs/counter/src/
│ └── lib.rs ← 程序逻辑
├── tests/
│ └── counter.ts ← TypeScript 测试
├── Anchor.toml
└── Cargo.toml
programs/counter/src/lib.rs
use anchor_lang::prelude::*;
// 程序 ID(部署后替换为实际地址)
declare_id!("Ctr1111111111111111111111111111111111111111");
#[program]
pub mod counter {
use super::*;
/// 初始化计数器 — 创建一个 PDA 账户存储计数值
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.authority = ctx.accounts.user.key();
counter.count = 0;
counter.bump = ctx.bumps.counter; // 保存 bump,后续用于 CPI 签名
msg!("Counter initialized! Authority: {}", counter.authority);
msg!("Counter PDA: {}", counter.key());
msg!("Bump: {}", counter.bump);
Ok(())
}
/// 递增计数器
pub fn increment(ctx: Context<Update>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_add(1)
.ok_or(ErrorCode::Overflow)?;
msg!("Counter incremented to: {}", counter.count);
Ok(())
}
/// 递减计数器
pub fn decrement(ctx: Context<Update>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_sub(1)
.ok_or(ErrorCode::Underflow)?;
msg!("Counter decremented to: {}", counter.count);
Ok(())
}
/// 重置计数器(仅 authority 可操作)
pub fn reset(ctx: Context<Reset>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = 0;
msg!("Counter reset by authority: {}", ctx.accounts.authority.key());
Ok(())
}
}
// ==================== 账户结构 ====================
/// Counter 状态账户 — 存储在 PDA 中
#[account]
pub struct Counter {
pub authority: Pubkey, // 32 bytes: 谁创建了这个计数器(有重置权限)
pub count: u64, // 8 bytes: 当前计数值
pub bump: u8, // 1 byte: PDA 的 bump seed(备用,方便后续 CPI)
}
// Counter 的 space: 8 (discriminator) + 32 (Pubkey) + 8 (u64) + 1 (u8) = 49
// ==================== 指令账户约束 ====================
/// Initialize: 创建新的 Counter PDA 账户
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init, // 创建新账户
payer = user, // user 支付 rent
space = 8 + 32 + 8 + 1, // discriminator + authority + count + bump
seeds = [b"counter", user.key().as_ref()], // PDA seeds: "counter" + 用户公钥
bump // Anchor 自动计算 canonical bump
)]
pub counter: Account<'info, Counter>,
#[account(mut)] // mut 因为要扣 rent 费用
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}
/// Update: 增/减计数器(任何人都可以操作)
#[derive(Accounts)]
pub struct Update<'info> {
#[account(
mut, // 要修改数据
seeds = [b"counter", user.key().as_ref()], // 验证 PDA 地址
bump = counter.bump // 使用存储的 bump 验证
)]
pub counter: Account<'info, Counter>,
pub user: Signer<'info>, // 签名者 = counter 的 owner
}
/// Reset: 重置计数器(仅 authority)
#[derive(Accounts)]
pub struct Reset<'info> {
#[account(
mut,
seeds = [b"counter", authority.key().as_ref()],
bump = counter.bump,
has_one = authority // 验证 counter.authority == authority.key()
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
}
// ==================== 自定义错误 ====================
#[error_code]
pub enum ErrorCode {
#[msg("Counter overflow: maximum value reached")]
Overflow,
#[msg("Counter underflow: already at zero")]
Underflow,
}
代码逐段解析
1. declare_id! 宏
declare_id!("Ctr1111111111111111111111111111111111111111");
这是程序部署后的链上地址。anchor build 会生成真实的 Program ID 并更新此处。所有 PDA 的推导都依赖这个 Program ID,换了地址 → PDA 全部变化。
2. #[account] 宏的作用
#[account]
pub struct Counter {
pub authority: Pubkey,
pub count: u64,
pub bump: u8,
}
#[account] 宏自动实现了:
AnchorSerialize/AnchorDeserialize→ Borsh 序列化- 8 字节 discriminator(基于
SHA256("account:Counter")前 8 字节) - 账户数据的读写逻辑
3. has_one 约束
#[account(
mut,
seeds = [...],
bump = counter.bump,
has_one = authority // 这一行的作用
)]
pub counter: Account<'info, Counter>,
pub authority: Signer<'info>,
has_one = authority 等价于:
require!(counter.authority == authority.key(), Error);
它确保只有当初创建计数器的用户才能 reset。
4. 为什么存储 bump?
counter.bump = ctx.bumps.counter; // 初始化时保存
bump = counter.bump // 后续使用时读取
两个原因:
- 性能:避免每次都重新计算
findProgramAddress(需要多次 SHA256) - CPI 签名:如果 Counter PDA 需要在 CPI 中签名,必须提供 bump
TypeScript 测试
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Counter } from "../target/types/counter";
import { assert } from "chai";
describe("counter", () => {
// 配置 provider(连接到 localnet/devnet)
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Counter as Program<Counter>;
const user = provider.wallet;
// 在客户端计算 PDA — 必须与程序中的 seeds 完全一致
const [counterPDA, bump] = anchor.web3.PublicKey.findProgramAddressSync(
[
Buffer.from("counter"), // seed 1: 字符串 "counter"
user.publicKey.toBuffer(), // seed 2: 用户公钥
],
program.programId // 程序 ID
);
console.log("Counter PDA:", counterPDA.toBase58());
console.log("Expected bump:", bump);
it("initializes the counter", async () => {
const tx = await program.methods
.initialize()
.accounts({
counter: counterPDA,
user: user.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log("Initialize tx:", tx);
// 读取账户数据
const counterAccount = await program.account.counter.fetch(counterPDA);
assert.equal(counterAccount.count.toNumber(), 0);
assert.equal(
counterAccount.authority.toBase58(),
user.publicKey.toBase58()
);
assert.equal(counterAccount.bump, bump);
console.log("Counter initialized with count:", counterAccount.count.toNumber());
});
it("increments the counter", async () => {
await program.methods
.increment()
.accounts({
counter: counterPDA,
user: user.publicKey,
})
.rpc();
const counterAccount = await program.account.counter.fetch(counterPDA);
assert.equal(counterAccount.count.toNumber(), 1);
console.log("Count after increment:", counterAccount.count.toNumber());
});
it("increments multiple times", async () => {
// 连续递增 4 次
for (let i = 0; i < 4; i++) {
await program.methods
.increment()
.accounts({
counter: counterPDA,
user: user.publicKey,
})
.rpc();
}
const counterAccount = await program.account.counter.fetch(counterPDA);
assert.equal(counterAccount.count.toNumber(), 5); // 1 + 4 = 5
console.log("Count after 4 more increments:", counterAccount.count.toNumber());
});
it("decrements the counter", async () => {
await program.methods
.decrement()
.accounts({
counter: counterPDA,
user: user.publicKey,
})
.rpc();
const counterAccount = await program.account.counter.fetch(counterPDA);
assert.equal(counterAccount.count.toNumber(), 4); // 5 - 1 = 4
console.log("Count after decrement:", counterAccount.count.toNumber());
});
it("resets the counter (authority only)", async () => {
await program.methods
.reset()
.accounts({
counter: counterPDA,
authority: user.publicKey,
})
.rpc();
const counterAccount = await program.account.counter.fetch(counterPDA);
assert.equal(counterAccount.count.toNumber(), 0);
console.log("Count after reset:", counterAccount.count.toNumber());
});
it("fails to decrement below zero", async () => {
// counter 已经是 0,decrement 应该失败
try {
await program.methods
.decrement()
.accounts({
counter: counterPDA,
user: user.publicKey,
})
.rpc();
assert.fail("Should have thrown an error");
} catch (err) {
console.log("Expected error:", err.error.errorMessage);
assert.include(err.error.errorMessage, "underflow");
}
});
it("fails to initialize twice (PDA already exists)", async () => {
// 同一个 PDA 不能初始化两次
try {
await program.methods
.initialize()
.accounts({
counter: counterPDA,
user: user.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
assert.fail("Should have thrown an error");
} catch (err) {
// init 约束会检查账户是否已存在
console.log("Expected error: account already initialized");
}
});
it("fails when wrong authority tries to reset", async () => {
// 先 increment 一下,让 count > 0
await program.methods
.increment()
.accounts({
counter: counterPDA,
user: user.publicKey,
})
.rpc();
// 生成一个新的 keypair 作为冒充者
const imposter = anchor.web3.Keypair.generate();
try {
await program.methods
.reset()
.accounts({
counter: counterPDA,
authority: imposter.publicKey,
})
.signers([imposter])
.rpc();
assert.fail("Should have thrown an error");
} catch (err) {
// has_one 约束或 seeds 不匹配
console.log("Expected error: unauthorized reset attempt blocked");
}
});
});
PDA 地址推导的完整流程图
客户端 (TypeScript):
┌─────────────────────────────────────────────┐
│ const [pda, bump] = findProgramAddressSync( │
│ [Buffer.from("counter"), user.toBuffer()],│
│ programId │
│ ); │
│ // 得到 pda = 7xKXt... , bump = 254 │
└──────────────────────┬──────────────────────┘
│
▼ 发送交易,传入 counterPDA 作为账户
┌─────────────────────────────────────────────┐
│ Solana Runtime │
└──────────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Anchor Framework 检查 │
│ │
│ 1. 读取 seeds = [b"counter", user.key()] │
│ 2. 计算 expected_pda = findProgramAddress( │
│ seeds, program_id) │
│ 3. 验证: expected_pda == 传入的 counter 地址 │
│ → 不匹配 → Error: ConstraintSeeds │
│ → 匹配 → 继续执行 │
│ 4. 如果 init → 调用 System Program │
│ create_account(pda, space, rent) │
│ 5. 执行程序逻辑 │
└─────────────────────────────────────────────┘
关键要点总结
PDA 的五个核心特性
1. 确定性 (Deterministic)
→ 同样的 seeds + program_id → 永远得到同样的地址
→ 客户端和程序端都能独立计算,无需额外查询
2. 无私钥 (No Private Key)
→ 不在 ed25519 曲线上 → 不存在对应的私钥
→ 没有人能伪造 PDA 的签名 → 安全
3. 程序可签名 (Program-Signable)
→ 通过 invoke_signed(seeds, bump) 实现
→ 只有创建 PDA 的程序能代签 → 权限控制
4. 唯一性 (Uniqueness)
→ seeds 组合不同 → PDA 不同
→ 每个用户的 counter → 不同的 PDA
5. 可寻址 (Addressable)
→ 客户端知道 seeds → 能算出地址 → 不需要存储映射关系
→ 类似于哈希表: seeds 是 key, PDA 是 value 的地址
Anchor PDA 使用清单
初始化 PDA 账户:
✓ 使用 init 约束
✓ 指定 payer(谁付 rent)
✓ 计算正确的 space(别忘了 8 bytes discriminator)
✓ 定义 seeds(确保唯一且可重现)
✓ 添加 bump(让 Anchor 自动处理)
✓ 保存 bump 到账户数据中(供后续使用)
使用已有 PDA 账户:
✓ 使用 mut(如果要修改)
✓ 提供相同的 seeds
✓ 用 bump = account.bump 验证
✓ 可选: has_one 验证关联字段
常见误区
误区 1: "PDA 是一种特殊的账户类型"
✗ 错误: PDA 是 Solana 中一种特殊的 Account
✓ 正确: PDA 只是一种特殊的地址推导方式
PDA 地址对应的账户和普通账户完全一样:
- 同样有 lamports, data, owner, executable 字段
- 只是地址的生成方式不同
- "特殊"在于: 没有私钥 → 只有程序能控制
误区 2: "bump 可以随便选一个有效值"
✗ 错误: 只要不在曲线上的 bump 都可以用
✓ 正确: 始终使用 canonical bump(findProgramAddress 返回的那个)
为什么?
- 如果不同的客户端用不同的 bump → 推导出不同的地址
- canonical bump 是唯一确定的 → 所有人计算结果一致
- Anchor 的 bump 约束自动处理这个问题
误区 3: "PDA 和 Ethereum 的 CREATE2 一样"
有相似性也有本质区别:
相似: 都是确定性地址推导
- CREATE2: hash(0xFF, sender, salt, bytecode)
- PDA: hash(seeds, bump, program_id)
区别:
- CREATE2 地址可以部署合约 → 有代码
- PDA 只是数据账户 → 没有代码
- CREATE2 没有"程序可签名"的概念
- PDA 的 bump 确保地址不在曲线上(CREATE2 无此约束)
误区 4: "space 只需要计算数据字段的大小"
✗ 错误: space = 32 + 8 + 1 = 41 bytes
✓ 正确: space = 8 + 32 + 8 + 1 = 49 bytes
永远记住加上 8 bytes 的 Anchor discriminator!
- discriminator 用于区分不同类型的 account
- 如果 space 不够 → 序列化时会 panic
- 如果 space 太大 → 浪费 rent(SOL)
面试关联
Q1: 什么是 PDA?为什么 Solana 需要它?
30 秒回答:
PDA 是 Program Derived Address,一种由 seeds + program_id 确定性推导出来的地址,不在 ed25519 曲线上因此没有对应的私钥。Solana 需要它是因为代码与数据分离——程序没有自己的存储空间,需要 PDA 来拥有和管理状态账户,同时通过 invoke_signed 实现程序对 PDA 账户的签名控制。
追问: PDA 和 Ethereum 的 mapping 有什么可类比性?
Ethereum:
mapping(address => uint256) public balances;
// 在 storage slot 中通过 keccak256(key, slot) 定位
Solana PDA:
seeds = ["balance", user.key()]
// 通过 SHA256(seeds, bump, programId) 推导地址
// 每个 key 对应一个独立的 Account
本质: 都是 "key → value" 的映射
区别:
- Ethereum 的 mapping 在单个合约的 storage 中
- Solana 的 PDA 是独立的 Account,可以并行读写(性能优势!)
Q2: Solana 程序如何实现访问控制?
方法 1: PDA seeds 中包含用户公钥
seeds = ["data", user.key()]
→ 只有该用户的交易才能匹配这个 PDA
方法 2: has_one 约束
#[account(has_one = authority)]
→ 验证账户中的 authority 字段等于传入的签名者
方法 3: constraint 约束
#[account(constraint = counter.authority == signer.key())]
→ 自定义条件检查
方法 4: 多签 / DAO 投票
→ 通过 PDA 管理的多签账户控制权限
参考资源
| 资源 | 说明 |
|---|---|
| Solana Cookbook - PDAs | PDA 官方指南 |
| Anchor Book - PDAs | Anchor 框架中 PDA 的使用 |
| Solana Docs - Program Derived Addresses | Solana 官方文档 |
| Anchor Constraints Reference | 所有 Anchor 约束的参考文档 |
| Understanding PDAs - Solana Dev | SolDev 交互式教程 |