Payment patterns
Templates that pay out SOL: a revenue split, weighted rewards, a refund with a deadline, a capped sweep and a payment agent with fixed limits. Each shows the template and the code that runs it, in TypeScript and Rust.
Batching alone is not a reason
A plain transaction can already send many transfers. Templates earn their place when an amount or a decision only exists while the transaction runs; see amounts read at run time and loops that decide per row.
Basis-point revenue split
Split total lamports between a partner and a treasury. partnerBps is the partner's share in basis points, or hundredths of a percent, so 10,000 is 100%. The template rejects a share above 10,000, pays the partner total × partnerBps / 10,000 rounded down, and pays the treasury total minus that. Because the treasury's amount is a subtraction, rounding can't create or lose lamports.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
defineTemplate,
expression,
step,
systemTransfer,
} from '@jac0xb/ballista';
/** Split `total` lamports: `partnerBps` of it to the partner, the rest to the treasury. */
export const basisPointRevenueSplit = defineTemplate({
inputs: { total: { type: 'u64' }, partnerBps: { type: 'u64' } },
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
source: { signer: true, writable: true },
partner: { writable: true },
treasury: { writable: true },
},
steps: [
step.require(expression.lessThanOrEqual(expression.input('partnerBps'), expression.u64(10_000))),
step.let(
'partnerAmount',
expression.divide(
expression.multiply(expression.input('total'), expression.input('partnerBps')),
expression.u64(10_000),
),
),
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('source'),
to: account.fixed('partner'),
lamports: expression.variable('partnerAmount'),
}),
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('source'),
to: account.fixed('treasury'),
lamports: expression.subtract(expression.input('total'), expression.variable('partnerAmount')),
}),
],
});import type { Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
export function runBasisPointRevenueSplit(run: {
templateAddress: Address;
source: Address;
partner: Address;
treasury: Address;
total: bigint;
partnerBps: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(basisPointRevenueSplit),
templateAddress: run.templateAddress,
inputs: { total: run.total, partnerBps: run.partnerBps },
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
source: { address: run.source },
partner: { address: run.partner },
treasury: { address: run.treasury },
},
});
}/// Split `total` lamports: `partnerBps` of it to the partner, the rest to the treasury.
pub fn basis_point_revenue_split() -> Template {
Template::new()
.input("total", Type::U64)
.input("partnerBps", Type::U64)
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("source", account::signer().writable())
.account("partner", account::writable())
.account("treasury", account::writable())
.step(step::require(input("partnerBps").lte(u64(10_000))))
.step(step::let_(
"partnerAmount",
input("total") * input("partnerBps") / u64(10_000),
))
.step(system_transfer(
"systemProgram",
"source",
"partner",
var("partnerAmount"),
))
.step(system_transfer(
"systemProgram",
"source",
"treasury",
input("total") - var("partnerAmount"),
))
}pub fn basis_point_revenue_split(
template: Pubkey,
source: Pubkey,
partner: Pubkey,
treasury: Pubkey,
total: u64,
partner_bps: u64,
) -> RunResult {
let instruction = templates::basis_point_revenue_split()
.compile()?
.run(template)
.input("total", total)
.input("partnerBps", partner_bps)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("source", source)
.account("partner", partner)
.account("treasury", treasury)
.instruction()?;
Ok(instruction)
}Index-weighted rewards
Pay each recipient a multiple of base set by its place in the list: the first gets 1 × base, the second 2 × base, and so on. expression.loopIndex() is the current row's position, starting at 0.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
defineTemplate,
expression,
step,
systemTransfer,
} from '@jac0xb/ballista';
/** Pay the recipient in row `i` (counting from 0) `(i + 1) × base` lamports. */
export const indexWeightedRewards = defineTemplate({
inputs: { base: { type: 'u64' } },
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
treasury: { signer: true, writable: true },
},
batch: { maxIterations: 30, minIterations: 1, row: { recipient: { writable: true } } },
steps: [
step.forEach([
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('treasury'),
to: account.iteration('recipient'),
lamports: expression.multiply(
expression.add(expression.loopIndex(), expression.u64(1)),
expression.input('base'),
),
}),
]),
],
});import type { Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
/** The order of `recipients` sets each one's multiple of `base`. */
export function runIndexWeightedRewards(run: {
templateAddress: Address;
treasury: Address;
recipients: readonly Address[];
base: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(indexWeightedRewards),
templateAddress: run.templateAddress,
inputs: { base: run.base },
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
treasury: { address: run.treasury },
},
batchRows: run.recipients.map((recipient) => ({ recipient: { address: recipient } })),
});
}/// Pay the recipient in row `i` (counting from 0) `(i + 1) × base` lamports.
pub fn index_weighted_rewards() -> Template {
Template::new()
.input("base", 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::for_each().step(system_transfer(
"systemProgram",
"treasury",
account::iteration("recipient"),
(loop_index() + u64(1)) * input("base"),
)))
}/// The order of `recipients` sets each one's multiple of `base`.
pub fn index_weighted_rewards(
template: Pubkey,
treasury: Pubkey,
recipients: &[Pubkey],
base: u64,
) -> RunResult {
let instruction = templates::index_weighted_rewards()
.compile()?
.run(template)
.input("base", base)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("treasury", treasury)
.rows(
recipients
.iter()
.map(|recipient| Row::new().account("recipient", *recipient)),
)
.instruction()?;
Ok(instruction)
}Deadline refund
Refund refundAmount lamports to the customer only if the run executes at or before deadline, a Unix timestamp. After the deadline, the when condition skips the transfer and the run still succeeds.
import { SYSTEM_PROGRAM_ADDRESS_BYTES, account, defineTemplate, expression, systemTransfer } from '@jac0xb/ballista';
/** Refund the customer only if the run executes at or before `deadline`. */
export const deadlineRefund = defineTemplate({
inputs: { refundAmount: { type: 'u64' }, deadline: { type: 'i64' } },
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
escrowAuthority: { signer: true, writable: true },
customer: { writable: true },
},
steps: [
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('escrowAuthority'),
to: account.fixed('customer'),
lamports: expression.input('refundAmount'),
when: expression.lessThanOrEqual(expression.clockUnixTimestamp(), expression.input('deadline')),
}),
],
});import type { Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
export function runDeadlineRefund(run: {
templateAddress: Address;
escrowAuthority: Address;
customer: Address;
refundAmount: bigint;
/** Unix timestamp, in seconds. */
deadline: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(deadlineRefund),
templateAddress: run.templateAddress,
inputs: { refundAmount: run.refundAmount, deadline: run.deadline },
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
escrowAuthority: { address: run.escrowAuthority },
customer: { address: run.customer },
},
});
}/// Refund the customer only if the run executes at or before `deadline`.
pub fn deadline_refund() -> Template {
Template::new()
.input("refundAmount", Type::U64)
.input("deadline", Type::I64)
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("escrowAuthority", account::signer().writable())
.account("customer", account::writable())
.step(
system_transfer(
"systemProgram",
"escrowAuthority",
"customer",
input("refundAmount"),
)
.when(clock_unix_timestamp().lte(input("deadline"))),
)
}pub fn deadline_refund(
template: Pubkey,
escrow_authority: Pubkey,
customer: Pubkey,
refund_amount: u64,
deadline: i64,
) -> RunResult {
let instruction = templates::deadline_refund()
.compile()?
.run(template)
.input("refundAmount", refund_amount)
.input("deadline", deadline)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("escrowAuthority", escrow_authority)
.account("customer", customer)
.instruction()?;
Ok(instruction)
}Authorization
Ballista has no authority over the escrow. escrowAuthority must sign the transaction itself. If the refund comes from a call to an escrow program instead, that program must approve it by its own rules.
Reserve-preserving sweep
Move up to cap lamports from payer to vault without letting payer fall below reserve. The template records the balance and fails if it is already below reserve. It then sends the smaller of cap and the amount above the reserve, and checks that the balance is still at least reserve.
import {
SYSTEM_PROGRAM_ADDRESS_BYTES,
account,
defineTemplate,
expression,
step,
systemTransfer,
} from '@jac0xb/ballista';
/** Move up to `cap` lamports to the vault without taking the payer below `reserve`. */
export const reservePreservingSweep = defineTemplate({
inputs: { reserve: { type: 'u64' }, cap: { type: 'u64' } },
accounts: {
systemProgram: { executable: true, address: SYSTEM_PROGRAM_ADDRESS_BYTES },
payer: { signer: true, writable: true },
vault: { writable: true },
},
steps: [
step.snapshot('before', expression.accountField(account.fixed('payer'), 'lamports')),
step.require(expression.greaterThanOrEqual(expression.snapshot('before'), expression.input('reserve'))),
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('payer'),
to: account.fixed('vault'),
lamports: expression.min(
expression.subtract(expression.snapshot('before'), expression.input('reserve')),
expression.input('cap'),
),
}),
step.require(
expression.greaterThanOrEqual(
expression.accountField(account.fixed('payer'), 'lamports'),
expression.input('reserve'),
),
),
],
});import type { Address } from '@solana/kit';
import { compileTemplate } from '@jac0xb/ballista';
import { SYSTEM_PROGRAM_ADDRESS, buildKitRunInstruction } from '@jac0xb/ballista/kit';
export function runReservePreservingSweep(run: {
templateAddress: Address;
payer: Address;
vault: Address;
reserve: bigint;
cap: bigint;
}) {
return buildKitRunInstruction({
compiled: compileTemplate(reservePreservingSweep),
templateAddress: run.templateAddress,
inputs: { reserve: run.reserve, cap: run.cap },
accounts: {
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
payer: { address: run.payer },
vault: { address: run.vault },
},
});
}/// Move up to `cap` lamports to the vault without taking the payer below `reserve`.
pub fn reserve_preserving_sweep() -> Template {
Template::new()
.input("reserve", Type::U64)
.input("cap", Type::U64)
.account("systemProgram", account::program(SYSTEM_PROGRAM_ID))
.account("payer", account::signer().writable())
.account("vault", account::writable())
.step(step::snapshot("before", lamports("payer")))
.step(step::require(snapshot("before").gte(input("reserve"))))
.step(system_transfer(
"systemProgram",
"payer",
"vault",
(snapshot("before") - input("reserve")).min(input("cap")),
))
.step(step::require(lamports("payer").gte(input("reserve"))))
}pub fn reserve_preserving_sweep(
template: Pubkey,
payer: Pubkey,
vault: Pubkey,
reserve: u64,
cap: u64,
) -> RunResult {
let instruction = templates::reserve_preserving_sweep()
.compile()?
.run(template)
.input("reserve", reserve)
.input("cap", cap)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.account("payer", payer)
.account("vault", vault)
.instruction()?;
Ok(instruction)
}Payment agent
An automated agent, such as an AI agent paying for API calls or invoices, sends SOL from its own wallet. The limits are constants in the template, so once it is uploaded the agent can't raise them.
import { account, defineTemplate, expression, rateLimit, step, systemTransfer } from '@jac0xb/ballista';
/** An agent pays at most 0.1 SOL at once and 1 SOL a day, and keeps 0.05 SOL in its wallet. */
export const paymentAgent = defineTemplate({
inputs: { amount: { type: 'u64' } },
registries: { limits: { spent: 'u64', lastSpend: 'i64' } },
accounts: {
agent: { signer: true, writable: true },
recipient: { writable: true },
agentLimit: account.registry('limits', { key: expression.accountKey('agent'), payer: 'agent' }),
systemProgram: account.systemProgram(),
},
steps: [
step.require(
expression.lessThanOrEqual(expression.input('amount'), expression.u64(100_000_000)), // 0.1 SOL
'perPaymentCap',
),
...rateLimit({
registry: 'agentLimit', // the entry's account, not the registry
name: 'dailyCap', // labels the check `withinDailyCap`
cap: expression.u64(1_000_000_000), // 1 SOL
refillPerSecond: expression.u64(11_574), // 1 SOL over 86,400 seconds, rounded down
amount: expression.input('amount'),
}),
systemTransfer({
systemProgram: account.fixed('systemProgram'),
from: account.fixed('agent'),
to: account.fixed('recipient'),
lamports: expression.input('amount'),
}),
step.require(
expression.greaterThanOrEqual(
expression.accountField(account.fixed('agent'), 'lamports'),
expression.u64(50_000_000), // 0.05 SOL
),
'keepsReserve',
),
],
});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 runPaymentAgent(run: {
templateAddress: Address;
agent: Address;
recipient: Address;
amount: bigint;
}) {
const compiled = compileTemplate(paymentAgent);
// The agent's entry: registry `limits`, keyed by the agent's address, as the template keys it.
const [agentLimit] = await findRegistryEntryAddress(
run.templateAddress,
registryIndex(compiled, 'limits'),
run.agent,
);
return buildKitRunInstruction({
compiled,
templateAddress: run.templateAddress,
inputs: { amount: run.amount },
accounts: {
agent: { address: run.agent },
recipient: { address: run.recipient },
agentLimit: { address: agentLimit },
systemProgram: { address: SYSTEM_PROGRAM_ADDRESS },
},
});
}/// An agent pays at most 0.1 SOL at once and 1 SOL a day, and keeps 0.05 SOL in its wallet.
pub fn payment_agent() -> Template {
Template::new()
.input("amount", Type::U64)
.registry("limits", [("spent", Type::U64), ("lastSpend", Type::I64)])
.account("agent", account::signer().writable())
.account("recipient", account::writable())
.account(
"agentLimit",
account::registry("limits", "agent").key(account_key("agent")),
)
.account("systemProgram", account::system_program())
.step(
step::require(input("amount").lte(u64(100_000_000))) // 0.1 SOL
.label("perPaymentCap"),
)
.steps(
rate_limit(
"agentLimit", // the entry's account, not the registry
u64(1_000_000_000), // cap: 1 SOL
u64(11_574), // refill per second: 1 SOL over 86,400 seconds, rounded down
input("amount"),
)
.name("dailyCap"), // labels the check `withinDailyCap`
)
.step(system_transfer(
"systemProgram",
"agent",
"recipient",
input("amount"),
))
.step(
step::require(lamports("agent").gte(u64(50_000_000))) // 0.05 SOL
.label("keepsReserve"),
)
}pub fn payment_agent(template: Pubkey, agent: Pubkey, recipient: Pubkey, amount: u64) -> RunResult {
let compiled = templates::payment_agent().compile()?;
// The agent's entry in `limits`, keyed by the agent's address.
let limits = compiled.registry_index("limits").unwrap();
let (agent_limit, _) = find_registry_entry_address(&template, limits, &agent.to_bytes());
let instruction = compiled
.run(template)
.input("amount", amount)
.account("agent", agent)
.account("recipient", recipient)
.account("agentLimit", agent_limit)
.account("systemProgram", SYSTEM_PROGRAM_ID)
.instruction()?;
Ok(instruction)
}perPaymentCaprejects any single payment above100,000,000 lamports,0.1 SOL.rateLimitkeeps a rolling daily cap of1 SOLin the agent's own entry oflimits, keyed by its address. At11,574 lamportsa second, the full1 SOLrefills in86,401 seconds, just over a day. The check is labeledwithinDailyCap, and A daily limit per caller explains the math.keepsReserverequires the wallet to hold at least50,000,000 lamports,0.05 SOL, after the transfer, so the agent can't drain what it needs for fees.- The first run creates the agent's entry, and the agent pays its rent:
1,097,280 lamports. - A run that breaks a limit fails with
RequirementFailed(6015) at the labeled step, and nothing moves. - To restrict who the agent can pay, keep the recipients in a registry, as in An allowlist.
The limits bind only this template
A template has no authority of its own. The agent's key signs the run, and the same key can sign a plain transfer that skips every limit. The limits hold only if the agent can't do that: for example, its key lives in a signing service whose policy signs only runs of this one template address.