Skip to content

Getting started ​

In this walkthrough you upload a template to a local Solana validator and run it, in TypeScript or Rust. The template sweeps a vault: it sends everything above a minimum balance, the reserve, to another account. The caller picks the reserve; the template reads the balance while the transaction runs and works out the amount.

Not audited

Ballista is on mainnet, but no third party has audited it. Use it at your own risk. Details

Each step adds to the same file. Pick a language on any code block and the rest follow.

Install ​

Neither the program nor the SDKs are published yet, so you build them from the repository. Clone it, build the program, and start a local validator with the program loaded. This needs the Solana CLI:

bash
git clone https://github.com/Jac0xb/ballista.git
cargo build-sbf --manifest-path ballista/programs/ballista/Cargo.toml
solana-test-validator --reset \
  --bpf-program BLSTAxXJ6fXnsQ2hxZmFQ1MYQaxpdqAtRNuo6ckY2mfD ballista/target/deploy/ballista.so

Leave it running. In another terminal, in the same directory, set up your project:

bash
# Node.js 22 or later, and pnpm to build the SDK. Pack it, then install the package.
pnpm --dir ballista install && pnpm --dir ballista build:sdk
(cd ballista/clients/js && npm pack)
mkdir sweep && cd sweep && npm init -y
npm install ../ballista/clients/js/jac0xb-ballista-1.0.0.tgz @solana/kit@8
bash
# ballista-sdk uses solana-program 4.1.0, so the client crates must match it.
cargo new sweep && cd sweep
cargo add ballista-sdk --git https://github.com/Jac0xb/ballista
cargo add solana-program@=4.1.0 solana-rpc-client@4 solana-keypair@3 solana-signer@3 \
  solana-transaction@4 solana-transaction-error@3 solana-commitment-config@3

When the file is complete, run it with npx tsx sweep.mts or cargo run.

1. Connect ​

ts
import {
  airdropFactory,
  appendTransactionMessageInstructions,
  assertIsTransactionWithBlockhashLifetime,
  createSolanaRpc,
  createSolanaRpcSubscriptions,
  createTransactionMessage,
  generateKeyPairSigner,
  lamports,
  pipe,
  sendAndConfirmTransactionFactory,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
  signTransactionMessageWithSigners,
  type Instruction,
  type TransactionSigner,
} from '@solana/kit';

// The local validator's default RPC and WebSocket addresses.
const rpc = createSolanaRpc('http://127.0.0.1:8899');
const rpcSubscriptions = createSolanaRpcSubscriptions('ws://127.0.0.1:8900');
const airdrop = airdropFactory({ rpc, rpcSubscriptions });
const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });

// A new wallet with 1 SOL from the validator's faucet.
async function fundedSigner(): Promise<TransactionSigner> {
  const signer = await generateKeyPairSigner();
  await airdrop({
    recipientAddress: signer.address,
    lamports: lamports(1_000_000_000n),
    commitment: 'confirmed',
  });
  return signer;
}

// The transaction `send` builds, before its instructions: version 0, signed and paid for
// by `feePayer`.
async function emptyMessage(feePayer: TransactionSigner) {
  const { value: blockhash } = await rpc.getLatestBlockhash().send();
  return pipe(
    createTransactionMessage({ version: 0 }),
    (m) => setTransactionMessageFeePayerSigner(feePayer, m),
    (m) => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
  );
}

// Send instructions in one transaction that `feePayer` signs and pays for.
async function send(feePayer: TransactionSigner, instructions: Instruction[]) {
  const empty = await emptyMessage(feePayer);
  const message = appendTransactionMessageInstructions(instructions, empty);
  const transaction = await signTransactionMessageWithSigners(message);
  assertIsTransactionWithBlockhashLifetime(transaction);
  await sendAndConfirm(transaction, { commitment: 'confirmed' });
}
rs
use solana_commitment_config::CommitmentConfig;
use solana_keypair::Keypair;
use solana_program::instruction::Instruction;
use solana_rpc_client::rpc_client::RpcClient;
use solana_signer::Signer;
use solana_transaction::Transaction;

