All articles
Solana·January 21, 2026·5 min read

Orca Whirlpool Arbitrage Bot with the TypeScript SDK: Step by Step

Orca Whirlpools expose tick-array accounts that must be loaded before a swap can be simulated on-chain, and missing this step is the most common reason bots silently fail during backtesting. This tutorial builds a two-hop arbitrage loop using the official @orca-so/whirlpools SDK, simulates profitability off-chain, and submits atomically via a versioned transaction.

Orca Whirlpool arbitrage bots written against the TypeScript SDK fail in backtesting far more often than people expect, and almost always for the same reason: tick-array accounts are not fetched before simulation. Once you understand that Whirlpools are CLMM pools where liquidity is chunked into discrete price ranges, and that crossing a tick boundary requires reading the next tick-array account on-chain, the whole model clicks — and so does writing a bot that actually holds up in production.

What Makes Whirlpools Different from Constant-Product AMMs

Orca uses a concentrated liquidity market maker (CLMM) design borrowed from Uniswap v3. Instead of a single invariant curve, liquidity lives in tick arrays — ordered arrays of 88 ticks, each representing a price range where LPs have deposited. When a swap crosses into a new range, the runtime must load the adjacent tick-array account to continue routing.

This has two practical consequences for arb bots:

  • Simulation requires the right accounts. The swapQuoteByInputToken helper in @orca-so/whirlpools-sdk accepts a TickArrays object that you must prefetch with SwapUtils.getTickArrays. If you pass stale or incomplete data, you get a silently wrong quote — not an error.
  • Price impact scales with depth, not total liquidity. A tick range with $200k of concentrated liquidity can be thinner than it looks because most of that capital stacks at a slightly different price. Always check sqrtPrice and the active tick before sizing.

Fetching Pool State and Tick Arrays Correctly

The entry point is WhirlpoolClient, instantiated from a WhirlpoolContext:

import { WhirlpoolContext, buildWhirlpoolClient, ORCA_WHIRLPOOL_PROGRAM_ID } from "@orca-so/whirlpools-sdk";
import { AnchorProvider } from "@coral-xyz/anchor";

const provider = AnchorProvider.env();
const ctx = WhirlpoolContext.withProvider(provider, ORCA_WHIRLPOOL_PROGRAM_ID);
const client = buildWhirlpoolClient(ctx);

From here, load the pool and tick arrays in a single getMultipleAccounts batch — not sequentially. Latency matters:

import { SwapUtils, PDAUtil, PoolUtil } from "@orca-so/whirlpools-sdk";
import { PublicKey } from "@solana/web3.js";

const whirlpool = await client.getPool(POOL_ADDRESS);
const poolData = whirlpool.getData();

const tickArrayAddresses = SwapUtils.getTickArrayPublicKeys(
  poolData.tickCurrentIndex,
  poolData.tickSpacing,
  true, // a-to-b direction
  ORCA_WHIRLPOOL_PROGRAM_ID,
  POOL_ADDRESS
);

// fetch in one round-trip
const tickArrays = await ctx.fetcher.getTickArrays(tickArrayAddresses);

Miss this batch and you will call the RPC three times sequentially, adding 200–400ms per leg on a remote node. In a competitive arb environment that latency window is everything.

Simulating a Two-Hop Route Off-Chain

For a SOL → USDC → SOL triangle (or any two-pool cycle), simulate both legs before touching the chain:

import { swapQuoteByInputToken, SwapQuote } from "@orca-so/whirlpools-sdk";
import { DecimalUtil, Percentage } from "@orca-so/common-sdk";
import Decimal from "decimal.js";

const INPUT_AMOUNT_SOL = DecimalUtil.toBN(new Decimal("0.5"), 9); // 0.5 SOL in lamports
const SLIPPAGE = Percentage.fromFraction(5, 1000); // 0.5 %

const legOneQuote: SwapQuote = await swapQuoteByInputToken(
  whirlpoolA,
  SOL_MINT,
  INPUT_AMOUNT_SOL,
  SLIPPAGE,
  ORCA_WHIRLPOOL_PROGRAM_ID,
  ctx.fetcher,
  true // refresh on-chain data
);

