Skip to content

Tutorial ​

Deploying programs ​

Most of the time we want to do more than just mess around with token transfers - we want to test our own programs.

TIP

If you want to pull a Solana program from mainnet or devnet, use the solana program dump command from the Solana CLI.

To add a compiled program to our tests we can use the addProgramFromFile method.

Here's an example using a simple program from the Solana Program Library that just does some logging:

ts
import { AccountRole, generateKeyPairSigner, lamports } from "@solana/kit";
import { LiteSVM, TransactionMetadata } from "litesvm";
import assert from "node:assert/strict";
import { test } from "node:test";
import {
	generateAddress,
	getSignedTransaction,
	LAMPORTS_PER_SOL,
} from "./util";

test("spl logging", async () => {
	// Given the following addresses and signers.
	const [payer, programAddress, loggedAddress] = await Promise.all([
		generateKeyPairSigner(),
		generateAddress(),
		generateAddress(),
	]);

	// And a LiteSVM client with a logging program loaded from `spl_example_logging.so`.
	const svm = new LiteSVM();
	svm.airdrop(payer.address, lamports(LAMPORTS_PER_SOL));
	svm.addProgramFromFile(
		programAddress,
		"program_bytes/spl_example_logging.so",
	);

	// When we simulate and send a transaction that calls the program.
	const transaction = await getSignedTransaction(svm, payer, [
		{
			accounts: [{ address: loggedAddress, role: AccountRole.READONLY }],
			programAddress,
		},
	]);
	const simulationResult = svm.simulateTransaction(transaction);
	const result = svm.sendTransaction(transaction);

	// Then we expect the logs from simulation and execution to match.
	if (result instanceof TransactionMetadata) {
		assert.deepStrictEqual(simulationResult.meta().logs(), result.logs());
		assert.strictEqual(result.logs()[1], "Program log: static string");
	} else {
		throw new Error("Unexpected tx failure");
	}
});

Time travel ​

Many programs rely on the Clock sysvar: for example, a mint that doesn't become available until after a certain time. With litesvm you can dynamically overwrite the Clock sysvar using svm.setClock(). Here's an example using a program that panics if clock.unix_timestamp is greater than 100 (which is on January 1st 1970):

ts
import { generateKeyPairSigner, lamports } from "@solana/kit";
import {
	FailedTransactionMetadata,
	LiteSVM,
	TransactionMetadata,
} from "litesvm";
import assert from "node:assert/strict";
import { test } from "node:test";
import {
	generateAddress,
	getSignedTransaction,
	LAMPORTS_PER_SOL,
} from "./util";

test("clock", async () => {
	// Given the following addresses and signers.
	const [payer, programAddress] = await Promise.all([
		generateKeyPairSigner(),
		generateAddress(),
	]);

	// And a LiteSVM client with a hello world program loaded from `litesvm_clock_example.so`.
	const svm = new LiteSVM();
	svm.airdrop(payer.address, lamports(LAMPORTS_PER_SOL));
	svm.addProgramFromFile(
		programAddress,
		"program_bytes/litesvm_clock_example.so",
	);

	// And given two unique transactions.
	const [firstTransaction, secondTransaction] = await Promise.all([
		getSignedTransaction(svm, payer, [
			{ programAddress, data: new Uint8Array([0]) },
		]),
		getSignedTransaction(svm, payer, [
			{ programAddress, data: new Uint8Array([1]) },
		]),
	]);

	// When we set the time to January 1st 2000 and send the first transaction.
	const initialClock = svm.getClock();
	initialClock.unixTimestamp = 1735689600n;
	svm.setClock(initialClock);
	const firstResult = svm.sendTransaction(firstTransaction);

	// Then it fails because the contract wants it to be January 1970.
	if (firstResult instanceof FailedTransactionMetadata) {
		assert.ok(firstResult.err().toString().includes("ProgramFailedToComplete"));
	} else {
		throw new Error("Expected transaction failure here");
	}

	// When we set the time to January 1st 1970 and send the second transaction.
	const newClock = svm.getClock();
	newClock.unixTimestamp = 50n;
	svm.setClock(newClock);
	const secondResult = svm.sendTransaction(secondTransaction);

	// Then it succeeds.
	assert.ok(secondResult instanceof TransactionMetadata);
});

See also: svm.warpToSlot(), which lets you jump to a future slot.

Writing arbitrary accounts ​

LiteSVM lets you write any account data you want, regardless of whether the account state would even be possible.

Here's an example where we give an account a bunch of USDC, even though we don't have the USDC mint keypair. This is convenient for testing because it means we don't have to work with fake USDC in our tests:

ts
import {
	AccountState,
	findAssociatedTokenPda,
	getTokenDecoder,
	getTokenEncoder,
	Token,
	TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
	address,
	assertAccountExists,
	decodeAccount,
	generateKeyPairSigner,
	lamports,
	none,
} from "@solana/kit";
import { LiteSVM } from "litesvm";
import assert from "node:assert/strict";
import { test } from "node:test";
import { generateAddress, LAMPORTS_PER_SOL } from "./util";

test("infinite usdc mint", async () => {
	// Given the following addresses and signers.
	const [payer, owner] = await Promise.all([
		generateKeyPairSigner(),
		generateAddress(),
	]);
	const usdcMint = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

	// And a LiteSVM client such that the payer has some balance.
	const svm = new LiteSVM();
	svm.airdrop(payer.address, lamports(LAMPORTS_PER_SOL));

	// Add the following associated token account for the owner.
	const [ata] = await findAssociatedTokenPda({
		owner,
		mint: usdcMint,
		tokenProgram: TOKEN_PROGRAM_ADDRESS,
	});

	// And the following token account data.
	const tokenAccountData: Token = {
		mint: usdcMint,
		owner,
		amount: 1_000_000_000_000n,
		delegate: none(),
		state: AccountState.Initialized,
		isNative: none(),
		delegatedAmount: 0n,
		closeAuthority: none(),
	};
	const encodedTokenAccountData = getTokenEncoder().encode(tokenAccountData);

	// When we set that associated token account on the LiteSVM.
	svm.setAccount({
		address: ata,
		lamports: lamports(LAMPORTS_PER_SOL),
		programAddress: TOKEN_PROGRAM_ADDRESS,
		executable: false,
		data: encodedTokenAccountData,
		space: BigInt(encodedTokenAccountData.length),
	});

	// Then we can fetch the account and it matches what we set.
	const fetchedAccount = decodeAccount(svm.getAccount(ata), getTokenDecoder());
	assertAccountExists(fetchedAccount);
	assert.deepStrictEqual(fetchedAccount.data, tokenAccountData);
});

Copying Accounts from a live environment ​

If you want to copy accounts from mainnet or devnet, you can use the solana account command in the Solana CLI to save account data to a file.

Or, if you want to pull live data every time you test, you can do this with a few lines of code. Here's a simple example that pulls account data from devnet and passes it to LiteSVM:

ts
import { address, assertAccountExists, createSolanaRpc, fetchEncodedAccount } from "@solana/kit";
import { LiteSVM } from "litesvm";
import assert from "node:assert/strict";
import { test } from "node:test";

test("copy accounts from devnet", async () => {
	const usdcMint = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
	const rpc = createSolanaRpc("https://api.devnet.solana.com");
	const account = await fetchEncodedAccount(rpc, usdcMint);
	assertAccountExists(account);

	const svm = new LiteSVM();
	svm.setAccount(account);
	const rawAccount = svm.getAccount(usdcMint);
	assert.notStrictEqual(rawAccount, null);
});

Snapshots ​

Setting up an environment (deploying programs, minting tokens, seeding accounts) can dominate the runtime of a test suite. toBytes and saveToFile capture the full state of a LiteSVM instance, including accounts, sysvars, the feature set and the transaction history, and fromBytes and loadFromFile restore that state into a fresh instance:

ts
import { getTransferSolInstruction } from "@solana-program/system";
import {
	assertAccountExists,
	decodeAccount,
	generateKeyPairSigner,
	getSignatureFromTransaction,
	lamports,
} from "@solana/kit";
import { LiteSVM } from "litesvm";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import {
	generateAddress,
	getCounterDecoder,
	getGreetInstruction,
	getSignedTransaction,
	LAMPORTS_PER_SOL,
	setHelloWorldAccount,
	setHelloWorldProgram,
} from "./util";

// Deploys the counter program, seeds a payer and a counter account, and
// records one transaction so that every part of the state is exercised.
async function setUpEnvironment() {
	const [payer, programAddress, greetedAddress] = await Promise.all([
		generateKeyPairSigner(),
		generateAddress(),
		generateAddress(),
	]);
	const svm = new LiteSVM();
	svm.airdrop(payer.address, lamports(LAMPORTS_PER_SOL));
	setHelloWorldProgram(svm, programAddress);
	setHelloWorldAccount(svm, greetedAddress, programAddress);
	const transaction = await getSignedTransaction(svm, payer, [
		getGreetInstruction(greetedAddress, programAddress),
	]);
	svm.sendTransaction(transaction);
	const signature = getSignatureFromTransaction(transaction);
	return { svm, payer, programAddress, greetedAddress, signature };
}

