Skip to content

Protocol templates ​

Twelve example templates. Eleven work with real Solana protocols: Jupiter, Kamino, Orca, pump.fun and Pyth, and one of those, the daily cap, also keeps state between runs. The twelfth settles a trade at a price someone signed off chain. Each one works with a value that only exists while the transaction runs, such as what a swap returned or what a position has earned. The source files are in clients/js/examples/protocols/.

Status: Tested locally in LiteSVM against copies of the protocols' mainnet programs and accounts; not yet run on devnet or mainnet. What has been tested has the details. Treat the templates as starting points, and check them against the protocols' current programs before you use them.

TemplateProtocolDecided during the run
Deposit what a swap producedJupiter → KaminoHow much the swap produced
Swap checked against an oracleJupiter + PythWhether the swap paid at least the oracle price, less a tolerance
Sell a whole balanceSPL Token → JupiterHow much there is to sell
Cap a caller's daily swapsJupiter + registryHow much wrapped SOL this caller can sell now, as its cap refills
Repay what a swap producedJupiter → KaminoHow much the swap produced
Liquidate with a minimum payoutKaminoHow much collateral the liquidator received
Compound collected feesOrcaHow much the position had earned
Harvest positions that earnedOrcaWhich positions have earned enough to collect
Buy a basket within a budgetpump.funWhat each buy cost, and whether the total is within budget
Sell all of a coin above a floorpump.funHow much there is to sell, and what the sale paid
Act only on a fresh pricePyth → JupiterWhether the price is recent, precise and in range
Settle at a signed quoteEd25519 → SPL TokenWhether the maker signed this quote for this taker, and it hasn't expired

What has been tested ​

  • Compiling and verifying. CI compiles every template (clients/js/src/protocol-examples.test.ts) and checks the result with the same verifier the Ballista program runs before it stores a template (common/src/template/verify.rs).
  • Reading the templates. A test reads each template and checks its calls: the program, the instruction and the accounts in the order the callee expects, which account each check reads, and the amount each call passes (clients/js/src/protocol-semantics.test.ts).
  • Rust. Each Rust template is byte-identical to the TypeScript one, and each Rust run passes the accounts, flags and inputs its template declares, right after any instructions it needs before it: Kamino's refreshes, or the Ed25519 instruction (clients/rust/tests/protocol_templates.rs).
  • Running against copies of the protocols. The eleven protocol templates run as signed transactions in LiteSVM, a local Solana runtime, against the protocols' programs and accounts copied from mainnet at a single slot. The tests never touch the network (tests/protocols/). The signed quote runs the same way against a copy of mainnet's Token program, with the Ed25519 precompile, and also in Mollusk, a harness that runs Solana programs without a validator (tests/ballista/). None has run on devnet or mainnet. Each page says what its runs showed.
  • Account offsets. The Orca, Pyth and SPL Token offsets are also checked against real devnet accounts by an opt-in test; see reading offsets. The Kamino templates don't read Kamino's accounts. They read SPL token accounts: their balances before and after each call and, where it matters, who owns them. The pump.fun templates read one pump.fun field, a curve's complete flag, and the tests read it from live and graduated mainnet curves.
  • Jupiter calls. The six templates that call Jupiter send its route instruction, with route's first accounts in the order Jupiter's published interface lists them and the rest as an account group. The semantics test checks this, and the protocol tests send real routes, recorded from Jupiter's API, through Jupiter's own program. Each template caps the route's platform fee at MAX_PLATFORM_FEE_BPS, 0 unless its author raises it. Getting a Jupiter route says how to request one.

Reading offsets from an account ​

Some templates read a protocol account's data at a fixed byte position, called an offset. Nothing at build time can check an offset. A wrong one doesn't cause an error: it reads a believable number from the wrong field.

pnpm test:devnet (clients/js/src/devnet-offsets.test.ts) reads real Orca, Pyth and SPL Token accounts on devnet and checks that the values at these offsets make sense: a plausible Unix timestamp, a price exponent between −18 and 0, tick bounds in the right order, and a token balance that matches the one the RPC reports. It needs no keypair and no SOL. It is opt-in, and CI does not run it.

