程序派生地址 (PDAs) 为 Solana 上的开发者提供了两个主要用例:

  • 确定性账户地址: PDAs 提供了一种机制,通过组合可选的“种子”(预定义输入)和 特定的程序 ID 来确定性地派生一个地址。
  • 启用程序签名: Solana 运行时允许程序为从其程序 ID 派生的 PDAs “签名”。

你可以将 PDAs 视为一种通过预定义的一组输入(例如字符串、数字和其他账户地址)在链 上创建类似哈希映射结构的方法。
这种方法的好处是消除了跟踪确切地址的需要。 相反,你只需记住用于派生地址的特定输 入。

重要的是要理解,简单地派生一个程序派生地址(PDA)并不会自动在该地址创建一个链上 账户。具有 PDA 作为链上地址的账户必须通过用于派生地址的程序显式创建。 你可以将派 生 PDA 视为在地图上找到一个地址。 仅仅拥有一个地址并不意味着在该位置有构建内任何 内容。

本节将介绍派生 PDAs 的详细信息。 有关程序如何使用 PDAs 进行签名的详细信息将 在跨程序调用(CPIs) 一节中介绍,因为它需要这两个概念的上 下文。

关键点

  • PDAs 是使用用户定义的种子、一个 bump 种子和程序 ID 的组合确定性派生的地址。
  • PDAs 是落在 Ed25519 曲线之外的地址,没有对应的私钥。
  • Solana 程序可以以编程方式代表使用其程序 ID 派生的 PDA 进行“签名”。
  • 派生 PDA 并不会自动创建链上账户。
  • 使用 PDA 作为地址的账户必须通过 Solana 程序中的专用指令显式创建。

什么是 PDA

PDAs 是确定性派生的地址,看起来像标准的公钥,但没有关联的私钥。 这意味着没有外部 用户可以为该地址生成有效的签名。 然而,Solana 运行时允许程序以编程方式为 PDAs“签 名”而无需私钥。

作为背景,Solana Keypairs 是 Ed25519 曲线(椭圆曲线加密)上的点,具有公钥和对应的私钥。 我们通常使用公钥作 为新链上账户的唯一 ID,并使用私钥进行签名。

PDA 是一个通过预定义的一组输入故意派生到 Ed25519 曲线之外的点。 一个不在 Ed25519 曲线上的点没有有效的对应私钥,不能用于加密操作(签名)。

然后,PDA 可以用作链上账户的地址(唯一标识符),提供一种轻松存储、映射和获取程序 状态的方法。

如何派生 PDA

派生 PDA 需要 3 个输入。

  • 可选种子:用于派生 PDA 的预定义输入(例如字符串、数字、其他账户地址)。这 些输入被转换为字节缓冲区。 这些输入被转换为字节缓冲区。
  • Bump 种子:一个附加输入(值在 255-0 之间),用于保证生成有效的 PDA(曲线 外)。 生成 PDA 时,将 bump 种子(从 255 开始)附加到可选种子,以将点“推离 ”Ed25519 曲线。 bump 种子有时被称为“nonce”。
  • 程序 ID:PDA 派生自的程序地址。 这也是可以代表 PDA“签名”的程序。

下面的示例包括链接到 Solana Playground,你可以在浏览器编辑器中运行这些示例。

FindProgramAddress

要派生 PDA,我们可以使用 findProgramAddressSync 方法,该方法来自 @solana/web3.js。其他编程语言 (例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。其他编程语 言(例如 Rust) 中也有此函数的等价物,但在本节中,我们将通过 Javascript 示例进行讲解。

使用findProgramAddressSync方法时,我们传入:

  • 转换为字节缓冲区的预定义可选种子,以及
  • 用于派生 PDA 的程序 ID(地址)

一旦找到有效的 PDA,findProgramAddressSync将返回派生 PDA 的地址(PDA)和 bump 种子。

下面的示例在没有提供任何可选种子的情况下派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");

const [PDA, bump] = PublicKey.findProgramAddressSync([], programId);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你可以在 Solana Playground 上 运行此示例。 PDA 和 bump 种子的输出将始终相同:

Running client...
  client.ts:
    PDA: Cu7NwqCXSmsR5vgGA3Vw9uYVViPi3kQvkbKByVQ8nPY9
    Bump: 255

下面的示例添加了一个可选种子"helloWorld"。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

const [PDA, bump] = PublicKey.findProgramAddressSync(
  [Buffer.from(string)],
  programId,
);

console.log(`PDA: ${PDA}`);
console.log(`Bump: ${bump}`);

你也可以在 Solana Playground 上运行此示例。 PDA 和 bump 种子的输出将始终相同:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
Bump: 254

注意,bump 种子是 254。这意味着 255 派生了 Ed25519 曲线 上的一个点,并不是一个有效的 PDA。
findProgramAddressSync返回的 bump 种子是给定可选种子和程序 ID 组合的第一个值 (在 255-0 之间),该值派生了一个有效的 PDA。

这个第一个有效的 bump 种子被称为“规范 bump”。 为了程序安全,建议在使用 PDAs 时 仅使用规范 bump。

CreateProgramAddress

在底层,findProgramAddressSync将迭代地将附加的 bump 种子(nonce)附加到种子缓 冲区,并调用 createProgramAddressSync 方法。bump 种子从 255 开始,每次减少 1,直到找到有效的 PDA(曲线外)

你可以通过使用createProgramAddressSync并显式传入 254 的 bump 种子来复制前面的 示例

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";
const bump = 254;

const PDA = PublicKey.createProgramAddressSync(
  [Buffer.from(string), Buffer.from([bump])],
  programId,
);

console.log(`PDA: ${PDA}`);

规范 Bump 在 Solana Playground 上运行上述示例。给定相同的种子和程序 ID,PDA 输出将与前一个匹配:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X

规范 Bump

“规范 bump”是指派生有效 PDA 的第一个 bump 种子(从 255 开始,每次减少 1)。 为了 程序安全,建议仅使用从规范 bump 派生的 PDAs。

以之前的示例为参考,下面的示例尝试使用从 255 到 0 的每个 bump 种子派生 PDA。

import { PublicKey } from "@solana/web3.js";

const programId = new PublicKey("11111111111111111111111111111111");
const string = "helloWorld";

// Loop through all bump seeds for demonstration
for (let bump = 255; bump >= 0; bump--) {
  try {
    const PDA = PublicKey.createProgramAddressSync(
      [Buffer.from(string), Buffer.from([bump])],
      programId,
    );
    console.log("bump " + bump + ": " + PDA);
  } catch (error) {
    console.log("bump " + bump + ": " + error);
  }
}

Solana Playground 上运行该 示例,你应该会看到以下输出:

Running client...
  client.ts:
    bump 255: Error: Invalid seeds, address must fall off the curve
    bump 254: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
    bump 253: GBNWBGxKmdcd7JrMnBdZke9Fumj9sir4rpbruwEGmR4y
    bump 252: THfBMgduMonjaNsCisKa7Qz2cBoG1VCUYHyso7UXYHH
    bump 251: EuRrNqJAofo7y3Jy6MGvF7eZAYegqYTwH2dnLCwDDGdP
    bump 250: Error: Invalid seeds, address must fall off the curve
    bump 249: Es18AFZ4N7U4aBKTVvq5GzpUxWseFzxxQuvpZaxne38Y
    bump 248: 8VzFABVVp8RRTzhmCpeMgYo8t3CYERtVpi646BabfV18
    ...

正如预期的那样,bump seed 255 会抛出错误,第一个导出有效 PDA 的 bump seed 是 254。

但是,请注意 bump seeds 253-251 都会导出具有不同地址的有效 PDA。 这意味着在给定 相同的可选种子和 programId 的情况下,具有不同值的 bump seed 仍然可以导出有效的 PDA。

在构建 Solana 程序时,建议包括安全检查,以验证传递给程序的 PDA 是使用规范的 bump 导出的。如果不这样做,可能会引入漏洞,允许向程序提供意外的账户。

创建 PDA 账户

这个在 Solana Playground 上的示例程序演示了如何使用 PDA 作为新账户的地址来创建账户。 示例程序是使用 Anchor 框架编写的。

在 lib.rs 文件中,你会发现以下程序,其中包括一个使用 PDA 作为账户地址创建新账 户的指令。 新账户存储了 user 的地址和用于导出 PDA 的 bump seed。

use anchor_lang::prelude::*;

declare_id!("75GJVCJNhaukaa2vCCqhreY31gaphv7XTScBChmr1ueR");

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

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let account_data = &mut ctx.accounts.pda_account;
        // store the address of the `user`
        account_data.user = *ctx.accounts.user.key;
        // store the canonical bump
        account_data.bump = ctx.bumps.pda_account;
        Ok(())
    }
}

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

    #[account(
        init,
        // set the seeds to derive the PDA
        seeds = [b"data", user.key().as_ref()],
        // use the canonical bump
        bump,
        payer = user,
        space = 8 + DataAccount::INIT_SPACE
    )]
    pub pda_account: Account<'info, DataAccount>,
    pub system_program: Program<'info, System>,
}

#[account]

#[derive(InitSpace)]
pub struct DataAccount {
    pub user: Pubkey,
    pub bump: u8,
}

用于导出 PDA 的种子包括硬编码字符串 data 和指令中提供的 user 账户的地址。 Anchor 框架会自动导出规范的 bump seed。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

init 约束指示 Anchor 调用系统程序,使用 PDA 作为地址创建新账户。 在底层,这是 通过 CPI 完成的。

#[account(
    init,
    seeds = [b"data", user.key().as_ref()],
    bump,
    payer = user,
    space = 8 + DataAccount::INIT_SPACE
)]
pub pda_account: Account<'info, DataAccount>,

在上述 Solana Playground 链接中的测试文件 (pda-account.test.ts) 中,你会找到等 效的 Javascript 代码来导出 PDA。

const [PDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("data"), user.publicKey.toBuffer()],
  program.programId,
);

然后发送一个交易来调用 initialize 指令,使用 PDA 作为地址创建一个新的链上账 户。 交易发送后,使用 PDA 来获取在该地址创建的链上账户。

it("Is initialized!", async () => {
  const transactionSignature = await program.methods
    .initialize()
    .accounts({
      user: user.publicKey,
      pdaAccount: PDA,
    })
    .rpc();

  console.log("Transaction Signature:", transactionSignature);
});

it("Fetch Account", async () => {
  const pdaAccount = await program.account.dataAccount.fetch(PDA);
  console.log(JSON.stringify(pdaAccount, null, 2));
});

请注意,如果使用相同的 user 地址作为种子多次调用 initialize 指令,则交易将失 败。 这是因为在导出的地址上已经存在一个账户。