Read-only analysis of Orca Whirlpool pools on Solana, plus on-chain management of concentrated liquidity positions. Generates TypeScript that queries public APIs and (when given a keypair) can submit transactions through @orca-so/whirlpools-sdk.
Default workflow: run analytics and planning without a wallet. For writes, use a small dedicated wallet, the pinned dependencies from this package, a quote preview, and a --confirm run after checking pool/mint addresses, token amounts, and slippage.
APIs:
https://api.orca.so/v2/solana — pool discovery, search, statshttps://stats-api.mainnet.orca.so — 6-month daily TVL/volume timeserieshttps://pools-api.mainnet.orca.so — multi-hop swap quotes (read-only)https://api.mainnet-beta.solana.com — on-chain reads + tx submissionUse when:
Do not use when:
Triggers: find pools, scan pools, rank pools, compare pools, stable pools, pool stability, pool APR, pool fees, fee tier, fee tier comparison, best pool, ATR, LP range, tick range, range sizing, price range, range projection, Monte Carlo, yield, yield analysis, retrospective yield, consistency, price consistency, which pool should I LP in, is this pool safe, add liquidity, provide liquidity, remove liquidity, withdraw LP, LP position, open position, close position, increase liquidity, decrease liquidity, rebalance, concentrated liquidity, CLMM, collect fees, collect rewards, claim rewards, swap, quote, exit position, exit everything, sweep to SOL
VariableRequired forDefaultKEYPAIR_PATHAny on-chain write (open / increase / decrease / collect / close / swap)—SOLANA_RPC_URLAll write opshttps://api.mainnet-beta.solana.com
Analytics and planning playbooks do not need a wallet. KEYPAIR_PATH points to a keypair that can sign transactions, so use a small dedicated wallet for write operations. Reserve ≥ 0.02 SOL in the wallet for rent + priority fees before any write operation. See Reserve math.
For every write playbook:
KEYPAIR_PATH.User intentPlaybookFirst action"Find / scan / rank pools"Quick RankingGET /pools?orderBy=tvlUsdc"Compare fee tiers for X/Y"Fee Tier ComparisonGET /pools/search?query=X/Y"Is this pool stable?" / "Which pools are safest?"Stability Analysis6mo Beachhouse TVL + volume"What range should I use?"Range SizingATR(14d) on 6mo A/B price"Where could the price go?" / "Simulate the range"Monte Carlo ProjectionGBM sim from realized vol"What would I earn?" / "How much would $X have made?"Retrospective YieldStable periods × feeRate × volume"Should I LP in X/Y?" (broad)Start with Quick Ranking, escalateSee Escalation rules"Swap X for Y" / "Get a quote"Swap (single-pool) or Swap (multi-hop)Quote → preview → execute"Open an LP position on X/Y"Open PositionCompute ticks, build quote, send"Open with only one token (usually SOL)"Open from Single TokenDetect SOL side, probe LP, exact-output swap, then open"Add to my position"Increase LiquidityRefresh pool, re-quote, send"Take out some liquidity"Decrease LiquidityPartial-liquidity quote, send"Collect fees" / "Claim rewards"Collect Fees + RewardsSync + collect in one tx"Close my position"Close PositionCollect → drain → close (burns NFT)"My position is out of range"RebalancePlan close + reopen; execute phases separately"Show my positions"Fetch PositionsScan both SPL programs for position NFTs"Exit everything and convert to SOL"Exit PlanningPlan close/swap phases; execute separately
Each playbook is standalone. Chain them as the user's question deepens — don't pre-run everything. Read-only playbooks (analytics + planning) need no wallet. Write playbooks (Swap, Open / Increase / Decrease / Collect / Close) require KEYPAIR_PATH and follow the Write Workflow.
Purpose: First-pass filter on TVL / APR / Vol/TVL / priceDelta. Never the final answer — always ask before escalating to Beachhouse.
Endpoint: GET /pools?orderBy=tvlUsdc&orderDirection=desc&limit=<n> or /pools/search?query=<pair>
Inputs: pair or criteria (min TVL, min APR), limit
Output columns: address, tokenA/B symbols, TVL, APR(7d), APR(30d), Vol/TVL, priceDelta(7d), feeRate
Compute:
APR(7d) = Number(stats["7d"].yieldOverTvl) / 7 * 365 * 100APR(30d) = Number(stats["30d"].yieldOverTvl) / 30 * 365 * 100Vol/TVL(24h) = Number(stats["24h"].volume) / Number(tvlUsdc)Flags to surface even at first pass: priceDelta(7d) below -20% (IL trap), Vol/TVL(24h) < 0.05 (stagnant pool)
Gotchas: numeric fields are strings — cast with Number() before math (see Gotcha #1). priceDelta is not volatility (see Gotcha #5).
Refs: Pool Response Key Fields, Pool Stats, examples/scan-pools.md
GET /pools/search?query=<pair>Purpose: Distinguish stable pools from trap APRs. High APR with falling TVL is a warning, not a buy.
Endpoints:
GET /api/pools/{address}/tvl?time_from=<6mo-ago>&time_to=<now>&type=1DGET /api/pools/{address}/volume?time_from=<6mo-ago>&time_to=<now>&type=1DInputs: pool address, 6-month window ( now - 180*86400 to now)
Compute:
stddev(ln(price[i]/price[i-1])) where price = Number(volumeQuote) / Number(volumeBase)mean(|price[i] - price[i-1]|) over last 14 daily A/B pricesRed flags (can disqualify):
permanentDelegate, mintCloseAuthority, or no entry in Orca's token list. Do not proceed without explicit user acknowledgement.Gotchas: use A/B ratio volumeQuote/volumeBase, not volumeBaseUsd/volumeBase (see Gotcha #2 and Two Different "Prices"). Beachhouse blocks default Python urllib UA (see Gotcha #3). Response is double-nested (see Gotcha #4).
Refs: Beachhouse API Reference, examples/stability-rankings.md
Purpose: Recommend tick ranges (tight / medium / wide) for a user-chosen pool, backtested against 6-month history.
Inputs: pool address, risk preference (optional)
Compute:
Output: three ranges (tight/med/wide) with containment% + rebalance frequency
Gotchas: containment % is a historical backtest, not a forward estimate — use Monte Carlo for forward-looking simulation.
Refs: examples/lp-range-analysis.md, examples/price-range-history.md
Purpose: Forward-looking simulation of price paths for a chosen range. Noisy — use Retrospective Yield for grounded earnings numbers.
Inputs: pool address, range widths, horizon (7/14/30/90 days)
Method: GBM from Beachhouse-derived realized volatility (A/B log returns, NOT priceDelta). Simulate ≥ 5000 paths.
Output:
Reporting rules (critical):
Purpose: What a deposit at a given range would have actually earned during stable periods in the last 6 months. The grounded baseline.
Inputs: pool address, range width, deposit size (USD)
Method:
Number(totalVolumeUsd) × (feeRate / 10000 / 100)Output: realized daily fees, annualized rate on stable periods, stable-period fraction of the 6mo window
Reporting rules:
Gotchas: concentrated-liquidity fee share scales with range width (see Gotcha #7) — don't use the full-range approximation deposit/TVL × pool_fees.
pool.refreshData() before quoting to avoid stale state.typescript
const pool = await client.getPool(poolAddress);
await pool.refreshData(); // freshen cached state before quoting
const quote = await swapQuoteByInputToken(
pool,
inputMint, // e.g. SOL mint
new BN(amountInBaseUnits),
Percentage.fromFraction(1, 100), // 1% slippage
ORCA_WHIRLPOOL_PROGRAM_ID,
ctx.fetcher,
);
console.log(`Quote: ${quote.estimatedAmountIn} → ${quote.estimatedAmountOut}`);
const tx = await pool.swap(quote);
const sig = await sendWithRetry(tx, connection);
GET /swap-quote returns multi-hop splits in data.swap.split[][] — use it to pick pools, then execute each leg via the SDK.swapQuoteByInputToken from the SDK for single-pool quotes.typescript
// Leg 1: TOKEN → USDC
const leg1Pool = await client.getPool(tokenUsdcPool);
await leg1Pool.refreshData();
const leg1Quote = await swapQuoteByInputToken(
leg1Pool, tokenMint, new BN(tokenAmount),
Percentage.fromFraction(1, 100), ORCA_WHIRLPOOL_PROGRAM_ID, ctx.fetcher,
);
await sendWithRetry(await leg1Pool.swap(leg1Quote), connection);
// Leg 2: USDC → SOL (after leg 1 lands)
const leg2Pool = await client.getPool(solUsdcPool);
await leg2Pool.refreshData();
const usdcReceived = leg1Quote.estimatedAmountOut;
const leg2Quote = await swapQuoteByInputToken(
leg2Pool, USDC_MINT, usdcReceived,
Percentage.fromFraction(1, 100), ORCA_WHIRLPOOL_PROGRAM_ID, ctx.fetcher,
);
await sendWithRetry(await leg2Pool.swap(leg2Quote), connection);
Purpose: Open a new concentrated position centered on the current price (or a custom range).
Inputs: pool address, deposit amount (token A human units), rangePct (e.g. 0.05 for ±5%), slippage
Output: position mint + tx signature
CU: 600,000 (position + metadata)
Compute:
data.sqrtPrice via PriceMath.sqrtPriceX64ToPricetickLower/tickUpper from rangePcttickSpacingincreaseLiquidityQuoteByInputTokenUsingPriceDeviationGotchas:
tokenMaxA/B vs tokenEstA/B. The wallet must hold tokenMaxA AND tokenMaxB (slippage-buffered), not tokenEst* — because the on-chain instruction pulls up to the Max if the price has moved. Buffer is asymmetric when range isn't centered on current price: at 1% deviation with a ±10% offset range, tokenMaxA can exceed tokenEstA by 10–15%.tokenMaxA/B against your available balance, not Est. When in doubt, scale input by ~90% of balance.Refs: examples/open-position.md, CLMM Concepts, Range strategies
typescript
const pool = await client.getPool(poolAddress);
await pool.refreshData();
const data = pool.getData();
const decA = pool.getTokenAInfo().decimals;
const decB = pool.getTokenBInfo().decimals;
const price = PriceMath.sqrtPriceX64ToPrice(data.sqrtPrice, decA, decB);
const rangePct = 0.05; // ±5%
const ts = data.tickSpacing;
const rawLower = PriceMath.priceToTickIndex(new Decimal(price.toNumber() * (1 - rangePct)), decA, decB);
const rawUpper = PriceMath.priceToTickIndex(new Decimal(price.toNumber() * (1 + rangePct)), decA, decB);
const tickLower = Math.floor(rawLower / ts) * ts;
const tickUpper = Math.ceil(rawUpper / ts) * ts;
const tokenExtCtx = await TokenExtensionUtil.buildTokenExtensionContextForPool(
ctx.fetcher, data.tokenMintA, data.tokenMintB,
);
// PriceDeviation returns matched tokenMaxA/B + minSqrtPrice/maxSqrtPrice bounds.
// Pass the quote directly to openPositionWithMetadata — no manual sqrt fields.
const quote = increaseLiquidityQuoteByInputTokenUsingPriceDeviation(
data.tokenMintA, // input token
new Decimal(depositAmount),
tickLower, tickUpper,
Percentage.fromFraction(1, 100),
pool, tokenExtCtx,
);
const { positionMint, tx } = await pool.openPositionWithMetadata(tickLower, tickUpper, quote);
const sig = await sendWithRetry(tx, connection);
console.log(`Position opened: ${positionMint.toBase58()} — tx ${sig}`);
Save positionMint for future operations.
Purpose: Start with only one token (usually SOL), probe the LP pool for the exact opposite-side amount, use an exact-output swap to acquire it, then open with the other token as input.
Inputs: pool, decA, decB, tickLower, tickUpper, tokenExtCtx (computed as in Open Position)
Flow:
increaseLiquidity with half SOL → learn tokenMax on the other sideswapQuoteByOutputToken to get exactly needOther0.90× multiplier to absorb price drift)Gotchas:
swapQuoteByOutputToken — swapQuoteByInputToken would require knowing the SOL → other rate ahead of time.0.90× multiplier in step 5 absorbs (a) price drift between swap and open, and (b) keeps the final quote's tokenMax under the received balance on skewed ranges. If you tighten it (e.g. 0.95), the open-position tx will bounce on insufficient balance.otherMint is Token-2022 with a transfer-fee extension, the wallet receives less than swapQuote.estimatedAmountOut. The 0.90× absorbs it in practice; if the transfer fee > 5%, tighten the probe size instead.solNeededForSwap + solForLPSide + ~20M lamports rent ≤ walletBalance. otherAmountThreshold (slippage-bounded max) is tighter than estimatedAmountIn for exact-output swaps — use it.Fallback: If the LP pool doesn't support the needed swap direction (rare), swap via a bridge pool ( TOKEN/USDC from /pools/search) and chain two swaps.
typescript
import { swapQuoteByOutputToken } from "@orca-so/whirlpools-sdk";
const SOL_MINT = new PublicKey("So11111111111111111111111111111111111111112");
// 1. Detect which side SOL is on
const data = pool.getData();
const solIsA = data.tokenMintA.equals(SOL_MINT);
const solMint = solIsA ? data.tokenMintA : data.tokenMintB;
const otherMint = solIsA ? data.tokenMintB : data.tokenMintA;
const decSol = solIsA ? decA : decB;
const decOther = solIsA ? decB : decA;
// 2. Reserve gas + rent, split the rest half-to-A / half-to-B
const solBalance = await connection.getBalance(wallet.publicKey);
const solForDeposit = solBalance - 25_000_000; // leave 0.025 SOL for rent + fees
const halfSolHuman = new Decimal(solForDeposit).div(10 ** decSol).div(2);
// 3. Probe: what does the LP quote want on the other side if we put half our SOL in?
const probe = increaseLiquidityQuoteByInputTokenUsingPriceDeviation(
solMint, halfSolHuman,
tickLower, tickUpper,
Percentage.fromFraction(1, 100),
pool, tokenExtCtx,
);
// Wallet must hold tokenMax (not tokenEst) — pick the Max on the non-SOL side.
const needOther = solIsA ? probe.tokenMaxB : probe.tokenMaxA;
// 4. Exact-output swap: get exactly `needOther` of the other token
const swapQuote = await swapQuoteByOutputToken(
pool, otherMint, needOther,
Percentage.fromFraction(1, 100),
ORCA_WHIRLPOOL_PROGRAM_ID, ctx.fetcher,
);
// Budget check
const solNeededForSwap = swapQuote.otherAmountThreshold.toNumber();
const solForLPSide = (solIsA ? probe.tokenMaxA : probe.tokenMaxB).toNumber();
if (solNeededForSwap + solForLPSide + 20_000_000 > solBalance) {
throw new Error(`Not enough SOL: need ${(solNeededForSwap + solForLPSide) / 1e9} + ~0.02 for rent`);
}
await sendWithRetry(await pool.swap(swapQuote), connection, 300_000);
// 5. Re-quote using the OTHER token as input — with 0.90× buffer
await pool.refreshData();
const receivedOther = DecimalUtil.fromBN(swapQuote.estimatedAmountOut, decOther);
const finalQuote = increaseLiquidityQuoteByInputTokenUsingPriceDeviation(
otherMint, receivedOther.mul(0.90),
tickLower, tickUpper,
Percentage.fromFraction(1, 100),
pool, tokenExtCtx,
);
const { positionMint, tx } = await pool.openPositionWithMetadata(tickLower, tickUpper, finalQuote);
await sendWithRetry(tx, connection, 600_000);
pool.refreshData() before quoting — position math is sqrt-price-sensitive.typescript
const posPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMint).publicKey;
const position = await client.getPosition(posPda);
const pool = await client.getPool(position.getData().whirlpool);
await pool.refreshData();
const tokenExtCtx = await TokenExtensionUtil.buildTokenExtensionContextForPool(
ctx.fetcher, pool.getData().tokenMintA, pool.getData().tokenMintB,
);
const quote = increaseLiquidityQuoteByInputTokenUsingPriceDeviation(
pool.getData().tokenMintA,
new Decimal(additionalAmount),
position.getData().tickLowerIndex,
position.getData().tickUpperIndex,
Percentage.fromFraction(1, 100),
pool, tokenExtCtx,
);
await sendWithRetry(await position.increaseLiquidity(quote), connection);
0.50 for 50%), slippagepool.refreshData() before quoting.typescript
const posPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMint).publicKey;
const position = await client.getPosition(posPda);
const pool = await client.getPool(position.getData().whirlpool);
await pool.refreshData();
const tokenExtCtx = await TokenExtensionUtil.buildTokenExtensionContextForPool(
ctx.fetcher, pool.getData().tokenMintA, pool.getData().tokenMintB,
);
const liquidity = position.getData().liquidity;
const amountToRemove = liquidity.muln(50).divn(100); // 50%
const quote = decreaseLiquidityQuoteByLiquidityWithParams({
liquidity: amountToRemove,
tickLowerIndex: position.getData().tickLowerIndex,
tickUpperIndex: position.getData().tickUpperIndex,
slippageTolerance: Percentage.fromFraction(1, 100),
sqrtPrice: pool.getData().sqrtPrice,
tickCurrentIndex: pool.getData().tickCurrentIndex,
tokenExtensionCtx: tokenExtCtx,
});
await sendWithRetry(await position.decreaseLiquidity(quote), connection);
collectFees(true) syncs fee accounting and collects in one tx. collectRewards returns TransactionBuilder[] — iterate.typescript
const posPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMint).publicKey;
const position = await client.getPosition(posPda);
// collectFees(true) syncs fee accounting and collects in one tx
await sendWithRetry(await position.collectFees(true), connection);
// collectRewards returns TransactionBuilder[] — iterate
const rewardTxs = await position.collectRewards(undefined, true);
for (const rtx of rewardTxs) await sendWithRetry(rtx, connection);
pool.closePosition(...) handles V2 paths automatically. For raw Anchor control, see Raw Anchor Fallback.typescript
const posPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMint).publicKey;
const position = await client.getPosition(posPda);
const pool = await client.getPool(position.getData().whirlpool);
await pool.refreshData();
// 1. Collect fees and rewards
await sendWithRetry(await position.collectFees(true), connection);
for (const rtx of await position.collectRewards(undefined, true)) await sendWithRetry(rtx, connection);
// 2. Drain liquidity to zero
if (!position.getData().liquidity.isZero()) {
const tokenExtCtx = await TokenExtensionUtil.buildTokenExtensionContextForPool(
ctx.fetcher, pool.getData().tokenMintA, pool.getData().tokenMintB,
);
const drainQuote = decreaseLiquidityQuoteByLiquidityWithParams({
liquidity: position.getData().liquidity,
tickLowerIndex: position.getData().tickLowerIndex,
tickUpperIndex: position.getData().tickUpperIndex,
slippageTolerance: Percentage.fromFraction(1, 100),
sqrtPrice: pool.getData().sqrtPrice,
tickCurrentIndex: pool.getData().tickCurrentIndex,
tokenExtensionCtx: tokenExtCtx,
});
await sendWithRetry(await position.decreaseLiquidity(drainQuote), connection);
}
// 3. Close (burns NFT, refunds rent)
const closeTxs = await pool.closePosition(posPda, Percentage.fromFraction(1, 100));
for (const tx of closeTxs) await sendWithRetry(tx, connection);
Purpose: List all open Orca positions owned by the wallet.
Inputs: wallet public key (implicit from wallet.publicKey)
Method:
decimals === 0 && amount === "1".PDAUtil.getPosition works for both (PDA seeds use only the mint).Gotcha: Non-Orca NFTs will throw on client.getPosition — catch and skip.
typescript
import { TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID } from "@solana/spl-token";
// Scan both token programs — position NFTs can be minted under either
const [classic, t2022] = await Promise.all([
connection.getParsedTokenAccountsByOwner(wallet.publicKey, { programId: TOKEN_PROGRAM_ID }),
connection.getParsedTokenAccountsByOwner(wallet.publicKey, { programId: TOKEN_2022_PROGRAM_ID }),
]);
const nftMints = [...classic.value, ...t2022.value]
.map(a => a.account.data.parsed.info)
.filter(i => i.tokenAmount.decimals === 0 && i.tokenAmount.amount === "1")
.map(i => new PublicKey(i.mint));
// PDAUtil.getPosition works for both classic and Token-2022 positions —
// the PDA seeds use the mint only, not the token program.
for (const mint of nftMints) {
const pda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, mint).publicKey;
try {
const position = await client.getPosition(pda);
// ... it's ours
} catch {
// not an Orca position; skip
}
}
Purpose: Produce an exit plan without signing transactions: positions to close, remaining tokens to swap to SOL, dust to skip, ATA rent refunds, and estimated fees.
Flow:
estimatedAmountOut (in SOL via current price) < ~0.0005 SOL, swap costs more than it returns.createCloseAccountInstruction(ata, wallet, wallet, [], tokenProgram) refunds ~0.002 SOL each).Reserves: ≥ 0.01 SOL at start — even closing operations need gas upfront.
Escalation — save cycles:
Reporting — never skip:
Summary recommendations (when the user asks "so what should I do?"):
Use these package versions for the TypeScript examples. The SDK's API has changed shape across versions, and different releases may not export the functions used below (e.g. WhirlpoolContext.from signature changed; …UsingPriceDeviation only exists in @orca-so/whirlpools-sdk@>=0.18; @orca-so/whirlpools-sdk@0.20 requires @coral-xyz/anchor@~0.32.1).
Pinned versions:
PackageVersion@solana/web3.js``1.98.4``@coral-xyz/anchor``0.32.1``bn.js``5.2.3``decimal.js``10.6.0``@orca-so/whirlpools-sdk``0.20.0``@orca-so/common-sdk``0.7.0``@solana/spl-token``0.4.14
Sanity-check scripts before sending any tx: npx tsc --noEmit your-script.ts catches wrong API signatures and missing exports for free.
Whirlpool program ID: whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc (same on mainnet + devnet).
Every write operation starts with this block:
typescript
import { Connection, Keypair, PublicKey, ComputeBudgetProgram } from "@solana/web3.js";
import { Wallet } from "@coral-xyz/anchor";
import BN from "bn.js";
import { Decimal } from "decimal.js";
import {
WhirlpoolContext, buildWhirlpoolClient, buildDefaultAccountFetcher,
ORCA_WHIRLPOOL_PROGRAM_ID, PDAUtil, PriceMath, TokenExtensionUtil,
swapQuoteByInputToken,
increaseLiquidityQuoteByInputTokenUsingPriceDeviation,
decreaseLiquidityQuoteByLiquidityWithParams,
} from "@orca-so/whirlpools-sdk";
import { Percentage, DecimalUtil } from "@orca-so/common-sdk";
import * as fs from "fs";
const KEYPAIR_PATH = process.env.KEYPAIR_PATH ?? (() => {
throw new Error("KEYPAIR_PATH environment variable is required.");
})();
const CONFIRM = process.argv.includes("--confirm"); // require --confirm to broadcast
const connection = new Connection(process.env.SOLANA_RPC_URL!, "confirmed");
const keypair = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(fs.readFileSync(KEYPAIR_PATH, "utf-8"))));
const wallet = new Wallet(keypair);
const fetcher = buildDefaultAccountFetcher(connection);
const ctx = WhirlpoolContext.from(connection, wallet, fetcher);
const client = buildWhirlpoolClient(ctx);
Keep ≥ 0.02 SOL in the wallet for rent + fees when opening or rebalancing positions ( openPositionWithMetadata costs ~0.015 SOL rent before it's reclaimed on close; ATA creation is ~0.002 SOL each). Any write operation also needs SOL upfront even if rent is refunded after — refunds arrive when the tx lands.
Every write operation uses this wrapper. It adapts the priority fee to current network load, retries on blockhash expiry, and verifies landing via signature status (because confirmTransaction can throw while the tx still lands):
typescript
import type { TransactionBuilder } from "@orca-so/common-sdk";
async function recommendedPriorityFee(conn: Connection): Promise<number> {
// p75 of recent prioritization fees; floor at 10k microLamports
const recent = await conn.getRecentPrioritizationFees();
if (recent.length === 0) return 10_000;
const fees = recent.map(x => x.prioritizationFee).sort((a, b) => a - b);
const p75 = fees[Math.floor(fees.length * 0.75)];
return Math.max(10_000, p75);
}
async function sendWithRetry(
txBuilder: TransactionBuilder,
conn: Connection,
computeUnits = 400_000,
): Promise<string> {
const microLamports = await recommendedPriorityFee(conn);
txBuilder.prependInstruction({
instructions: [
ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports }),
],
cleanupInstructions: [],
signers: [],
});
for (let attempt = 1; attempt <= 3; attempt++) {
let sig: string;
try {
sig = await txBuilder.buildAndExecute();
} catch (e: any) {
if (attempt < 3 && /Blockhash|expired|BlockheightExceeded/i.test(e.message)) continue;
throw e;
}
for (let i = 0; i < 30; i++) {
const status = (await conn.getSignatureStatus(sig)).value;
if (status?.err) throw new Error(`Tx ${sig} failed: ${JSON.stringify(status.err)}`);
if (status?.confirmationStatus === "confirmed" || status?.confirmationStatus === "finalized") return sig;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error(`Tx ${sig} did not confirm within 30s`);
}
throw new Error("unreachable");
}
OperationCUSwap (single-pool)300,000Open position (with metadata)600,000Increase / decrease liquidity400,000Collect fees200,000Close position (drain + close)500,000Multi-instruction bundle (V2 close)800,000
Multi-hop swaps with 3+ pools can exceed the 1232-byte legacy transaction limit. Pool detail responses include addressLookupTable — fetch each ALT with connection.getAddressLookupTable(key), then compile the tx as V0:
typescript
import { TransactionMessage, VersionedTransaction } from "@solana/web3.js";
const alt = (await connection.getAddressLookupTable(altKey)).value!;
const { blockhash } = await connection.getLatestBlockhash();
const msg = new TransactionMessage({
payerKey: wallet.publicKey,
recentBlockhash: blockhash,
instructions,
}).compileToV0Message([alt]); // single arg: ALT array
const tx = new VersionedTransaction(msg);
tx.sign([keypair]);
await connection.sendTransaction(tx);
price = 1.0001^tick (before decimal adjustment).tickSpacing. Each pool has its own.tickLowerIndex <= currentTick < tickUpperIndex. Out-of-range positions earn nothing and sit entirely in one token.Fee rateFee %Tick spacingTypical use10.0001%1Stablecoin pairs1000.01%1Tight stablecoin4000.04%4Major pairs (SOL/USDC)30000.30%64Standard pairs100001.00%128Volatile / exotic pairs
StrategyWidthBest forRebalanceTight±2%Stablecoin pairsRarelyMedium±5%Correlated (SOL/JitoSOL)WeeklyWide±10%Volatile (SOL/USDC)Bi-weeklyFull range-443636 → 443636Set-and-forgetNever
Tick conversion uses the SDK: PriceMath.priceToTickIndex(price, decA, decB) and PriceMath.tickIndexToPrice(tick, decA, decB). Align ticks: Math.floor(raw / tickSpacing) * tickSpacing for lower, Math.ceil(...) for upper.
For multi-hop routes and dust swaps, these are the deepest pools for common bridge pairs:
PairFeePoolSOL/USDC0.04%Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zEUSDC/USDT0.01%4fuUiYxTQ6QCrdSq9ouBYcTM7bqSwYTSyLueGZLTy4T4SOL/JitoSOL0.01%Hp53XEtt4S8SvPCXarsLSdGfZBuUr5mMmZmX2DRNXQKpSOL/USDT0.02%FwewVm8u6tFPGewAyHmWAqad9hmF7mvqxK4mJ7iNqqGCcbBTC/WBTC0.01%4v8ufj8Hj7UvFgtofQJAtzUud5xomwZfEqfCTHZ4wM72PYUSD/USDC0.01%9tXiuRRw7kbejLhZXtxDxYs2REe43uH2e7k1kocgdM9BUSDG/USDC0.01%9RqDTfwCx2SgxsvKpspQHc38HUo3B6hRd3oR9JR966Ps
For other pairs, discover via /pools/search?query=TOKEN/USDC.
Pools where at least one side (or the position NFT) uses the Token-2022 program need V2 instructions. The high-level SDK methods ( pool.swap, position.collectFees, pool.closePosition, etc.) handle this internally — use them and it just works. Drop to raw instructions only if bundling custom flows.
Detect a mint's program:
typescript
const prog = (await connection.getAccountInfo(mint))!.owner;
// equals TOKEN_PROGRAM_ID or TOKEN_2022_PROGRAM_ID
V1 → V2 instruction map (raw-Anchor flows only):
V1V2 (mixed / Token-2022)collectFees``collectFeesV2``collectReward``collectRewardV2``increaseLiquidity``increaseLiquidityV2``decreaseLiquidity``decreaseLiquidityV2``swap``swapV2
V2 instructions take tokenProgramA, tokenProgramB, tokenMintA, tokenMintB, and memoProgram ( MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr) accounts. ATA creation: pass the correct program per mint — createAssociatedTokenAccountIdempotentInstruction(payer, ata, owner, mint, tokenProgram).
The high-level helpers cover nearly everything. Drop to raw Anchor when:
Fetch accounts and build instructions directly:
typescript
const posPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMint).publicKey;
const position = await (ctx.program.account as any).position.fetch(posPda);
const pool = await (ctx.program.account as any).whirlpool.fetch(position.whirlpool);
const tokenProgA = (await connection.getAccountInfo(pool.tokenMintA))!.owner;
const tokenProgB = (await connection.getAccountInfo(pool.tokenMintB))!.owner;
// `whirlpool` (relations:[position]) and `memoProgram` (address-constrained)
// are auto-resolved by Anchor's typed builder — omit them from .accounts().
const ix = await ctx.program.methods
.collectFeesV2(null)
.accounts({
positionAuthority: wallet.publicKey,
position: posPda,
positionTokenAccount,
tokenMintA: pool.tokenMintA, tokenMintB: pool.tokenMintB,
tokenOwnerAccountA, tokenOwnerAccountB,
tokenVaultA: pool.tokenVaultA, tokenVaultB: pool.tokenVaultB,
tokenProgramA: tokenProgA, tokenProgramB: tokenProgB,
})
.remainingAccounts([])
.instruction();
Decode a program error code from the IDL:
typescript
import idl from "@orca-so/whirlpools-sdk/dist/artifacts/whirlpool.json" with { type: "json" };
const err = idl.errors.find((e: any) => e.code === parseInt("17b5", 16));
// { code: 6069, name: "PriceSlippageOutOfBounds", msg: "Price outside slippage bounds" }
See examples/close-position-anchor.md for a complete multi-instruction close using raw Anchor + V2.
Read before writing code. These bite agents who don't.
Both REST and Beachhouse return numeric values as JSON strings. Cast with Number() (TypeScript) or float() (Python) before math or comparisons.
Number(pool.tvlUsdc), Number(stats["7d"].yieldOverTvl) — feeRate is already a number, but most others are strings.Number(point.tvl), Number(point.baseAmount), Number(point.volumeBase), Number(point.volumeQuote), Number(point.totalVolumeUsd) — every timeseries field is a string.tvls.filter((x) => typeof x === "number") silently drops EVERY row because they're all strings. Cast first, then filter.For LP range analysis, use the pool ratio volumeQuote / volumeBase (B per A), not volumeBaseUsd / volumeBase (which is base-token USD price). Identical for USDC-quoted pools; divergent for LST pairs (SOL/JitoSOL), BTC pairs (cbBTC/WBTC), etc. See Two Different "Prices".
Cloudflare returns 403 for the default urllib UA. Set a browser-like header:
python
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
data = json.loads(urllib.request.urlopen(req).read())
curl and Node fetch work without extra headers.
response.data.data[] — outer data wraps the payload; inner data is the timeseries array. REST is single-nested: response.data[] or response.data.<field>.
priceDelta is NOT volatility#It's the net price change over the period. A choppy week ending flat shows priceDelta ≈ 0 despite high realized volatility. For actual volatility, use log returns on the A/B price from Beachhouse: stddev(ln(price[i]/price[i-1])).
Pool price P = quote / base. For a pair like SOL/whETH where A=SOL, B=whETH, P ≈ 0.037 means "0.037 whETH per SOL". If you know SOL_USD, then whETH_USD = SOL_USD / P, NOT SOL_USD * P. Multiplying gives ~$3 for whETH instead of ~$2,275. Bites anyone doing HODL comparisons or USD-denominated MC projections on non-USDC pools.
A narrower position earns proportionally more fees per in-range day. Rough approximation: relative_fee_share ∝ 1 / width. Do NOT model concentrated LP fees as deposit/TVL × pool_fees — that's the full-range approximation and severely underestimates tight-range earnings (and overestimates if you assume a tight range earns the same as full-range). Calibrate against a baseline width (e.g. ±20% ≈ pool average) and scale from there.
Retrospective Yield reports actual historical fee earnings during stable periods. Full LP-vs-HODL Monte Carlo with fee attribution requires deep CLMM math (liquidity-from-deposit inversion, active-liquidity attribution, concentration scaling) and is high-variance even when done correctly. If you build one, verify against a known baseline (e.g. the retrospective yield on a stablecoin pair) before reporting numbers.
Base: https://api.orca.so/v2/solana
PathPurposeResponse shapeGET /poolsList pools. Params: orderBy=tvlUsdc|volume24hUsdc, orderDirection=asc|desc, limit``{ data: [...pools] }``GET /pools/search?query=<pair-or-addr>Search by pair or address{ data: [...pools] }``GET /pools/{address}Single pool detail{ data: {...pool} }``GET /protocolProtocol-wide stats (flat, no data wrapper){ volume24hUsdc, fees24hUsdc, tvl }``GET /tokens/search?query=<symbol>Token lookup{ data: [...tokens] }
text
address - Pool address (string)
feeRate - Fee in basis points × 100 (e.g. 300 = 0.03%)
price - Current price as string
tvlUsdc - TVL in USD as string
tickSpacing - 1 (stables), 4 (majors), 64 (standard), 128 (volatile)
tokenA / tokenB - { symbol, name, decimals, address }
tokenBalanceA/B - Raw integer token balance (÷ 10^decimals)
Each pool has stats.24h, stats.7d, stats.30d:
text
volume - Total volume in USD (string)
fees - Total fees generated (string)
rewards - Total reward emissions in USD (string)
yieldOverTvl - Fee yield as fraction of TVL (e.g. 0.0019 = 0.19%)
volumeDelta - Volume change vs previous period (24h and 7d only)
feesDelta - Fees change rate (24h and 7d only)
tvlDelta - TVL change rate (24h and 7d only)
priceDelta - Price change rate (24h and 7d only)
yieldOverTvlDelta - Yield change rate (24h and 7d only)
Base: https://stats-api.mainnet.orca.so
Daily timeseries for up to 6 months. Coverage: top 50 pools by TVL. Use for realized volatility (VWAP log returns), ATR, historical range analysis, and retrospective yield.
GET /api/pools/{address}/tvl?time_from=<unix>&time_to=<unix>&type=1D
json
{ "data": { "data": [
{ "tvl": "1234567.89", "baseAmount": "500.5", "quoteAmount": "75000.0", "unixTime": 1700000000 }
] } }
GET /api/pools/{address}/volume?time_from=<unix>&time_to=<unix>&type=1D
json
{ "data": { "data": [
{ "totalVolumeUsd": "500000.0", "volumeBase": "3000.0", "volumeQuote": "450000.0",
"volumeBaseUsd": "250000.0", "volumeQuoteUsd": "250000.0", "unixTime": 1700000000 }
] } }
Resolutions: 1H, 1D, 1W, 1M, 1Y. baseAmount and quoteAmount are human-readable (already decimal-adjusted). 6-month window: time_from = now - 180 * 86400, time_to = now.
FormulaMeaningUse whenNumber(volumeQuote) / Number(volumeBase)A/B pool ratio — token B per token A (actual trade execution ratio)Pool-internal price for in-range analysis, ATR of the pair, realized vol of the actual LP position. Correct for all pools, especially LST pairs (SOL/JitoSOL) and BTC wrapped pairs (cbBTC/WBTC).Number(volumeBaseUsd) / Number(volumeBase)USD price of the base tokenUSD-denominated price context (e.g. "what was SOL worth each day"). For USDC-quoted pools these are identical; for LST/BTC pairs they diverge — USD price reflects the dollar value, not the pool ratio.
For LP range sizing and in-range checking, always use volumeQuote / volumeBase. For "what was the price in USD" context, use volumeBaseUsd / volumeBase.
Base: https://pools-api.mainnet.orca.so
Read-only multi-hop swap quoting. Returns route splits across pools so you can pick the deepest path before executing each leg via the SDK.
GET /swap-quote?from=<mint>&to=<mint>&amount=<base-units>&slippageBps=<bps>&amountIsInput=true
Required params: from (input mint), to (output mint), amount (base units), slippageBps, amountIsInput ( true for exact-input, false for exact-output). Snorkel returns 400 missing field "from" if you use inputMint/ outputMint — those are SDK names, not API names.
Response shape:
text
{ data: {
request: { from, to, amount, amountIsInput, ... },
swap: { inputAmount, outputAmount, split: [ [ ...hops ] ] },
num_measured, optimal_amount_deviation, price_impact
}, meta }
split is an array of routes; each route is an array of hops (pools chained sequentially). For single-route swaps, split.length === 1; for split routes (Snorkel sharded the input across pools), split.length > 1.
Rate limit: 429s on hot loops. Space iterated calls ≥ 200–500 ms. For single-pool quotes, swapQuoteByInputToken from the SDK avoids the API entirely.
FormulaExpressionFee %feeRate / 10000 (e.g. 400 → 0.04%)APR (7d)Number(stats["7d"].yieldOverTvl) / 7 * 365 * 100APR (30d)Number(stats["30d"].yieldOverTvl) / 30 * 365 * 100A/B pool priceNumber(volumeQuote) / Number(volumeBase)USD price of baseNumber(volumeBaseUsd) / Number(volumeBase)Realized volatilitystddev(ln(price[i]/price[i-1])) (A/B price for LP; USD price for market context)ATR (14d)`mean(Daily fees (pool)Number(totalVolumeUsd) × (feeRate / 10000 / 100)Vol/TVL (24h)Number(stats["24h"].volume) / Number(tvlUsdc) — higher = more activeDaily earning (full-range approx)deposit × Number(stats["7d"].yieldOverTvl) / 7
Token program is classic SPL ( TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA, shown as cls) unless marked T22 ( TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb). For any mint not in this table, resolve dynamically with (await connection.getAccountInfo(mint))!.owner.
SymbolDecimalsProgMintSOL (wSOL)9clsSo11111111111111111111111111111111111111112USDC6clsEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1vUSDT6clsEs9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYBORCA6clsorcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZEBONK5clsDezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263JitoSOL9clsJ1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPnJupSOL9clsjupSoLaHXQiZZTSfEWMTRRgpnyFm8f6sZdosWBjx93vINF9cls5oVNBeEEQvYi1cX3ir8Dx5n1P7pdxydbGF2X4TxVusJmJUP6clsJUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCNJLP6cls27G8MtK7VtTcCHkpASjSDdkWWYfoqT6ggEuKidVJidD4whETH8cls7vfCXTUXx5WJV5JADk17DUJ4ksgau7utNKj4b963voxscbBTC8clscbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMijWBTC8cls3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJhxBTC8clsCtzPWv73Sn1dMGVU3ZtLv9yWSyUAanBni19YWDaznnknZEC8clsA7bdiYdS5GjqGFtxf17ppRHtDKPkkRqbKtR27dxvQXaSPYUSD6T222b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXoUSDG6T222u1tszSeqZ3qBWF3uNGPFc8TzMk2tdiwknnRMWGWjGWHCASH6T22CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASHPUMP6T22pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9DfnsyrupUSDC6clsAvZZF1YaZDziPY2RCK4oJrRVrbN3mTD9NL24hPeaZeUjhyUSD6cls5YMkXAYccHSGnHn9nob9xEvv6Pvka9DZWH7nTbotTu9EUSX6cls6FrrzDk5mQARGc1TDYoyVnSyRdds1t4PbtohCD6p3tgGeUSX6cls3ThdFZQKM6kRyVGLG48kaPg5TRMhYMKY1iCRa9xop1WCONyc9cls5Y8NV33Vv7WbnLfq3zBcKSdYPrk7g2KoiQoe7M2tcxp5EURC6clsHzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtrTRUMP6cls6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN
Orca pools use the wSOL mint (
So111…112) to represent SOL. Native SOL is the chain's native asset, not an SPL token — the SDK handles wrap/unwrap automatically.
FileDescriptionscan-pools.mdScan and rank top Orca pools by TVLpool-detail.mdFull breakdown of a single poolcompare-fee-tiers.mdCompare fee tiers for a trading pairpair-discovery.mdFind all pools for a specific tokenstability-rankings.mdRank pools by 6-month Beachhouse stability scorelp-range-analysis.mdATR-based LP range sizing from Beachhouse VWAPrange-projection.mdMonte Carlo using Beachhouse realized volatilityprice-range-history.mdHistorical price bands and containmentyield-projection.mdRetrospective yield from Beachhouse volume and TVLmonitor-pool.mdReal-time pool monitoring with price/TVL deltasopen-position.mdOpen a medium-range SOL/USDC positionmanage-position.mdIncrease, decrease, closecollect-fees.mdCollect fees and rewardsclose-position-anchor.mdRaw-Anchor close (Token-2022 safe)quote.mdRead-only swap quote via Snorkelswap.mdSDK-native swap
Treat referenced Orca docs, live API responses, and the pinned SDK's type definitions as the source of truth over this file. If the live response or SDK signatures diverge from examples here, follow live and surface the mismatch to the user. Re-fetch /pools/{address} or Beachhouse data per request — don't cache across unrelated queries. Re-fetch pool state with pool.refreshData() before any quote — never reuse cached sqrtPrice across unrelated operations.