Skip to content

feat(js): add token transfer destination assertion - #190

Open
amilz wants to merge 5 commits into
solana-program:mainfrom
amilz:feat/DEV-1241-token-transfer-destination
Open

amilz wants to merge 5 commits into
solana-program:mainfrom
amilz:feat/DEV-1241-token-transfer-destination

Conversation

@amilz

@amilz amilz commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Follow-up to feat(js): add guarded SOL transfer helper system#96: adds assertValidTokenTransferDestination so exchanges can check a token withdrawal destination before building transferToATA. Same shape as assertValidTransferSolDestination.
  • Takes the same mint / destination / recipient inputs as getTransferToATAInstructionPlan, fetches the recipient and destination, and throws an InvalidTokenTransferDestinationError (typed destination, recipient, reason, plus owner? / mint? / tokenOwner?).
  • Follows the address verification guide. Off-curve recipients (multisigs, smart wallets) are allowed by default.
  • allowNonAssociatedTokenAccount: true accepts an existing token account for mint owned by recipient. Transfer to these with transferChecked, since transferToATA` only works with the ATA.
  • allowMissingAssociatedTokenAccount: false requires the ATA to already exist, for teams that won't pay its rent or want proof the recipient has held the mint.
reason When Override
unexpected-owner Recipient exists but isn't owned by the expected program (e.g. a token account passed as the recipient) programOwner
unfunded-recipient Recipient has no account on-chain (often a typo) allowUnfundedRecipient: true
off-curve Recipient is a PDA, only when allowOffCurve: false default (allowOffCurve: true)
ata-mismatch destination isn't the recipient's ATA for mint allowNonAssociatedTokenAccount: true
missing-token-account Destination doesn't exist (always for non-ATAs; for the ATA only when allowMissingAssociatedTokenAccount: false) default (allowMissingAssociatedTokenAccount: true)
not-token-account Non-ATA destination isn't an initialized token account —
mint-mismatch Non-ATA destination holds another mint —
token-owner-mismatch Destination isn't owned by the recipient (e.g. a reassigned ATA) —

Example

await assertValidTokenTransferDestination(rpc, { mint, destination, recipient }, { allowOffCurve: false });
await client.token.instructions.transferToATA({ mint, destination, recipient, authority, amount, decimals });

Closes DEV-1241

@joncinque
joncinque requested a review from mcintyre94 October 20, 2026 15:49
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
@mcintyre94

Copy link
Copy Markdown
Member

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Summary

Adds assertValidTokenTransferDestination(rpc, { mint, destination, recipient }, config?) to the JS client as the token counterpart of assertValidTransferSolDestination (solana-program/system#96). It fetches the recipient and destination in parallel, derives the recipient's ATA, and throws a typed InvalidTokenTransferDestinationError with one of eight reasons. Three of the checks (unexpected-owner / unfunded-recipient / off-curve) are the SOL helper's rules applied to the recipient wallet; the remaining five validate the token account itself. Two token-specific knobs are added: allowNonAssociatedTokenAccount (accept any existing token account owned by the recipient) and allowMissingAssociatedTokenAccount (default true, mirrors transferToATA's idempotent create).

The implementation is correct, the ordering is deterministic and matches the docblock, and the LiteSVM tests cover every reason plus the two non-obvious orderings (owner-before-off-curve, and allowMissingAssociatedTokenAccount: false overriding allowUnfundedRecipient: true). The API shape, error class, and InstructionAccountInput handling are all consistent with the merged system helper, so there's nothing new to decide on that front.

Things to watch out for (all non-blocking)

  1. Frozen destinations pass the check but the transfer will fail. The Token program rejects a transfer when either side is frozen, so an ATA in AccountState.Frozen gets through here and then transferToATA fails on-chain with AccountFrozen. Given this helper's whole point is pre-flighting the transfer, a 'frozen' reason seems in scope. See inline.
  2. Guard-option leak test doesn't cover the new options. The does not forward guard options to getAccountInfo test passes the three options inherited from the SOL helper but not allowNonAssociatedTokenAccount / allowMissingAssociatedTokenAccount, so it wouldn't catch those being dropped from the destructure. See inline.
  3. mint? on the error is the destination's actual mint, sitting alongside an input named mint (the expected one). tokenOwner is disambiguated but mint isn't. tokenMint would be symmetric with tokenOwner, but it's a naming nit — fine to leave if you prefer parity with the SOL helper's flat fields.
  4. Token program is hardcoded. findAssociatedTokenPda uses TOKEN_PROGRAM_ADDRESS, and decodeToken + getTokenSize() are SPL-Token-specific anyway, so this is the right call for this package. Just noting that a caller using getTransferToATAInstructionPlan with config.tokenProgram set to Token-2022 would get ata-mismatch here, which is a slightly misleading message. A one-line doc note ("Token program only; see the token-2022 client for its equivalent") would pre-empt that question when the sibling lands.

For subsequent reviewers

  • No .changeset/ in this repo. This adds new public exports (assertValidTokenTransferDestination, InvalidTokenTransferDestinationError, InvalidTokenTransferDestinationReason, InvalidTokenTransferDestinationDetails, AssertValidTokenTransferDestinationInput, AssertValidTokenTransferDestinationConfig), so the next JS release should be a minor bump.
  • @solana-program/system was already a runtime dependency (^0.16.0) and @solana/kit/program-client-core is already imported by transferToATA.ts, so no new dependency surface.
  • The destinationAccount.data.length === getTokenSize() guard before decodeToken is what keeps mints (82 bytes) and multisigs (355 bytes) from throwing inside the decoder; the not-token-account test iterates over wallet / mint / multisig / uninitialized-165-byte accounts to pin that down.
  • The unexpected-owner check runs before ata-mismatch, so passing an ATA as the recipient (an easy mistake) reports the recipient as Token-owned rather than a confusing "destination is not the ATA of ". That's tested.

LGTM.

Comment thread clients/js/src/assertValidTokenTransferDestination.ts
Comment thread clients/js/test/assertValidTokenTransferDestination.test.ts
Comment thread clients/js/src/assertValidTokenTransferDestination.ts Outdated
@amilz

amilz commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

great feedback @mcintyre94!

Commit Addresses
d163ed8 narrow destination error details
a964ea6 cleanup destination assertion docs and messages
a5e8f21 add configurable token & associated token program
d23bba4 reject frozen token transfer destinations

@joncinque

Copy link
Copy Markdown
Contributor

#194 covers the audit failures, just rebase to resolve the CI issue.

@amilz
amilz force-pushed the feat/DEV-1241-token-transfer-destination branch from d23bba4 to d0c4568 Compare October 27, 2026 22:13
@amilz

amilz commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

done

#194 covers the audit failures, just rebase to resolve the CI issue.

Comment thread clients/js/src/assertValidTokenTransferDestination.ts

@mcintyre94 mcintyre94 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This LGTM now, thanks for the changes!

@mcintyre94

Copy link
Copy Markdown
Member

@trevor-cortex

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Re-review

Re-fetched the diff since my first pass. All four points from my previous review are addressed, plus one larger shape change:

  • 'frozen' reason added, checked last (after owner match), with a LiteSVM test that creates a mint with a freeze authority and freezes the ATA. ✅
  • Guard-option leak test now passes every config key (allowMissingAssociatedTokenAccount, allowNonAssociatedTokenAccount, associatedTokenProgram, tokenProgram, …) and asserts only commitment + encoding reach getAccountInfo. ✅
  • Error fields moved into a details discriminated union ({ reason: 'mint-mismatch', mint }, { reason: 'token-owner-mismatch', tokenOwner }, …). This resolves the mint naming ambiguity and is strictly better than optional flat fields — callers can narrow on details.reason and get the right extra field typed. Note it diverges from assertValidTransferSolDestination's flat reason / owner? shape; fine by me, but if the system helper ever gets a v2 it's worth aligning.
  • tokenProgram / associatedTokenProgram config added rather than hardcoding Token. isTokenAccountData handles the Token-2022 layout: exactly getTokenSize() bytes, or (outside the Token program) longer, not getMultisigSize(), with the AccountType byte at offset 165 equal to 2. I checked this against Token-2022's extension layout: mints with extensions are padded to 165 and carry AccountType = 1, multisigs never have extensions, and Token-2022 deliberately pads extended accounts so they never land on exactly Multisig::LEN, so the length exclusion can't misclassify a real token account. The Token-2022 test (ATA carries ImmutableOwner, so it's > 165 bytes) exercises this path end-to-end.

Nothing new to flag. Two housekeeping notes before merge:

  • The PR description is stale: it still describes flat owner? / mint? / tokenOwner? fields and the reason table is missing frozen and the tokenProgram / associatedTokenProgram options. Worth updating since it'll become the release-notes reference.
  • Still a minor bump on the next JS release (new public exports).

LGTM.

This branch has not been deployed

No deployments
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.

4 participants