Skip to main content
Liquidation bots keep Vanna Protocol solvent by calling AccountManager.liquidate() on undercollateralized accounts. Anyone can run one — no whitelist, no special permissions.

How Liquidation Works

When a margin account’s health factor drops to or below 1.1×:
  1. Any caller can call AccountManager.liquidate(smart_account)
  2. The protocol repays all debt by drawing from the SmartAccount’s collateral
  3. Remaining collateral is swept to the trader
  4. The liquidator pays gas and receives no explicit bonus in the current version
Vanna V1 does not currently pay a liquidation bonus to the bot. The economic incentive is keeping the protocol healthy. A liquidation premium may be introduced in a future version.

Architecture of a Liquidation Bot

┌─────────────────────────────────────────────────────────┐
│                  LIQUIDATION BOT                         │
│                                                          │
│  1. Discovery      2. Monitoring      3. Execution       │
│  ─────────────    ─────────────────  ───────────────    │
│  Get all accounts  Poll health factor  Call liquidate()  │
│  from Registry     every N ledgers     if HF ≤ 1.1×      │
└─────────────────────────────────────────────────────────┘

Step 1: Discover All Margin Accounts

Fetch the full list of accounts from AccountManager events (Mercury) or read from Registry:
import { decodeScVal, fetchContractEvents } from './mercury-client';

const ACCOUNT_MANAGER = 'CAK2IJIO2SKZWUODY4G7ZRIUUIIMJUUAIXE3I5YTQ5QYNSS2RYJ3P4CV';

async function getAllSmartAccounts(): Promise<string[]> {
  // Fetch all AccountCreationEvents
  const events = await fetchContractEvents(ACCOUNT_MANAGER, { limit: 1000 });

  const creationEvents = events.filter(
    e => decodeScVal(e.topic1) === 'AccountCreationEvent'
  );

  const accounts = creationEvents.map(e => {
    const data = decodeScVal(e.data) as { smart_account: string };
    return data.smart_account;
  });

  // Remove duplicates (users may have created multiple accounts)
  return [...new Set(accounts)];
}

Via Registry Contract

async function getAccountsForUser(userAddress: string): Promise<string[]> {
  const result = await readContract(
    REGISTRY,
    'get_accounts',
    [nativeToScVal(userAddress, { type: 'address' })],
  );
  return result as string[];
}

Step 2: Check Health Factor for Each Account

const RISK_ENGINE = 'CBL7RCG5H4VIZCNF7BRM2FQFXK7N5KRQKW7ZVEQZJKNXHA6FEU4OXK5I';
const WAD = 10n ** 18n;
const LIQUIDATION_THRESHOLD = 1_100_000_000_000_000_000n; // 1.1 × WAD

interface AccountHealth {
  smartAccount: string;
  collateralUsd: bigint;
  debtUsd: bigint;
  healthFactor: number | null;
  isLiquidatable: boolean;
}

async function checkAccountHealth(smartAccount: string): Promise<AccountHealth> {
  const [collateralScVal, debtScVal] = await Promise.all([
    readContract(
      RISK_ENGINE,
      'get_current_total_balance',
      [nativeToScVal(smartAccount, { type: 'address' })],
    ),
    readContract(
      RISK_ENGINE,
      'get_current_total_borrows',
      [nativeToScVal(smartAccount, { type: 'address' })],
    ),
  ]);

  const collateralUsd = collateralScVal as bigint;
  const debtUsd       = debtScVal as bigint;

  if (debtUsd === 0n) {
    return {
      smartAccount,
      collateralUsd,
      debtUsd,
      healthFactor: null,
      isLiquidatable: false,
    };
  }

  const hfWad = (collateralUsd * WAD) / debtUsd;
  const isLiquidatable = hfWad <= LIQUIDATION_THRESHOLD;

  return {
    smartAccount,
    collateralUsd,
    debtUsd,
    healthFactor: Number(hfWad) / 1e18,
    isLiquidatable,
  };
}

Step 3: Execute Liquidation

async function liquidateAccount(
  botAddress: string,
  smartAccount: string,
): Promise<string> {
  console.log(`Liquidating ${smartAccount}...`);

  const txHash = await invokeContract(
    botAddress,
    ACCOUNT_MANAGER,
    'liquidate',
    [nativeToScVal(smartAccount, { type: 'address' })],
  );

  console.log(`Liquidation submitted: ${txHash}`);
  return txHash;
}

Full Bot Loop

import { Horizon } from '@stellar/stellar-sdk';

const BOT_ADDRESS = 'G...'; // your bot's Stellar account
const POLL_INTERVAL_LEDGERS = 3; // check every ~15 seconds (one ledger ≈ 5s)

async function runLiquidationBot() {
  const horizon = new Horizon.Server('https://horizon-testnet.stellar.org');

  // Start: discover all accounts
  let accounts = await getAllSmartAccounts();
  console.log(`Monitoring ${accounts.length} accounts`);

  // Subscribe to ledger closes
  horizon.ledgers().cursor('now').stream({
    onmessage: async (ledger) => {
      if (ledger.sequence % POLL_INTERVAL_LEDGERS !== 0) return;

      // Refresh account list periodically
      if (ledger.sequence % 60 === 0) {
        accounts = await getAllSmartAccounts();
      }

      // Check all accounts in parallel (batched to avoid rate limits)
      const BATCH_SIZE = 10;
      for (let i = 0; i < accounts.length; i += BATCH_SIZE) {
        const batch = accounts.slice(i, i + BATCH_SIZE);
        const healthChecks = await Promise.allSettled(
          batch.map(checkAccountHealth)
        );

        for (const result of healthChecks) {
          if (result.status === 'rejected') continue;
          const health = result.value;

          if (!health.isLiquidatable) continue;

          console.log(
            `Liquidatable account found: ${health.smartAccount}`,
            `HF: ${health.healthFactor?.toFixed(3)}`,
            `Debt: $${Number(health.debtUsd) / 1e18}`,
          );

          try {
            const txHash = await liquidateAccount(BOT_ADDRESS, health.smartAccount);
            console.log(`Liquidation successful: ${txHash}`);
          } catch (err) {
            console.error(`Liquidation failed for ${health.smartAccount}:`, err);
          }
        }

        // Small delay between batches
        await new Promise(r => setTimeout(r, 500));
      }
    },
    onerror: console.error,
  });
}

runLiquidationBot().catch(console.error);

Handling Race Conditions

Multiple bots may attempt to liquidate the same account simultaneously. The liquidate() function will succeed for one and fail for the others (the account is no longer unhealthy after the first liquidation). Handle this gracefully:
try {
  await liquidateAccount(BOT_ADDRESS, smartAccount);
} catch (err) {
  const msg = (err as Error).message;
  if (msg.includes('AccountNotUnhealthy') || msg.includes('healthy')) {
    console.log(`Account ${smartAccount} already liquidated`);
  } else {
    throw err;
  }
}

Monitoring with Mercury Events

Set up event tracking to know when liquidations happen in real time:
async function watchLiquidationEvents() {
  // On each ledger close, fetch new liquidation events since last cursor
  let cursor: string | undefined;

  setInterval(async () => {
    const events = await fetchContractEvents(ACCOUNT_MANAGER, {
      limit: 50,
      cursor,
    });

    const liquidations = events.filter(
      e => decodeScVal(e.topic1) === 'TraderLiquidateEvent'
    );

    for (const event of liquidations) {
      const data = decodeScVal(event.data) as {
        smart_account: string;
        timestamp: bigint;
      };
      console.log(`Liquidation detected:`, data.smart_account, event.tx);
    }

    if (events.length > 0) {
      cursor = events[events.length - 1].id;
    }
  }, 5_000); // every 5 seconds
}

Bot Account Setup

Your bot needs a funded Stellar testnet account with enough XLM to pay transaction fees:
# Create account via Friendbot
curl "https://friendbot.stellar.org?addr=<your-bot-address>"
Each liquidate() call costs ~0.001–0.01 XLM in fees. Keep the bot account funded.

Production Considerations

ConcernRecommendation
Bot key securityUse a dedicated keypair stored in environment variables or a secrets manager
Rate limitingBatch RPC calls, add delays between batches
Bot uptimeRun on a VPS or cloud instance with monitoring/alerting
Failed transactionsImplement retry with exponential backoff
Stale account listRefresh from Mercury every N ledgers