跳到主要内容

理解 Solana 中的账户所有权:将 SOL 转出 PDA

solana account owner

在 Solana 中,账户的所有者能够减少 SOL 余额、向账户写入数据以及更改所有者。

以下是 Solana 中账户所有权的摘要:

1) 系统程序 拥有尚未分配所有权给程序(已初始化)的钱包和密钥对账户。 2) BPFLoader 拥有程序。 3) 程序拥有 Solana PDA。如果所有权已转移给程序,则它也可以拥有密钥对账户(这是在初始化期间发生的事情)。

现在我们来研究这些事实的影响。

系统程序拥有密钥对账户

为了说明这一点,让我们使用 Solana CLI 查看我们的 Solana 钱包地址并检查其元数据:

system program

请注意,所有者不是我们的地址,而是一个地址为 111…111 的账户。这是系统程序,与我们在早期教程中看到的那个移动 SOL 的系统程序相同。

只有账户的所有者才能修改其中的数据

这包括减少 lamport 数据(你无需是所有者即可增加另一个账户的 lamport 数据,我们稍后会看到)。

尽管你在某种形而上学意义上“拥有”你的钱包,但从 Solana 运行时的角度来看,你无法直接向其中写入数据或减少 lamport 余额,因为你不是所有者。

你之所以能够在你的钱包中花费 SOL,是因为你拥有生成该地址或公钥的私钥。当系统程序认识到你为公钥生成了有效签名时,它将认可你请求花费账户中的 lamports 是合法的,然后根据你的指示花费它们。

然而,系统程序并没有提供一个机制,让签名者直接向账户写入数据。

上面示例中显示的账户是一个密钥对账户,或者我们可能认为是一个“常规 Solana 钱包”。系统程序是密钥对账户的所有者。

程序初始化的 PDAs 和密钥对账户由程序拥有

程序可以写入由程序初始化但在程序外创建的 PDA 或密钥对账户的原因是因为程序拥有它们。

当我们讨论重新初始化攻击时,我们将更仔细地探讨初始化,但现在,重要的一点是初始化账户会将账户的所有者从系统程序更改为程序。

为了说明这一点,考虑以下初始化 PDA 和密钥对账户的程序。Typescript 测试将在初始化事务之前和之后记录所有者。

如果我们尝试确定一个不存在的地址的所有者,我们会得到一个 null

以下是 Rust 代码:

use anchor_lang::prelude::*;

declare_id!("C2ZKJPhNiCM6CqTneGUXJoE4o6YhMzNUes3q5WNcH3un");

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

pub fn initialize_keypair(ctx: Context<InitializeKeypair>) -> Result<()> {
Ok(())
}

pub fn initialize_pda(ctx: Context<InitializePda>) -> Result<()> {
Ok(())
}
}

#[derive(Accounts)]
pub struct InitializeKeypair<'info> {
#[account(init, payer = signer, space = 8)]
keypair: Account<'info, Keypair>,
#[account(mut)]
signer: Signer<'info>,
system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct InitializePda<'info> {
#[account(init, payer = signer, space = 8, seeds = [], bump)]
pda: Account<'info, Pda>,
#[account(mut)]
signer: Signer<'info>,
system_program: Program<'info, System>,
}

#[account]
pub struct Keypair();

#[account]
pub struct Pda();

以下是 Typescript 代码:

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

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("owner", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());

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

it("Is initialized!", async () => {
console.log("program address", program.programId.toBase58());
const seeds = []
const [pda, bump_] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);

console.log("owner of pda before initialize:",
await anchor.getProvider().connection.getAccountInfo(pda));

await program.methods.initializePda()
.accounts({pda: pda}).rpc();

console.log("owner of pda after initialize:",
(await anchor.getProvider().connection.getAccountInfo(pda)).owner.toBase58());

let keypair = anchor.web3.Keypair.generate();

console.log("owner of keypair before airdrop:",
await anchor.getProvider().connection.getAccountInfo(keypair.publicKey));

await airdropSol(keypair.publicKey, 1); // 1 SOL

console.log("owner of keypair after airdrop:",
(await anchor.getProvider().connection.getAccountInfo(keypair.publicKey)).owner.toBase58());

await program.methods.initializeKeypair()
.accounts({keypair: keypair.publicKey})
.signers([keypair]) // the signer must be the keypair
.rpc();

console.log("owner of keypair after initialize:",
(await anchor.getProvider().connection.getAccountInfo(keypair.publicKey)).owner.toBase58());

});
});

测试的工作方式如下:

1) 预测 PDA 的地址并查询所有者。得到 null。 2) 调用 initializePDA 然后查询所有者。得到程序的地址。 3) 生成一个密钥对账户并查询所有者。得到 null。 4) 向密钥对账户空投 SOL。现在所有者是系统程序,就像一个普通的钱包一样。 5) 调用 initializeKeypair 然后查询所有者。得到程序的地址。

测试结果截图如下:

print solana account owner

这就是程序能够向账户写入数据的方式:它们拥有这些账户。在初始化期间,程序接管了账户的所有权。

练习: 修改测试以打印密钥对和 PDA 的地址。然后使用 Solana CLI 检查这些账户的所有者是谁。它应该与测试打印的内容相匹配。确保 solana-test-validator 在后台运行,以便你可以使用 CLI。

BPFLoaderUpgradeable 拥有程序

让我们使用 Solana CLI 确定我们的程序的所有者:

BPFLoaderUpgradeable

部署程序的钱包并不是其所有者。Solana 程序之所以能够被部署的钱包升级,是因为 BpfLoaderUpgradeable 能够向程序写入新的字节码,并且它只会接受来自预先指定地址的新字节码:最初部署程序的地址。

当我们部署(或升级)一个程序时,实际上是在调用 BPFLoaderUpgradeable 程序,如日志所示:

BPFLoader call

程序可以转移拥有账户的所有权

这可能是你不太经常使用的功能,但以下是执行此操作的代码。

Rust:

use anchor_lang::prelude::*;
use std::mem::size_of;
use anchor_lang::system_program;

declare_id!("Hxj38tktrD7YcSvKRxVrYQfxptkZd7NVbmrRKvLxznyA");


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

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}

pub fn change_owner(ctx: Context<ChangeOwner>) -> Result<()> {
let account_info = &mut ctx.accounts.my_storage.to_account_info();

// assign is the function to transfer ownership
account_info.assign(&system_program::ID);

// we must erase all the data in the account or the transfer will fail
let res = account_info.realloc(0, false);

if !res.is_ok() {
return err!(Err::ReallocFailed);
}

Ok(())
}
}

#[error_code]
pub enum Err {
#[msg("realloc failed")]
ReallocFailed,
}

#[derive(Accounts)]
pub struct Initialize<'info> {

#[account(init,
payer = signer,
space=size_of::<MyStorage>() + 8,
seeds = [],
bump)]
pub my_storage: Account<'info, MyStorage>,

#[account(mut)]
pub signer: Signer<'info>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct ChangeOwner<'info> {
#[account(mut)]
pub my_storage: Account<'info, MyStorage>,
}

#[account]
pub struct MyStorage {
x: u64,
}

Typescript:

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

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

describe("change_owner", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());

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

it("Is initialized!", async () => {
const deployer = anchor.web3.Keypair.fromSecretKey(Uint8Array.from(privateKey));

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

console.log("the storage account address is", myStorage.toBase58());

await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
await program.methods.changeOwner().accounts({myStorage: myStorage}).rpc();

// after the ownership has been transferred
// the account can still be initialized again
await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
});
});

以下是我们要注意的一些事项:

  • 在转移账户后,必须在同一事务中擦除数据。否则,我们可能会向其他程序拥有的账户插入数据。这是 account_info.realloc(0, false); 代码。false 表示不要清零数据,但这没有关系,因为数据已经不存在了。
  • 转移账户所有权并不会永久删除账户,它可以再次初始化,正如测试所示。

既然我们清楚地了解了程序拥有由它们初始化的 PDAs 和密钥对账户,我们可以做的有趣且有用的事情是将 SOL 从中转出。

从 PDA 转出 SOL:众筹示例

以下是一个简单的众筹应用程序的代码。感兴趣的函数是 withdraw 函数,其中程序将 lamports 从 PDA 转出并转给提款人。

use anchor_lang::prelude::*;
use anchor_lang::system_program;
use std::mem::size_of;
use std::str::FromStr;

declare_id!("BkthFL8LV2V2MxVgQtA9tT5goeeJhUdxRPahzavqHPFZ");

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

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
let initialized_pda = &mut ctx.accounts.pda;
Ok(())
}

pub fn donate(ctx: Context<Donate>, amount: u64) -> Result<()> {
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.signer.to_account_info().clone(),
to: ctx.accounts.pda.to_account_info().clone(),
},
);

system_program::transfer(cpi_context, amount)?;

Ok(())
}

pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
ctx.accounts.pda.sub_lamports(amount)?;
ctx.accounts.signer.add_lamports(amount)?;

// in anchor 0.28 or lower, use the following syntax:
// **ctx.accounts.pda.to_account_info().try_borrow_mut_lamports()? -= amount;
// **ctx.accounts.signer.to_account_info().try_borrow_mut_lamports()? += amount;
Ok(())
}
}

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

#[account(init, payer = signer, space=size_of::<Pda>() + 8, seeds=[], bump)]
pub pda: Account<'info, Pda>,
pub system_program: Program<'info, System>,
}

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

#[account(mut)]
pub pda: Account<'info, Pda>,
pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut, address = Pubkey::from_str("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj").unwrap())]
pub signer: Signer<'info>,

#[account(mut)]
pub pda: Account<'info, Pda>,
}

#[account]
pub struct Pda {}

因为程序拥有 PDA,所以它可以直接从账户中扣除 lamport 余额。

当我们作为正常钱包交易的一部分转移 SOL 时,我们不会直接扣除 lamport 余额,因为我们不是账户的所有者。系统程序拥有钱包,并且只有在看到请求其这样做的交易上有有效签名时,它才会扣除 lamport 余额。

在这种情况下,程序拥有 PDA,因此可以直接从中扣除 lamports。

代码中还值得注意的一些内容:

  • 我们硬编码了谁可以从 PDA 提取的约束,使用约束 #[account(mut, address = Pubkey::from_str("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj").unwrap())]。这检查该账户的地址是否与字符串中的地址匹配。为了使此代码工作,我们还需要导入 use std::str::FromStr;。要测试此代码,请将字符串中的地址更改为你的 solana address
  • 使用 Anchor 0.29,我们可以使用语法 ctx.accounts.pda.sub_lamports(amount)?;ctx.accounts.signer.add_lamports(amount)?;。对于 Anchor 的早期版本,请使用 ctx.accounts.pda.to_account_info().try_borrow_mut_lamports()? -= amount; ctx.accounts.signer.to_account_info().try_borrow_mut_lamports()? += amount;
  • 你不需要拥有你要转移 lamports 的账户。

以下是相应的 Typescript 代码:

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

describe("crowdfund", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());

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

it("Is initialized!", async () => {
const programId = await program.account.pda.programId;

let seeds = [];
let pdaAccount = anchor.web3.PublicKey.findProgramAddressSync(seeds, programId)[0];

const tx = await program.methods.initialize().accounts({
pda: pdaAccount
}).rpc();

// transfer 2 SOL
const tx2 = await program.methods.donate(new anchor.BN(2_000_000_000)).accounts({
pda: pdaAccount
}).rpc();

console.log("lamport balance of pdaAccount",
await anchor.getProvider().connection.getBalance(pdaAccount));

// transfer back 1 SOL
// the signer is the permitted address
await program.methods.withdraw(new anchor.BN(1_000_000_000)).accounts({
pda: pdaAccount
}).rpc();

console.log("lamport balance of pdaAccount",
await anchor.getProvider().connection.getBalance(pdaAccount));

});
});

练习: 尝试向接收地址添加比你从 PDA 提取的 lamports 更多的 lamports。即将代码更改为以下内容:

ctx.accounts.pda.sub_lamports(amount)?;
// sneak in an extra lamport
ctx.accounts.signer.add_lamports(amount + 1)?;

运行时应该会阻止你。

请注意,将 lamport 余额提取到低于租金免除阈值的账户将导致该账户被关闭。如果账户中有数据,那将被擦除。因此,程序应该在提取 SOL 之前跟踪需要多少 SOL 才能获得租金豁免,除非他们不在乎账户被擦除。

通过 RareSkills 了解更多

请查看我们的 Solana 教程 以获取完整的主题列表。