Skip to content

Cap a caller's daily swaps ​

Jupiter · Registry

Status: Tested locally in LiteSVM against Jupiter, Meteora and Raydium programs and accounts copied from mainnet; not yet run on devnet or mainnet.

Cost: Ballista's own work took 9,644 of the tested transaction's 82,728compute units when it created the caller's entry, and 7,909 of 71,942 after; the protocols took the rest. The first run also pays the entry's rent, below. Ballista charges no fee; see what it costs.

What it does ​

Sells SOL through Jupiter, and limits each caller to 1.728 SOL at once, refilling daily. A caller who spends it all can spend it again as it refills, so about 3.456 SOL can move in any 24 hours.

A daily limit has to remember earlier sales, and a run's values are gone when it ends. So the template keeps each caller's total in a registry entry: an account that Ballista owns and only this template's runs can change. Each caller has their own entry, picked by their address, and must sign, so no caller can use another's.

The entry holds spent, in lamports (billionths of a SOL), and lastSpend, the time of the last sale. The rateLimit helper lowers spent by 20,000 lamports for each second since lastSpend, but not below zero. It then adds inAmount, the amount the route sells, and fails at withinRateLimit if the total is over the cap of 1,728,000,000. At that rate the cap refills in a day, continuously rather than at midnight. The cap and the rate are constants, so the caller can't change them.

Because the cap counts lamports, the route must sell wrapped SOL (wSOL), SOL held in a token account. The Swap API wraps SOL before route and unwraps it after. The template:

  1. requires sourceAta, the route's source token account, to hold wSOL (spendsWrappedSol). inAmount is in the smallest units of whatever the route sells, so otherwise 150 USDC would count as 0.15 SOL;
  2. requires sourceAta to belong to the caller (sourceBelongsToTheCaller);
  3. charges inAmount to the entry, or fails at withinRateLimit;
  4. requires the route's platformFeeBps to be at most MAX_PLATFORM_FEE_BPS, a constant that is 0 (platformFeeWithinCap);
  5. calls route with that same inAmount;
  6. requires exactly inAmount to have left sourceAta (soldWhatTheCapCharged), since Jupiter doesn't require its steps to move the source account it is given. Without this check, a second wSOL account of the caller's at sourceAta passed the first two checks while the route sold 150 USDC, charged as 0.15 SOL.

It does not guard against:

  • Spending the caller's other token accounts. The caller signs route, and Jupiter passes that authority to every step, so a further step could spend another of the caller's token accounts without the cap counting it.
  • Swaps outside this template. The cap counts only this template's runs. The caller can still swap through Jupiter directly.

Template ​

ts
import {
  TOKEN_PROGRAM_ADDRESS_BYTES,
  account,
  compileTemplate,
  data,
  defineTemplate,
  expression,
  rateLimit,
  step,
} from '@jac0xb/ballista';
import {
  JUPITER_ROUTE,
  JUPITER_V6,
  TOKEN_ACCOUNT_AMOUNT_OFFSET,
  TOKEN_ACCOUNT_LENGTH,
  TOKEN_ACCOUNT_MINT_OFFSET,
  TOKEN_ACCOUNT_OWNER_OFFSET,
  WRAPPED_SOL_MINT,
  addressBytes,
} from './shared.js';

const sourceBalance = expression.accountData(account.fixed('sourceAta'), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64');

/** 1.728 SOL, in lamports: the most a caller can sell at once. */
export const DAILY_CAP = 1_728_000_000n;
/** The cap over 86,400 seconds, so a caller can sell about twice the cap in any 24 hours. */
export const REFILL_PER_SECOND = 20_000n;

/** The route's platform fee account and rate are chosen by whoever builds the run: cap the rate. */
export const MAX_PLATFORM_FEE_BPS = 0n;

export const jupiterDailyCapSwap = defineTemplate({
  inputs: {
    /** `route_plan` as the Swap API encoded it: the bytes between the discriminator and `in_amount`. */
    routePlan: { type: 'bytes', maxLength: 512 },
    /** The route's `in_amount`, in lamports: what it sells, what the cap is charged, and what must leave `sourceAta`. */
    inAmount: { type: 'u64' },
    /** The quote's `quoted_out_amount`. */
    quotedOutAmount: { type: 'u64' },
    /** The quote's `slippage_bps`. */
    slippageBps: { type: 'u64' },
    /** The quote's `platform_fee_bps`. */
    platformFeeBps: { type: 'u64' },
  },
  registries: { dailySpend: { spent: 'u64', lastSpend: 'i64' } },
  accounts: {
    actionProgram: { executable: true, address: addressBytes(JUPITER_V6) },
    tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
    actor: { signer: true, writable: true },
    /** The actor's wrapped-SOL token account, which the route sells from. */
    sourceAta: {
      writable: true,
      owner: TOKEN_PROGRAM_ADDRESS_BYTES,
      minDataLength: TOKEN_ACCOUNT_LENGTH,
    },
    spend: account.registry('dailySpend', { key: expression.accountKey('actor'), payer: 'actor' }),
    systemProgram: account.systemProgram(),
  },
  accountGroups: ['actionAccounts'],
  steps: [
    // The cap counts lamports: a route that sells another mint would be charged in its units.
    step.require(
      expression.equal(
        expression.accountData(account.fixed('sourceAta'), TOKEN_ACCOUNT_MINT_OFFSET, 'pubkey'),
        expression.pubkey(addressBytes(WRAPPED_SOL_MINT)),
      ),
      'spendsWrappedSol',
    ),
    step.require(
      expression.equal(
        expression.accountData(account.fixed('sourceAta'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
        expression.accountKey('actor'),
      ),
      'sourceBelongsToTheCaller',
    ),
    ...rateLimit({
      registry: 'spend',
      cap: expression.u64(DAILY_CAP),
      refillPerSecond: expression.u64(REFILL_PER_SECOND),
      amount: expression.input('inAmount'),
    }),
    step.snapshot('sourceBefore', sourceBalance, 'readSourceBeforeSwap'),

    // The fee account sits in the route's own accounts: any nonzero rate pays whoever chose it.
    step.require(
      expression.lessThanOrEqual(expression.input('platformFeeBps'), expression.u64(MAX_PLATFORM_FEE_BPS)),
      'platformFeeWithinCap',
    ),
    step.invoke({
      program: account.fixed('actionProgram'),
      accounts: [
        { account: account.fixed('tokenProgram'), signer: false, writable: false },
        { account: account.fixed('actor'), signer: true, writable: false },
        { account: account.fixed('sourceAta'), signer: false, writable: true },
      ],
      accountGroup: 'actionAccounts',
      data: [
        data.literal(JUPITER_ROUTE),
        data.encode('bytes', expression.input('routePlan')),
        data.encode('u64', expression.input('inAmount')),
        data.encode('u64', expression.input('quotedOutAmount')),
        data.encode('u16', expression.input('slippageBps')),
        data.encode('u8', expression.input('platformFeeBps')),
      ],
      label: 'swapWithinTheCap',
    }),
    // Jupiter moves the accounts its steps name, not the source it was handed: a step that sold
    // another account's tokens would leave the charge in their units. Jupiter required the source
    // to hold `inAmount`, so the subtraction cannot underflow.
    step.require(
      expression.equal(
        expression.subtract(expression.snapshot('sourceBefore'), expression.input('inAmount')),
        sourceBalance,
      ),
      'soldWhatTheCapCharged',
    ),
  ],
});
rs
/// 1.728 SOL, in lamports: the most a caller can sell at once.
const DAILY_CAP: u64 = 1_728_000_000;
/// The cap over 86,400 seconds, so a caller can sell about twice the cap in any 24 hours.
const REFILL_PER_SECOND: u64 = 20_000;

/// Cap each caller's Jupiter sales of wrapped SOL.
pub fn jupiter_daily_cap_swap() -> Template {
    let source_balance = balance_of("sourceAta");
    Template::new()
        .input("routePlan", Type::Bytes(512))
        // What the route sells, what the cap is charged, and what must leave `sourceAta`.
        .input("inAmount", Type::U64)
        .input("quotedOutAmount", Type::U64)
        .input("slippageBps", Type::U64)
        .input("platformFeeBps", Type::U64)
        .registry(
            "dailySpend",
            [("spent", Type::U64), ("lastSpend", Type::I64)],
        )
        .account("actionProgram", account::program(JUPITER_V6))
        .account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
        .account("actor", account::signer().writable())
        // The actor's wrapped-SOL token account, which the route sells from.
        .account("sourceAta", token_account())
        .account(
            "spend",
            account::registry("dailySpend", "actor").key(account_key("actor")),
        )
        .account("systemProgram", account::system_program())
        .account_group("actionAccounts")
        // The cap counts lamports: a route that sells another mint would be charged in its units.
        .step(
            step::require(
                account_data("sourceAta", TOKEN_ACCOUNT_MINT_OFFSET, ReadType::Pubkey)
                    .eq(pubkey(WRAPPED_SOL_MINT)),
            )
            .label("spendsWrappedSol"),
        )
        .step(
            step::require(
                account_data("sourceAta", TOKEN_ACCOUNT_OWNER_OFFSET, ReadType::Pubkey)
                    .eq(account_key("actor")),
            )
            .label("sourceBelongsToTheCaller"),
        )
        .steps(rate_limit(
            "spend",
            u64(DAILY_CAP),
            u64(REFILL_PER_SECOND),
            input("inAmount"),
        ))
        .step(step::snapshot("sourceBefore", &source_balance).label("readSourceBeforeSwap"))
        .step(platform_fee_within_cap())
        .step(
            step::invoke("actionProgram")
                .readonly("tokenProgram")
                .signer("actor")
                .writable("sourceAta")
                .account_group("actionAccounts")
                .data_parts(jupiter_route_data(
                    input("inAmount"),
                    input("quotedOutAmount"),
                ))
                .label("swapWithinTheCap"),
        )
        // Jupiter required the source to hold `inAmount`, so the subtraction cannot underflow.
        .step(
            step::require((snapshot("sourceBefore") - input("inAmount")).eq(source_balance))
                .label("soldWhatTheCapCharged"),
        )
}
ts
import type { Address, Instruction } from '@solana/kit';

import { registryIndex } from '@jac0xb/ballista';
import { buildKitRunInstruction, findRegistryEntryAddress, type KitAccountBinding } from '@jac0xb/ballista/kit';
import { compiled } from '../jupiter-daily-cap-swap.js';
import { JUPITER_V6, splitJupiterRoute } from '../shared.js';
import { SYSTEM_PROGRAM, TOKEN_PROGRAM, at, pinned } from './programs.js';

/**
 * The actor's entry is its `dailySpend` entry for its own address: the actor's first run creates
 * it, and pays its rent.
 */
export async function buildDailyCapRun(input: {
  templateAddress: Address;
  /** Signs, keys the entry, and pays its rent on the first run. */
  actor: Address;
  /** The actor's wrapped-SOL token account, which the route sells from. */
  sourceAta: Address;
  /** The Swap API's `route` data. */
  routeData: Uint8Array;
  /** The route's account list from the fourth account on: the template passes the token program, the actor and the source. */
  actionAccounts: readonly KitAccountBinding[];
}): Promise<Instruction> {
  const route = splitJupiterRoute(input.routeData);
  const [spend] = await findRegistryEntryAddress(
    input.templateAddress,
    registryIndex(compiled, 'dailySpend'),
    input.actor,
  );
  return buildKitRunInstruction({
    compiled,
    templateAddress: input.templateAddress,
    inputs: {
      routePlan: route.routePlan,
      inAmount: route.inAmount,
      quotedOutAmount: route.quotedOutAmount,
      slippageBps: route.slippageBps,
      platformFeeBps: route.platformFeeBps,
    },
    accounts: {
      actionProgram: pinned(JUPITER_V6),
      tokenProgram: pinned(TOKEN_PROGRAM),
      actor: at(input.actor),
      sourceAta: at(input.sourceAta),
      spend: at(spend),
      systemProgram: pinned(SYSTEM_PROGRAM),
    },
    accountGroups: { actionAccounts: input.actionAccounts },
  });
}
rs
pub struct DailyCapAccounts {
    /// Signs, keys the entry, and pays its rent on the first run.
    pub actor: Pubkey,
    /// The actor's wrapped-SOL token account, which the route sells from.
    pub source_ata: Pubkey,
}

/// `route` is the Swap API's `route` data split by [`RouteQuote::split`], and `action_accounts`
/// its account list from the fourth account on: the template passes the token program, the actor
/// and the source itself. The actor's entry is its `dailySpend` entry for its own address: the
/// actor's first run creates it, and pays its rent.
pub fn run_jupiter_daily_cap(
    template: Pubkey,
    a: &DailyCapAccounts,
    route: &RouteQuote,
    action_accounts: Vec<AccountMeta>,
) -> Result<Instruction, Box<dyn Error>> {
    let compiled = templates::jupiter_daily_cap_swap().compile()?;
    let daily_spend = compiled
        .registry_index("dailySpend")
        .ok_or("no dailySpend registry")?;
    let (spend, _) = find_registry_entry_address(&template, daily_spend, &a.actor.to_bytes());
    let instruction = compiled
        .run(template)
        .input("routePlan", route.route_plan)
        .input("inAmount", route.in_amount)
        .input("quotedOutAmount", route.quoted_out_amount)
        .input("slippageBps", route.slippage_bps)
        .input("platformFeeBps", route.platform_fee_bps)
        .account("actionProgram", JUPITER_V6)
        .account("tokenProgram", TOKEN_PROGRAM_ID)
        .account("actor", a.actor)
        .account("sourceAta", a.source_ata)
        .account("spend", spend)
        .account("systemProgram", SYSTEM_PROGRAM_ID)
        .group("actionAccounts", action_accounts)
        .instruction()?;
    Ok(instruction)
}

The Rust template takes its program addresses, token_account(), balance_of() and jupiter_route_data() from the shared helpers.

The Rust template writes out the steps that rateLimit returns.

Run it ​

route starts its account list with the token program, the signer, and the signer's source and destination token accounts. The template passes the first three itself; the rest of the route's accounts, from the destination token account on, arrive as the actionAccountsaccount group.

The Run tabs pass the six declared accounts, actionProgram (Jupiter), tokenProgram, actor, sourceAta (the actor's wSOL account), spend (the actor's entry) and systemProgram, then the inputs routePlan, inAmount, quotedOutAmount, slippageBps and platformFeeBps, then the group. splitJupiterRoute (TypeScript) and RouteQuote::split (Rust) split the Swap API's route data into routePlan and the four numbers after it. findRegistryEntryAddress (TypeScript) and find_registry_entry_address (Rust) find the entry from the template's address, registry index 0 and the actor's address.

The actor's first run creates the entry, with no separate instruction, and the actor pays its rent, the lamports Solana requires an account to hold for its size: 1,097,280 lamports, about 0.0011 SOL, on mainnet, for this 88-byte entry. Nothing closes an entry, so the rent isn't returned.

Getting a Jupiter route says how to request the route from Jupiter's Swap API and what to keep from its response.

What has been tested ​

  • In LiteSVM. tests/protocols/tests/jupiter_daily_cap.rs sells 1 SOL for USDC through Jupiter and a Meteora pool, in place of route in the transaction Jupiter's API built. The first run creates the caller's entry and buys the same USDC as that transaction alone. A second sale fails at withinRateLimit before Jupiter is called, and still fails 13,599 seconds later. At 13,600 seconds it lands, and spent ends exactly at the cap. Another caller, with the first's limit spent, still sells, on an entry of their own.
  • Size. The transaction is 807 bytes, 109 more than Jupiter's own, and the template 1,014 bytes.
  • Failures. Each of these fails, and the whole transaction reverts, so no entry is created or changed: a route selling 150 USDC through a Raydium pool (spendsWrappedSol, before Jupiter is called); the same route with a second wSOL account of the caller's at sourceAta (soldWhatTheCapCharged, after the route); a caller passing another caller's entry, before or after it exists (InvalidRegistryEntry, before the first step); a route that charges a platform fee (platformFeeWithinCap, before Jupiter is called).
  • Not tested. Neither gap above has a test.

All protocol templates · What has been tested

BALLISTA / A SMALL MACHINE FOR COMPLEX TRANSACTIONS