PDA (Program Derived Address) 是一种账户,其地址由创建它的程序的地址以及传入 init 交易的 seeds 派生而来。到目前为止,我们只使用过 PDA。
我们也可以在程序外部创建一个账户,然后在程序内部对该账户进行 init。
有趣的是,我们在程序外部创建的账户会有一个私钥,但我们稍后会看到,这并不会带来表面上看起来的那种安全隐患。我们将这种账户称为 “keypair account”(密钥对账户)。
回顾账户创建
在深入探讨 keypair account 之前,让我们先回顾一下到目前为止我们在 Solana tutorials 中是如何创建账户的。这是我们一直在使用的相同模板代码,它创建的是程序派生地址 (PDA):
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("4wLnxvLwgXGT4eNg3D456K6Fxa1RieaUdERSPQ3WEpuV");
#[program]
pub mod keypair_vs_pda {
use super::*;
pub fn initialize_pda(ctx: Context<InitializePDA>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct InitializePDA<'info> {
// This is the program derived address
#[account(init,
payer = signer,
space=size_of::<MyPDA>() + 8,
seeds = [],
bump)]
pub my_pda: Account<'info, MyPDA>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyPDA {
x: u64,
}
以下是调用 initialize 的相关 Typescript 代码:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";
describe("keypair_vs_pda", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
it("Is initialized -- PDA version", async () => {
const seeds = [];
const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
console.log("the storage account address is", myPda.toBase58());
const tx = await program.methods.initializePda().accounts({myPda: myPda}).rpc();
});
});
到目前为止,所有这些内容应该都很熟悉,除了我们明确地将我们的账户称为 “PDA”。
Program Derived Address (程序派生地址)
如果一个账户的地址是从程序的地址派生出来的,即 findProgramAddressSync(seeds, program.programId) 中的 programId,那么该账户就是一个 Program Derived Address (PDA)。它也是 seeds 的函数。
具体来说,我们知道它是一个 PDA,因为 init 宏中存在 seeds 和 bump。
Keypair Account (密钥对账户)
下面的代码看起来与上面的代码非常相似,但请注意,这里的 init 宏缺少了 seeds 和 bump:
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("4wLnxvLwgXGT4eNg3D456K6Fxa1RieaUdERSPQ3WEpuV");
#[program]
pub mod keypair_vs_pda {
use super::*;
pub fn initialize_keypair_account(ctx: Context<InitializeKeypairAccount>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct InitializeKeypairAccount<'info> {
// This is the keypair account
#[account(init,
payer = signer,
space = size_of::<MyKeypairAccount>() + 8,)]
pub my_keypair_account: Account<'info, MyKeypairAccount>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyKeypairAccount {
x: u64,
}
当没有 seeds 和 bump 时,Anchor 程序现在期望我们先创建一个账户,然后将该账户传递给程序。由于账户是我们自己创建的,它的地址不会“派生自”程序的地址。换句话说,它不会是一个程序派生账户 (PDA)。
为程序创建账户非常简单,只需生成一个新的 keypair(就像我们以前在 test different signers in Anchor 中所做的那样)。是的,这听起来可能有点可怕,因为我们竟然持有程序用来存储数据的账户的私钥——我们稍后会重新讨论这个问题。现在,这是创建新账户并将其传递给上述程序的 Typescript 代码。接下来我们将重点说明其中的重要部分:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";
// this airdrops sol to an address
async function airdropSol(publicKey, amount) {
let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
await confirmTransaction(airdropTx);
}
async function confirmTransaction(tx) {
const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
await anchor.getProvider().connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: tx,
});
}
describe("keypair_vs_pda", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
it("Is initialized -- keypair version", async () => {
const newKeypair = anchor.web3.Keypair.generate();
await airdropSol(newKeypair.publicKey, 1e9); // 1 SOL
console.log("the keypair account address is", newKeypair.publicKey.toBase58());
await program.methods.initializeKeypairAccount()
.accounts({myKeypairAccount: newKeypair.publicKey})
.signers([newKeypair]) // the signer must be the keypair
.rpc();
});
});
我们希望引起注意的几点:
- 我们添加了一个实用函数
airdropSol,用于向我们创建的新 keypair(即newKeypair)空投 SOL。如果没有 SOL,它将无法支付交易费用。此外,由于这也是用于存储数据的同一个账户,它需要一定的 SOL 余额才能实现 rent exempt(免租金)。在空投 SOL 时,需要一个额外的confirmTransaction流程,因为在 SOL 实际空投的时间和交易被确认的时间之间,运行时(runtime)似乎存在竞态条件。 - 我们将
signers从默认的签名者改为了newKeypair。在创建 keypair account 时,你无法创建一个你不持有其私钥的账户。
无法初始化一个你不持有私钥的 keypair account
如果你可以创建一个具有任意地址的账户,那将是一个重大的安全风险,因为你可以将恶意数据插入到任意账户中。
练习:修改测试以生成第二个 keypair secondKeypair。使用第二个 keypair 的公钥,并将 .accounts({myKeypairAccount: newKeypair.publicKey}) 替换为 .accounts({myKeypairAccount: secondKeypair.publicKey})。不要更改签名者。你应该会看到测试失败。你不需要向新的 keypair 空投 SOL,因为它不是交易的签名者。
你应该会看到类似以下的错误:

如果我们试图伪造 PDA 的地址会怎样?
练习:不传入上面练习中的 secondKeypair,而是通过以下方式派生出一个 PDA:
const seeds = [];
const [pda, _bump] = anchor
.web3
.PublicKey
.findProgramAddressSync(
seeds,
program.programId);
然后替换 myKeypairAccount 参数 .accounts({myKeypairAccount: pda})。
你应该会再次看到一个 unknown signer 错误。
Solana 运行时不会允许你这样做。如果一个程序的 PDA 在没有被初始化的情况下突然出现,将会导致严重的安全问题。
有人拥有该账户的私钥是个问题吗?
看起来,拥有私钥的人似乎能够从该账户中花费 SOL,并可能使其余额低于免租金阈值。然而,当账户由程序初始化时,Solana 运行时会防止这种情况发生。
为了验证这一点,请看以下单元测试:
- 在 Typescript 中创建一个 keypair account
- 向该 keypair account 空投 SOL
- 将 SOL 从该 keypair account 转移到另一个地址(成功)
- 初始化该 keypair account
- 尝试使用该 keypair 作为签名者,从 keypair account 中转移 SOL(失败)
代码如下所示:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";
async function airdropSol(publicKey, amount) {
let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
await confirmTransaction(airdropTx);
}
async function confirmTransaction(tx) {
const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
await anchor.getProvider().connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: tx,
});
}
describe("keypair_vs_pda", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
it("Writing to keypair account fails", async () => {
const newKeypair = anchor.web3.Keypair.generate();
var receiverWallet = anchor.web3.Keypair.generate();
await airdropSol(newKeypair.publicKey, 10);
var transaction = new anchor.web3.Transaction().add(
anchor.web3.SystemProgram.transfer({
fromPubkey: newKeypair.publicKey,
toPubkey: receiverWallet.publicKey,
lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
}),
);
await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
console.log('sent 1 SOL');
await program.methods.initializeKeypairAccount()
.accounts({myKeypairAccount: newKeypair.publicKey})
.signers([newKeypair]) // the signer must be the keypair
.rpc();
console.log("initialized");
// try to transfer again, this fails
var transaction = new anchor.web3.Transaction().add(
anchor.web3.SystemProgram.transfer({
fromPubkey: newKeypair.publicKey,
toPubkey: receiverWallet.publicKey,
lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
}),
);
await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
});
});
以下是预期的错误信息:

即使我们持有该账户的私钥,我们也无法从该账户“花费 SOL”,因为它现在属于该程序所有。
所有权与初始化简介
Solana 运行时是如何知道在初始化后要阻止 SOL 转移的呢?
练习:将测试修改为以下代码。请注意已添加的 console log 语句。它们打印出了账户中的 “owner” 元数据字段以及程序的地址:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";
async function airdropSol(publicKey, amount) {
let airdropTx = await anchor.getProvider().connection.requestAirdrop(publicKey, amount * anchor.web3.LAMPORTS_PER_SOL);
await confirmTransaction(airdropTx);
}
async function confirmTransaction(tx) {
const latestBlockHash = await anchor.getProvider().connection.getLatestBlockhash();
await anchor.getProvider().connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: tx,
});
}
describe("keypair_vs_pda", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
it("Console log account owner", async () => {
console.log(`The program address is ${program.programId}`);
const newKeypair = anchor.web3.Keypair.generate();
// get account owner before initialization
await airdropSol(newKeypair.publicKey, 10);
const accountInfoBefore = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
console.log(`initial keypair account owner is ${accountInfoBefore.owner}`);
await program.methods.initializeKeypairAccount()
.accounts({myKeypairAccount: newKeypair.publicKey})
.signers([newKeypair]) // the signer must be the keypair
.rpc();
// get account owner after initialization
const accountInfoAfter = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
console.log(`initial keypair account owner is ${accountInfoAfter.owner}`);
});
});
下方的截图展示了预期的结果:

初始化后,keypair account 的所有者(owner)从 111...111 变成了已部署的程序。我们在目前的 Solana tutorials 中还没有深入讨论账户所有权或 system program(全为 1 的地址)的重要意义。不过,这应该能让你对“初始化”在做什么,以及为什么私钥的所有者不能再从账户中转移出 SOL 有一个概念。
我应该使用 PDA 还是 Keypair account?
一旦账户被初始化,它们的行为方式是相同的,因此实际上并没有太大的区别。
唯一显著的差异(这不会影响大多数应用)是 PDA 在初始化时只能设置为 10,240 字节大小,而 keypair account 可以初始化为最大 10 MB 的完整大小。不过,PDA 的大小后续也可以调整到 10 MB 的上限。
大多数应用都使用 PDA,因为可以通过 seeds 参数对它们进行编程式寻址,而要访问 keypair account,你必须预先知道它的地址。我们之所以加入对 keypair account 的讨论,是因为网上有几个教程使用它们作为示例,所以我们希望你能了解其中的背景。但在实践中,PDA 是存储数据的首选方式。
在 RareSkills 了解更多
继续学习我们的 Solana course 以获取更多知识!
原文发布于 2024 年 3 月 6 日