Repository navigation
feat(js): add guarded SOL transfer helper #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
lorisleiva
merged 3 commits into
solana-program:main
from
amilz:feat/guarded-transfer-sol
Oct 19, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { | ||
| fetchEncodedAccount, | ||
| isOffCurveAddress, | ||
| type Address, | ||
| type FetchAccountConfig, | ||
| type GetAccountInfoApi, | ||
| type Rpc, | ||
| } from '@solana/kit'; | ||
| import { | ||
| getAddressFromResolvedInstructionAccount, | ||
| type InstructionAccountInput, | ||
| } from '@solana/kit/program-client-core'; | ||
|
|
||
| import { SYSTEM_PROGRAM_ADDRESS } from './generated'; | ||
|
|
||
| /** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ | ||
| export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; | ||
|
|
||
| /** | ||
| * Thrown by {@link assertValidTransferSolDestination} when a destination is rejected. `owner` is set only | ||
| * when `reason` is `'unexpected-owner'`. | ||
| */ | ||
| export class InvalidTransferSolDestinationError extends Error { | ||
| override readonly name = 'InvalidTransferSolDestinationError'; | ||
|
|
||
| constructor( | ||
| message: string, | ||
| readonly destination: Address, | ||
| readonly reason: InvalidTransferSolDestinationReason, | ||
| readonly owner?: Address, | ||
| ) { | ||
| super(message); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Options for {@link assertValidTransferSolDestination}. Any {@link FetchAccountConfig} fields are passed | ||
| * through to the account fetch. | ||
| */ | ||
| export type AssertValidTransferSolDestinationConfig = FetchAccountConfig & { | ||
| /** | ||
| * Allow a destination that has no account on-chain yet, funding it as part of the transfer. | ||
| * Defaults to `false`, since an unfunded recipient is often a mistyped address. | ||
| */ | ||
| allowUnfundedRecipient?: boolean; | ||
| /** | ||
| * Allow an off-curve (program-derived) destination, existing or not. Defaults to `true`, since | ||
| * program-controlled wallets such as multisigs are common, legitimate recipients; set to `false` to | ||
| * reject off-curve destinations until the recipient confirms they control them. | ||
| */ | ||
| allowOffCurve?: boolean; | ||
| /** | ||
| * The program an existing destination account must be owned by. Defaults to the System Program | ||
| * ({@link SYSTEM_PROGRAM_ADDRESS}). | ||
| */ | ||
| programOwner?: Address; | ||
| }; | ||
|
|
||
| /** | ||
| * Asserts that `destination` can safely receive a SOL transfer by fetching its account. Otherwise throws | ||
| * an {@link InvalidTransferSolDestinationError} with one of these reasons: | ||
| * | ||
| * - `'unexpected-owner'`: the account exists but is not owned by `programOwner` (the System Program by | ||
| * default), e.g. an SPL token mint. SOL sent to such an account is typically unrecoverable. | ||
| * - `'unfunded-recipient'`: no account exists, which often means a mistyped address. Skipped when | ||
| * `allowUnfundedRecipient` is set. | ||
| * - `'off-curve'`: the address is off-curve, existing or not, and may be a smart wallet the recipient | ||
| * must confirm they control. Only checked when `allowOffCurve` is `false`. | ||
| * | ||
| * The owner is checked first, so an off-curve account owned by another program, such as an associated | ||
| * token account, reports `'unexpected-owner'`. | ||
| */ | ||
| export async function assertValidTransferSolDestination( | ||
| rpc: Rpc<GetAccountInfoApi>, | ||
| destinationInput: InstructionAccountInput, | ||
| config?: AssertValidTransferSolDestinationConfig, | ||
| ): Promise<void> { | ||
| const destination = getAddressFromResolvedInstructionAccount('destination', destinationInput); | ||
| const { | ||
| allowOffCurve = true, | ||
| allowUnfundedRecipient = false, | ||
| programOwner = SYSTEM_PROGRAM_ADDRESS, | ||
| ...fetchConfig | ||
| } = config ?? {}; | ||
| const account = await fetchEncodedAccount(rpc, destination, fetchConfig); | ||
|
|
||
| if (account.exists) { | ||
| if (account.programAddress !== programOwner) { | ||
| throw new InvalidTransferSolDestinationError( | ||
| `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}).`, | ||
| destination, | ||
| 'unexpected-owner', | ||
| account.programAddress, | ||
| ); | ||
| } | ||
| } else if (!allowUnfundedRecipient) { | ||
| throw new InvalidTransferSolDestinationError( | ||
| `Refusing to transfer SOL to ${destination}: it has no account on-chain yet, which often means the address is mistyped. If you intend to fund a new account, pass { allowUnfundedRecipient: true }.`, | ||
| destination, | ||
| 'unfunded-recipient', | ||
| ); | ||
| } | ||
|
|
||
| if (isOffCurveAddress(destination) && !allowOffCurve) { | ||
| throw new InvalidTransferSolDestinationError( | ||
| `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; once the recipient confirms they control it, pass { allowOffCurve: true }.`, | ||
| destination, | ||
| 'off-curve', | ||
| ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * from './generated'; | ||
| export * from './assertValidTransferSolDestination'; |
209 changes: 209 additions & 0 deletions
209
clients/js/test/assertValidTransferSolDestination.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| import { | ||
| address, | ||
| generateKeyPairSigner, | ||
| getProgramDerivedAddress, | ||
| lamports, | ||
| type Address, | ||
| type HasAddress, | ||
| } from '@solana/kit'; | ||
| import { expect, it, vi } from 'vitest'; | ||
|
|
||
| import { assertValidTransferSolDestination, InvalidTransferSolDestinationError, SYSTEM_PROGRAM_ADDRESS } from '../src'; | ||
| import { createTestClient } from './_setup'; | ||
|
|
||
| const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); | ||
|
|
||
| type TestClient = Awaited<ReturnType<typeof createTestClient>>; | ||
|
|
||
| class AddressWrapper implements HasAddress { | ||
| constructor(readonly address: Address) {} | ||
| } | ||
|
|
||
| // Creates a funded account assigned to the Token program, standing in for an SPL token mint. | ||
| const createForeignOwnedAddress = async (client: TestClient): Promise<Address> => { | ||
| const account = await generateKeyPairSigner(); | ||
| await client.airdrop(account.address, lamports(1_000_000_000n)); | ||
| await client.system.instructions.assign({ account, programAddress: TOKEN_PROGRAM_ADDRESS }).sendTransaction(); | ||
| return account.address; | ||
| }; | ||
|
|
||
| const createFundedAddress = async (client: TestClient): Promise<Address> => { | ||
| const destination = (await generateKeyPairSigner()).address; | ||
| await client.airdrop(destination, lamports(1_000_000_000n)); | ||
| return destination; | ||
| }; | ||
|
|
||
| const deriveOffCurveAddress = async (): Promise<Address> => { | ||
| const [pda] = await getProgramDerivedAddress({ | ||
| programAddress: SYSTEM_PROGRAM_ADDRESS, | ||
| seeds: ['guarded-transfer'], | ||
| }); | ||
| return pda; | ||
| }; | ||
|
|
||
| it('accepts an existing System-Program-owned destination', async () => { | ||
| // Given a funded System-Program-owned destination. | ||
| const client = await createTestClient(); | ||
| const destination = await createFundedAddress(client); | ||
|
|
||
| // Then it is a valid destination. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects an existing destination owned by another program', async () => { | ||
| // Given a destination owned by the Token program. | ||
| const client = await createTestClient(); | ||
| const destination = await createForeignOwnedAddress(client); | ||
|
|
||
| // Then it is rejected as owned by an unexpected program. | ||
| const promise = assertValidTransferSolDestination(client.rpc, destination); | ||
| await expect(promise).rejects.toBeInstanceOf(InvalidTransferSolDestinationError); | ||
| await expect(promise).rejects.toMatchObject({ | ||
| message: expect.stringContaining(`owned by program ${TOKEN_PROGRAM_ADDRESS}`), | ||
| destination, | ||
| reason: 'unexpected-owner', | ||
| owner: TOKEN_PROGRAM_ADDRESS, | ||
| }); | ||
| }); | ||
|
|
||
| it('accepts an existing destination owned by the program given in programOwner', async () => { | ||
| // Given a destination owned by the Token program. | ||
| const client = await createTestClient(); | ||
| const destination = await createForeignOwnedAddress(client); | ||
|
|
||
| // Then it is valid when the Token program is the expected owner. | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { programOwner: TOKEN_PROGRAM_ADDRESS }), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects a System-Program-owned destination when programOwner expects another program', async () => { | ||
| // Given a funded System-Program-owned destination. | ||
| const client = await createTestClient(); | ||
| const destination = await createFundedAddress(client); | ||
|
|
||
| // Then it is rejected when the Token program is the expected owner. | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { programOwner: TOKEN_PROGRAM_ADDRESS }), | ||
| ).rejects.toMatchObject({ reason: 'unexpected-owner', owner: SYSTEM_PROGRAM_ADDRESS }); | ||
| }); | ||
|
|
||
| it('accepts a HasAddress destination', async () => { | ||
| // Given a funded destination held as a signer. | ||
| const client = await createTestClient(); | ||
| const destination = await generateKeyPairSigner(); | ||
| await client.airdrop(destination.address, lamports(1_000_000_000n)); | ||
|
|
||
| // Then it is valid as a signer and as an `AddressWrapper`. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, new AddressWrapper(destination.address)), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects a HasAddress destination owned by another program', async () => { | ||
| // Given a Token-program-owned destination held in an `AddressWrapper`. | ||
| const client = await createTestClient(); | ||
| const destination = new AddressWrapper(await createForeignOwnedAddress(client)); | ||
|
|
||
| // Then it is rejected, reporting the wrapped address. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ | ||
| destination: destination.address, | ||
| reason: 'unexpected-owner', | ||
| }); | ||
| }); | ||
|
|
||
| it('accepts a ProgramDerivedAddress destination', async () => { | ||
| // Given a funded off-curve System-Program-owned account held as a `[address, bump]` tuple. | ||
| const client = await createTestClient(); | ||
| const destination = await getProgramDerivedAddress({ | ||
| programAddress: SYSTEM_PROGRAM_ADDRESS, | ||
| seeds: ['guarded-transfer'], | ||
| }); | ||
| await client.airdrop(destination[0], lamports(1_000_000_000n)); | ||
|
|
||
| // Then it is valid by default and rejected, reporting the PDA's address, when off-curve is disallowed. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: false }), | ||
| ).rejects.toMatchObject({ destination: destination[0], reason: 'off-curve' }); | ||
| }); | ||
|
|
||
| it('rejects an unfunded recipient by default', async () => { | ||
| // Given an on-curve address with no account on-chain. | ||
| const client = await createTestClient(); | ||
| const destination = (await generateKeyPairSigner()).address; | ||
|
|
||
| // Then it is rejected as unfunded. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ | ||
| reason: 'unfunded-recipient', | ||
| }); | ||
| }); | ||
|
|
||
| it('accepts an unfunded on-curve recipient when allowUnfundedRecipient is set', async () => { | ||
| // Given an on-curve address with no account on-chain. | ||
| const client = await createTestClient(); | ||
| const destination = (await generateKeyPairSigner()).address; | ||
|
|
||
| // Then it is valid when unfunded recipients are allowed. | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('accepts an unfunded off-curve recipient when allowUnfundedRecipient is set', async () => { | ||
| // Given an off-curve address with no account on-chain. | ||
| const client = await createTestClient(); | ||
| const destination = await deriveOffCurveAddress(); | ||
|
|
||
| // Then it is valid when unfunded recipients are allowed. | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), | ||
| ).resolves.toBeUndefined(); | ||
| }); | ||
|
|
||
| it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is false', async () => { | ||
| // Given an off-curve address with no account on-chain. | ||
| const client = await createTestClient(); | ||
| const destination = await deriveOffCurveAddress(); | ||
|
|
||
| // Then it is rejected as off-curve even when unfunded recipients are allowed. | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { | ||
| allowOffCurve: false, | ||
| allowUnfundedRecipient: true, | ||
| }), | ||
| ).rejects.toMatchObject({ reason: 'off-curve' }); | ||
| }); | ||
|
|
||
| it('accepts an existing off-curve System-Program-owned account unless allowOffCurve is false', async () => { | ||
| // Given a funded off-curve System-Program-owned account. | ||
| const client = await createTestClient(); | ||
| const destination = await deriveOffCurveAddress(); | ||
| await client.airdrop(destination, lamports(1_000_000_000n)); | ||
|
|
||
| // Then it is valid by default and rejected when off-curve recipients are disallowed. | ||
| await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); | ||
| await expect( | ||
| assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: false }), | ||
| ).rejects.toMatchObject({ reason: 'off-curve' }); | ||
| }); | ||
|
|
||
| it('does not forward guard options to getAccountInfo', async () => { | ||
| // Given a funded destination and an RPC that records getAccountInfo calls. | ||
| const client = await createTestClient(); | ||
| const destination = await createFundedAddress(client); | ||
| const getAccountInfo = vi.fn(client.rpc.getAccountInfo); | ||
| const rpc = { getAccountInfo } as unknown as typeof client.rpc; | ||
|
|
||
| // When we validate it with guard options and a commitment. | ||
| await assertValidTransferSolDestination(rpc, destination, { | ||
| allowOffCurve: true, | ||
| allowUnfundedRecipient: true, | ||
| commitment: 'confirmed', | ||
| programOwner: SYSTEM_PROGRAM_ADDRESS, | ||
| }); | ||
|
|
||
| // Then only the fetch options reach the RPC. | ||
| expect(getAccountInfo).toHaveBeenCalledWith(destination, { commitment: 'confirmed', encoding: 'base64' }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.