From 2c23d2859aa193f787baf7d337f0e395f8f026ac Mon Sep 17 00:00:00 2001 From: Callum Date: Mon, 3 Aug 2026 16:50:39 +0000 Subject: [PATCH] Add a react hook for useAirdrop --- .changeset/purple-ducks-scream.md | 5 ++ packages/react/README.md | 21 +++++++ .../src/__tests__/useAirdrop-test.browser.tsx | 62 +++++++++++++++++++ .../src/__typetests__/useAirdrop-typetest.ts | 51 +++++++++++++++ packages/react/src/index.ts | 1 + packages/react/src/useAirdrop.ts | 45 ++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 .changeset/purple-ducks-scream.md create mode 100644 packages/react/src/__tests__/useAirdrop-test.browser.tsx create mode 100644 packages/react/src/__typetests__/useAirdrop-typetest.ts create mode 100644 packages/react/src/useAirdrop.ts diff --git a/.changeset/purple-ducks-scream.md b/.changeset/purple-ducks-scream.md new file mode 100644 index 000000000..a09736628 --- /dev/null +++ b/.changeset/purple-ducks-scream.md @@ -0,0 +1,5 @@ +--- +'@solana/react': minor +--- + +Add a `useAirdrop` hook that wraps a client's `airdrop` capability (`ClientWithAirdrop`) as a tracked `useAction`. `dispatch(address, amount)` requests an airdrop with an injected `AbortSignal`, resolving with the transaction `Signature` (or `undefined` when the airdrop is applied without a transaction). diff --git a/packages/react/README.md b/packages/react/README.md index 47ab888e2..b4697ec34 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -169,6 +169,27 @@ function SendButton({ client, instructions }) { - **`usePlanTransaction(client)`** / **`usePlanTransactions(client)`** — plan a single, or multiple, transaction message(s) from an instruction input. Requires a client with transaction planning installed (`ClientWithTransactionPlanning`). `usePlanTransaction` resolves with a single transaction message; `usePlanTransactions` resolves with the full transaction plan. - **`useSendTransaction(client)`** / **`useSendTransactions(client)`** — sign, submit, and confirm a single, or multiple, transaction(s). Requires a client with transaction sending installed (`ClientWithTransactionSending`). Both accept flexible input (instructions, an instruction plan, or a transaction plan); `useSendTransaction` additionally accepts a single transaction message. +### `useAirdrop(client)` + +Request an airdrop of SOL to an address as a tracked action. Requires a client with an airdrop plugin installed (`ClientWithAirdrop`) — typically available on devnet, testnet, and local validators. Returns the same `ActionResult` as [`useAction`](#useactionfn); `dispatch(address, amount)` supplies the recipient and lamport amount while the hook injects the `AbortSignal`. A natural fit for a devnet "fund this account" button. + +```tsx +import { useAirdrop } from '@solana/react'; +import { lamports } from '@solana/kit'; +import type { Address } from '@solana/kit'; + +function AirdropButton({ client, address }: { client: ClientWithAirdrop; address: Address }) { + const { dispatch, isRunning } = useAirdrop(client); + return ( + + ); +} +``` + +`data` resolves to the transaction `Signature`, or `undefined` when the airdrop was applied without a transaction (some implementations, e.g. LiteSVM, adjust balances directly) — null-check it before use. + ### Payer & identity Read the signer a client uses to pay for transactions (`payer`), or the wallet whose on-chain assets the app acts upon (`identity`), and re-render whenever it changes. Both hooks return the current `TransactionSigner`, or `undefined` while none is available. diff --git a/packages/react/src/__tests__/useAirdrop-test.browser.tsx b/packages/react/src/__tests__/useAirdrop-test.browser.tsx new file mode 100644 index 000000000..d98a14002 --- /dev/null +++ b/packages/react/src/__tests__/useAirdrop-test.browser.tsx @@ -0,0 +1,62 @@ +import type { Address, ClientWithAirdrop, Lamports, Signature } from '@solana/kit'; +import { act } from '@testing-library/react'; + +import { renderHook } from '../__test-utils__/render'; +import { useAirdrop } from '../useAirdrop'; + +describe('useAirdrop', () => { + const address = 'AACC' as Address; + const amount = 1_000_000_000n as Lamports; + const signature = 'sig' as Signature; + + it('calls client.airdrop with the address, amount, and an abort signal, then resolves', async () => { + expect.assertions(5); + const { promise, resolve } = Promise.withResolvers(); + const airdrop = jest.fn(() => promise); + const client = { airdrop } as unknown as ClientWithAirdrop; + const { result } = renderHook(() => useAirdrop(client)); + + expect(result.current.status).toBe('idle'); + + act(() => { + result.current.dispatch(address, amount); + }); + expect(result.current.status).toBe('running'); + expect(airdrop).toHaveBeenCalledWith(address, amount, expect.any(AbortSignal)); + + await act(async () => resolve(signature)); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBe(signature); + }); + + it('resolves with `undefined` when the airdrop was performed without a transaction', async () => { + expect.assertions(2); + const { promise, resolve } = Promise.withResolvers(); + const airdrop = jest.fn(() => promise); + const client = { airdrop } as unknown as ClientWithAirdrop; + const { result } = renderHook(() => useAirdrop(client)); + + act(() => { + result.current.dispatch(address, amount); + }); + await act(async () => resolve(undefined)); + expect(result.current.status).toBe('success'); + expect(result.current.data).toBeUndefined(); + }); + + it('surfaces a rejection as an error', async () => { + expect.assertions(2); + const boom = new Error('boom'); + const { promise, reject } = Promise.withResolvers(); + const airdrop = jest.fn(() => promise); + const client = { airdrop } as unknown as ClientWithAirdrop; + const { result } = renderHook(() => useAirdrop(client)); + + act(() => { + result.current.dispatch(address, amount); + }); + await act(async () => reject(boom)); + expect(result.current.status).toBe('error'); + expect(result.current.error).toBe(boom); + }); +}); diff --git a/packages/react/src/__typetests__/useAirdrop-typetest.ts b/packages/react/src/__typetests__/useAirdrop-typetest.ts new file mode 100644 index 000000000..8dc764c56 --- /dev/null +++ b/packages/react/src/__typetests__/useAirdrop-typetest.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable react-hooks/rules-of-hooks */ + +import type { Address, ClientWithAirdrop, ClientWithRpc, Lamports, Signature } from '@solana/kit'; + +import type { ActionResult } from '../useAction'; +import { useAirdrop } from '../useAirdrop'; + +// [DESCRIBE] useAirdrop +{ + const client = {} as ClientWithAirdrop; + const address = {} as Address; + const amount = {} as Lamports; + + // It returns an ActionResult over the (address, amount) args and the optional signature + { + const result = useAirdrop(client); + result satisfies ActionResult<[address: Address, amount: Lamports], Signature | undefined>; + result.dispatch(address, amount) satisfies void; + result.dispatchAsync(address, amount) satisfies Promise; + result.data satisfies Signature | undefined; + } + + // dispatch rejects a non-Address first argument + { + const { dispatch } = useAirdrop(client); + // @ts-expect-error - first argument must be an Address + dispatch(123, amount); + } + + // dispatch rejects a non-Lamports second argument + { + const { dispatch } = useAirdrop(client); + // @ts-expect-error - second argument must be Lamports + dispatch(address, 123); + } + + // dispatch rejects a missing amount argument + { + const { dispatch } = useAirdrop(client); + // @ts-expect-error - amount is required + dispatch(address); + } + + // It rejects a client that lacks an airdrop capability + { + const rpcOnlyClient = {} as ClientWithRpc; + // @ts-expect-error - client must have an airdrop capability + useAirdrop(rpcOnlyClient); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 45c4889a4..93935b15c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -5,6 +5,7 @@ */ export * from './ClientProvider'; export * from './useAction'; +export * from './useAirdrop'; export * from './useClient'; export * from './useClientCapability'; export * from './useIdentity'; diff --git a/packages/react/src/useAirdrop.ts b/packages/react/src/useAirdrop.ts new file mode 100644 index 000000000..3c87cd11b --- /dev/null +++ b/packages/react/src/useAirdrop.ts @@ -0,0 +1,45 @@ +import type { Address, ClientWithAirdrop, Lamports, Signature } from '@solana/kit'; + +import { type ActionResult, useAction } from './useAction'; + +/** + * Requests an airdrop of SOL to an address, as a reactive action. + * + * Wraps `client.airdrop` with {@link useAction}: each `dispatch(address, amount)` runs the airdrop + * with a fresh `AbortSignal` and tracks its lifecycle through React state. Calling `dispatch` again + * while a previous airdrop is in flight aborts the first. This is a great fit for a devnet or + * localnet "fund this account" button, where the `isRunning` / `data` / `error` tracking drives the + * UI directly. + * + * The airdrop capability is typically available on test networks (devnet, testnet) and local + * validators. Some implementations (e.g. LiteSVM) update balances directly without sending a + * transaction, in which case the resolved `data` is `undefined` rather than a {@link Signature}. + * + * @param client - A client with an airdrop plugin installed (`ClientWithAirdrop`). + * @returns An {@link ActionResult} whose `dispatch`/`dispatchAsync` take the recipient `address` and + * the `amount` of lamports, and resolve with the transaction {@link Signature}, or `undefined` + * when the airdrop was performed without a transaction. + * + * @example + * ```tsx + * import { useAirdrop } from '@solana/react'; + * import { lamports } from '@solana/kit'; + * + * function AirdropButton({ client, address }) { + * const { dispatch, isRunning } = useAirdrop(client); + * return ( + * + * ); + * } + * ``` + * + * @see {@link ActionResult} + * @see {@link useAction} + */ +export function useAirdrop( + client: ClientWithAirdrop, +): ActionResult<[address: Address, amount: Lamports], Signature | undefined> { + return useAction((abortSignal, address: Address, amount: Lamports) => client.airdrop(address, amount, abortSignal)); +}