function getCount(svm: LiteSVM, address: Parameters<LiteSVM["getAccount"]>[0]) {
	const account = decodeAccount(svm.getAccount(address), getCounterDecoder());
	assertAccountExists(account);
	return account.data.count;
}

test("snapshot round trip through bytes", async () => {
	// Given an environment with a program, some accounts and one transaction.
	const { svm, payer, programAddress, greetedAddress, signature } =
		await setUpEnvironment();

	// When we snapshot it and restore the snapshot into a new instance.
	const restored = LiteSVM.fromBytes(svm.toBytes());

	// Then the restored instance has the same accounts, blockhash and history.
	assert.strictEqual(getCount(restored, greetedAddress), 1);
	assert.strictEqual(
		restored.getBalance(payer.address),
		svm.getBalance(payer.address),
	);
	assert.strictEqual(restored.latestBlockhash(), svm.latestBlockhash());
	assert.notStrictEqual(restored.getTransaction(signature), null);

	// And the program deployed before the snapshot still runs. The blockhash
	// is expired first so the new transaction does not collide with the one
	// already in the restored history.
	restored.expireBlockhash();
	const transaction = await getSignedTransaction(restored, payer, [
		getGreetInstruction(greetedAddress, programAddress),
	]);
	restored.sendTransaction(transaction);
	assert.strictEqual(getCount(restored, greetedAddress), 2);
});

test("snapshot round trip through a file", async () => {
	const { svm, greetedAddress } = await setUpEnvironment();
	const dir = mkdtempSync(join(tmpdir(), "litesvm-snapshot-"));
	try {
		const path = join(dir, "state.bin");
		svm.saveToFile(path);
		const restored = LiteSVM.loadFromFile(path);
		assert.strictEqual(getCount(restored, greetedAddress), 1);
		assert.strictEqual(restored.latestBlockhash(), svm.latestBlockhash());
	} finally {
		rmSync(dir, { recursive: true, force: true });
	}
});

test("restored instances are independent", async () => {
	// Given a snapshot restored twice, as a test suite would before each test.
	const { svm, payer } = await setUpEnvironment();
	const snapshot = svm.toBytes();
	const [first, second] = [
		LiteSVM.fromBytes(snapshot),
		LiteSVM.fromBytes(snapshot),
	];
	const balanceBefore = svm.getBalance(payer.address);

	// When we spend from one of the restored instances.
	const receiver = await generateAddress();
	const transaction = await getSignedTransaction(first, payer, [
		getTransferSolInstruction({
			source: payer,
			destination: receiver,
			amount: lamports(1_000_000n),
		}),
	]);
	first.sendTransaction(transaction);

	// Then neither the original nor the other restored instance is affected.
	assert.strictEqual(first.getBalance(receiver), lamports(1_000_000n));
	assert.strictEqual(second.getBalance(receiver), null);
	assert.strictEqual(svm.getBalance(receiver), null);
	assert.strictEqual(second.getBalance(payer.address), balanceBefore);
});

test("fromBytes rejects data that is not a snapshot", () => {
	assert.throws(
		() => LiteSVM.fromBytes(new Uint8Array([255, 1, 2, 3])),
		/Failed to load snapshot/,
	);
});

Snapshots use the same format as the litesvm-persistence Rust crate, so a file written from Rust can be loaded from JavaScript and vice versa.

Other features ​

Other things you can do with litesvm include:

  • Changing the max compute units and other compute budget behaviour using the withComputeBudget method.
  • Disable transaction signature checking using svm.withSigverify(false).
  • Find previous transactions using the getTransaction method.

When should I use solana-test-validator? ​

While litesvm is faster and more convenient, it is also less like a real RPC node. So solana-test-validator is still useful when you need to call RPC methods that LiteSVM doesn't support, or when you want to test something that depends on real-life validator behaviour rather than just testing your program and client code.

In general though I would recommend using litesvm wherever possible, as it will make your life much easier.

Supported platforms ​

litesvm is supported on Linux x64 and MacOS targets. If you find a platform that is not supported but which can run the litesvm Rust crate, please open an issue.