Batch execution
A template can declare one batch: a row of 1 to 8 accounts, plus optional row inputs, that the caller repeats once per item, such as one recipient per row. step.forEach runs its steps once per row.
The caller passes the rows after the fixed accounts and before any account group members, and the row inputs in the run data, one set per row. The program counts the rows from the accounts left over: they must divide evenly into rows, and the count must fall between the batch's minimum and maximum (at most 60), or the run fails.
Variable payroll
This template pays each of 1 to 30 recipients its own number of lamports from a treasury, which must sign. step.forEach runs its steps once per row, and account.iteration('recipient') refers to the current row's account. batch.rowInputs declares inputs that every row carries, and expression.rowInput('amount') reads the current row's value. At run time the caller passes one set of values per row, in the same order as the rows.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
defineTemplate,
expression,
step,
systemTransfer,
} from '@jac0xb/ballista';
/** Pay each recipient its own amount, carried as a row input. */
export const rowAmounts = defineTemplate({
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
treasury: { signer: true, writable: true },
},
batch: {
maxIterations: 30,
minIterations: 1,
row: { recipient: { writable: true } },
rowInputs: { amount: { type: 'u64' } },
},
steps: [
step.forEach([
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('treasury'),
to: account.iteration('recipient'),
lamports: expression.rowInput('amount'),
}),
]),
],
});import type { Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
/** `payees` with each one's amount in lamports. */
export function runRowAmounts(run: {
templateAddress: Address;
treasury: Address;
payees: readonly { address: Address; lamports: bigint }[];
}) {
return buildKitRunInstruction({
compiled: compileTemplate(rowAmounts),
templateAddress: run.templateAddress,
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
treasury: { address: run.treasury },
},
// One row of accounts and one row of inputs per payee, in the same order.
batchRows: run.payees.map((payee) => ({ recipient: { address: payee.address } })),
batchInputs: run.payees.map((payee) => ({ amount: payee.lamports })),
});
}/// Pay each recipient its own amount, carried as a row input.
pub fn row_amounts() -> Template {
Template::new()
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("treasury", account::signer().writable())
.batch(
Batch::new(30)
.min_iterations(1)
.account("recipient", account::writable())
.input("amount", Type::U64),
)
.step(step::for_each().step(system_transfer(
"systemProgram",
"treasury",
account::iteration("recipient"),
row_input("amount"),
)))
}/// `payees` with each one's amount in lamports.
pub fn row_amounts(template: Pubkey, treasury: Pubkey, payees: &[(Pubkey, u64)]) -> RunResult {
let instruction = templates::row_amounts()
.compile()?
.run(template)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("treasury", treasury)
.rows(payees.iter().map(|(payee, lamports)| {
Row::new()
.account("recipient", *payee)
.input("amount", *lamports)
}))
.instruction()?;
Ok(instruction)
}These limits apply to row inputs:
- Fixed inputs and row inputs together are limited to
32declarations, with at most8per row. - A run can carry at most
256values: the fixed inputs plus the row inputs times the template's maximum number of rows. - The whole run data is limited to
1,024 bytes, which in practice boundsbytesrow inputs.
If the row values do not match the rows, the run fails with InvalidRunInputs, and the error reports the index of the first missing or malformed value, counting fixed values first.
Rows supply accounts and values, not new calls: the loop body's CPIs are the same for every row. An invoke inside the loop that forwards an account group forwards the same group on every row.
Carry a total across rows
Values set inside the loop body are discarded after each row, unless the loop carries them. A carried variable is defined with step.let before the loop, listed in the loop's carry option, updated inside the loop with step.assign, and still readable after the loop ends. That lets a template enforce a limit over the whole batch, such as a total budget.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
defineTemplate,
expression,
step,
systemTransfer,
} from '@jac0xb/ballista';
/** Pay `amount` to every recipient, then require the total stays within `budget`. */
export const budgetedPayroll = defineTemplate({
inputs: { amount: { type: 'u64' }, budget: { type: 'u64' } },
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
treasury: { signer: true, writable: true },
},
batch: { maxIterations: 30, minIterations: 1, row: { recipient: { writable: true } } },
steps: [
step.let('total', expression.u64(0)),
step.forEach(
[
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('treasury'),
to: account.iteration('recipient'),
lamports: expression.input('amount'),
}),
step.assign('total', expression.add(expression.variable('total'), expression.input('amount'))),
],
{ carry: ['total'] },
),
step.require(expression.lessThanOrEqual(expression.variable('total'), expression.input('budget')), 'withinBudget'),
],
});import type { Address } from '@solana/kit';
import { compileTemplate, explainRunError } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
const compiled = compileTemplate(budgetedPayroll);
export function runBudgetedPayroll(run: {
templateAddress: Address;
treasury: Address;
recipients: readonly Address[];
amount: bigint;
budget: bigint;
}) {
return buildKitRunInstruction({
compiled,
templateAddress: run.templateAddress,
inputs: { amount: run.amount, budget: run.budget },
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
treasury: { address: run.treasury },
},
batchRows: run.recipients.map((recipient) => ({ recipient: { address: recipient } })),
});
}
/** Going over budget fails the labelled require: 'RequirementFailed at steps[2] (withinBudget)'. */
export function explainBudgetFailure(code: number) {
return explainRunError(code, compiled)?.message;
}/// Pay `amount` to every recipient, then require the total stays within `budget`.
pub fn budgeted_payroll() -> Template {
Template::new()
.input("amount", Type::U64)
.input("budget", Type::U64)
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("treasury", account::signer().writable())
.batch(
Batch::new(30)
.min_iterations(1)
.account("recipient", account::writable()),
)
.step(step::let_("total", u64(0)))
.step(
step::for_each()
.step(system_transfer(
"systemProgram",
"treasury",
account::iteration("recipient"),
input("amount"),
))
.step(step::assign("total", var("total") + input("amount")))
.carry("total"),
)
.step(step::require(var("total").lte(input("budget"))).label("withinBudget"))
}pub fn budgeted_payroll(
template: Pubkey,
treasury: Pubkey,
recipients: &[Pubkey],
amount: u64,
budget: u64,
) -> RunResult {
let instruction = templates::budgeted_payroll()
.compile()?
.run(template)
.input("amount", amount)
.input("budget", budget)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("treasury", treasury)
.rows(
recipients
.iter()
.map(|recipient| Row::new().account("recipient", *recipient)),
)
.instruction()?;
Ok(instruction)
}The require carries the label withinBudget, so a run that goes over budget fails with an error that names that step; the TypeScript Run tab shows how to read it with explainRunError.
In Rust, the loop lists its carried variables with .carry("total"). Finalization, the one-time check before a template is locked on chain, confirms that every carried value is set before the loop and keeps its type through the loop body. A carried bytes value must also keep its maximum length.
Stride-two rows
A row can hold more than one account. The number of accounts in each row is its stride; here each row has two, a recipient's wallet and a token account. For every row, the template checks that the token account is the wallet's ATA, creates the account if it does not exist, and transfers tokens to it. The same template appears on token-account patterns.
import {
ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES,
SYSTEM_PROGRAM_ADDRESS_BYTES,
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
assertAta,
defineTemplate,
ensureAssociatedTokenAccount,
expression,
step,
tokenTransfer,
} from '@jac0xb/ballista';
/** For each row: prove the destination is the recipient's ATA, create it if missing, then pay. */
export const assertCreateThenTransfer = defineTemplate({
inputs: { amount: { type: 'u64' } },
accounts: {
associatedTokenProgram: { executable: true, address: ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
mint: { owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 82 },
payer: { signer: true, writable: true },
authority: { signer: true },
source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
},
batch: {
maxIterations: 8,
minIterations: 1,
// Each row is two accounts: the recipient's wallet, then its ATA.
row: { recipient: {}, destinationAta: { writable: true } },
},
steps: [
step.forEach([
assertAta({
associatedTokenAccount: account.iteration('destinationAta'),
owner: account.iteration('recipient'),
mint: account.fixed('mint'),
tokenProgram: account.fixed('tokenProgram'),
associatedTokenProgram: account.fixed('associatedTokenProgram'),
}),
ensureAssociatedTokenAccount({
associatedTokenProgram: account.fixed('associatedTokenProgram'),
payer: account.fixed('payer'),
associatedTokenAccount: account.iteration('destinationAta'),
owner: account.iteration('recipient'),
mint: account.fixed('mint'),
systemProgram: account.fixed('systemProgram'),
tokenProgram: account.fixed('tokenProgram'),
}),
tokenTransfer({
tokenProgram: account.fixed('tokenProgram'),
source: account.fixed('source'),
destination: account.iteration('destinationAta'),
authority: account.fixed('authority'),
amount: expression.input('amount'),
}),
]),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
/** `recipients` pairs each wallet with its ATA for `mint`. */
export function runAssertCreateThenTransfer(run: {
templateAddress: Address;
mint: Address;
payer: Address;
authority: Address;
source: Address;
recipients: readonly { wallet: Address; ata: Address }[];
amount: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(assertCreateThenTransfer),
templateAddress: run.templateAddress,
inputs: { amount: run.amount },
accounts: {
associatedTokenProgram: { address: ASSOCIATED_TOKEN_PROGRAM },
tokenProgram: { address: TOKEN_PROGRAM },
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
mint: { address: run.mint },
payer: { address: run.payer },
authority: { address: run.authority },
source: { address: run.source },
},
batchRows: run.recipients.map((recipient) => ({
recipient: { address: recipient.wallet },
destinationAta: { address: recipient.ata },
})),
});
}/// For each row: prove the destination is the recipient's ATA, create it if missing, then pay.
pub fn assert_create_then_transfer() -> Template {
Template::new()
.input("amount", Type::U64)
.account(
"associatedTokenProgram",
account::program(ASSOCIATED_TOKEN_PROGRAM_ID),
)
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account(
"mint",
account::readonly()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(82),
)
.account("payer", account::signer().writable())
.account("authority", account::signer())
.account(
"source",
account::writable()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(165),
)
.batch(
Batch::new(8)
.min_iterations(1)
// Each row is two accounts: the recipient's wallet, then its ATA.
.account("recipient", account::readonly())
.account("destinationAta", account::writable()),
)
.step(
step::for_each()
.step(assert_ata(
account::iteration("destinationAta"),
account::iteration("recipient"),
"mint",
"tokenProgram",
"associatedTokenProgram",
))
.step(ensure_associated_token_account(AtaAccounts {
associated_token_program: "associatedTokenProgram".into(),
payer: "payer".into(),
associated_token_account: account::iteration("destinationAta"),
owner: account::iteration("recipient"),
mint: "mint".into(),
system_program: "systemProgram".into(),
token_program: "tokenProgram".into(),
}))
.step(token_transfer(
"tokenProgram",
"source",
account::iteration("destinationAta"),
"authority",
input("amount"),
)),
)
}/// `rows` pairs each recipient's wallet with its ATA for `mint`.
pub fn assert_create_then_transfer(
template: Pubkey,
mint: Pubkey,
payer: Pubkey,
authority: Pubkey,
source: Pubkey,
rows: &[(Pubkey, Pubkey)],
amount: u64,
) -> RunResult {
let instruction = templates::assert_create_then_transfer()
.compile()?
.run(template)
.input("amount", amount)
.account("associatedTokenProgram", ASSOCIATED_TOKEN_PROGRAM_ID)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("mint", mint)
.account("payer", payer)
.account("authority", authority)
.account("source", source)
.rows(rows.iter().map(|(recipient, destination_ata)| {
Row::new()
.account("recipient", *recipient)
.account("destinationAta", *destination_ata)
}))
.instruction()?;
Ok(instruction)
}The caller passes each row's accounts together, in the order row declares them: wallet, ATA, wallet, ATA, and so on.
Rules and limits
- Checked at finalization. Up to eight loops (
forEachandrepeat), one after another and never nested, elseInvalidLoop(6129). At most64CPIs in the worst case: each loop's calls times its maximum, plus the calls outside loops. A30-row loop with two calls counts60. - Capped by Solana. The instruction trace counts the run and every call the called programs make, and often runs out before
64. Size loops to it.