PDA(程序派生地址)与 Solana 中的密钥对账户

文章详细介绍了 Solana 区块链中的 Program Derived Address (PDA) 和 Keypair Account 的区别与使用场景,并通过代码示例展示了如何创建和初始化这两种账户,解释了它们的安全性和应用差异。

Hero image showing PDA \(Program Derived Address v.s. Keypair accounts\)

一个程序派生地址(PDA)是一个账户,其地址是由创建它的程序的地址和传递给 init 交易的 seeds 派生而来的。到目前为止,我们只使用了 PDAs。

也可以在程序外创建一个账户,然后在程序内对该账户进行 init

有趣的是,我们在程序外创建的账户将有一个私钥,但是我们会看到这并不会如看上去那样带来安全隐患。我们将其称为“keypair account”。

账户创建回顾

在讨论 keypair accounts 之前,让我们回顾一下我们在 Solana 教程 中创建账户的方法。这是我们使用的相同代码模版,它创建程序派生地址(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> {

    // 这是程序派生地址
    #[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("已初始化 -- PDA 版本", async () => {
    const seeds = []
    const [myPda, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

    console.log("存储账户地址是", myPda.toBase58());

    const tx = await program.methods.initializePda().accounts({myPda: myPda}).rpc();
  });
});

到目前为止,这些都应该是熟悉的,除了我们明确调用我们的账户为“PDA”。

程序派生地址

如果一个账户的地址是由程序的地址派生而来的,例如在 findProgramAddressSync(seeds, program.programId) 中的 programId,那么该账户就是程序派生地址(PDA)。它也是 seeds 的一个函数。

具体地说,我们知道它是一个 PDA,因为 seedsbump 出现在 init 宏中。

Keypair Account

以下代码将与上面的代码非常相似,但是请注意 init 宏缺少 seedsbump

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> {
    // 这是程序派生地址
    #[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,
}

seedbump 缺失时,Anchor 程序现在期望我们先创建一个账户,然后将该账户传递给程序。由于我们自己创建该账户,其地址将不会是“派生自”程序的地址。换句话说,它将不是程序派生账户(PDA)

为程序创建一个账户简单到只需生成一个新的 keypair(以与我们用来 测试不同签名者在 Anchor 中 相同的方式)。是的,这听起来可能有点可怕,因为我们持有用来存储数据的账户的秘密钥匙——我们稍后会重新讨论这一点。现在,这是创建新账户并将其传递给上述程序的 Typescript 代码。我们会对此中的重要部分进行强调:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

// 该函数向一个地址空投sol
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("已初始化 -- keypair 版本", async () => {

    const newKeypair = anchor.web3.Keypair.generate();
    await airdropSol(newKeypair.publicKey, 1e9); // 1 SOL

    console.log("keypair 账户地址是", newKeypair.publicKey.toBase58());

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // 签名者必须是keypair
      .rpc();
  });
});

我们想强调的几点:

  • 我们添加了一个工具函数 airdropSol,以将 SOL 空投到我们创建的 newKeypair。如果没有 SOL,将无法支付交易费用。此外,因为这是用来存储数据的相同账户,它需要一个 SOL 余额以 达到租金豁免。在空投 SOL 时,需要额外的 confirmTransaction 例程,因为在运行时关于 SOL 实际空投的时间以及交易确认时间之间似乎存在竞争条件。
  • 我们将 signers 从默认的一个改为 newKeypair。当创建 keypair 账户时,无法创建你不持有私钥的账户。

不可能初始化一个没有私钥的 keypair 账户

如果可以用任意地址创建账户,那将是一个重大的安全风险,因为你可以将恶意数据插入任意账户。

练习 : 修改测试以生成第二个 keypair secondKeypair。使用第二个 keypair 的公钥并将 .accounts({myKeypairAccount: newKeypair.publicKey}) 替换为 .accounts({myKeypairAccount: secondKeypair.publicKey})。不要更改签名者。你应该看到测试失败。你不需要给新 keypair 空投 SOL,因为它不是交易的签名者。

你应该看到类似以下的错误:

keypair_vs_pda : Error: unknown signer

如果我们尝试伪造 PDA 的地址会怎样?

练习 : 而不是从上述练习传递 secondKeypair,派生一个 PDA:

const seeds = []
const [pda, _bump] = anchor
                        .web3
                        .PublicKey
                        .findProgramAddressSync(
                            seeds,
                            program.programId);

然后将 myKeypairAccount 参数替换为 .accounts({myKeypairAccount: pda})

你应该再次看到 unknown signer 错误。

Solana 运行时不会让你这样做。如果程序突然出现未初始化的 PDAs,这将导致严重的安全问题。