Pyth's PriceUpdateV2 account needs extra care. Near the start it stores a verification level. Full takes one byte and Partial takes two, so in a Full account every later field sits one byte earlier. Offsets worked out from the struct's LEN constant match the Partial layout. The templates read the verification level first and require Full. That fixes the layout, and Full is also the stronger guarantee: the update carries all the required signatures, not just some.

LayoutFieldOffset
Pyth PriceUpdateV2, Fullverification_level u840
feed_id (32 bytes)41
price i6473
conf u6481
exponent i3289
publish_time i6493
pump.fun BondingCurvecomplete bool48
Orca Positionliquidity u12872
fee_owed_a u64112
fee_owed_b u64136
SPL Token accountmint0
owner32
amount u6464

Pyth, Orca and pump.fun are Anchor programs (Anchor is the most common Solana program framework). Anchor starts each account's data with an eight-byte discriminator, a tag that identifies the account type, and their offsets above count those eight bytes. A Token-2022 token account keeps its mint, owner and balance where an SPL Token account does. Anchor instructions start with a discriminator too. The templates compute discriminators instead of copying them: the first eight bytes of sha256("global:<handler>") for an instruction and of sha256("account:<Name>") for an account.

Check before you upload

The tests use a copy of mainnet from one slot. They can't tell you whether a protocol has changed since. Before you upload one of these templates:

  • check each call's accounts, arguments and offsets against the protocol's current IDL (its published interface description), and prefer fields the protocol documents as public;
  • check each account's type, not just its owner, by its Anchor discriminator or its exact data length. An owner pin is not a type pin: see pins.

Refreshing Kamino ​

Kamino's v2 deposit, repayment and liquidation work only against reserves and an obligation refreshed in the same slot, and no template refreshes them. A reserve is Kamino's pool for one token, and an obligation is a borrower's record of deposits and debts. Put the refreshes before the run, in the same transaction:

  1. refresh_reserve for each reserve the obligation holds, deposits then borrows, in the order the obligation lists them. Each names the reserve's Scope price account last. The main market prices by Scope alone, so the Pyth and Switchboard slots before it hold the Kamino program, which Kamino reads as "none".
  2. refresh_obligation, with the same reserves in the same order.

buildKaminoRefreshes and kamino_refreshes build both. Beside them, kaminoFarmPair and kamino_farm_pair fill a farm's two slots in a v2 instruction's farmAccounts, with the Kamino program standing in for a farm the reserve doesn't have.

The refresh helpers
ts
import { AccountRole, address, type Address, type Instruction } from '@solana/kit';

import type { KitAccountBinding } from '@jac0xb/ballista/kit';
import { KAMINO_FARMS, KAMINO_LEND, KAMINO_REFRESH_OBLIGATION, KAMINO_REFRESH_RESERVE } from '../shared.js';

/** A reserve, and the Scope price account its config names. */
export interface KaminoReserve {
  reserve: Address;
  scopePrices: Address;
}

/**
 * Kamino's refreshes, which go before the run in the same transaction. Kamino's v2 deposit,
 * repayment and liquidation check only that the obligation and the reserves they price were
 * refreshed in the current slot, not where.
 *
 * - `held` is every reserve the obligation holds, deposits in its deposit order and then borrows
 *   in its borrow order. The main market prices by Scope alone, so the Pyth and Switchboard slots
 *   take the Kamino program, which Kamino reads as "none".
 * - `touched` adds any other reserve the run needs fresh. A first deposit's needs none: Kamino's
 *   deposit refreshes its own reserve.
 * - `referrerTokenStates` is empty unless the obligation has a referrer; then Kamino expects one
 *   per borrow after the reserves.
 */
export function buildKaminoRefreshes(input: {
  lendingMarket: Address;
  obligation: Address;
  held: readonly KaminoReserve[];
  touched?: readonly KaminoReserve[];
  referrerTokenStates?: readonly Address[];
}): Instruction[] {
  const kamino = address(KAMINO_LEND);
  const none = { address: kamino, role: AccountRole.READONLY };
  const refreshed = new Set<Address>();
  const instructions: Instruction[] = [];
  for (const { reserve, scopePrices } of [...input.held, ...(input.touched ?? [])]) {
    if (refreshed.has(reserve)) continue;
    refreshed.add(reserve);
    instructions.push({
      programAddress: kamino,
      accounts: [
        { address: reserve, role: AccountRole.WRITABLE },
        { address: input.lendingMarket, role: AccountRole.READONLY },
        none, // Pyth
        none, // Switchboard price
        none, // Switchboard TWAP
        { address: scopePrices, role: AccountRole.READONLY },
      ],
      data: KAMINO_REFRESH_RESERVE,
    });
  }
  instructions.push({
    programAddress: kamino,
    accounts: [
      { address: input.lendingMarket, role: AccountRole.READONLY },
      { address: input.obligation, role: AccountRole.WRITABLE },
      ...input.held.map(({ reserve }) => ({ address: reserve, role: AccountRole.WRITABLE })),
      ...(input.referrerTokenStates ?? []).map((state) => ({ address: state, role: AccountRole.WRITABLE })),
    ],
    data: KAMINO_REFRESH_OBLIGATION,
  });
  return instructions;
}

/** A reserve's farm, as a Kamino v2 tail passes it. */
export interface KaminoFarm {
  /** The obligation's user state in the farm, which `init_obligation_farms_for_reserve` creates. */
  obligationFarmUserState: Address;
  reserveFarmState: Address;
}

/**
 * One farm in a Kamino v2 account tail, both writable. When the reserve has no such farm, Kamino
 * reads its own program, twice and read-only, as "none".
 */
export function kaminoFarmPair(farm: KaminoFarm | undefined): KitAccountBinding[] {
  return farm
    ? [
        { address: farm.obligationFarmUserState, writable: true },
        { address: farm.reserveFarmState, writable: true },
      ]
    : [{ address: address(KAMINO_LEND) }, { address: address(KAMINO_LEND) }];
}

/** The Farms program, which ends every Kamino v2 tail. */
export const KAMINO_FARMS_PROGRAM: KitAccountBinding = { address: address(KAMINO_FARMS) };
rs
/// Kamino's refreshes, which go before the run in the same transaction. Kamino's v2 deposit,
/// repayment and liquidation check only that the obligation and the reserves they price were
/// refreshed in the current slot, not where.
///
/// - `held` is every reserve the obligation holds, deposits in its deposit order and then borrows
///   in its borrow order, each with the Scope price account its config names. The main market
///   prices by Scope alone, so the Pyth and Switchboard slots take the Kamino program ID, which
///   Kamino reads as "none".
/// - `touched` adds any other reserve the run needs fresh. A first deposit's needs none: Kamino's
///   deposit refreshes its own reserve.
/// - `referrer_token_states` is empty unless the obligation has a referrer; then Kamino expects
///   one per borrow after the reserves.
pub fn kamino_refreshes(
    lending_market: Pubkey,
    obligation: Pubkey,
    held: &[(Pubkey, Pubkey)],
    touched: &[(Pubkey, Pubkey)],
    referrer_token_states: &[Pubkey],
) -> Vec<Instruction> {
    let none = AccountMeta::new_readonly(KAMINO_LEND, false);
    let mut refreshed: Vec<Pubkey> = Vec::new();
    let mut instructions = Vec::new();
    for &(reserve, scope_prices) in held.iter().chain(touched) {
        if refreshed.contains(&reserve) {
            continue;
        }
        refreshed.push(reserve);
        instructions.push(Instruction {
            program_id: KAMINO_LEND,
            accounts: vec![
                AccountMeta::new(reserve, false),
                AccountMeta::new_readonly(lending_market, false),
                none.clone(), // Pyth
                none.clone(), // Switchboard price
                none.clone(), // Switchboard TWAP
                AccountMeta::new_readonly(scope_prices, false),
            ],
            data: anchor_discriminator("refresh_reserve").to_vec(),
        });
    }
    let mut accounts = vec![
        AccountMeta::new_readonly(lending_market, false),
        AccountMeta::new(obligation, false),
    ];
    accounts.extend(
        held.iter()
            .map(|&(reserve, _)| AccountMeta::new(reserve, false)),
    );
    accounts.extend(
        referrer_token_states
            .iter()
            .map(|&state| AccountMeta::new(state, false)),
    );
    instructions.push(Instruction {
        program_id: KAMINO_LEND,
        accounts,
        data: anchor_discriminator("refresh_obligation").to_vec(),
    });
    instructions
}

/// One farm in a Kamino v2 account tail: `(the obligation's user state in the farm, the farm)`,
/// both writable. When the reserve has no such farm, Kamino reads its own program ID, twice and
/// read-only, as "none".
pub fn kamino_farm_pair(farm: Option<(Pubkey, Pubkey)>) -> [AccountMeta; 2] {
    match farm {
        Some((user_state, farm_state)) => [
            AccountMeta::new(user_state, false),
            AccountMeta::new(farm_state, false),
        ],
        None => [
            AccountMeta::new_readonly(KAMINO_LEND, false),
            AccountMeta::new_readonly(KAMINO_LEND, false),
        ],
    }
}

TypeScript helpers ​

The TypeScript templates and run files take their program addresses, account offsets, discriminators and Jupiter route helper (splitJupiterRoute) from shared.ts. The run files bind accounts with pinned and at:

The run files' bindings
ts
/** Program accounts the run files bind by address. */
import { address, type Address } from '@solana/kit';

import type { KitAccountBinding } from '@jac0xb/ballista/kit';

export const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
export const SYSTEM_PROGRAM = '11111111111111111111111111111111';

/** A binding for a program the template pins: the run must pass exactly this address. */
export function pinned(program: string): KitAccountBinding {
  return { address: address(program) };
}

/** A binding for an account the caller chooses. */
export function at(account: Address): KitAccountBinding {
  return { address: account };
}

Rust helpers ​

The Rust templates share these constants and helpers: the program addresses, account offsets and instruction discriminators, token_account() and token_2022_account() to declare an SPL Token or Token-2022 account, balance_of() to read its balance, and jupiter_route_data() and platform_fee_within_cap() for the Jupiter templates.

The Rust helpers
rs
// ======================================================================== shared constants

// ------------------------------------------------------------------------------- programs

/// Jupiter aggregator v6.
const JUPITER_V6: Pubkey = pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4");
/// Kamino Lend, the primary market program.
const KAMINO_LEND: Pubkey = pubkey!("KLend2g3cP87fffoy8q1mQqGKjrxjC8boSyAYavgmjD");
/// Orca Whirlpools.
const ORCA_WHIRLPOOL: Pubkey = pubkey!("whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc");
/// Pyth Solana receiver, the non-`pro-compatible` build.
const PYTH_RECEIVER: Pubkey = pubkey!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ");
/// SPL Memo. Orca's v2 instructions take it.
const MEMO_PROGRAM: Pubkey = pubkey!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr");
/// pump.fun's bonding-curve program. Not PumpSwap, the AMM a coin moves to when it graduates.
const PUMP_FUN: Pubkey = pubkey!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P");
/// Pump Fees, which pump.fun invokes on every trade for its fee rates.
const PUMP_FEES: Pubkey = pubkey!("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ");
/// The wrapped SOL mint. Its token accounts count their balance in lamports.
const WRAPPED_SOL_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112");
/// Circle's USDC mint.
const USDC_MINT: Pubkey = pubkey!("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

// -------------------------------------------------------------------------------- layouts

/// SPL Token account: the mint, then the owner, then the `u64` amount, in 165 bytes.
const TOKEN_ACCOUNT_MINT_OFFSET: u32 = 0;
const TOKEN_ACCOUNT_OWNER_OFFSET: u32 = 32;
const TOKEN_ACCOUNT_AMOUNT_OFFSET: u32 = 64;
const TOKEN_ACCOUNT_LENGTH: u32 = 165;

/// SPL Token `Mint`: `decimals` is the `u8` at offset 44 of the 82-byte layout.
const SPL_MINT_LENGTH: u32 = 82;
const SPL_MINT_DECIMALS: u32 = 44;

/// Pyth `PriceUpdateV2`, at the offsets of a `Full`-verification account. Read
/// `verificationLevel` first: a `Partial` account shifts every later field by one byte.
const PYTH_LENGTH: u32 = 134;
const PYTH_VERIFICATION_LEVEL: u32 = 40;
const PYTH_VERIFICATION_LEVEL_FULL: u64 = 1;
const PYTH_FEED_ID: u32 = 41;
const PYTH_PRICE: u32 = 73;
const PYTH_CONFIDENCE: u32 = 81;
const PYTH_EXPONENT: u32 = 89;
const PYTH_PUBLISH_TIME: u32 = 93;

/// Orca `Position`: liquidity, then the fees owed in each token.
const ORCA_POSITION_LENGTH: u32 = 216;
const ORCA_POSITION_LIQUIDITY: u32 = 72;
const ORCA_POSITION_FEE_OWED_A: u32 = 112;
const ORCA_POSITION_FEE_OWED_B: u32 = 136;

/// pump.fun `BondingCurve`: `complete` follows the discriminator and five `u64`s. A curve holds
/// at least the bytes through it; later upgrades appended fields.
const PUMP_CURVE_MIN_LENGTH: u32 = 49;
const PUMP_CURVE_COMPLETE: u32 = 48;

// --------------------------------------------------------------------------- instructions

/// Jupiter v6 `route(route_plan, in_amount, quoted_out_amount, slippage_bps, platform_fee_bps)`.
fn jupiter_route() -> [u8; 8] {
    anchor_discriminator("route")
}

/// Kamino `deposit_reserve_liquidity_and_obligation_collateral_v2(liquidity_amount: u64)`.
fn kamino_deposit() -> [u8; 8] {
    anchor_discriminator("deposit_reserve_liquidity_and_obligation_collateral_v2")
}

/// Kamino `repay_obligation_liquidity_v2(liquidity_amount: u64)`.
fn kamino_repay() -> [u8; 8] {
    anchor_discriminator("repay_obligation_liquidity_v2")
}

/// Kamino `liquidate_obligation_and_redeem_reserve_collateral_v2(liquidity_amount,
/// min_acceptable_received_liquidity_amount, max_allowed_ltv_override_percent)`.
fn kamino_liquidate() -> [u8; 8] {
    anchor_discriminator("liquidate_obligation_and_redeem_reserve_collateral_v2")
}

/// Orca `collect_fees()`.
fn orca_collect_fees() -> [u8; 8] {
    anchor_discriminator("collect_fees")
}

/// Orca `update_fees_and_rewards()`: folds the pool's fee growth into a position's fees owed.
fn orca_update_fees_and_rewards() -> [u8; 8] {
    anchor_discriminator("update_fees_and_rewards")
}

/// Orca `increase_liquidity_by_token_amounts_v2(method, remaining_accounts_info)`.
fn orca_increase_liquidity_by_token_amounts_v2() -> [u8; 8] {
    anchor_discriminator("increase_liquidity_by_token_amounts_v2")
}

/// `IncreaseLiquidityMethod::ByTokenAmounts`, the enum's only variant, as its Borsh tag.
const ORCA_BY_TOKEN_AMOUNTS: [u8; 1] = [0];

/// Borsh `Option::None`.
const OPTION_NONE: [u8; 1] = [0];

/// pump.fun `buy(amount, max_sol_cost, track_volume)`: 16 named accounts, then the coin's
/// `bonding-curve-v2` PDA and a buyback fee recipient.
fn pump_fun_buy() -> [u8; 8] {
    anchor_discriminator("buy")
}

/// pump.fun `sell(amount, min_sol_output)`: 14 named accounts, then the same two.
fn pump_fun_sell() -> [u8; 8] {
    anchor_discriminator("sell")
}

/// pump.fun's `OptionBool(true)`: `buy` records the volume for its trading rewards.
const PUMP_TRACK_VOLUME: [u8; 1] = [1];

/// The route's platform fee account and rate are chosen by whoever builds the run: cap the rate.
const MAX_PLATFORM_FEE_BPS: u64 = 0;

/// A token account of the SPL Token program, read as data.
fn token_account() -> Account {
    account::writable()
        .owner(TOKEN_PROGRAM_ID)
        .min_data_length(TOKEN_ACCOUNT_LENGTH)
}

/// A token account of Token-2022, read as data. pump.fun mints its coins with it.
fn token_2022_account() -> Account {
    account::writable()
        .owner(TOKEN_2022_PROGRAM_ID)
        .min_data_length(TOKEN_ACCOUNT_LENGTH)
}

/// The `u64` balance of the token account `name`.
fn balance_of(name: &str) -> Expr {
    account_data(name, TOKEN_ACCOUNT_AMOUNT_OFFSET, ReadType::U64)
}

/// The fee account sits in the route's own accounts: any nonzero rate pays whoever chose it.
fn platform_fee_within_cap() -> Step {
    step::require(input("platformFeeBps").lte(u64(MAX_PLATFORM_FEE_BPS)))
        .label("platformFeeWithinCap")
}

/// Jupiter `route` data: the discriminator, the plan as the Swap API encoded it, then the tail.
fn jupiter_route_data(in_amount: Expr, quoted_out_amount: Expr) -> [DataPart; 6] {
    [
        data::literal(jupiter_route()),
        data::bytes(input("routePlan")),
        data::u64(in_amount),
        data::u64(quoted_out_amount),
        data::u16(input("slippageBps")),
        data::u8(input("platformFeeBps")),
    ]
}

Running them ​

You upload a template once. After that, each use is a single run instruction, which a bot or service can build in TypeScript or Rust. Building a run doesn't need the template's bytecode, only the names of its declared accounts and inputs, and, for a template that takes one, the accounts in each account group or batch row.

Each page shows the template and a run of it, in TypeScript and in Rust.

  • TypeScript runs are in clients/js/examples/protocols/run/. They bind accounts by name, and buildKitRunInstruction puts them in the template's order.
  • Rust templates are in clients/rust/examples/protocol_templates.rs, written with the declarative ballista_sdk::template API. Each compiles to the same bytes as the TypeScript one.
  • Rust runs are in clients/rust/examples/protocol_templates_run.rs. They also name every input and account, and the run builder puts them in the template's order.

Getting a Jupiter route ​

Six templates call Jupiter's route instruction, and each takes the route from Jupiter's Swap API:

  1. Quote with swapMode=ExactIn and instructionVersion=V1. ExactOut returns exact_out_route instead, and V2 returns route_v2.
  2. Ask /swap-instructions for that quote with useSharedAccounts: false. Without it, Jupiter may return shared_accounts_route, whose accounts are in another order. The response's swapInstruction is the route call: its data is the route data, and its accounts are the route's. The tests' routes were fetched this way, by scripts/snapshot/jupiter.mjs.
  3. Split the data with splitJupiterRoute (TypeScript) or RouteQuote::split (Rust) into routePlan and the four numbers after it. Both refuse data that isn't route.
  4. Drop the accounts the template passes itself: the first four, or three for the daily cap and two for the price gate. The rest are the run's account group.
  5. Keep the rest of the response. Put the setup instructions before the run and the cleanup after it: they create token accounts and wrap and unwrap SOL. Compile the transaction with the lookup tables in addressLookupTableAddresses; most routes don't fit without them.
  6. Set your own compute limit. The response's is either the 1,400,000 maximum or, with dynamicComputeUnitLimit, a limit fitted to route alone, which the run around it exceeds.

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS