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 拥有,而应检查:
- source 和 destination 是否由正确的 Token Program 拥有;
- 两个 Token 账户是否使用预期的 mint;
- source 的 authority 是否为 payer;
- 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 的区别,正是当前实现中的核心问题。
备注:内容仅供参考。
