diff --git a/.changeset/fruity-bugs-guess.md b/.changeset/fruity-bugs-guess.md new file mode 100644 index 000000000..2de6a7ff0 --- /dev/null +++ b/.changeset/fruity-bugs-guess.md @@ -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. diff --git a/packages/kit/README.md b/packages/kit/README.md index 2ed62774a..192af30fc 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -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. diff --git a/packages/kit/src/__tests__/create-client-with-interfaces-from-rpc-test.ts b/packages/kit/src/__tests__/create-client-with-interfaces-from-rpc-test.ts new file mode 100644 index 000000000..c98cc94b6 --- /dev/null +++ b/packages/kit/src/__tests__/create-client-with-interfaces-from-rpc-test.ts @@ -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): Rpc { + return { + getMinimumBalanceForRentExemption: jest.fn((size: bigint) => ({ + send: jest.fn().mockResolvedValue(lamports(responseBySize[size.toString()])), + })), + } as unknown as Rpc; + } + + 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; + + 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 = { + 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 }, + ]); + }); + }); +}); diff --git a/packages/kit/src/__typetests__/create-client-with-interfaces-from-rpc-typetest.ts b/packages/kit/src/__typetests__/create-client-with-interfaces-from-rpc-typetest.ts new file mode 100644 index 000000000..4043894cd --- /dev/null +++ b/packages/kit/src/__typetests__/create-client-with-interfaces-from-rpc-typetest.ts @@ -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, + ) 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); + } +} + +// [DESCRIBE] createClientWithFetchAccountsFromRpc +{ + // It returns a ClientWithFetchAccounts from an RPC supporting both methods. + { + createClientWithFetchAccountsFromRpc( + null as unknown as Rpc, + ) 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); + } + + // 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); + } + + // 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); + } +} + +// [DESCRIBE] createClientWithInterfacesFromRpc +{ + // A minimum-balance-only RPC yields a ClientWithGetMinimumBalance. + { + const client = createClientWithInterfacesFromRpc(null as unknown as Rpc); + 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); + } + + // 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); + } + + // An RPC supporting both account-fetching methods yields a ClientWithFetchAccounts. + { + const client = createClientWithInterfacesFromRpc( + null as unknown as Rpc, + ); + 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, + ); + 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, + ); + 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); + client satisfies ClientWithFetchAccounts & ClientWithGetMinimumBalance; + } +} diff --git a/packages/kit/src/create-client-with-interfaces-from-rpc.ts b/packages/kit/src/create-client-with-interfaces-from-rpc.ts new file mode 100644 index 000000000..e03380729 --- /dev/null +++ b/packages/kit/src/create-client-with-interfaces-from-rpc.ts @@ -0,0 +1,137 @@ +import { BASE_ACCOUNT_SIZE, fetchEncodedAccount, fetchEncodedAccounts } from '@solana/accounts'; +import type { ClientWithFetchAccounts, ClientWithGetMinimumBalance } from '@solana/plugin-interfaces'; +import type { GetAccountInfoApi, GetMinimumBalanceForRentExemptionApi, GetMultipleAccountsApi, Rpc } from '@solana/rpc'; +import { lamports } from '@solana/rpc-types'; + +/** + * Creates a {@link ClientWithGetMinimumBalance} from a raw `Rpc` object. + * + * The returned client computes the minimum balance for rent exemption using the + * {@link GetMinimumBalanceForRentExemptionApi.getMinimumBalanceForRentExemption | getMinimumBalanceForRentExemption} + * RPC method. By default, the 128-byte account header is included on top of the provided `space`; + * pass `{ withoutHeader: true }` to compute the minimum balance for the data portion only. + * + * This is a convenience helper for consumers that only have a raw `Rpc` object rather than a Kit + * client. If you are building a full client, prefer composing it with a plugin such as `solanaRpc` + * (i.e. `createClient().use(solanaRpc(...))`), which provides `getMinimumBalance` amongst other + * capabilities. + * + * @param rpc - An object that supports the {@link GetMinimumBalanceForRentExemptionApi} of the + * Solana RPC API. + * + * @example + * ```ts + * const client = createClientWithGetMinimumBalanceFromRpc(rpc); + * const rentExemptBalance = await client.getMinimumBalance(100); + * ``` + */ +export function createClientWithGetMinimumBalanceFromRpc( + rpc: Rpc, +): ClientWithGetMinimumBalance { + return { + async getMinimumBalance(space, config) { + if (config?.withoutHeader) { + // The runtime computes rent as `rate * (BASE_ACCOUNT_SIZE + space)`, where `rate` + // folds in the per-byte cost and the exemption threshold (see Agave's + // `Rent::minimum_balance`). Querying `space = 0` therefore returns `rate * 128`, + // which divides evenly by `BASE_ACCOUNT_SIZE` to recover `rate` exactly. There is + // no truncation here: `rate * 128 / 128 === rate`. + const headerBalance = await rpc.getMinimumBalanceForRentExemption(0n).send(); + const lamportsPerByte = headerBalance / BigInt(BASE_ACCOUNT_SIZE); + return lamports(lamportsPerByte * BigInt(space)); + } + return await rpc.getMinimumBalanceForRentExemption(BigInt(space)).send(); + }, + }; +} + +/** + * Creates a {@link 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 is fetched via the + * {@link GetAccountInfoApi.getAccountInfo | getAccountInfo} RPC method, whilst multiple accounts are + * fetched in a single round-trip via the + * {@link GetMultipleAccountsApi.getMultipleAccounts | getMultipleAccounts} RPC method. Fetching an + * empty list short-circuits to an empty array without issuing any RPC call. + * + * The dispatch is based purely on the number of addresses because a raw `Rpc` object's capabilities + * cannot be detected at runtime. For this reason, 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. If you are building a full client, prefer composing it with a plugin such as `solanaRpc` + * (i.e. `createClient().use(solanaRpc(...))`), which provides account fetching amongst other + * capabilities. + * + * @param rpc - An object that supports both the {@link GetAccountInfoApi} and the + * {@link GetMultipleAccountsApi} of the Solana RPC API. + * + * @example + * ```ts + * const client = createClientWithFetchAccountsFromRpc(rpc); + * const accounts = await client.fetchAccounts([addressA, addressB]); + * ``` + */ +export function createClientWithFetchAccountsFromRpc( + rpc: Rpc, +): ClientWithFetchAccounts { + return { + async fetchAccounts(addresses, config) { + if (addresses.length === 0) { + return []; + } + if (addresses.length === 1) { + return [await fetchEncodedAccount(rpc, addresses[0], config)]; + } + return await fetchEncodedAccounts(rpc, addresses, config); + }, + }; +} + +type ClientInterfacesFromRpc = (TRpc extends Rpc + ? ClientWithFetchAccounts + : object) & + (TRpc extends Rpc ? ClientWithGetMinimumBalance : object); + +/** + * Creates a client from a raw `Rpc` object, filling in whichever client interfaces the RPC supports. + * + * The returned object's type implements a {@link ClientWithGetMinimumBalance} when the RPC supports + * the {@link GetMinimumBalanceForRentExemptionApi}, and a {@link ClientWithFetchAccounts} when it + * supports both the {@link GetAccountInfoApi} and the {@link GetMultipleAccountsApi}. The return + * type reflects the interfaces available on the provided RPC, so you only get the interfaces your + * RPC can actually back. + * + * Because a raw `Rpc` object's capabilities cannot be detected at runtime, the returned object + * always carries both `getMinimumBalance` and `fetchAccounts` at runtime; the return type is what + * narrows them to the interfaces your RPC declares. Invoking a method that your `Rpc` does not + * actually support will fail when the underlying RPC method is called. + * + * Note that this does not create a fully-fledged Kit client — it only wraps the RPC in the account + * interfaces above. To build a complete client, use `createClient().use(solanaRpc(...))` instead, + * which additionally exposes the underlying RPC and other capabilities. + * + * @param rpc - A raw `Rpc` object that supports either the + * {@link GetMinimumBalanceForRentExemptionApi}, or both the {@link GetAccountInfoApi} + * and the {@link GetMultipleAccountsApi} (otherwise no interface can be produced). The + * interfaces exposed on the returned client's type depend on which RPC methods it + * supports. + * + * @example + * ```ts + * // With an RPC supporting both APIs, the client implements both interfaces. + * const client = createClientWithInterfacesFromRpc(rpc); + * const rentExemptBalance = await client.getMinimumBalance(100); + * const accounts = await client.fetchAccounts([addressA, addressB]); + * ``` + */ +export function createClientWithInterfacesFromRpc< + TRpc extends Rpc | Rpc, +>(rpc: TRpc): ClientInterfacesFromRpc { + // Both interfaces are built unconditionally; the return type narrows them to whatever the RPC + // declares. Runtime capability detection is not possible on a raw `Rpc` object. + return { + ...createClientWithGetMinimumBalanceFromRpc(rpc as Rpc), + ...createClientWithFetchAccountsFromRpc(rpc as Rpc), + } as ClientInterfacesFromRpc; +} diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts index ae22e2af5..bd72aeb3a 100644 --- a/packages/kit/src/index.ts +++ b/packages/kit/src/index.ts @@ -29,6 +29,7 @@ export * from '@solana/transaction-introspection'; export * from '@solana/transaction-messages'; export * from '@solana/transactions'; export * from './create-async-generator-with-initial-value-and-slot-tracking'; +export * from './create-client-with-interfaces-from-rpc'; export * from './create-reactive-store-with-initial-value-and-slot-tracking'; export * from './airdrop'; export * from './compute-unit-limit-estimation';