diff --git a/clients/js/src/assertValidTransferSolDestination.ts b/clients/js/src/assertValidTransferSolDestination.ts new file mode 100644 index 0000000..b92b518 --- /dev/null +++ b/clients/js/src/assertValidTransferSolDestination.ts @@ -0,0 +1,111 @@ +import { + fetchEncodedAccount, + isOffCurveAddress, + type Address, + type FetchAccountConfig, + type GetAccountInfoApi, + type Rpc, +} from '@solana/kit'; +import { + getAddressFromResolvedInstructionAccount, + type InstructionAccountInput, +} from '@solana/kit/program-client-core'; + +import { SYSTEM_PROGRAM_ADDRESS } from './generated'; + +/** Distinguishes why a destination was rejected by {@link assertValidTransferSolDestination}. */ +export type InvalidTransferSolDestinationReason = 'unexpected-owner' | 'unfunded-recipient' | 'off-curve'; + +/** + * Thrown by {@link assertValidTransferSolDestination} when a destination is rejected. `owner` is set only + * when `reason` is `'unexpected-owner'`. + */ +export class InvalidTransferSolDestinationError extends Error { + override readonly name = 'InvalidTransferSolDestinationError'; + + constructor( + message: string, + readonly destination: Address, + readonly reason: InvalidTransferSolDestinationReason, + readonly owner?: Address, + ) { + super(message); + } +} + +/** + * Options for {@link assertValidTransferSolDestination}. Any {@link FetchAccountConfig} fields are passed + * through to the account fetch. + */ +export type AssertValidTransferSolDestinationConfig = FetchAccountConfig & { + /** + * Allow a destination that has no account on-chain yet, funding it as part of the transfer. + * Defaults to `false`, since an unfunded recipient is often a mistyped address. + */ + allowUnfundedRecipient?: boolean; + /** + * Allow an off-curve (program-derived) destination, existing or not. Defaults to `true`, since + * program-controlled wallets such as multisigs are common, legitimate recipients; set to `false` to + * reject off-curve destinations until the recipient confirms they control them. + */ + allowOffCurve?: boolean; + /** + * The program an existing destination account must be owned by. Defaults to the System Program + * ({@link SYSTEM_PROGRAM_ADDRESS}). + */ + programOwner?: Address; +}; + +/** + * Asserts that `destination` can safely receive a SOL transfer by fetching its account. Otherwise throws + * an {@link InvalidTransferSolDestinationError} with one of these reasons: + * + * - `'unexpected-owner'`: the account exists but is not owned by `programOwner` (the System Program by + * default), e.g. an SPL token mint. SOL sent to such an account is typically unrecoverable. + * - `'unfunded-recipient'`: no account exists, which often means a mistyped address. Skipped when + * `allowUnfundedRecipient` is set. + * - `'off-curve'`: the address is off-curve, existing or not, and may be a smart wallet the recipient + * must confirm they control. Only checked when `allowOffCurve` is `false`. + * + * The owner is checked first, so an off-curve account owned by another program, such as an associated + * token account, reports `'unexpected-owner'`. + */ +export async function assertValidTransferSolDestination( + rpc: Rpc, + destinationInput: InstructionAccountInput, + config?: AssertValidTransferSolDestinationConfig, +): Promise { + const destination = getAddressFromResolvedInstructionAccount('destination', destinationInput); + const { + allowOffCurve = true, + allowUnfundedRecipient = false, + programOwner = SYSTEM_PROGRAM_ADDRESS, + ...fetchConfig + } = config ?? {}; + const account = await fetchEncodedAccount(rpc, destination, fetchConfig); + + if (account.exists) { + if (account.programAddress !== programOwner) { + throw new InvalidTransferSolDestinationError( + `Refusing to transfer SOL to ${destination}: it is owned by program ${account.programAddress}, not the expected program (${programOwner}).`, + destination, + 'unexpected-owner', + account.programAddress, + ); + } + } else if (!allowUnfundedRecipient) { + throw new InvalidTransferSolDestinationError( + `Refusing to transfer SOL to ${destination}: it has no account on-chain yet, which often means the address is mistyped. If you intend to fund a new account, pass { allowUnfundedRecipient: true }.`, + destination, + 'unfunded-recipient', + ); + } + + if (isOffCurveAddress(destination) && !allowOffCurve) { + throw new InvalidTransferSolDestinationError( + `Refusing to transfer SOL to ${destination}: it is an off-curve (program-derived) address, which no keypair controls. It may be a program-controlled account such as a multisig or smart wallet; once the recipient confirms they control it, pass { allowOffCurve: true }.`, + destination, + 'off-curve', + ); + } +} diff --git a/clients/js/src/index.ts b/clients/js/src/index.ts index 69e4e4e..ecfe08e 100644 --- a/clients/js/src/index.ts +++ b/clients/js/src/index.ts @@ -1 +1,2 @@ export * from './generated'; +export * from './assertValidTransferSolDestination'; diff --git a/clients/js/test/assertValidTransferSolDestination.test.ts b/clients/js/test/assertValidTransferSolDestination.test.ts new file mode 100644 index 0000000..97fc17f --- /dev/null +++ b/clients/js/test/assertValidTransferSolDestination.test.ts @@ -0,0 +1,209 @@ +import { + address, + generateKeyPairSigner, + getProgramDerivedAddress, + lamports, + type Address, + type HasAddress, +} from '@solana/kit'; +import { expect, it, vi } from 'vitest'; + +import { assertValidTransferSolDestination, InvalidTransferSolDestinationError, SYSTEM_PROGRAM_ADDRESS } from '../src'; +import { createTestClient } from './_setup'; + +const TOKEN_PROGRAM_ADDRESS = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'); + +type TestClient = Awaited>; + +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. + const promise = assertValidTransferSolDestination(client.rpc, destination); + await expect(promise).rejects.toBeInstanceOf(InvalidTransferSolDestinationError); + await expect(promise).rejects.toMatchObject({ + message: expect.stringContaining(`owned by program ${TOKEN_PROGRAM_ADDRESS}`), + destination, + reason: 'unexpected-owner', + owner: TOKEN_PROGRAM_ADDRESS, + }); +}); + +it('accepts an existing destination owned by the program given in programOwner', async () => { + // Given a destination owned by the Token program. + const client = await createTestClient(); + const destination = await createForeignOwnedAddress(client); + + // Then it is valid when the Token program is the expected owner. + await expect( + assertValidTransferSolDestination(client.rpc, destination, { programOwner: TOKEN_PROGRAM_ADDRESS }), + ).resolves.toBeUndefined(); +}); + +it('rejects a System-Program-owned destination when programOwner expects another program', async () => { + // Given a funded System-Program-owned destination. + const client = await createTestClient(); + const destination = await createFundedAddress(client); + + // Then it is rejected when the Token program is the expected owner. + await expect( + assertValidTransferSolDestination(client.rpc, destination, { programOwner: TOKEN_PROGRAM_ADDRESS }), + ).rejects.toMatchObject({ reason: 'unexpected-owner', owner: SYSTEM_PROGRAM_ADDRESS }); +}); + +it('accepts a HasAddress destination', async () => { + // Given a funded destination held as a signer. + const client = await createTestClient(); + const destination = await generateKeyPairSigner(); + await client.airdrop(destination.address, lamports(1_000_000_000n)); + + // Then it is valid as a signer and as an `AddressWrapper`. + await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); + await expect( + assertValidTransferSolDestination(client.rpc, new AddressWrapper(destination.address)), + ).resolves.toBeUndefined(); +}); + +it('rejects a HasAddress destination owned by another program', async () => { + // Given a Token-program-owned destination held in an `AddressWrapper`. + const client = await createTestClient(); + const destination = new AddressWrapper(await createForeignOwnedAddress(client)); + + // Then it is rejected, reporting the wrapped address. + await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ + destination: destination.address, + reason: 'unexpected-owner', + }); +}); + +it('accepts a ProgramDerivedAddress destination', async () => { + // Given a funded off-curve System-Program-owned account held as a `[address, bump]` tuple. + const client = await createTestClient(); + const destination = await getProgramDerivedAddress({ + programAddress: SYSTEM_PROGRAM_ADDRESS, + seeds: ['guarded-transfer'], + }); + await client.airdrop(destination[0], lamports(1_000_000_000n)); + + // Then it is valid by default and rejected, reporting the PDA's address, when off-curve is disallowed. + await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: false }), + ).rejects.toMatchObject({ destination: destination[0], reason: 'off-curve' }); +}); + +it('rejects an unfunded recipient by default', async () => { + // Given an on-curve address with no account on-chain. + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + + // Then it is rejected as unfunded. + await expect(assertValidTransferSolDestination(client.rpc, destination)).rejects.toMatchObject({ + reason: 'unfunded-recipient', + }); +}); + +it('accepts an unfunded on-curve recipient when allowUnfundedRecipient is set', async () => { + // Given an on-curve address with no account on-chain. + const client = await createTestClient(); + const destination = (await generateKeyPairSigner()).address; + + // Then it is valid when unfunded recipients are allowed. + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), + ).resolves.toBeUndefined(); +}); + +it('accepts an unfunded off-curve recipient when allowUnfundedRecipient is set', async () => { + // Given an off-curve address with no account on-chain. + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + + // Then it is valid when unfunded recipients are allowed. + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowUnfundedRecipient: true }), + ).resolves.toBeUndefined(); +}); + +it('rejects an unfunded off-curve recipient as off-curve when allowOffCurve is false', async () => { + // Given an off-curve address with no account on-chain. + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + + // Then it is rejected as off-curve even when unfunded recipients are allowed. + await expect( + assertValidTransferSolDestination(client.rpc, destination, { + allowOffCurve: false, + allowUnfundedRecipient: true, + }), + ).rejects.toMatchObject({ reason: 'off-curve' }); +}); + +it('accepts an existing off-curve System-Program-owned account unless allowOffCurve is false', async () => { + // Given a funded off-curve System-Program-owned account. + const client = await createTestClient(); + const destination = await deriveOffCurveAddress(); + await client.airdrop(destination, lamports(1_000_000_000n)); + + // Then it is valid by default and rejected when off-curve recipients are disallowed. + await expect(assertValidTransferSolDestination(client.rpc, destination)).resolves.toBeUndefined(); + await expect( + assertValidTransferSolDestination(client.rpc, destination, { allowOffCurve: false }), + ).rejects.toMatchObject({ reason: 'off-curve' }); +}); + +it('does not forward guard options to getAccountInfo', async () => { + // Given a funded destination and an RPC that records getAccountInfo calls. + const client = await createTestClient(); + const destination = await createFundedAddress(client); + const getAccountInfo = vi.fn(client.rpc.getAccountInfo); + const rpc = { getAccountInfo } as unknown as typeof client.rpc; + + // When we validate it with guard options and a commitment. + await assertValidTransferSolDestination(rpc, destination, { + allowOffCurve: true, + allowUnfundedRecipient: true, + commitment: 'confirmed', + programOwner: SYSTEM_PROGRAM_ADDRESS, + }); + + // Then only the fetch options reach the RPC. + expect(getAccountInfo).toHaveBeenCalledWith(destination, { commitment: 'confirmed', encoding: 'base64' }); +});