@solana/surfpool/kit runs a Surfnet — a local, Solana-compatible network —
inside your test process and hands back a
Solana Kit client already pointed at it. One
.use(surfpool()) replaces the RPC plugin you would normally reach for
(solanaLocalRpc(), litesvm()) and adds a pre-funded payer plus Surfpool's
cheatcodes:
import { createClient } from "@solana/kit";import { surfpool } from "@solana/surfpool/kit";const client = await createClient().use(surfpool());const slot = await client.rpc.getSlot().send();await client.cheatcodes.timeTravel({ absoluteSlot: 1_000_000n }).send();
No port to pick, no payer to generate and fund, and no separate surfpool start
process to manage. New to the SDK? Start with the
Overview.
Which Entry Point You Want
| Entry point | Reach for it when |
|---|---|
surfpool() | Default for tests. An isolated Surfnet per test file, with a Kit client already wired up. |
surfpool({ rpcUrl }) | A long-lived surfpool start instance is shared across processes, or your platform has no native binary. |
surfnetCheatcodes() | You already have a client and only want cheatcodes on it. |
Surfnet from @solana/surfpool | You are not using Kit — see the JS reference. |
Prerequisites
- Node.js 20.18+, the floor
@solana/kitv7 declares.@solana/surfpoolitself runs on 18+, but the Kit packages do not. Some program plugins require more —@solana-program/tokendeclares 24+. - A supported platform (macOS, Linux x86-64) for embedded mode, which loads a native binary. Elsewhere, use attach mode.
- Familiarity with Kit's plugin composition — clients are built by chaining
.use()calls, and each plugin adds properties to the client.
Installation
npm install --save-dev @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana/surfpool# orpnpm add -D @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana/surfpool
These are declared as optional peer dependencies of @solana/surfpool: skip
them if you only use the Surfnet class directly, but importing
@solana/surfpool/kit requires @solana/kit and @solana/kit-plugin-rpc. See
Installation for the platform support
matrix and troubleshooting.
Embedded Mode
Calling surfpool() with no rpcUrl boots an in-process Surfnet on dynamic
ports and points the whole Kit client at it. The plugin is async, so await the
.use() chain:
import { createClient } from "@solana/kit";import { surfpool } from "@solana/surfpool/kit";const client = await createClient().use(surfpool());
Parallel test files
Every surfpool() call binds its own dynamic ports, so each test file can
boot its own isolated Surfnet and the suite still runs in parallel.
A Complete Test
Boot a Surfnet, send a transfer paid for by the pre-funded payer, and assert on
the result. Examples here use node:test; Vitest and Jest work the same way
with their own after / afterAll hooks.
import { after, test } from "node:test";import assert from "node:assert/strict";import { getTransferSolInstruction } from "@solana-program/system";import { createClient, generateKeyPairSigner, lamports } from "@solana/kit";import { surfpool } from "@solana/surfpool/kit";const client = await createClient().use(surfpool());after(() => {client.surfnet.stop();});test("transfers SOL on an embedded Surfnet", async () => {const recipient = await generateKeyPairSigner();const amount = lamports(5_000_000n);await client.sendTransaction(getTransferSolInstruction({amount,destination: recipient.address,source: client.payer}));const { value: balance } = await client.rpc.getBalance(recipient.address).send();assert.equal(balance, amount);});
Lifecycle
Call client.surfnet.stop() in teardown, as above, so the Surfnet's ports and
servers are released. stop() is idempotent and synchronous — it returns once
the runtime has actually closed. Stopping is final; creating another client
boots a fresh instance.
Teardown is not automatic
A client held at module scope — the usual pattern for a test file — is never
disposed, so nothing stops the Surfnet for you. Without a teardown hook the
process can hang or log connection reset warnings as the OS tears down
sockets at exit.
What The Plugin Installs
| On the client | Comes from | What it is |
|---|---|---|
client.payer | @solana/kit-plugin-signer | A KeyPairSigner for Surfnet's pre-funded payer account |
client.rpc / client.rpcSubscriptions | @solana/kit-plugin-rpc | The standard Solana RPC and subscriptions clients, pointed at the Surfnet |
client.airdrop | @solana/kit-plugin-rpc | requestAirdrop against the Surfnet |
client.getMinimumBalance | @solana/kit-plugin-rpc | Rent-exemption lookups |
client.transactionPlanner / ...PlanExecutor | @solana/kit-plugin-rpc | Transaction planning and execution |
client.sendTransaction / client.sendTransactions | @solana/kit-plugin-rpc (via kit-plugin-instruction-plan) | Plan and send instructions in one call |
client.rpcUrl / client.wsUrl | @solana/surfpool/kit | The Surfnet's HTTP and WebSocket URLs |
client.surfnet | @solana/surfpool/kit | The native Surfnet handle (fundSol, deploy, drainEvents, …) |
client.cheatcodes | @solana/surfpool/kit | A typed RPC covering every surfnet_* cheatcode |
The plugin does not install an identity. Add one with .use(identity(...)) if
your test needs an authority separate from client.payer.
Cheatcodes
Cheatcodes are state mutations that bypass the normal transaction flow — they
run instantly, without consuming a blockhash or paying fees, which is what you
want for test setup. client.cheatcodes exposes all of them as a typed RPC.
Method names drop the surfnet_ prefix, so surfnet_pauseClock is
client.cheatcodes.pauseClock(), and responses arrive already unwrapped from
their { context, value } envelope.
import { address, generateKeyPairSigner } from "@solana/kit";// Deterministic clock.const paused = await client.cheatcodes.pauseClock().send();await client.cheatcodes.timeTravel({ absoluteSlot: paused.absoluteSlot + 1_000n }).send();await client.cheatcodes.resumeClock().send();// Arbitrary account state. `data` is hex-encoded.const account = (await generateKeyPairSigner()).address;const owner = (await generateKeyPairSigner()).address;await client.cheatcodes.setAccount(account, { data: "aabbcc", lamports: 777_777, owner }).send();// Token balances, without minting through the token program. The mint must// already exist — create it, or clone it from mainnet with cloneProgramAccount.const mint = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");await client.cheatcodes.setTokenAccount(owner, mint, { amount: 1_000_000n }).send();
The full method list — including streamAccount, cloneProgramAccount,
profileTransaction, registerIdl, and resetNetwork — is documented under
Cheatcodes and the
RPC reference.
Writing Structured Accounts With A Codec
setAccount takes raw bytes as hex, which pairs well with the account encoders
Kit's program clients ship. Rather than sending transactions to build up state,
encode the account you want and write it directly — here, a fully initialized
SPL mint with a supply already on it:
import {fetchMint,getMintEncoder,TOKEN_PROGRAM_ADDRESS} from "@solana-program/token";import {generateKeyPairSigner,getBase16Decoder,none,some} from "@solana/kit";const mint = (await generateKeyPairSigner()).address;const data = getMintEncoder().encode({decimals: 6,freezeAuthority: none(),isInitialized: true,mintAuthority: some(client.payer.address),supply: 1_000_000_000n});await client.cheatcodes.setAccount(mint, {// getBase16Decoder() turns the encoded bytes into the hex `data` expects.data: getBase16Decoder().decode(data),lamports: 1_461_600, // rent-exempt minimum for an 82-byte mintowner: TOKEN_PROGRAM_ADDRESS}).send();// Reads back as a normal mint through the program client.const account = await fetchMint(client.rpc, mint);account.data.decimals; // 6account.data.supply; // 1_000_000_000n
The same pattern works for any Codama-generated client: encode with the
account's encoder, hex it, and hand it to setAccount. Pair it with
setTokenAccount above to stand up a mint and funded holders without a single
transaction.
Cheatcode responses use bigint
The cheatcodes transport parses every JSON integer as a bigint, so u64
values such as rentEpoch survive past 2^53. Request payloads accept number | bigint.
Cheatcodes Without The Plugin
Two smaller entry points cover cases where you don't want the full plugin. Both
are synchronous — they only attach a transport, so neither needs await.
import {createSurfnetCheatcodesRpc,surfnetCheatcodes} from "@solana/surfpool/kit";// Standalone RPC, no Kit client involved.const cheatcodes = createSurfnetCheatcodesRpc("http://127.0.0.1:8899");await cheatcodes.pauseClock().send();// Add `client.cheatcodes` to a client you already composed.const client = createClient().use(surfnetCheatcodes());
surfnetCheatcodes() resolves its endpoint from url if given, then from an
existing client.rpcUrl (so it composes with any client that carries one), and
finally from DEFAULT_SURFNET_ENDPOINT (http://127.0.0.1:8899). Both accept a
headers option for authenticating against a remote Surfpool.
Configuration
Surfnet startup options go under the surfnet key and are forwarded to
Surfnet.startWithConfig(). Everything else is forwarded to the local Solana
RPC plugin:
const client = await createClient().use(surfpool({surfnet: { offline: true }, // Surfnet startup configskipPreflight: true // forwarded to solanaLocalRpc()}));
Omit surfnet entirely and the plugin calls Surfnet.start() with its
defaults. See Configuration for the
full set of startup options — remote RPC fallback, block production mode, slot
timing, feature gates, and custom payers.
Composing With Program Plugins
Because surfpool() satisfies the same contracts as solanaLocalRpc(), Kit
program plugins layer on top of it and their instructions execute against the
embedded Surfnet. Only the final result needs awaiting — use() on an async
client returns another async client, so sync and async plugins chain freely.
import { createClient, generateKeyPairSigner } from "@solana/kit";import { tokenProgram } from "@solana-program/token";import { surfpool } from "@solana/surfpool/kit";const client = await createClient().use(surfpool()).use(tokenProgram());const newMint = await generateKeyPairSigner();await client.token.instructions.createMint({ decimals: 6, mintAuthority: client.payer.address, newMint }).sendTransaction();await client.token.instructions.mintToATA({amount: 1_000_000n,decimals: 6,mint: newMint.address,mintAuthority: client.payer,owner: client.payer.address}).sendTransaction();
Attach Mode
Passing rpcUrl switches the plugin to attach mode: it connects to an
already-running Surfpool — one started with
surfpool start — instead of booting one.
No native module is loaded, so this mode works on platforms without a prebuilt
binary. It is also synchronous, so nothing needs awaiting:
import { createKeyPairSignerFromBytes, createClient } from "@solana/kit";import { payer } from "@solana/kit-plugin-signer";import { surfpool } from "@solana/surfpool/kit";import { readFile } from "node:fs/promises";// Any funded signer works; this loads the local CLI keypair.const keypairPath = `${process.env.HOME}/.config/solana/id.json`;const myPayer = await createKeyPairSignerFromBytes(new Uint8Array(JSON.parse(await readFile(keypairPath, "utf8"))));const client = createClient().use(payer(myPayer)).use(surfpool({ rpcUrl: "http://127.0.0.1:8899" }));
Three differences from embedded mode:
- The client must already have a
payer. Attach mode has no access to the running instance's payer secret key, so it installs none. Fund whichever signer you supply withclient.cheatcodes.setAccount(...)or the running instance's own faucet. - There is no
client.surfnethandle. In-process helpers are unavailable; useclient.cheatcodesfor state manipulation instead. surfnetstartup config is rejected. The instance is already running, sorpcUrlandsurfnetare mutually exclusive in the types.
WebSocket port
Surfpool serves subscriptions on its own port (default 8900, --ws-port),
independent of the HTTP port. When rpcUrl has an explicit port, the plugin
derives the subscriptions URL as port 8900 on the same host. When it has no
port — behind a proxy, say — only the protocol is swapped to ws/wss. Set
rpcSubscriptionsUrl yourself when neither rule fits.
Next Steps
- Programs — deploy your program into the Surfnet before a test
- Cheatcodes — the full state-mutation surface
- Configuration — mainnet forking, block production, feature gates
- Installation — platform support and troubleshooting
- JS Reference — the
Surfnetclass behindclient.surfnet
Is this page helpful?