Skip to main content

Solana核心概念: Solana程序派生地址 (PDA)

鱼雪

在Solana中,程序派生地址(PDA有两个主要用途

  1. 确定性账户地址:通过组合可选“种子”(预定义输入)和特定程序ID来确定性地派生地址。
  2. 启用程序签名:Solana运行时允许程序对派生自其程序ID的PDA进行“签名”。

可以将PDA看作是通过预定义输入(例如字符串、数字和其他账户地址)在链上创建类似哈希表结构的一种方式。

这种方法的好处在于,它消除了对准确地址的跟踪需求。 相反,您只需记住用于其派生的特定输入。

PDA

重要的是要明白,简单地派生程序派生地址(PDA)不会自动在该地址上创建链上账户。 以 PDA 作为链上地址的账户必须通过用于派生地址的程序明确创建。 你可以将派生 PDA 理解为在地图上找到地址。仅有地址并不意味着该位置上有任何建筑物。

info

本节将涵盖有关推导 PDAs 的详细信息。 有关程序如何使用 PDAs 进行签名的细节将在跨程序调用 (CPIs) 部分中进行讨论, 因为这需要两个概念的上下文。

关键点

  • 通过使用用户定义的种子、bump seed 和程序的 ID 的组合,确定性地推导出 PDAs 地址。
  • PDAs 是指偏离 Ed25519 曲线且没有相应私钥的地址。
  • Solana 程序可以通过其程序 ID 为使用该程序 ID 推导出的 PDAs 进行程序化“签名”。
  • 推导 PDA 不会自动创建链上账户。
  • 使用 PDA 作为地址的账户必须通过 Solana 程序内的专用指令显式创建。

什么是PDA

PDA是确定性派生的地址,类似标准公钥,但没有关联的私钥。

外部用户无法为该地址生成有效签名。

然而,Solana运行时允许程序无需私钥即可程序化地对PDA进行“签名”。

在上下文中,Solana Keypairs 是 Ed25519 曲线上的点(椭圆曲线加密),具有公钥和对应的私钥。

我们经常使用公钥作为新的链上账户的唯一标识符,私钥用于签名

在曲线上的地址

PDA 是使用预定义的一组输入故意导出到 Ed25519 曲线之外的点。

不在 Ed25519 曲线上的点没有有效的对应私钥,无法用于加密操作(签名)。

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

弧线以外的地址

如何派生PDA

派生PDA需要三个输入:

  1. 可选种子:用于派生PDA的预定义输入(如字符串、数字、其他账户地址), 这些输入转换为字节缓冲区。
  2. 递增种子:保证生成有效PDA的附加输入(值在2550之间)。 递增种子附加到可选种子后用于生成PDA,将点“推离”Ed25519曲线。
  3. 程序ID:用于派生PDA的程序地址,也是可以代表PDA“签名”的程序。

使用findProgramAddressSync方法可以派生PDA,输入可选种子的字节缓冲区和程序ID。 找到有效PDA后,该方法返回地址(PDA)和递增种子。

PDA推导

以下示例包含指向Solana Playground的链接,在那里您可以在基于浏览器的编辑器中运行示例。

示例代码

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

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

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

找到有效的PDA后,findProgramAddressSync 方法将返回PDA的地址和用于衍生PDA的bump seed

以下示例衍生一个PDA,而不提供任何可选seeds

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 上运行此示例。PDAbump seed 输出将始终相同。

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 和 增量种子 的输出将始终相同:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X
Bump: 254

请注意,增量种子为254。这意味着255导出了Ed25519曲线上的一个点,并不是有效的PDA

findProgramAddressSync 返回的增量种子是在给定的可选种子和程序 ID 组合中导出有效 PDA 的第一个值(介于255-0之间)。

info

这个第一个有效的提升种子被称为“规范提升”。 为了程序安全起见,在处理 PDAs 时建议只使用规范提升。

生成PDA账户

在底层,findProgramAddressSync 会迭代地将一个额外的bump seed (nonce) 附加到种子缓冲区并调用 createProgramAddressSync 方法。 bump seed 从值为 255 开始,每次减 1,直到找到有效的 PDA (脱轨)。

您可以通过使用 createProgramAddressSync 并显式传递 254 的增量种子来复制先前的示例。

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}`);

在Solana Playground上运行上述示例。在使用相同的seeds和程序ID的情况下,PDA输出将与先前的匹配:

PDA: 46GZzzetjCURsdFPb7rcnspbEMnCBXe9kpjrsZAkKb6X

Canonical Bump

“规范碰撞” 指的是从 255 开始递减到 1 的第一个碰撞 seed,从而派生出一个有效的 PDA。 出于程序安全考虑,建议仅使用从规范碰撞派生的 PDA。

使用上面的例子作为参考,下面的示例尝试使用从 255-0 的每个碰撞 seed 来派生一个 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 上运行示例,您应该会看到以下输出:

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
...
// remaining bump outputs

正如预期的那样,bump 种子 255 会引发错误,第一个用于导出有效 PDA 的 bump 种子是 254

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

warning

在构建Solana程序时,建议包含安全检查,验证传递给程序的PDA是否使用规范的增量派生。 如果未这样做,可能会引入漏洞,允许向程序提供意外的帐户。

创建PDA账户

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

lib.rs 文件中,您将找到以下程序,其中包含一个指令,用于使用 PDA 作为账户地址创建新账户。

新账户存储用户地址和用于派生 PDA 的递增种子。

lib.rs
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 的种子包括指令中提供的user帐户的硬编码字符串data和地址。 Anchor 框架会自动派生规范化的bump种子。

#[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)中, 您将找到派生 PDA 的 JavaScript 等效代码。

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指令,则交易将失败。

这是因为在派生地址处已经存在一个帐户。

总结

程序派生地址 (PDA) 概述

在Solana中,程序派生地址(PDA)有两个主要用途:

  1. 确定性账户地址:通过组合可选的“种子”和程序ID来确定性地生成地址。
  2. 程序签名:Solana运行时允许程序对PDA进行签名。

关键特点

  • 确定性派生:PDA通过用户定义的种子、递增种子(bump seed)和程序ID组合确定性地派生。
  • 无私钥:PDA没有对应的私钥,因此没有用户可以对其生成有效签名,但程序可以进行签名。
  • 显式创建账户:派生PDA不会自动创建链上账户,必须通过程序显式创建。

PDA的派生过程

派生PDA需要三个输入:

  1. 可选种子:预定义的输入(如字符串、数字、其他账户地址)。
  2. 递增种子:用于保证生成有效PDA的附加输入,值在255到0之间。
  3. 程序ID:用于派生PDA的程序地址。

使用这些输入,程序可以确定性地生成唯一的PDA。

示例代码

以下示例展示了如何使用findProgramAddressSync方法派生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}`);

增加可选种子“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}`);

创建PDA账户

以下示例展示了如何使用PDA作为新账户的地址创建账户(使用Anchor框架编写):

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;
account_data.user = *ctx.accounts.user.key;
account_data.bump = ctx.bumps.pda_account;
Ok(())
}
}

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

#[account(
init,
seeds = [b"data", user.key().as_ref()],
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,以确保程序安全。
  • 账户创建:使用相同用户地址作为种子多次调用initialize指令会失败,因为在派生地址处已经存在账户。