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
7 changes: 7 additions & 0 deletions .changeset/fruity-bugs-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@solana/kit': minor
---

Add helpers to create client interfaces from a raw `Rpc`

Add `createClientWithGetMinimumBalanceFromRpc`, `createClientWithFetchAccountsFromRpc` and `createClientWithInterfacesFromRpc` to `@solana/kit`. These convenience helpers let consumers that only have a raw `Rpc` object construct the corresponding client interfaces (`ClientWithGetMinimumBalance` and `ClientWithFetchAccounts`) without assembling a full Kit client. `createClientWithInterfacesFromRpc` fills in whichever interfaces the RPC supports and narrows its return type accordingly.
12 changes: 12 additions & 0 deletions packages/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ Returns a `TransactionMessage` from a `CompiledTransactionMessage`. If any of th

Given a list of addresses belonging to address lookup tables, returns a map of lookup table addresses to an ordered array of the addresses they contain.

### `createClientWithGetMinimumBalanceFromRpc(rpc)`

Creates a `ClientWithGetMinimumBalance` from a raw `Rpc` object. The returned client computes the minimum balance for rent exemption using the `getMinimumBalanceForRentExemption` RPC method, adding the 128-byte account header by default (pass `{ withoutHeader: true }` to compute the data-only amount). This is a convenience helper for consumers that only have a raw `Rpc` object rather than a Kit client.

### `createClientWithFetchAccountsFromRpc(rpc)`

Creates a `ClientWithFetchAccounts` from a raw `Rpc` object. The returned client fetches the encoded content of accounts from their addresses, dispatching on the number of requested addresses: a single account uses `getAccountInfo`, multiple accounts use `getMultipleAccounts` in a single round-trip, and an empty list short-circuits to an empty array without any RPC call. Because a raw `Rpc` object's capabilities cannot be detected at runtime, the `Rpc` is required to support both methods. This is a convenience helper for consumers that only have a raw `Rpc` object rather than a Kit client.

### `createClientWithInterfacesFromRpc(rpc)`

Creates a client from a raw `Rpc` object, filling in whichever client interfaces the RPC supports — a `ClientWithGetMinimumBalance` when it supports `getMinimumBalanceForRentExemption`, and a `ClientWithFetchAccounts` when it supports both `getAccountInfo` and `getMultipleAccounts`. The return type narrows accordingly. Since a raw `Rpc` object's capabilities cannot be detected at runtime, the returned object always carries both methods at runtime; the return type is what restricts them to the interfaces your RPC declares. Note that this does not create a fully-fledged Kit client; use `createClient().use(solanaRpc(...))` for that.

### Compute Unit Limit Estimation

Correctly budgeting a compute unit limit for your transaction message can increase the probability that your transaction will be accepted for processing. If you don't declare a compute unit limit on your transaction, validators will assume an upper limit of 200K compute units (CU) per instruction.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { BASE_ACCOUNT_SIZE } from '@solana/accounts';
import type { Address } from '@solana/addresses';
import {
createJsonRpcApi,
createRpc,
type GetAccountInfoApi,
type GetMinimumBalanceForRentExemptionApi,
type GetMultipleAccountsApi,
type Rpc,
type RpcTransport,
} from '@solana/rpc';
import { lamports } from '@solana/rpc-types';

import {
createClientWithFetchAccountsFromRpc,
createClientWithGetMinimumBalanceFromRpc,
createClientWithInterfacesFromRpc,
} from '../create-client-with-interfaces-from-rpc';

const addressA = '1111' as Address<'1111'>;
const addressB = '2222' as Address<'2222'>;

describe('createClientWithGetMinimumBalanceFromRpc', () => {
function getMockRpc(responseBySize: Record<string, bigint>): Rpc<GetMinimumBalanceForRentExemptionApi> {
return {
getMinimumBalanceForRentExemption: jest.fn((size: bigint) => ({
send: jest.fn().mockResolvedValue(lamports(responseBySize[size.toString()])),
})),
} as unknown as Rpc<GetMinimumBalanceForRentExemptionApi>;
}

it('computes the minimum balance for the provided space including the account header by default', async () => {
expect.assertions(2);
const rpc = getMockRpc({ '100': 1_000_000n });

const client = createClientWithGetMinimumBalanceFromRpc(rpc);
const result = await client.getMinimumBalance(100);

expect(result).toBe(1_000_000n);
// The space is passed through unchanged; the runtime adds the 128-byte header.
expect(rpc.getMinimumBalanceForRentExemption).toHaveBeenCalledWith(100n);
});

it('computes the header-less minimum balance when withoutHeader is set', async () => {
expect.assertions(2);
// Rent for a 128-byte (header-only) account, used to derive the per-byte rate.
const headerBalance = 1_280_000n;
const rpc = getMockRpc({ '0': headerBalance });

const client = createClientWithGetMinimumBalanceFromRpc(rpc);
const result = await client.getMinimumBalance(100, { withoutHeader: true });

// (headerBalance / 128) * 100
const lamportsPerByte = headerBalance / BigInt(BASE_ACCOUNT_SIZE);
expect(result).toBe(lamportsPerByte * 100n);
// It queries the header-only balance (size 0) to derive the per-byte rate.
expect(rpc.getMinimumBalanceForRentExemption).toHaveBeenCalledWith(0n);
});
});

describe('createClientWithFetchAccountsFromRpc', () => {
function getMockGetAccountInfo() {
return jest.fn().mockReturnValue({
send: jest.fn().mockResolvedValue({ value: null }),
});
}

function getMockGetMultipleAccounts() {
return jest.fn().mockReturnValue({
send: jest.fn().mockResolvedValue({ value: [null, null] }),
});
}

it('returns an empty array without issuing any RPC call for an empty address list', async () => {
expect.assertions(3);
const getAccountInfo = getMockGetAccountInfo();
const getMultipleAccounts = getMockGetMultipleAccounts();
const rpc = { getAccountInfo, getMultipleAccounts } as unknown as Rpc<
GetAccountInfoApi & GetMultipleAccountsApi
>;

const client = createClientWithFetchAccountsFromRpc(rpc);
const accounts = await client.fetchAccounts([]);

expect(accounts).toStrictEqual([]);
expect(getAccountInfo).not.toHaveBeenCalled();
expect(getMultipleAccounts).not.toHaveBeenCalled();
});

it('uses getAccountInfo for a single account', async () => {
expect.assertions(3);
const getAccountInfo = getMockGetAccountInfo();
const getMultipleAccounts = getMockGetMultipleAccounts();
const rpc = { getAccountInfo, getMultipleAccounts } as unknown as Rpc<
GetAccountInfoApi & GetMultipleAccountsApi
>;

const client = createClientWithFetchAccountsFromRpc(rpc);
const accounts = await client.fetchAccounts([addressA]);

expect(getAccountInfo).toHaveBeenCalledWith(addressA, { encoding: 'base64' });
expect(getMultipleAccounts).not.toHaveBeenCalled();
expect(accounts).toStrictEqual([{ address: addressA, exists: false }]);
});

it('uses getMultipleAccounts for multiple accounts', async () => {
expect.assertions(3);
const getAccountInfo = getMockGetAccountInfo();
const getMultipleAccounts = getMockGetMultipleAccounts();
const rpc = { getAccountInfo, getMultipleAccounts } as unknown as Rpc<
GetAccountInfoApi & GetMultipleAccountsApi
>;

const client = createClientWithFetchAccountsFromRpc(rpc);
const accounts = await client.fetchAccounts([addressA, addressB]);

expect(getMultipleAccounts).toHaveBeenCalledWith([addressA, addressB], { encoding: 'base64' });
expect(getAccountInfo).not.toHaveBeenCalled();
expect(accounts).toStrictEqual([
{ address: addressA, exists: false },
{ address: addressB, exists: false },
]);
});
});

describe('createClientWithInterfacesFromRpc', () => {
it('exposes both interfaces at runtime regardless of the RPC type', () => {
const rpc = {
getMinimumBalanceForRentExemption: jest.fn(),
} as unknown as Rpc<GetMinimumBalanceForRentExemptionApi>;

const client = createClientWithInterfacesFromRpc(rpc);

// Both methods are always present at runtime; the return type is what narrows them.
expect(client).toHaveProperty('getMinimumBalance');
expect(client).toHaveProperty('fetchAccounts');
});

describe('with a proxy-backed RPC', () => {
// A real Kit `Rpc` is a Proxy with no `has` trap, so `'method' in rpc` is always false. This
// guards against regressing to runtime capability detection, which would silently return an
// empty client for such RPCs.
function getProxyBackedRpc(transport: RpcTransport) {
return createRpc({
api: createJsonRpcApi<
GetAccountInfoApi & GetMinimumBalanceForRentExemptionApi & GetMultipleAccountsApi
>(),
transport,
});
}

it('exposes a working getMinimumBalance and fetchAccounts', async () => {
expect.assertions(3);
const responseByMethod: Record<string, unknown> = {
getAccountInfo: { value: null },
getMinimumBalanceForRentExemption: 1_000_000n,
getMultipleAccounts: { value: [null, null] },
};
const transport = jest.fn(({ payload }: { payload: { method: string } }) =>
Promise.resolve(responseByMethod[payload.method]),
) as unknown as RpcTransport;
const rpc = getProxyBackedRpc(transport);

const client = createClientWithInterfacesFromRpc(rpc);

await expect(client.getMinimumBalance(100)).resolves.toBe(1_000_000n);
await expect(client.fetchAccounts([addressA])).resolves.toStrictEqual([
{ address: addressA, exists: false },
]);
await expect(client.fetchAccounts([addressA, addressB])).resolves.toStrictEqual([
{ address: addressA, exists: false },
{ address: addressB, exists: false },
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { ClientWithFetchAccounts, ClientWithGetMinimumBalance } from '@solana/plugin-interfaces';
import type {
GetAccountInfoApi,
GetMinimumBalanceForRentExemptionApi,
GetMultipleAccountsApi,
Rpc,
SolanaRpcApi,
} from '@solana/rpc';

import {
createClientWithFetchAccountsFromRpc,
createClientWithGetMinimumBalanceFromRpc,
createClientWithInterfacesFromRpc,
} from '../create-client-with-interfaces-from-rpc';

// [DESCRIBE] createClientWithGetMinimumBalanceFromRpc
{
// It returns a ClientWithGetMinimumBalance.
{
createClientWithGetMinimumBalanceFromRpc(
null as unknown as Rpc<GetMinimumBalanceForRentExemptionApi>,
) satisfies ClientWithGetMinimumBalance;
}

// It fails to typecheck when the RPC lacks the getMinimumBalanceForRentExemption method.
{
// @ts-expect-error The RPC does not support GetMinimumBalanceForRentExemptionApi.
createClientWithGetMinimumBalanceFromRpc(null as unknown as Rpc<GetAccountInfoApi>);
}
}

// [DESCRIBE] createClientWithFetchAccountsFromRpc
{
// It returns a ClientWithFetchAccounts from an RPC supporting both methods.
{
createClientWithFetchAccountsFromRpc(
null as unknown as Rpc<GetAccountInfoApi & GetMultipleAccountsApi>,
) satisfies ClientWithFetchAccounts;
}

// It fails to typecheck when the RPC supports getAccountInfo but not getMultipleAccounts.
{
// @ts-expect-error The RPC does not support GetMultipleAccountsApi.
createClientWithFetchAccountsFromRpc(null as unknown as Rpc<GetAccountInfoApi>);
}

// It fails to typecheck when the RPC supports getMultipleAccounts but not getAccountInfo.
{
// @ts-expect-error The RPC does not support GetAccountInfoApi.
createClientWithFetchAccountsFromRpc(null as unknown as Rpc<GetMultipleAccountsApi>);
}

// It fails to typecheck when the RPC supports neither account-fetching method.
{
// @ts-expect-error The RPC does not support GetAccountInfoApi nor GetMultipleAccountsApi.
createClientWithFetchAccountsFromRpc(null as unknown as Rpc<GetMinimumBalanceForRentExemptionApi>);
}
}

// [DESCRIBE] createClientWithInterfacesFromRpc
{
// A minimum-balance-only RPC yields a ClientWithGetMinimumBalance.
{
const client = createClientWithInterfacesFromRpc(null as unknown as Rpc<GetMinimumBalanceForRentExemptionApi>);
client satisfies ClientWithGetMinimumBalance;
// @ts-expect-error It does not implement ClientWithFetchAccounts.
client satisfies ClientWithFetchAccounts;
}

// A getAccountInfo-only RPC produces no interfaces and is rejected (fetchAccounts requires both
// account methods, and there is no getMinimumBalanceForRentExemption).
{
// @ts-expect-error An RPC with only getAccountInfo cannot produce any interface.
createClientWithInterfacesFromRpc(null as unknown as Rpc<GetAccountInfoApi>);
}

// A getMultipleAccounts-only RPC produces no interfaces and is rejected (fetchAccounts requires
// both account methods, and there is no getMinimumBalanceForRentExemption).
{
// @ts-expect-error An RPC with only getMultipleAccounts cannot produce any interface.
createClientWithInterfacesFromRpc(null as unknown as Rpc<GetMultipleAccountsApi>);
}

// An RPC supporting both account-fetching methods yields a ClientWithFetchAccounts.
{
const client = createClientWithInterfacesFromRpc(
null as unknown as Rpc<GetAccountInfoApi & GetMultipleAccountsApi>,
);
client satisfies ClientWithFetchAccounts;
// @ts-expect-error It does not implement ClientWithGetMinimumBalance.
client satisfies ClientWithGetMinimumBalance;
}

// An RPC supporting getMinimumBalanceForRentExemption but only one account method yields only
// ClientWithGetMinimumBalance.
{
const client = createClientWithInterfacesFromRpc(
null as unknown as Rpc<GetAccountInfoApi & GetMinimumBalanceForRentExemptionApi>,
);
client satisfies ClientWithGetMinimumBalance;
// @ts-expect-error It does not implement ClientWithFetchAccounts.
client satisfies ClientWithFetchAccounts;
}

// An RPC supporting getMinimumBalanceForRentExemption and both account methods yields both
// interfaces.
{
const client = createClientWithInterfacesFromRpc(
null as unknown as Rpc<GetAccountInfoApi & GetMinimumBalanceForRentExemptionApi & GetMultipleAccountsApi>,
);
client satisfies ClientWithFetchAccounts & ClientWithGetMinimumBalance;
}

// A full Solana RPC (a superset of all three methods) also yields both interfaces.
{
const client = createClientWithInterfacesFromRpc(null as unknown as Rpc<SolanaRpcApi>);
client satisfies ClientWithFetchAccounts & ClientWithGetMinimumBalance;
}
}
Loading
Loading