I was poking around Solana Explorer one night, tracing some transactions on Marginfi, and I noticed something weird. Someone was making 5-8% returns on single transactions, over and over. Not trading. Not arbitrage. They were liquidating underwater lending positions and pocketing the bonus.

I’d heard of liquidation bots on Ethereum. I figured Solana’s space was too competitive. Turns out, it wasn’t. There’s real money sitting on the table — especially on smaller lending protocols where the big MEV operators aren’t paying attention.

So I built one. Here’s exactly how it works.


How Lending Protocols Create Liquidation Opportunities

If you’ve used Solend, Marginfi, or any DeFi lending protocol, you know the basics. You deposit collateral (say, SOL), and borrow against it (say, USDC). The protocol enforces a loan-to-value ratio. If your collateral drops in value and your position becomes undercollateralized, you’re in trouble.

Every position has a health factor. Above 1.0, you’re safe. Below 1.0, anyone can liquidate you. That means a third party repays part of your debt and receives your collateral at a discount.

That discount is the liquidation bonus. On most Solana protocols, it’s between 5% and 10%. You repay someone’s 1000 USDC debt and get $1,050-$1,100 worth of SOL in return.

That’s the whole business model. Find underwater positions, close them, keep the spread.


Setting Up the Project

You’ll need a few things:

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

const connection = new Connection(
  "https://api.mainnet-beta.solana.com",
  "confirmed"
);

const wallet = Keypair.fromSecretKey(
  bs58.decode(process.env.PRIVATE_KEY!)
);

I use a dedicated RPC endpoint in production. The public one rate-limits you fast, and speed is everything here. Helius or Triton work well for this.


Fetching All Lending Positions

The first step is scanning every open position on your target protocol. On Marginfi, each borrower has a margin account stored as a program account. You can fetch all of them with getProgramAccounts.

const MARGINFI_PROGRAM_ID = new PublicKey(
  "MFv2hWf31Z9kbCa1snEPYctwafyhdvnV7FZnsebVacA"
);

async function fetchAllPositions(): Promise<Buffer[]> {
  const accounts = await connection.getProgramAccounts(
    MARGINFI_PROGRAM_ID,
    {
      filters: [
        { dataSize: 2048 }, // margin account size
      ],
    }
  );

  console.log(`Found ${accounts.length} margin accounts`);
  return accounts.map((a) => a.account.data);
}

This returns thousands of accounts. Most of them are healthy. Your job is finding the ones that aren’t.


Calculating Health Factors

Each margin account holds data about what the user deposited and what they borrowed. You need to decode that data, fetch current oracle prices, and calculate the health factor yourself.

interface Position {
  owner: PublicKey;
  collateralMint: PublicKey;
  collateralAmount: number;
  debtMint: PublicKey;
  debtAmount: number;
  healthFactor: number;
}

async function calculateHealthFactor(
  collateralAmount: number,
  collateralPrice: number,
  liquidationThreshold: number,
  debtAmount: number,
  debtPrice: number
): Promise<number> {
  const collateralValue =
    collateralAmount * collateralPrice * liquidationThreshold;
  const debtValue = debtAmount * debtPrice;

  if (debtValue === 0) return Infinity;
  return collateralValue / debtValue;
}

The liquidation threshold varies by asset. SOL might have a threshold of 0.85, meaning your collateral value is weighted at 85% for health calculations. Riskier assets have lower thresholds.

I made the mistake of hardcoding these values early on. Don’t do that. Fetch them from the protocol’s on-chain config. They change.


Monitoring for Underwater Positions

You don’t want to scan every account every time. That’s slow and wasteful. Instead, I run a loop that watches price feeds and only recalculates health factors when prices move significantly.

async function monitorPositions(positions: Position[]) {
  const HEALTH_THRESHOLD = 1.02; // slightly above 1.0

  while (true) {
    const prices = await fetchOraclePrices();

    const atRisk = [];

    for (const pos of positions) {
      const health = await calculateHealthFactor(
        pos.collateralAmount,
        prices[pos.collateralMint.toBase58()],
        0.85,
        pos.debtAmount,
        prices[pos.debtMint.toBase58()]
      );

      if (health < HEALTH_THRESHOLD) {
        atRisk.push({ ...pos, healthFactor: health });
      }
    }

    if (atRisk.length > 0) {
      console.log(`${atRisk.length} positions near liquidation`);
      for (const pos of atRisk) {
        if (pos.healthFactor < 1.0) {
          await executeLiquidation(pos);
        }
      }
    }

    await sleep(2000); // poll every 2 seconds
  }
}

I set the threshold at 1.02, not 1.0. That gives me a heads-up before positions actually become liquidatable. When a position crosses below 1.0, I fire immediately.


Executing the Liquidation

This is where the money happens. You build a transaction that repays the borrower’s debt and claims their collateral at a discount.

async function executeLiquidation(position: Position) {
  const liquidateIx = createLiquidateInstruction({
    liquidator: wallet.publicKey,
    marginAccount: position.owner,
    debtMint: position.debtMint,
    collateralMint: position.collateralMint,
    repayAmount: Math.floor(position.debtAmount * 0.5),
  });

  const tx = new Transaction().add(liquidateIx);
  tx.recentBlockhash = (
    await connection.getLatestBlockhash()
  ).blockhash;
  tx.feePayer = wallet.publicKey;

  try {
    const sig = await sendAndConfirmTransaction(
      connection,
      tx,
      [wallet]
    );
    console.log(`Liquidation successful: ${sig}`);
    console.log(
      `Profit: ~${position.debtAmount * 0.05} USD`
    );
  } catch (err) {
    console.error("Liquidation failed:", err);
  }
}

Most protocols cap how much of a position you can liquidate in one go — usually 50%. That 0.5 multiplier isn’t arbitrary. And the 0.05 profit estimate assumes a 5% liquidation bonus, which is protocol-specific.

The createLiquidateInstruction function is protocol-specific too. On Marginfi, you’re calling their liquidation instruction. On Solend, it’s a different interface. I wrote wrappers for both.


The Liquidation Bonus Is Your Edge

Here’s the math that matters. Say someone borrowed 10,000 USDC against 80 SOL (at $150/SOL = $12,000 collateral). SOL drops to $130. Their collateral is now worth $10,400 — below the liquidation threshold.

You repay 5,000 USDC of their debt. The protocol gives you SOL worth $5,250-$5,500 depending on the bonus percentage. That $250-$500 spread is pure profit, minus transaction fees (which on Solana are basically nothing).

I’ve seen bonuses as high as 10% on volatile assets. On stablecoins it’s lower, around 5%. The riskier the collateral, the fatter the bonus.


The Full Bot Loop

Here’s the simplified main loop that ties everything together:

async function runBot() {
  console.log("Starting liquidation bot...");

  // initial scan
  let rawAccounts = await fetchAllPositions();
  let positions = await decodeAllPositions(rawAccounts);
  console.log(`Tracking ${positions.length} positions`);

  // refresh positions every 5 minutes
  setInterval(async () => {
    rawAccounts = await fetchAllPositions();
    positions = await decodeAllPositions(rawAccounts);
  }, 5 * 60 * 1000);

  // continuous monitoring
  await monitorPositions(positions);
}

runBot().catch(console.error);

Nothing fancy. Fetch all positions on startup, refresh the list periodically, and continuously monitor health factors. When something goes underwater, liquidate it.


Competition Is Real

I won’t pretend this is easy money with no competition. Other bots are doing this. Some of them are fast. Some use Jito bundles to guarantee their transactions land first.

When I started, I was losing about 60% of liquidation races. My transactions would land, but someone else’s landed first. The position was already liquidated by the time my transaction executed.

What helped:

  • Better RPC nodes — I switched to a staked connection through Jito. Costs more, but my transactions land faster.
  • Priority fees — Adding a compute unit price of 10,000-50,000 lamports bumps you up in block ordering.
  • Targeting smaller protocols — Fewer bots watching Marginfi edge cases vs. the main Solend pools.
import { ComputeBudgetProgram } from "@solana/web3.js";

const priorityFeeIx = ComputeBudgetProgram.setComputeUnitPrice({
  microLamports: 50_000,
});

const tx = new Transaction()
  .add(priorityFeeIx)
  .add(liquidateIx);

That priority fee costs you fractions of a cent but can mean the difference between winning and losing the liquidation.


Capital Requirements

You need capital to liquidate. If someone owes 10,000 USDC, you need 5,000 USDC sitting in your wallet to repay half their debt.

I started with about $5,000. That limited me to smaller positions, but smaller positions also have less competition. It worked out.

For bigger positions, flash loans are the answer. Solana has flash loan support through protocols like Jupiter and Solend itself. You borrow the capital, execute the liquidation, repay the flash loan, and keep the profit — all in a single transaction.

// pseudocode for flash loan liquidation
// 1. Flash borrow 5000 USDC
// 2. Repay borrower's 5000 USDC debt
// 3. Receive ~5250 USDC worth of SOL
// 4. Swap SOL back to USDC
// 5. Repay flash loan (5000 + small fee)
// 6. Keep ~200-240 USDC profit

Flash loans remove the capital requirement entirely. But they add complexity and another failure point. I used my own capital for the first few months before adding flash loan support.


Things I Got Wrong

A few mistakes that cost me money early on:

Ignoring slippage on collateral swaps. You receive collateral in whatever token the borrower deposited. If that’s SOL, great — easy to sell. If it’s some low-liquidity token, you might lose your profit margin on the swap.

Not accounting for oracle staleness. Sometimes Pyth or Switchboard oracles lag behind real prices. I’d calculate a position as underwater, but by the time my transaction landed, the oracle had updated and the position was healthy again. Wasted transaction fees.

Running on a single RPC. My node went down for 20 minutes once. Missed three liquidation opportunities. Now I failover between two providers.


Is It Worth It?

Liquidation bots are one of the few sustainable on-chain income sources that don’t depend on market direction. Bull or bear, people get liquidated. The worse the market, the more opportunities you get.

But the barrier to entry keeps rising. Better bots, faster infrastructure, more competition. What worked easily six months ago requires more sophistication now. If you’re getting into this, start small, target protocols that aren’t saturated, and invest in your infrastructure before your capital. The edge isn’t in having the most money — it’s in being the fastest to act.