diff --git a/clients/js/README.md b/clients/js/README.md index ada9cfe..e0a5513 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 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: + +```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); +``` + +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 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..6473913 --- /dev/null +++ b/clients/js/src/transferSolGuarded.ts @@ -0,0 +1,109 @@ +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 = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; + +/** Options for validating a SOL-transfer destination. Extends {@link FetchAccountConfig}. */ +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, whether or not it already exists. + * 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 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 `'unexpected-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. + * + * 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 — + * (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. + */ +export async function assertValidTransferSolDestination( + rpc: Parameters[0], + 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 !== programOwner) { + throw new InvalidTransferSolDestinationError( + `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' }, + ); + } + } 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' }, + ); + } + + if (isOffCurveAddress(destination) && !config?.allowOffCurve) { + throw new InvalidTransferSolDestinationError( + `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' }, + ); + } +} + +/** + * 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..bdb5b4e --- /dev/null +++ b/clients/js/test/transferSolGuarded.test.ts @@ -0,0 +1,183 @@ +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 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)); + 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 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('unexpected-owner'); + expect(error.owner).toBe(TOKEN_PROGRAM_ADDRESS); + 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('unexpected-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; + + 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'); +}); + +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, + allowUnfundedRecipient: true, + }), + ).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(); + const destination = (await generateKeyPairSigner()).address; + const input = { source, destination, amount: 1_000_000_000 }; + + const guarded = await getTransferSolGuardedInstruction(client.rpc, input, { allowUnfundedRecipient: true }); + + 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 }, { allowUnfundedRecipient: true }) + .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); +});