Skip to main content

What is Bankrun and How to Use it to Enhance Solana Local Development?

Created on
Updated on
Nov 26, 2024

23 min read

Overview

Bankrun is a fast, powerful, and lightweight framework for testing Solana programs in NodeJS. It addresses several common developer pain points when testing Solana programs, which can save you time. Let's look at what it can do for you and how to start using it!

What You Will Do

  • Learn about what Bankrun is and how it can help you test Solana programs
  • Create a new Anchor program that depends on certain time and account-based constraints
  • Write a test using Bankrun to verify the program's functionality

What You Will Need

DependencyVersion
Solana cli1.18.8
Anchor CLI0.30.1
Node.jslatest
yarnlatest
ts-nodelatest
typescriptlatest
Rustlatest

What is Bankrun?

Bankrun is an alternative to the Solana CLI test validator (run via solana-test-validator) that is designed to be faster and more flexible. It offers several features that make it easier to test your Solana programs, including:

  • faster test validation
  • "time travel" to modify the state of the blockchain
  • arbitrary accounts data (this can be helpful in utilizing token mints and other relevant accounts from mainnet)

Bankrun works by spinning up a lightweight BanksServer, which functions almost like a light RPC, and creating a BanksClient to talk to the server.

How to Use Bankrun?

Let's create a simple Anchor project that will allow us to test some of Bankrun's features. Before starting, double-check that you have installed Anchor version 0.30.1 or higher. You can check the version by running the following command:

anchor --version

If you do not, you can follow the installation instructions here.

Create a New Project

Go ahead and create a new project in your terminal. From the project parent directory, run the following command:

anchor init bankrun-test

And then change into the new directory:

cd bankrun-test

After the project is created, go ahead and build the program to make sure everything is working:

anchor build

Your initial build should complete successfully after a few minutes. If you see an error, follow the instructions in the error message to fix the issue.

Install Dependencies

We will need a few dependencies to make our project work. Let's install them:

First, let's install our Node.js dependencies:

yarn add solana-bankrun anchor-bankrun @solana/spl-token

This will allow us to use Bankrun in our tests along with the Solana Token Program.

Next, let's add the SPL token program to our Anchor Program. Navigate to programs/bankrun-test/Cargo.toml and add anchor-spl to the dependencies:

[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"

Since we are using Anchor 0.30+, we will also need to update the idl-build feature in programs/bankrun-test/Cargo.toml to include the anchor-spl dependency:

idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]

Great! We're now ready to start building our program.

Create Anchor Program

Since the focus of this guide is using Bankrun for testing, we will not spend much time on the Anchor program itself. Instead, we will focus on writing the Bankrun tests. Navigate to programs/bankrun-test/src/lib.rs and replace the contents with the following (make sure to replace the program ID with your own):

use anchor_lang::prelude::*;
use anchor_spl::token::TokenAccount;
use std::str::FromStr;

declare_id!("11111111111111111111111111111111"); // REPLACE WITH YOUR PROGRAM ID

const MINIMUM_SLOT: u64 = 100;
const TOKEN_MINIMUM_BALANCE: u64 = 100_000_000_000;
const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";

#[program]
pub mod bankrun_test {
use super::*;

pub fn set_data(ctx: Context<SetData>) -> Result<()> {
let current_slot = Clock::get()?.slot;
msg!("Current slot: {}", current_slot);
require_gte!(current_slot, MINIMUM_SLOT, BankrunError::InvalidSlot);

ctx.accounts.data_account.new_data = ctx.accounts.new_data.key();
ctx.accounts.data_account.last_updated_slot = current_slot;
msg!("Set new data: {}", ctx.accounts.new_data.key());

Ok(())
}

pub fn check_spl_token(ctx: Context<CheckSplToken>) -> Result<()> {
let usdc_mint = Pubkey::from_str(USDC_MINT).unwrap();

let token_account = &ctx.accounts.token_account;
let token_balance = token_account.amount;

msg!("Token account: {} has a balance of {}", token_account.key(), token_balance);
require_keys_eq!(token_account.mint, usdc_mint, BankrunError::InvalidTokenMint);
require_gte!(token_balance, TOKEN_MINIMUM_BALANCE, BankrunError::InsufficientTokenBalance);

Ok(())
}
}

#[derive(Accounts)]
pub struct SetData<'info> {
#[account(mut)]
pub payer: Signer<'info>,

#[account(
init,
payer = payer,
space = 8 + DataAccount::INIT_SPACE
)]
pub data_account: Account<'info, DataAccount>,

pub new_data: Signer<'info>,

pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct CheckSplToken<'info> {
pub token_account: Account<'info, TokenAccount>,
}

#[account]
#[derive(InitSpace)]
pub struct DataAccount {
pub last_updated_slot: u64,
pub new_data: Pubkey
}

#[error_code]
pub enum BankrunError {
// Error code: 6000
#[msg("Invalid slot")]
InvalidSlot,
// Error code: 6001
#[msg("Insufficient token balance")]
InsufficientTokenBalance,
// Error code: 6002
#[msg("Invalid token mint")]
InvalidTokenMint,
}

You can run anchor keys sync to update your declare_id! macro to match the program ID of your program.

Let's take a look at each of our program's instructions:

  • set_data: This instruction sets the last_updated_slot field of the DataAccount to the current slot, and sets the new_data field to the public key of the new_data account that signs the transaction. The instruction checks that the current slot is greater than or equal to the MINIMUM_SLOT constant. If the current slot is less than the MINIMUM_SLOT constant, the instruction will fail with the error code InvalidSlot.
  • check_spl_token: This instruction checks that the token_account has a balance greater than or equal to the TOKEN_MINIMUM_BALANCE constant. It also checks that the token_account is associated with the USDC_MINT constant. If either of these conditions are not met, the instruction will fail with the error code InsufficientTokenBalance or InvalidTokenMint, respectively.

The should be sufficient in helping us demonstrate Bankrun's ability to "time travel" and write arbitrary data to accounts.

Go ahead and run anchor build to build the program and ensure that it compiles successfully. You should not see any errors, but if you do, follow the instructions in the terminal to fix them.

Write Tests

Alright! Let's get started writing our tests. Navigate to tests/bankrun-test.ts and delete the existing contents.

Import Dependencies

Let's start by importing the necessary dependencies. At the top of the file, add the following:

import { setProvider, Program } from "@coral-xyz/anchor";
import { BankrunTest } from "../target/types/bankrun_test";
import {
AccountInfoBytes,
AddedAccount,
BanksClient,
BanksTransactionResultWithMeta,
ProgramTestContext,
startAnchor
} from "solana-bankrun";
import { BankrunProvider } from "anchor-bankrun";
import { expect } from "chai";
import {
PublicKey,
Transaction,
Keypair,
Connection,
clusterApiUrl,
TransactionInstruction
} from "@solana/web3.js";
import {
ACCOUNT_SIZE,
AccountLayout,
getAssociatedTokenAddressSync,
MintLayout,
TOKEN_PROGRAM_ID
} from "@solana/spl-token";

const IDL = require("../target/idl/bankrun_test.json");

Most of these should look familiar, but we are importing a few new dependencies from anchor-bankrun and solana-bankrun. We will cover these when we get to the setup functions.

Define Constants

Let's create a few constants to help us with our tests. Below your imports, add the following:

// Constants
const PROJECT_DIRECTORY = ""; // Leave empty if using default anchor project
const USDC_DECIMALS = 6;
const USDC_MINT_ADDRESS = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const MINIMUM_SLOT = 100;
const MINIMUM_USDC_BALANCE = 100_000_000_000; // 100k USDC
  • PROJECT_DIRECTORY: This is the directory where your Anchor project (same directory with Anchor.toml) is located. If you are using the default project, you can leave this as an empty string.
  • USDC_DECIMALS: This is the number of decimals in the USDC token.
  • USDC_MINT_ADDRESS: This is the address of the USDC token mint.
  • MINIMUM_SLOT: This is the same minimum slot that we defined in our Anchor program.
  • MINIMUM_USDC_BALANCE: This is the same minimum balance of USDC that we defined in our Anchor program.

Setup Functions

Next, let's create some setup functions to help us with our tests. Below your constants, add the following:

async function createAndProcessTransaction(
client: BanksClient,
payer: Keypair,
instruction: TransactionInstruction,
additionalSigners: Keypair[] = []
): Promise<BanksTransactionResultWithMeta> {
const tx = new Transaction();
const [latestBlockhash] = await client.getLatestBlockhash();
tx.recentBlockhash = latestBlockhash;
tx.add(instruction);
tx.feePayer = payer.publicKey;
tx.sign(payer, ...additionalSigners);
return await client.tryProcessTransaction(tx);
}

async function setupATA(
context: ProgramTestContext,
usdcMint: PublicKey,
owner: PublicKey,
amount: number
): Promise<PublicKey> {
const tokenAccData = Buffer.alloc(ACCOUNT_SIZE);
AccountLayout.encode(
{
mint: usdcMint,
owner,
amount: BigInt(amount),
delegateOption: 0,
delegate: PublicKey.default,
delegatedAmount: BigInt(0),
state: 1,
isNativeOption: 0,
isNative: BigInt(0),
closeAuthorityOption: 0,
closeAuthority: PublicKey.default,
},
tokenAccData,
);

const ata = getAssociatedTokenAddressSync(usdcMint, owner, true);
const ataAccountInfo = {
lamports: 1_000_000_000,
data: tokenAccData,
owner: TOKEN_PROGRAM_ID,
executable: false,
};

context.setAccount(ata, ataAccountInfo);
return ata;
}

Let's take a look at each of these functions:

  • createAndProcessTransaction accepts some basic transaction information and a BanksClient, which is a client for the ledger state, from the perspective of an arbitrary validator (it will be created when we initiate our tests with the startAnchor function). It then creates a new transaction, adds the provided instruction to it, signs it with the provided payer, and processes it using the tryProcessTransaction function of the BanksClient. This function is handy for testing as it returns BanksTransactionResultWithMeta, which contains the transaction logs, return data, compute units used, and (if applicable) an error.
  • setupATA accepts a ProgramTestContext, which is effectively an extension of the BanksClient that includes some additional functionality, including the setAccount function, which allows us to set an account in the context. The function will encode the provided account data into a buffer, create an associated token account (ATA) for the provided owner and mint, and set the ATA's data to the encoded buffer. It will then return the ATA's public key.

Frame Our Test Environment

Now that we have our setup functions, we can start writing our tests. Below your support functions, describe the test suite:

describe("Bankrun Tests", () => {
const usdcMint = new PublicKey(USDC_MINT_ADDRESS);
let context: ProgramTestContext;
let client: BanksClient;
let payer: Keypair;
let provider: BankrunProvider;
let program: Program<BankrunTest>;

before(async () => {
const connection = new Connection(clusterApiUrl("mainnet-beta"));
const accountInfo = await connection.getAccountInfo(usdcMint);
const usdcAccount: AddedAccount = { address: usdcMint, info: accountInfo };

context = await startAnchor(PROJECT_DIRECTORY, [], [usdcAccount]);
client = context.banksClient;
payer = context.payer;
provider = new BankrunProvider(context);
setProvider(provider);
program = new Program<BankrunTest>(IDL, provider);
});

// TODO: Add Time Travel Tests Here

// TODO: Add Arbitrary Data Account Tests Here

});

Here we are defining a few variables that will be used globally throughout the tests:

  • usdcMint: This is the public key of the USDC token mint.
  • context: This is an instance of ProgramTestContext, which is a wrapper around the BanksClient that includes additional functionality. This is initiated using the startAnchor function. Note that we are passing in the usdcAccount. This will initiate our test environment with the USDC mint account initialized (we will write a test to verify this in a bit).
  • client: This is the BanksClient that will be used to interact with the ledger state.
  • payer: This is the payer that will be used to sign transactions.
  • provider: This is an instance of BankrunProvider (an implementation of the Anchor Provider) that will include additional context and functionality.
  • program: This is an instance of the anchor Program<BankrunTest>, which will be used just like any other anchor test suite.

Time Travel Tests

Let's create a few tests that help us verify the program's functionality. Since we have an instruction that will fail if the current slot is less than the MINIMUM_SLOT, this test might could be cumbersome in a traditional testing environment. Bankrun allows us to "time travel" to different slots or epochs based on our needs. This can be achieved in one of two ways:

  1. using the provider.context.warpToSlot function (or warpToEpoch for epochs), or
  2. by using the context.setClock function.

Inside your "Bankrun Tests" describe block*, after the "before" block, add the following "Time Travel Tests" describe block:

  describe("Time Travel Tests", () => {
const testCases = [
{ desc: "(too early)", slot: MINIMUM_SLOT - 1, shouldSucceed: false },
{ desc: "(at or above threshold)", slot: MINIMUM_SLOT, shouldSucceed: true },
]
testCases.forEach(({ desc, slot, shouldSucceed }) => {
describe(`When slot is ${slot} ${desc}`, () => {
let txResult: BanksTransactionResultWithMeta;
let newData: Keypair;
let dataAccount: Keypair;

before(async () => {
provider.context.warpToSlot(BigInt(slot));
newData = Keypair.generate();
dataAccount = Keypair.generate();
const ix = await program.methods
.setData()
.accounts({
payer: payer.publicKey,
newData: newData.publicKey,
dataAccount: dataAccount.publicKey,
})
.signers([newData, dataAccount])
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix, [newData, dataAccount]);
});

if (!shouldSucceed) {
it("transaction should fail", () => {
expect(txResult.result).to.exist;
});

it("should contain specific error details in log", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InvalidSlot') &&
log.includes('6000') &&
log.includes('Error Message: Invalid slot')
);
expect(errorLog).to.exist;
});

it("last log message should indicate failure", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
} else {
it("transaction should succeed", () => {
expect(txResult.result).to.be.null;
});

it("last log message should indicate success", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});

it("should contain expected log message", () => {
const expectedLog = "Set new data: " + newData.publicKey.toString();
const foundLog = txResult.meta.logMessages.some(log => log.includes(expectedLog));
expect(foundLog).to.be.true;
});

it("should set new data correctly", async () => {
const onChainData = await program.account.dataAccount.fetch(dataAccount.publicKey);
expect(onChainData.newData.toString()).to.equal(newData.publicKey.toString());
});
}
});
});
});

Let's break down the tests:

  • First, we are defining an array of test cases. Each test case is an object that contains a description, the slot number, and a boolean value indicating whether the test should succeed or fail. In this case, we are testing one case where the current slot is less than the MINIMUM_SLOT constant (expected to fail), and another case where the current slot is greater than or equal to the MINIMUM_SLOT constant (expected to succeed). For each test case, we create a describe block that describes the test case. Inside the describe block, we create a before block that sets up the test environment. Before creating and sending the transaction, we use the provider.context.warpToSlot function to "time travel" to the specified slot. We then run a series of tests inside the describe block depending on whether the instruction is expected to succeed or fail. For each test, we create an it block that describes the test case. Inside the it block, we check the transaction result and make assertions about the expected behavior.
  • The createAndProcessTransaction function only returns a result property if an error occured during the transaction, so we can use the expect function to check if the result is null or not. Additionally, the createAndProcessTransaction function returns our program's log messages as a meta.logMessages property. We have created a couple of helpers to find expected AnchorError logs or the expected success or failed log messages.
  • Finally, we use the Anchor fetch method to get the newData field from the dataAccount account and assert that it matches the expected value.

We should be able to run the tests now. In your terminal enter:

anchor test

Your tests should pass, but you should notice that your terminal includes a lot of debugging information:

      When slot is 100 (at or above threshold)
[2024-07-17T21:57:31.175139000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk invoke [1]
[2024-07-17T21:57:31.175203000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Instruction: SetData
[2024-07-17T21:57:31.175246000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 invoke [2]
[2024-07-17T21:57:31.175254000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111111 success
[2024-07-17T21:57:31.175290000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Current slot: 100
[2024-07-17T21:57:31.175372000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Set new data: 5go3aphd2yJPqRpQEN8Pg4Asvp2xkpNLJxXc6HErm4a5
[2024-07-17T21:57:31.175383000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk consumed 18600 of 200000 compute units
[2024-07-17T21:57:31.175391000Z DEBUG solana_runtime::message_processor::stable_log] Program 4uqt4ZDm7WhG3BfQASEADZNi5TUyE21zwXwbqUb1Qjmk success
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly

This is another perk of using Bankrun, as it allows us to debug our tests more granularly. Though not a big deal since our tests are passing, this can be really useful in debugging programs quickly! You should be able to browse the logs and see exactly how and where your program logged the error or success messages (as we identified in our tests). Pretty cool, right?

Arbitrary Data Account Tests

Now let's look at some tests to help us explore BankRun's arbitrary data account functionality. We will test the check_spl_token instruction, which will check if the token account has a balance greater than or equal to the TOKEN_MINIMUM_BALANCE constant. If you recall, our Anchor program required that the token mint must be the USDC mint. Running this test would typically require using a fake USDC token, but Bankrun allows us to use arbitrary accounts, meaning we can write token account information to any account we want. In fact, we have already done this in our startAnchor function--by initiating the usdcMint account with account information that we pulled from the Solana mainnet. Let's (1) write a test to ensure the USDC mint is properly initialized and (2) write a set of tests to verify that the ATA with sufficient balance is initialized. Inside your "Bankrun Tests" describe block, after the "Time Travel Tests" block, add the following "Arbitrary Data Account Tests" describe block:

  describe("Arbitrary Data Account Tests", () => {
const testCases = [
{ desc: "insufficient", amount: MINIMUM_USDC_BALANCE - 1_000_000, shouldSucceed: false },
{ desc: "sufficient", amount: MINIMUM_USDC_BALANCE, shouldSucceed: true },
];

describe("USDC mint initialization", () => {
let rawAccount: AccountInfoBytes;

before(async () => {
rawAccount = await client.getAccount(usdcMint);
});

it("should have initialized USDC mint", () => {
expect(rawAccount).to.exist;
});

it("should have correct decimals", () => {
const mintInfo = MintLayout.decode(rawAccount.data);
expect(mintInfo.decimals).to.equal(USDC_DECIMALS);
});
});

testCases.forEach(({ desc, amount, shouldSucceed }) => {
describe(`ATA with ${desc} USDC balance`, () => {
let ata: PublicKey;
let txResult: BanksTransactionResultWithMeta;

before(async () => {
let owner = Keypair.generate();
ata = await setupATA(context, usdcMint, owner.publicKey, amount);
const ix = await program.methods
.checkSplToken()
.accounts({
tokenAccount: ata,
})
.instruction();
txResult = await createAndProcessTransaction(client, payer, ix);
});

it("should have initialized USDC ATA", async () => {
const rawAccount = await client.getAccount(ata);
expect(rawAccount).to.exist;
});

it("should have correct balance in ATA", async () => {
const accountInfo = await client.getAccount(ata);
const tokenAccountInfo = AccountLayout.decode(accountInfo.data);
expect(tokenAccountInfo.amount).to.equal(BigInt(amount));
});

if (shouldSucceed) {
it("should process the transaction successfully", () => {
expect(txResult.result).to.be.null;
});

it("last log message should indicate success", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('success');
});
} else {
it("should fail to process the transaction", () => {
expect(txResult.result).to.exist;
});

it("should contain specific error details in log", () => {
const errorLog = txResult.meta.logMessages.find(log =>
log.includes('AnchorError') &&
log.includes('InsufficientTokenBalance') &&
log.includes('6001') &&
log.includes('Error Message: Insufficient token balance.')
);
expect(errorLog).to.exist;
});

it("last log message should indicate failure", () => {
expect(txResult.meta.logMessages[txResult.meta.logMessages.length - 1]).to.include('failed');
});
}
});
});
});

Let's break down the tests:

  • First, like before, we define an array of test cases. Each test case is an object that contains a description, the amount of USDC balance, and a boolean value indicating whether the test should succeed or fail. In this case, we are testing one case where the ATA has an insufficient balance (expected to fail), and another case where the ATA has sufficient balance (expected to succeed).
  • Next, we are creating a test for the USDC mint initialization. We are using the getAccount method of the BanksClient to get the raw account information for the USDC mint. We then use the expect function to check if the account exists and if the decimals match the expected value. For each test case, we create a describe block that describes the test case. Inside the describe block, we create a before block that sets up the test environment. Before creating and sending the transaction, we use the setupATA function to create an ATA with the specified balance. We then use the createAndProcessTransaction function to send the transaction to the ledger state.
  • We then run a series of tests inside the describe block depending on whether the instruction is expected to succeed or fail. For each test, we are creating an it block that describes the test case. Inside the it block, we are checking the result of the transaction and making assertions about the expected behavior. These tests structurally mirror the tests we wrote before, leveraging the results and meta.logMessages properties of the BanksTransactionResultWithMeta object.

Run the Tests

In your terminal, enter:

anchor test

Your tests should pass:

  18 passing (2s)

✨ Done in 3.02s.

Wow! That's fast! Despite sending several transactions to the cluster, our tests run in a fraction of a second. This is a huge advantage of using Bankrun, as it allows us to run our tests in a much more efficient manner! But what about those logs? Don't worry--if you do not want to see them, you can remove them easily.

Open up Anchor.toml and update your [scripts] section to look like this:

[scripts]
test = "RUST_LOG= yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"
test_debug = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts"

Now, when you run anchor test, you should see no logs:

  Bankrun Tests
Time Travel Tests
When slot is 99 (too early)
✔ transaction should fail
✔ should contain specific error details in log
✔ last log message should indicate failure
When slot is 100 (at or above threshold)
✔ transaction should succeed
✔ last log message should indicate success
✔ should contain expected log message
✔ should set new data correctly
Arbitrary Data Account Tests
USDC mint initialization
✔ should have initialized USDC mint
✔ should have correct decimals
ATA with insufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should fail to process the transaction
✔ should contain specific error details in log
✔ last log message should indicate failure
ATA with sufficient USDC balance
✔ should have initialized USDC ATA
✔ should have correct balance in ATA
✔ should process the transaction successfully
✔ last log message should indicate success


18 passing (442ms)

✨ Done in 1.19s.

This is great! But we also added a test_debug script that will display the logs if we need to debug something. Let's rerun the tests, but this time we will use the test_debug script:

anchor run test_debug

And there you have it! Your logs are displayed. This is a great way to debug your tests and make sure that they are working as expected.

Wrap Up

Great work! You now have some additional tools in your tool belt to accelerate your Solana program testing and development.

Resources

Let's Connect!

We'd love to hear what you are building and testing. Send us your experience, questions, or feedback via Twitter or Discord.

We ❤️ Feedback!

Let us know if you have any feedback or requests for new topics. We'd love to hear from you.

Share this guide