Skip to content

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

Open
NikkiAung wants to merge 1 commit into
solana-foundation:mainfrom
NikkiAung:fix/transfer-tokens-recipient-signer
Open

fix(transfer-tokens): native transfer wrongly requires the recipient to co-sign#695
NikkiAung wants to merge 1 commit 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 wallet authorizes a transfer.

  • Passes an empty multisig signer list to the SPL Token transfer CPI.
  • Changes the TypeScript recipient parameter from a transaction signer to a plain address with a read-only account role.
  • Updates the native test to verify transfers without a recipient signature.

Confidence Score: 5/5

The PR appears safe to merge with no actionable correctness or security issues identified.

The Rust CPI, TypeScript account metadata, and test now consistently enforce sender authorization without requiring recipient consent, while preserving the account order and writable destination account needed for transfers and associated-token-account creation.

Important Files Changed

Filename Overview
tokens/transfer-tokens/native/program/src/instructions/transfer.rs Correctly constructs the SPL Token CPI for a plain-wallet authority by removing the erroneous multisig signer list.
tokens/transfer-tokens/native/ts/instructions/transfer.ts Aligns the client account metadata with the program contract by treating the recipient as a read-only address rather than a signer.
tokens/transfer-tokens/native/tests/test.ts Exercises the corrected behavior by passing only the recipient address and no longer requesting its signature.

Reviews (1): Last reviewed commit: "fix(transfer-tokens): stop requiring the..." | 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.

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.

1 participant