在 Solana 中,账户的所有者(owner)能够减少该账户的 SOL 余额、向账户写入数据以及更改所有权。
以下是 Solana 中账户所有权的总结:
system program拥有尚未将其所有权分配(初始化)给其他程序的钱包和密钥对(keypair)账户。- BPFLoader 拥有程序(programs)。
- 程序拥有 Solana PDA。如果所有权已转移给程序(即在初始化期间发生的情况),它也可以拥有密钥对账户。
我们现在来探讨这些事实背后的含义。
system program 拥有密钥对账户
为了说明这一点,让我们使用 Solana CLI 查看我们的 Solana 钱包地址并检查其元数据(metadata):

可以观察到所有者并不我们的地址,而是一个地址为 111...111 的账户。这就是系统程序(system program),与我们在之前教程中看到的用于转移 SOL 的 system program 是同一个。
只有账户的所有者才有能力修改其中的数据
这包括减少 lamport 数据(正如我们稍后会看到的,你不需要成为所有者也可以增加另一个账户的 lamport 数据)。
尽管从某种形而上学的角度来说你“拥有”你的钱包,但你并没有直接向其中写入数据或减少 lamport 余额的能力,因为从 Solana 运行时的角度来看,你并不是所有者。
你能够花费钱包中 SOL 的原因是,你拥有生成该地址(或公钥)的私钥。当 system program 识别出你为该公钥生成了有效的签名时,它就会承认你花费该账户中 lamport 的请求是合法的,然后根据你的指令花费它们。
然而,system program 并没有为签名者提供直接向账户写入数据的机制。
上面示例中显示的账户是一个密钥对账户,或者可以被视为“普通的 Solana 钱包”。system program 是密钥对账户的所有者。
由程序初始化的 PDA 和密钥对账户归该程序所有
程序能够写入 PDA 或在程序外部创建但由程序初始化的密钥对账户的原因,是因为程序拥有它们。
我们将在讨论重新初始化攻击(re-initialization attack)时更深入地探讨初始化,但目前,重要的结论是:初始化一个账户会将该账户的所有者从 system program 更改为执行初始化的程序。
为了说明这一点,请看以下初始化 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());
});
});
测试的执行过程如下:
- 它预测 PDA 的地址并查询所有者。得到
null。 - 它调用
initializePDA然后查询所有者。得到程序的地址。 - 它生成一个密钥对账户并查询所有者。得到
null。 - 它向该密钥对账户空投 SOL。现在所有者是 system program,就像普通的钱包一样。
- 它调用
initializeKeypair然后查询所有者。得到程序的地址。
测试结果截图如下:

这就是程序能够向账户写入数据的方式:它拥有这些账户。在初始化期间,程序获得了账户的所有权。
练习:修改测试以打印密钥对和 PDA 的地址。然后使用 Solana CLI 检查这些账户的所有者是谁。结果应该与测试打印的内容相匹配。请确保 solana-test-validator 正在后台运行,以便你能使用 CLI。
BPFLoaderUpgradeable 拥有程序
让我们使用 Solana CLI 来确定我们的程序的所有者:

部署程序的钱包并不是程序的所有者。部署钱包能够升级 Solana 程序的原因是 BPFLoaderUpgradeable 能够向程序写入新的字节码,并且它只接受来自预先指定地址的新字节码:即最初部署该程序的地址。
当我们部署(或升级)一个程序时,我们实际上是在调用 BPFLoaderUpgradeable 程序,正如日志中所示:
Signature: 2zBBEPWsMvf8t7wkNEDqfHJKw83aBMgwGi3G9uZ6m9qG9t4kjJA2wFEP84dkKCjiCdbh54xeEDYFeDcNS7FkyLEw
Status: Ok
Log Messages:
Program 11111111111111111111111111111111 invoke [1]
Program 11111111111111111111111111111111 success
Program BPFLoaderUpgradeab1e11111111111111111111111 invoke [1]
Program 11111111111111111111111111111111 invoke [2]
Program 11111111111111111111111111111111 success
Deployed program C2ZKJPhNiCM6CqTneGUXJoE4o6YhMzNUes3q5WNcH3un
Program BPFLoaderUpgradeab1e11111111111111111111111 success
Transaction executed in slot 34:
程序可以转移其所拥有账户的所有权
这可能是一个你不常使用的功能,但下面是实现它的代码。
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表示不将数据置零,但这没有什么区别,因为已经没有数据了。 - 转移账户所有权并不会永久删除该账户,正如测试所示,它可以被重新初始化。
既然我们已经清楚地了解程序拥有 PDA 以及由它们初始化的密钥对账户,我们可以做的一件有趣且有用的事情就是将 SOL 从中转出。
将 SOL 从 PDA 中转出:众筹示例
下面我们展示了一个最基础的众筹应用的代码。我们关注的函数是 withdraw,在这个函数中,程序将 lamport 从 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 余额,因为我们不是账户的所有者。system program 拥有钱包,如果它看到请求它这样做的交易上附有有效的签名,它就会扣除 lamport 余额。
在这种情况下,程序拥有该 PDA,因此可以直接从中扣除 lamport。
代码中还有一些值得注意的地方:
- 我们使用约束
#[account(mut, address = Pubkey::from_str("5jmigjgt77kAfKsHri3MHpMMFPo6UuiAMF19VdDfrrTj").unwrap())]硬编码了谁可以从 PDA 中提取。这会检查该账户的地址是否与字符串中的地址匹配。为了让这段代码工作,我们还需要导入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;。 - 你不需要拥有接收 lamport 转账的账户。
以下是配套的 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 提取的更多的 lamport。即将代码更改为如下所示:
ctx.accounts.pda.sub_lamports(amount)?;
// sneak in an extra lamport
ctx.accounts.signer.add_lamports(amount + 1)?;
运行时(runtime)会拦截你。
请注意,将 lamport 余额提取到低于免租金阈值(rent-exempt threshold)会导致账户被关闭。如果账户中有数据,它将被擦除。因此,程序在提取 SOL 之前应该追踪需要多少 SOL 用于免除租金,除非它们不关心账户是否被擦除。
通过 RareSkills 了解更多
请参阅我们的 Solana 教程 获取完整的主题列表。
原载于 2024 年 3 月 7 日