返回 SC 笔记
SC Day 37

Solana/Anchor - Counter 程序 + PDA (Program Derived Address)

### 一、什么是 PDA?为什么 Solana 必须有它?

2026-05-07
第二阶段:框架实战
SolanaAnchorPDACounterSeedsBump

日期: 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 普通密钥对

维度普通 KeypairPDA
生成方式随机生成私钥 → 派生公钥由 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 = useruser 支付账户的 rent 费用user 余额不足 → Error
space = 8 + 8账户需要 16 字节(8 discriminator + 8 数据)空间不够 → 序列化 Error
seeds = [...]PDA 的 seeds,Anchor 验证地址是否匹配地址不匹配 → Error
bumpAnchor 自动计算并验证 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 - PDAsPDA 官方指南
Anchor Book - PDAsAnchor 框架中 PDA 的使用
Solana Docs - Program Derived AddressesSolana 官方文档
Anchor Constraints Reference所有 Anchor 约束的参考文档
Understanding PDAs - Solana DevSolDev 交互式教程