// The local validator's default RPC address.
let rpc = RpcClient::new_with_commitment(
    "http://127.0.0.1:8899".to_string(),
    CommitmentConfig::confirmed(),
);

// A new wallet with 1 SOL from the validator's faucet.
let funded_signer = || -> Result<Keypair, Box<dyn std::error::Error>> {
    let signer = Keypair::new();
    let signature = rpc.request_airdrop(&signer.pubkey(), 1_000_000_000)?;
    rpc.poll_for_signature(&signature)?;
    Ok(signer)
};

// Send instructions in one legacy transaction that `fee_payer` signs and pays for.
let send = |fee_payer: &Keypair, instructions: &[Instruction]| {
    let blockhash = rpc.get_latest_blockhash()?;
    let transaction = Transaction::new_signed_with_payer(
        instructions,
        Some(&fee_payer.pubkey()),
        &[fee_payer],
        blockhash,
    );
    rpc.send_and_confirm_transaction(&transaction)
};

fundedSigner creates a wallet and asks the validator's faucet for 1 SOL, which works only on a local validator or devnet. send signs and sends a transaction: version 0 in TypeScript, legacy in Rust. In TypeScript, emptyMessage is that transaction before its instructions; step 3 uses it to size the upload.

2. Define the template ​

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

const sweep = defineTemplate({
  // The caller picks the reserve, in lamports, on every run.
  inputs: { reserve: { type: 'u64' } },
  accounts: {
    // Must be exactly the System program, so a caller can't swap in another.
    systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
    // The account swept. It signs, so only its owner can run the sweep.
    vault: { signer: true, writable: true },
    // Where the swept lamports go.
    destination: { writable: true },
  },
  steps: [
    // Read the vault's balance while the transaction runs.
    step.let('balance', expression.accountField(account.fixed('vault'), 'lamports')),
    // Stop the whole run unless there is something above the reserve.
    step.require(
      expression.greaterThan(expression.variable('balance'), expression.input('reserve')),
      'aboveReserve',
    ),
    // Send everything above the reserve: balance minus reserve.
    systemTransfer({
      systemProgram: account.fixed('systemProgram'),
      from: account.fixed('vault'),
      to: account.fixed('destination'),
      lamports: expression.subtract(
        expression.variable('balance'),
        expression.input('reserve'),
      ),
    }),
  ],
});

// Compile to the bytes you upload in step 3.
const compiled = compileTemplate(sweep);
rs
use ballista_sdk::template::prelude::*;

let sweep = Template::new()
    // The caller picks the reserve, in lamports, on every run.
    .input("reserve", Type::U64)
    // Must be exactly the System program, so a caller can't swap in another.
    .account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
    // The account swept. It signs, so only its owner can run the sweep.
    .account("vault", account::signer().writable())
    // Where the swept lamports go.
    .account("destination", account::writable())
    // Read the vault's balance while the transaction runs.
    .step(step::let_("balance", lamports("vault")))
    // Stop the whole run unless there is something above the reserve.
    .step(step::require(var("balance").gt(input("reserve"))).label("aboveReserve"))
    // Send everything above the reserve: balance minus reserve.
    .step(system_transfer(
        "systemProgram",
        "vault",
        "destination",
        var("balance") - input("reserve"),
    ));

// Compile to the bytes you upload in step 3.
let compiled = sweep.compile()?;

The template takes one input, reserve, and three accounts. Each account entry says what the caller's account must be: signer means it must sign the transaction, writable that the transaction must let it change, executable that it must be a program, and address fixes it to one exact address. The steps read the vault's balance in lamports, stop the run unless it is above the reserve, and transfer the difference. The label aboveReserve names the check in error messages.

Compiling is deterministic. The Rust tab compiles to the same bytes, with the same checks: every program the template calls must have a fixed address, and every account whose data it reads a fixed owner or address. A template built in either language can be uploaded and run from either.

3. Upload ​

ts
import { buildKitTemplateUploadPlan } from '@jac0xb/ballista/kit';

// The creator uploads the template and pays the rent for its account.
const creator = await fundedSigner();
const upload = await buildKitTemplateUploadPlan({
  compiled,
  creator: creator.address,
  templateId: 7,
  // Size each instruction to fit the transactions `send` builds.
  transactionMessage: await emptyMessage(creator),
});
for (const { instruction } of upload.instructions) {
  await send(creator, [instruction]);
}
console.log('uploaded to', upload.templateAddress);
rs
use ballista_sdk::{create_template_instruction, find_template_pda};

// The creator uploads the template and pays the rent for its account.
let creator = funded_signer()?;
let create = create_template_instruction(creator.pubkey(), 7, &compiled.bytes);
send(&creator, &[create])?;
// A template's address comes from its creator's address and its template ID.
let (template, _) = find_template_pda(&creator.pubkey(), 7);
println!("uploaded to {template}");

The upload stores the template in its own account, checks it, and finalizes it: locks it for good, so it can never change. The account's address is a PDA of the Ballista program, derived from the creator's address and the template ID. 7 is the template ID, which the creator picks: any number from 0 to 65,535 that the creator has not used yet.

A transaction holds at most 1,232 bytes, so a large template is uploaded in pieces, one transaction each (Limits):

  • In TypeScript, the plan fits every instruction to the transaction send builds: one CreateTemplate when the template fits, and otherwise a BeginTemplate, WriteTemplateChunks and a FinalizeTemplate.
  • In Rust, create_template_instruction puts the whole template in one transaction, which fits a template of up to 960 bytes. Template lifecycle uploads larger ones in pieces.

4. Run ​

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

// The vault is the account the template sweeps. It signs the run and pays the fee.
const vault = await fundedSigner();
// Any account can receive the lamports; here, another new wallet.
const destination = (await fundedSigner()).address;

// The caller picks the reserve; the template works out the amount.
function sweepInstruction(reserve: bigint) {
  return buildKitRunInstruction({
    compiled,
    templateAddress: upload.templateAddress,
    inputs: { reserve },
    accounts: {
      systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
      vault: { address: vault.address },
      destination: { address: destination },
    },
  });
}

await send(vault, [sweepInstruction(2_000_000n)]);
const { value: left } = await rpc.getBalance(vault.address).send();
console.log('vault keeps', left); // 2000000n
rs
// The vault is the account the template sweeps. It signs the run and pays the fee.
let vault = funded_signer()?;
// Any account can receive the lamports; here, another new wallet.
let destination = funded_signer()?.pubkey();

// The caller picks the reserve; the template works out the amount.
let sweep_instruction = |reserve: u64| {
    compiled
        .run(template)
        .input("reserve", reserve)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .account("vault", vault.pubkey())
        .account("destination", destination)
        .instruction()
};

send(&vault, &[sweep_instruction(2_000_000)?])?;
// Prints 2000000: the vault keeps exactly the reserve.
println!("vault keeps {}", rpc.get_balance(&vault.pubkey())?);

The vault and the creator are different wallets: anyone can run a finalized template, and the creator does not sign runs. The run instruction lists the template account first (both SDKs add it), then the template's accounts in the order it declares them, and carries the inputs in declaration order. Both SDKs take them by name, put them in that order, and check them against compiled. The vault signs because the template declares it as a signer. The run leaves the vault with exactly the 2,000,000-lamport reserve.

5. Read a failure ​

ts
import { isSolanaError, SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM } from '@solana/kit';
import { explainRunError } from '@jac0xb/ballista';

// The vault now holds less than 5,000,000 lamports, so the check fails.
try {
  await send(vault, [sweepInstruction(5_000_000n)]);
} catch (error) {
  const cause = error instanceof Error ? error.cause : undefined;
  if (!isSolanaError(cause, SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM)) throw error;
  const { code } = cause.context;
  console.log(code, explainRunError(code, compiled)?.message);
  // 202623 RequirementFailed at steps[1] (aboveReserve)
}
rs
use solana_program::instruction::InstructionError;
use solana_transaction_error::TransactionError;

// The vault now holds less than 5,000,000 lamports, so the check fails.
let error = send(&vault, &[sweep_instruction(5_000_000)?]).unwrap_err();
if let Some(TransactionError::InstructionError(_, InstructionError::Custom(code))) =
    error.get_transaction_error()
{
    println!("{code} {:?}", compiled.explain_error(code));
    // 202623 Some("RequirementFailed at steps[1] (aboveReserve)")
}

This run asks for a reserve larger than the vault's balance, so the require step stops it and nothing moves. The transaction fails with custom error 202623, which is 0x0003177F. Its low 16 bits, 6015, are the error kind RequirementFailed, and its high 16 bits, 3, are the context, which says where the failure happened. For RequirementFailed the context is the program counter: the index of the failing bytecode instruction. For other kinds it can be an account index or an input index instead; Error codes lists which.

explainRunError in TypeScript and compiled.explain_error in Rust use the compiled template's source map to name the step, steps[1], and its label.

6. Call your own program ​

The sweep calls the System program. To call your own program, give the template the program's address as 32 bytes and build the instruction data it expects. For an Anchor program, that is the instruction's 8-byte discriminator, then its arguments.

ts
import { address } from '@solana/kit';

import {
  account,
  addressBytes,
  anchorDiscriminator,
  compileTemplate,
  data,
  defineTemplate,
  expression,
  step,
} from '@jac0xb/ballista';

// Your program's address. This one is a placeholder.
const MY_PROGRAM = address('MyProgram1111111111111111111111111111111111');

const deposit = defineTemplate({
  inputs: { amount: { type: 'u64' } },
  accounts: {
    myProgram: { executable: true, address: addressBytes(MY_PROGRAM) },
    vault: { writable: true },
    authority: { signer: true },
  },
  steps: [
    step.invoke({
      program: account.fixed('myProgram'),
      accounts: [
        { account: account.fixed('vault'), writable: true, signer: false },
        { account: account.fixed('authority'), writable: false, signer: true },
      ],
      // An Anchor instruction's data: its 8-byte discriminator, then its arguments.
      data: [
        data.literal(anchorDiscriminator('deposit')),
        data.encode('u64', expression.input('amount')),
      ],
    }),
  ],
});

const compiled = compileTemplate(deposit);
rs
use ballista_sdk::{anchor_discriminator, template::prelude::*};

// Your program's address. This one is a placeholder.
let my_program = pubkey!("MyProgram1111111111111111111111111111111111");

let deposit = Template::new()
    .input("amount", Type::U64)
    .account("myProgram", account::program(my_program))
    .account("vault", account::writable())
    .account("authority", account::signer())
    .step(
        step::invoke("myProgram")
            .writable("vault")
            .signer("authority")
            // An Anchor instruction's data: its 8-byte discriminator, then its arguments.
            .data(data::literal(anchor_discriminator("deposit")))
            .data(data::u64(input("amount"))),
    );

let compiled = deposit.compile()?;

addressBytes turns a Kit address or a base58 string into the 32 bytes a template takes; Kit's getAddressEncoder().encode() returns a read-only array, which the template's types reject. anchorDiscriminator('deposit') is the first 8 bytes of sha256("global:deposit"). In Rust, account::program takes a Pubkey, and ballista_sdk::anchor_discriminator gives the same 8 bytes. Fixing the program's address, as here, stops a caller from passing a different program; Accounts and CPIs covers the rest of a call.

Run the shipped examples ​

Steps 1 to 5 ship as runnable programs in both languages. With the validator from Install running, run either from the repository you cloned. The TypeScript one needs pnpm install && pnpm build:sdk at its root first.

bash
pnpm --dir clients/js exec tsx examples/start/getting-started.ts
cargo run --manifest-path clients/rust/examples/getting-started/Cargo.toml

Next ​

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS