Skip to content

fix(transfer-tokens): native transfer wrongly requires the recipient to co-sign - #695

Merged
dev-jodee merged 2 commits into
solana-foundation:mainfrom
NikkiAung:fix/transfer-tokens-recipient-signer
Aug 26, 2026
Merged

fix(transfer-tokens): native transfer wrongly requires the recipient to co-sign#695
dev-jodee merged 2 commits into
solana-foundation:mainfrom
NikkiAung:fix/transfer-tokens-recipient-signer

Conversation

@NikkiAung

Copy link
Copy Markdown
Contributor

The bug

The native implementation of tokens/transfer-tokens's transfer_tokens instruction builds the SPL Token Transfer CPI like this:

&token_instruction::transfer(
    token_program.key,
    from_associated_token_account.key,
    to_associated_token_account.key,
    owner.key,
    &[owner.key, recipient.key],   // <- signer_pubkeys
    args.quantity,
)?,

Passing a non-empty signer_pubkeys list tells the SPL Token program the authority account (owner) is a multisig account, and marks owner itself as a non-signer in the CPI's account metas — the listed pubkeys (owner, recipient) are appended as the individual co-signers of that multisig instead. owner here is just a plain wallet, not a multisig account, so this construction is wrong regardless of who calls it.

Concrete effect, confirmed empirically: a transfer only succeeds if the recipient also signs the transaction. Without it, the CPI is rejected at the runtime privilege-check stage before the Token program even runs:

"Ar4ej...'s signer privilege escalated"
"... failed: Cross-program invocation with unauthorized signer or writable account"

This is backwards — a recipient should never need to approve/co-sign to receive tokens sent to them. As shipped, this example teaches a pattern that makes tokens effectively impossible to transfer to any wallet that isn't present to co-sign, which defeats the purpose of the instruction. The existing test suite didn't catch this because it happened to make the recipient a signer for unrelated reasons (its account needed funding to test with).

The anchor and pinocchio implementations of this same example were already correct (signer_pubkeys: &[] in pinocchio, and Anchor's Transfer CPI simply never lists recipient as an authority) — only native had this bug.

Fix

Pass an empty signer_pubkeys list, matching owner's actual role as a plain single-signer authority — the same pattern already used by the anchor and pinocchio versions. Updated the TS instruction builder (ts/instructions/transfer.ts) to take the recipient as a plain Address instead of a TransactionSigner, and updated the test to pass the recipient's address without asking it to sign.

Verification

  • Confirmed red/green: reverted just the signer_pubkeys fix (keeping the updated, non-signing-recipient test), rebuilt, and confirmed the transfer tests fail with Cross-program invocation with unauthorized signer or writable account — then reapplied the fix and confirmed all 6 tests pass.
  • Full native litesvm suite passes (Create an SPL Token!, Create an NFT!, Mint some tokens to your wallet!, Mint the NFT to your wallet!, Transfer tokens to another wallet!, Transfer NFT to another wallet!).
  • cargo fmt -p transfer-tokens-program -- --check and cargo clippy -p transfer-tokens-program --all-targets -- -D warnings both clean.
  • tsc --noEmit and repo-wide prettier --check both clean on the touched files.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@NikkiAung
NikkiAung requested a review from dev-jodee as a code owner August 24, 2026 17:14
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR corrects the native token-transfer authority model so only the sending owner authorizes the debit.

  • Passes an empty multisig signer list to the SPL Token transfer CPI.
  • Represents the recipient as a read-only address in the TypeScript instruction builder.
  • Updates the transfer test to demonstrate that the recipient need not co-sign.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
tokens/transfer-tokens/native/program/src/instructions/transfer.rs Removes the erroneous multisig signer list so the ordinary wallet owner remains the sole transfer authority.
tokens/transfer-tokens/native/ts/instructions/transfer.ts Changes the recipient contract from a writable signer to a read-only address, matching its actual on-chain role.
tokens/transfer-tokens/native/tests/test.ts Passes only the recipient address, covering successful transfer and ATA creation without recipient authorization.

Reviews (2): Last reviewed commit: "docs(transfer-tokens): trim self-explana..." | Re-trigger Greptile

@NikkiAung

Copy link
Copy Markdown
Contributor Author

Hey @dev-jodee — this one's ready for review whenever you have a chance. CI is green and Greptile's automated pass found no blocking issues.

to_associated_token_account.key,
owner.key,
&[owner.key, recipient.key],
// Empty: `owner` is a plain wallet authority, not a multisig account.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove the comments that are self explanatory

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d89b110 — trimmed the comment to 2 lines.

dev-jodee's feedback on PR solana-foundation#695: the comment explaining the empty
signer_pubkeys list was too long. Trimmed to 2 lines.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dev-jodee
dev-jodee merged commit 73219fe into solana-foundation:main Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants