Skip to content

Conditional calls ​

If one instruction fails, the whole transaction fails, and checking the state before sending doesn't help: it can change before the transaction runs. A template can give one call a when condition, checked while it runs. If it is false, the call is skipped and the run carries on.

The four examples below claim, liquidate, top up and initialize only when they should. when works on step.invoke, which makes a CPI, and on helpers such as systemTransfer. Calls to other protocols use marked stand-ins; replace them with the protocol's own.

Claim only when there is something ​

Call a protocol's claim instruction only when its rewards account shows a pending amount above zero. PENDING_OFFSET is the byte offset of that amount in the rewards account. A keeper (a bot that sends routine transactions for a protocol) can send this on a schedule without checking first.

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 rewards program's address, its
// claim instruction data, and the offset of the pending amount in its rewards account.
const REWARDS_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const CLAIM_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const CLAIM_ARGUMENT = 10_000n;
const PENDING_OFFSET = 8;

/** Call the claim instruction only when the rewards account shows a pending amount. */
export const claimOnlyWhenThereIsSomething = defineTemplate({
  accounts: {
    rewardsProgram: { executable: true, address: REWARDS_PROGRAM },
    rewards: { owner: REWARDS_PROGRAM, minDataLength: 128 },
    claimant: { signer: true, writable: true },
    destination: { writable: true },
  },
  steps: [
    step.invoke({
      program: account.fixed('rewardsProgram'),
      accounts: [
        { account: account.fixed('claimant'), signer: true, writable: true },
        { account: account.fixed('destination'), signer: false, writable: true },
      ],
      data: [data.literal(CLAIM_DISCRIMINATOR), data.encode('u64', expression.u64(CLAIM_ARGUMENT))],
      when: expression.greaterThan(
        expression.accountData(account.fixed('rewards'), PENDING_OFFSET, 'u64'),
        expression.u64(0),
      ),
    }),
  ],
});
ts
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

/** Safe to send on a schedule: with nothing pending, the run succeeds without calling claim. */
export function runClaimOnlyWhenThereIsSomething(run: {
  templateAddress: Address;
  rewards: Address;
  claimant: Address;
  destination: Address;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(claimOnlyWhenThereIsSomething),
    templateAddress: run.templateAddress,
    accounts: {
      rewardsProgram: { address: REWARDS_PROGRAM_ADDRESS },
      rewards: { address: run.rewards },
      claimant: { address: run.claimant },
      destination: { address: run.destination },
    },
  });
}
rs
/// Call the claim instruction only when the rewards account shows a pending amount.
pub fn claim_only_when_there_is_something() -> Template {
    // Stand-ins so the example runs as written: replace them with the rewards program's address,
    // its claim instruction data, and the offset of the pending amount in its rewards account.
    const REWARDS_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const CLAIM_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
    const CLAIM_ARGUMENT: u64 = 10_000;
    const PENDING_OFFSET: u32 = 8;

    Template::new()
        .account("rewardsProgram", account::program(REWARDS_PROGRAM))
        .account(
            "rewards",
            account::readonly()
                .owner(REWARDS_PROGRAM)
                .min_data_length(128),
        )
        .account("claimant", account::signer().writable())
        .account("destination", account::writable())
        .step(
            step::invoke("rewardsProgram")
                .writable_signer("claimant")
                .writable("destination")
                .data(data::literal(CLAIM_DISCRIMINATOR))
                .data(data::u64(u64(CLAIM_ARGUMENT)))
                .when(account_data("rewards", PENDING_OFFSET, ReadType::U64).gt(u64(0))),
        )
}
rs
/// Safe to send on a schedule: with nothing pending, the run succeeds without calling claim.
pub fn claim_only_when_there_is_something(
    template: Pubkey,
    rewards: Pubkey,
    claimant: Pubkey,
    destination: Pubkey,
) -> RunResult {
    const REWARDS_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template

    let instruction = templates::claim_only_when_there_is_something()
        .compile()?
        .run(template)
        .account("rewardsProgram", REWARDS_PROGRAM)
        .account("rewards", rewards)
        .account("claimant", claimant)
        .account("destination", destination)
        .instruction()?;
    Ok(instruction)
}

Most protocols treat a claim of nothing as an error, and an error reverts the whole transaction. With when, an empty epoch is a no-op instead of a failure.

Liquidate only when unhealthy ​

Liquidate a lending position only when its health value is below a threshold the caller passes. The template reads the health value from the position account at HEALTH_OFFSET.

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 lending program's address, its
// liquidate instruction data, and the offset of the health value in its position account.
const LENDING_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const LIQUIDATE_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const LIQUIDATE_ARGUMENT = 10_000n;
const HEALTH_OFFSET = 8;

/** Liquidate only when the position's health value is below `threshold`. */
export const liquidateOnlyWhenUnhealthy = defineTemplate({
  inputs: { threshold: { type: 'u64' } },
  accounts: {
    lendingProgram: { executable: true, address: LENDING_PROGRAM },
    position: { owner: LENDING_PROGRAM, minDataLength: 128 },
    liquidator: { signer: true, writable: true },
    vault: { writable: true },
  },
  steps: [
    step.invoke({
      program: account.fixed('lendingProgram'),
      accounts: [
        { account: account.fixed('liquidator'), signer: true, writable: true },
        { account: account.fixed('vault'), signer: false, writable: true },
      ],
      data: [data.literal(LIQUIDATE_DISCRIMINATOR), data.encode('u64', expression.u64(LIQUIDATE_ARGUMENT))],
      when: expression.lessThan(
        expression.accountData(account.fixed('position'), HEALTH_OFFSET, 'u64'),
        expression.input('threshold'),
      ),
    }),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

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

export function runLiquidateOnlyWhenUnhealthy(run: {
  templateAddress: Address;
  position: Address;
  liquidator: Address;
  vault: Address;
  threshold: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(liquidateOnlyWhenUnhealthy),
    templateAddress: run.templateAddress,
    inputs: { threshold: run.threshold },
    accounts: {
      lendingProgram: { address: LENDING_PROGRAM_ADDRESS },
      position: { address: run.position },
      liquidator: { address: run.liquidator },
      vault: { address: run.vault },
    },
  });
}
rs
/// Liquidate only when the position's health value is below `threshold`.
pub fn liquidate_only_when_unhealthy() -> Template {
    // Stand-ins so the example runs as written: replace them with the lending program's address,
    // its liquidate instruction data, and the offset of the health value in its position account.
    const LENDING_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const LIQUIDATE_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
    const LIQUIDATE_ARGUMENT: u64 = 10_000;
    const HEALTH_OFFSET: u32 = 8;

    Template::new()
        .input("threshold", Type::U64)
        .account("lendingProgram", account::program(LENDING_PROGRAM))
        .account(
            "position",
            account::readonly()
                .owner(LENDING_PROGRAM)
                .min_data_length(128),
        )
        .account("liquidator", account::signer().writable())
        .account("vault", account::writable())
        .step(
            step::invoke("lendingProgram")
                .writable_signer("liquidator")
                .writable("vault")
                .data(data::literal(LIQUIDATE_DISCRIMINATOR))
                .data(data::u64(u64(LIQUIDATE_ARGUMENT)))
                .when(
                    account_data("position", HEALTH_OFFSET, ReadType::U64).lt(input("threshold")),
                ),
        )
}
rs
pub fn liquidate_only_when_unhealthy(
    template: Pubkey,
    position: Pubkey,
    liquidator: Pubkey,
    vault: Pubkey,
    threshold: u64,
) -> RunResult {
    const LENDING_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template

    let instruction = templates::liquidate_only_when_unhealthy()
        .compile()?
        .run(template)
        .input("threshold", threshold)
        .account("lendingProgram", LENDING_PROGRAM)
        .account("position", position)
        .account("liquidator", liquidator)
        .account("vault", vault)
        .instruction()?;
    Ok(instruction)
}

Keepers that watch lending positions often send the same liquidation for the same position. Only the first can succeed. The others execute after the position has already changed. With a plain liquidation instruction, each of those transactions fails. With when, they succeed and skip the call, so any other work in them still takes effect.

Top up to a target ​

Bring a bot's balance up to target lamports from a funder. The template reads the bot's balance while it runs and sends the difference. When the bot already has target or more, the transfer is skipped.

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

/** Bring the bot's balance up to `target` lamports; send nothing when it already has that much. */
export const topUpToATarget = defineTemplate({
  inputs: { target: { type: 'u64' } },
  accounts: {
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    funder: { signer: true, writable: true },
    bot: { writable: true },
  },
  steps: [
    step.let('botBalance', expression.accountField(account.fixed('bot'), 'lamports')),
    systemTransfer({
      systemProgram: account.fixed('systemProgram'),
      from: account.fixed('funder'),
      to: account.fixed('bot'),
      // target - botBalance. The amount is worked out even when `when` skips the transfer, so the
      // `min` keeps it from going below zero, which would fail the run.
      lamports: expression.subtract(
        expression.input('target'),
        expression.min(expression.variable('botBalance'), expression.input('target')),
      ),
      when: expression.lessThan(expression.variable('botBalance'), expression.input('target')),
    }),
  ],
});
ts
import type { Address } from '@solana/kit';

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

export function runTopUpToATarget(run: { templateAddress: Address; funder: Address; bot: Address; target: bigint }) {
  return buildKitRunInstruction({
    compiled: compileTemplate(topUpToATarget),
    templateAddress: run.templateAddress,
    inputs: { target: run.target },
    accounts: {
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      funder: { address: run.funder },
      bot: { address: run.bot },
    },
  });
}
rs
/// Bring the bot's balance up to `target` lamports; send nothing when it already has that much.
pub fn top_up_to_a_target() -> Template {
    Template::new()
        .input("target", Type::U64)
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account("funder", account::signer().writable())
        .account("bot", account::writable())
        .step(step::let_("botBalance", lamports("bot")))
        .step(
            system_transfer(
                "systemProgram",
                "funder",
                "bot",
                // target - botBalance. The amount is worked out even when `when` skips the
                // transfer, so the `min` keeps it from going below zero, which would fail the run.
                input("target") - var("botBalance").min(input("target")),
            )
            .when(var("botBalance").lt(input("target"))),
        )
}
rs
pub fn top_up_to_a_target(template: Pubkey, funder: Pubkey, bot: Pubkey, target: u64) -> RunResult {
    let instruction = templates::top_up_to_a_target()
        .compile()?
        .run(template)
        .input("target", target)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("funder", funder)
        .account("bot", bot)
        .instruction()?;
    Ok(instruction)
}

A plain transfer fixes its amount when the transaction is built, from a balance read off chain. By the time it lands the balance may have changed, so the bot ends up above or below the target.

The amount uses min because a call's data is worked out even when when skips the call. target - botBalance alone would go below zero and fail the run.

Initialize only if missing ​

Create an account only if it does not exist yet.

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 your program's address and its
// initialize instruction data.
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const INITIALIZE_DISCRIMINATOR = Uint8Array.of(2, 0, 0, 0);
const INITIALIZE_ARGUMENT = 10_000n;

/** Call the initialize instruction only when the position account holds no data yet. */
export const initializeOnlyIfMissing = defineTemplate({
  accounts: {
    protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
    payer: { signer: true, writable: true },
    position: { writable: true },
  },
  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(INITIALIZE_DISCRIMINATOR), data.encode('u64', expression.u64(INITIALIZE_ARGUMENT))],
      when: expression.accountField(account.fixed('position'), 'isEmpty'),
    }),
  ],
});
ts
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 same instruction whether or not the position exists yet. */
export function runInitializeOnlyIfMissing(run: { templateAddress: Address; payer: Address; position: Address }) {
  return buildKitRunInstruction({
    compiled: compileTemplate(initializeOnlyIfMissing),
    templateAddress: run.templateAddress,
    accounts: {
      protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
      payer: { address: run.payer },
      position: { address: run.position },
    },
  });
}
rs
/// Call the initialize instruction only when the position account holds no data yet.
pub fn initialize_only_if_missing() -> Template {
    // Stand-ins so the example runs as written: replace them with your program's address and its
    // initialize instruction data.
    const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const INITIALIZE_DISCRIMINATOR: [u8; 4] = [2, 0, 0, 0];
    const INITIALIZE_ARGUMENT: u64 = 10_000;

    Template::new()
        .account("protocolProgram", account::program(PROTOCOL_PROGRAM))
        .account("payer", account::signer().writable())
        .account("position", account::writable())
        .step(
            step::invoke("protocolProgram")
                .writable_signer("payer")
                .writable("position")
                .data(data::literal(INITIALIZE_DISCRIMINATOR))
                .data(data::u64(u64(INITIALIZE_ARGUMENT)))
                .when(is_empty("position")),
        )
}
rs
/// The same instruction whether or not the position exists yet.
pub fn initialize_only_if_missing(template: Pubkey, payer: Pubkey, position: Pubkey) -> RunResult {
    const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in as the template

    let instruction = templates::initialize_only_if_missing()
        .compile()?
        .run(template)
        .account("protocolProgram", PROTOCOL_PROGRAM)
        .account("payer", payer)
        .account("position", position)
        .instruction()?;
    Ok(instruction)
}

isEmpty is true when the account holds no data, as it does before it is created. A few programs offer an idempotent Create, one that succeeds even when the account already exists. For the others, the caller must know whether the account exists, and still be right when the transaction executes.

Only calls take when ​

when belongs to step.invoke and the helpers that build one. Every other step runs whenever the run reaches it:

  • A registry write has no condition. To change a field only sometimes, write expression.select(condition, newValue, currentValue), which writes the current value back otherwise, as the allowlist does.
  • An emit has no condition. Every emit the run reaches is logged.
  • A return-data read must come straight after a call without when. After a guarded call, the compiler refuses it, and so does the verifier.
  • A value that depends on a condition is a select, not a skipped step.

The full rules are under Registries and Output.

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS