Skip to content
Merged
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
111 changes: 111 additions & 0 deletions clients/js/src/assertValidTransferSolDestination.ts
Original file line number Diff line number Diff line change
@@ -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<GetAccountInfoApi>,
destinationInput: InstructionAccountInput,
config?: AssertValidTransferSolDestinationConfig,
): Promise<void> {
const destination = getAddressFromResolvedInstructionAccount('destination', destinationInput);
const {
allowOffCurve = true,
allowUnfundedRecipient = false,
Comment thread
amilz marked this conversation as resolved.
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',
);
}
}
1 change: 1 addition & 0 deletions clients/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './generated';
export * from './assertValidTransferSolDestination';
209 changes: 209 additions & 0 deletions clients/js/test/assertValidTransferSolDestination.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestClient>>;

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<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 createFundedAddress = async (client: TestClient): Promise<Address> => {
const destination = (await generateKeyPairSigner()).address;
await client.airdrop(destination, lamports(1_000_000_000n));
return destination;
};

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 () => {
// 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' });
});
Loading