Skip to content

Loops over rows and counts ​

In these loops each row decides what to do from what it reads during the run: pay creditors in order, collect only funded token accounts, process only due entries, split by weight. The last example repeats a call a number of times read during the run.

Row loops run over a batch: step.forEach runs once per row, with account.iteration('name') as the row's account and expression.rowInput('name') as its value. The caller fixes how many rows run; each row can still act on what earlier rows spent, and when skips one call. Calls to other protocols use marked stand-ins.

Waterfall until the money runs out ​

Pay creditors from a treasury in priority order, the first row first. Each payment is capped by what is left, and what is left is known only when the transaction executes.

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

/** Pay creditors in row order, each the smaller of what it is owed and what is left. */
export const waterfallUntilTheMoneyRunsOut = defineTemplate({
  inputs: { reserve: { type: 'u64' } },
  accounts: {
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    treasury: { signer: true, writable: true },
  },
  batch: {
    maxIterations: 8,
    minIterations: 1,
    row: { creditor: { writable: true } },
    rowInputs: { owed: { type: 'u64' } },
  },
  steps: [
    step.let(
      'remaining',
      expression.subtract(expression.accountField(account.fixed('treasury'), 'lamports'), expression.input('reserve')),
    ),
    step.forEach(
      [
        step.let('pay', expression.min(expression.variable('remaining'), expression.rowInput('owed'))),
        systemTransfer({
          systemProgram: account.fixed('systemProgram'),
          from: account.fixed('treasury'),
          to: account.iteration('creditor'),
          lamports: expression.variable('pay'),
          when: expression.greaterThan(expression.variable('pay'), expression.u64(0)),
        }),
        step.assign('remaining', expression.subtract(expression.variable('remaining'), expression.variable('pay'))),
      ],
      { carry: ['remaining'] },
    ),
  ],
});
ts
import type { Address } from '@solana/kit';

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

/** `creditors` in priority order, each with what it is owed. */
export function runWaterfallUntilTheMoneyRunsOut(run: {
  templateAddress: Address;
  treasury: Address;
  reserve: bigint;
  creditors: readonly { address: Address; owed: bigint }[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(waterfallUntilTheMoneyRunsOut),
    templateAddress: run.templateAddress,
    inputs: { reserve: run.reserve },
    accounts: {
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      treasury: { address: run.treasury },
    },
    // One row per creditor, and one set of row inputs per row, in the same order.
    batchRows: run.creditors.map((creditor) => ({ creditor: { address: creditor.address } })),
    batchInputs: run.creditors.map((creditor) => ({ owed: creditor.owed })),
  });
}
rs
/// Pay creditors in row order, each the smaller of what it is owed and what is left.
pub fn waterfall_until_the_money_runs_out() -> Template {
    Template::new()
        .input("reserve", Type::U64)
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account("treasury", account::signer().writable())
        .batch(
            Batch::new(8)
                .min_iterations(1)
                .account("creditor", account::writable())
                .input("owed", Type::U64),
        )
        .step(step::let_(
            "remaining",
            lamports("treasury") - input("reserve"),
        ))
        .step(
            step::for_each()
                .step(step::let_("pay", var("remaining").min(row_input("owed"))))
                .step(
                    system_transfer(
                        "systemProgram",
                        "treasury",
                        account::iteration("creditor"),
                        var("pay"),
                    )
                    .when(var("pay").gt(u64(0))),
                )
                .step(step::assign("remaining", var("remaining") - var("pay")))
                .carry("remaining"),
        )
}
rs
/// `creditors` in priority order, each with what it is owed.
pub fn waterfall_until_the_money_runs_out(
    template: Pubkey,
    treasury: Pubkey,
    reserve: u64,
    creditors: &[(Pubkey, u64)],
) -> RunResult {
    let instruction = templates::waterfall_until_the_money_runs_out()
        .compile()?
        .run(template)
        .input("reserve", reserve)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("treasury", treasury)
        // One row per creditor: its account and what it is owed.
        .rows(creditors.iter().map(|(creditor, owed)| {
            Row::new()
                .account("creditor", *creditor)
                .input("owed", *owed)
        }))
        .instruction()?;
    Ok(instruction)
}

Each row is one creditor account, writable because it receives lamports. remaining starts as the treasury's balance minus a reserve. Each row pays the smaller of remaining and that creditor's owed amount, then subtracts the payment. carry passes remaining from row to row, so row four sees what rows one to three paid, while pay starts fresh on every row. Once the money runs out, pay is zero and when skips the transfer, so the later creditors get nothing and the transaction still succeeds.

Consolidate only the funded accounts ​

Move the whole balance of each token account in the batch into one vault, and skip the accounts that are empty.

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

/** Move each row's whole token balance into the vault, skipping empty accounts. */
export const consolidateOnlyTheFundedAccounts = defineTemplate({
  accounts: {
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    vault: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
    authority: { signer: true },
  },
  batch: {
    maxIterations: 8,
    minIterations: 1,
    row: { source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 } },
  },
  steps: [
    step.forEach([
      step.let('amount', expression.accountData(account.iteration('source'), 64, 'u64')),
      tokenTransfer({
        tokenProgram: account.fixed('tokenProgram'),
        source: account.iteration('source'),
        destination: account.fixed('vault'),
        authority: account.fixed('authority'),
        amount: expression.variable('amount'),
        when: expression.greaterThan(expression.variable('amount'), expression.u64(0)),
      }),
    ]),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

/** Pass every candidate; the run skips the empty ones. */
export function runConsolidateOnlyTheFundedAccounts(run: {
  templateAddress: Address;
  vault: Address;
  authority: Address;
  sources: readonly Address[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(consolidateOnlyTheFundedAccounts),
    templateAddress: run.templateAddress,
    accounts: {
      tokenProgram: { address: TOKEN_PROGRAM },
      vault: { address: run.vault },
      authority: { address: run.authority },
    },
    batchRows: run.sources.map((source) => ({ source: { address: source } })),
  });
}
rs
/// Move each row's whole token balance into the vault, skipping empty accounts.
pub fn consolidate_only_the_funded_accounts() -> Template {
    Template::new()
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account(
            "vault",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .account("authority", account::signer())
        .batch(
            Batch::new(8).min_iterations(1).account(
                "source",
                account::writable()
                    .owner(TOKEN_PROGRAM_ID)
                    .min_data_length(165),
            ),
        )
        .step(
            step::for_each()
                .step(step::let_(
                    "amount",
                    account_data(account::iteration("source"), 64, ReadType::U64),
                ))
                .step(
                    token_transfer(
                        "tokenProgram",
                        account::iteration("source"),
                        "vault",
                        "authority",
                        var("amount"),
                    )
                    .when(var("amount").gt(u64(0))),
                ),
        )
}
rs
/// Pass every candidate; the run skips the empty ones.
pub fn consolidate_only_the_funded_accounts(
    template: Pubkey,
    vault: Pubkey,
    authority: Pubkey,
    sources: &[Pubkey],
) -> RunResult {
    let instruction = templates::consolidate_only_the_funded_accounts()
        .compile()?
        .run(template)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("vault", vault)
        .account("authority", authority)
        .rows(
            sources
                .iter()
                .map(|source| Row::new().account("source", *source)),
        )
        .instruction()?;
    Ok(instruction)
}

Each row reads its own account's balance (the u64 at offset 64 of an SPL Token account) and transfers all of it, and when skips the transfer when the balance is zero. Sending one plain Transfer instruction per account needs every amount before signing, and the whole transaction fails at the first account that turns out to be empty.

Crank only the ripe entries ​

A crank is a transaction that processes the items waiting in a protocol's queue. Keepers (bots that do routine maintenance for a protocol) send them. This template settles only the entries whose deadline has passed.

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

// Stand-ins so the example runs as written: replace them with the queue program's address, its
// settle instruction data, and the offset of the deadline in its entry account.
const QUEUE_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const SETTLE_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const SETTLE_ARGUMENT = 10_000n;
const DEADLINE_OFFSET = 8;

/** Settle each queue entry whose deadline has passed; skip the rest. */
export const crankOnlyTheRipeEntries = defineTemplate({
  accounts: {
    queueProgram: { executable: true, address: QUEUE_PROGRAM },
    keeper: { signer: true, writable: true },
  },
  batch: {
    maxIterations: 8,
    minIterations: 1,
    row: { entry: { writable: true, owner: QUEUE_PROGRAM, minDataLength: 128 } },
  },
  steps: [
    step.forEach([
      step.invoke({
        program: account.fixed('queueProgram'),
        accounts: [
          { account: account.fixed('keeper'), signer: true, writable: true },
          { account: account.iteration('entry'), signer: false, writable: true },
        ],
        data: [data.literal(SETTLE_DISCRIMINATOR), data.encode('u64', expression.u64(SETTLE_ARGUMENT))],
        when: expression.lessThanOrEqual(
          expression.accountData(account.iteration('entry'), DEADLINE_OFFSET, 'i64'),
          expression.clockUnixTimestamp(),
        ),
      }),
    ]),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const QUEUE_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-in

/** Pass the whole queue; the run settles the entries that are due when it executes. */
export function runCrankOnlyTheRipeEntries(run: {
  templateAddress: Address;
  keeper: Address;
  entries: readonly Address[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(crankOnlyTheRipeEntries),
    templateAddress: run.templateAddress,
    accounts: {
      queueProgram: { address: QUEUE_PROGRAM_ADDRESS },
      keeper: { address: run.keeper },
    },
    batchRows: run.entries.map((entry) => ({ entry: { address: entry } })),
  });
}
rs
/// Settle each queue entry whose deadline has passed; skip the rest.
pub fn crank_only_the_ripe_entries() -> Template {
    // Stand-ins so the example runs as written: replace them with the queue program's address,
    // its settle instruction data, and the offset of the deadline in its entry account.
    const QUEUE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const SETTLE_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
    const SETTLE_ARGUMENT: u64 = 10_000;
    const DEADLINE_OFFSET: u32 = 8;

    Template::new()
        .account("queueProgram", account::program(QUEUE_PROGRAM))
        .account("keeper", account::signer().writable())
        .batch(
            Batch::new(8).min_iterations(1).account(
                "entry",
                account::writable()
                    .owner(QUEUE_PROGRAM)
                    .min_data_length(128),
            ),
        )
        .step(
            step::for_each().step(
                step::invoke("queueProgram")
                    .writable_signer("keeper")
                    .writable(account::iteration("entry"))
                    .data(data::literal(SETTLE_DISCRIMINATOR))
                    .data(data::u64(u64(SETTLE_ARGUMENT)))
                    .when(
                        account_data(account::iteration("entry"), DEADLINE_OFFSET, ReadType::I64)
                            .lte(clock_unix_timestamp()),
                    ),
            ),
        )
}
rs
/// Pass the whole queue; the run settles the entries that are due when it executes.
pub fn crank_only_the_ripe_entries(
    template: Pubkey,
    keeper: Pubkey,
    entries: &[Pubkey],
) -> RunResult {
    const QUEUE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template

    let instruction = templates::crank_only_the_ripe_entries()
        .compile()?
        .run(template)
        .account("queueProgram", QUEUE_PROGRAM)
        .account("keeper", keeper)
        .rows(
            entries
                .iter()
                .map(|entry| Row::new().account("entry", *entry)),
        )
        .instruction()?;
    Ok(instruction)
}

Each row is one queue entry. The template reads the entry's deadline, a Unix timestamp at DEADLINE_OFFSET, and compares it with the network's clock when the transaction executes. For each entry that is due, step.invoke makes a CPI into the protocol. The keeper can pass the whole queue and let the run decide. Filtering the queue before sending would compare the deadlines with a time that has already passed when the transaction runs.

Distribute a runtime pot pro rata ​

Split a vault's balance above a reserve (the pot) among holders, each in proportion to a weight the caller passes.

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

/** Pay each holder `weightBps` of the vault's balance above `reserve`. */
export const distributeARuntimePotProRata = defineTemplate({
  inputs: { reserve: { type: 'u64' } },
  accounts: {
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    vault: { signer: true, writable: true },
  },
  batch: {
    maxIterations: 8,
    minIterations: 1,
    row: { holder: { writable: true } },
    rowInputs: { weightBps: { type: 'u64' } },
  },
  steps: [
    step.let(
      'pot',
      expression.subtract(expression.accountField(account.fixed('vault'), 'lamports'), expression.input('reserve')),
    ),
    step.forEach([
      systemTransfer({
        systemProgram: account.fixed('systemProgram'),
        from: account.fixed('vault'),
        to: account.iteration('holder'),
        lamports: expression.divide(
          expression.multiply(expression.variable('pot'), expression.rowInput('weightBps')),
          expression.u64(10_000),
        ),
      }),
    ]),
  ],
});
ts
import type { Address } from '@solana/kit';

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

/** `holders` with each one's weight in basis points. */
export function runDistributeARuntimePotProRata(run: {
  templateAddress: Address;
  vault: Address;
  reserve: bigint;
  holders: readonly { address: Address; weightBps: bigint }[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(distributeARuntimePotProRata),
    templateAddress: run.templateAddress,
    inputs: { reserve: run.reserve },
    accounts: {
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      vault: { address: run.vault },
    },
    batchRows: run.holders.map((holder) => ({ holder: { address: holder.address } })),
    batchInputs: run.holders.map((holder) => ({ weightBps: holder.weightBps })),
  });
}
rs
/// Pay each holder `weightBps` of the vault's balance above `reserve`.
pub fn distribute_a_runtime_pot_pro_rata() -> Template {
    Template::new()
        .input("reserve", Type::U64)
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account("vault", account::signer().writable())
        .batch(
            Batch::new(8)
                .min_iterations(1)
                .account("holder", account::writable())
                .input("weightBps", Type::U64),
        )
        .step(step::let_("pot", lamports("vault") - input("reserve")))
        .step(step::for_each().step(system_transfer(
            "systemProgram",
            "vault",
            account::iteration("holder"),
            var("pot") * row_input("weightBps") / u64(10_000),
        )))
}
rs
/// `holders` with each one's weight in basis points.
pub fn distribute_a_runtime_pot_pro_rata(
    template: Pubkey,
    vault: Pubkey,
    reserve: u64,
    holders: &[(Pubkey, u64)],
) -> RunResult {
    let instruction = templates::distribute_a_runtime_pot_pro_rata()
        .compile()?
        .run(template)
        .input("reserve", reserve)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("vault", vault)
        .rows(holders.iter().map(|(holder, weight_bps)| {
            Row::new()
                .account("holder", *holder)
                .input("weightBps", *weight_bps)
        }))
        .instruction()?;
    Ok(instruction)
}

weightBps is each holder's share in basis points (hundredths of a percent, so 10,000 is 100%). The caller chooses the weights, and the template reads the pot when the transaction executes. Computing the shares off chain would divide a balance that may have changed by then. Each share is rounded down, and whatever the rounding leaves over stays in the vault.

Crank once per waiting entry ​

step.repeat(count, steps, { max }) is a count loop: it runs its steps count times, and it has no rows. This template reads how many entries wait in a queue when the transaction executes, and cranks the queue that many times, up to eight. A plain transaction fixes its number of crank instructions when it is signed.

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

// Stand-ins so the example runs as written: replace them with the queue program's address, its
// crank instruction data, and the offset of the waiting count in its queue account.
const QUEUE_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const CRANK_DATA = Uint8Array.of(2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0);
const WAITING_OFFSET = 8;

/** Crank the queue once for each waiting entry, at most eight times. */
export const crankOncePerWaitingEntry = defineTemplate({
  accounts: {
    queueProgram: { executable: true, address: QUEUE_PROGRAM },
    keeper: { signer: true, writable: true },
    queue: { writable: true, owner: QUEUE_PROGRAM },
  },
  steps: [
    step.repeat(
      expression.min(expression.accountData(account.fixed('queue'), WAITING_OFFSET, 'u64'), expression.u64(8)),
      [
        step.invoke({
          program: account.fixed('queueProgram'),
          accounts: [
            { account: account.fixed('keeper'), signer: true, writable: true },
            { account: account.fixed('queue'), signer: false, writable: true },
          ],
          data: [data.literal(CRANK_DATA)],
        }),
      ],
      { max: 8 },
    ),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const QUEUE_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-in

/** The run reads the count itself, so the keeper passes only the accounts. */
export function runCrankOncePerWaitingEntry(run: { templateAddress: Address; keeper: Address; queue: Address }) {
  return buildKitRunInstruction({
    compiled: compileTemplate(crankOncePerWaitingEntry),
    templateAddress: run.templateAddress,
    accounts: {
      queueProgram: { address: QUEUE_PROGRAM_ADDRESS },
      keeper: { address: run.keeper },
      queue: { address: run.queue },
    },
  });
}
rs
/// Crank the queue once for each waiting entry, at most eight times.
pub fn crank_once_per_waiting_entry() -> Template {
    // Stand-ins so the example runs as written: replace them with the queue program's address,
    // its crank instruction data, and the offset of the waiting count in its queue account.
    const QUEUE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const CRANK_DATA: [u8; 12] = [2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0];
    const WAITING_OFFSET: u32 = 8;

    Template::new()
        .account("queueProgram", account::program(QUEUE_PROGRAM))
        .account("keeper", account::signer().writable())
        .account("queue", account::writable().owner(QUEUE_PROGRAM))
        .step(
            step::repeat(
                account_data("queue", WAITING_OFFSET, ReadType::U64).min(u64(8)),
                8,
            )
            .step(
                step::invoke("queueProgram")
                    .writable_signer("keeper")
                    .writable("queue")
                    .data(data::literal(CRANK_DATA)),
            ),
        )
}
rs
/// The run reads the count itself, so the keeper passes only the accounts.
pub fn crank_once_per_waiting_entry(template: Pubkey, keeper: Pubkey, queue: Pubkey) -> RunResult {
    const QUEUE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template

    let instruction = templates::crank_once_per_waiting_entry()
        .compile()?
        .run(template)
        .account("queueProgram", QUEUE_PROGRAM)
        .account("keeper", keeper)
        .account("queue", queue)
        .instruction()?;
    Ok(instruction)
}
  • The count is a u64, read once, when the loop starts. A count of 0 skips the loop.
  • max, from 1 to 255, is the most times the loop may run. A run whose count is above it fails with LoopCountExceeded (6022), so this template caps the count with min.
  • Finalization counts the loop's calls at max, here 8 of the 64 a run may make. Rules and limits covers that count and Solana's instruction trace, which can run out first.
  • carry and expression.loopIndex() (the pass number, from 0) work as they do in forEach. There are no rows, so account.iteration and expression.rowInput are rejected inside.

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS