在 Solana Anchor 中向多个公钥转账的更好方式
结论
当前写法没有问题,不需要为了两笔转账改用 transfer_many。
system_instruction::transfer_many 只是批量调用 transfer 并返回一个 Vec<Instruction>,不会生成一条可同时向多个地址转账的 System Program instruction。在链上程序中,这些 instruction 仍需逐条 invoke。
当前执行流程包括:
- 一笔外层 Solana transaction
- 一次 Anchor 程序调用
- 两次对 System Program 的 CPI 调用
两次转账不会被当作两笔独立 transaction 收取基础交易费,但每次 CPI 都会消耗计算单元。
transfer_many 返回的内容
用法大致如下:
let transfers = [
(to_account.key(), amount),
(fee_account.key(), fee_amount),
];
let instructions =
system_instruction::transfer_many(&from_account.key(), &transfers);
返回值类型为:
Vec<Instruction>
instructions 中仍然是两条普通的 System Program transfer instruction:
instructions[0]: from -> to
instructions[1]: from -> feeto
invoke 和 invoke_signed 每次只能执行一条 instruction,无法直接接收整个 Vec<Instruction>,所以需要遍历列表并逐条调用。
在链上使用 transfer_many
如果要使用 transfer_many,可以这样写:
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{
program::invoke,
system_instruction,
};
pub fn transfer_lamports(
ctx: Context<TransferLamports>,
amount: u64,
) -> Result<()> {
let from = ctx.accounts.from.to_account_info();
let to = ctx.accounts.to.to_account_info();
let fee_to = ctx.accounts.feeto.to_account_info();
let system_program = ctx.accounts.system_program.to_account_info();
let recipients = [to, fee_to];
let transfers = [
(recipients[0].key(), amount),
(recipients[1].key(), amount),
];
let instructions =
system_instruction::transfer_many(&from.key(), &transfers);
for (instruction, recipient) in instructions.iter().zip(recipients.iter()) {
invoke(
instruction,
&[
from.clone(),
recipient.clone(),
system_program.clone(),
],
)?;
}
Ok(())
}
这与分别调用两次 transfer 和两次 invoke 的效果相同,区别只在于 instruction 列表由辅助函数统一生成。
转账目标不能只提供 Pubkey。执行 CPI 时,对应账户必须由调用方传入当前 transaction,并标记为 writable。程序不能随意生成一组公钥,再在缺少对应 AccountInfo 的情况下向这些地址转账。
更符合 Anchor 风格的写法
只有两个固定收款账户时,直接使用 Anchor 提供的 System Program CPI 包装会更清楚:
use anchor_lang::prelude::*;
use anchor_lang::system_program::{self, Transfer};
pub fn transfer_lamports(
ctx: Context<TransferLamports>,
amount: u64,
) -> Result<()> {
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
},
),
amount,
)?;
system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.feeto.to_account_info(),
},
),
amount,
)?;
Ok(())
}
这里的 from 是 Signer<'info>,签名由外层 transaction 提供,因此不需要 PDA signer seeds。原代码使用带空 seeds 的 invoke_signed(..., &[]) 也能工作,不过普通 invoke 或 Anchor 的 system_program::transfer 更符合这里的实际签名方式。
如果资金来自 PDA,应根据 PDA 的 ownership 和账户类型选择处理方式:
- 对于由 System Program 拥有且数据为空的 PDA,可以使用
invoke_signed调用 System Program,同时传入正确的 signer seeds。 - 对于当前程序拥有的账户,通常需要直接修改 lamports 来转出资金。来源账户不是合适的系统账户时,System Program 的普通
transfer可能会失败。
为什么只收取一笔 transaction fee
两次 CPI 都在同一笔外层 transaction 中执行。System Program CPI 不会创建新的 Solana transaction,所以不会分别产生两次基础交易费。
第二次转账仍然会产生执行成本,包括:
- 消耗计算单元;
- 要求目标账户出现在外层 transaction 的账户列表中;
- 增加账户数量和运行时处理量;
- 受到 transaction 计算预算、账户数量等限制。
如果配置了 priority fee,计算预算设置也可能影响整笔 transaction 的费用。Solana 不会因为调用了两次 transfer,就分别收取两笔 transaction fee。
两次转账会原子执行
同一次程序调用中的操作具有原子性。如果第一笔转账成功,而第二笔因余额不足或账户配置错误而失败,整个 transaction 都会回滚,第一笔转账也不会保留。
来源账户至少要能覆盖两笔转账的总额。当前示例中的总转账额为:
amount + amount
如果以后改为按比例收取费用,应先安全地计算主金额和费用金额,并检查加法、乘法是否可能溢出。
需要补充的账户检查
AccountInfo<'info> 不会验证账户用途或地址。如果 feeto 是固定的手续费接收账户,需要在账户约束或处理函数中验证其公钥,否则调用者可以传入任意 writable 账户作为 feeto。
地址固定时,可以添加以下约束:
#[account(
mut,
address = EXPECTED_FEE_ACCOUNT
)]
pub feeto: AccountInfo<'info>,
如果只允许普通系统账户接收 lamports,可以根据所用 Anchor 版本考虑使用 SystemAccount<'info>。如果业务允许其他类型的账户接收 lamports,则应保留更通用的账户类型,并自行完成必要检查。
收款地址数量动态变化时,可以通过 remaining_accounts 接收账户列表,再逐个构造和执行转账。此时需要限制最大收款数量、验证每个账户并计算总转账额,避免计算单元消耗或账户数量失控。
对于两个固定地址,直接执行两次 CPI 已经足够清楚。transfer_many 只是生成多条 instruction 的辅助函数,既不会把多笔转账合并为一条 instruction,也不会节省 transaction fee。
备注:内容仅供参考。
