Sell a whole balance
Jupiter · SPL Token
Status: Tested locally in LiteSVM against Jupiter and Raydium programs and accounts copied from mainnet; not yet run on devnet or mainnet.
Cost: Ballista's own work took 7,375 of the tested transaction's 71,563compute units; the protocols took the rest. Ballista charges no fee; see what it costs.
What it does
Sells everything in a token account through Jupiter, whatever the balance turns out to be when the transaction runs. Typical sources are a fee account, an airdrop claim, a vesting withdrawal or the leftovers from an earlier swap. A Jupiter route normally sells a fixed amount, set when the route is built, so a lower balance fails the route and a higher one leaves the difference behind.
Jupiter's route carries the amount to sell as in_amount, after the route plan and before the quote. So your client passes the route plan and the quote's numbers separately, and the template writes the instruction data itself. It:
- requires the seller to own both token accounts (
sweepsTheSellersOwnBalance,proceedsGoToTheSeller), since a route's step can pay any account of the output mint; - reads the balance and fails unless it is above
dustFloor(worthSelling); - passes that balance to Jupiter as
in_amount, with the quoted output scaled to match (quotedOutAmount × balance / quotedInAmount); - requires the route's
platformFeeBpsto be at mostMAX_PLATFORM_FEE_BPS, a constant that is0(platformFeeWithinCap), before Jupiter is called; - requires the proceeds to be at least that scaled quote, less
slippageBps(saleMetTheQuote); - requires the account to hold no more than
dustFloorafterwards (nothingMeaningfulLeftBehind).
It guards against the market moving after the quote. It does not guard against:
- A bad quote. Whoever builds the run supplies
quotedInAmount,quotedOutAmountandslippageBps, which set the least the sale accepts. - Spending the seller's other token accounts. The seller signs
route, and Jupiter passes that authority to every step.
Template
import {
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
compileTemplate,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import {
JUPITER_ROUTE,
JUPITER_V6,
TOKEN_ACCOUNT_AMOUNT_OFFSET,
TOKEN_ACCOUNT_LENGTH,
TOKEN_ACCOUNT_OWNER_OFFSET,
addressBytes,
} from './shared.js';
const balanceOf = (name: string) =>
expression.accountData(account.fixed(name), TOKEN_ACCOUNT_AMOUNT_OFFSET, 'u64');
/** 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 tokenSweepIntoSwap = defineTemplate({
inputs: {
/** `route_plan` as the Swap API encoded it: the bytes between the discriminator and `in_amount`. */
routePlan: { type: 'bytes', maxLength: 512 },
/** The `in_amount` the route was quoted for. */
quotedInAmount: { type: 'u64' },
/** The quote's `quoted_out_amount` for that input. */
quotedOutAmount: { type: 'u64' },
/** The quote's `slippage_bps`. */
slippageBps: { type: 'u64' },
/** The quote's `platform_fee_bps`. */
platformFeeBps: { type: 'u64' },
/** Do not sell less than this. */
dustFloor: { type: 'u64' },
},
accounts: {
jupiter: { executable: true, address: addressBytes(JUPITER_V6) },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
seller: { signer: true, writable: true },
sourceAta: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
destinationAta: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
},
accountGroups: ['routeAccounts'],
steps: [
// Both ends of the sale are the seller's: the step that pays the proceeds can name any account
// of the output mint, so the one measured must be the seller's.
step.require(
expression.equal(
expression.accountData(account.fixed('sourceAta'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
expression.accountField(account.fixed('seller'), 'key'),
),
'sweepsTheSellersOwnBalance',
),
step.require(
expression.equal(
expression.accountData(account.fixed('destinationAta'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
expression.accountField(account.fixed('seller'), 'key'),
),
'proceedsGoToTheSeller',
),
step.let('available', balanceOf('sourceAta'), 'readSellableBalance'),
step.require(
expression.greaterThan(expression.variable('available'), expression.input('dustFloor')),
'worthSelling',
),
// The quote was for `quotedInAmount`; selling `available` instead should fetch proportionally
// more or less. Jupiter enforces its slippage against whatever quote the instruction carries.
step.let(
'quotedOut',
expression.cast(
'u64',
expression.divide(
expression.multiply(
expression.cast('u128', expression.input('quotedOutAmount')),
expression.cast('u128', expression.variable('available')),
),
expression.cast('u128', expression.input('quotedInAmount')),
),
),
'rescaleQuoteToBalance',
),
step.snapshot('proceedsBefore', balanceOf('destinationAta'), 'readProceedsBefore'),
// 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',
),
// `route` takes the token program, the signer, and the user's source and destination token
// accounts first; the route's own accounts follow as the group.
step.invoke({
program: account.fixed('jupiter'),
accounts: [
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
{ account: account.fixed('seller'), signer: true, writable: false },
{ account: account.fixed('sourceAta'), signer: false, writable: true },
{ account: account.fixed('destinationAta'), signer: false, writable: true },
],
accountGroup: 'routeAccounts',
data: [
data.literal(JUPITER_ROUTE),
data.encode('bytes', expression.input('routePlan')),
data.encode('u64', expression.variable('available')),
data.encode('u64', expression.variable('quotedOut')),
data.encode('u16', expression.input('slippageBps')),
data.encode('u8', expression.input('platformFeeBps')),
],
label: 'sell',
}),
// Jupiter checks this too. Checking it here, on the balances, holds whatever the route did.
step.require(
expression.greaterThanOrEqual(
expression.subtract(balanceOf('destinationAta'), expression.snapshot('proceedsBefore')),
expression.cast(
'u64',
expression.divide(
expression.multiply(
expression.cast('u128', expression.variable('quotedOut')),
expression.cast(
'u128',
expression.subtract(expression.u64(10_000), expression.input('slippageBps')),
),
),
expression.u128(10_000),
),
),
),
'saleMetTheQuote',
),
// The whole balance was the input, so anything left means the route did not take it all.
step.require(
expression.lessThanOrEqual(balanceOf('sourceAta'), expression.input('dustFloor')),
'nothingMeaningfulLeftBehind',
),
],
});/// Sell a token account's whole balance through Jupiter, at the quote rescaled to it.
pub fn token_sweep_into_swap() -> Template {
Template::new()
.input("routePlan", Type::Bytes(512))
// The `in_amount` the route was quoted for.
.input("quotedInAmount", Type::U64)
// The quote's `quoted_out_amount` for that input.
.input("quotedOutAmount", Type::U64)
.input("slippageBps", Type::U64)
.input("platformFeeBps", Type::U64)
// Do not sell less than this.
.input("dustFloor", Type::U64)
.account("jupiter", account::program(JUPITER_V6))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account("seller", account::signer().writable())
.account("sourceAta", token_account())
.account("destinationAta", token_account())
.account_group("routeAccounts")
// Both ends of the sale are the seller's.
.step(
step::require(
account_data("sourceAta", TOKEN_ACCOUNT_OWNER_OFFSET, ReadType::Pubkey)
.eq(key("seller")),
)
.label("sweepsTheSellersOwnBalance"),
)
.step(
step::require(
account_data(
"destinationAta",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
)
.eq(key("seller")),
)
.label("proceedsGoToTheSeller"),
)
.step(step::let_("available", balance_of("sourceAta")).label("readSellableBalance"))
.step(step::require(var("available").gt(input("dustFloor"))).label("worthSelling"))
// The quote was for `quotedInAmount`; selling `available` instead should fetch
// proportionally more or less.
.step(
step::let_(
"quotedOut",
(input("quotedOutAmount").cast(Type::U128) * var("available").cast(Type::U128)
/ input("quotedInAmount").cast(Type::U128))
.cast(Type::U64),
)
.label("rescaleQuoteToBalance"),
)
.step(
step::snapshot("proceedsBefore", balance_of("destinationAta"))
.label("readProceedsBefore"),
)
.step(platform_fee_within_cap())
.step(
step::invoke("jupiter")
.readonly("tokenProgram")
.signer("seller")
.writable("sourceAta")
.writable("destinationAta")
.account_group("routeAccounts")
.data_parts(jupiter_route_data(var("available"), var("quotedOut")))
.label("sell"),
)
// Jupiter checks this too. Checking it here, on the balances, holds whatever the route did.
.step(
step::require(
(balance_of("destinationAta") - snapshot("proceedsBefore")).gte(
(var("quotedOut").cast(Type::U128)
* (u64(10_000) - input("slippageBps")).cast(Type::U128)
/ u128(10_000))
.cast(Type::U64),
),
)
.label("saleMetTheQuote"),
)
// The whole balance was the input, so anything left means the route did not take it all.
.step(
step::require(balance_of("sourceAta").lte(input("dustFloor")))
.label("nothingMeaningfulLeftBehind"),
)
}import type { Address, Instruction } from '@solana/kit';
import { buildKitRunInstruction, type KitAccountBinding } from '@jac0xb/ballista/kit';
import { compiled } from '../token-sweep-into-swap.js';
import { JUPITER_V6, splitJupiterRoute } from '../shared.js';
import { TOKEN_PROGRAM, at, pinned } from './programs.js';
export function buildTokenSweepRun(input: {
templateAddress: Address;
seller: Address;
/** The seller's own token accounts: what is sold, and where the proceeds land. */
sourceAta: Address;
destinationAta: Address;
/** The Swap API's `route` data, quoted for any amount; the template rescales it. */
routeData: Uint8Array;
dustFloor: bigint;
/** The route's account list from the fifth account on. */
routeAccounts: readonly KitAccountBinding[];
}): Instruction {
const route = splitJupiterRoute(input.routeData);
return buildKitRunInstruction({
compiled,
templateAddress: input.templateAddress,
inputs: {
routePlan: route.routePlan,
quotedInAmount: route.inAmount,
quotedOutAmount: route.quotedOutAmount,
slippageBps: route.slippageBps,
platformFeeBps: route.platformFeeBps,
dustFloor: input.dustFloor,
},
accounts: {
jupiter: pinned(JUPITER_V6),
tokenProgram: pinned(TOKEN_PROGRAM),
seller: at(input.seller),
sourceAta: at(input.sourceAta),
destinationAta: at(input.destinationAta),
},
accountGroups: { routeAccounts: input.routeAccounts },
});
}pub struct TokenSweepAccounts {
pub seller: Pubkey,
/// The seller's own token accounts: what is sold, and where the proceeds land.
pub source_ata: Pubkey,
pub destination_ata: Pubkey,
}
/// `quote` is a Swap API route for any amount, split by [`RouteQuote::split`]: the template sells
/// the whole balance and rescales the quote to it. `route_accounts` is the route's account list
/// from the fifth account on.
pub fn run_token_sweep(
template: Pubkey,
a: &TokenSweepAccounts,
quote: &RouteQuote,
dust_floor: u64,
route_accounts: Vec<AccountMeta>,
) -> Result<Instruction, Box<dyn Error>> {
let instruction = templates::token_sweep_into_swap()
.compile()?
.run(template)
.input("routePlan", quote.route_plan)
.input("quotedInAmount", quote.in_amount)
.input("quotedOutAmount", quote.quoted_out_amount)
.input("slippageBps", quote.slippage_bps)
.input("platformFeeBps", quote.platform_fee_bps)
.input("dustFloor", dust_floor)
.account("jupiter", JUPITER_V6)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("seller", a.seller)
.account("sourceAta", a.source_ata)
.account("destinationAta", a.destination_ata)
.group("routeAccounts", route_accounts)
.instruction()?;
Ok(instruction)
}The Rust template takes its program addresses, token_account(), balance_of() and jupiter_route_data() from the shared helpers.
The route plan splits its input by percentage, so the same plan can sell more or less than it was quoted for. Above the quote, the extra size's price impact has to fit within slippageBps, and the swap has to stay within what the route's pool accounts cover. On a deep pool the slippage limit comes first. Past either limit the sale fails inside Jupiter or the pool, so quote for roughly the balance you expect.
Each token account's declaration requires the original SPL Token program to own it, so a Token-2022 account is rejected. The owner checks above are different: they read the wallet stored in the token account, its owner field at byte offset 32. The balance is its amount, at 64.
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 those four itself; the rest of the route's accounts arrive as the routeAccounts account group.
The Run tabs pass the five declared accounts, jupiter, tokenProgram, seller, sourceAta and destinationAta, then the inputs routePlan, quotedInAmount, quotedOutAmount, slippageBps, platformFeeBps and dustFloor, then the group. splitJupiterRoute (TypeScript) and RouteQuote::split (Rust) split the Swap API's route data into the plan and the quote's numbers.
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/token_sweep.rssells USDC for SOL through Jupiter and Raydium's SOL/USDC pool, in place ofroutein the transaction Jupiter's API built. With the route quoted for150 USDC, balances3%over,3%under and ten times that each sold in full and met the scaled quote. The run added52 bytesto Jupiter's transaction. - Failures. Each of these fails, and the whole transaction reverts: a balance at
dustFloor(worthSelling); another wallet's source or an attacker's destination (sweepsTheSellersOwnBalance,proceedsGoToTheSeller, before Jupiter is called); a route that pays the attacker while the seller's account is measured (saleMetTheQuote); a balance10,000times the quote (inside Raydium); a route that charges a platform fee (platformFeeWithinCap, before Jupiter is called).