Skip to content

Accounts and CPIs ​

A template declares each account it uses, with what it must be: a signer, writable, a program, or pinned by address, owner and minDataLength. A run rejects any account that doesn't match, a CPI can't pass an account with more privilege than its declaration gives, and Ballista never signs a template's calls; see Privileges.

Protocol helper ​

This template moves tokens between two token accounts. Its accounts section shows each kind of requirement: a pinned program, a signer, and two writable token accounts pinned by owner and size.

ts
import { TOKEN_PROGRAM_ADDRESS_BYTES, account, defineTemplate, expression, tokenTransfer } from '@jac0xb/ballista';

/** One SPL Token transfer: the Token Program pinned, both token accounts pinned by owner and size. */
export const tokenTransferTemplate = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    authority: { signer: true },
    source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
    destination: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
  },
  steps: [
    tokenTransfer({
      tokenProgram: account.fixed('tokenProgram'),
      source: account.fixed('source'),
      destination: account.fixed('destination'),
      authority: account.fixed('authority'),
      amount: expression.input('amount'),
    }),
  ],
});
ts
import { address, type Address } from '@solana/kit';

import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

export function runTokenTransfer(run: {
  templateAddress: Address;
  authority: Address;
  source: Address;
  destination: Address;
  amount: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(tokenTransferTemplate),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      tokenProgram: { address: TOKEN_PROGRAM },
      authority: { address: run.authority },
      source: { address: run.source },
      destination: { address: run.destination },
    },
  });
}
rs
/// One SPL Token transfer: the Token Program pinned, both token accounts pinned by owner and size.
pub fn token_transfer_template() -> Template {
    Template::new()
        .input("amount", Type::U64)
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("authority", account::signer())
        .account(
            "source",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .account(
            "destination",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .step(token_transfer(
            "tokenProgram",
            "source",
            "destination",
            "authority",
            input("amount"),
        ))
}
rs
pub fn token_transfer(
    template: Pubkey,
    authority: Pubkey,
    source: Pubkey,
    destination: Pubkey,
    amount: u64,
) -> RunResult {
    let instruction = templates::token_transfer_template()
        .compile()?
        .run(template)
        .input("amount", amount)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("authority", authority)
        .account("source", source)
        .account("destination", destination)
        .instruction()?;
    Ok(instruction)
}

tokenTransfer is a shortcut, not a special instruction in the Ballista program. It compiles to an ordinary CPI: the Token Program's accounts, the byte that selects its Transfer instruction, and the amount encoded as a u64. Rust's token_transfer compiles to the same bytes.

Generic CPI ​

step.invoke builds any CPI from parts: the program to call, the accounts with the privileges to pass, and the instruction data as a list of pieces, either literal bytes or encoded values. The optional when condition is covered below.

ts
import { account, data, defineTemplate, expression, step } from '@jac0xb/ballista';

// Placeholders: replace them with your program's address and its instruction discriminator.
const MY_PROGRAM = new Uint8Array(32).fill(7);
const MY_DISCRIMINATOR = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8);

/** A CPI built from parts: literal bytes, an encoded `u64`, and caller bytes, only when `enabled`. */
export const genericCpi = defineTemplate({
  inputs: {
    amount: { type: 'u64' },
    clientPayload: { type: 'bytes', maxLength: 128 },
    enabled: { type: 'bool' },
  },
  accounts: {
    program: { executable: true, address: MY_PROGRAM },
    vault: { writable: true },
    authority: { signer: true },
  },
  steps: [
    step.invoke({
      program: account.fixed('program'),
      accounts: [
        { account: account.fixed('vault'), writable: true, signer: false },
        { account: account.fixed('authority'), writable: false, signer: true },
      ],
      data: [
        data.literal(MY_DISCRIMINATOR),
        data.encode('u64', expression.input('amount')),
        data.encode('bytes', expression.input('clientPayload')),
      ],
      when: expression.input('enabled'),
    }),
  ],
});
ts
import { getAddressDecoder, type Address } from '@solana/kit';

import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';

const MY_PROGRAM_ADDRESS = getAddressDecoder().decode(new Uint8Array(32).fill(7)); // the placeholder

export function runGenericCpi(run: {
  templateAddress: Address;
  vault: Address;
  authority: Address;
  amount: bigint;
  clientPayload: Uint8Array;
  enabled: boolean;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(genericCpi),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount, clientPayload: run.clientPayload, enabled: run.enabled },
    accounts: {
      program: { address: MY_PROGRAM_ADDRESS },
      vault: { address: run.vault },
      authority: { address: run.authority },
    },
  });
}
rs
/// A CPI built from parts: literal bytes, an encoded `u64`, and caller bytes, only when `enabled`.
pub fn generic_cpi() -> Template {
    // Placeholders: replace them with your program's address and its instruction discriminator.
    const MY_PROGRAM: [u8; 32] = [7; 32];
    const MY_DISCRIMINATOR: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8];

    Template::new()
        .input("amount", Type::U64)
        .input("clientPayload", Type::Bytes(128))
        .input("enabled", Type::Bool)
        .account("program", account::program(MY_PROGRAM))
        .account("vault", account::writable())
        .account("authority", account::signer())
        .step(
            step::invoke("program")
                .writable("vault")
                .signer("authority")
                .data(data::literal(MY_DISCRIMINATOR))
                .data(data::u64(input("amount")))
                .data(data::bytes(input("clientPayload")))
                .when(input("enabled")),
        )
}
rs
pub fn generic_cpi(
    template: Pubkey,
    vault: Pubkey,
    authority: Pubkey,
    amount: u64,
    client_payload: &[u8],
    enabled: bool,
) -> RunResult {
    const MY_PROGRAM: Pubkey = Pubkey::new_from_array([7; 32]); // the template's placeholder

    let instruction = templates::generic_cpi()
        .compile()?
        .run(template)
        .input("amount", amount)
        .input("clientPayload", client_payload)
        .input("enabled", enabled)
        .account("program", MY_PROGRAM)
        .account("vault", vault)
        .account("authority", authority)
        .instruction()?;
    Ok(instruction)
}

MY_PROGRAM and MY_DISCRIMINATOR are placeholders for your program's address and instruction. A CPI's data can be at most 4,096 bytes. Finalization works out the largest size each CPI's data can reach, here 8 + 8 + 128 bytes, and rejects a template that could exceed the limit.

Account groups ​

A CPI's account list is normally fixed when the template is written, one declared account in each position, which lets finalization check every account the template touches. Some programs need accounts the author can't know in advance, such as the pools along a swap route. An account group removes that limit: the template declares the group by name, the caller supplies its members at run time, and a CPI passes them after its own declared accounts, the way Solana programs pass "remaining accounts".

What a group is ​

  • A template declares up to eight groups by name: accountGroups: ['routeA', 'routeB'].
  • At run time the caller passes each group's members after the fixed accounts and batch rows, and the run data starts with one byte per group giving its size. A group may be empty.
  • An invoke names the group it forwards with accountGroup. The CPI receives the invoke's declared accounts first, then every member of the group, in the order the caller supplied them.
  • One CPI can pass at most 64 accounts, counting its declared accounts and the group's members. A run that goes over fails with CpiAccountLimitExceeded (error 6021), and the error reports the total.

What a group is not ​

Members have no requirements, and a template can't read a member's data freely. It can count the members and test each one against a filter it pins: the owner program, a data length, and bytes at fixed offsets (see Checking what a group holds). Anything else the template must check about the result, such as a change in balance, has to be read from declared accounts. That is why a swap's user token accounts belong among the declared accounts, not in the group.

Each member is passed as writable if the transaction marked it writable, but never as a signer. So a template can pass on a signature only through an account its author declared. The extra accounts a swap route needs are pools and vaults, which never sign anyway.

A group belongs to a CPI, not to a batch row: an invoke inside forEach forwards the same group on every row.

Checking what a group holds ​

A swap call carries the user's signature, and the group carries whatever accounts the route names. A route that slips in another of the user's token accounts could spend from it. The template below refuses such a route before it swaps.

ts
import {
  TOKEN_2022_PROGRAM_ADDRESS_BYTES,
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  data,
  defineTemplate,
  expression,
  step,
} from '@jac0xb/ballista';
import { JUPITER_V6, addressBytes } from '../protocols/shared.js';

/** One swap over a caller-supplied route that may hold none of the user's other token accounts. */
export const swapThroughACheckedRoute = defineTemplate({
  inputs: { route: { type: 'bytes', maxLength: 256 } },
  accounts: {
    jupiter: { executable: true, address: addressBytes(JUPITER_V6) },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    user: { signer: true },
    source: { writable: true },
    destination: { writable: true },
  },
  accountGroups: ['amm'],
  steps: [
    step.require(
      expression.not(
        expression.groupAny('amm', {
          // A Token or Token-2022 account whose owner, the pubkey at byte 32, is the user...
          programs: [TOKEN_PROGRAM_ADDRESS_BYTES, TOKEN_2022_PROGRAM_ADDRESS_BYTES],
          match: [{ offset: 32, equals: expression.accountKey('user') }],
          // ...other than the two this swap is meant to touch.
          exceptKeys: [expression.accountKey('source'), expression.accountKey('destination')],
        }),
      ),
      'noOtherUserTokenAccount',
    ),
    step.invoke({
      program: account.fixed('jupiter'),
      accounts: [
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('user'), signer: true, writable: false },
        { account: account.fixed('source'), signer: false, writable: true },
        { account: account.fixed('destination'), signer: false, writable: true },
      ],
      accountGroup: 'amm',
      data: [data.encode('bytes', expression.input('route'))],
    }),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

export function runSwapThroughACheckedRoute(run: {
  templateAddress: Address;
  user: Address;
  source: Address;
  destination: Address;
  /** The quote's route data and its pool accounts, `{ address, writable }` as the quote lists them. */
  route: Uint8Array;
  pools: readonly KitAccountBinding[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(swapThroughACheckedRoute),
    templateAddress: run.templateAddress,
    accounts: {
      jupiter: { address: address(JUPITER_V6) },
      tokenProgram: { address: TOKEN_PROGRAM },
      user: { address: run.user },
      source: { address: run.source },
      destination: { address: run.destination },
    },
    inputs: { route: run.route },
    accountGroups: { amm: run.pools },
  });
}
rs
/// One swap over a caller-supplied route that may hold none of the user's other token accounts.
pub fn swap_through_a_checked_route() -> Template {
    const JUPITER_V6: Pubkey =
        Pubkey::from_str_const("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4");

    Template::new()
        .input("route", Type::Bytes(256))
        .account("jupiter", account::program(JUPITER_V6))
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("user", account::signer())
        .account("source", account::writable())
        .account("destination", account::writable())
        .account_group("amm")
        .step(
            step::require(
                group_any(
                    "amm",
                    // A Token or Token-2022 account whose owner, the pubkey at byte 32, is the
                    // user...
                    GroupFilter::new()
                        .program(TOKEN_PROGRAM_ID)
                        .program(TOKEN_2022_PROGRAM_ID)
                        .equals(32, key("user"))
                        // ...other than the two this swap is meant to touch.
                        .except_key(key("source"))
                        .except_key(key("destination")),
                )
                .not(),
            )
            .label("noOtherUserTokenAccount"),
        )
        .step(
            step::invoke("jupiter")
                .readonly("tokenProgram")
                .signer("user")
                .writable("source")
                .writable("destination")
                .account_group("amm")
                .data(data::bytes(input("route"))),
        )
}
rs
/// `route` and `pools` are the quote's route data and pool accounts, each pool writable or not as
/// the quote lists it.
pub fn swap_through_a_checked_route(
    template: Pubkey,
    user: Pubkey,
    source: Pubkey,
    destination: Pubkey,
    route: Vec<u8>,
    pools: Vec<AccountMeta>,
) -> RunResult {
    const JUPITER_V6: Pubkey = pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4");

    let instruction = templates::swap_through_a_checked_route()
        .compile()?
        .run(template)
        .account("jupiter", JUPITER_V6)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("user", user)
        .account("source", source)
        .account("destination", destination)
        .input("route", route)
        .group("amm", pools)
        .instruction()?;
    Ok(instruction)
}
  • groupAny(group, filter) is true when any member matches. groupCount returns how many match, and groupLength how many members the caller passed.
  • A member matches when its owner is one of programs, its data holds each match value at its offset, and its address is none of exceptKeys.
  • Here the filter finds a Token or Token-2022 account whose owner, at byte 32, is the user. source and destination are excepted, so the swap can still use them.
  • A route that breaks the check fails with RequirementFailed (6015) at noOtherUserTokenAccount, and nothing moves.
  • To require that something is there instead, require groupAny: for example, a token account of the user's that the route pays into.

The programs are pinned in the template, so a reader sees which accounts it checks. Account groups has the rules.

Choosing between swaps at run time ​

The template below records three token balances, decides which of three swaps are needed, and runs only those. Each swap has its own route data and its own group, so one finalized template works for any route over any tokens. When a balance already meets its target, the template skips that swap, and the caller passes an empty group and empty route data for it.

ts
import {
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  data,
  defineTemplate,
  expression,
  step,
  type Step,
} from '@jac0xb/ballista';
import { JUPITER_V6, addressBytes } from '../protocols/shared.js';

// SPL Token account layout: the owner is the pubkey at byte 32, the amount the u64 at byte 64.
const OWNER_OFFSET = 32;
const AMOUNT_OFFSET = 64;
// No fixed mint: the caller decides which tokens are involved, and the checks decide whether the
// result is acceptable. The owner and size pins let the template read the accounts' data.
const tokenAccount = { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 } as const;

/** The five steps of one optional swap: leg `A` uses sourceA, destinationA, routeA and group ammA. */
function swapLeg(leg: 'A' | 'B' | 'C'): Step[] {
  const destination = account.fixed(`destination${leg}`);
  const needed = expression.variable(`need${leg}`);
  return [
    step.snapshot(`balance${leg}`, expression.accountData(destination, AMOUNT_OFFSET, 'u64')),
    step.let(`need${leg}`, expression.lessThan(expression.snapshot(`balance${leg}`), expression.input(`target${leg}`))),
    step.require(
      expression.equal(
        expression.accountData(destination, OWNER_OFFSET, 'pubkey'),
        expression.accountField(account.fixed('user'), 'key'),
      ),
    ),
    step.invoke({
      program: account.fixed('jupiter'),
      accounts: [
        // Jupiter's leading accounts, in its order; the quote's pools follow as the group.
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('user'), signer: true, writable: false },
        { account: account.fixed(`source${leg}`), signer: false, writable: true },
        { account: destination, signer: false, writable: true },
      ],
      accountGroup: `amm${leg}`,
      data: [data.encode('bytes', expression.input(`route${leg}`))],
      when: needed,
    }),
    // Skipped, or the balance rose by at least the minimum.
    step.require(
      expression.or(
        expression.not(needed),
        expression.greaterThanOrEqual(
          expression.subtract(
            expression.accountData(destination, AMOUNT_OFFSET, 'u64'),
            expression.snapshot(`balance${leg}`),
          ),
          expression.input(`minOut${leg}`),
        ),
      ),
    ),
  ];
}

/** Three optional swaps, each forwarding its own account group, each checked afterwards. */
export const rebalanceThreeSwaps = defineTemplate({
  accounts: {
    jupiter: { executable: true, address: addressBytes(JUPITER_V6) },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    user: { signer: true, writable: true },
    sourceA: tokenAccount,
    destinationA: tokenAccount,
    sourceB: tokenAccount,
    destinationB: tokenAccount,
    sourceC: tokenAccount,
    destinationC: tokenAccount,
  },
  inputs: {
    routeA: { type: 'bytes', maxLength: 256 },
    routeB: { type: 'bytes', maxLength: 256 },
    routeC: { type: 'bytes', maxLength: 256 },
    targetA: { type: 'u64' },
    targetB: { type: 'u64' },
    targetC: { type: 'u64' },
    minOutA: { type: 'u64' },
    minOutB: { type: 'u64' },
    minOutC: { type: 'u64' },
  },
  accountGroups: ['ammA', 'ammB', 'ammC'],
  steps: [...swapLeg('A'), ...swapLeg('B'), ...swapLeg('C')],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

/**
 * One swap: its token accounts, its quote's route data and pool accounts, and its limits. A swap
 * that is not needed passes empty route data and no pools.
 */
export interface SwapLeg {
  source: Address;
  destination: Address;
  route: Uint8Array;
  pools: readonly KitAccountBinding[];
  target: bigint;
  minOut: bigint;
}

export function runRebalanceThreeSwaps(run: {
  templateAddress: Address;
  user: Address;
  legs: readonly [SwapLeg, SwapLeg, SwapLeg];
}) {
  const [a, b, c] = run.legs;
  return buildKitRunInstruction({
    compiled: compileTemplate(rebalanceThreeSwaps),
    templateAddress: run.templateAddress,
    accounts: {
      jupiter: { address: address(JUPITER_V6) },
      tokenProgram: { address: TOKEN_PROGRAM },
      user: { address: run.user },
      sourceA: { address: a.source },
      destinationA: { address: a.destination },
      sourceB: { address: b.source },
      destinationB: { address: b.destination },
      sourceC: { address: c.source },
      destinationC: { address: c.destination },
    },
    inputs: {
      routeA: a.route,
      routeB: b.route,
      routeC: c.route,
      targetA: a.target,
      targetB: b.target,
      targetC: c.target,
      minOutA: a.minOut,
      minOutB: b.minOut,
      minOutC: c.minOut,
    },
    // Each member is `{ address, writable }`, as the quote lists it. Members never sign.
    accountGroups: { ammA: a.pools, ammB: b.pools, ammC: c.pools },
  });
}
rs
// SPL Token account layout: the owner is the pubkey at byte 32, the amount the u64 at byte 64.
const OWNER_OFFSET: u32 = 32;
const AMOUNT_OFFSET: u32 = 64;

/// The five steps of one optional swap: leg `A` uses sourceA, destinationA, routeA and group ammA.
fn swap_leg(leg: &str) -> Vec<Step> {
    let destination = format!("destination{leg}");
    let needed = var(format!("need{leg}"));
    vec![
        step::snapshot(
            format!("balance{leg}"),
            account_data(&destination, AMOUNT_OFFSET, ReadType::U64),
        ),
        step::let_(
            format!("need{leg}"),
            snapshot(format!("balance{leg}")).lt(input(format!("target{leg}"))),
        ),
        step::require(account_data(&destination, OWNER_OFFSET, ReadType::Pubkey).eq(key("user"))),
        step::invoke("jupiter")
            // Jupiter's leading accounts, in its order; the quote's pools follow as the group.
            .readonly("tokenProgram")
            .signer("user")
            .writable(format!("source{leg}"))
            .writable(&destination)
            .account_group(format!("amm{leg}"))
            .data(data::bytes(input(format!("route{leg}"))))
            .when(needed.clone())
            .into(),
        // Skipped, or the balance rose by at least the minimum.
        step::require(
            needed
                .not()
                .or((account_data(&destination, AMOUNT_OFFSET, ReadType::U64)
                    - snapshot(format!("balance{leg}")))
                .gte(input(format!("minOut{leg}")))),
        ),
    ]
}

/// Three optional swaps, each forwarding its own account group, each checked afterwards.
pub fn rebalance_three_swaps() -> Template {
    const JUPITER_V6: Pubkey =
        Pubkey::from_str_const("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4");
    // No fixed mint: the caller decides which tokens are involved, and the checks decide whether
    // the result is acceptable. The owner and size pins let the template read the accounts' data.
    let token_account = account::writable()
        .owner(TOKEN_PROGRAM_ID)
        .min_data_length(165);

    Template::new()
        .account("jupiter", account::program(JUPITER_V6))
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("user", account::signer().writable())
        .account("sourceA", token_account.clone())
        .account("destinationA", token_account.clone())
        .account("sourceB", token_account.clone())
        .account("destinationB", token_account.clone())
        .account("sourceC", token_account.clone())
        .account("destinationC", token_account)
        .input("routeA", Type::Bytes(256))
        .input("routeB", Type::Bytes(256))
        .input("routeC", Type::Bytes(256))
        .input("targetA", Type::U64)
        .input("targetB", Type::U64)
        .input("targetC", Type::U64)
        .input("minOutA", Type::U64)
        .input("minOutB", Type::U64)
        .input("minOutC", Type::U64)
        .account_group("ammA")
        .account_group("ammB")
        .account_group("ammC")
        .steps(swap_leg("A"))
        .steps(swap_leg("B"))
        .steps(swap_leg("C"))
}
rs
/// One swap: its token accounts, its quote's route data and pool accounts, and its limits. A
/// swap that is not needed passes empty route data and no pools.
pub struct SwapLeg {
    pub source: Pubkey,
    pub destination: Pubkey,
    pub route: Vec<u8>,
    /// Writable or not as the quote lists each one. Members never sign.
    pub pools: Vec<AccountMeta>,
    pub target: u64,
    pub min_out: u64,
}

pub fn rebalance_three_swaps(template: Pubkey, user: Pubkey, legs: &[SwapLeg; 3]) -> RunResult {
    const JUPITER_V6: Pubkey = pubkey!("JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4");

    let [a, b, c] = legs;
    let instruction = templates::rebalance_three_swaps()
        .compile()?
        .run(template)
        .account("jupiter", JUPITER_V6)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("user", user)
        .account("sourceA", a.source)
        .account("destinationA", a.destination)
        .account("sourceB", b.source)
        .account("destinationB", b.destination)
        .account("sourceC", c.source)
        .account("destinationC", c.destination)
        .input("routeA", &a.route)
        .input("routeB", &b.route)
        .input("routeC", &c.route)
        .input("targetA", a.target)
        .input("targetB", b.target)
        .input("targetC", c.target)
        .input("minOutA", a.min_out)
        .input("minOutB", b.min_out)
        .input("minOutC", c.min_out)
        .group("ammA", a.pools.clone())
        .group("ammB", b.pools.clone())
        .group("ammC", c.pools.clone())
        .instruction()?;
    Ok(instruction)
}

For each swap, the template:

  1. records the destination account's token balance;
  2. decides whether the swap is needed, which is when that balance is below its target;
  3. requires that the destination account belongs to the user;
  4. runs the swap only if it is needed and, if it ran, requires that the balance rose by at least the minimum.

The TypeScript writes these steps once, in swapLeg, and repeats them for A, B and C; the Rust loops over the three legs.

To skip a swap, the caller passes an empty group and empty route data for it. In the run data, the three group lengths come first, one byte each, then the inputs in declaration order. In the accounts, each group's members follow the declared accounts, group A's first.

The routes come from off-chain quotes; a template cannot find a route on chain. What it can do is fail the transaction unless the result passes the checks its author wrote.

Budget ​

A transaction uses at most 64 accounts, lookup tables included. The Ballista program and the template account take two, and user can pay the fee, so the run has 62. Nine are fixed, which leaves the three groups 53 between them, about 17 each: enough for short routes, so cap each quote's accounts (Jupiter's maxAccounts). A version 1 transaction lists all 64 addresses in its 4,096 bytes; a version 0 transaction needs an address lookup table to fit them in 1,232. The run data has its own limit of 1,024 bytes, which is why this template caps each route at 256 bytes. See accounts per transaction.

Conditional invocation ​

when makes a single CPI optional. Its condition is evaluated when the run reaches that step. If the condition is false, the call is skipped and the run continues with the next step. A template that emits a run event records which calls actually ran.

Compare step.require, which fails the whole transaction when its condition is false. Use when for work that is sometimes unnecessary, such as creating an account that may already exist. Use step.require for a condition whose failure means something is wrong.

expression.returnData reads the data a called program returns, but not from a call with a when condition, because a skipped call returns nothing.

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS