From 66d95d7279e983b39df8364ed154465cdcc79c66 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:32:42 -0700 Subject: [PATCH 1/3] feat(js): add guarded SOL transfer helper 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. --- clients/js/src/index.ts | 5 + clients/js/src/plugin.ts | 41 +++ clients/js/src/transferSolGuarded.ts | 116 ++++++++ clients/js/test/transferSolGuarded.test.ts | 299 +++++++++++++++++++++ 4 files changed, 461 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/src/index.ts b/clients/js/src/index.ts index 69e4e4eb..8cbbde6e 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1 +1,6 @@ export * from './generated'; + +// Named exports take precedence over the generated plugin exported by `export *` above. +export { systemProgram, type SystemPlugin, type SystemPluginInstructions } from './plugin'; + +export * from './transferSolGuarded'; diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts new file mode 100644 index 00000000..f95247f2 --- /dev/null +++ b/clients/js/src/plugin.ts @@ -0,0 +1,41 @@ +import { extendClient, pipe } from '@solana/kit'; +import { addSelfPlanAndSendFunctions, type SelfPlanAndSendFunctions } from '@solana/kit/program-client-core'; + +import { + systemProgram as generatedSystemProgram, + type SystemPlugin as GeneratedSystemPlugin, + type SystemPluginInstructions as GeneratedSystemPluginInstructions, + type SystemPluginRequirements, + type TransferSolInput, +} from './generated'; +import { getTransferSolGuardedInstruction, type TransferSolGuardConfig } from './transferSolGuarded'; + +export type SystemPlugin = Omit & { instructions: SystemPluginInstructions }; + +export type SystemPluginInstructions = GeneratedSystemPluginInstructions & { + /** Like `transferSol`, but rejects destinations that fail `assertValidTransferSolDestination`. */ + transferSolGuarded: ( + input: TransferSolInput, + config?: TransferSolGuardConfig, + ) => ReturnType & SelfPlanAndSendFunctions; +}; + +export function systemProgram() { + return (client: T) => { + return pipe(client, generatedSystemProgram(), c => + extendClient(c, { + system: { + ...c.system, + instructions: { + ...c.system.instructions, + transferSolGuarded: (input, config) => + addSelfPlanAndSendFunctions( + client, + getTransferSolGuardedInstruction(client.rpc, input, config), + ), + }, + }, + }), + ); + }; +} diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/transferSolGuarded.ts new file mode 100644 index 00000000..4c1b03d4 --- /dev/null +++ b/clients/js/src/transferSolGuarded.ts @@ -0,0 +1,116 @@ +import { + fetchEncodedAccount, + isOffCurveAddress, + type Address, + type FetchAccountConfig, + type HasAddress, +} from '@solana/kit'; +import { + getAddressFromResolvedInstructionAccount, + type InstructionAccountInput, + type InstructionSignerInput, +} from '@solana/kit/program-client-core'; + +import { getTransferSolInstruction, SYSTEM_PROGRAM_ADDRESS, type TransferSolInput } from './generated'; + +/** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ +export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; + +/** + * The `cause` of the `Error` thrown by {@link assertValidTransferSolDestination}. `owner` is set only + * when `reason` is `'unexpected-owner'`. + */ +export type InvalidTransferSolDestinationCause = { + destination: Address; + reason: InvalidTransferSolDestinationReason; + owner?: Address; +}; + +/** + * Options for {@link assertValidTransferSolDestination}. Any {@link FetchAccountConfig} fields are passed + * through to the account fetch. + */ +export type TransferSolGuardConfig = 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`; 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 `Error` whose `cause` is an {@link InvalidTransferSolDestinationCause} 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: Parameters[0], + destinationInput: Address | HasAddress, + config?: TransferSolGuardConfig, +): Promise { + const destination = typeof destinationInput === 'string' ? destinationInput : destinationInput.address; + 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 Error( + `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}).`, + { cause: { destination, reason: 'unexpected-owner', owner: account.programAddress } }, + ); + } + } else if (!allowUnfundedRecipient) { + throw new Error( + `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 }.`, + { cause: { destination, reason: 'unfunded-recipient' } }, + ); + } + + if (isOffCurveAddress(destination) && !allowOffCurve) { + throw new Error( + `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address, which no keypair controls. It may be a smart wallet (e.g. Squads or Swig); once the recipient confirms they control it, pass { allowOffCurve: true }.`, + { cause: { destination, reason: 'off-curve' } }, + ); + } +} + +/** + * Builds a `transferSol` instruction after checking the destination with + * {@link assertValidTransferSolDestination}. Accepts the same input as `getTransferSolInstruction`. + */ +export async function getTransferSolGuardedInstruction< + TAccountSource extends InstructionSignerInput, + TAccountDestination extends InstructionAccountInput, +>( + rpc: Parameters[0], + input: TransferSolInput, + config?: TransferSolGuardConfig, +): Promise>> { + const destination = getAddressFromResolvedInstructionAccount('destination', input.destination); + await assertValidTransferSolDestination(rpc, 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 00000000..c0cd2300 --- /dev/null +++ b/clients/js/test/transferSolGuarded.test.ts @@ -0,0 +1,299 @@ +import { + address, + generateKeyPairSigner, + getProgramDerivedAddress, + lamports, + type Address, + type HasAddress, +} from '@solana/kit'; +import { expect, it, vi } from 'vitest'; + +import { + assertValidTransferSolDestination, + getTransferSolGuardedInstruction, + getTransferSolInstruction, + SYSTEM_PROGRAM_ADDRESS, +} from '../src'; +import { createTestClient } from './_setup'; + +const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + +type TestClient = Awaited>; + +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
=> { + 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
=> { + const destination = (await generateKeyPairSigner()).address; + await client.airdrop(destination, lamports(1_000_000_000n)); + return destination; +}; + +const deriveOffCurveAddress = async (): Promise
=> { + 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. + await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ + message: expect.stringContaining(`owned by program ${TOKEN_PROGRAM_ADDRESS}`), + cause: { 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({ cause: { 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({ + cause: { destination: destination.address, reason: 'unexpected-owner' }, + }); +}); + +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({ + cause: { 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({ cause: { 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({ cause: { reason: 'off-curve' } }); +}); + +it('builds an instruction identical to getTransferSolInstruction for a safe destination', async () => { + // Given an unfunded on-curve destination. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = (await generateKeyPairSigner()).address; + const input = { source, destination, amount: 1_000_000_000 }; + + // When we build a guarded transfer allowing unfunded recipients. + const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); + + // Then it matches the unguarded instruction. + expect(guarded).toStrictEqual(getTransferSolInstruction(input)); +}); + +it('builds an instruction for a HasAddress destination', async () => { + // Given an unfunded on-curve destination held in an `AddressWrapper`. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = new AddressWrapper((await generateKeyPairSigner()).address); + const input = { source, destination, amount: 1_000_000_000 }; + + // When we build a guarded transfer allowing unfunded recipients. + const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); + + // Then it matches the unguarded instruction. + expect(guarded).toStrictEqual(getTransferSolInstruction(input)); +}); + +it('refuses to build an instruction for an unsafe destination', async () => { + // Given a destination owned by the Token program. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = await createForeignOwnedAddress(client); + + // Then building a guarded transfer throws. + await expect(getTransferSolGuardedInstruction(client.rpc, { source, destination, amount: 1n })).rejects.toThrow( + `owned by program ${TOKEN_PROGRAM_ADDRESS}`, + ); +}); + +it('refuses to build an instruction for an unsafe HasAddress destination', async () => { + // Given a Token-program-owned destination held in an `AddressWrapper`. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = new AddressWrapper(await createForeignOwnedAddress(client)); + + // Then building a guarded transfer throws for the wrapped address. + await expect(getTransferSolGuardedInstruction(client.rpc, { source, destination, amount: 1n })).rejects.toThrow( + `Refusing to transfer SOL to ${destination.address}`, + ); +}); + +it('refuses to build an instruction for an off-curve program-derived address destination', async () => { + // Given an unfunded off-curve destination as a program-derived address tuple. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + const destination = await getProgramDerivedAddress({ + programAddress: SYSTEM_PROGRAM_ADDRESS, + seeds: ['guarded-transfer'], + }); + + // Then building a guarded transfer that disallows off-curve recipients throws for the derived address. + await expect( + getTransferSolGuardedInstruction( + client.rpc, + { source, destination, amount: 1n }, + { allowOffCurve: false, allowUnfundedRecipient: true }, + ), + ).rejects.toThrow(`Refusing to transfer SOL to ${destination[0]}: it is an off-curve`); +}); + +it('transfers SOL to a safe destination via the client plugin method', async () => { + // Given a source account with 3 SOL and an unfunded on-curve destination. + 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)); + + // When the source transfers 1 SOL through the guarded plugin method. + await client.system.instructions + .transferSolGuarded({ source, destination, amount: 1_000_000_000 }, { allowUnfundedRecipient: true }) + .sendTransaction(); + + // Then the source has exactly 2 SOL. + const { value: sourceBalance } = await client.rpc.getBalance(source.address, { commitment: 'confirmed' }).send(); + expect(sourceBalance).toBe(lamports(2_000_000_000n)); + // And the destination has exactly 1 SOL. + 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 () => { + // Given a funded source and a destination owned by the Token program. + const client = await createTestClient(); + const source = await generateKeyPairSigner(); + await client.airdrop(source.address, lamports(1_000_000_000n)); + const destination = await createForeignOwnedAddress(client); + + // Then transferring SOL through the guarded plugin method throws. + await expect( + client.system.instructions.transferSolGuarded({ source, destination, amount: 1n }).sendTransaction(), + ).rejects.toThrow(`owned by program ${TOKEN_PROGRAM_ADDRESS}`); +}); + +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' }); +}); From 7ca201746c3ba5bf296a6ee6e41dc254e8e696c1 Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:55:54 -0700 Subject: [PATCH 2/3] refactor(js): expose only assertValidTransferSolDestination --- ...s => assertValidTransferSolDestination.ts} | 24 +--- clients/js/src/index.ts | 6 +- clients/js/src/plugin.ts | 41 ------- ...assertValidTransferSolDestination.test.ts} | 113 +----------------- 4 files changed, 3 insertions(+), 181 deletions(-) rename clients/js/src/{transferSolGuarded.ts => assertValidTransferSolDestination.ts} (78%) delete mode 100644 clients/js/src/plugin.ts rename clients/js/test/{transferSolGuarded.test.ts => assertValidTransferSolDestination.test.ts} (60%) diff --git a/clients/js/src/transferSolGuarded.ts b/clients/js/src/assertValidTransferSolDestination.ts similarity index 78% rename from clients/js/src/transferSolGuarded.ts rename to clients/js/src/assertValidTransferSolDestination.ts index 4c1b03d4..3ba8f3e3 100644 --- a/clients/js/src/transferSolGuarded.ts +++ b/clients/js/src/assertValidTransferSolDestination.ts @@ -5,13 +5,8 @@ import { type FetchAccountConfig, type HasAddress, } from '@solana/kit'; -import { - getAddressFromResolvedInstructionAccount, - type InstructionAccountInput, - type InstructionSignerInput, -} from '@solana/kit/program-client-core'; -import { getTransferSolInstruction, SYSTEM_PROGRAM_ADDRESS, type TransferSolInput } from './generated'; +import { SYSTEM_PROGRAM_ADDRESS } from './generated'; /** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; @@ -97,20 +92,3 @@ export async function assertValidTransferSolDestination( ); } } - -/** - * Builds a `transferSol` instruction after checking the destination with - * {@link assertValidTransferSolDestination}. Accepts the same input as `getTransferSolInstruction`. - */ -export async function getTransferSolGuardedInstruction< - TAccountSource extends InstructionSignerInput, - TAccountDestination extends InstructionAccountInput, ->( - rpc: Parameters[0], - input: TransferSolInput, - config?: TransferSolGuardConfig, -): Promise>> { - const destination = getAddressFromResolvedInstructionAccount('destination', input.destination); - await assertValidTransferSolDestination(rpc, destination, config); - return getTransferSolInstruction(input); -} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 8cbbde6e..ecfe08e6 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1,6 +1,2 @@ export * from './generated'; - -// Named exports take precedence over the generated plugin exported by `export *` above. -export { systemProgram, type SystemPlugin, type SystemPluginInstructions } from './plugin'; - -export * from './transferSolGuarded'; +export * from './assertValidTransferSolDestination'; diff --git a/clients/js/src/plugin.ts b/clients/js/src/plugin.ts deleted file mode 100644 index f95247f2..00000000 --- a/clients/js/src/plugin.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { extendClient, pipe } from '@solana/kit'; -import { addSelfPlanAndSendFunctions, type SelfPlanAndSendFunctions } from '@solana/kit/program-client-core'; - -import { - systemProgram as generatedSystemProgram, - type SystemPlugin as GeneratedSystemPlugin, - type SystemPluginInstructions as GeneratedSystemPluginInstructions, - type SystemPluginRequirements, - type TransferSolInput, -} from './generated'; -import { getTransferSolGuardedInstruction, type TransferSolGuardConfig } from './transferSolGuarded'; - -export type SystemPlugin = Omit & { instructions: SystemPluginInstructions }; - -export type SystemPluginInstructions = GeneratedSystemPluginInstructions & { - /** Like `transferSol`, but rejects destinations that fail `assertValidTransferSolDestination`. */ - transferSolGuarded: ( - input: TransferSolInput, - config?: TransferSolGuardConfig, - ) => ReturnType & SelfPlanAndSendFunctions; -}; - -export function systemProgram() { - return (client: T) => { - return pipe(client, generatedSystemProgram(), c => - extendClient(c, { - system: { - ...c.system, - instructions: { - ...c.system.instructions, - transferSolGuarded: (input, config) => - addSelfPlanAndSendFunctions( - client, - getTransferSolGuardedInstruction(client.rpc, input, config), - ), - }, - }, - }), - ); - }; -} diff --git a/clients/js/test/transferSolGuarded.test.ts b/clients/js/test/assertValidTransferSolDestination.test.ts similarity index 60% rename from clients/js/test/transferSolGuarded.test.ts rename to clients/js/test/assertValidTransferSolDestination.test.ts index c0cd2300..4ecd23c5 100644 --- a/clients/js/test/transferSolGuarded.test.ts +++ b/clients/js/test/assertValidTransferSolDestination.test.ts @@ -8,12 +8,7 @@ import { } from '@solana/kit'; import { expect, it, vi } from 'vitest'; -import { - assertValidTransferSolDestination, - getTransferSolGuardedInstruction, - getTransferSolInstruction, - SYSTEM_PROGRAM_ADDRESS, -} from '../src'; +import { assertValidTransferSolDestination, SYSTEM_PROGRAM_ADDRESS } from '../src'; import { createTestClient } from './_setup'; const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); @@ -173,112 +168,6 @@ it('accepts an existing off-curve System-Program-owned account unless allowOffCu ).rejects.toMatchObject({ cause: { reason: 'off-curve' } }); }); -it('builds an instruction identical to getTransferSolInstruction for a safe destination', async () => { - // Given an unfunded on-curve destination. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - const destination = (await generateKeyPairSigner()).address; - const input = { source, destination, amount: 1_000_000_000 }; - - // When we build a guarded transfer allowing unfunded recipients. - const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); - - // Then it matches the unguarded instruction. - expect(guarded).toStrictEqual(getTransferSolInstruction(input)); -}); - -it('builds an instruction for a HasAddress destination', async () => { - // Given an unfunded on-curve destination held in an `AddressWrapper`. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - const destination = new AddressWrapper((await generateKeyPairSigner()).address); - const input = { source, destination, amount: 1_000_000_000 }; - - // When we build a guarded transfer allowing unfunded recipients. - const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); - - // Then it matches the unguarded instruction. - expect(guarded).toStrictEqual(getTransferSolInstruction(input)); -}); - -it('refuses to build an instruction for an unsafe destination', async () => { - // Given a destination owned by the Token program. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - const destination = await createForeignOwnedAddress(client); - - // Then building a guarded transfer throws. - await expect(getTransferSolGuardedInstruction(client.rpc, { source, destination, amount: 1n })).rejects.toThrow( - `owned by program ${TOKEN_PROGRAM_ADDRESS}`, - ); -}); - -it('refuses to build an instruction for an unsafe HasAddress destination', async () => { - // Given a Token-program-owned destination held in an `AddressWrapper`. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - const destination = new AddressWrapper(await createForeignOwnedAddress(client)); - - // Then building a guarded transfer throws for the wrapped address. - await expect(getTransferSolGuardedInstruction(client.rpc, { source, destination, amount: 1n })).rejects.toThrow( - `Refusing to transfer SOL to ${destination.address}`, - ); -}); - -it('refuses to build an instruction for an off-curve program-derived address destination', async () => { - // Given an unfunded off-curve destination as a program-derived address tuple. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - const destination = await getProgramDerivedAddress({ - programAddress: SYSTEM_PROGRAM_ADDRESS, - seeds: ['guarded-transfer'], - }); - - // Then building a guarded transfer that disallows off-curve recipients throws for the derived address. - await expect( - getTransferSolGuardedInstruction( - client.rpc, - { source, destination, amount: 1n }, - { allowOffCurve: false, allowUnfundedRecipient: true }, - ), - ).rejects.toThrow(`Refusing to transfer SOL to ${destination[0]}: it is an off-curve`); -}); - -it('transfers SOL to a safe destination via the client plugin method', async () => { - // Given a source account with 3 SOL and an unfunded on-curve destination. - 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)); - - // When the source transfers 1 SOL through the guarded plugin method. - await client.system.instructions - .transferSolGuarded({ source, destination, amount: 1_000_000_000 }, { allowUnfundedRecipient: true }) - .sendTransaction(); - - // Then the source has exactly 2 SOL. - const { value: sourceBalance } = await client.rpc.getBalance(source.address, { commitment: 'confirmed' }).send(); - expect(sourceBalance).toBe(lamports(2_000_000_000n)); - // And the destination has exactly 1 SOL. - 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 () => { - // Given a funded source and a destination owned by the Token program. - const client = await createTestClient(); - const source = await generateKeyPairSigner(); - await client.airdrop(source.address, lamports(1_000_000_000n)); - const destination = await createForeignOwnedAddress(client); - - // Then transferring SOL through the guarded plugin method throws. - await expect( - client.system.instructions.transferSolGuarded({ source, destination, amount: 1n }).sendTransaction(), - ).rejects.toThrow(`owned by program ${TOKEN_PROGRAM_ADDRESS}`); -}); - it('does not forward guard options to getAccountInfo', async () => { // Given a funded destination and an RPC that records getAccountInfo calls. const client = await createTestClient(); From 9bdee15507af3c41fe0904822fb35829c4ca38bb Mon Sep 17 00:00:00 2001 From: amilz <85324096+amilz@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:53:33 +0000 Subject: [PATCH 3/3] refactor(js): address review feedback on assertValidTransferSolDestination - Throw a typed InvalidTransferSolDestinationError instead of Error + cause - Rename TransferSolGuardConfig to AssertValidTransferSolDestinationConfig - Type rpc as Rpc - 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 --- .../src/assertValidTransferSolDestination.ts | 61 ++++++++++++------- .../assertValidTransferSolDestination.test.ts | 37 ++++++++--- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/clients/js/src/assertValidTransferSolDestination.ts b/clients/js/src/assertValidTransferSolDestination.ts index 3ba8f3e3..b92b5189 100644 --- a/clients/js/src/assertValidTransferSolDestination.ts +++ b/clients/js/src/assertValidTransferSolDestination.ts @@ -3,8 +3,13 @@ import { isOffCurveAddress, type Address, type FetchAccountConfig, - type HasAddress, + type GetAccountInfoApi, + type Rpc, } from '@solana/kit'; +import { + getAddressFromResolvedInstructionAccount, + type InstructionAccountInput, +} from '@solana/kit/program-client-core'; import { SYSTEM_PROGRAM_ADDRESS } from './generated'; @@ -12,28 +17,36 @@ import { SYSTEM_PROGRAM_ADDRESS } from './generated'; export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; /** - * The `cause` of the `Error` thrown by {@link assertValidTransferSolDestination}. `owner` is set only + * Thrown by {@link assertValidTransferSolDestination} when a destination is rejected. `owner` is set only * when `reason` is `'unexpected-owner'`. */ -export type InvalidTransferSolDestinationCause = { - destination: Address; - reason: InvalidTransferSolDestinationReason; - owner?: Address; -}; +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 TransferSolGuardConfig = FetchAccountConfig & { +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`; set to - * `false` to reject off-curve destinations until the recipient confirms they control them. + * 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; /** @@ -45,7 +58,7 @@ export type TransferSolGuardConfig = FetchAccountConfig & { /** * Asserts that `destination` can safely receive a SOL transfer by fetching its account. Otherwise throws - * an `Error` whose `cause` is an {@link InvalidTransferSolDestinationCause} with one of these reasons: + * 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. @@ -58,11 +71,11 @@ export type TransferSolGuardConfig = FetchAccountConfig & { * token account, reports `'unexpected-owner'`. */ export async function assertValidTransferSolDestination( - rpc: Parameters[0], - destinationInput: Address | HasAddress, - config?: TransferSolGuardConfig, + rpc: Rpc, + destinationInput: InstructionAccountInput, + config?: AssertValidTransferSolDestinationConfig, ): Promise { - const destination = typeof destinationInput === 'string' ? destinationInput : destinationInput.address; + const destination = getAddressFromResolvedInstructionAccount('destination', destinationInput); const { allowOffCurve = true, allowUnfundedRecipient = false, @@ -73,22 +86,26 @@ export async function assertValidTransferSolDestination( if (account.exists) { if (account.programAddress !== programOwner) { - throw new Error( + throw new InvalidTransferSolDestinationError( `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}).`, - { cause: { destination, reason: 'unexpected-owner', owner: account.programAddress } }, + destination, + 'unexpected-owner', + account.programAddress, ); } } else if (!allowUnfundedRecipient) { - throw new Error( + 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 }.`, - { cause: { destination, reason: 'unfunded-recipient' } }, + destination, + 'unfunded-recipient', ); } if (isOffCurveAddress(destination) && !allowOffCurve) { - throw new Error( - `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address, which no keypair controls. It may be a smart wallet (e.g. Squads or Swig); once the recipient confirms they control it, pass { allowOffCurve: true }.`, - { cause: { destination, reason: 'off-curve' } }, + 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', ); } } diff --git a/clients/js/test/assertValidTransferSolDestination.test.ts b/clients/js/test/assertValidTransferSolDestination.test.ts index 4ecd23c5..97fc17f1 100644 --- a/clients/js/test/assertValidTransferSolDestination.test.ts +++ b/clients/js/test/assertValidTransferSolDestination.test.ts @@ -8,7 +8,7 @@ import { } from '@solana/kit'; import { expect, it, vi } from 'vitest'; -import { assertValidTransferSolDestination, SYSTEM_PROGRAM_ADDRESS } from '../src'; +import { assertValidTransferSolDestination, InvalidTransferSolDestinationError, SYSTEM_PROGRAM_ADDRESS } from '../src'; import { createTestClient } from './_setup'; const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); @@ -56,9 +56,13 @@ it('rejects an existing destination owned by another program', async () => { const destination = await createForeignOwnedAddress(client); // Then it is rejected as owned by an unexpected program. - await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ + 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}`), - cause: { destination, reason: 'unexpected-owner', owner: TOKEN_PROGRAM_ADDRESS }, + destination, + reason: 'unexpected-owner', + owner: TOKEN_PROGRAM_ADDRESS, }); }); @@ -81,7 +85,7 @@ it('rejects a System-Program-owned destination when programOwner expects another // Then it is rejected when the Token program is the expected owner. await expect( assertValidTransferSolDestination(client.rpc, destination, { programOwner: TOKEN_PROGRAM_ADDRESS }), - ).rejects.toMatchObject({ cause: { reason: 'unexpected-owner', owner: SYSTEM_PROGRAM_ADDRESS } }); + ).rejects.toMatchObject({ reason: 'unexpected-owner', owner: SYSTEM_PROGRAM_ADDRESS }); }); it('accepts a HasAddress destination', async () => { @@ -104,10 +108,27 @@ it('rejects a HasAddress destination owned by another program', async () => { // Then it is rejected, reporting the wrapped address. await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ - cause: { destination: destination.address, reason: 'unexpected-owner' }, + 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(); @@ -115,7 +136,7 @@ it('rejects an unfunded recipient by default', async () => { // Then it is rejected as unfunded. await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ - cause: { reason: 'unfunded-recipient' }, + reason: 'unfunded-recipient', }); }); @@ -152,7 +173,7 @@ it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is f allowOffCurve: false, allowUnfundedRecipient: true, }), - ).rejects.toMatchObject({ cause: { reason: 'off-curve' } }); + ).rejects.toMatchObject({ reason: 'off-curve' }); }); it('accepts an existing off-curve System-Program-owned account unless allowOffCurve is false', async () => { @@ -165,7 +186,7 @@ it('accepts an existing off-curve System-Program-owned account unless allowOffCu await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); await expect( assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: false }), - ).rejects.toMatchObject({ cause: { reason: 'off-curve' } }); + ).rejects.toMatchObject({ reason: 'off-curve' }); }); it('does not forward guard options to getAccountInfo', async () => {