Skip to content

Assertions and snapshots ​

This page shows how a template checks conditions with step.require, and how a snapshot lets it compare an account before and after a call.

step.require(condition) fails the whole transaction unless its condition is true. A condition can combine account reads, inputs, the clock, checked arithmetic, comparisons, and the boolean operators and, or, and not. Inputs come from the caller, so a check that an input can turn off, such as one joined with or to an input flag, protects nothing.

Why snapshots exist ​

Reading an account is live: expression.accountField(vault, 'lamports') reads the balance again every time it appears. After a CPI changes the balance, every read returns the new value, and the old one is gone.

step.snapshot(name, value) reads once and keeps the result for the rest of the run. Compare it with a live read after the call to check what the call did. step.let is the same step, named for values that aren't a before-and-after; read either with expression.snapshot or expression.variable.

  • Scope. A name is readable only by the steps after it. One defined inside a loop is gone after the loop.
  • Fixed. A name can't be reassigned, except a variable a loop carries, which step.assign updates.
  • Free. Names cost no accounts or rent and vanish with the transaction.

Exact lamport delta ​

This template requires the sender and recipient to be different accounts, records the sender's balance in lamports, transfers an amount, then requires that the balance fell by exactly that amount.

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

/** Transfer `amount` lamports, then require the sender's balance fell by exactly that much. */
export const exactLamportDelta = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    sender: { signer: true, writable: true },
    recipient: { writable: true },
  },
  steps: [
    // A run accepts one account in both slots, so require two different accounts.
    step.require(
      expression.notEqual(expression.accountKey('sender'), expression.accountKey('recipient')),
      'distinctAccounts',
    ),
    step.snapshot('before', expression.accountField(account.fixed('sender'), 'lamports')),
    systemTransfer({
      systemProgram: account.fixed('systemProgram'),
      from: account.fixed('sender'),
      to: account.fixed('recipient'),
      lamports: expression.input('amount'),
    }),
    step.require(
      expression.equal(
        expression.accountField(account.fixed('sender'), 'lamports'),
        expression.subtract(expression.snapshot('before'), expression.input('amount')),
      ),
    ),
  ],
});
ts
import type { Address } from '@solana/kit';

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

/** If the balance does not fall by exactly `amount`, the run fails and the transfer is undone. */
export function runExactLamportDelta(run: {
  templateAddress: Address;
  sender: Address;
  recipient: Address;
  amount: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(exactLamportDelta),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      sender: { address: run.sender },
      recipient: { address: run.recipient },
    },
  });
}
rs
/// Transfer `amount` lamports, then require the sender's balance fell by exactly that much.
pub fn exact_lamport_delta() -> Template {
    Template::new()
        .input("amount", Type::U64)
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account("sender", account::signer().writable())
        .account("recipient", account::writable())
        // A run accepts one account in both slots, so require two different accounts.
        .step(
            step::require(account_key("sender").ne(account_key("recipient")))
                .label("distinctAccounts"),
        )
        .step(step::snapshot("before", lamports("sender")))
        .step(system_transfer(
            "systemProgram",
            "sender",
            "recipient",
            input("amount"),
        ))
        .step(step::require(
            lamports("sender").eq(snapshot("before") - input("amount")),
        ))
}
rs
/// If the balance does not fall by exactly `amount`, the run fails and the transfer is undone.
pub fn exact_lamport_delta(
    template: Pubkey,
    sender: Pubkey,
    recipient: Pubkey,
    amount: u64,
) -> RunResult {
    let instruction = templates::exact_lamport_delta()
        .compile()?
        .run(template)
        .input("amount", amount)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("sender", sender)
        .account("recipient", recipient)
        .instruction()?;
    Ok(instruction)
}

The first require refuses one account in both slots. A run accepts that and checks each slot on its own, so a check that adds up changes across accounts could count one account twice. Here an alias would only fail the exact check, at a less clear step. See Aliased accounts.

Token amount delta ​

The same check works for tokens. An SPL token account stores its balance as a u64 at byte offset 64. Exact token debit is the complete template, in TypeScript and Rust.

ts
step.snapshot(
  'sourceBefore',
  expression.accountData(account.fixed('source'), 64, 'u64'),
),
tokenTransfer({ /* ... */ }),
step.require(
  expression.equal(
    expression.accountData(account.fixed('source'), 64, 'u64'),
    expression.subtract(expression.snapshot('sourceBefore'), expression.input('amount')),
  ),
),

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS