From bc82eaa924263a6b758535ada2b93388d6c05b9d Mon Sep 17 00:00:00 2001 From: Aung Nanda Oo Date: Mon, 24 Aug 2026 10:14:04 -0700 Subject: [PATCH 1/2] fix(transfer-tokens): stop requiring the recipient to co-sign transfers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native implementation's transfer_tokens instruction built the SPL Token transfer CPI with signer_pubkeys = [owner, recipient]. Passing a non-empty signer_pubkeys list marks the authority account as a non-signer and instead treats it as a multisig whose members must each co-sign — but owner is a plain wallet, not a multisig account. The practical effect: the transfer only succeeded if the recipient also signed the transaction, which defeats the purpose of a token transfer (a recipient should never need to approve incoming tokens). Confirmed empirically: with the recipient not signing, the CPI is rejected at the runtime privilege-check stage ("Cross-program invocation with unauthorized signer or writable account") before the SPL Token program even runs. The anchor and pinocchio implementations of this same example already pass signer_pubkeys = [] and never require the recipient to sign. Fix: pass an empty signer_pubkeys list, matching owner's actual role as a single-signer authority. Updated the TS instruction builder and test to pass the recipient's address instead of a signer. Co-Authored-By: Claude Sonnet 5 --- .../native/program/src/instructions/transfer.rs | 6 +++++- tokens/transfer-tokens/native/tests/test.ts | 2 +- tokens/transfer-tokens/native/ts/instructions/transfer.ts | 6 ++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tokens/transfer-tokens/native/program/src/instructions/transfer.rs b/tokens/transfer-tokens/native/program/src/instructions/transfer.rs index 9d265b941..7de3ca2c5 100644 --- a/tokens/transfer-tokens/native/program/src/instructions/transfer.rs +++ b/tokens/transfer-tokens/native/program/src/instructions/transfer.rs @@ -62,7 +62,11 @@ pub fn transfer_tokens(accounts: &[AccountInfo], args: TransferTokensArgs) -> Pr from_associated_token_account.key, to_associated_token_account.key, owner.key, - &[owner.key, recipient.key], + // Empty: `owner` is a plain wallet authority, not a multisig account. + // A non-empty list here marks `owner` as non-signer and instead + // requires each listed pubkey to co-sign as a multisig member — which + // wrongly forced the recipient to sign just to receive tokens. + &[], args.quantity, )?, &[ diff --git a/tokens/transfer-tokens/native/tests/test.ts b/tokens/transfer-tokens/native/tests/test.ts index de2704d44..5a2ae99ce 100644 --- a/tokens/transfer-tokens/native/tests/test.ts +++ b/tokens/transfer-tokens/native/tests/test.ts @@ -216,7 +216,7 @@ describe('Transferring Tokens', () => { await findAssociatedTokenAddress(mint, payer.address), await findAssociatedTokenAddress(mint, recipientWallet.address), payer, - recipientWallet, + recipientWallet.address, payer, programId, quantity, diff --git a/tokens/transfer-tokens/native/ts/instructions/transfer.ts b/tokens/transfer-tokens/native/ts/instructions/transfer.ts index 8b60694f3..56c8e1f79 100644 --- a/tokens/transfer-tokens/native/ts/instructions/transfer.ts +++ b/tokens/transfer-tokens/native/ts/instructions/transfer.ts @@ -21,7 +21,7 @@ export function createTransferTokensInstruction( fromAssociatedTokenAccount: Address, toAssociatedTokenAccount: Address, owner: TransactionSigner, - recipient: TransactionSigner, + recipient: Address, payer: TransactionSigner, programId: Address, quantity: bigint, @@ -33,7 +33,9 @@ export function createTransferTokensInstruction( { address: fromAssociatedTokenAccount, role: AccountRole.WRITABLE }, { address: toAssociatedTokenAccount, role: AccountRole.WRITABLE }, { address: owner.address, role: AccountRole.WRITABLE_SIGNER, signer: owner }, - { address: recipient.address, role: AccountRole.WRITABLE_SIGNER, signer: recipient }, + // Recipient just needs to be named, not to sign — receiving tokens + // must never require the recipient's approval. + { address: recipient, role: AccountRole.READONLY }, { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, { address: TOKEN_PROGRAM_ADDRESS, role: AccountRole.READONLY }, From d89b110ebbc95a799aee5bdcf80b208977a53bb4 Mon Sep 17 00:00:00 2001 From: Aung Nanda Oo Date: Wed, 26 Aug 2026 09:41:32 -0700 Subject: [PATCH 2/2] docs(transfer-tokens): trim self-explanatory comment per review dev-jodee's feedback on PR #695: the comment explaining the empty signer_pubkeys list was too long. Trimmed to 2 lines. Co-Authored-By: Claude Sonnet 5 --- .../native/program/src/instructions/transfer.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tokens/transfer-tokens/native/program/src/instructions/transfer.rs b/tokens/transfer-tokens/native/program/src/instructions/transfer.rs index 7de3ca2c5..44b6da91d 100644 --- a/tokens/transfer-tokens/native/program/src/instructions/transfer.rs +++ b/tokens/transfer-tokens/native/program/src/instructions/transfer.rs @@ -62,10 +62,8 @@ pub fn transfer_tokens(accounts: &[AccountInfo], args: TransferTokensArgs) -> Pr from_associated_token_account.key, to_associated_token_account.key, owner.key, - // Empty: `owner` is a plain wallet authority, not a multisig account. - // A non-empty list here marks `owner` as non-signer and instead - // requires each listed pubkey to co-sign as a multisig member — which - // wrongly forced the recipient to sign just to receive tokens. + // Empty: `owner` is a single signer, not a multisig — a non-empty + // list here wrongly required the recipient to also sign. &[], args.quantity, )?,