拥有私钥账户是否有问题?

看起来持有私钥的人能够从账户中花费 SOL,并可能将其带入租金豁免阈值。可是,Solana 运行时在账户由程序初始化时防止这种情况发生。

为了查看这点,请考虑以下单元测试:

  • 在 Typescript 中创建一个 keypair 账户
  • 向 keypair 账户空投 SOL
  • 从 keypair 账户转移 SOL 到另一个地址(成功)
  • 初始化 keypair 账户
  • 尝试使用 keypair 作为签名者从 keypair 账户转移 SOL(失败)

代码如下所示:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

// 更改为你的路径
import privateKey from '/Users/RareSkills/.config/solana/id.json';

import { fs } from fs;

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", () => {
  const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;

  it("写入 keypair 账户失败", async () => {
    const newKeypair = anchor.web3.Keypair.generate();
    var recieverWallet = anchor.web3.Keypair.generate();

    await airdropSol(newKeypair.publicKey, 10);

    var transaction = new anchor.web3.Transaction().add(
      anchor.web3.SystemProgram.transfer({
        fromPubkey: newKeypair.publicKey,
        toPubkey: recieverWallet.publicKey,
        lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
      }),
    );
    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
    console.log('发送1 lamport') 

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // 签名者必须是keypair
      .rpc();

    console.log("已初始化");
    // 再次尝试转移,这会失败
    var transaction = new anchor.web3.Transaction().add(
      anchor.web3.SystemProgram.transfer({
        fromPubkey: newKeypair.publicKey,
        toPubkey: recieverWallet.publicKey,
        lamports: 1 * anchor.web3.LAMPORTS_PER_SOL,
      }),
    );
    await anchor.web3.sendAndConfirmTransaction(anchor.getProvider().connection, transaction, [newKeypair]);
  });
});

这里是预期的错误消息:

keypair_vs_pda : Error: failed to send transaction

即使我们持有该账户的私钥,我们也无法从账户中“花费” SOL,因为它现在由程序拥有。

所有权和初始化介绍

Solana 运行时如何知道在初始化后阻止 SOL 转移?

练习 : 将测试修改为以下代码。请注意已经添加的控制台日志语句。它们在记录账户中的“所有者”元数据字段和程序地址:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KeypairVsPda } from "../target/types/keypair_vs_pda";

import privateKey from '/Users/jeffreyscholz/.config/solana/id.json';

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", () => {
  const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.KeypairVsPda as Program<KeypairVsPda>;
  it("控制台记录账户所有者", async () => {

    console.log(`程序地址是 ${program.programId}`) 
    const newKeypair = anchor.web3.Keypair.generate();
    var recieverWallet = anchor.web3.Keypair.generate();

    // 在初始化之前获取账户所有者
    await airdropSol(newKeypair.publicKey, 10);
    const accountInfoBefore = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
    console.log(`初始 keypair 账户所有者是 ${accountInfoBefore.owner}`);

    await program.methods.initializeKeypairAccount()
      .accounts({myKeypairAccount: newKeypair.publicKey})
      .signers([newKeypair]) // 签名者必须是keypair
      .rpc();

    // 在初始化后获取账户所有者

    const accountInfoAfter = await anchor.getProvider().connection.getAccountInfo(newKeypair.publicKey);
    console.log(`初始 keypair 账户所有者是 ${accountInfoAfter.owner}`);
  });
});

下面是预期结果的截图:

keypair_vs_pda : 1 passing

初始化后,keypair 账户的所有者从 111...111 更改为部署的程序。我们尚未在我们的 Solana 教程 中深入探讨账户所有权或系统程序(所有为一的地址的重要性)。但是,这应该让你了解“初始化”在做什么,以及私钥的拥有者为何无法从账户中转移 SOL。

我是应该使用 PDA 还是 Keypair 账户?

一旦账户初始化,它们的行为是一样的,因此实际上没有太大区别。

唯一显著的差异(这不会影响大多数应用程序)是 PDA 只能用大小为 10,240 字节的账户进行初始化,而 keypair 账户可以初始化到最大的 10MB。然而,PDA 可以调整大小到 10MB 限制。

大多数应用程序使用 PDA,因为它们可以通过 seeds 参数以编程方式进行寻址,但是要访问 keypair 账户必须事先知道其地址。我们包含 keypair 账户的讨论是因为网上有几个教程将其用作示例,因此我们希望你有一些上下文。然而,在实践中,PDA 是存储数据的首选方式。

了解更多

继续学习我们的 Solana 课程

最初发布于 2024 年 3 月 6 日

  • 原文链接: rareskills.io/post/solan...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/