Skip to main content
TLDR:
  • The Surfpool Kit plugin, @solana/surfpool/kit, starts a local Surfnet inside your test process and connects a Solana Kit client to it, with a funded payer and typed cheatcodes.
  • Pass your Chainstack Solana Mainnet endpoint as remoteRpcUrl, and the Surfnet forks Mainnet: each account is copied from your endpoint the first time something reads it, and every transaction runs locally. Without remoteRpcUrl, the Surfnet is offline and Mainnet accounts don’t exist in it.
  • The test suite in this guide reads the real USDC mint, gives a wallet 1,000 USDC with a cheatcode, sends a transfer, profiles compute units without sending, and moves the clock forward 30 days. A run takes about 2 seconds and sends 12 read requests to your endpoint.
  • Set slotTimeMs: 400 if your code depends on slots or epochs. With the default 1 ms slot, moving the clock forward 30 days advances 6,000 epochs instead of 15.

Main article

Surfpool is a local Solana network that forks Mainnet on demand. The Solana: Surfpool quick-start covers the surfpool CLI. This guide covers the Kit plugin, which runs the same Surfnet inside a Node.js process, so a TypeScript test suite can start a fresh fork for each test file and drive it through a Kit client. The fork is lazy. Your tests use real Mainnet accounts, such as token mints, programs, and pools, without a clone list or a snapshot, and nothing they do reaches Mainnet.
To follow along, you need a Solana Mainnet node endpoint. See Solana tooling to get set up.

Requirements

  • Node.js 20.18 or later, which @solana/kit 8 requires.
  • macOS (Apple silicon or Intel) or Linux x64 with glibc. @solana/surfpool ships its native Surfnet build only for these platforms. On other platforms, run the surfpool CLI and connect to it with the plugin’s attach mode, described in the Surfpool Kit plugin documentation.

Set up the project

This guide uses @solana/kit 8.4.0, @solana/surfpool 1.6.1, @solana/kit-plugin-rpc 0.18.0, @solana/kit-plugin-signer 0.18.0, @solana/sysvars 8.4.0, and @solana-program/token 0.17.0. Two of these packages need care:
  • @solana/surfpool 1.6.1 requires the 0.18 line of @solana/kit-plugin-rpc and @solana/kit-plugin-signer. Installing all the packages in one command lets npm pick 0.18. Requesting 0.19 explicitly fails with ERESOLVE.
  • @solana/sysvars provides fetchSysvarClock. Kit 8 doesn’t re-export it from @solana/kit.

How the fork reaches your endpoint

The Surfnet calls your Chainstack endpoint only to read state:
  • At startup, it reads the genesis hash, epoch info, the epoch schedule, and one sysvar account. The local clock starts at your endpoint’s current slot and epoch.
  • The first time your code or a transaction reads an account, the Surfnet fetches it with getAccountInfo or getMultipleAccounts. Later reads use the local copy, so the copy doesn’t follow changes on Mainnet.
  • Transactions run only in the local Surfnet. The Surfnet never sends a transaction to your endpoint.
Each Surfnet starts with a new payer funded with 10 SOL, available as client.payer.

Write the test suite

The suite uses the Node.js built-in test runner, so it needs no test framework. It reads your endpoint from the CHAINSTACK_ENDPOINT environment variable.
fork.test.mjs

Start and stop the fork

The before hook builds the client. surfpool() starts the Surfnet and installs client.rpc, client.payer, client.cheatcodes, and client.surfnet. The surfnet options go to the native Surfnet: remoteRpcUrl turns on the fork, and slotTimeMs: 400 matches Mainnet’s slot time. tokenProgram() from @solana-program/token adds the Token program’s instructions and accounts as client.token. The after hook calls client.surfnet.stop(), which closes the Surfnet’s local HTTP and WebSocket servers and frees their ports. Node.js runs each test file in its own process, so each file gets its own Surfnet.

Read Mainnet state

The first test reads the USDC mint twice: through the fork and directly from your endpoint. The forked copy has the same decimals and mint authority as the account on Mainnet.

Fund a wallet and send a transfer

The setTokenAccount cheatcode writes the payer’s USDC token account, creating it if needed, with a balance of 1,000 USDC. No faucet or swap is involved, and the USDC mint authority doesn’t sign anything. client.token.instructions.transferToATA creates the recipient’s associated token account and transfers 250 USDC. sendTransaction() plans the transaction, adds a SetComputeUnitLimit instruction with an estimated limit, signs it with the payer, and sends it to the Surfnet. The test then checks both balances, and checks that the recipient’s token account doesn’t exist on Mainnet.

Profile compute units

The profileTransaction cheatcode runs a signed transaction against the fork and returns its compute units, logs, and account states, without changing any account. The test confirms that the recipient’s token account still doesn’t exist after profiling. The test builds this transaction by hand rather than with planTransaction(). A planned transaction carries a SetComputeUnitLimit instruction with a limit of 0, which sendTransaction() replaces with an estimate when it sends. Profiling a planned transaction as it is fails with Computational budget exceeded.

Move the clock

The timeTravel cheatcode takes absoluteTimestamp in milliseconds and moves the Clock sysvar to that time. Use it to test vesting schedules, lockups, and other time-based logic. The Surfnet converts the time difference into slots using slotTimeMs. With slotTimeMs: 400, moving 30 days forward advances about 6.48 million slots and 15 epochs, as on Mainnet. With the default of 1 ms, the same move advances about 2.59 billion slots and 6,000 epochs.

Run the tests

On Node.js 24, the output looks like this:
The compute units change from run to run because each run uses a new recipient. Creating the recipient’s associated token account searches for a valid address, and each extra search step costs 1,500 compute units.

Why a Mainnet account is not found

surfpool() without remoteRpcUrl starts an offline Surfnet. It still has a funded payer, but no Mainnet accounts, so reading the USDC mint fails:
If a test that worked against Mainnet state fails with Account not found, check that remoteRpcUrl is set and that CHAINSTACK_ENDPOINT holds your endpoint.

See also

Last modified on October 11, 2026