Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions clients/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions clients/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './generated';
export * from './transferSolGuarded';
export { systemProgram, type SystemPluginInstructionsWithGuard, type SystemPluginWithGuard } from './plugin';
42 changes: 42 additions & 0 deletions clients/js/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getTransferSolInstruction>>;
};

export type SystemPluginWithGuard = Omit<SystemPlugin, 'instructions'> & {
instructions: SystemPluginInstructionsWithGuard;
};

export function systemProgram() {
return <T extends SystemPluginRequirements>(client: T): Omit<T, 'system'> & { 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,
});
};
}
109 changes: 109 additions & 0 deletions clients/js/src/transferSolGuarded.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetchEncodedAccount>[0],
destination: Address,
config?: TransferSolGuardConfig,
): Promise<void> {
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<typeof fetchEncodedAccount>[0],
input: TransferSolInput<TAccountSource, TAccountDestination>,
config?: TransferSolGuardConfig,
): Promise<TransferSolInstruction<typeof SYSTEM_PROGRAM_ADDRESS, TAccountSource, TAccountDestination>> {
await assertValidTransferSolDestination(rpc, input.destination, config);
return getTransferSolInstruction<TAccountSource, TAccountDestination, typeof SYSTEM_PROGRAM_ADDRESS>(input);
}
183 changes: 183 additions & 0 deletions clients/js/test/transferSolGuarded.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestClient>>;

// 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<Address> => {
const account = await generateKeyPairSigner();
await client.airdrop(account.address, lamports(1_000_000_000n));
await client.system.instructions.assign({ account, programAddress: TOKEN_PROGRAM_ADDRESS }).sendTransaction();
return account.address;
};

const deriveOffCurveAddress = async (): Promise<Address> => {
const [pda] = await getProgramDerivedAddress({
programAddress: SYSTEM_PROGRAM_ADDRESS,
seeds: ['guarded-transfer'],
});
return pda;
};

it('accepts an existing System-Program-owned destination', async () => {
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);
});