Files

14 KiB

title, description
title description
Surfpool Kit Plugin The @solana/surfpool/kit plugin — one .use(surfpool()) gives a Kit client backed by an embedded Surfnet, with a pre-funded payer, the full RPC stack, and typed cheatcodes.

Surfpool Kit Plugin (@solana/surfpool/kit)

@solana/surfpool/kit boots a surfnet inside the test process and hands back a @solana/kit client already pointed at it. A single .use(surfpool()) replaces the RPC plugin you would otherwise reach for (solanaLocalRpc(), litesvm()) and adds a pre-funded payer plus a typed cheatcodes RPC.

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, no separate surfpool start process. This is the default for TypeScript integration tests — prefer it over driving Surfnet directly and hand-rolling a fetch cheatcode helper.

Choosing an Entry Point

Entry point Reach for it when
surfpool() Default for tests. An isolated surfnet per test file, Kit client already wired up.
surfpool({ rpcUrl }) A long-lived surfpool start instance is shared across processes, or the platform has no native binary.
surfnetCheatcodes() You already have a client and only want client.cheatcodes on it.
createSurfnetCheatcodesRpc(url) Standalone typed cheatcodes RPC, no Kit client involved.
Surfnet from @solana/surfpool Not using Kit — see overview.md.

Install

npm install --save-dev @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana/surfpool

The three Kit packages are optional peer dependencies of @solana/surfpool (@solana/kit ^7, @solana/kit-plugin-rpc ^0.15, @solana/kit-plugin-signer ^0.13). Skip them if you only use the Surfnet class; importing @solana/surfpool/kit requires at least @solana/kit and @solana/kit-plugin-rpc.

Requirements:

  • Node.js 20.18+ — the floor @solana/kit v7 declares. @solana/surfpool itself runs on 18+, but the Kit packages do not, and some program plugins want more (@solana-program/token declares 24+).
  • macOS x64/arm64, or Linux x64 GNU for embedded mode, which loads a native (napi-rs) binary. Everything else — Linux arm64, musl/Alpine, Windows — has no prebuilt binary; use attach mode there.

Two footguns follow from that list. Docker on Apple Silicon defaults to arm64 Linux containers, which have no binary even though the macOS host does. And a platform package with no artifact is an optional dependency, so install succeeds silently and only fails at require time with a module-not-found rather than a clear "unsupported platform" message.

Embedded Mode

Calling surfpool() with no rpcUrl boots an in-process surfnet on dynamic ports. The plugin is async in this mode — await the .use() chain. Every call binds its own ports, so test files can each own a surfnet and still run in parallel.

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);
});

Vitest and Jest work identically with their own afterAll hooks.

Teardown Is Not Automatic

As of @solana/surfpool 1.5.0, call client.surfnet.stop() in teardown so ports and servers are released. The client implements no disposal protocol, so a client held at module scope — the usual test-file pattern — is never cleaned up; without a teardown hook the process can hang or log connection reset warnings as the OS tears down sockets at exit. The plugin does stop the surfnet if setup itself throws.

stop() is idempotent and synchronous — it returns once the runtime has actually closed. Stopping is final: creating another client boots a fresh instance.

What The Plugin Installs

On the client Comes from What it is
client.payer @solana/kit-plugin-signer A KeyPairSigner for the surfnet's pre-funded payer
client.rpc / client.rpcSubscriptions @solana/kit-plugin-rpc 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 / client.transactionPlanExecutor @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 Typed RPC covering every surfnet_* cheatcode

The plugin does not install an identity. Add one with .use(identity(...)) when a test needs an authority separate from client.payer.

Typed Cheatcodes

Cheatcodes bypass the normal transaction flow — they apply instantly, consume no blockhash, and pay no fees, which is exactly what test setup wants. client.cheatcodes exposes all 26 as a typed RPC.

Method names drop the surfnet_ prefix (surfnet_pauseClock → 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. 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 transport parses every JSON integer as a bigint, so u64 values such as rentEpoch survive past 2^53. Request payloads accept number | bigint.

Full method list and JSON-RPC parameter schemas: cheatcodes.md.

Seeding 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 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 mint
        owner: TOKEN_PROGRAM_ADDRESS,
    })
    .send();

// Reads back as a normal mint through the program client
const account = await fetchMint(client.rpc, mint);
account.data.decimals; // 6
account.data.supply;   // 1_000_000_000n

The same pattern works for any Codama-generated client: encode with the account's encoder, hex it, hand it to setAccount. Pair it with setTokenAccount to stand up a mint and funded holders without a single transaction.

Cheatcodes Without The Full Plugin

Two smaller entry points cover cases where the full plugin is unwanted. 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 carrying 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 config
        skipPreflight: true,        // forwarded to solanaLocalRpc()
    }),
);

Omit surfnet entirely and the plugin calls Surfnet.start() with its defaults (mainnet fork, clock block production, dynamic ports).

Composing With Program Plugins

surfpool() satisfies the same contracts as solanaLocalRpc(), so Kit program plugins layer on top 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 to attach mode: the plugin connects to an already-running Surfpool (started with surfpool start) instead of booting one. No native module is loaded, so this works on platforms without a prebuilt binary. It is synchronous — nothing needs awaiting.

import { createClient, createKeyPairSignerFromBytes } 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 via client.cheatcodes.setAccount(...) or the instance's own faucet.
  • There is no client.surfnet handle. In-process helpers are unavailable; use client.cheatcodes for state manipulation.
  • surfnet startup config is rejected. The instance is already running, so rpcUrl and surfnet are 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.

Gotchas

  • Forgetting await in embedded mode. surfpool() with no rpcUrl is async; createClient().use(surfpool()) without await yields a promise, not a client. Attach mode is sync — mixing the two up is the most common error.
  • No teardown hook. Always wire client.surfnet.stop() into after / afterAll, or the process hangs at exit.
  • Reaching for client.surfnet in attach mode. It is not installed; use client.cheatcodes.
  • setTokenAccount against a nonexistent mint. The mint must exist first — clone it from mainnet with cloneProgramAccount, create it through the token program, or write it with setAccount + getMintEncoder().
  • data is hex, not base64. Use getBase16Decoder().decode(bytes) from @solana/kit.

See Also

  • overview.md — Surfpool CLI, cheatcode catalog, MCP server, IaC runbooks
  • cheatcodes.md — full parameter schemas for every surfnet_* method
  • ../testing.md — where this plugin fits in the testing pyramid
  • ../kit/plugins.md — Kit plugin composition and ordering rules