Rust SDK
The ballista-sdk crate, in clients/rust, authors templates, derives template and registry entry addresses, builds the instructions that upload and run a template, and decodes errors and a run's logged output.
Templates are written with ballista_sdk::template, which mirrors the TypeScript SDK's defineTemplate and compileTemplate. Inputs, accounts and variables keep the same camelCase names, functions are snake_case, and a template compiles to the same bytes with the same checks. Tests hold every docs and protocol template to the TypeScript compiler's bytes.
Install
The crate is not on crates.io yet. Add it from the repository:
[dependencies]
ballista-sdk = { git = "https://github.com/Jac0xb/ballista" }
# The SDK's instructions and addresses are solana-program 4.1.0 types; use the same version.
solana-program = "=4.1.0"cargo add ballista-sdk --git https://github.com/Jac0xb/ballista writes the same line, and Cargo.lock pins the commit you build against.
The examples in clients/rust/examples run as they are:
cargo run -p ballista-sdk --example docs_templates # every guide template, compiled
cargo run -p ballista-sdk --example docs_runs # a run of each
cargo run -p ballista-sdk --example protocol_templates # the protocol templatesAuthoring
use ballista_sdk::template::prelude::*; brings in everything below, plus Pubkey, pubkey! and the program IDs. This template pays each batch row's recipient and keeps the total within a budget:
/// Pays `amount` lamports to each batch row's recipient, keeps a running total, and requires the
/// total to stay within `budget`.
pub fn payroll() -> Template {
use ballista_sdk::template::prelude::*;
Template::new()
.input("amount", Type::U64)
.input("budget", Type::U64)
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("treasury", account::signer().writable())
.batch(
Batch::new(30)
.min_iterations(1)
.account("recipient", account::writable()),
)
.step(step::let_("total", u64(0)))
.step(
step::for_each()
.step(system_transfer(
"systemProgram",
"treasury",
account::iteration("recipient"),
input("amount"),
))
.step(step::assign("total", var("total") + input("amount")))
.carry("total"),
)
.step(step::require(var("total").lte(input("budget"))).label("withinBudget"))
}The template
Template::new(), then.input(name, Type),.registry(name, fields),.account(name, ..),.batch(..),.account_group(name),.emit_event()and.step(..)or.steps(..).TypeisBool,U64,I64,U128,PubkeyorBytes(max_length).Batch::new(max).min_iterations(n).account(..).input(..)declares the rows.
Accounts
account::signer(),writable(),readonly(),program(address),system_program().- Chain
.signer(),.writable(),.address(..),.owner(..),.min_data_length(n). account::registry(registry, payer).key(expr)holds a registry entry.- In a step, a
&strnames a fixed account, andaccount::iteration(name)the current row's.
Steps (step::)
let_,snapshot,assign,require,set_registry,emit,set_return_data.invoke(program), then.writable(..),.signer(..),.readonly(..),.writable_signer(..),.data(..),.account_group(name)and.when(condition).for_each()andrepeat(count, max), then.step(..)and.carry(name)..label("name")on any step names it in errors.
Data and expressions
data::literal(bytes),data::u8todata::u128,data::i64,data::pubkey,data::bool,data::bytes.- Expression functions are the TypeScript ones in snake_case:
input,var,lamports,account_data,clock_unix_timestamp,pda,registry,return_data,instruction_program, and the rest. group_length(group),group_any(group, filter)andgroup_count(group, filter)take aGroupFilter::new().program(..).equals(offset, value).except_key(..), with an optional.min_data_length(n).- Operators build arithmetic:
a + b,-,*,/,%,&,|,^,<<,>>. - Methods build comparisons and logic:
.eq,.ne,.lt,.lte,.gt,.gte,.and,.or,.not,.min,.max,.mul_div,.cast. Prefer.not()to!, which negates a whole chain.
Helpers
system_transfer,token_transfer,assert_pda,assert_ata.create_associated_token_accountandensure_associated_token_account, which take anAtaAccounts { .. }with named fields.ed25519_signatureandrate_limit, as the TypeScript helpers of the same names.
Template language says what each step and expression does.
Compiling
template.compile() returns a CompiledTemplate, or a CompileError with the TypeScript compiler's message. It refuses a call to a program with no pinned address, a data read of an account that pins neither owner nor address, and a template past a limit. .unsafe_unpinned() on an account turns the pin rules off for it.
bytesandhashare what you upload.statshas the sizes: bytes, instructions, registers, calls.source(pc)andexplain_error(code)name the step a failure points at.registry_index(name)gives a registry's index, for its entry addresses.
The on-chain program checks none of the pin rules. ProgramView::parse(&compiled.bytes)?.verify() runs the checks it does run when it finalizes a template; the trust model lists them.
Output, introspection and registries
emit logs a tagged line, set_return_data returns bytes to the caller, and emit_event makes every successful run log its run event:
/// Ends a template like the payroll above: logs the tag `PAID` and `total`, and returns `total`
/// to the caller. `emit_event` makes every successful run log its run event as well.
pub fn log_and_return(template: Template) -> Template {
use ballista_sdk::template::prelude::*;
template
.emit_event()
.step(step::emit([
data::literal(b"PAID"),
data::u64(var("total")),
]))
.step(step::set_return_data([data::u64(var("total"))]))
}Introspection reads the transaction's other instructions through the Instructions sysvar, declared as an account pinned to INSTRUCTIONS_SYSVAR_ID:
/// Requires the instruction just before the run's to call the Ed25519 program.
pub fn after_an_ed25519_instruction() -> Template {
use ballista_sdk::template::prelude::*;
let previous = current_instruction_index("instructions") - u64(1);
Template::new()
.account(
"instructions",
account::readonly().address(INSTRUCTIONS_SYSVAR_ID),
)
.step(step::require(
instruction_program("instructions", previous).eq(pubkey(ED25519_PROGRAM_ID)),
))
}A registry keeps state between runs, in entries Ballista owns:
/// Adds `amount` to the caller's running total, which must stay within 1 SOL. The total lives in
/// an entry of `totals` keyed by the caller, who pays for the entry the first time.
pub fn capped_total() -> Template {
use ballista_sdk::template::prelude::*;
let total = registry("callerTotal", "sent") + input("amount");
Template::new()
.input("amount", Type::U64)
.registry("totals", [("sent", Type::U64)])
.account("caller", account::signer().writable())
.account(
"callerTotal",
account::registry("totals", "caller").key(account_key("caller")),
)
.account("systemProgram", account::system_program())
.step(step::require(total.lte(u64(1_000_000_000))).label("withinCap"))
.step(step::set_registry("callerTotal", "sent", total))
}Reading a run's output decodes logs and return data from a transaction.
Addresses and hashing
/// The template `creator` publishes as `template_id`, the same ID under another deployment, and
/// `caller`'s entry of the template's registry `totals`.
pub fn addresses(
creator: &Pubkey,
template_id: u16,
other_program_id: &Pubkey,
caller: &Pubkey,
) -> Result<[Pubkey; 3], Box<dyn std::error::Error>> {
use ballista_sdk::{
find_registry_entry_address, find_template_pda, find_template_pda_for_program,
};
let (template, _bump) = find_template_pda(creator, template_id);
let (elsewhere, _) = find_template_pda_for_program(creator, template_id, other_program_id);
// A registry's index is its position among the template's registries.
let totals = capped_total().compile()?.registry_index("totals").unwrap();
let (entry, _) = find_registry_entry_address(&template, totals, &caller.to_bytes());
Ok([template, elsewhere, entry])
}find_template_pda(creator, template_id)derives a template's address from the seeds"template", the creator's address, and the16-bit template ID.find_registry_entry_address(template, registry_index, key)derives an entry's from"registry"(REGISTRY_SEED), the template's address, the registry index, and the32-bytekey,[0; 32]for an entry without one. It panics if the index is not belowMAX_REGISTRIES(8).template_hash(payload)is the payload's SHA-256 hash, which the upload instructions carry.
Both address functions derive under ballista_sdk::ID, the address of the pre-release devnet build, which rejects templates from this repository (status). Each has a _for_program variant that takes your deployment's program ID, as every instruction builder does.
The crate also exports TEMPLATE_SEED, BALLISTA_ID (the same as ID), and the program IDs SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, INSTRUCTIONS_SYSVAR_ID, and ED25519_PROGRAM_ID.
Calling your own program
account::program pins the program to a Pubkey. An Anchor program's instruction data starts with the handler's discriminator, which anchor_discriminator(name) returns for the handler's snake_case name. The arguments follow in Borsh order, which writes integers little-endian and addresses as 32 bytes, as the data:: functions encode them.
/// Calls `deposit(amount: u64)` on your own Anchor program, with the vault writable.
pub fn deposit_into(my_program: Pubkey) -> Template {
use ballista_sdk::{anchor_discriminator, template::prelude::*};
Template::new()
.input("amount", Type::U64)
.account("myProgram", account::program(my_program))
.account("vault", account::writable())
.step(
step::invoke("myProgram")
.writable("vault")
// Anchor instruction data: the handler's discriminator, then its arguments in
// Borsh order.
.data(data::literal(anchor_discriminator("deposit")))
.data(data::u64(input("amount"))),
)
}Lifecycle instructions
/// The instructions that upload `payload` as `creator`'s template `id`, one per transaction, in
/// order: a single create when the payload fits, or begin, a write per chunk, and finalize.
pub fn upload(creator: Pubkey, id: u16, payload: &[u8]) -> Vec<Instruction> {
use ballista_sdk::{
begin_template_instruction, create_template_instruction, finalize_template_instruction,
find_template_pda, template_hash, write_template_chunk_instruction,
};
// The most a legacy transaction holds when the creator signs it, pays the fee, and it carries
// only this instruction. A v0 transaction holds 2 bytes less of each.
const ONE_SHOT_LEN: usize = 960;
const CHUNK_LEN: usize = 1_023;
if payload.len() <= ONE_SHOT_LEN {
return vec![create_template_instruction(creator, id, payload)];
}
let (template, _) = find_template_pda(&creator, id);
let (length, hash) = (payload.len() as u32, template_hash(payload));
let mut instructions = vec![begin_template_instruction(creator, id, length, hash)];
for (index, chunk) in payload.chunks(CHUNK_LEN).enumerate() {
let offset = (index * CHUNK_LEN) as u32;
let write = write_template_chunk_instruction(creator, template, offset, chunk);
instructions.push(write);
}
instructions.push(finalize_template_instruction(creator, template));
instructions
}| Function | Instruction |
|---|---|
create_template_instruction | Creates, verifies, and finalizes a template in one instruction |
begin_template_instruction | Starts a chunked upload by creating the template account with the payload's length and hash |
write_template_chunk_instruction | Writes the next chunk; chunks go in order |
finalize_template_instruction | Checks the hash, verifies the bytecode, and makes the template permanent and runnable |
cancel_template_instruction | Closes an unfinished upload and returns its lamports to the creator |
The creator signs each one. Create and begin also pass the System Program, which creates the template account. Each function has a _for_program variant for another deployment.
A legacy or v0 transaction holds at most 1,232 bytes (limits). In a legacy transaction that the creator signs and pays for, with nothing else in it, that fits a 960-byte payload in create_template_instruction, or a 1,023-byte chunk; a v0 transaction fits 2 bytes less of each. Another signer or instruction, such as a compute-budget instruction, takes more of the room.
Inputs and the run instruction
/// A run of the payroll template above: `amount` to each recipient, within `budget`.
pub fn run_payroll(
template: Pubkey,
treasury: Pubkey,
recipients: &[Pubkey],
amount: u64,
budget: u64,
) -> Result<Instruction, Box<dyn std::error::Error>> {
use ballista_sdk::{template::Row, SYSTEM_PROGRAM_ID};
let instruction = payroll()
.compile()?
.run(template)
.input("amount", amount)
.input("budget", budget)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("treasury", treasury)
.rows(
recipients
.iter()
.map(|recipient| Row::new().account("recipient", *recipient)),
)
.instruction()?;
Ok(instruction)
}compiled.run(template) builds a run by name, as TypeScript's buildRunInstruction does:
.input(name, value)takes an integer, abool, aPubkey, or bytes..account(name, address)binds each fixed account. Its signer and writable flags come from the declaration..row(Row::new().account(..).input(..))or.rows(..)adds batch rows..group(name, metas)adds an account group's members, which never sign..instruction()checks every name, type and row count, then returns theRuninstruction, or aRunError.
The instruction lists the template account first, read-only, then the fixed accounts, each row's accounts, and the group members, all in declaration order. .encode_inputs() returns only the input bytes; start from compiled.run_inputs() when there is no run to build, such as a nested run's inputs.
Reading a run's output
A Program data: line doesn't name the program that logged it. program_data(logs) follows the invoke, success, and failed lines around each one, and returns it with its program, its stack height (1 for a run that is one of the transaction's instructions, more for a nested run), and its invocation number. Lines with the same invocation came from one call, so a template's EMIT lines pair with the run event that names it. ballista_output(&ballista_sdk::ID) then tells a run event from an EMIT by the tag rule, and gives None for another program's line, even one with the same bytes.
/// What the runs in a transaction logged: run events, and the `PAID` totals the template above
/// emits. `logs` are the transaction's log lines.
pub fn print_run_output(logs: &[String]) -> Result<(), ballista_sdk::LogError> {
use ballista_sdk::{program_data, BallistaOutput};
for line in program_data(logs)? {
match line.ballista_output(&ballista_sdk::ID) {
Some(BallistaOutput::RunEvent(event)) => println!(
"{} ran {} of the {} invokes it reached (mask {:#b})",
event.template_address,
event.executed.count_ones(),
event.expanded,
event.executed,
),
Some(BallistaOutput::Emit(bytes)) if bytes.starts_with(b"PAID") => {
let total = u64::from_le_bytes(bytes[4..12].try_into().unwrap());
println!("paid {total} lamports, at stack height {}", line.height);
}
_ => {}
}
}
Ok(())
}
/// The total a payroll run returned, from the transaction's return data as a simulation or the
/// transaction's metadata reports it: the program that set it, and the bytes.
pub fn returned_total(program_id: &Pubkey, data: &[u8]) -> Option<u64> {
// Only the program that set the bytes counts: a run that sets none leaves a callee's.
if *program_id != ballista_sdk::ID {
return None;
}
Some(u64::from_le_bytes(data.get(..8)?.try_into().ok()?))
}decode_run_event(bytes)decodes exactly47 bytesthat start withBEV1:version,iterations,expanded(the invokes reached),executed(a bit per invoke reached, set if it ran), andtemplate_address. Wire format has the layout.program_datareturns aLogErrornaming the line when the logs don't nest. Logs cut off at Solana's log limit parse up to theLog truncatedline, so check for that line when you need every event.- A failed transaction still logs what ran before the failure. Check that it succeeded first.
- Return data names the program that set it, not the template. Read it from the return data that simulation or the transaction's metadata reports, as
returned_totaldoes. AProgram return:log line names the program whose call just ended instead, so after a run that sets none, it can hold a called program's bytes under Ballista's name.
Decoding failures
/// Names a failed transaction's custom error `code`, and where it happened, if it is Ballista's.
pub fn describe(code: u32) -> Option<String> {
use ballista_sdk::{decode_ballista_error, ErrorSource};
let error = decode_ballista_error(code)?;
let context = match (error.source, error.name) {
(ErrorSource::Verifier, _) => "verifier context",
(_, "AccountConstraintFailed") => "runtime account index",
(_, "InvalidRunInputs") => "input index",
(_, "InvalidAccountRange") => "accounts or rows supplied",
(_, "CpiAccountLimitExceeded") => "accounts in the call",
_ => "program counter",
};
Some(format!("{} ({context} {})", error.name, error.context))
}decode_ballista_error returns a DecodedError with the error's name, its source (runtime or verifier), and its context. The low 16 bits of a code name the error, and the high 16 bits say where it happened: for most runtime errors the program counter. The kinds matched above give an account index, an input index, or a count instead. Error codes lists the context of every kind.
With the compiled template at hand, compiled.explain_error(code) names the step and its label instead, as RequirementFailed at steps[2] (withinBudget).
Anchor programs number their errors from 6000 too, so a code in Ballista's ranges may come from a program the run called. Decode it only when the transaction's first Program <address> failed: log line names Ballista. None means the code is outside Ballista's ranges.
Decoding a template account
/// Checks the bytecode of a finalized template, read from its account's data.
pub fn inspect(account_data: &[u8]) -> Result<VerificationStats, Box<dyn std::error::Error>> {
use ballista_sdk::ballista_common::template::TemplateAccount;
let account = TemplateAccount::parse(account_data)?;
let program = account.finalized_program()?;
Ok(program.verify()?)
}TemplateAccount::parse reads a template account's header and payload, and finalized_program returns a ProgramView of the bytecode, failing if the template is not finalized. Both point into the account bytes instead of copying them. verify returns counts such as the worst-case number of CPIs.
Using TypeScript-compiled templates
A build step can save compiled.bytes from the TypeScript compiler to a file; the guides use a .bvm extension. A Rust service can then hash, upload, inspect, and run those exact bytes. The example payloads in fixtures/ come from the TypeScript compiler, and the Rust tests, including the Mollusk tests that run the program in a simulated Solana runtime, execute them against the program.