From 1d54dbabc60fce3afe94fd58b0ea2b7bc1a60d04 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:16:56 -0700 Subject: [PATCH 1/4] feat: add transferSolGuarded --- clients/js/README.md | 24 ++++ clients/js/src/index.ts | 2 + clients/js/src/plugin.ts | 42 +++++++ clients/js/src/transferSolGuarded.ts | 93 +++++++++++++++ clients/js/test/transferSolGuarded.test.ts | 130 +++++++++++++++++++++ 5 files changed, 291 insertions(+) create mode 100644 clients/js/src/plugin.ts create mode 100644 clients/js/src/transferSolGuarded.ts create mode 100644 clients/js/test/transferSolGuarded.test.ts diff --git a/clients/js/README.md b/clients/js/README.md index ada9cfe..51ffd84 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -2,6 +2,30 @@ A generated JavaScript library for the System program. +## Guarded SOL transfers + +SOL sent to an account that is not owned by the System Program — most commonly an SPL token mint — is typically unrecoverable. The guarded transfer helpers refuse to build a `transferSol` instruction unless the destination is a valid System-owned recipient: either an account already owned by the System Program, or a not-yet-created on-curve address. + +Using the `systemProgram()` client plugin: + +```ts +// Throws InvalidTransferSolDestinationError if `destination` is not a valid recipient. +await client.system.instructions.transferSolGuarded({ source, destination, amount }).sendTransaction(); +``` + +Or build the instruction (or validate a destination) directly: + +```ts +import { assertValidTransferSolDestination, getTransferSolGuardedInstruction } from '@solana-program/system'; + +const instruction = await getTransferSolGuardedInstruction(rpc, { source, destination, amount }); + +// Validate a destination on its own, e.g. as a user types it. +await assertValidTransferSolDestination(rpc, destination); +``` + +Pass `{ allowOffCurve: true }` to permit funding a not-yet-created off-curve address (a program-derived address). + ## Getting started The JS client tests use [LiteSVM](https://github.com/LiteSVM/litesvm) in-process, so no local validator is needed. From the root of the repository: diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 69e4e4e..87a5919 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1 +1,3 @@ export * from './generated'; +export * from './transferSolGuarded'; +export { systemProgram, type SystemPluginInstructionsWithGuard, type SystemPluginWithGuard } from './plugin'; diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts new file mode 100644 index 0000000..50a94e0 --- /dev/null +++ b/clients/js/src/plugin.ts @@ -0,0 +1,42 @@ +import { extendClient } from '@solana/kit'; +import { addSelfPlanAndSendFunctions, type SelfPlanAndSendFunctions } from '@solana/kit/program-client-core'; + +import { + getTransferSolInstruction, + systemProgram as generatedSystemProgram, + type SystemPlugin, + type SystemPluginInstructions, + type SystemPluginRequirements, + type TransferSolInput, +} from './generated'; +import { getTransferSolGuardedInstruction, type TransferSolGuardConfig } from './transferSolGuarded'; + +export type SystemPluginInstructionsWithGuard = SystemPluginInstructions & { + transferSolGuarded: ( + input: TransferSolInput, + config?: TransferSolGuardConfig, + ) => SelfPlanAndSendFunctions & Promise>; +}; + +export type SystemPluginWithGuard = Omit & { + instructions: SystemPluginInstructionsWithGuard; +}; + +export function systemProgram() { + return (client: T): Omit & { system: SystemPluginWithGuard } => { + const { system } = generatedSystemProgram()(client); + return extendClient(client, { + system: { + ...system, + instructions: { + ...system.instructions, + transferSolGuarded: (input: TransferSolInput, config?: TransferSolGuardConfig) => + addSelfPlanAndSendFunctions( + client, + getTransferSolGuardedInstruction(client.rpc, input, config), + ), + }, + } as SystemPluginWithGuard, + }); + }; +} diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/transferSolGuarded.ts new file mode 100644 index 0000000..70c087d --- /dev/null +++ b/clients/js/src/transferSolGuarded.ts @@ -0,0 +1,93 @@ +import { fetchEncodedAccount, isOffCurveAddress, type Address, type FetchAccountConfig } from '@solana/kit'; + +import { + getTransferSolInstruction, + SYSTEM_PROGRAM_ADDRESS, + type TransferSolInput, + type TransferSolInstruction, +} from './generated'; + +/** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ +export type InvalidTransferSolDestinationReason = 'non-system-owner' | 'off-curve-nonexistent'; + +/** Options for validating a SOL-transfer destination. Extends {@link FetchAccountConfig}. */ +export type TransferSolGuardConfig = FetchAccountConfig & { + /** + * Allow a destination that has no account on-chain and is off-curve (a program-derived address). + * Has no effect on accounts that already exist. Defaults to `false`. + */ + allowOffCurve?: boolean; +}; + +/** Thrown when a SOL-transfer destination is not a valid System-Program-owned recipient. */ +export class InvalidTransferSolDestinationError extends Error { + readonly destination: Address; + /** The program that owns the destination account, set only when `reason` is `'non-system-owner'`. */ + readonly owner?: Address; + readonly reason: InvalidTransferSolDestinationReason; + + constructor( + message: string, + details: { destination: Address; owner?: Address; reason: InvalidTransferSolDestinationReason }, + ) { + super(message); + this.name = 'InvalidTransferSolDestinationError'; + this.destination = details.destination; + this.owner = details.owner; + this.reason = details.reason; + } +} + +/** + * Asserts that `destination` can safely receive a SOL transfer, throwing + * {@link InvalidTransferSolDestinationError} otherwise. + * + * A destination is valid when it is either an account already owned by the System Program, or an + * address with no account on-chain that a keypair could still control (on-curve). An existing + * account owned by any other program — most commonly an SPL token mint — is rejected, since SOL + * sent to it is typically unrecoverable. A non-existent off-curve address is rejected unless + * `allowOffCurve` is set. + * + * Reads the destination's on-chain owner, so an RPC supporting `getAccountInfo` is required. + */ +export async function assertValidTransferSolDestination( + rpc: Parameters[0], + destination: Address, + config?: TransferSolGuardConfig, +): Promise { + const account = await fetchEncodedAccount(rpc, destination, config); + + if (account.exists) { + if (account.programAddress !== SYSTEM_PROGRAM_ADDRESS) { + throw new InvalidTransferSolDestinationError( + `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the System Program (${SYSTEM_PROGRAM_ADDRESS}). It is likely an SPL token mint or another program account, and SOL sent to it would be unrecoverable. Verify the recipient is a wallet address.`, + { destination, owner: account.programAddress, reason: 'non-system-owner' }, + ); + } + return; + } + + if (isOffCurveAddress(destination) && !config?.allowOffCurve) { + throw new InvalidTransferSolDestinationError( + `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address with no account on-chain, so no keypair can control funds sent to it. If you intend to fund a PDA, pass { allowOffCurve: true }.`, + { destination, reason: 'off-curve-nonexistent' }, + ); + } +} + +/** + * Builds a `transferSol` instruction after asserting the destination is a valid recipient via + * {@link assertValidTransferSolDestination}. Async because the owner check requires an RPC; + * otherwise equivalent to `getTransferSolInstruction`. + */ +export async function getTransferSolGuardedInstruction< + TAccountSource extends string, + TAccountDestination extends string, +>( + rpc: Parameters[0], + input: TransferSolInput, + config?: TransferSolGuardConfig, +): Promise> { + await assertValidTransferSolDestination(rpc, input.destination, config); + return getTransferSolInstruction(input); +} diff --git a/clients/js/test/transferSolGuarded.test.ts b/clients/js/test/transferSolGuarded.test.ts new file mode 100644 index 0000000..f78579e --- /dev/null +++ b/clients/js/test/transferSolGuarded.test.ts @@ -0,0 +1,130 @@ +import { address, generateKeyPairSigner, getProgramDerivedAddress, lamports, type Address } from '@solana/kit'; +import { expect, it } from 'vitest'; + +import { + assertValidTransferSolDestination, + getTransferSolGuardedInstruction, + getTransferSolInstruction, + InvalidTransferSolDestinationError, + SYSTEM_PROGRAM_ADDRESS, +} from '../src'; +import { createTestClient } from './_setup'; + +const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + +type TestClient = Awaited>; + +// Creates an account that exists on-chain but is owned by a program other than the System Program, +// reproducing the footgun this helper guards against (e.g. sending SOL to a token mint). +const createForeignOwnedAddress = async (client: TestClient): Promise
=> { + 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 deriveOffCurveAddress = async (): Promise
=> { + const [pda] = await getProgramDerivedAddress({ + programAddress: SYSTEM_PROGRAM_ADDRESS, + seeds: ['guarded-transfer'], + }); + return pda; +}; + +it('accepts a non-existent on-curve destination', async () => { + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); +}); + +it('accepts an existing System-Program-owned destination', async () => { + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + await client.airdrop(destination, lamports(1_000_000_000n)); + await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); +}); + +it('rejects an existing destination owned by another program', async () => { + const client = await createTestClient(); + const destination = await createForeignOwnedAddress(client); + + await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toThrow( + InvalidTransferSolDestinationError, + ); + + const error = await assertValidTransferSolDestination(client.rpc, destination).catch(e => e); + expect(error).toBeInstanceOf(InvalidTransferSolDestinationError); + expect(error.reason).toBe('non-system-owner'); + expect(error.owner).toBe(TOKEN_PROGRAM_ADDRESS); + expect(error.destination).toBe(destination); +}); + +it('rejects a non-existent off-curve destination by default', async () => { + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + + await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toThrow( + InvalidTransferSolDestinationError, + ); + + const error = await assertValidTransferSolDestination(client.rpc, destination).catch(e => e); + expect(error.reason).toBe('off-curve-nonexistent'); +}); + +it('accepts a non-existent off-curve destination when allowOffCurve is set', async () => { + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: true }), + ).resolves.toBeUndefined(); +}); + +it('builds an instruction identical to getTransferSolInstruction for a safe destination', async () => { + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = (await generateKeyPairSigner()).address; + const input = { source, destination, amount: 1_000_000_000 }; + + const guarded = await getTransferSolGuardedInstruction(client.rpc, input); + + expect(guarded).toStrictEqual(getTransferSolInstruction(input)); +}); + +it('refuses to build an instruction for an unsafe destination', async () => { + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = await createForeignOwnedAddress(client); + + await expect(getTransferSolGuardedInstruction(client.rpc, { source, destination, amount: 1n })).rejects.toThrow( + InvalidTransferSolDestinationError, + ); +}); + +it('transfers SOL to a safe destination via the client plugin method', async () => { + const client = await createTestClient(); + const [source, destination] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner().then(signer => signer.address), + ]); + await client.airdrop(source.address, lamports(3_000_000_000n)); + + await client.system.instructions + .transferSolGuarded({ source, destination, amount: 1_000_000_000 }) + .sendTransaction(); + + const { value: sourceBalance } = await client.rpc.getBalance(source.address, { commitment: 'confirmed' }).send(); + expect(sourceBalance).toBe(lamports(2_000_000_000n)); + const { value: destinationBalance } = await client.rpc.getBalance(destination, { commitment: 'confirmed' }).send(); + expect(destinationBalance).toBe(lamports(1_000_000_000n)); +}); + +it('rejects an unsafe destination via the client plugin method', async () => { + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + await client.airdrop(source.address, lamports(1_000_000_000n)); + const destination = await createForeignOwnedAddress(client); + + await expect( + client.system.instructions.transferSolGuarded({ source, destination, amount: 1n }).sendTransaction(), + ).rejects.toThrow(InvalidTransferSolDestinationError); +}); From ea8631a49a19a833393910889e2cf326af1763a0 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:47:14 -0700 Subject: [PATCH 2/4] feat: add allowUnfundedRecipient --- clients/js/README.md | 4 +- clients/js/src/transferSolGuarded.ts | 29 +++++++++++---- clients/js/test/transferSolGuarded.test.ts | 43 ++++++++++++++++------ 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/clients/js/README.md b/clients/js/README.md index 51ffd84..6d7fad9 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -4,7 +4,7 @@ A generated JavaScript library for the System program. ## Guarded SOL transfers -SOL sent to an account that is not owned by the System Program — most commonly an SPL token mint — is typically unrecoverable. The guarded transfer helpers refuse to build a `transferSol` instruction unless the destination is a valid System-owned recipient: either an account already owned by the System Program, or a not-yet-created on-curve address. +SOL sent to an account that is not owned by the System Program — most commonly an SPL token mint — is typically unrecoverable. The guarded transfer helpers refuse to build a `transferSol` instruction unless the destination is a valid recipient. By default that means an account already owned by the System Program; accounts owned by other programs and addresses with no account on-chain yet (often a mistyped address) are rejected. Using the `systemProgram()` client plugin: @@ -24,7 +24,7 @@ const instruction = await getTransferSolGuardedInstruction(rpc, { source, destin await assertValidTransferSolDestination(rpc, destination); ``` -Pass `{ allowOffCurve: true }` to permit funding a not-yet-created off-curve address (a program-derived address). +To fund an address that has no account on-chain yet, pass `{ allowUnfundedRecipient: true }`; if that address is also off-curve (a program-derived address), additionally pass `{ allowOffCurve: true }`. ## Getting started diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/transferSolGuarded.ts index 70c087d..b0c3af6 100644 --- a/clients/js/src/transferSolGuarded.ts +++ b/clients/js/src/transferSolGuarded.ts @@ -8,13 +8,19 @@ import { } from './generated'; /** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ -export type InvalidTransferSolDestinationReason = 'non-system-owner' | 'off-curve-nonexistent'; +export type InvalidTransferSolDestinationReason = 'non-system-owner' | 'unfunded-recipient' | 'off-curve-nonexistent'; /** Options for validating a SOL-transfer destination. Extends {@link FetchAccountConfig}. */ export type TransferSolGuardConfig = FetchAccountConfig & { /** - * Allow a destination that has no account on-chain and is off-curve (a program-derived address). - * Has no effect on accounts that already exist. Defaults to `false`. + * 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 unfunded destination that is off-curve (a program-derived address). Only relevant + * together with `allowUnfundedRecipient`, and has no effect on accounts that already exist. + * Defaults to `false`. */ allowOffCurve?: boolean; }; @@ -42,11 +48,11 @@ export class InvalidTransferSolDestinationError extends Error { * Asserts that `destination` can safely receive a SOL transfer, throwing * {@link InvalidTransferSolDestinationError} otherwise. * - * A destination is valid when it is either an account already owned by the System Program, or an - * address with no account on-chain that a keypair could still control (on-curve). An existing - * account owned by any other program — most commonly an SPL token mint — is rejected, since SOL - * sent to it is typically unrecoverable. A non-existent off-curve address is rejected unless - * `allowOffCurve` is set. + * By default a destination is valid only when it is an account already owned by the System Program. + * An existing account owned by any other program — most commonly an SPL token mint — is rejected, + * since SOL sent to it is typically unrecoverable. A destination with no account on-chain is rejected + * unless `allowUnfundedRecipient` is set, and an unfunded off-curve address additionally requires + * `allowOffCurve`. * * Reads the destination's on-chain owner, so an RPC supporting `getAccountInfo` is required. */ @@ -67,6 +73,13 @@ export async function assertValidTransferSolDestination( return; } + if (!config?.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, reason: 'unfunded-recipient' }, + ); + } + if (isOffCurveAddress(destination) && !config?.allowOffCurve) { throw new InvalidTransferSolDestinationError( `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address with no account on-chain, so no keypair can control funds sent to it. If you intend to fund a PDA, pass { allowOffCurve: true }.`, diff --git a/clients/js/test/transferSolGuarded.test.ts b/clients/js/test/transferSolGuarded.test.ts index f78579e..61cf696 100644 --- a/clients/js/test/transferSolGuarded.test.ts +++ b/clients/js/test/transferSolGuarded.test.ts @@ -31,12 +31,6 @@ const deriveOffCurveAddress = async (): Promise
=> { return pda; }; -it('accepts a non-existent on-curve destination', async () => { - const client = await createTestClient(); - const destination = (await generateKeyPairSigner()).address; - await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); -}); - it('accepts an existing System-Program-owned destination', async () => { const client = await createTestClient(); const destination = (await generateKeyPairSigner()).address; @@ -59,23 +53,48 @@ it('rejects an existing destination owned by another program', async () => { expect(error.destination).toBe(destination); }); -it('rejects a non-existent off-curve destination by default', async () => { +it('rejects an unfunded recipient by default', async () => { const client = await createTestClient(); - const destination = await deriveOffCurveAddress(); + const destination = (await generateKeyPairSigner()).address; await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toThrow( InvalidTransferSolDestinationError, ); const error = await assertValidTransferSolDestination(client.rpc, destination).catch(e => e); + expect(error.reason).toBe('unfunded-recipient'); +}); + +it('accepts an unfunded on-curve recipient when allowUnfundedRecipient is set', async () => { + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), + ).resolves.toBeUndefined(); +}); + +it('rejects an unfunded off-curve recipient even when allowUnfundedRecipient is set', async () => { + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), + ).rejects.toThrow(InvalidTransferSolDestinationError); + + const error = await assertValidTransferSolDestination(client.rpc, destination, { + allowUnfundedRecipient: true, + }).catch(e => e); expect(error.reason).toBe('off-curve-nonexistent'); }); -it('accepts a non-existent off-curve destination when allowOffCurve is set', async () => { +it('accepts an unfunded off-curve recipient when both flags are set', async () => { const client = await createTestClient(); const destination = await deriveOffCurveAddress(); await expect( - assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: true }), + assertValidTransferSolDestination(client.rpc, destination, { + allowOffCurve: true, + allowUnfundedRecipient: true, + }), ).resolves.toBeUndefined(); }); @@ -85,7 +104,7 @@ it('builds an instruction identical to getTransferSolInstruction for a safe dest const destination = (await generateKeyPairSigner()).address; const input = { source, destination, amount: 1_000_000_000 }; - const guarded = await getTransferSolGuardedInstruction(client.rpc, input); + const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); expect(guarded).toStrictEqual(getTransferSolInstruction(input)); }); @@ -109,7 +128,7 @@ it('transfers SOL to a safe destination via the client plugin method', async () await client.airdrop(source.address, lamports(3_000_000_000n)); await client.system.instructions - .transferSolGuarded({ source, destination, amount: 1_000_000_000 }) + .transferSolGuarded({ source, destination, amount: 1_000_000_000 }, { allowUnfundedRecipient: true }) .sendTransaction(); const { value: sourceBalance } = await client.rpc.getBalance(source.address, { commitment: 'confirmed' }).send(); From 742c679d3f26dca22d336a63e7623e14c649958b Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:02:00 -0700 Subject: [PATCH 3/4] feat: add programOwner config --- clients/js/src/transferSolGuarded.ts | 17 ++++++++++++----- clients/js/test/transferSolGuarded.test.ts | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/transferSolGuarded.ts index b0c3af6..77458df 100644 --- a/clients/js/src/transferSolGuarded.ts +++ b/clients/js/src/transferSolGuarded.ts @@ -23,6 +23,11 @@ export type TransferSolGuardConfig = FetchAccountConfig & { * Defaults to `false`. */ allowOffCurve?: boolean; + /** + * The program expected to own an existing destination account. A destination owned by any other + * program is rejected. Defaults to the System Program ({@link SYSTEM_PROGRAM_ADDRESS}). + */ + programOwner?: Address; }; /** Thrown when a SOL-transfer destination is not a valid System-Program-owned recipient. */ @@ -48,9 +53,10 @@ export class InvalidTransferSolDestinationError extends Error { * Asserts that `destination` can safely receive a SOL transfer, throwing * {@link InvalidTransferSolDestinationError} otherwise. * - * By default a destination is valid only when it is an account already owned by the System Program. - * An existing account owned by any other program — most commonly an SPL token mint — is rejected, - * since SOL sent to it is typically unrecoverable. A destination with no account on-chain is rejected + * By default a destination is valid only when it is an account already owned by the System Program; + * pass `programOwner` to expect a different owner. An existing account owned by any other program — + * most commonly an SPL token mint — is rejected, since SOL sent to it is typically unrecoverable. A + * destination with no account on-chain is rejected * unless `allowUnfundedRecipient` is set, and an unfunded off-curve address additionally requires * `allowOffCurve`. * @@ -61,12 +67,13 @@ export async function assertValidTransferSolDestination( destination: Address, config?: TransferSolGuardConfig, ): Promise { + const programOwner = config?.programOwner ?? SYSTEM_PROGRAM_ADDRESS; const account = await fetchEncodedAccount(rpc, destination, config); if (account.exists) { - if (account.programAddress !== SYSTEM_PROGRAM_ADDRESS) { + if (account.programAddress !== programOwner) { throw new InvalidTransferSolDestinationError( - `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the System Program (${SYSTEM_PROGRAM_ADDRESS}). It is likely an SPL token mint or another program account, and SOL sent to it would be unrecoverable. Verify the recipient is a wallet address.`, + `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}). It is likely an SPL token mint or another program account, and SOL sent to it would be unrecoverable. Verify the recipient is a wallet address.`, { destination, owner: account.programAddress, reason: 'non-system-owner' }, ); } diff --git a/clients/js/test/transferSolGuarded.test.ts b/clients/js/test/transferSolGuarded.test.ts index 61cf696..2a2809a 100644 --- a/clients/js/test/transferSolGuarded.test.ts +++ b/clients/js/test/transferSolGuarded.test.ts @@ -53,6 +53,27 @@ it('rejects an existing destination owned by another program', async () => { expect(error.destination).toBe(destination); }); +it('accepts an existing destination owned by the program given in programOwner', async () => { + const client = await createTestClient(); + const destination = await createForeignOwnedAddress(client); + 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 () => { + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + await client.airdrop(destination, lamports(1_000_000_000n)); + + const error = await assertValidTransferSolDestination(client.rpc, destination, { + programOwner: TOKEN_PROGRAM_ADDRESS, + }).catch(e => e); + expect(error).toBeInstanceOf(InvalidTransferSolDestinationError); + expect(error.reason).toBe('non-system-owner'); + expect(error.owner).toBe(SYSTEM_PROGRAM_ADDRESS); +}); + it('rejects an unfunded recipient by default', async () => { const client = await createTestClient(); const destination = (await generateKeyPairSigner()).address; From 8aa693ab33d00790f1c29c96d9b51ea056d42a9e Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:50:34 -0700 Subject: [PATCH 4/4] chore: refactor offcurve --- clients/js/README.md | 2 +- clients/js/src/transferSolGuarded.ts | 30 ++++++++++------------ clients/js/test/transferSolGuarded.test.ts | 23 +++++++++++++---- 3 files changed, 32 insertions(+), 23 deletions(-) diff --git a/clients/js/README.md b/clients/js/README.md index 6d7fad9..e0a5513 100644 --- a/clients/js/README.md +++ b/clients/js/README.md @@ -24,7 +24,7 @@ const instruction = await getTransferSolGuardedInstruction(rpc, { source, destin await assertValidTransferSolDestination(rpc, destination); ``` -To fund an address that has no account on-chain yet, pass `{ allowUnfundedRecipient: true }`; if that address is also off-curve (a program-derived address), additionally pass `{ allowOffCurve: true }`. +To fund an address that has no account on-chain yet, pass `{ allowUnfundedRecipient: true }`. Any off-curve (program-derived) destination — existing or not — requires `{ allowOffCurve: true }`. To require an existing destination be owned by a program other than the System Program, pass `{ programOwner }`. ## Getting started diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/transferSolGuarded.ts index 77458df..6473913 100644 --- a/clients/js/src/transferSolGuarded.ts +++ b/clients/js/src/transferSolGuarded.ts @@ -8,7 +8,7 @@ import { } from './generated'; /** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ -export type InvalidTransferSolDestinationReason = 'non-system-owner' | 'unfunded-recipient' | 'off-curve-nonexistent'; +export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; /** Options for validating a SOL-transfer destination. Extends {@link FetchAccountConfig}. */ export type TransferSolGuardConfig = FetchAccountConfig & { @@ -18,8 +18,7 @@ export type TransferSolGuardConfig = FetchAccountConfig & { */ allowUnfundedRecipient?: boolean; /** - * Allow an unfunded destination that is off-curve (a program-derived address). Only relevant - * together with `allowUnfundedRecipient`, and has no effect on accounts that already exist. + * Allow an off-curve (program-derived) destination, whether or not it already exists. * Defaults to `false`. */ allowOffCurve?: boolean; @@ -30,10 +29,10 @@ export type TransferSolGuardConfig = FetchAccountConfig & { programOwner?: Address; }; -/** Thrown when a SOL-transfer destination is not a valid System-Program-owned recipient. */ +/** Thrown when a SOL-transfer destination fails validation by {@link assertValidTransferSolDestination}. */ export class InvalidTransferSolDestinationError extends Error { readonly destination: Address; - /** The program that owns the destination account, set only when `reason` is `'non-system-owner'`. */ + /** The program that owns the destination account, set only when `reason` is `'unexpected-owner'`. */ readonly owner?: Address; readonly reason: InvalidTransferSolDestinationReason; @@ -55,10 +54,10 @@ export class InvalidTransferSolDestinationError extends Error { * * By default a destination is valid only when it is an account already owned by the System Program; * pass `programOwner` to expect a different owner. An existing account owned by any other program — - * most commonly an SPL token mint — is rejected, since SOL sent to it is typically unrecoverable. A - * destination with no account on-chain is rejected - * unless `allowUnfundedRecipient` is set, and an unfunded off-curve address additionally requires - * `allowOffCurve`. + * (commonly an SPL token mint) — is rejected, since SOL sent to it is typically unrecoverable. A + * destination with no account on-chain is rejected unless `allowUnfundedRecipient` is set. An + * off-curve (program-derived) destination is rejected unless `allowOffCurve` is set, whether or not + * it already exists. * * Reads the destination's on-chain owner, so an RPC supporting `getAccountInfo` is required. */ @@ -73,14 +72,11 @@ export async function assertValidTransferSolDestination( 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}). It is likely an SPL token mint or another program account, and SOL sent to it would be unrecoverable. Verify the recipient is a wallet address.`, - { destination, owner: account.programAddress, reason: 'non-system-owner' }, + `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}).`, + { destination, owner: account.programAddress, reason: 'unexpected-owner' }, ); } - return; - } - - if (!config?.allowUnfundedRecipient) { + } else if (!config?.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, reason: 'unfunded-recipient' }, @@ -89,8 +85,8 @@ export async function assertValidTransferSolDestination( if (isOffCurveAddress(destination) && !config?.allowOffCurve) { throw new InvalidTransferSolDestinationError( - `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address with no account on-chain, so no keypair can control funds sent to it. If you intend to fund a PDA, pass { allowOffCurve: true }.`, - { destination, reason: 'off-curve-nonexistent' }, + `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address, so no keypair can control SOL sent there. If this is intentional, pass { allowOffCurve: true }.`, + { destination, reason: 'off-curve' }, ); } } diff --git a/clients/js/test/transferSolGuarded.test.ts b/clients/js/test/transferSolGuarded.test.ts index 2a2809a..bdb5b4e 100644 --- a/clients/js/test/transferSolGuarded.test.ts +++ b/clients/js/test/transferSolGuarded.test.ts @@ -14,8 +14,7 @@ const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5 type TestClient = Awaited>; -// Creates an account that exists on-chain but is owned by a program other than the System Program, -// reproducing the footgun this helper guards against (e.g. sending SOL to a token mint). +// Creates an on-chain account owned by a program other than the System Program, like an SPL token mint. const createForeignOwnedAddress = async (client: TestClient): Promise
=> { const account = await generateKeyPairSigner(); await client.airdrop(account.address, lamports(1_000_000_000n)); @@ -48,7 +47,7 @@ it('rejects an existing destination owned by another program', async () => { const error = await assertValidTransferSolDestination(client.rpc, destination).catch(e => e); expect(error).toBeInstanceOf(InvalidTransferSolDestinationError); - expect(error.reason).toBe('non-system-owner'); + expect(error.reason).toBe('unexpected-owner'); expect(error.owner).toBe(TOKEN_PROGRAM_ADDRESS); expect(error.destination).toBe(destination); }); @@ -70,7 +69,7 @@ it('rejects a System-Program-owned destination when programOwner expects another programOwner: TOKEN_PROGRAM_ADDRESS, }).catch(e => e); expect(error).toBeInstanceOf(InvalidTransferSolDestinationError); - expect(error.reason).toBe('non-system-owner'); + expect(error.reason).toBe('unexpected-owner'); expect(error.owner).toBe(SYSTEM_PROGRAM_ADDRESS); }); @@ -105,7 +104,7 @@ it('rejects an unfunded off-curve recipient even when allowUnfundedRecipient is const error = await assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true, }).catch(e => e); - expect(error.reason).toBe('off-curve-nonexistent'); + expect(error.reason).toBe('off-curve'); }); it('accepts an unfunded off-curve recipient when both flags are set', async () => { @@ -119,6 +118,20 @@ it('accepts an unfunded off-curve recipient when both flags are set', async () = ).resolves.toBeUndefined(); }); +it('rejects an existing off-curve System-Program-owned account unless allowOffCurve is set', async () => { + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + await client.airdrop(destination, lamports(1_000_000_000n)); + + const error = await assertValidTransferSolDestination(client.rpc, destination).catch(e => e); + expect(error).toBeInstanceOf(InvalidTransferSolDestinationError); + expect(error.reason).toBe('off-curve'); + + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: true }), + ).resolves.toBeUndefined(); +}); + it('builds an instruction identical to getTransferSolInstruction for a safe destination', async () => { const client = await createTestClient(); const source = await generateKeyPairSigner();