I was watching a SOL/USDC pool on Raydium one night when I noticed the price was about 0.3% higher than what Orca was showing. It lasted maybe 8 seconds. By the time I opened a second tab, it was gone.
That got me obsessed. I spent the next two weeks building a scanner that watches multiple DEXs at once and flags price gaps in real time. I didn’t get rich. But I learned more about how Solana DEXs actually work than months of reading docs ever taught me.
What’s the Idea
Same token, different DEXs, different prices. It happens constantly. Raydium, Orca, Phoenix, Lifinity — they all have their own liquidity pools with their own order books or AMM curves. Prices don’t stay perfectly synced.
Arbitrage is buying where it’s cheap and selling where it’s expensive. In theory, you pocket the difference. In practice, it’s way harder than it sounds.
But let’s build the scanner first.
Setting Up the Project
You’ll need Node.js 18+ and a Solana RPC endpoint. I use Helius because their free tier is generous enough for polling.
npm init -y
npm install @solana/web3.js cross-fetch
Here’s the basic setup:
import { Connection, PublicKey } from "@solana/web3.js";
import fetch from "cross-fetch";
const connection = new Connection(
"https://mainnet.helius-rpc.com/?api-key=YOUR_API_KEY"
);
const JUPITER_QUOTE_API = "https://quote-api.jup.ag/v6/quote";
// Common token mints
const TOKENS = {
SOL: "So11111111111111111111111111111111111111112",
USDC: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
BONK: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
JUP: "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN",
RAY: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
};
Fetching Quotes from Jupiter
Jupiter is perfect for this because it aggregates routes across all major Solana DEXs. You can ask it for quotes through specific DEXs by filtering the response.
Here’s what I do:
async function getQuote(
inputMint: string,
outputMint: string,
amount: number,
dexes?: string[]
): Promise<any> {
const params = new URLSearchParams({
inputMint,
outputMint,
amount: amount.toString(),
slippageBps: "50",
});
if (dexes && dexes.length > 0) {
params.append("dexes", dexes.join(","));
}
const response = await fetch(`${JUPITER_QUOTE_API}?${params}`);
if (!response.ok) {
throw new Error(`Quote failed: ${response.status}`);
}
return response.json();
}
The amount is in the smallest unit. For SOL that’s lamports, so 1 SOL = 1,000,000,000. For USDC it’s 1,000,000 (6 decimals).
Comparing Prices Across DEXs
Now the fun part. I grab quotes for the same pair through different DEXs and compare them:
async function comparePrices(
inputMint: string,
outputMint: string,
amount: number
) {
const dexList = [
{ name: "Raydium", ids: ["Raydium", "Raydium CLMM"] },
{ name: "Orca", ids: ["Orca", "Orca (Whirlpools)"] },
{ name: "Phoenix", ids: ["Phoenix"] },
{ name: "Lifinity", ids: ["Lifinity", "Lifinity V2"] },
];
const quotes = await Promise.allSettled(
dexList.map(async (dex) => {
const quote = await getQuote(inputMint, outputMint, amount, dex.ids);
return {
dex: dex.name,
outAmount: parseInt(quote.outAmount),
priceImpact: parseFloat(quote.priceImpactPct),
route: quote.routePlan,
};
})
);
const results = quotes
.filter((q) => q.status === "fulfilled")
.map((q) => (q as PromiseFulfilledResult<any>).value)
.sort((a, b) => b.outAmount - a.outAmount);
return results;
}
The key metric is outAmount. If Raydium gives you 152.3 USDC for 1 SOL but Orca gives you 151.8 USDC, that’s a 0.33% gap. That’s your potential profit before fees.
Calculating the Spread
function calculateSpread(results: any[]) {
if (results.length < 2) return null;
const best = results[0];
const worst = results[results.length - 1];
const spreadPct =
((best.outAmount - worst.outAmount) / worst.outAmount) * 100;
return {
buyDex: worst.dex,
sellDex: best.dex,
buyAmount: worst.outAmount,
sellAmount: best.outAmount,
spreadPct: spreadPct.toFixed(4),
};
}
I used to just compare the top two. That was a mistake. Sometimes the best arb is between the highest and lowest quote, and the lowest isn’t always the second result. Sort everything, then compare the extremes.
Building the Scanner Loop
Here’s the full scanner that checks multiple pairs on a loop:
const PAIRS = [
{ name: "SOL/USDC", input: TOKENS.SOL, output: TOKENS.USDC, amount: 1e9 },
{
name: "BONK/USDC",
input: TOKENS.BONK,
output: TOKENS.USDC,
amount: 1e11,
},
{ name: "JUP/USDC", input: TOKENS.JUP, output: TOKENS.USDC, amount: 1e8 },
{ name: "RAY/USDC", input: TOKENS.RAY, output: TOKENS.USDC, amount: 1e8 },
];
async function scan() {
console.log(`\n--- Scan at ${new Date().toISOString()} ---`);
for (const pair of PAIRS) {
try {
const results = await comparePrices(
pair.input,
pair.output,
pair.amount
);
const spread = calculateSpread(results);
if (!spread) continue;
const threshold = 0.1; // 0.1% minimum spread
if (parseFloat(spread.spreadPct) >= threshold) {
console.log(
`[${pair.name}] ${spread.spreadPct}% gap — ` +
`Buy on ${spread.buyDex}, Sell on ${spread.sellDex}`
);
}
} catch (err) {
console.error(`Error scanning ${pair.name}:`, err.message);
}
}
}
// Run every 5 seconds
setInterval(scan, 5000);
scan();
I run this in a tmux session on a cheap VPS. Nothing fancy.
Is the Gap Actually Profitable?
This is where most people stop too early. A 0.3% spread looks great until you factor in costs. Here’s what eats your profit on Solana:
- Transaction fees: ~0.000005 SOL per transaction (basically nothing)
- Priority fees: 0.0001–0.005 SOL depending on network congestion
- Slippage: the price moves between quote and execution
- Price impact: your trade itself moves the pool price
function isProfitable(spread: any, tradeSizeUSDC: number) {
const spreadValue = tradeSizeUSDC * (parseFloat(spread.spreadPct) / 100);
// Estimated costs in USDC
const txFees = 0.01; // two transactions worth of priority fees
const estimatedSlippage = tradeSizeUSDC * 0.0005; // 0.05% slippage estimate
const totalCost = txFees + estimatedSlippage;
const netProfit = spreadValue - totalCost;
return {
grossProfit: spreadValue.toFixed(4),
totalCost: totalCost.toFixed(4),
netProfit: netProfit.toFixed(4),
profitable: netProfit > 0,
};
}
On a $1,000 trade with a 0.3% spread, that’s $3 gross. After slippage and fees, you’re looking at maybe $2.40. Not bad if it happens often. The problem is it doesn’t.
The MEV Bot Problem
Here’s the honest truth: by the time your scanner sees a price gap and you submit a transaction, a MEV bot has already closed it. Solana’s block time is 400ms. These bots run on validators with direct access to the transaction pipeline.
I watched my scanner flag 47 opportunities in one hour. I tried to execute 12 of them manually. Zero worked. The gap was gone before my transaction landed.
The bots aren’t competing with you on speed. They’re competing with each other. You’re not even in the race. Jito bundles and validator tips give them sub-second execution that no Node.js script can match.
Flash Loans: Solana vs Ethereum
On Ethereum, flash loans make arbitrage accessible because you can borrow millions, arb, and repay in one transaction with zero capital. Aave and dYdX popularized this.
Solana doesn’t have native flash loans the same way. There are some protocols like Solend that offer flash loan instructions, but they’re less mature. The bigger issue is that Solana’s transaction size limit (1232 bytes) makes it hard to pack a borrow-swap-swap-repay sequence into a single transaction.
I tried building a flash loan arb on Solana. The transaction kept failing because I was hitting the compute unit limit. You can request more compute units, but it gets expensive and still isn’t enough for complex multi-hop routes.
Where the Real Money Is
After a few weeks of this, I talked to someone who actually makes money in this space. They told me two things:
Statistical arbitrage isn’t about catching one big gap. It’s about modeling the expected price relationship between correlated tokens and trading tiny deviations thousands of times. You need capital, infrastructure, and a statistical edge. Not a cron job.
Cross-DEX market making is where you provide liquidity on one DEX and hedge on another. You profit from the spread between your buy and sell orders while staying delta-neutral. This requires serious capital and risk management.
Both of these need dedicated servers co-located near validators, custom Rust programs, and months of development. Not a weekend project.
Making the Scanner Smarter
Even if you won’t profit from executing trades, the scanner is useful for understanding market dynamics. Here’s an upgrade that tracks historical spreads:
const spreadHistory: Map<string, number[]> = new Map();
function trackSpread(pair: string, spread: number) {
if (!spreadHistory.has(pair)) {
spreadHistory.set(pair, []);
}
const history = spreadHistory.get(pair)!;
history.push(spread);
// Keep last 1000 readings
if (history.length > 1000) {
history.shift();
}
const avg = history.reduce((a, b) => a + b, 0) / history.length;
const max = Math.max(...history);
console.log(
`[${pair}] Current: ${spread.toFixed(4)}% | ` +
`Avg: ${avg.toFixed(4)}% | Max: ${max.toFixed(4)}%`
);
}
I ran this for a week on SOL/USDC. The average spread between Raydium and Orca was 0.04%. The max was 1.2%, and it lasted less than 2 seconds. That tells you everything about how efficient these markets already are.
What I Actually Learned
Building this scanner didn’t make me money. But it made me a much better Solana developer.
I now understand how AMM pricing works at a gut level. I know why Jupiter exists and why route optimization matters. I understand why MEV is such a big deal and why Jito built an entire ecosystem around it. I can look at a pool’s liquidity depth and immediately know whether a trade will move the price.
If you’re trying to understand DeFi mechanics on Solana, build an arb scanner. Don’t expect to profit from it. Expect to learn from it. The code is simple. The market lessons are priceless.