// feed legOne's estimated output into legTwo
const legTwoQuote: SwapQuote = await swapQuoteByInputToken(
  whirlpoolB,
  USDC_MINT,
  legOneQuote.estimatedAmountOut,
  SLIPPAGE,
  ORCA_WHIRLPOOL_PROGRAM_ID,
  ctx.fetcher,
  true
);

const profitLamports = legTwoQuote.estimatedAmountOut.sub(INPUT_AMOUNT_SOL).toNumber();
const txFeeLamports = 5000 + jitoTipLamports; // base fee + tip estimate
const profitable = profitLamports > txFeeLamports;

Key numbers to calibrate: with a 0.3% fee tier on both pools, you need a spread of at least 0.6% before fees to break even on a round trip. Factor in the Jito tip (5,000–50,000 lamports is typical depending on competition) and the priority fee burn. Sub-$10 positions rarely make sense unless your latency is genuinely sub-50ms to validator.

Building and Submitting a Versioned Transaction

Versioned transactions (v0) are required to use Address Lookup Tables (ALTs), which Orca's SDK uses to compress the large account list a Whirlpool swap instruction carries:

import { TransactionBuilder } from "@orca-so/common-sdk";
import { AddressLookupTableAccount, VersionedTransaction } from "@solana/web3.js";

if (!profitable) return; // never submit unprofitable legs

const swapTxA = await whirlpoolA.swap(legOneQuote);
const swapTxB = await whirlpoolB.swap(legTwoQuote);

// merge into a single versioned transaction
const builder = new TransactionBuilder(ctx.provider.connection, ctx.provider.wallet);
builder.addInstruction(swapTxA.compressIx(true));
builder.addInstruction(swapTxB.compressIx(true));

const { transaction, signers } = await builder.buildV0(
  [altAccount], // preloaded ALT
  computeBudgetIxs  // SetComputeUnitLimit + SetComputeUnitPrice
);

const sig = await ctx.provider.connection.sendTransaction(transaction, { skipPreflight: false });

Wrap this in a simulation call (simulateTransaction) before sending. A simulation failure means your account state drifted between quote and submission — retry immediately rather than forwarding a doomed transaction and burning the priority fee.

Risk Guards You Actually Need

Running this in production without guardrails will cost you money before it makes you money:

  • Stale quote guard. Reject any arb where pool sqrtPrice changed by more than X ticks between prefetch and submission. One slot on Solana is ~400ms; a lot can move.
  • Minimum profit floor. Hard-code a floor in lamports (e.g., 20,000 lamports ≈ $0.004) below which you never submit. This absorbs priority fee variance.
  • Revert-on-fail atomic structure. Both swap instructions live in the same transaction. If leg two fails — say the pool moved — the whole transaction reverts cleanly. You lose the priority fee, not inventory.
  • RPC node selection. Use a staked connection or a provider like Helius/Triton with confirmed commitment. Public mainnet endpoints rate-limit and lag, which makes your quotes stale before you can act on them.

The MEV & Arbitrage Bot service at TierZero ships all of these guards out of the box, tuned from real production runs across Raydium, Orca and Meteora pools.

What Slows Most Implementations Down

Two things kill otherwise solid implementations:

Refetching accounts inside the quote loop. The refresh: true flag on swapQuoteByInputToken triggers an RPC call every time. In a tight loop scanning dozens of pool pairs, this saturates your connection. The right pattern: batch-prefetch all pool states on a timer (every 400ms or every slot), then simulate against the cached snapshot synchronously.

Using legacy transactions. A legacy Solana transaction tops out around 1232 bytes. A two-leg Whirlpool arb with tick arrays and ALTs can exceed that. V0 transactions with an ALT compress the account list and keep you well inside limits. The @orca-so/whirlpools-sdk exposes this through buildV0 — use it from day one, not as a retrofit.


If you want a production-ready Orca Whirlpool arbitrage bot — complete with multi-pool scanning, Jito bundle submission, and live PnL monitoring — reach out via the contact page and we can scope it in one conversation.

Need a bot like this built?

We design, build and run trading bots on Solana, Hyperliquid and Polymarket.

Start a project
#Solana#Orca#Whirlpool#Arbitrage#TypeScript#DeFi#Trading Bots#DEX