Protocol composition
Templates that call other programs in sequence, with checks between the calls. Ballista works with any program. Your client still finds routes, quotes and accounts as it would for a plain transaction; the template fixes the order of the calls and the conditions that must hold while they run.
Each call to another program is a CPI, made with step.invoke and instruction data your client supplies. Each recipe shows the template and the code that runs it, in TypeScript and Rust. So that the code compiles and runs as written, the protocol calls use marked stand-ins (the System program and its Transfer data); replace them with the protocols' own.
Swap then deposit
Swap, check how many tokens the swap actually delivered, then deposit. Your client builds the instruction data for both calls. Between them, the template requires receivedTokens to have grown by at least minimumOut; if it hasn't, the deposit never happens and the whole run reverts. The deposit amount is whatever your client put in the deposit data. To deposit exactly what the swap produced, see deposit exactly what a swap produced.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
// Stand-ins so the example runs as written: replace them with the swap and vault programs.
const SWAP_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const VAULT_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const receivedBalance = expression.accountData(account.fixed('receivedTokens'), 64, 'u64');
/** Swap, require at least `minimumOut` arrived, then deposit. */
export const swapThenDeposit = defineTemplate({
inputs: {
minimumOut: { type: 'u64' },
swapData: { type: 'bytes', maxLength: 256 },
depositData: { type: 'bytes', maxLength: 256 },
},
accounts: {
swapProgram: { executable: true, address: SWAP_PROGRAM },
vaultProgram: { executable: true, address: VAULT_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
receivedTokens: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
},
steps: [
step.snapshot('before', receivedBalance),
step.invoke({
program: account.fixed('swapProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('swapData'))],
}),
// `receivedBalance` is read again here, after the swap.
step.require(
expression.greaterThanOrEqual(
expression.subtract(receivedBalance, expression.snapshot('before')),
expression.input('minimumOut'),
),
),
step.invoke({
program: account.fixed('vaultProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('depositData'))],
}),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
const SWAP_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-ins
const VAULT_PROGRAM_ADDRESS = address('11111111111111111111111111111111');
/** `swapData` and `depositData` are the two instructions' data, built by your client. */
export function runSwapThenDeposit(run: {
templateAddress: Address;
payer: Address;
pool: Address;
receivedTokens: Address;
minimumOut: bigint;
swapData: Uint8Array;
depositData: Uint8Array;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(swapThenDeposit),
templateAddress: run.templateAddress,
inputs: { minimumOut: run.minimumOut, swapData: run.swapData, depositData: run.depositData },
accounts: {
swapProgram: { address: SWAP_PROGRAM_ADDRESS },
vaultProgram: { address: VAULT_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
receivedTokens: { address: run.receivedTokens },
},
});
}/// Swap, require at least `minimumOut` arrived, then deposit.
pub fn swap_then_deposit() -> Template {
// Stand-ins so the example runs as written: replace them with the swap and vault programs.
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const VAULT_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let received_balance = account_data("receivedTokens", 64, ReadType::U64);
Template::new()
.input("minimumOut", Type::U64)
.input("swapData", Type::Bytes(256))
.input("depositData", Type::Bytes(256))
.account("swapProgram", account::program(SWAP_PROGRAM))
.account("vaultProgram", account::program(VAULT_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.account(
"receivedTokens",
account::writable()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(165),
)
.step(step::snapshot("before", received_balance.clone()))
.step(
step::invoke("swapProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("swapData"))),
)
// `received_balance` is read again here, after the swap.
.step(step::require(
(received_balance - snapshot("before")).gte(input("minimumOut")),
))
.step(
step::invoke("vaultProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("depositData"))),
)
}/// `swap_data` and `deposit_data` are the two instructions' data, built by your client.
pub fn swap_then_deposit(
template: Pubkey,
payer: Pubkey,
pool: Pubkey,
received_tokens: Pubkey,
minimum_out: u64,
swap_data: &[u8],
deposit_data: &[u8],
) -> RunResult {
// The same stand-ins as the template.
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const VAULT_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let instruction = templates::swap_then_deposit()
.compile()?
.run(template)
.input("minimumOut", minimum_out)
.input("swapData", swap_data)
.input("depositData", deposit_data)
.account("swapProgram", SWAP_PROGRAM)
.account("vaultProgram", VAULT_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.account("receivedTokens", received_tokens)
.instruction()?;
Ok(instruction)
}Claim then distribute
Claim rewards into a treasury token account, then pay the same amount to each recipient in a list. The claim runs once, before the loop; the transfer runs once per row.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
data,
defineTemplate,
expression,
step,
tokenTransfer,
} from '@jac0xb/ballista';
// Stand-ins so the example runs as written: replace them with the rewards program's address and
// its claim instruction data.
const REWARDS_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const CLAIM_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const CLAIM_ARGUMENT = 10_000n;
/** Claim once, then pay `amountPerRecipient` tokens to each row's token account. */
export const claimThenDistribute = defineTemplate({
inputs: { amountPerRecipient: { type: 'u64' } },
accounts: {
rewardsProgram: { executable: true, address: REWARDS_PROGRAM },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
claimer: { signer: true, writable: true },
pool: { writable: true },
treasuryTokens: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
authority: { signer: true },
},
batch: {
maxIterations: 16,
minIterations: 1,
row: { recipientTokens: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 } },
},
steps: [
step.invoke({
program: account.fixed('rewardsProgram'),
accounts: [
{ account: account.fixed('claimer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.literal(CLAIM_DISCRIMINATOR), data.encode('u64', expression.u64(CLAIM_ARGUMENT))],
}),
step.forEach([
tokenTransfer({
tokenProgram: account.fixed('tokenProgram'),
source: account.fixed('treasuryTokens'),
destination: account.iteration('recipientTokens'),
authority: account.fixed('authority'),
amount: expression.input('amountPerRecipient'),
}),
]),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
const REWARDS_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-in
const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
export function runClaimThenDistribute(run: {
templateAddress: Address;
claimer: Address;
pool: Address;
treasuryTokens: Address;
authority: Address;
recipientTokens: readonly Address[];
amountPerRecipient: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(claimThenDistribute),
templateAddress: run.templateAddress,
inputs: { amountPerRecipient: run.amountPerRecipient },
accounts: {
rewardsProgram: { address: REWARDS_PROGRAM_ADDRESS },
tokenProgram: { address: TOKEN_PROGRAM },
claimer: { address: run.claimer },
pool: { address: run.pool },
treasuryTokens: { address: run.treasuryTokens },
authority: { address: run.authority },
},
batchRows: run.recipientTokens.map((recipient) => ({ recipientTokens: { address: recipient } })),
});
}/// Claim once, then pay `amountPerRecipient` tokens to each row's token account.
pub fn claim_then_distribute() -> Template {
// Stand-ins so the example runs as written: replace them with the rewards program's address
// and its claim instruction data.
const REWARDS_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const CLAIM_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const CLAIM_ARGUMENT: u64 = 10_000;
Template::new()
.input("amountPerRecipient", Type::U64)
.account("rewardsProgram", account::program(REWARDS_PROGRAM))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account("claimer", account::signer().writable())
.account("pool", account::writable())
.account(
"treasuryTokens",
account::writable()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(165),
)
.account("authority", account::signer())
.batch(
Batch::new(16).min_iterations(1).account(
"recipientTokens",
account::writable()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(165),
),
)
.step(
step::invoke("rewardsProgram")
.writable_signer("claimer")
.writable("pool")
.data(data::literal(CLAIM_DISCRIMINATOR))
.data(data::u64(u64(CLAIM_ARGUMENT))),
)
.step(step::for_each().step(token_transfer(
"tokenProgram",
"treasuryTokens",
account::iteration("recipientTokens"),
"authority",
input("amountPerRecipient"),
)))
}pub fn claim_then_distribute(
template: Pubkey,
claimer: Pubkey,
pool: Pubkey,
treasury_tokens: Pubkey,
authority: Pubkey,
recipient_tokens: &[Pubkey],
amount_per_recipient: u64,
) -> RunResult {
const REWARDS_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::claim_then_distribute()
.compile()?
.run(template)
.input("amountPerRecipient", amount_per_recipient)
.account("rewardsProgram", REWARDS_PROGRAM)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("claimer", claimer)
.account("pool", pool)
.account("treasuryTokens", treasury_tokens)
.account("authority", authority)
.rows(
recipient_tokens
.iter()
.map(|account| Row::new().account("recipientTokens", *account)),
)
.instruction()?;
Ok(instruction)
}Primary or fallback route
Include two routes, each behind a when condition, where one condition is the opposite of the other. Each run calls exactly one route, chosen by the usePrimary input.
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 two routes' programs.
const PRIMARY_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const FALLBACK_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const usePrimary = expression.input('usePrimary');
/** Call exactly one of two routes, chosen by `usePrimary`. */
export const primaryOrFallbackRoute = defineTemplate({
inputs: {
usePrimary: { type: 'bool' },
primaryData: { type: 'bytes', maxLength: 256 },
fallbackData: { type: 'bytes', maxLength: 256 },
},
accounts: {
primaryProgram: { executable: true, address: PRIMARY_PROGRAM },
fallbackProgram: { executable: true, address: FALLBACK_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
},
steps: [
step.invoke({
program: account.fixed('primaryProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('primaryData'))],
when: usePrimary,
}),
step.invoke({
program: account.fixed('fallbackProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('fallbackData'))],
when: expression.not(usePrimary),
}),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
const PRIMARY_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-ins
const FALLBACK_PROGRAM_ADDRESS = address('11111111111111111111111111111111');
/** Both routes' data travel in every run; `usePrimary` picks which call happens. */
export function runPrimaryOrFallbackRoute(run: {
templateAddress: Address;
payer: Address;
pool: Address;
usePrimary: boolean;
primaryData: Uint8Array;
fallbackData: Uint8Array;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(primaryOrFallbackRoute),
templateAddress: run.templateAddress,
inputs: { usePrimary: run.usePrimary, primaryData: run.primaryData, fallbackData: run.fallbackData },
accounts: {
primaryProgram: { address: PRIMARY_PROGRAM_ADDRESS },
fallbackProgram: { address: FALLBACK_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
},
});
}/// Call exactly one of two routes, chosen by `usePrimary`.
pub fn primary_or_fallback_route() -> Template {
// Stand-ins so the example runs as written: replace them with the two routes' programs.
const PRIMARY_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const FALLBACK_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let use_primary = input("usePrimary");
Template::new()
.input("usePrimary", Type::Bool)
.input("primaryData", Type::Bytes(256))
.input("fallbackData", Type::Bytes(256))
.account("primaryProgram", account::program(PRIMARY_PROGRAM))
.account("fallbackProgram", account::program(FALLBACK_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.step(
step::invoke("primaryProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("primaryData")))
.when(&use_primary),
)
.step(
step::invoke("fallbackProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("fallbackData")))
.when(use_primary.not()),
)
}/// Both routes' data travel in every run; `use_primary` picks which call happens.
pub fn primary_or_fallback_route(
template: Pubkey,
payer: Pubkey,
pool: Pubkey,
use_primary: bool,
primary_data: &[u8],
fallback_data: &[u8],
) -> RunResult {
// The same stand-ins as the template.
const PRIMARY_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const FALLBACK_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let instruction = templates::primary_or_fallback_route()
.compile()?
.run(template)
.input("usePrimary", use_primary)
.input("primaryData", primary_data)
.input("fallbackData", fallback_data)
.account("primaryProgram", PRIMARY_PROGRAM)
.account("fallbackProgram", FALLBACK_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.instruction()?;
Ok(instruction)
}Time-gated governance execution
Execute a governance action only if the proposal account says it is approved and its execution time has passed. The template reads both fields from the proposal account during the run, then forwards the execute instruction your client built. APPROVED_OFFSET and TIME_OFFSET are the byte positions of those fields in your governance program's proposal account.
import { SYSTEM_PROGRAM_ADDRESS_BYTES, account, data, defineTemplate, expression, step } from '@jac0xb/ballista';
// Stand-ins so the example runs as written: replace them with your governance program's address
// and the offsets of the two fields in its proposal account.
const GOVERNANCE_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const APPROVED_OFFSET = 0;
const TIME_OFFSET = 8;
const approved = expression.accountData(account.fixed('proposal'), APPROVED_OFFSET, 'bool');
const executableAfter = expression.accountData(account.fixed('proposal'), TIME_OFFSET, 'i64');
/** Forward the execute instruction only once the proposal is approved and its time has come. */
export const timeGatedGovernanceExecution = defineTemplate({
inputs: { executeData: { type: 'bytes', maxLength: 256 } },
accounts: {
governanceProgram: { executable: true, address: GOVERNANCE_PROGRAM },
proposal: { owner: GOVERNANCE_PROGRAM, minDataLength: 128 },
payer: { signer: true, writable: true },
target: { writable: true },
},
steps: [
step.require(
expression.and(approved, expression.greaterThanOrEqual(expression.clockUnixTimestamp(), executableAfter)),
),
step.invoke({
program: account.fixed('governanceProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('target'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('executeData'))],
}),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
const GOVERNANCE_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-in
export function runTimeGatedGovernanceExecution(run: {
templateAddress: Address;
proposal: Address;
payer: Address;
target: Address;
executeData: Uint8Array;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(timeGatedGovernanceExecution),
templateAddress: run.templateAddress,
inputs: { executeData: run.executeData },
accounts: {
governanceProgram: { address: GOVERNANCE_PROGRAM_ADDRESS },
proposal: { address: run.proposal },
payer: { address: run.payer },
target: { address: run.target },
},
});
}/// Forward the execute instruction only once the proposal is approved and its time has come.
pub fn time_gated_governance_execution() -> Template {
// Stand-ins so the example runs as written: replace them with your governance program's
// address and the offsets of the two fields in its proposal account.
const GOVERNANCE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const APPROVED_OFFSET: u32 = 0;
const TIME_OFFSET: u32 = 8;
let approved = account_data("proposal", APPROVED_OFFSET, ReadType::Bool);
let executable_after = account_data("proposal", TIME_OFFSET, ReadType::I64);
Template::new()
.input("executeData", Type::Bytes(256))
.account("governanceProgram", account::program(GOVERNANCE_PROGRAM))
.account(
"proposal",
account::readonly()
.owner(GOVERNANCE_PROGRAM)
.min_data_length(128),
)
.account("payer", account::signer().writable())
.account("target", account::writable())
.step(step::require(
approved.and(clock_unix_timestamp().gte(executable_after)),
))
.step(
step::invoke("governanceProgram")
.writable_signer("payer")
.writable("target")
.data(data::bytes(input("executeData"))),
)
}pub fn time_gated_governance_execution(
template: Pubkey,
proposal: Pubkey,
payer: Pubkey,
target: Pubkey,
execute_data: &[u8],
) -> RunResult {
const GOVERNANCE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::time_gated_governance_execution()
.compile()?
.run(template)
.input("executeData", execute_data)
.account("governanceProgram", GOVERNANCE_PROGRAM)
.account("proposal", proposal)
.account("payer", payer)
.account("target", target)
.instruction()?;
Ok(instruction)
}Bounded keeper crank
Call the same maintenance instruction, often called a crank, once for each market and queue pair in a list; each pair is one row of a batch. A keeper, the bot or service that sends maintenance transactions, signs each call and is passed first, before the row's market and queue. Ballista holds no authority over the accounts.
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 protocol's address and its
// crank instruction data.
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const CRANK_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const CRANK_ARGUMENT = 1_000n;
/** Call the crank instruction once for each (market, queue) row. */
export const boundedKeeperCrank = defineTemplate({
accounts: {
protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
keeper: { signer: true, writable: true },
},
batch: {
maxIterations: 24,
minIterations: 1,
// Each row is two accounts: a market, then its queue.
row: { market: { writable: true }, queue: { writable: true } },
},
steps: [
step.forEach([
step.invoke({
program: account.fixed('protocolProgram'),
accounts: [
{ account: account.fixed('keeper'), signer: true, writable: true },
{ account: account.iteration('market'), writable: true, signer: false },
{ account: account.iteration('queue'), writable: true, signer: false },
],
data: [data.literal(CRANK_DISCRIMINATOR), data.encode('u64', expression.u64(CRANK_ARGUMENT))],
}),
]),
],
});import { address, type Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { buildKitRunInstruction } from '@jac0xb/ballista/kit';
const PROTOCOL_PROGRAM_ADDRESS = address('11111111111111111111111111111111'); // the same stand-in
export function runBoundedKeeperCrank(run: {
templateAddress: Address;
keeper: Address;
rows: readonly { market: Address; queue: Address }[];
}) {
return buildKitRunInstruction({
compiled: compileTemplate(boundedKeeperCrank),
templateAddress: run.templateAddress,
accounts: {
protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
keeper: { address: run.keeper },
},
batchRows: run.rows.map((row) => ({ market: { address: row.market }, queue: { address: row.queue } })),
});
}/// Call the crank instruction once for each (market, queue) row.
pub fn bounded_keeper_crank() -> Template {
// Stand-ins so the example runs as written: replace them with the protocol's address and its
// crank instruction data.
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const CRANK_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const CRANK_ARGUMENT: u64 = 1_000;
Template::new()
.account("protocolProgram", account::program(PROTOCOL_PROGRAM))
.account("keeper", account::signer().writable())
.batch(
Batch::new(24)
.min_iterations(1)
// Each row is two accounts: a market, then its queue.
.account("market", account::writable())
.account("queue", account::writable()),
)
.step(
step::for_each().step(
step::invoke("protocolProgram")
.writable_signer("keeper")
.writable(account::iteration("market"))
.writable(account::iteration("queue"))
.data(data::literal(CRANK_DISCRIMINATOR))
.data(data::u64(u64(CRANK_ARGUMENT))),
),
)
}/// `rows` pairs each market with its queue.
pub fn bounded_keeper_crank(
template: Pubkey,
keeper: Pubkey,
rows: &[(Pubkey, Pubkey)],
) -> RunResult {
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::bounded_keeper_crank()
.compile()?
.run(template)
.account("protocolProgram", PROTOCOL_PROGRAM)
.account("keeper", keeper)
.rows(rows.iter().map(|(market, queue)| {
Row::new()
.account("market", *market)
.account("queue", *queue)
}))
.instruction()?;
Ok(instruction)
}Ballista doesn't run on a schedule. A bot, a user or a keeper service still decides when to send each run.
Run another template
A template can call Ballista's Run to run another finalized template, then read what that run returns. Here the inner template swaps, requires a minimum, and returns what arrived with step.setReturnData. The outer template runs it, reads the amount with expression.returnData, and deposits exactly that.
The inner template:
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
// A stand-in so the example runs as written: replace it with the swap program.
const SWAP_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const receivedBalance = expression.accountData(account.fixed('receivedTokens'), 64, 'u64');
/** Swap, require at least `minimumOut` arrived, and return what arrived as a `u64`. */
export const swapAndReturnWhatArrived = defineTemplate({
inputs: {
minimumOut: { type: 'u64' },
swapData: { type: 'bytes', maxLength: 256 },
},
accounts: {
swapProgram: { executable: true, address: SWAP_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
receivedTokens: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
},
steps: [
step.snapshot('before', receivedBalance),
step.invoke({
program: account.fixed('swapProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.encode('bytes', expression.input('swapData'))],
}),
step.let('received', expression.subtract(receivedBalance, expression.snapshot('before'))),
step.require(expression.greaterThanOrEqual(expression.variable('received'), expression.input('minimumOut'))),
// Last, after every call: a call would clear it.
step.setReturnData([data.encode('u64', expression.variable('received'))]),
],
});/// Swap, require at least `minimumOut` arrived, and return what arrived as a `u64`.
pub fn swap_and_return_what_arrived() -> Template {
// A stand-in so the example runs as written: replace it with the swap program.
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let received_balance = account_data("receivedTokens", 64, ReadType::U64);
Template::new()
.input("minimumOut", Type::U64)
.input("swapData", Type::Bytes(256))
.account("swapProgram", account::program(SWAP_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.account(
"receivedTokens",
account::writable()
.owner(TOKEN_PROGRAM_ID)
.min_data_length(165),
)
.step(step::snapshot("before", received_balance.clone()))
.step(
step::invoke("swapProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("swapData"))),
)
.step(step::let_(
"received",
received_balance - snapshot("before"),
))
.step(step::require(var("received").gte(input("minimumOut"))))
// Last, after every call: a call would clear it.
.step(step::set_return_data([data::u64(var("received"))]))
}The outer template, and its run:
import { getAddressEncoder } from '@solana/kit';
import {
INSTRUCTION_RUN,
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import { BALLISTA_ADDRESS } from '@jac0xb/ballista/kit';
// Stand-ins so the example runs as written: replace them with the swap and vault programs, the
// vault's deposit discriminator, and the inner template's address (`getTemplateAddress` gives it).
const SWAP_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const VAULT_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const DEPOSIT_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const INNER_TEMPLATE = new Uint8Array(32).fill(7);
const BALLISTA = Uint8Array.from(getAddressEncoder().encode(BALLISTA_ADDRESS));
/** Run the inner swap template, then deposit exactly what it returned. */
export const nestedSwapThenDeposit = defineTemplate({
inputs: {
/** The inner run's data after the `run` tag. */
innerRun: { type: 'bytes', maxLength: 512 },
},
accounts: {
ballista: { executable: true, address: BALLISTA },
innerTemplate: { address: INNER_TEMPLATE },
swapProgram: { executable: true, address: SWAP_PROGRAM },
vaultProgram: { executable: true, address: VAULT_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
receivedTokens: { writable: true },
},
steps: [
step.invoke({
program: account.fixed('ballista'),
// The inner template, then the inner run's accounts in the order it declares them.
accounts: [
{ account: account.fixed('innerTemplate'), signer: false, writable: false },
{ account: account.fixed('swapProgram'), signer: false, writable: false },
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
{ account: account.fixed('receivedTokens'), signer: false, writable: true },
],
data: [data.literal(Uint8Array.of(INSTRUCTION_RUN)), data.encode('bytes', expression.input('innerRun'))],
}),
// Straight after the call.
step.let('received', expression.returnData('u64')),
step.invoke({
program: account.fixed('vaultProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.literal(DEPOSIT_DISCRIMINATOR), data.encode('u64', expression.variable('received'))],
}),
],
});import { getAddressDecoder, type Address } from '@solana/kit';
import { compileTemplate, encodeRunInputs } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
import { swapAndReturnWhatArrived } from './swap-and-return-what-arrived.js';
// The same stand-ins as the template.
const INNER_TEMPLATE_ADDRESS = getAddressDecoder().decode(INNER_TEMPLATE);
const SWAP_PROGRAM_ADDRESS = SYSTEM_PROGRAM_ADDRESS;
const VAULT_PROGRAM_ADDRESS = SYSTEM_PROGRAM_ADDRESS;
/** `innerRun` is what the inner template's own run would carry: its inputs, encoded. */
export function runNestedSwapThenDeposit(run: {
templateAddress: Address;
payer: Address;
pool: Address;
receivedTokens: Address;
minimumOut: bigint;
swapData: Uint8Array;
}) {
const innerRun = encodeRunInputs(compileTemplate(swapAndReturnWhatArrived), {
minimumOut: run.minimumOut,
swapData: run.swapData,
});
return buildKitRunInstruction({
compiled: compileTemplate(nestedSwapThenDeposit),
templateAddress: run.templateAddress,
inputs: { innerRun },
accounts: {
ballista: { address: BALLISTA_ADDRESS },
innerTemplate: { address: INNER_TEMPLATE_ADDRESS },
swapProgram: { address: SWAP_PROGRAM_ADDRESS },
vaultProgram: { address: VAULT_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
receivedTokens: { address: run.receivedTokens },
},
});
}/// Run the inner swap template, then deposit exactly what it returned.
pub fn nested_swap_then_deposit() -> Template {
// Stand-ins so the example runs as written: replace them with the swap and vault programs, the
// vault's deposit discriminator, and the inner template's address (`find_template_pda` gives
// it).
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const VAULT_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const DEPOSIT_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const INNER_TEMPLATE: [u8; 32] = [7; 32];
Template::new()
// The inner run's data after the `run` tag.
.input("innerRun", Type::Bytes(512))
.account("ballista", account::program(ballista_sdk::ID))
.account("innerTemplate", account::readonly().address(INNER_TEMPLATE))
.account("swapProgram", account::program(SWAP_PROGRAM))
.account("vaultProgram", account::program(VAULT_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.account("receivedTokens", account::writable())
.step(
step::invoke("ballista")
// The inner template, then the inner run's accounts in the order it declares them.
.readonly("innerTemplate")
.readonly("swapProgram")
.writable_signer("payer")
.writable("pool")
.writable("receivedTokens")
.data(data::literal([IX_RUN]))
.data(data::bytes(input("innerRun"))),
)
// Straight after the call.
.step(step::let_("received", return_data(ReadType::U64)))
.step(
step::invoke("vaultProgram")
.writable_signer("payer")
.writable("pool")
.data(data::literal(DEPOSIT_DISCRIMINATOR))
.data(data::u64(var("received"))),
)
}/// `innerRun` is what the inner template's own run would carry: its inputs, encoded.
pub fn nested_swap_then_deposit(
template: Pubkey,
payer: Pubkey,
pool: Pubkey,
received_tokens: Pubkey,
minimum_out: u64,
swap_data: &[u8],
) -> RunResult {
// The same stand-ins as the template.
const INNER_TEMPLATE: Pubkey = Pubkey::new_from_array([7; 32]);
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const VAULT_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
let inner_run = templates::swap_and_return_what_arrived()
.compile()?
.run_inputs()
.input("minimumOut", minimum_out)
.input("swapData", swap_data)
.encode_inputs()?;
let instruction = templates::nested_swap_then_deposit()
.compile()?
.run(template)
.input("innerRun", inner_run)
.account("ballista", ballista_sdk::ID)
.account("innerTemplate", INNER_TEMPLATE)
.account("swapProgram", SWAP_PROGRAM)
.account("vaultProgram", VAULT_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.account("receivedTokens", received_tokens)
.instruction()?;
Ok(instruction)
}- Pin the inner template by address. Return data shows only that a Ballista run set it, not which template ran. A finalized template can't be changed or closed, so its address fixes what it does. Upload the inner template first, and put its address in place of the stand-in.
- Pass the inner run's accounts as its own run would: the inner template account, then its accounts in the order it declares them. An account the inner run needs as a signer must be declared and passed as one by the outer template.
- Read the return data straight after the call, which has no
when, as the output rules require. - It costs a call frame. The inner run is frame
2,so its own calls start at frame3of Solana's 5, and the inner run and its calls all count toward the transaction's instruction trace. In return, the inner run has its own registers, VM instructions and64CPIs.