Liquidation Bot

Build a Lotus liquidation bot that monitors positions, encodes lmData, executes liquidations, and uses flash-funded callbacks with profitability checks.

Build a bot that monitors Lotus positions, detects unhealthy borrowers, encodes lmData with BaseLiquidationParams, and executes liquidations.

circle-info

Prerequisites: Understanding of liquidation mechanics (see Learn → Loan Health), understanding of BaseLiquidationParams encoding (see Build → Liquidation Modules), RPC access to an Ethereum node, and familiarity with the liquidate function (see Reference → Contract API).

1

Monitor Positions

Identify all active borrowers by indexing Borrow events, then check each position's health:

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const lotus = new ethers.Contract(LOTUS_ADDRESS, lotusAbi, signer);

const ORACLE_PRICE_SCALE = 10n ** 36n;
const WAD = 10n ** 18n;

async function findUnhealthyPositions(marketId: string, marketParams: any) {
  const numTranches = await lotus.getNumMarketTranches(marketId);

  for (let t = 0; t < numTranches; t++) {
    // Use accrueInterestAndReturnMaxBorrow for accurate health checks
    const borrowers = await getActiveBorrowers(marketId, t); // from indexed events

    for (const borrower of borrowers) {
      const position = await lotus.getPosition(marketId, borrower);
      const borrowShares = position.borrowShares[t];
      if (borrowShares === 0n) continue;

      const tranches = await lotus.getMarketTranches(marketId);
      const tranche = tranches[t];
      const borrowed = borrowShares * tranche.trancheBorrowAssets / tranche.trancheBorrowShares;

      const oracle = new ethers.Contract(marketParams.oracles[t], oracleAbi, provider);
      const price = await oracle.price();
      const collateral = position.collateral[t];
      const collateralValue = collateral * price / ORACLE_PRICE_SCALE;
      const maxBorrow = collateralValue * marketParams.lltvs[t] / WAD;

      if (borrowed > maxBorrow) {
        console.log(`Unhealthy: ${borrower} tranche ${t}`);
        await executeLiquidation(marketParams, t, borrower, borrowShares, price);
      }
    }
  }
}
2

Encode lmData

The lmData parameter is decoded by the liquidation module's BaseLiquidationModule into a BaseLiquidationParams struct:

struct BaseLiquidationParams {
    uint256 seizedAssets;   // Collateral to seize (0 if using repaidShares)
    uint256 repaidShares;   // Borrow shares to repay (0 if using seizedAssets)
    bytes callbackData;     // Optional callback data for ILotusLiquidateCallback
}

Exactly one of seizedAssets or repaidShares must be non-zero.

Asset-based mode: specify collateral to seize, the module computes repayment:

bytes memory lmData = abi.encode(BaseLiquidationParams({
    seizedAssets: 5e18,       // Seize 5 WETH of collateral
    repaidShares: 0,
    callbackData: ""
}));

Share-based mode: specify borrow shares to repay, the module computes seizure:

bytes memory lmData = abi.encode(BaseLiquidationParams({
    seizedAssets: 0,
    repaidShares: sharesToRepay,
    callbackData: ""
}));

In TypeScript:

const lmData = ethers.AbiCoder.defaultAbiCoder().encode(
  ["tuple(uint256 seizedAssets, uint256 repaidShares, bytes callbackData)"],
  [{ seizedAssets: collateralToSeize, repaidShares: 0n, callbackData: "0x" }]
);
3

Execute Liquidation

Call liquidate with the encoded lmData:

(uint256 seizedAssets, uint256 repaidAssets) = lotus.liquidate(
    marketParams,
    trancheIndex,
    borrower,
    "",        // irmData (empty for AdaptiveLinearKinkIrm)
    lmData
);

In TypeScript:

async function executeLiquidation(
  marketParams: any,
  trancheIndex: number,
  borrower: string,
  borrowShares: bigint,
  price: bigint
) {
  // Encode lmData — repay all borrow shares
  const lmData = ethers.AbiCoder.defaultAbiCoder().encode(
    ["tuple(uint256,uint256,bytes)"],
    [[0n, borrowShares, "0x"]]
  );

  const tx = await lotus.liquidate(
    marketParams,
    trancheIndex,
    borrower,
    "0x",     // irmData
    lmData
  );
  const receipt = await tx.wait();
  console.log(`Liquidated ${borrower}: tx ${receipt.hash}`);
}
4

Flash-Funded Liquidation

You can liquidate without upfront capital using the liquidation callback. Pass callback data in BaseLiquidationParams to receive the ILotusLiquidateCallback:

contract FlashLiquidator is ILotusLiquidateCallback {
    ILotus public immutable lotus;
    ISwapRouter public immutable router;

    function liquidate(
        MarketParams calldata marketParams,
        uint256 trancheIndex,
        address borrower,
        uint256 seizedAssets
    ) external {
        bytes memory callbackData = abi.encode(msg.sender);
        bytes memory lmData = abi.encode(BaseLiquidationParams({
            seizedAssets: seizedAssets,
            repaidShares: 0,
            callbackData: callbackData
        }));

        lotus.liquidate(marketParams, trancheIndex, borrower, "", lmData);
    }

    function onLotusLiquidate(uint256 repaidAssets, bytes calldata data) external {
        // 1. At this point we have received seized collateral.
        // 2. Swap collateral → loan token to cover repaidAssets.
        address collateralToken = /* from context */;
        address loanToken = /* from context */;

        IERC20(collateralToken).approve(address(router), type(uint256).max);
        router.exactOutputSingle(/* swap params to get repaidAssets of loanToken */);

        // 3. Approve Lotus to pull repaidAssets of loan token.
        IERC20(loanToken).approve(address(lotus), repaidAssets);

        // 4. Profit remains in this contract.
    }
}
circle-exclamation

Profitability Calculation

Walk through a concrete example:

Setup: A borrower has 10 WETH collateral and 15,000 USDC debt in a tranche with 90% liquidation loan-to-value (LLTV) and 1.05x liquidation incentive factor.

Price drop: ETH drops from $2,000 to $1,600. The borrower's loan-to-value (LTV) becomes 15,000 / (10 * 1,600) = 93.75%, exceeding the 90% LLTV.

Liquidation: The liquidator repays the full 15,000 USDC debt and seizes collateral worth 15,000 * 1.05 = 15,750 USDC at the oracle price.

Seized collateral: 15,750 / 1,600 = 9.84375 WETH

Profit calculation:

  • Seized: 9.84375 WETH (worth 15,750 USDC at oracle price)

  • Paid: 15,000 USDC

  • Gross profit: 750 USDC (the 5% incentive)

  • Minus: gas cost (~200K gas) and swap fees (~0.3%)

  • Net profit: ~700 USDC

The profitability depends on the incentive factor, gas price, swap routing efficiency, and competition from other liquidators.

See Also

Last updated