PAYATHON 2026

在 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。

备注:内容仅供参考。