A few months ago I swapped some SOL for a token on Jupiter. Nothing unusual. But when I checked the transaction on Solscan, there were two other transactions sandwiching mine — one right before, one right after, same token, same block. I’d been sandwiched.

I wasn’t even mad. I was curious. How did they do that? Solana doesn’t have a public mempool like Ethereum. So how did someone see my transaction before it landed?

That question sent me down a rabbit hole that lasted weeks. I ended up building my own MEV bot. Most of it didn’t work. But I learned more about how Solana actually works than anything else I’ve done.


MEV on Solana Is Different

On Ethereum, MEV is straightforward. Transactions sit in a public mempool. Bots watch the mempool, spot profitable opportunities, and bid higher gas to get priority. It’s an open auction.

Solana doesn’t have a mempool. Transactions go directly to the current leader validator. In theory, that makes MEV harder. In practice, Jito changed everything.

Jito runs a modified validator client that accepts bundles — ordered groups of transactions that execute atomically. You pay a tip to the validator, and your bundle gets included in the block exactly as you ordered it. That’s the mechanism that makes sandwich attacks possible on Solana.

Without Jito, you’d need to be the block leader yourself or have a deal with one. With Jito, anyone can pay for transaction ordering. That’s powerful and a little scary.


Understanding Jito Tips

Jito tips are just SOL transfers to a specific tip account. You attach them to your bundle, and the validator takes the tip as incentive to include your transactions in the right order.

Here’s how you create a tip instruction:

import { SystemProgram, PublicKey, TransactionInstruction } from "@solana/web3.js";

const JITO_TIP_ACCOUNTS = [
  "96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5",
  "HFqU5x63VTqvQss8hp11i4bVqkfRtQ7NmXwkiNPLM68z",
  "Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY",
  "ADaUMid9yfUytqMBgopwjb2DTLSLoC1fJQ6C4HPnNBz",
  "DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh",
];

function createTipInstruction(payer: PublicKey, tipLamports: number): TransactionInstruction {
  const tipAccount = JITO_TIP_ACCOUNTS[Math.floor(Math.random() * JITO_TIP_ACCOUNTS.length)];

  return SystemProgram.transfer({
    fromPubkey: payer,
    toPubkey: new PublicKey(tipAccount),
    lamports: tipLamports,
  });
}

You pick a random tip account from Jito’s list. The tip amount depends on how competitive the opportunity is. I started at 10,000 lamports and quickly realized that’s nothing. Competitive sandwiches need tips in the hundreds of thousands or more.


Monitoring Transactions in Real Time

The first real challenge is seeing transactions before they land. I used Geyser plugins through a gRPC stream to watch for pending swap transactions. You need an RPC provider that supports this — not all of them do.

Here’s a simplified version of what I built:

import { Connection } from "@solana/web3.js";

const RAYDIUM_AMM = "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8";
const JUPITER_V6 = "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4";

async function monitorSwaps(geyserUrl: string) {
  const connection = new Connection(geyserUrl, "confirmed");

  connection.onLogs(
    new (await import("@solana/web3.js")).PublicKey(RAYDIUM_AMM),
    (logs) => {
      if (logs.err) return;

      const hasSwap = logs.logs.some(
        (log) => log.includes("Instruction: Swap") || log.includes("ray_log")
      );

      if (hasSwap) {
        console.log("Swap detected:", logs.signature);
        analyzeSwap(logs.signature);
      }
    },
    "processed"
  );
}

This watches for Raydium swaps at the processed commitment level, which is the earliest you can see them. But here’s the thing — by the time you see a confirmed transaction, it’s already too late for a sandwich. You need to catch transactions while they’re still being processed.

That’s where Jito’s block engine API and mempool streams come in. I won’t share the full setup because it changes often, but the idea is the same: watch for swap instructions, parse the amounts, and decide if there’s profit.


How Sandwich Attacks Work

A sandwich attack has three parts:

  1. Front-run: Buy the token before the victim’s swap executes, pushing the price up
  2. Victim swap: The victim’s transaction executes at a worse price
  3. Back-run: Sell the token immediately after, capturing the price difference

The profit comes from the price impact of the victim’s trade. If someone’s swapping 100 SOL into a low-liquidity token, that swap moves the price. You buy before, they push the price up for you, then you sell.

Here’s how I calculated whether a sandwich was worth attempting:

interface SwapInfo {
  inputMint: string;
  outputMint: string;
  amountIn: number;
  poolLiquidity: number;
  slippageBps: number;
}

function estimateSandwichProfit(swap: SwapInfo, frontRunAmount: number): number {
  const priceImpactVictim = swap.amountIn / swap.poolLiquidity;
  const priceImpactFrontRun = frontRunAmount / swap.poolLiquidity;

  const expectedGain = frontRunAmount * priceImpactVictim;
  const slippageCost = frontRunAmount * priceImpactFrontRun;

  const jitoTip = 0.001; // SOL
  const txFees = 0.000015; // two transactions worth of fees

  const netProfit = expectedGain - slippageCost - jitoTip - txFees;
  return netProfit;
}

function shouldSandwich(swap: SwapInfo): boolean {
  const optimalAmount = swap.poolLiquidity * 0.01; // start small
  const profit = estimateSandwichProfit(swap, optimalAmount);

  // only attempt if profit exceeds minimum threshold
  return profit > 0.005; // 0.005 SOL minimum
}

This is massively simplified. Real sandwich bots use constant product AMM formulas to calculate exact optimal amounts. But the logic is the same: estimate the price impact, subtract costs, check if it’s positive.

I ran this for a few days on devnet with fake transactions. The math worked. When I tried mainnet, I got outbid on every single opportunity. That’s when I learned how competitive this space really is.


Backrunning: The Less Aggressive Approach

Sandwich attacks require front-running someone, which is adversarial. Backrunning is different — you’re just buying after someone else creates a price opportunity.

Say someone dumps a large amount of a token, crashing the price below what other DEXes have it at. You can backrun that transaction by buying the dipped token and selling it on another DEX for an arbitrage profit. Nobody gets hurt. You’re actually helping restore the price.

Here’s how I detected backrun opportunities:

import { Connection, PublicKey } from "@solana/web3.js";

interface PriceImpact {
  signature: string;
  token: string;
  pool: string;
  priceChange: number; // percentage
  volumeSol: number;
}

async function detectBackrunOpportunity(
  connection: Connection,
  recentSwaps: PriceImpact[]
): Promise<PriceImpact | null> {
  for (const swap of recentSwaps) {
    if (swap.priceChange < -0.02 && swap.volumeSol > 5) {
      // price dropped more than 2% on a 5+ SOL trade
      // check other pools for the same token
      const otherPoolPrice = await getTokenPriceFromJupiter(swap.token);
      const thisPoolPrice = await getPoolPrice(connection, swap.pool);

      const arbSpread = (otherPoolPrice - thisPoolPrice) / otherPoolPrice;

      if (arbSpread > 0.005) {
        return swap;
      }
    }
  }
  return null;
}

async function getTokenPriceFromJupiter(mint: string): Promise<number> {
  const res = await fetch(`https://price.jup.ag/v6/price?ids=${mint}`);
  const data = await res.json();
  return data.data[mint]?.price || 0;
}

Backrunning felt more natural to me. You’re not extracting value from other traders — you’re capturing inefficiencies between pools. I had slightly more success here, but “slightly more” still means most attempts failed.


Submitting Jito Bundles

Once you’ve identified an opportunity, you need to submit it as a Jito bundle. A bundle is just an ordered list of transactions that execute together or not at all.

import { Transaction, Keypair, Connection } from "@solana/web3.js";

async function submitJitoBundle(
  transactions: Transaction[],
  signers: Keypair[],
  tipLamports: number
) {
  const JITO_BLOCK_ENGINE = "https://mainnet.block-engine.jito.wtf/api/v1/bundles";

  // add tip to the last transaction
  const tipIx = createTipInstruction(signers[0].publicKey, tipLamports);
  transactions[transactions.length - 1].add(tipIx);

  const connection = new Connection("https://api.mainnet-beta.solana.com");
  const { blockhash } = await connection.getLatestBlockhash();

  const serialized = transactions.map((tx) => {
    tx.recentBlockhash = blockhash;
    tx.sign(...signers);
    return Buffer.from(tx.serialize()).toString("base64");
  });

  const response = await fetch(JITO_BLOCK_ENGINE, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "sendBundle",
      params: [serialized],
    }),
  });

  const result = await response.json();
  return result;
}

The atomicity is the key part. If your front-run succeeds but your back-run fails, you’re stuck holding a token you didn’t want. Bundles prevent that — all or nothing.


The Economics Are Brutal

Here’s what I learned about the actual numbers after running this for two weeks:

Success rate: Under 5%. Most bundles get outbid or the opportunity disappears before your bundle lands. Other bots are running on bare-metal servers co-located with validators. My cloud VPS couldn’t compete on latency.

Tip costs: Even failed bundles cost you nothing (they just don’t execute), but you burn compute time and RPC credits. Successful bundles need aggressive tips. I saw competitive tips ranging from 0.01 to 0.5 SOL for good opportunities.

Competition: There are maybe a dozen serious MEV operations on Solana. They have custom infrastructure, private validator relationships, and optimized code in Rust. My TypeScript bot was bringing a knife to a gunfight.

I made a grand total of about 0.3 SOL in profit over two weeks, and spent way more than that on RPC provider fees and Jito tips that went to losing bundles. Net negative. Not even close.


The Ethics Problem

I need to be honest about this. Sandwich attacks are parasitic. They take money directly from regular users. When someone sets 1% slippage on a swap and a bot extracts that full 1%, a real person just paid more than they should have.

I knew this going in, but building the bot made it concrete. I could see the wallets I’d be sandwiching. Some of them were clearly regular people doing small swaps. That’s someone’s money.

Backrunning and cross-pool arbitrage are different — they actually help market efficiency. But pure sandwich attacks? They’re a tax on regular users who don’t understand what’s happening to their transactions.

Some people argue that MEV is just the cost of using a public blockchain. I get the argument. I don’t fully agree with it. There’s a difference between arbitrage that corrects prices and front-running that extracts value from individuals.


What I Actually Learned

Building this bot taught me more about Solana than I expected:

Transaction lifecycle: I now understand exactly how a transaction goes from your wallet to a block. The leader schedule, the forwarding, the vote transactions — all of it clicked once I had to care about millisecond-level timing.

Program internals: Parsing Raydium and Orca swap instructions byte by byte forced me to understand how Solana programs encode data. No tutorial taught me this as effectively as needing to decode a real transaction.

Economic design: Solana’s fee model, priority fees, and how Jito’s tip market creates an auction for block space — these are fascinating systems. They shape what’s possible and what’s profitable.

Infrastructure matters: Latency is everything. The difference between a 50ms and 200ms response time is the difference between winning and losing every single opportunity. This applies to any real-time system, not just MEV.

I don’t run the bot for profit. The economics don’t make sense for a solo developer, and I’m not comfortable running sandwich attacks on real people. But I keep the code around because it’s the best Solana debugging tool I’ve ever built. When I want to understand what’s happening on-chain, I fire it up and watch transactions flow.

If you want to understand how a system really works, try to exploit it. You’ll learn things the documentation never covers.