Skip to content

Remember state between runs ​

A run's values are gone when it ends. To keep something between runs, such as what a caller has spent or who may run the template, a template declares a registry: a named set of fields. Each entry is an account holding one copy of those fields, picked by a 32-byte key the template computes; key it by the caller's address and each caller gets their own.

The first run to open an entry creates it, and the payer the template names pays its rent, never returned: 1,097,280 lamports for 16 bytes of fields. Only runs of its template can change an entry, and anyone can read it. This page builds a run counter, a daily spending limit and an allowlist; the rules are under Registries.

Declare a registry ​

This template counts each caller's runs. It declares a registry, runs, with one field, count. Every run opens the caller's own entry, creating it on the first run, and adds one to count.

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

/** Count each caller's runs, in an entry of their own. */
export const countRuns = defineTemplate({
  // Each registry and its fields: `runs`, with one u64 field, `count`.
  registries: { runs: { count: 'u64' } },
  accounts: {
    caller: { signer: true, writable: true },
    // The account holding the caller's entry in `runs`. Before the first step, every run checks
    // it, or creates it.
    callerRuns: account.registry('runs', {
      // Keyed by the caller, who must sign, so a caller opens only their own entry.
      // Leave `key` out for one entry that every run shares.
      key: expression.accountKey('caller'),
      // Pays the rent when a run creates the entry, and nothing after that.
      payer: 'caller',
    }),
    // Creating an entry calls the System program.
    systemProgram: account.systemProgram(),
  },
  steps: [
    // Read and write name the entry's account, not the registry, so a template can open two
    // entries of one registry. The write lands at once; if the run fails, Solana undoes it.
    step.setRegistry(
      'callerRuns',
      'count',
      expression.add(expression.registry('callerRuns', 'count'), expression.u64(1)),
    ),
  ],
});
rs
/// Count each caller's runs, in an entry of their own.
pub fn count_runs() -> Template {
    Template::new()
        // Each registry and its fields: `runs`, with one u64 field, `count`.
        .registry("runs", [("count", Type::U64)])
        .account("caller", account::signer().writable())
        // The account holding the caller's entry in `runs`. Before the first step, every run checks
        // it, or creates it. `"caller"` pays the rent when a run creates the entry, and nothing
        // after that.
        .account(
            "callerRuns",
            account::registry("runs", "caller")
                // Keyed by the caller, who must sign, so a caller opens only their own entry.
                // Leave `.key` out for one entry that every run shares.
                .key(account_key("caller")),
        )
        // Creating an entry calls the System program.
        .account("systemProgram", account::system_program())
        // Read and write name the entry's account, not the registry, so a template can open two
        // entries of one registry. The write lands at once; if the run fails, Solana undoes it.
        .step(step::set_registry(
            "callerRuns",
            "count",
            registry("callerRuns", "count") + u64(1),
        ))
}

Two entries of one registry can't share a key in a run; see Opening an entry.

Don't let the caller choose the key

The caller sets every input. An entry keyed by an input is one the caller chooses, so a caller could open a fresh entry, with a fresh limit, on every run. Key an entry that limits callers by a signer's address, or leave the key out.

A daily limit per caller ​

Let each caller send at most 1 SOL at once, with the allowance refilling over about a day. The rateLimit helper (rate_limit in Rust) returns the steps.

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

/** Send SOL, at most 1 SOL at once per caller, refilling over about a day. */
export const dailyLimitPerCaller = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  // The two fields rateLimit uses: `spent`, and `lastSpend`, a Unix time.
  registries: { limits: { spent: 'u64', lastSpend: 'i64' } },
  accounts: {
    caller: { signer: true, writable: true },
    recipient: { writable: true },
    // Each caller's own entry, keyed by their address.
    callerLimit: account.registry('limits', { key: expression.accountKey('caller'), payer: 'caller' }),
    systemProgram: account.systemProgram(),
  },
  steps: [
    // Each run refills `spent` by the seconds since `lastSpend` times `refillPerSecond` (not below
    // zero), adds `amount`, requires the total to be at most `cap`, then writes both fields back.
    // Over the limit, the run fails with RequirementFailed (6015) at `withinRateLimit`, and nothing
    // moves.
    ...rateLimit({
      registry: 'callerLimit', // the entry's account, not the registry
      cap: expression.u64(1_000_000_000), // 1 SOL
      // 1 SOL refills in 86,401 seconds. The limit refills continuously, so over any 24 hours a
      // caller can send up to about 2 SOL: the full 1 SOL plus what refills.
      refillPerSecond: expression.u64(11_574),
      amount: expression.input('amount'),
    }),
    systemTransfer({
      systemProgram: account.fixed('systemProgram'),
      from: account.fixed('caller'),
      to: account.fixed('recipient'),
      lamports: expression.input('amount'),
    }),
  ],
});
ts
import { type Address } from '@solana/kit';

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

export async function runDailyLimitPerCaller(run: {
  templateAddress: Address;
  caller: Address;
  recipient: Address;
  amount: bigint;
}) {
  const compiled = compileTemplate(dailyLimitPerCaller);
  // The caller's entry: registry `limits`, keyed by the caller's address, as the template keys it.
  const [callerLimit] = await findRegistryEntryAddress(
    run.templateAddress,
    registryIndex(compiled, 'limits'),
    run.caller,
  );
  return buildKitRunInstruction({
    compiled,
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      caller: { address: run.caller },
      recipient: { address: run.recipient },
      callerLimit: { address: callerLimit },
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
    },
  });
}
rs
/// Send SOL, at most 1 SOL at once per caller, refilling over about a day.
pub fn daily_limit_per_caller() -> Template {
    Template::new()
        .input("amount", Type::U64)
        // The two fields rate_limit uses: `spent`, and `lastSpend`, a Unix time.
        .registry("limits", [("spent", Type::U64), ("lastSpend", Type::I64)])
        .account("caller", account::signer().writable())
        .account("recipient", account::writable())
        // Each caller's own entry, keyed by their address.
        .account(
            "callerLimit",
            account::registry("limits", "caller").key(account_key("caller")),
        )
        .account("systemProgram", account::system_program())
        // Each run refills `spent` by the seconds since `lastSpend` times the refill rate (not below
        // zero), adds `amount`, requires the total to be at most the cap, then writes both fields
        // back. Over the limit, the run fails with RequirementFailed (6015) at `withinRateLimit`,
        // and nothing moves.
        .steps(rate_limit(
            "callerLimit",      // the entry's account, not the registry
            u64(1_000_000_000), // cap: 1 SOL
            // 1 SOL refills in 86,401 seconds. The limit refills continuously, so over any 24 hours
            // a caller can send up to about 2 SOL: the full 1 SOL plus what refills.
            u64(11_574),
            input("amount"),
        ))
        .step(system_transfer(
            "systemProgram",
            "caller",
            "recipient",
            input("amount"),
        ))
}
rs
pub fn daily_limit_per_caller(
    template: Pubkey,
    caller: Pubkey,
    recipient: Pubkey,
    amount: u64,
) -> RunResult {
    let compiled = templates::daily_limit_per_caller().compile()?;
    // The caller's entry in `limits`, keyed by the caller's address.
    let limits = compiled.registry_index("limits").unwrap();
    let (caller_limit, _) = find_registry_entry_address(&template, limits, &caller.to_bytes());
    let instruction = compiled
        .run(template)
        .input("amount", amount)
        .account("caller", caller)
        .account("recipient", recipient)
        .account("callerLimit", caller_limit)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .instruction()?;
    Ok(instruction)
}

rateLimit refuses a cap or refillPerSecond that the caller could set, such as an input. It can't see the key, so key the entry by a signer's address, as here, or leave the key out for one limit that every caller shares. Give each rateLimit in a template its own name: it names the requirement, within<Name>, so a failure says which limit was hit. The full rules are under Spending limits.

An allowlist ​

This template lets only listed callers make a call. Its author keeps the list: each member has an entry with an ok flag, keyed by the member's address. The author's runs add or remove members, and anyone else's run fails unless their own flag is set. The guarded call is a System program transfer, standing in for the call you'd protect, so the example runs as written.

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

// Stand-ins so the example runs as written: replace AUTHOR with the author's address, and the
// program and data with the call the list guards.
const AUTHOR = new Uint8Array(32).fill(7);
const PROTOCOL_PROGRAM = SYSTEM_PROGRAM_ADDRESS_BYTES;
const CALL_DATA = Uint8Array.of(2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0);

// True only in the author's runs: `caller` must sign, so only the author can match AUTHOR. A
// template has no `if` step; it branches with `expression.select(condition, a, b)`, which gives `a`
// when the condition is true and `b` otherwise.
const isAuthor = expression.equal(expression.accountKey('caller'), expression.pubkey(AUTHOR));

/** Only listed callers make the call. The author's runs add or remove a member instead. */
export const listedCallersOnly = defineTemplate({
  // Every run passes both inputs, but only the author's runs read them.
  inputs: {
    member: { type: 'pubkey' }, // author's runs: whose entry to set
    allow: { type: 'bool' }, // author's runs: the flag to set
  },
  registries: { allowed: { ok: 'bool' } },
  accounts: {
    caller: { signer: true, writable: true },
    // One entry per run: the member's in the author's runs, the caller's own in everyone else's,
    // so only the author picks the key. The author pays the rent for each new member's entry.
    entry: account.registry('allowed', {
      key: expression.select(isAuthor, expression.input('member'), expression.accountKey('caller')),
      payer: 'caller',
    }),
    systemProgram: account.systemProgram(),
    protocolProgram: { executable: true, address: PROTOCOL_PROGRAM },
    pool: { writable: true },
  },
  steps: [
    // The author's runs write `allow` into the member's entry; `false` removes them, and the entry
    // stays, since entries are never closed. A write can't be skipped, so everyone else's runs
    // write back the flag already there, which changes nothing.
    step.setRegistry(
      'entry',
      'ok',
      expression.select(isAuthor, expression.input('allow'), expression.registry('entry', 'ok')),
    ),
    // Everyone but the author must be listed. Anyone else fails at `listed` with RequirementFailed
    // (6015), and the entry their run created is undone too, so they pay no rent.
    step.require(expression.or(isAuthor, expression.registry('entry', 'ok')), 'listed'),
    // The call the list guards. The author's runs skip it, so they only set flags.
    step.invoke({
      program: account.fixed('protocolProgram'),
      accounts: [
        { account: account.fixed('caller'), signer: true, writable: true },
        { account: account.fixed('pool'), signer: false, writable: true },
      ],
      data: [data.literal(CALL_DATA)],
      when: expression.not(isAuthor),
    }),
  ],
});
ts
import { getAddressEncoder, type Address } from '@solana/kit';

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

const PROTOCOL_PROGRAM_ADDRESS = SYSTEM_PROGRAM_ADDRESS; // the same stand-in

export async function runListedCallersOnly(run: {
  templateAddress: Address;
  caller: Address;
  pool: Address;
  /** The author's runs only: the member to add or remove. */
  set?: { member: Address; allow: boolean };
}) {
  const compiled = compileTemplate(listedCallersOnly);
  // The key the template computes: the member in the author's runs, the caller in everyone else's.
  const key = run.set?.member ?? run.caller;
  const [entry] = await findRegistryEntryAddress(run.templateAddress, registryIndex(compiled, 'allowed'), key);
  return buildKitRunInstruction({
    compiled,
    templateAddress: run.templateAddress,
    // Every run passes both inputs. Only the author's runs read them.
    inputs: { member: Uint8Array.from(getAddressEncoder().encode(key)), allow: run.set?.allow ?? false },
    accounts: {
      caller: { address: run.caller },
      entry: { address: entry },
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      protocolProgram: { address: PROTOCOL_PROGRAM_ADDRESS },
      pool: { address: run.pool },
    },
  });
}
rs
/// Only listed callers make the call. The author's runs add or remove a member instead.
pub fn listed_callers_only() -> Template {
    // Stand-ins so the example runs as written: replace AUTHOR with the author's address, and the
    // program and data with the call the list guards.
    const AUTHOR: [u8; 32] = [7; 32];
    const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID;
    const CALL_DATA: [u8; 12] = [2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0];

    // True only in the author's runs: `caller` must sign, so only the author can match AUTHOR. A
    // template has no `if` step; it branches with `select(condition, a, b)`, which gives `a` when
    // the condition is true and `b` otherwise.
    let is_author = account_key("caller").eq(pubkey(AUTHOR));

    Template::new()
        // Every run passes both inputs, but only the author's runs read them.
        .input("member", Type::Pubkey) // author's runs: whose entry to set
        .input("allow", Type::Bool) // author's runs: the flag to set
        .registry("allowed", [("ok", Type::Bool)])
        .account("caller", account::signer().writable())
        // One entry per run: the member's in the author's runs, the caller's own in everyone
        // else's, so only the author picks the key. The author pays the rent for each new member's
        // entry.
        .account(
            "entry",
            account::registry("allowed", "caller").key(select(
                &is_author,
                input("member"),
                account_key("caller"),
            )),
        )
        .account("systemProgram", account::system_program())
        .account("protocolProgram", account::program(PROTOCOL_PROGRAM))
        .account("pool", account::writable())
        // The author's runs write `allow` into the member's entry; `false` removes them, and the
        // entry stays, since entries are never closed. A write can't be skipped, so everyone else's
        // runs write back the flag already there, which changes nothing.
        .step(step::set_registry(
            "entry",
            "ok",
            select(&is_author, input("allow"), registry("entry", "ok")),
        ))
        // Everyone but the author must be listed. Anyone else fails at `listed` with
        // RequirementFailed (6015), and the entry their run created is undone too, so they pay no
        // rent.
        .step(step::require(is_author.clone().or(registry("entry", "ok"))).label("listed"))
        // The call the list guards. The author's runs skip it, so they only set flags.
        .step(
            step::invoke("protocolProgram")
                .writable_signer("caller")
                .writable("pool")
                .data(data::literal(CALL_DATA))
                .when(is_author.not()),
        )
}
rs
/// `set` is for the author's runs only: the member to add or remove, and the flag to set.
pub fn listed_callers_only(
    template: Pubkey,
    caller: Pubkey,
    pool: Pubkey,
    set: Option<(Pubkey, bool)>,
) -> RunResult {
    const PROTOCOL_PROGRAM: Pubkey = SYSTEM_PROGRAM_ID; // the same stand-in

    let compiled = templates::listed_callers_only().compile()?;
    // The key the template computes: the member in the author's runs, the caller in everyone else's.
    let (member, allow) = set.unwrap_or((caller, false));
    let allowed = compiled.registry_index("allowed").unwrap();
    let (entry, _) = find_registry_entry_address(&template, allowed, &member.to_bytes());
    let instruction = compiled
        .run(template)
        // Every run passes both inputs. Only the author's runs read them.
        .input("member", member)
        .input("allow", allow)
        .account("caller", caller)
        .account("entry", entry)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("protocolProgram", PROTOCOL_PROGRAM)
        .account("pool", pool)
        .instruction()?;
    Ok(instruction)
}

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS