🌊Liquidator

Secure the Zeta platform from over-bankrupt accounts

Github repository: https://github.com/zetamarkets/sdk/tree/main/examples/liquidator-example

Performing liquidations is a risky endeavour, and is not guaranteed to be profitable. Those performing liquidations should be advised that their capital is not guaranteed to be safe and should only risk what they are willing to lose. This software is unaudited / untested and should solely serve as an example for interacting with the Zeta DEX.

How the liquidation process works on Zeta DEX:

  1. An underwater account goes below maintenance margin, and is able to be liquidated.

  2. Liquidators must cancel all open orders for that account before performing liquidations.

  3. Liquidations occur at mark price +/- a liquidation reward for shorts/longs. This reward is for liquidators can be found under Liquidation Rewards.

  4. Liquidators can send in liquidation instructions and proceed to liquidate positions one at a time for a certain party. At each liquidation instruction the smart contract code checks that the user is still below their maintenance margin requirement (partial liquidations).

Unlike the other examples, there are a few separate files. They're pasted below, but it's probably easier to clone our Github repository instead.

Code (liquidator.ts)
require("dotenv").config();
import {
  CrossClient,
  Exchange,
  Network,
  utils,
  types,
  assets,
  programTypes,
  constants,
} from "@zetamarkets/sdk";
import { PublicKey, Connection, Keypair } from "@solana/web3.js";
import { Wallet } from "@zetamarkets/anchor";
import {
  cancelAllActiveOrders,
  findAccountsAtRisk,
  findLiquidatableAccounts,
  liquidateAccounts,
} from "./liquidator-utils";
import * as anchor from "@zetamarkets/anchor";

import { airdropUsdc } from "./utils";

let client: CrossClient;
let scanning: boolean = false;

// Function that will do a few things sequentially.
// 1. Get all margin accounts for the program.
// 2. Cancel all active orders for accounts at risk. (This is required to liquidate an account)
// 3. Naively liquidate all margin accounts at risk up to your own margin account available balance limit.
export async function scanMarginAccounts() {
  // Just early exit if previous scan is still running.
  if (scanning) {
    return;
  }
  console.log(`Scanning margin accounts...`);
  scanning = true;
  let marginAccounts: anchor.ProgramAccount[] =
    await Exchange.program.account.crossMarginAccount.all();

  console.log(`${marginAccounts.length} margin accounts.`);

  let accountsAtRisk = await findAccountsAtRisk(marginAccounts);
  if (accountsAtRisk.length == 0) {
    console.log("No accounts at risk.");
    scanning = false;
    return;
  }

  // We need to cancel all orders on accounts that are at risk
  // before we are able to liquidate them.
  await cancelAllActiveOrders(client, accountsAtRisk);

  // Liquidate the accounts that are under water exclusive of initial
  // margin as cancelling active orders reduces initial margin to 0.
  let liquidatableAccounts: anchor.ProgramAccount[] =
    await findLiquidatableAccounts(accountsAtRisk);

  await liquidateAccounts(client, liquidatableAccounts);

  // Display the latest client state.
  await client.updateState();
  let clientMarginAccountState =
    Exchange.riskCalculator.getCrossMarginAccountState(client.account);
  console.log(
    `Client margin account state: ${JSON.stringify(clientMarginAccountState)}`
  );
  scanning = false;
}

async function main() {
  let connection: Connection = new Connection(process.env.connection, {
    commitment: "confirmed",
    disableRetryOnRateLimit: true,
  });

  // Starting balance for USDC to airdrop.
  const startingBalance = 10_000;

  // Set the network for the SDK to use.
  const network = Network.DEVNET;

  // Load user wallet.
  const keypair = Keypair.generate();

  // You can load from your local keypair as well.
  /*
  const keypair = Keypair.fromSecretKey(
    new Uint8Array(JSON.parse(Buffer.from(process.env.private_key!).toString()))
  );
  */

  const wallet = new Wallet(keypair);
  // Add some solana and airdrop some fake USDC.
  if (network == Network.DEVNET) {
    // Airdrop 1 SOL.
    await connection.requestAirdrop(wallet.publicKey, 1_000_000_000);
    await airdropUsdc(wallet.publicKey, startingBalance);
  }

  const loadExchangeConfig = types.defaultLoadExchangeConfig(
    network,
    connection,
    utils.defaultCommitment(),
    0, // Increase if you are getting rate limited on startup.
    true
  );

  // Load the SDK Exchange object.
  await Exchange.load(
    loadExchangeConfig,
    new types.DummyWallet() // Normal clients don't need to use a real wallet for exchange loading.
  );

  client = await CrossClient.load(
    connection,
    wallet,
    utils.defaultCommitment()
  );

  // The deposit function for client will initialize a margin account for you
  // atomically in the same transaction if you haven't created one already.
  await client.deposit(utils.convertDecimalToNativeInteger(startingBalance));

  setInterval(
    async () => {
      try {
        await scanMarginAccounts();
      } catch (e) {
        console.log(`Scan margin account error: ${e}`);
      }
    },
    process.env.check_interval_ms
      ? parseInt(process.env.check_interval_ms)
      : 5000
  );
}

main().catch(console.error.bind(console));
Code (liquidator-utils.ts)
import * as anchor from "@zetamarkets/anchor";
import {
  constants,
  CrossClient,
  Exchange,
  programTypes,
  assets,
} from "@zetamarkets/sdk";

export async function findAccountsAtRisk(
  accounts: anchor.ProgramAccount[]
): Promise<anchor.ProgramAccount[]> {
  let accountsAtRisk: anchor.ProgramAccount[] = [];
  await Promise.all(
    accounts.map((account: anchor.ProgramAccount) => {
      if (account.account == null) {
        return;
      }
      let marginAccountState =
        Exchange.riskCalculator.getCrossMarginAccountState(
          account.account as programTypes.CrossMarginAccount
        );
      if (marginAccountState.availableBalanceInitial >= 0) {
        return;
      }
      console.log(
        `[ACCOUNT_AT_RISK] [ACCOUNT]: ${account.publicKey.toString()} [BALANCE]: ${
          marginAccountState.balance
        } [INITIAL] ${marginAccountState.initialMarginTotal} [MAINTENANCE]: ${
          marginAccountState.maintenanceMarginTotal
        } [UNREALIZED PNL] ${
          marginAccountState.unrealizedPnlTotal
        } [AVAILABLE BALANCE INITIAL] ${
          marginAccountState.availableBalanceInitial
        } [AVAILABLE BALANCE MAINTENANCE] ${
          marginAccountState.availableBalanceMaintenance
        }`
      );
      accountsAtRisk.push(account);
    })
  );
  return accountsAtRisk;
}

export async function findLiquidatableAccounts(
  accounts: anchor.ProgramAccount[]
): Promise<anchor.ProgramAccount[]> {
  let liquidatableAccounts: anchor.ProgramAccount[] = [];
  await Promise.all(
    accounts.map((account: anchor.ProgramAccount) => {
      if (account.account == null) {
        return;
      }

      let marginAccountState =
        Exchange.riskCalculator.getCrossMarginAccountState(
          account.account as programTypes.CrossMarginAccount
        );

      // We assume the accounts passed in have had their open orders cancelled.
      // Therefore can just use availableBalanceLiquidation which assumes 0 open orders.
      if (marginAccountState.availableBalanceMaintenance >= 0) {
        return;
      }
      console.log(
        `[LIQUIDATABLE ACCOUNT] [ACCOUNT] ${account.publicKey.toString()} [BALANCE] ${
          marginAccountState.balance
        } [AVAILABLE BALANCE MAINTENANCE] ${
          marginAccountState.availableBalanceMaintenance
        }`
      );
      liquidatableAccounts.push(account);
    })
  );
  return liquidatableAccounts;
}

export async function cancelAllActiveOrders(
  client: CrossClient,
  accountsAtRisk: anchor.ProgramAccount[]
) {
  await Promise.all(
    accountsAtRisk.map(async (programAccount) => {
      let marginAccount =
        programAccount.account as programTypes.CrossMarginAccount;
      // If they have any active orders, we can cancel.

      for (var asset of Exchange.assets) {
        let position =
          marginAccount.productLedgers[assets.assetToIndex(asset)].orderState;
        if (
          position.openingOrders[0].toNumber() != 0 ||
          position.openingOrders[1].toNumber() != 0 ||
          position.closingOrders.toNumber() != 0
        ) {
          console.log("[FORCE_CANCEL] " + programAccount.publicKey.toString());
          try {
            await client.forceCancelOrders(asset, programAccount.publicKey);
          } catch (e) {
            console.log(e);
          }
        }
      }
    })
  );
}

// Naively liquidates all accounts up to initial margin requirement limits.
export async function liquidateAccounts(
  client: CrossClient,
  accounts: anchor.ProgramAccount[]
) {
  for (var i = 0; i < accounts.length; i++) {
    const liquidateeMarginAccount = accounts[i]
      .account as programTypes.CrossMarginAccount;
    const liquidateeKey = accounts[i].publicKey;

    // If an account is underwater then we can liquidate any position
    for (var asset of Exchange.assets) {
      const position =
        liquidateeMarginAccount.productLedgers[
          assets.assetToIndex(asset)
        ].position.size.toNumber();

      if (position == 0) {
        continue;
      }

      // Get latest state for your margin account.
      await client.updateState();
      let clientState = Exchange.riskCalculator.getCrossMarginAccountState(
        client.account
      );

      let marginConstrainedSize = calculateMaxLiquidationNativeSize(
        asset,
        clientState.availableBalanceInitial,
        position > 0
      );

      const size = Math.min(marginConstrainedSize, Math.abs(position));
      const side = position > 0 ? "Bid" : "Ask";

      console.log(
        "[LIQUIDATE] " +
          liquidateeKey.toString() +
          " [SIDE] " +
          side +
          " [AMOUNT] " +
          size +
          " [MAX CAPACITY WITH MARGIN] " +
          marginConstrainedSize +
          " [AVAILABLE SIZE] " +
          Math.abs(position)
      );
      try {
        let txId = await client.liquidate(asset, liquidateeKey, size);
        console.log(`TX ID: ${txId}`);
      } catch (e) {
        console.log(e);
      }
    }
  }
}

/**
 * @param availableBalance  Available balance for the liquidator.
 * @param marketIndex       The market index of the position to be liquidated.
 * @param long              Whether the liquidatee is long or short.
 * @returns native lot size given liquidator available balance.
 */
export function calculateMaxLiquidationNativeSize(
  asset: constants.Asset,
  availableMargin: number,
  long: boolean
): number {
  // Initial margin requirement per contract in decimals.
  // We use this so you are not at margin requirement limits after liquidation.

  let marginRequirements =
    Exchange.riskCalculator.getPerpMarginRequirements(asset);

  let initialMarginRequirement = long
    ? marginRequirements.initialLong
    : marginRequirements.initialShort;

  console.log(asset, availableMargin, initialMarginRequirement);

  return parseInt(
    (
      (availableMargin / initialMarginRequirement) *
      Math.pow(10, constants.POSITION_PRECISION)
    ).toFixed(0)
  );
}
Code (utils.ts)
import { PublicKey } from "@solana/web3.js";
import fetch from "node-fetch";

export async function airdropUsdc(publicKey: PublicKey, amount: number) {
  const body = {
    key: publicKey.toString(),
    amount,
  };
  await fetch(`${process.env.server_url}/faucet/USDC`, {
    method: "post",
    body: JSON.stringify(body),
    headers: { "Content-Type": "application/json" },
  });
}

Last updated

#954: TGE

Change request updated