Harvest positions that earned
Orca
Status: Tested locally in LiteSVM against Orca's Whirlpools program and a SOL/USDC pool copied from mainnet; not yet run on devnet or mainnet.
Cost: Ballista's own work took 14,587 of the tested four-row transaction's 60,880compute units; Whirlpools took the rest. Ballista charges no fee; see what it costs.
What it does
Collects fees from up to 12 Orca Whirlpools positions in one transaction, skipping the ones whose fees are all at or below dustFloor. The positions must share one pool and one holder.
A liquidity provider may hold dozens of positions. Which of them have earned since the last harvest depends on trades that land after the transaction is signed, so the template checks each one during the run. Whoever holds a position's NFT, a token with a supply of one, owns the position.
The template reads who owns the fee accounts, tokenOwnerAccountA and tokenOwnerAccountB, once. Then, for each position, it:
- requires the holder of the position's NFT to own both fee accounts (
positionBelongsToTheFeeOwner). Whirlpools checks only the fee accounts' mints, so a run built by someone else could otherwise send the fees anywhere; - calls
update_fees_and_rewardsif the position has liquidity. Without it,fee_owed_aandfee_owed_bhold only what the last update recorded, and a position that earned looks empty; - calls
collect_feesif either fee is abovedustFloor.
Skipping a collect saves about 13,300 compute units and leaves dust alone. It doesn't prevent reverts: collect_fees with nothing owed succeeds and moves nothing. What reverts the whole harvest is a row that fails regardless of its fees: a position the signer isn't allowed to sign for (Whirlpools' MissingOrInvalidDelegate, 6019), one from another pool (ConstraintHasOne, 2001), or one of another holder (positionBelongsToTheFeeOwner). None of these depends on trades, so leave such positions out when you build the run.
Template
import {
TOKEN_PROGRAM_ADDRESS_BYTES,
account,
compileTemplate,
data,
defineTemplate,
expression,
step,
} from '@jac0xb/ballista';
import {
ORCA_COLLECT_FEES,
ORCA_POSITION,
ORCA_UPDATE_FEES_AND_REWARDS,
ORCA_WHIRLPOOL,
TOKEN_ACCOUNT_LENGTH,
TOKEN_ACCOUNT_OWNER_OFFSET,
addressBytes,
} from './shared.js';
const position = account.iteration('position');
const aboveFloor = (offset: number) =>
expression.greaterThan(
expression.accountData(position, offset, 'u64'),
expression.input('dustFloor'),
);
export const orcaHarvestManyPositions = defineTemplate({
inputs: {
/** Fees at or below this, in either token's base units, are left for a later harvest. */
dustFloor: { type: 'u64' },
},
accounts: {
whirlpoolProgram: { executable: true, address: addressBytes(ORCA_WHIRLPOOL) },
tokenProgram: { executable: true, address: TOKEN_PROGRAM_ADDRESS_BYTES },
positionAuthority: { signer: true },
/** Written by each row's `update_fees_and_rewards`. */
whirlpool: { writable: true },
/** Must belong to each row's position holder (`positionBelongsToTheFeeOwner`). */
tokenOwnerAccountA: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
tokenOwnerAccountB: {
writable: true,
owner: TOKEN_PROGRAM_ADDRESS_BYTES,
minDataLength: TOKEN_ACCOUNT_LENGTH,
},
tokenVaultA: { writable: true },
tokenVaultB: { writable: true },
},
batch: {
maxIterations: 12,
minIterations: 1,
row: {
position: {
writable: true,
owner: addressBytes(ORCA_WHIRLPOOL),
minDataLength: ORCA_POSITION.length,
},
/**
* Read for its owner field, the row's real holder (`positionBelongsToTheFeeOwner`). A row's
* NFT can be held by either Token or Token-2022, so its owning program is not pinned;
* Whirlpools' own mint and amount checks on this account make that read trustworthy without
* one.
*/
positionTokenAccount: { unsafeUnpinned: true, minDataLength: TOKEN_ACCOUNT_LENGTH },
/** The tick array holding the position's lower tick; `update_fees_and_rewards` reads it. */
tickArrayLower: {},
/** The tick array holding the position's upper tick. */
tickArrayUpper: {},
},
},
steps: [
// Fixed accounts, shared by every row: read once for the whole batch, not once per row.
step.let(
'feeOwnerA',
expression.accountData(account.fixed('tokenOwnerAccountA'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
'readFeeOwnerA',
),
step.let(
'feeOwnerB',
expression.accountData(account.fixed('tokenOwnerAccountB'), TOKEN_ACCOUNT_OWNER_OFFSET, 'pubkey'),
'readFeeOwnerB',
),
step.forEach(
[
step.let(
'positionHolder',
expression.accountData(
account.iteration('positionTokenAccount'),
TOKEN_ACCOUNT_OWNER_OFFSET,
'pubkey',
),
'readPositionHolder',
),
// Whirlpools checks only the fee accounts' mints. The holder is the NFT account's owner, not
// `positionAuthority`, which may be a delegate.
step.require(
expression.and(
expression.equal(expression.variable('positionHolder'), expression.variable('feeOwnerA')),
expression.equal(expression.variable('positionHolder'), expression.variable('feeOwnerB')),
),
'positionBelongsToTheFeeOwner',
),
// Folds the pool's fee growth into the position, so the owed fees are current. Whirlpools
// refuses it for a position without liquidity, which earns nothing.
step.invoke({
program: account.fixed('whirlpoolProgram'),
accounts: [
{ account: account.fixed('whirlpool'), signer: false, writable: true },
{ account: position, signer: false, writable: true },
{ account: account.iteration('tickArrayLower'), signer: false, writable: false },
{ account: account.iteration('tickArrayUpper'), signer: false, writable: false },
],
data: [data.literal(ORCA_UPDATE_FEES_AND_REWARDS)],
when: expression.greaterThan(
expression.accountData(position, ORCA_POSITION.liquidity, 'u128'),
expression.u128(0),
),
label: 'updateIfLiquid',
}),
step.invoke({
program: account.fixed('whirlpoolProgram'),
accounts: [
{ account: account.fixed('whirlpool'), signer: false, writable: false },
{ account: account.fixed('positionAuthority'), signer: true, writable: false },
{ account: position, signer: false, writable: true },
{ account: account.iteration('positionTokenAccount'), signer: false, writable: false },
{ account: account.fixed('tokenOwnerAccountA'), signer: false, writable: true },
{ account: account.fixed('tokenVaultA'), signer: false, writable: true },
{ account: account.fixed('tokenOwnerAccountB'), signer: false, writable: true },
{ account: account.fixed('tokenVaultB'), signer: false, writable: true },
{ account: account.fixed('tokenProgram'), signer: false, writable: false },
],
data: [data.literal(ORCA_COLLECT_FEES)],
// This row's own fees, just updated, decide whether it collects.
when: expression.or(aboveFloor(ORCA_POSITION.feeOwedA), aboveFloor(ORCA_POSITION.feeOwedB)),
label: 'collectIfWorthIt',
}),
],
{ label: 'everyPosition' },
),
],
});
export const compiled = compileTemplate(orcaHarvestManyPositions);/// Collect the fees of many Whirlpools positions, each only when it is worth it.
pub fn orca_harvest_many_positions() -> Template {
let position = account::iteration("position");
let above_floor =
|offset: u32| account_data(position.clone(), offset, ReadType::U64).gt(input("dustFloor"));
Template::new()
.input("dustFloor", Type::U64)
.account("whirlpoolProgram", account::program(ORCA_WHIRLPOOL))
.account("tokenProgram", account::program(TOKEN_PROGRAM_ID))
.account("positionAuthority", account::signer())
.account("whirlpool", account::writable())
.account("tokenOwnerAccountA", token_account())
.account("tokenOwnerAccountB", token_account())
.account("tokenVaultA", account::writable())
.account("tokenVaultB", account::writable())
.batch(
Batch::new(12)
.min_iterations(1)
.account(
"position",
account::writable()
.owner(ORCA_WHIRLPOOL)
.min_data_length(ORCA_POSITION_LENGTH),
)
.account(
"positionTokenAccount",
account::readonly()
.unsafe_unpinned()
.min_data_length(TOKEN_ACCOUNT_LENGTH),
)
.account("tickArrayLower", account::readonly())
.account("tickArrayUpper", account::readonly()),
)
// Fixed accounts, shared by every row: read once for the whole batch, not once per row.
.step(
step::let_(
"feeOwnerA",
account_data(
"tokenOwnerAccountA",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
),
)
.label("readFeeOwnerA"),
)
.step(
step::let_(
"feeOwnerB",
account_data(
"tokenOwnerAccountB",
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
),
)
.label("readFeeOwnerB"),
)
.step(
step::for_each()
.step(
step::let_(
"positionHolder",
account_data(
account::iteration("positionTokenAccount"),
TOKEN_ACCOUNT_OWNER_OFFSET,
ReadType::Pubkey,
),
)
.label("readPositionHolder"),
)
// The holder is the NFT account's owner, not `positionAuthority`, which may be a
// delegate.
.step(
step::require(
var("positionHolder")
.eq(var("feeOwnerA"))
.and(var("positionHolder").eq(var("feeOwnerB"))),
)
.label("positionBelongsToTheFeeOwner"),
)
// Folds the pool's fee growth into the position, so the owed fees are current.
.step(
step::invoke("whirlpoolProgram")
.writable("whirlpool")
.writable(position.clone())
.readonly(account::iteration("tickArrayLower"))
.readonly(account::iteration("tickArrayUpper"))
.data(data::literal(orca_update_fees_and_rewards()))
.when(
account_data(position.clone(), ORCA_POSITION_LIQUIDITY, ReadType::U128)
.gt(u128(0)),
)
.label("updateIfLiquid"),
)
.step(
step::invoke("whirlpoolProgram")
.readonly("whirlpool")
.signer("positionAuthority")
.writable(position.clone())
.readonly(account::iteration("positionTokenAccount"))
.writable("tokenOwnerAccountA")
.writable("tokenVaultA")
.writable("tokenOwnerAccountB")
.writable("tokenVaultB")
.readonly("tokenProgram")
.data(data::literal(orca_collect_fees()))
// This row's own fees, just updated, decide whether it collects.
.when(
above_floor(ORCA_POSITION_FEE_OWED_A)
.or(above_floor(ORCA_POSITION_FEE_OWED_B)),
)
.label("collectIfWorthIt"),
)
.label("everyPosition"),
)
}import {
address,
getAddressEncoder,
getProgramDerivedAddress,
type Address,
type Instruction,
} from '@solana/kit';
import { explainRunError, failedProgram } from '@jac0xb/ballista';
import { BALLISTA_ADDRESS, buildKitRunInstruction, getTemplateAddress } from '@jac0xb/ballista/kit';
import { compiled } from './orca-harvest-many-positions.js';
import { ORCA_WHIRLPOOL } from './shared.js';
/** One position, the token account holding its NFT, and the tick arrays holding its two bounds. */
export interface HarvestRow {
position: Address;
positionTokenAccount: Address;
/** The tick array holding the position's lower tick: `getOrcaTickArrayAddress`. */
tickArrayLower: Address;
/** The tick array holding the position's upper tick. */
tickArrayUpper: Address;
}
export interface HarvestAccounts {
positionAuthority: Address;
whirlpool: Address;
tokenOwnerAccountA: Address;
tokenOwnerAccountB: Address;
tokenVaultA: Address;
tokenVaultB: Address;
}
/** The template's declared ceiling; more positions than this need a second run. */
export const MAX_POSITIONS_PER_RUN = 12;
/** Ticks in one Whirlpool tick array. */
const TICKS_PER_ARRAY = 88;
/**
* The tick array holding `tickIndex` in a pool whose tick spacing is `tickSpacing`: the PDA
* `["tick_array", whirlpool, start]`, where `start` is the array's first tick as a decimal string.
*/
export async function getOrcaTickArrayAddress(
whirlpool: Address,
tickIndex: number,
tickSpacing: number,
): Promise<Address> {
const span = TICKS_PER_ARRAY * tickSpacing;
const start = Math.floor(tickIndex / span) * span;
const [tickArray] = await getProgramDerivedAddress({
programAddress: address(ORCA_WHIRLPOOL),
seeds: ['tick_array', getAddressEncoder().encode(whirlpool), String(start)],
});
return tickArray;
}
export async function buildOrcaHarvestRun(input: {
creator: Address;
templateId: number;
accounts: HarvestAccounts;
positions: readonly HarvestRow[];
dustFloor: bigint;
}): Promise<Instruction> {
if (input.positions.length === 0) {
throw new Error('The template declares minIterations 1; pass at least one position');
}
if (input.positions.length > MAX_POSITIONS_PER_RUN) {
throw new Error(
`${input.positions.length} positions exceeds the template's ${MAX_POSITIONS_PER_RUN}; split the run`,
);
}
const [templateAddress] = await getTemplateAddress(input.creator, input.templateId);
return buildKitRunInstruction({
compiled,
programAddress: BALLISTA_ADDRESS,
templateAddress,
inputs: { dustFloor: input.dustFloor },
accounts: {
whirlpoolProgram: { address: address(ORCA_WHIRLPOOL) },
tokenProgram: { address: address('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') },
positionAuthority: { address: input.accounts.positionAuthority },
whirlpool: { address: input.accounts.whirlpool },
tokenOwnerAccountA: { address: input.accounts.tokenOwnerAccountA },
tokenOwnerAccountB: { address: input.accounts.tokenOwnerAccountB },
tokenVaultA: { address: input.accounts.tokenVaultA },
tokenVaultB: { address: input.accounts.tokenVaultB },
},
// One record per row, in order. The run's iteration count is derived from the account list.
batchRows: input.positions.map((row) => ({
position: { address: row.position },
positionTokenAccount: { address: row.positionTokenAccount },
tickArrayLower: { address: row.tickArrayLower },
tickArrayUpper: { address: row.tickArrayUpper },
})),
});
}pub struct OrcaHarvestAccounts {
pub position_authority: Pubkey,
pub whirlpool: Pubkey,
/// The positions' holder's own token accounts, where every row's fees go.
pub token_owner_account_a: Pubkey,
pub token_owner_account_b: Pubkey,
pub token_vault_a: Pubkey,
pub token_vault_b: Pubkey,
}
/// One row per position.
pub struct OrcaHarvestRow {
pub position: Pubkey,
/// The token account holding the position's NFT.
pub position_token_account: Pubkey,
/// The tick arrays holding the position's lower and upper ticks.
pub tick_array_lower: Pubkey,
pub tick_array_upper: Pubkey,
}
/// 1 to 12 rows, all of one holder: the fee accounts are fixed for the batch, and each row's NFT
/// must be held by their owner. The row count comes from the account list, so there is no count
/// to pass. A row that collects costs about 24,000 compute units, so more than eight need a
/// compute budget.
pub fn run_orca_harvest(
template: Pubkey,
a: &OrcaHarvestAccounts,
rows: &[OrcaHarvestRow],
dust_floor: u64,
) -> Result<Instruction, Box<dyn Error>> {
let instruction = templates::orca_harvest_many_positions()
.compile()?
.run(template)
.input("dustFloor", dust_floor)
.account("whirlpoolProgram", ORCA_WHIRLPOOL)
.account("tokenProgram", TOKEN_PROGRAM_ID)
.account("positionAuthority", a.position_authority)
.account("whirlpool", a.whirlpool)
.account("tokenOwnerAccountA", a.token_owner_account_a)
.account("tokenOwnerAccountB", a.token_owner_account_b)
.account("tokenVaultA", a.token_vault_a)
.account("tokenVaultB", a.token_vault_b)
.rows(rows.iter().map(|row| {
Row::new()
.account("position", row.position)
.account("positionTokenAccount", row.position_token_account)
.account("tickArrayLower", row.tick_array_lower)
.account("tickArrayUpper", row.tick_array_upper)
}))
.instruction()?;
Ok(instruction)
}The offsets come from Orca's Position account and the SPL Token account; see reading offsets. The Rust template takes its program addresses, offsets, discriminators and token_account() from the shared helpers.
Run it
The positions form a batch. The Run tabs pass the eight declared accounts in order, whirlpoolProgram, tokenProgram, positionAuthority, whirlpool, tokenOwnerAccountA, tokenOwnerAccountB, tokenVaultA and tokenVaultB, then one row of four accounts per position, then the input dustFloor. A row is:
position;positionTokenAccount, the token account holding the position's NFT;tickArrayLowerandtickArrayUpper, which hold the position's lower and upper ticks, the prices its range starts and ends at. Whirlpools stores a pool's ticks88to an account, in tick arrays.getOrcaTickArrayAddress, in the TypeScript run, finds the one holding a tick; a test checks it against mainnet's tick arrays.
Pass 1 to 12 rows; the row count comes from the account list, so there is no count to pass. Every row must be a position in whirlpool, held by the owner of the fee accounts. Another holder's positions need their own run, with that holder's fee accounts. Both pool mints must be SPL Token mints, as SOL and USDC are.
positionAuthority signs for every position. It can be the holder, or a delegate: an account, such as a keeper bot, that the holder approved on each positionTokenAccount with the token program's approve. Through this template the fees still go to the holder's accounts, and since a harvest never spends from them, a delegate needs no other approval.
Approving a keeper hands it the positions
The approval isn't limited to this template. Outside it, the delegate can call Whirlpools' collect_fees itself and send the fees to accounts of its own, or move the NFT, and with it the position. Approve only a keeper you would trust with the positions themselves.
A row that collects costs about 24,000 compute units, so eight fit the default limit of 200,000; for more, add a compute-budget instruction that raises the limit. Rows that share tick arrays fit about ten to a transaction; more need an address lookup table.
Whirlpools numbers its errors from 6000, as Ballista does, so a failed run's code alone can't say which program refused; see which program failed. The TypeScript run's describeFailure reads the logs to tell.
What has been tested
- In LiteSVM.
tests/protocols/tests/orca_harvest_many_positions.rsearns fees with real swaps through the pool, then harvests:- Four rows: fees in both tokens; fees in token B only, with the NFT held in a Token-2022 account; out of range, with no fees; and no liquidity. The first two update and collect, the third only updates, and the fourth makes no call. The holder receives exactly the first two rows' fees. The run took
60,880compute units and747 bytes. - A row whose only fee equals
dustFloorupdates and leaves the fee owed, while a row above the floor collects.
- Four rows: fees in both tokens; fees in token B only, with the NFT held in a Token-2022 account; out of range, with no fees; and no liquidity. The first two update and collect, the third only updates, and the fourth makes no call. The holder receives exactly the first two rows' fees. The run took
- Failures. A stranger signing fails in Whirlpools with
MissingOrInvalidDelegate, which the test tells apart from Ballista's own6019by the logs. A row from another pool fails withConstraintHasOne, and a row from another holder atpositionBelongsToTheFeeOwner; in both, the first row's collect reverts too. A stranger's account in both fee slots, or in token B's alone, fails atpositionBelongsToTheFeeOwner. - A delegate. A keeper approved on the NFT signs the run, and the holder's own accounts receive the fees.
- Limits. Eight earning rows land within the default
200,000compute units, with no compute-budget instruction, using192,418; nine run out. Ten rows sharing tick arrays fit one transaction with a compute-budget instruction; eleven don't. - Whirlpools alone.
collect_feeswith nothing owed succeeds and moves nothing (tests/protocols/tests/orca_setup.rs). - Every Whirlpools call passes the same accounts, in the same order and with the same signer and writable flags, as Orca's own Rust client (
tests/protocols/tests/orca_cpis.rs). - An opt-in test checks the Orca offsets against devnet accounts.