PAYATHON 2026

Solana 程序如何创建用于执行合约付款的 transfer account?

支付阿杰

结论

createAccountWithSeed 不适合创建用于 SPL Token 转账的账户,也不能创建一个由 payer 和自定义 program “共同拥有”的 Solana 账户。

这里需要分清两个概念:

  • owner:负责修改账户数据的程序。
  • authority:有权授权 Token Program 对代币账户执行转账的主体。

每个账户只能有一个 owner。如果 transfer_acc 是 SPL Token 账户,它的 owner 必须是 SPL Token Program,不能是你的 program。payer 或 PDA 应记录为该 Token 账户的 authority。

当前账户由 System Program 拥有,还有两个直接原因:代码只是构造了 SystemProgram.createAccountWithSeed(...) 指令,并未发送包含该指令的交易;同时,代码提前对派生地址执行了 requestAirdrop,这会先创建一个由 System Program 拥有的系统账户。

为什么当前代码不能工作

下面的代码只生成 TransactionInstruction,不会在链上执行:

SystemProgram.createAccountWithSeed({
  basePubkey: payer.publicKey,
  fromPubkey: payer.publicKey,
  lamports: 100000,
  newAccountPubkey: transferAcc,
  programId: PROGRAM_ID,
  seed: "payer",
  space: 1024,
});

需要把返回的指令加入交易并发送:

const instruction = SystemProgram.createAccountWithSeed({
  basePubkey: payer.publicKey,
  fromPubkey: payer.publicKey,
  lamports,
  newAccountPubkey: transferAcc,
  programId: PROGRAM_ID,
  seed: "payer",
  space: 1024,
});

const transaction = new Transaction().add(instruction);
await sendAndConfirmTransaction(connection, transaction, [payer]);

但即使交易正确发送,它创建的也只是一个由 PROGRAM_ID 拥有的普通数据账户。这个账户不是合法的 SPL Token 账户,不能直接作为 spl_token::instruction::transfer 的 source。

下面的操作也不应该执行:

await connection.requestAirdrop(transferAcc, 100000);

空地址收到 SOL 后会变成由 System Program 管理的系统账户。之后再用 createAccountWithSeed 初始化同一个地址,通常会因为账户已经存在而失败。测试所需的 SOL 应空投给 payer,再由 payer 支付创建账户的费用。

正确的 SPL Token 转账模型

你的 Rust 代码调用了:

spl_token::instruction::transfer(...)

因此,transfer_acc 和 receiver_acc 都必须是对应 mint 的 SPL Token 账户:

  • 账户本身的 owner 是 Token Program。
  • source Token 账户内部保存的 authority 可以是 payer。
  • 如果要让程序控制资金,应把 authority 设置为程序的 PDA。
  • payer 和 PDA 可以借助 delegate、多签或程序规则实现组合授权,但这不属于”共同拥有账户”。

模式一:由 payer 直接授权付款

如果每次付款都要求 payer 签名,最简单的做法是把 payer 的关联代币账户(ATA)作为 source:

payer signer
    │ authority
    ▼
payer token account ── SPL Token Program ──► receiver token account

此时,Rust 代码不应检查 source 是否由你的 program 拥有,而应检查:

  1. source 和 destination 是否由正确的 Token Program 拥有;
  2. 两个 Token 账户是否使用预期的 mint;
  3. source 的 authority 是否为 payer;
  4. payer 是否已经签名。

示例:

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint::ProgramResult,
    program::invoke,
    program_error::ProgramError,
    pubkey::Pubkey,
    program_pack::Pack,
};
use spl_token::state::Account as TokenAccount;

pub fn process_payment(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    payment_fee: u64,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();

    let token_program = next_account_info(account_info_iter)?;
    let payer = next_account_info(account_info_iter)?;
    let source = next_account_info(account_info_iter)?;
    let destination = next_account_info(account_info_iter)?;

    if !payer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if *token_program.key != spl_token::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    if source.owner != token_program.key || destination.owner != token_program.key {
        return Err(ProgramError::IllegalOwner);
    }

    let source_state = TokenAccount::unpack(&source.data.borrow())?;
    let destination_state = TokenAccount::unpack(&destination.data.borrow())?;

    if source_state.owner != *payer.key {
        return Err(ProgramError::InvalidAccountData);
    }

    if source_state.mint != destination_state.mint {
        return Err(ProgramError::InvalidAccountData);
    }

    let instruction = spl_token::instruction::transfer(
        token_program.key,
        source.key,
        destination.key,
        payer.key,
        &[],
        payment_fee,
    )?;

    invoke(
        &instruction,
        &[
            source.clone(),
            destination.clone(),
            payer.clone(),
            token_program.clone(),
        ],
    )
}

payer 必须在外层交易中签名。程序通过 CPI 调用 Token Program 时,payer 的 signer 权限会继续传递。

模式二:使用 PDA 创建由程序控制的付款账户

如果要先把代币存入 escrow,再由程序按照合约逻辑付款,建议使用 PDA 作为 Token 账户的 authority。

需要注意以下区别:

  • PDA 可以是实际存在且由程序拥有的数据账户;
  • PDA 也可以只作为签名 authority,不保存数据;
  • 存放代币的 Token 账户仍由 Token Program 拥有。

例如生成 PDA:

const [vaultAuthority] = PublicKey.findProgramAddressSync(
  [Buffer.from("vault"), payer.publicKey.toBuffer()],
  PROGRAM_ID,
);

接着,为 vaultAuthority 创建对应 mint 的关联代币账户。具体创建方式取决于所用的 @solana/spl-token 版本,但需要满足:

token account owner program = SPL Token Program
token account authority     = vaultAuthority PDA

程序付款时,通过 invoke_signed 让 PDA 签名:

use solana_program::program::invoke_signed;

let (vault_authority, bump) = Pubkey::find_program_address(
    &[b"vault", payer.key.as_ref()],
    program_id,
);

if vault_authority != *vault_authority_info.key {
    return Err(ProgramError::InvalidSeeds);
}

let source_state = TokenAccount::unpack(&source.data.borrow())?;

if source.owner != token_program.key {
    return Err(ProgramError::IllegalOwner);
}

if source_state.owner != vault_authority {
    return Err(ProgramError::InvalidAccountData);
}

let instruction = spl_token::instruction::transfer(
    token_program.key,
    source.key,
    destination.key,
    vault_authority_info.key,
    &[],
    payment_fee,
)?;

invoke_signed(
    &instruction,
    &[
        source.clone(),
        destination.clone(),
        vault_authority_info.clone(),
        token_program.clone(),
    ],
    &[&[
        b"vault",
        payer.key.as_ref(),
        &[bump],
    ]],
)?;

PDA 没有私钥。只有对应程序提供完全相同的 seeds 和 bump 时,才能通过 invoke_signed 代表它签名。

如果确实需要 payer 和 program 共同授权

Solana 不支持”两个程序共同 owner”的账户模型。可以根据具体业务选择以下方式:

  • 每次由 payer 签名,程序负责验证业务规则;
  • 由 PDA 独立控制资金;
  • payer 通过 SPL Token delegate,把有限额度委托给 PDA;
  • 使用 SPL Token multisig;
  • 在程序状态中记录批准状态,仅在 payer 已签名且程序条件也满足时执行转账。

例如,可以要求 payer 签名,同时让 PDA 作为 Token authority:

if !payer.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

// 验证付款条件后,再由 PDA 通过 invoke_signed 授权 Token Program 转账。

这种设计实现的是”双重条件”,不是账户的共同所有权。

需要修改的关键点

删除对 transfer_acc 的 airdrop,也不要用 createAccountWithSeed 创建 SPL Token source。应创建使用正确 mint 的 Token 账户或 ATA,再根据业务选择 payer 或 PDA 作为 authority。

下面的检查不适用于 Token 账户:

check_account_owner(transfer_acc, program_id)?;

对于 SPL Token 账户,应检查:

if transfer_acc.owner != token_program.key {
    return Err(ProgramError::IllegalOwner);
}

之后再反序列化 Token 账户,验证其内部的 owner/authority、mint 以及其他业务约束。这里有两个名称相同但含义不同的字段:

  • AccountInfo::owner:应为 SPL Token Program。
  • spl_token::state::Account::owner:该 Token 账户的转账 authority,可以是 payer 或 PDA。

这两个 owner 的区别,正是当前实现中的核心问题。

备注:内容仅供参考。