Safety guardrails
A guardrail is a check that runs in the same transaction as the operation it protects. This page shows five: a swap deadline and minimum output, a fixed program address and account owner, an oracle price band, a cap on how much SOL the payer can spend, and a check that an account is the one a program would derive.
If a guardrail fails, the whole transaction fails, and every CPI made before it is rolled back. Most of the examples use step.require, which stops the transaction unless its condition holds. Every example here guards a call to another protocol. So that the code compiles and runs as written, it calls marked stand-ins (the System program and its Transfer data); replace them with the protocol's own.
Deadline and minimum output
Before calling a swap program with route data the client built, check that the quote has not expired and that it promises at least the minimum output.
import { SYSTEM_PROGRAM_ADDRESS_BYTES, account, data, defineTemplate, expression, step } from '@jac0xb/ballista';
// Stand-in so the example runs as written: replace it with the swap program's address.
const SWAP_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
/** Forward the client's swap only if the quote is unexpired and promises at least `minimumOut`. */
export const deadlineAndMinimumOutput = defineTemplate({
inputs: {
deadline: { type: 'i64' },
quotedOut: { type: 'u64' },
minimumOut: { type: 'u64' },
routeData: { type: 'bytes', maxLength: 256 },
},
accounts: {
swapProgram: { executable: true, address: SWAP_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
},
steps: [
step.require(
expression.and(
expression.lessThanOrEqual(expression.clockUnixTimestamp(), expression.input('deadline')),
expression.greaterThanOrEqual(expression.input('quotedOut'), expression.input('minimumOut')),
),
),
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('routeData'))],
}),
],
});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-in
/** `routeData` is the swap instruction's data from your quote. */
export function runDeadlineAndMinimumOutput(run: {
templateAddress: Address;
payer: Address;
pool: Address;
deadline: bigint;
quotedOut: bigint;
minimumOut: bigint;
routeData: Uint8Array;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(deadlineAndMinimumOutput),
templateAddress: run.templateAddress,
inputs: {
deadline: run.deadline,
quotedOut: run.quotedOut,
minimumOut: run.minimumOut,
routeData: run.routeData,
},
accounts: {
swapProgram: { address: SWAP_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
},
});
}/// Forward the client's swap only if the quote is unexpired and promises at least `minimumOut`.
pub fn deadline_and_minimum_output() -> Template {
// Stand-in so the example runs as written: replace it with the swap program's address.
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
Template::new()
.input("deadline", Type::I64)
.input("quotedOut", Type::U64)
.input("minimumOut", Type::U64)
.input("routeData", Type::Bytes(256))
.account("swapProgram", account::program(SWAP_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.step(step::require(
clock_unix_timestamp()
.lte(input("deadline"))
.and(input("quotedOut").gte(input("minimumOut"))),
))
.step(
step::invoke("swapProgram")
.writable_signer("payer")
.writable("pool")
.data(data::bytes(input("routeData"))),
)
}/// `quote` is `(deadline, quoted_out, minimum_out)`; `route_data` is the swap instruction's data.
pub fn deadline_and_minimum_output(
template: Pubkey,
payer: Pubkey,
pool: Pubkey,
quote: (i64, u64, u64),
route_data: &[u8],
) -> RunResult {
const SWAP_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let (deadline, quoted_out, minimum_out) = quote;
let instruction = templates::deadline_and_minimum_output()
.compile()?
.run(template)
.input("deadline", deadline)
.input("quotedOut", quoted_out)
.input("minimumOut", minimum_out)
.input("routeData", route_data) // a u16 length, then the bytes
.account("swapProgram", SWAP_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.instruction()?;
Ok(instruction)
}clockUnixTimestamp is the network's clock when the transaction executes. The quoted and minimum outputs are both inputs from the caller, so this check only enforces what the client claims. To protect the output without trusting the caller, record the destination token account's balance with step.snapshot before the swap, and check how much it grew after the swap, as swap then deposit does.
Also cap the route's platform fee: whoever builds the run picks its account and rate, so require platformFeeBps to be at most a constant.
Pinned program and owner
Fix the address of each program the template calls, and the owner of each protocol account, in the template's account schema: its list of accounts and the rules each must meet.
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 protocol's address and the
// instruction's discriminator.
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const INSTRUCTION_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
/** Call one program, with the program address and the position's owner pinned in the schema. */
export const pinnedProgramAndOwner = defineTemplate({
inputs: { amount: { type: 'u64' } },
accounts: {
protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
payer: { signer: true, writable: true },
position: { writable: true, owner: PROTOCOL_PROGRAM, minDataLength: 128 },
},
steps: [
step.invoke({
program: account.fixed('protocolProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('position'), signer: false, writable: true },
],
data: [data.literal(INSTRUCTION_DISCRIMINATOR), data.encode('u64', expression.input('amount'))],
}),
],
});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
/** A different program, or a position with another owner, fails the run before its first step. */
export function runPinnedProgramAndOwner(run: {
templateAddress: Address;
payer: Address;
position: Address;
amount: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(pinnedProgramAndOwner),
templateAddress: run.templateAddress,
inputs: { amount: run.amount },
accounts: {
protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
payer: { address: run.payer },
position: { address: run.position },
},
});
}/// Call one program, with the program address and the position's owner pinned in the schema.
pub fn pinned_program_and_owner() -> Template {
// Stand-ins so the example runs as written: replace them with your protocol's address and the
// instruction's discriminator.
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const INSTRUCTION_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
Template::new()
.input("amount", Type::U64)
.account("protocolProgram", account::program(PROTOCOL_PROGRAM))
.account("payer", account::signer().writable())
.account(
"position",
account::writable()
.owner(PROTOCOL_PROGRAM)
.min_data_length(128),
)
.step(
step::invoke("protocolProgram")
.writable_signer("payer")
.writable("position")
.data(data::literal(INSTRUCTION_DISCRIMINATOR))
.data(data::u64(input("amount"))),
)
}/// A different program, or a position with another owner, fails the run before its first step.
pub fn pinned_program_and_owner(
template: Pubkey,
payer: Pubkey,
position: Pubkey,
amount: u64,
) -> RunResult {
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::pinned_program_and_owner()
.compile()?
.run(template)
.input("amount", amount)
.account("protocolProgram", PROTOCOL_PROGRAM)
.account("payer", payer)
.account("position", position)
.instruction()?;
Ok(instruction)
}executable: true with an address means the account must be that exact program. owner means the account must belong to the given program, minDataLength means it must hold at least that many bytes of data, and writable means the transaction may change it. If any account breaks its rules, the run fails before the first step, so a caller cannot swap in a different program, or an account another program owns.
The owner pin doesn't fix which account position is, or even its type: any account the protocol owns with at least 128 bytes passes, another user's position included. Before relying on its data, also check its type, by its discriminator or exact length, and its identity, as Canonical position account does with a derivation. See Pins.
Oracle price band
Read a price from an oracle account (an account in which a price feed publishes prices) and stop the run unless the price lies between a minimum and a maximum. PRICE_OFFSET is the byte offset of the price in the oracle's account layout. The oracle account's owner is fixed in the account schema, as in the previous example, so the price comes from the oracle program. That doesn't say which feed: one program owns every feed's accounts. The band is two inputs, so it guards the caller against a price move, not against whoever builds the transaction.
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 oracle program (the owner of the
// price account), the price's offset in its layout, and the protocol call. A real template also
// checks which feed the account holds and when its price was published.
const ORACLE_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const PRICE_OFFSET = 8;
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const INSTRUCTION_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const INSTRUCTION_ARGUMENT = 10_000n;
const price = expression.accountData(account.fixed('oracle'), PRICE_OFFSET, 'i64');
/** Call the protocol only while the oracle's price lies within `[minimumPrice, maximumPrice]`. */
export const oraclePriceBand = defineTemplate({
inputs: { minimumPrice: { type: 'i64' }, maximumPrice: { type: 'i64' } },
accounts: {
oracle: { owner: ORACLE_PROGRAM, minDataLength: 128 },
protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
},
steps: [
step.require(
expression.and(
expression.greaterThanOrEqual(price, expression.input('minimumPrice')),
expression.lessThanOrEqual(price, expression.input('maximumPrice')),
),
),
step.invoke({
program: account.fixed('protocolProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.literal(INSTRUCTION_DISCRIMINATOR), data.encode('u64', expression.u64(INSTRUCTION_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
/** The band is in the oracle's own units. */
export function runOraclePriceBand(run: {
templateAddress: Address;
oracle: Address;
payer: Address;
pool: Address;
minimumPrice: bigint;
maximumPrice: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(oraclePriceBand),
templateAddress: run.templateAddress,
inputs: { minimumPrice: run.minimumPrice, maximumPrice: run.maximumPrice },
accounts: {
oracle: { address: run.oracle },
protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
},
});
}/// Call the protocol only while the oracle's price lies within `[minimumPrice, maximumPrice]`.
pub fn oracle_price_band() -> Template {
// Stand-ins so the example runs as written: replace them with the oracle program (the owner of
// the price account), the price's offset in its layout, and the protocol call. A real template
// also checks which feed the account holds and when its price was published.
const ORACLE_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const PRICE_OFFSET: u32 = 8;
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const INSTRUCTION_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const INSTRUCTION_ARGUMENT: u64 = 10_000;
let price = account_data("oracle", PRICE_OFFSET, ReadType::I64);
Template::new()
.input("minimumPrice", Type::I64)
.input("maximumPrice", Type::I64)
.account(
"oracle",
account::readonly()
.owner(ORACLE_PROGRAM)
.min_data_length(128),
)
.account("protocolProgram", account::program(PROTOCOL_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.step(step::require(
price
.gte(input("minimumPrice"))
.and(price.lte(input("maximumPrice"))),
))
.step(
step::invoke("protocolProgram")
.writable_signer("payer")
.writable("pool")
.data(data::literal(INSTRUCTION_DISCRIMINATOR))
.data(data::u64(u64(INSTRUCTION_ARGUMENT))),
)
}/// `band` is `(minimum_price, maximum_price)` in the oracle's own units.
pub fn oracle_price_band(
template: Pubkey,
oracle: Pubkey,
payer: Pubkey,
pool: Pubkey,
band: (i64, i64),
) -> RunResult {
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::oracle_price_band()
.compile()?
.run(template)
.input("minimumPrice", band.0)
.input("maximumPrice", band.1)
.account("oracle", oracle)
.account("protocolProgram", PROTOCOL_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.instruction()?;
Ok(instruction)
}Layout, feed and freshness
Ballista does not understand oracle formats. The template must read the price at the offset the oracle protocol documents, check that the account holds the feed it expects, by its address or a feed ID in its data, and check the publish slot or timestamp so that a stale price is rejected. Act only on a fresh price does all three for Pyth.
Maximum lamport spend
Record a signer's balance in lamports before calling another program, and fail the whole transaction if the balance dropped by more than a limit. A signer is an account that signed the transaction; here it is the payer.
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 call to protect.
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const INSTRUCTION_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const INSTRUCTION_ARGUMENT = 10_000n;
/** Make the call, then fail the run if the payer's balance fell by more than `maximumSpend`. */
export const maximumLamportSpend = defineTemplate({
inputs: { maximumSpend: { type: 'u64' } },
accounts: {
protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
payer: { signer: true, writable: true },
pool: { writable: true },
},
steps: [
step.snapshot('before', expression.accountField(account.fixed('payer'), 'lamports')),
step.invoke({
program: account.fixed('protocolProgram'),
accounts: [
{ account: account.fixed('payer'), signer: true, writable: true },
{ account: account.fixed('pool'), signer: false, writable: true },
],
data: [data.literal(INSTRUCTION_DISCRIMINATOR), data.encode('u64', expression.u64(INSTRUCTION_ARGUMENT))],
}),
step.require(
expression.lessThanOrEqual(
expression.subtract(expression.snapshot('before'), expression.accountField(account.fixed('payer'), 'lamports')),
expression.input('maximumSpend'),
),
),
],
});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 runMaximumLamportSpend(run: {
templateAddress: Address;
payer: Address;
pool: Address;
maximumSpend: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(maximumLamportSpend),
templateAddress: run.templateAddress,
inputs: { maximumSpend: run.maximumSpend },
accounts: {
protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
payer: { address: run.payer },
pool: { address: run.pool },
},
});
}/// Make the call, then fail the run if the payer's balance fell by more than `maximumSpend`.
pub fn maximum_lamport_spend() -> Template {
// Stand-ins so the example runs as written: replace them with the protocol call to protect.
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const INSTRUCTION_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const INSTRUCTION_ARGUMENT: u64 = 10_000;
Template::new()
.input("maximumSpend", Type::U64)
.account("protocolProgram", account::program(PROTOCOL_PROGRAM))
.account("payer", account::signer().writable())
.account("pool", account::writable())
.step(step::snapshot("before", lamports("payer")))
.step(
step::invoke("protocolProgram")
.writable_signer("payer")
.writable("pool")
.data(data::literal(INSTRUCTION_DISCRIMINATOR))
.data(data::u64(u64(INSTRUCTION_ARGUMENT))),
)
.step(step::require(
(snapshot("before") - lamports("payer")).lte(input("maximumSpend")),
))
}pub fn maximum_lamport_spend(
template: Pubkey,
payer: Pubkey,
pool: Pubkey,
maximum_spend: u64,
) -> RunResult {
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let instruction = templates::maximum_lamport_spend()
.compile()?
.run(template)
.input("maximumSpend", maximum_spend)
.account("protocolProgram", PROTOCOL_PROGRAM)
.account("payer", payer)
.account("pool", pool)
.instruction()?;
Ok(instruction)
}step.snapshot saves the payer's balance before the call. After the call, the template reads the balance again and requires that it dropped by at most maximumSpend. Because the check runs after the call, it limits what the call actually spent, whatever instruction the client chose. Ballista's subtraction fails instead of going below zero, so the run also fails if the call leaves the payer with more lamports than before.
Canonical position account
Check that the position account the caller passed is the one the protocol derives for this owner and position ID. An owner check alone would accept any account the protocol owns that has a valid layout, including another position.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
assertPda,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
// Stand-ins so the example runs as written: replace them with your protocol's address and the
// instruction to call on the position.
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const INSTRUCTION_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const INSTRUCTION_ARGUMENT = 10_000n;
/** Require `position` to be the PDA the protocol derives from ("position", owner, positionId). */
export const canonicalPositionAccount = defineTemplate({
inputs: { positionId: { type: 'bytes', maxLength: 8 } },
accounts: {
protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
owner: { signer: true, writable: true },
position: { writable: true, owner: PROTOCOL_PROGRAM, minDataLength: 128 },
},
steps: [
assertPda({
account: account.fixed('position'),
program: account.fixed('protocolProgram'),
seeds: [
expression.bytes(new TextEncoder().encode('position')),
expression.accountField(account.fixed('owner'), 'key'),
expression.input('positionId'),
],
}),
step.invoke({
program: account.fixed('protocolProgram'),
accounts: [
{ account: account.fixed('owner'), signer: true, writable: true },
{ account: account.fixed('position'), signer: false, writable: true },
],
data: [data.literal(INSTRUCTION_DISCRIMINATOR), data.encode('u64', expression.u64(INSTRUCTION_ARGUMENT))],
}),
],
});import { address, getAddressEncoder, getProgramDerivedAddress, 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
/** Derives the position the same way the template checks it. */
export async function runCanonicalPositionAccount(run: {
templateAddress: Address;
owner: Address;
positionId: bigint;
}) {
const positionId = new Uint8Array(8);
new DataView(positionId.buffer).setBigUint64(0, run.positionId, true);
const [position] = await getProgramDerivedAddress({
programAddress: PROTOCOL_PROGRAM_ADDRESS,
seeds: ['position', getAddressEncoder().encode(run.owner), positionId],
});
return buildKitRunInstruction({
compiled: compileTemplate(canonicalPositionAccount),
templateAddress: run.templateAddress,
inputs: { positionId }, // a bytes input
accounts: {
protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
owner: { address: run.owner },
position: { address: position },
},
});
}/// Require `position` to be the PDA the protocol derives from ("position", owner, positionId).
pub fn canonical_position_account() -> Template {
// Stand-ins so the example runs as written: replace them with your protocol's address and the
// instruction to call on the position.
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
const INSTRUCTION_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
const INSTRUCTION_ARGUMENT: u64 = 10_000;
Template::new()
.input("positionId", Type::Bytes(8))
.account("protocolProgram", account::program(PROTOCOL_PROGRAM))
.account("owner", account::signer().writable())
.account(
"position",
account::writable()
.owner(PROTOCOL_PROGRAM)
.min_data_length(128),
)
.step(assert_pda(
"position",
"protocolProgram",
[bytes(b"position"), key("owner"), input("positionId")],
))
.step(
step::invoke("protocolProgram")
.writable_signer("owner")
.writable("position")
.data(data::literal(INSTRUCTION_DISCRIMINATOR))
.data(data::u64(u64(INSTRUCTION_ARGUMENT))),
)
}/// Derives the position the same way the template checks it.
pub fn canonical_position_account(template: Pubkey, owner: Pubkey, position_id: u64) -> RunResult {
const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template
let position_id = position_id.to_le_bytes();
let (position, _bump) = Pubkey::find_program_address(
&[b"position", owner.as_ref(), &position_id],
&PROTOCOL_PROGRAM,
);
let instruction = templates::canonical_position_account()
.compile()?
.run(template)
.input("positionId", position_id) // a bytes input
.account("protocolProgram", PROTOCOL_PROGRAM)
.account("owner", owner)
.account("position", position)
.instruction()?;
Ok(instruction)
}assertPda computes a PDA from the protocol program and the seeds, searching for the canonical bump, and the run fails if position has a different address. The caller derives the same address to build the account list, as both Run tabs show. PDA and ATA assertions covers the bump and what it costs.