SDK Guide: Performing a Liquidation

The @townsq/mm-sdk is a lightweight JavaScript/TypeScript SDK designed for interacting with the liquidation functionality of the TownSquare Lending Protocol.

It enables developers to:

  • Identify under-collateralized (liquidatable) loans.

  • Compute loan health and repayment requirements.

  • Trigger on-chain liquidation transactions.

Note: This SDK currently focuses only on liquidation. Support for borrowing, supplying, and other protocol actions will be added in future releases.

Installation

npm install @townsq/mm-sdk

or

yarn add @townsq/mm-sdk

Understanding TownSquare Liquidation

Before diving into code, it’s important to understand how liquidation works in TownSquare.

1. What Makes a Loan Liquidatable?

A loan becomes liquidatable when its borrowed value (with interest) is greater than or equal to its collateral value.

Formally checked as:

dn.gte(
  violatorLoanInfo.totalEffectiveBorrowBalanceValue,
  violatorLoanInfo.totalEffectiveCollateralBalanceValue,
);

If the above condition is true → the loan is unsafe → liquidators may step in.

2. Liquidator Requirements

To liquidate, a liquidator must already have a loan opened within the protocol.

  • This loan provides the funds for repayment.

  • The liquidator’s loan must hold enough collateral of the repayment token.

  • If insufficient, the liquidation transaction will fail.

3. Loan Types & Efficiencies

TownSquare supports three loan types, each with specific efficiency rules:

  • General Loans → Accept all supported tokens.

  • Stable Efficiency → Restricted to stable coins related assets

  • MON Efficiency Loans → You can only borrow or supply MON-related assets with this loan

When computing loan health or preparing a liquidation, always check the loanTypeId to apply the correct efficiency rules.

Reading Loans

When a liquidator service starts, it is recommended to read all existing loans into memory (or preferably into a persistent database).

  • This gives a snapshot of the system’s state.

  • From here, you will only need to process incremental updates from events, rather than repeatedly querying the entire chain.

  • Without this snapshot, you may miss loans that are already unhealthy at startup.

Updating Loan State with Events

The LoanManager contract is the central source of truth for loan changes. It emits events for every important action that affects loan health. Liquidators must listen to these events and update their internal state accordingly.

Events to track:

  • Deposit

    {
      loanId: string;
      poolId: number;
      amount: bigint;
      fAmount: bigint;
    }

    Indicates collateral was added to the loan.

  • Borrow

    {
      loanId: string;
      poolId: number;
      amount: bigint;
      isStableBorrow: boolean;
      stableInterestRate: bigint;
    }

    Indicates debt was added to the loan. If isStableBorrow is true, the borrow position is fixed-rate.

  • Withdraw

    {
      loanId: string;
      poolId: number;
      amount: bigint;
      fAmount: bigint;
    }

    Indicates collateral was removed from the loan.

  • Repay

    {
      loanId: string;
      poolId: number;
      principalPaid: bigint;
      interestPaid: bigint;
      excessPaid: bigint;
    }

    Indicates loan debt was reduced. This directly improves loan health.

  • Liquidate

    {
      violatorLoanId: string;
      liquidatorLoanId: string;
      colPoolId: number;
      borPoolId: number;
      repayBorrowBalance: bigint;
      liquidatorCollateralFAmount: bigint;
      reserveCollateralFAmount: bigint;
    }

    Indicates a liquidation occurred. The violator’s loan was partially or fully closed, and the liquidator’s loan received seized collateral.

When a liquidator service starts, it is recommended to read all existing loans into memory (or preferably into a persistent database).

  • This gives a snapshot of the system’s state.

  • From here, you will only need to process incremental updates from events, rather than repeatedly querying the entire chain.

  • Without this snapshot, you may miss loans that are already unhealthy at startup.

When an event is received (e.g., Borrow, Deposit, Repay, Withdraw, Liquidate), liquidators should recompute the full loan state before persisting it in their database. This ensures loan health metrics are always accurate.

Example (computing loanInfo after receiving a loanId from an event):


const poolsInfo: Partial<Record<TSTokenId, PoolInfo>> = {};
await Promise.all(
  Object.values(TESTNET_TS_TOKEN_ID).map(async (tsTokenId) => {
    const poolInfo = await TSPool.read.poolInfo(tsTokenId);
    poolsInfo[tsTokenId] = poolInfo;
  }),
);

const loanId = event.args.violatorLoanId as LoanId;

const loanTypeMap = Object.entries(TESTNET_LOAN_TYPE_ID).reduce(
  (acc, [key, value]) => {
    acc[key as any] = value;
    return acc;
  },
  {} as Record<number, LoanTypeId>,
);

try {
  const oraclePrices = await TSOracle.read.oraclePrices();
  const userLoans = await TSLoan.read.userLoans([loanId]);
  const loan = userLoans?.get(loanId);

  if (!loan) return;

  const loanType = Object.values(loanTypeMap).find((type) => type === loan.loanTypeId);

  if (!loanType || loanType === TESTNET_LOAN_TYPE_ID.DEPOSIT) return;

  const loanTypeInfo = {
    [loanType]: await TSLoan.read.loanTypeInfo(loanType),
  };

  const loanInfo = TSLoan.util.userLoansInfo(
    userLoans,
    poolsInfo,
    loanTypeInfo,
    oraclePrices,
  )[loanId];

  if (!loanInfo) return;

  // At this point, `loanInfo` contains all computed fields needed
  // to evaluate liquidation eligibility and can be stored in your DB.
} catch (e) {
  console.error("Failed to compute loan info", e);
}

Updating Loan State with Events

The LoanManager contract is the central source of truth for loan changes. It emits events for every important action that affects loan health. Liquidators must listen to these events and update their internal state accordingly.

Step-by-Step: Liquidate a Loan

1. Initialize the SDK and set network

import { TSCore, NetworkType, TS_CHAIN_ID } from 'townsq-mm-sdk';

TSCore.init({ network: NetworkType.TESTNET, provider: { evm: {} } });
TSCore.setNetwork(NetworkType.TESTNET);
  1. Set up your signer

import { createWalletClient, http } from 'viem';
import { CHAIN_VIEM } from '@townsq/mm-sdk';

const signer = createWalletClient({
  chain: CHAIN_VIEM[TS_CHAIN_ID.MONAD_TESTNET],
  transport: http(), // You can pass your rpc (optional)
});

TSCore.setTSsSigner({
  signer,
  tsChainId: TS_CHAIN_ID.MONAD_TESTNET,
});
  1. Resolve Account ID from Address

import { TSAccount } from '@townsq/mm-sdk';

const accountId = await TSAccount.read.getAccountIdOfAddressOnChain(
  '0xYourEvmAddressHere'
);

4. Check if the Loan is Liquidatable

import { TSLoan, TSPool, TSOracle, TESTNET_TS_TOKEN_ID, TESTNET_LOAN_TYPE_ID } from 'townsq-mm-sdk';
import * as dn from 'dnum';

const violatorLoanId = '0x...'; // loan ID to liquidate

// Fetch oracle price and violator loan
const [oraclePrices, userLoans] = await Promise.all([
  TSOracle.read.oraclePrices(),
  TSLoan.read.userLoans([violatorLoanId]),
]);

const poolsInfo = await Promise.all(
  Object.values(TESTNET_TS_TOKEN_ID).map(async (tokenId) => ({
    tokenId,
    pool: await TSPool.read.poolInfo(tokenId),
  }))
);

// Prepare loan info
const userGeneralLoansInfo = TSLoan.util.userLoansInfo(
  userLoans,
  Object.fromEntries(poolsInfo.map(({ tokenId, pool }) => [tokenId, pool])),
  {
    [TESTNET_LOAN_TYPE_ID.GENERAL]: await TSLoan.read.loanTypeInfo(TESTNET_LOAN_TYPE_ID.GENERAL),
  },
  oraclePrices
);

const loanInfo = userGeneralLoansInfo[violatorLoanId];

if (dn.lt(
  loanInfo.totalEffectiveBorrowBalanceValue,
  loanInfo.totalEffectiveCollateralBalanceValue
)) {
  console.log("Loan is healthy — not eligible for liquidation.");
  return;
}

5. Prepare Liquidation Transaction

Important: The liquidator’s loan must have sufficient collateral If the liquidator's loan doesn’t hold sufficient collateral to cover for the amount the liquidator wants to pay, the transaction will fail.

import { TSLoan, convertToGenericAddress, ChainType, parseUnits } from '@townsq/mm-sdk'

const prepareLiquidationCall = await TSLoan.prepare.liquidate(
  accountId,
  '0xYourLiquidatorLoanId',
  violatorLoanId,
  TESTNET_TS_TOKEN_ID.USDC,  // Repay in USDC
  TESTNET_TS_TOKEN_ID.WETH,   // Receive WETH as collateral
  parseUnits('100', 18),      // Amount to repay
  parseUnits('0', 18),        // Minimum collateral to seize
  convertToGenericAddress('0xYourAddressAssociatedToYourAccount', ChainType.EVM)
);

6. Execute the Liquidation

const result = await TSLoan.write.liquidate(accountId, prepareLiquidationCall);
console.log(`Transaction hash: ${result}`);

Last updated