Skip to content

feat(js): add guarded SOL transfer helper - #96

Merged
lorisleiva merged 3 commits into
solana-program:mainfrom
amilz:feat/guarded-transfer-sol
Oct 19, 2026
Merged

lorisleiva merged 3 commits into
solana-program:mainfrom
amilz:feat/guarded-transfer-sol

Conversation

@amilz

@amilz amilz commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Follow-up based on discussion @joncinque @gitteri
  • Adds assertValidTransferSolDestination and getTransferSolGuardedInstruction to validate a SOL transfer destination before building transferSol, preventing sends to unrecoverable addresses. This will help add clarity for exchanges/organizations handling user transfers.
  • Rules follow the address verification guide; off-curve destinations (smart wallets, PDAs) are allowed by default so they aren't blocked outright, and exchanges that want to confirm them with the user first can pass allowOffCurve: false.

A destination is rejected unless it's an existing account owned by the expected program (default: System Program). Rejections throw a plain Error whose cause is { destination, reason, owner? }:

reason When Override
unexpected-owner Account exists, owned by another program (e.g. a mint) programOwner
unfunded-recipient No account on-chain (often a typo) allowUnfundedRecipient: true
off-curve Program-derived address, only when allowOffCurve: false default (allowOffCurve: true)
  • If we get this merged, I'd liked to create similar helpers for token & token-2022 libraries.

Example Use

Simulated example of how an exchange might use this to prompt user for confirmation before sending a transfer.

image

@amilz
amilz force-pushed the feat/guarded-transfer-sol branch from ee983f6 to e127f83 Compare October 15, 2026 21:06

@joncinque joncinque left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just from a quick pass, since the checks are all completely independent of the instruction creation, how about just exposing the checks as a new function?

When we discussed a new instruction creator, that would be for something like requiring the destination to sign. We shouldn't need it here.

@lorisleiva can you take a look?

@amilz

amilz commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Just from a quick pass, since the checks are all completely independent of the instruction creation, how about just exposing the checks as a new function?

Thx for looking. Fair point--dropped the instruction wrapper and left the assertion.
8ade93d

@lorisleiva

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 a hand-written assertValidTransferSolDestination(rpc, destination, config?) helper to the JS client. It fetches the destination account and throws a plain Error (with a structured cause) if the account is owned by an unexpected program, doesn't exist (unless allowUnfundedRecipient), or is off-curve (only when allowOffCurve: false). Exported from index.ts alongside the generated code, with LiteSVM-backed tests covering each branch and a mock test checking that guard options aren't leaked into getAccountInfo.

The implementation is small and correct for what it sets out to do, and the test coverage is good. My comments are mostly about API shape, since this is the first non-generated export in this package and will set the precedent for the token / token-2022 follow-ups mentioned in the description.

Things to consider before merging

1. Scope: first hand-written code in a generated client. src/index.ts was previously just export * from './generated'. That's fine in principle (other solana-program clients do it), but it's worth @joncinque / @lorisleiva explicitly deciding this is the home for it rather than, say, a Kit-level or docs-level helper, given the stated plan to replicate it across token clients.

2. Throwing vs. classifying. The PR's own example is an exchange prompting the user for confirmation. With an assert-only API, that flow has to try/catch, then narrow e.cause by hand. A non-throwing classifier (e.g. getTransferSolDestinationStatus(...) returning { status: 'valid' } | { status: 'unexpected-owner', owner } | ...) plus a thin assert* wrapper over it follows Kit's isX / assertIsX pattern and serves both the "prompt the user" and the "hard fail" use cases. Not a blocker, but cheaper to decide now than after a token-client copy exists.

3. Error typing. A plain Error with an untyped cause means consumers can't instanceof or narrow without a cast. See inline comment on the cause type.

4. Defaults. Unfunded destinations are a hard failure by default while off-curve ones pass by default. Both are "confirm with the user" cases in the address-verification guide, so the asymmetry is worth a sentence of justification in the docs or a rethink. See inline.

For subsequent reviewers

  • No .changeset/ in this repo, so nothing to check there. This is a new public export, so it'll want a minor bump on the next JS release.
  • isOffCurveAddress from @solana/kit is synchronous, so the ordering (owner → unfunded → off-curve) is deterministic and matches the docblock.
  • The does not forward guard options to getAccountInfo test relies on fetchEncodedAccount spreading { ...rpcConfig, encoding: 'base64' } — that's Kit's current behaviour, but it's a test of Kit internals as much as of this helper.

Comment thread clients/js/src/assertValidTransferSolDestination.ts Outdated
Comment thread clients/js/src/assertValidTransferSolDestination.ts Outdated
Comment thread clients/js/src/assertValidTransferSolDestination.ts Outdated
Comment thread clients/js/src/assertValidTransferSolDestination.ts Outdated
Comment thread clients/js/src/assertValidTransferSolDestination.ts
Comment thread clients/js/src/assertValidTransferSolDestination.ts Outdated
amilz added 3 commits October 16, 2026 17:54
Validates a transferSol destination before building the instruction,
rejecting accounts not owned by the expected program and unfunded
recipients unless explicitly allowed. Off-curve destinations are
allowed by default and can be rejected with allowOffCurve: false.
Aligns with the payments address verification guide.
…ation

- Throw a typed InvalidTransferSolDestinationError instead of Error + cause
- Rename TransferSolGuardConfig to AssertValidTransferSolDestinationConfig
- Type rpc as Rpc<GetAccountInfoApi>
- Accept any InstructionAccountInput as the destination, matching transferSol
- Document why off-curve destinations are allowed by default
- Drop product names from the off-curve error message
@amilz
amilz force-pushed the feat/guarded-transfer-sol branch from 3ddcca1 to 9bdee15 Compare October 16, 2026 17:54
@amilz

amilz commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author
  • accepted all of @trevor-cortex comments except defaults. we're trying to get more teams to support off curve, so intentionally defaulting that as an allow case.
  • rebased

@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

All the points from the first pass have been addressed:

  • InvalidTransferSolDestinationError replaces Error + untyped cause, with typed destination / reason / owner fields and override readonly name. Tests now use toBeInstanceOf and match on the fields directly.
  • Config type renamed to AssertValidTransferSolDestinationConfig.
  • rpc is typed as Rpc<GetAccountInfoApi>.
  • Destination accepts InstructionAccountInput and is unwrapped with getAddressFromResolvedInstructionAccount('destination', input), which is exactly how the hand-written helpers in program-metadata and token-2022 do it, and matches what the generated transferSol builder accepts. New tests cover HasAddress, signer and PDA-tuple inputs.
  • Both defaults now have a one-line rationale in the docblock (unfunded → likely typo; off-curve → multisigs are common), which is what I was after. The asymmetry is a defensible product call once it's written down.
  • Product names dropped from the off-curve message.

The classifier-vs-assert question (#2 in my first review) wasn't taken up, which is fine as it was explicitly non-blocking; the typed error class makes try/catch + instanceof + reason a perfectly usable path for the "prompt the user" flow.

Minor, non-blocking

  • The docblock calls out that an off-curve account owned by another program (e.g. an ATA) reports 'unexpected-owner' rather than 'off-curve' because the owner check runs first. That ordering is the one non-obvious behaviour in the function and isn't covered by a test. A single case (allowOffCurve: false + Token-owned PDA → reason: 'unexpected-owner') would pin it down.

For maintainers

  • No .changeset/ in this repo. This adds new public exports (assertValidTransferSolDestination, InvalidTransferSolDestinationError, InvalidTransferSolDestinationReason, AssertValidTransferSolDestinationConfig), so the next JS release should be a minor bump.
  • The @solana/kit/program-client-core subpath import is already used by the generated code, so no new peer-dep requirement.

LGTM.

@lorisleiva lorisleiva 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.

Thank you!

@lorisleiva
lorisleiva merged commit 6dd5774 into solana-program:main Oct 19, 2026
20 checks passed
@amilz
amilz deleted the feat/guarded-transfer-sol branch October 19, 2026 20:31
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