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
5 changes: 5 additions & 0 deletions .changeset/purple-ducks-scream.md
Original file line number Diff line number Diff line change
@@ -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).
21 changes: 21 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button disabled={isRunning} onClick={() => dispatch(address, lamports(1_000_000_000n))}>
{isRunning ? 'Airdropping…' : 'Airdrop 1 SOL'}
</button>
);
}
```

`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.
Expand Down
62 changes: 62 additions & 0 deletions packages/react/src/__tests__/useAirdrop-test.browser.tsx
Original file line number Diff line number Diff line change
@@ -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<Signature | undefined>();
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<Signature | undefined>();
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<Signature | undefined>();
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);
});
});
51 changes: 51 additions & 0 deletions packages/react/src/__typetests__/useAirdrop-typetest.ts
Original file line number Diff line number Diff line change
@@ -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<Signature | undefined>;
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<unknown>;
// @ts-expect-error - client must have an airdrop capability
useAirdrop(rpcOnlyClient);
}
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
export * from './ClientProvider';
export * from './useAction';
export * from './useAirdrop';
export * from './useClient';
export * from './useClientCapability';
export * from './useIdentity';
Expand Down
45 changes: 45 additions & 0 deletions packages/react/src/useAirdrop.ts
Original file line number Diff line number Diff line change
@@ -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 (
* <button disabled={isRunning} onClick={() => dispatch(address, lamports(1_000_000_000n))}>
* {isRunning ? 'Airdropping…' : 'Airdrop 1 SOL'}
* </button>
* );
* }
* ```
*
* @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));
}
Loading