Skip to content

Token-account patterns ​

Templates that create, pay into, close and check SPL token accounts. An ATA (associated token account) is the standard token account for a given wallet and mint. Each shows the template and the code that runs it, in TypeScript and Rust.

Most of these can also be done with plain instructions in one transaction. Templates earn their place when they read a balance or a flag while the transaction runs, as in forward the whole token balance and consolidate only the funded accounts.

Some recipes read a token account's balance straight from its data. In the SPL Token account layout, the balance is a u64 at byte offset 64. When a template reads raw bytes like this, have it also pin the Token Program's address and require the account's owner and a minimum data length of 165 bytes. Those pins still admit a 355-byte Token multisig. A transfer from or to one fails, but where no transfer would, require the length to be exactly 165. See what a pin proves.

Assert, create, then transfer ​

For each recipient, check that the destination is the recipient's ATA, create it if it doesn't exist yet, then transfer amount tokens to it. An ATA's address is a PDA of the Associated Token Account program, and its seeds are the owner, the token program and the mint. assertAta derives that address and fails the run if the account passed in doesn't match. If any step fails, the whole run reverts.

Each recipient is one row of a batch: two accounts, the recipient's wallet and its ATA. step.forEach runs the three steps once per row.

ts
import {
  ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES,
  SYSTEM_PROGRAM_ADDRESS_BYTES,
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  assertAta,
  defineTemplate,
  ensureAssociatedTokenAccount,
  expression,
  step,
  tokenTransfer,
} from '@jac0xb/ballista';

/** For each row: prove the destination is the recipient's ATA, create it if missing, then pay. */
export const assertCreateThenTransfer = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    associatedTokenProgram: { executable: true, address: ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    mint: { owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 82 },
    payer: { signer: true, writable: true },
    authority: { signer: true },
    source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
  },
  batch: {
    maxIterations: 8,
    minIterations: 1,
    // Each row is two accounts: the recipient's wallet, then its ATA.
    row: { recipient: {}, destinationAta: { writable: true } },
  },
  steps: [
    step.forEach([
      assertAta({
        associatedTokenAccount: account.iteration('destinationAta'),
        owner: account.iteration('recipient'),
        mint: account.fixed('mint'),
        tokenProgram: account.fixed('tokenProgram'),
        associatedTokenProgram: account.fixed('associatedTokenProgram'),
      }),
      ensureAssociatedTokenAccount({
        associatedTokenProgram: account.fixed('associatedTokenProgram'),
        payer: account.fixed('payer'),
        associatedTokenAccount: account.iteration('destinationAta'),
        owner: account.iteration('recipient'),
        mint: account.fixed('mint'),
        systemProgram: account.fixed('systemProgram'),
        tokenProgram: account.fixed('tokenProgram'),
      }),
      tokenTransfer({
        tokenProgram: account.fixed('tokenProgram'),
        source: account.fixed('source'),
        destination: account.iteration('destinationAta'),
        authority: account.fixed('authority'),
        amount: expression.input('amount'),
      }),
    ]),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');

/** `recipients` pairs each wallet with its ATA for `mint`. */
export function runAssertCreateThenTransfer(run: {
  templateAddress: Address;
  mint: Address;
  payer: Address;
  authority: Address;
  source: Address;
  recipients: readonly { wallet: Address; ata: Address }[];
  amount: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(assertCreateThenTransfer),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      associatedTokenProgram: { address: ASSOCIATED_TOKEN_PROGRAM },
      tokenProgram: { address: TOKEN_PROGRAM },
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      mint: { address: run.mint },
      payer: { address: run.payer },
      authority: { address: run.authority },
      source: { address: run.source },
    },
    batchRows: run.recipients.map((recipient) => ({
      recipient: { address: recipient.wallet },
      destinationAta: { address: recipient.ata },
    })),
  });
}
rs
/// For each row: prove the destination is the recipient's ATA, create it if missing, then pay.
pub fn assert_create_then_transfer() -> Template {
    Template::new()
        .input("amount", Type::U64)
        .account(
            "associatedTokenProgram",
            account::program(ASSOCIATED_TOKEN_PROGRAM_ID),
        )
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account(
            "mint",
            account::readonly()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(82),
        )
        .account("payer", account::signer().writable())
        .account("authority", account::signer())
        .account(
            "source",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .batch(
            Batch::new(8)
                .min_iterations(1)
                // Each row is two accounts: the recipient's wallet, then its ATA.
                .account("recipient", account::readonly())
                .account("destinationAta", account::writable()),
        )
        .step(
            step::for_each()
                .step(assert_ata(
                    account::iteration("destinationAta"),
                    account::iteration("recipient"),
                    "mint",
                    "tokenProgram",
                    "associatedTokenProgram",
                ))
                .step(ensure_associated_token_account(AtaAccounts {
                    associated_token_program: "associatedTokenProgram".into(),
                    payer: "payer".into(),
                    associated_token_account: account::iteration("destinationAta"),
                    owner: account::iteration("recipient"),
                    mint: "mint".into(),
                    system_program: "systemProgram".into(),
                    token_program: "tokenProgram".into(),
                }))
                .step(token_transfer(
                    "tokenProgram",
                    "source",
                    account::iteration("destinationAta"),
                    "authority",
                    input("amount"),
                )),
        )
}
rs
/// `rows` pairs each recipient's wallet with its ATA for `mint`.
pub fn assert_create_then_transfer(
    template: Pubkey,
    mint: Pubkey,
    payer: Pubkey,
    authority: Pubkey,
    source: Pubkey,
    rows: &[(Pubkey, Pubkey)],
    amount: u64,
) -> RunResult {
    let instruction = templates::assert_create_then_transfer()
        .compile()?
        .run(template)
        .input("amount", amount)
        .account("associatedTokenProgram", ASSOCIATED_TOKEN_PROGRAM_ID)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("mint", mint)
        .account("payer", payer)
        .account("authority", authority)
        .account("source", source)
        .rows(rows.iter().map(|(recipient, destination_ata)| {
            Row::new()
                .account("recipient", *recipient)
                .account("destinationAta", *destination_ata)
        }))
        .instruction()?;
    Ok(instruction)
}

Existing-account token payroll ​

Send the same token amount to up to 32 token accounts that already exist. Each destination must be owned by the Token Program and be at least 165 bytes long, the size of a token account. A Token multisig passes both checks, but the transfer to it fails.

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

/** Pay `amount` tokens from one source to each of 1 to 32 existing token accounts. */
export const existingAccountTokenPayroll = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
    authority: { signer: true },
  },
  batch: {
    maxIterations: 32,
    minIterations: 1,
    row: { destination: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 } },
  },
  steps: [
    step.forEach([
      tokenTransfer({
        tokenProgram: account.fixed('tokenProgram'),
        source: account.fixed('source'),
        destination: account.iteration('destination'),
        authority: account.fixed('authority'),
        amount: expression.input('amount'),
      }),
    ]),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

export function runExistingAccountTokenPayroll(run: {
  templateAddress: Address;
  source: Address;
  authority: Address;
  destinations: readonly Address[];
  amount: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(existingAccountTokenPayroll),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      tokenProgram: { address: TOKEN_PROGRAM },
      source: { address: run.source },
      authority: { address: run.authority },
    },
    batchRows: run.destinations.map((destination) => ({ destination: { address: destination } })),
  });
}
rs
/// Pay `amount` tokens from one source to each of 1 to 32 existing token accounts.
pub fn existing_account_token_payroll() -> Template {
    Template::new()
        .input("amount", Type::U64)
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account(
            "source",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .account("authority", account::signer())
        .batch(
            Batch::new(32).min_iterations(1).account(
                "destination",
                account::writable()
                    .owner(TOKEN_PROGRAM_ID)
                    .min_data_length(165),
            ),
        )
        .step(step::for_each().step(token_transfer(
            "tokenProgram",
            "source",
            account::iteration("destination"),
            "authority",
            input("amount"),
        )))
}
rs
pub fn existing_account_token_payroll(
    template: Pubkey,
    source: Pubkey,
    authority: Pubkey,
    destinations: &[Pubkey],
    amount: u64,
) -> RunResult {
    let instruction = templates::existing_account_token_payroll()
        .compile()?
        .run(template)
        .input("amount", amount)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("source", source)
        .account("authority", authority)
        .rows(
            destinations
                .iter()
                .map(|destination| Row::new().account("destination", *destination)),
        )
        .instruction()?;
    Ok(instruction)
}

Conditional ATA setup ​

Create an ATA only if it doesn't exist yet. ensureAssociatedTokenAccount calls the ATA program's Create instruction with a when condition that the account is empty, so a repeat run skips the call instead of failing. It uses Create rather than CreateIdempotent so that the condition is what does the work. CreateIdempotent sent as a plain instruction does the same job without a template.

ts
import {
  ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES,
  SYSTEM_PROGRAM_ADDRESS_BYTES,
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  defineTemplate,
  ensureAssociatedTokenAccount,
} from '@jac0xb/ballista';

/** Create the wallet's ATA with the ATA program's `Create`, only if it does not exist yet. */
export const conditionalAtaSetup = defineTemplate({
  accounts: {
    associatedTokenProgram: { executable: true, address: ASSOCIATED_TOKEN_PROGRAM_ADDRESS_BYTES },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    mint: { owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 82 },
    payer: { signer: true, writable: true },
    wallet: {},
    ata: { writable: true },
  },
  steps: [
    ensureAssociatedTokenAccount({
      associatedTokenProgram: account.fixed('associatedTokenProgram'),
      payer: account.fixed('payer'),
      associatedTokenAccount: account.fixed('ata'),
      owner: account.fixed('wallet'),
      mint: account.fixed('mint'),
      systemProgram: account.fixed('systemProgram'),
      tokenProgram: account.fixed('tokenProgram'),
    }),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM = address('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');

/** Re-sending this after the ATA exists skips `Create` instead of failing. */
export function runConditionalAtaSetup(run: {
  templateAddress: Address;
  mint: Address;
  payer: Address;
  wallet: Address;
  ata: Address;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(conditionalAtaSetup),
    templateAddress: run.templateAddress,
    accounts: {
      associatedTokenProgram: { address: ASSOCIATED_TOKEN_PROGRAM },
      tokenProgram: { address: TOKEN_PROGRAM },
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      mint: { address: run.mint },
      payer: { address: run.payer },
      wallet: { address: run.wallet },
      ata: { address: run.ata },
    },
  });
}
rs
/// Create the wallet's ATA with the ATA program's `Create`, only if it does not exist yet.
pub fn conditional_ata_setup() -> Template {
    Template::new()
        .account(
            "associatedTokenProgram",
            account::program(ASSOCIATED_TOKEN_PROGRAM_ID),
        )
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
        .account(
            "mint",
            account::readonly()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(82),
        )
        .account("payer", account::signer().writable())
        .account("wallet", account::readonly())
        .account("ata", account::writable())
        .step(ensure_associated_token_account(AtaAccounts {
            associated_token_program: "associatedTokenProgram".into(),
            payer: "payer".into(),
            associated_token_account: "ata".into(),
            owner: "wallet".into(),
            mint: "mint".into(),
            system_program: "systemProgram".into(),
            token_program: "tokenProgram".into(),
        }))
}
rs
/// Re-sending this after the ATA exists skips `Create` instead of failing.
pub fn conditional_ata_setup(
    template: Pubkey,
    mint: Pubkey,
    payer: Pubkey,
    wallet: Pubkey,
    ata: Pubkey,
) -> RunResult {
    let instruction = templates::conditional_ata_setup()
        .compile()?
        .run(template)
        .account("associatedTokenProgram", ASSOCIATED_TOKEN_PROGRAM_ID)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("mint", mint)
        .account("payer", payer)
        .account("wallet", wallet)
        .account("ata", ata)
        .instruction()?;
    Ok(instruction)
}

Close empty token accounts ​

For each token account in the list, read its balance (the u64 at byte offset 64) and call SPL Token's CloseAccount only if it is zero. Accounts that still hold tokens are skipped, so one funded account doesn't fail the whole run. Closing an account returns its rent (the SOL deposit that keeps an account open) to rentDestination.

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

/** SPL Token `CloseAccount`: a single discriminator byte, no arguments. */
const CLOSE_ACCOUNT = Uint8Array.of(9);

/** Close each row's token account whose balance is zero; skip the others. */
export const closeEmptyTokenAccounts = defineTemplate({
  accounts: {
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    rentDestination: { writable: true },
    authority: { signer: true },
  },
  batch: {
    maxIterations: 16,
    minIterations: 1,
    row: { tokenAccount: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 } },
  },
  steps: [
    step.forEach([
      step.invoke({
        program: account.fixed('tokenProgram'),
        programAddress: TOKEN_PROGRAM_ADDRESS_BYTES,
        accounts: [
          { account: account.iteration('tokenAccount'), writable: true, signer: false },
          { account: account.fixed('rentDestination'), writable: true, signer: false },
          { account: account.fixed('authority'), writable: false, signer: true },
        ],
        data: [data.literal(CLOSE_ACCOUNT)],
        when: expression.equal(expression.accountData(account.iteration('tokenAccount'), 64, 'u64'), expression.u64(0)),
      }),
    ]),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

/** Pass every candidate; the run closes only those holding zero tokens. */
export function runCloseEmptyTokenAccounts(run: {
  templateAddress: Address;
  rentDestination: Address;
  authority: Address;
  tokenAccounts: readonly Address[];
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(closeEmptyTokenAccounts),
    templateAddress: run.templateAddress,
    accounts: {
      tokenProgram: { address: TOKEN_PROGRAM },
      rentDestination: { address: run.rentDestination },
      authority: { address: run.authority },
    },
    batchRows: run.tokenAccounts.map((tokenAccount) => ({ tokenAccount: { address: tokenAccount } })),
  });
}
rs
/// Close each row's token account whose balance is zero; skip the others.
pub fn close_empty_token_accounts() -> Template {
    /// SPL Token `CloseAccount`: a single discriminator byte, no arguments.
    const CLOSE_ACCOUNT: [u8; 1] = [9];

    Template::new()
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("rentDestination", account::writable())
        .account("authority", account::signer())
        .batch(
            Batch::new(16).min_iterations(1).account(
                "tokenAccount",
                account::writable()
                    .owner(TOKEN_PROGRAM_ID)
                    .min_data_length(165),
            ),
        )
        .step(
            step::for_each().step(
                step::invoke("tokenProgram")
                    .program_address(TOKEN_PROGRAM_ID)
                    .writable(account::iteration("tokenAccount"))
                    .writable("rentDestination")
                    .signer("authority")
                    .data(data::literal(CLOSE_ACCOUNT))
                    .when(
                        account_data(account::iteration("tokenAccount"), 64, ReadType::U64)
                            .eq(u64(0)),
                    ),
            ),
        )
}
rs
/// Pass every candidate; the run closes only those holding zero tokens.
pub fn close_empty_token_accounts(
    template: Pubkey,
    rent_destination: Pubkey,
    authority: Pubkey,
    token_accounts: &[Pubkey],
) -> RunResult {
    let instruction = templates::close_empty_token_accounts()
        .compile()?
        .run(template)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("rentDestination", rent_destination)
        .account("authority", authority)
        .rows(
            token_accounts
                .iter()
                .map(|account| Row::new().account("tokenAccount", *account)),
        )
        .instruction()?;
    Ok(instruction)
}

Exact token debit ​

Transfer tokens, then check that the source balance dropped by exactly amount. The template records the balance before the transfer and compares it afterwards; any other change fails the whole run.

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

/** Transfer `amount` tokens, then require the source fell by exactly that much. */
export const exactTokenDebit = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    source: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
    destination: { writable: true, owner: TOKEN_PROGRAM_ADDRESS_BYTES, minDataLength: 165 },
    authority: { signer: true },
  },
  steps: [
    step.snapshot('before', expression.accountData(account.fixed('source'), 64, 'u64')),
    tokenTransfer({
      tokenProgram: account.fixed('tokenProgram'),
      source: account.fixed('source'),
      destination: account.fixed('destination'),
      authority: account.fixed('authority'),
      amount: expression.input('amount'),
    }),
    step.require(
      expression.equal(
        expression.accountData(account.fixed('source'), 64, 'u64'),
        expression.subtract(expression.snapshot('before'), expression.input('amount')),
      ),
    ),
  ],
});
ts
import { address, type Address } from '@solana/kit';

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

const TOKEN_PROGRAM = address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');

export function runExactTokenDebit(run: {
  templateAddress: Address;
  source: Address;
  destination: Address;
  authority: Address;
  amount: bigint;
}) {
  return buildKitRunInstruction({
    compiled: compileTemplate(exactTokenDebit),
    templateAddress: run.templateAddress,
    inputs: { amount: run.amount },
    accounts: {
      tokenProgram: { address: TOKEN_PROGRAM },
      source: { address: run.source },
      destination: { address: run.destination },
      authority: { address: run.authority },
    },
  });
}
rs
/// Transfer `amount` tokens, then require the source fell by exactly that much.
pub fn exact_token_debit() -> Template {
    Template::new()
        .input("amount", Type::U64)
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account(
            "source",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .account(
            "destination",
            account::writable()
                .owner(TOKEN_PROGRAM_ID)
                .min_data_length(165),
        )
        .account("authority", account::signer())
        .step(step::snapshot(
            "before",
            account_data("source", 64, ReadType::U64),
        ))
        .step(token_transfer(
            "tokenProgram",
            "source",
            "destination",
            "authority",
            input("amount"),
        ))
        .step(step::require(
            account_data("source", 64, ReadType::U64).eq(snapshot("before") - input("amount")),
        ))
}
rs
pub fn exact_token_debit(
    template: Pubkey,
    source: Pubkey,
    destination: Pubkey,
    authority: Pubkey,
    amount: u64,
) -> RunResult {
    let instruction = templates::exact_token_debit()
        .compile()?
        .run(template)
        .input("amount", amount)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("source", source)
        .account("destination", destination)
        .account("authority", authority)
        .instruction()?;
    Ok(instruction)
}

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS