Automating Liquidations — A Deep Dive into the Compound V3 Architecture Liquidator 💸
January 27th, 2025

Introduction

With lending protocols like Compound V3 and its derivatives, such as Swaylend (a top lending protocol on Fuel Network), a liquidation happens when a borrower's collateral value drops below a certain threshold compared to their borrowed amount. In such cases, the user must be liquidated. Anyone can call the liquidation method; it is not limited to the governor. Liquidations are necessary to maintain a healthy market. They ensure that all debts are repaid appropriately and help prevent losses for both the protocol and its users.

With that purpose, instead of liquidating users manually, automated bots come in handy.

Steps

We roughly divide the liquidation process into three main steps:

  1. Get and liquidate insolvent users

  2. Buy off protocol's collateral

  3. Sell collateral

The steps are explained in How to below.

Liquidator's flowchart
Liquidator's flowchart

Relevant contract methods

Let’s take a look at Swaylend’s and Compound’s contracts, which are written in Sway and Solidity, respectively. One runs on FuelVM, while the other operates on the traditional EVM. The most relevant methods are listed below.

  1. Absorbing users

    // Sway
    fn absorb(accounts: Vec<Identity>, price_data_update: PriceDataUpdate);
    
    // Solidity
    function absorb(address absorber, address[] calldata accounts) override external;
    

    This method liquidates users passed as the accounts parameter when called.

  2. Getting collateral quotes

    // Sway
    fn collateral_value_to_sell(asset_id: AssetId, collateral_amount: u64) -> u64;
    
    // Solidity
    function absorb(address absorber, address[] calldata accounts) override external;
    

    Calculates the amount of base asset to send along with the buy_collateral call to buy the desired amount of the collateral asset.

  3. Buying collateral

    // Sway
    fn buy_collateral(asset_id: AssetId, min_amount: u64, recipient: Identity);
    
    // Solidity
    function buyCollateral(address asset, uint minAmount, uint baseAmount, address recipient) override external nonReentrant;
    

    Buys collateral for the amount of base asset passed along with the function and receives, at minimum, the passed min_amount.

How to

Liquidate users

1. Getting and updating configurations

First up, we need to get the latest market configurations to ensure we are working with the latest data.

marketConfig := marketContract.getMarketConfiguration()
collateralConfig := marketContract.getCollateralConfiguration()
marketBasics := marketContract.getMarketBasics()

liquidator.updateConfigs(marketConfig, collateralConfig, marketBasics)

2. Finding liquidatable users

Next, we first need to get users with negative principals and calculate the insolvent ones, ready to go underwater.

usersWithNegativePrincipal := indexer.fetchUsersWithNegativePrincipals()
insolventUsers := []

for each user in usersWithNegativePrincipal
  if isInsolvent(user)
    append user to insolventUsers

Under the hood, we can use indexers like The Graph and Envio to index the market contract and to get users with negative principals — users currently borrowing from our lending protocol. All calculations are then made offchain by using an adapted version of market contract's function is_liquidatable — be found here. Alternatively, instead of calculating users' insolvency off-chain, we can call the is_liquidatable method for each user. However, this approach is less efficient.

3. Absorbing users

The users we get from step 3 are then passed into the absorb call to liquidate underwater accounts.

marketContract.absorb(insolventUsers)

Note

The fees are not covered by the liquidator; they are paid by the protocol.

Buy collateral assets

1. Updating collateral reserves

First, we need to update the market’s collateral reserves in the liquidator's internal storage.

collaterals := marketContract.getCollateralConfigurations()

for each collateral in collaterals
  config := marketContract.getCollateralReservers(collateral.id)
  updateCollateralReserves(collateral.id, config)

2. Updating collateral values

Next, we update the actual value of the collateral reserves by iterating through the previously updated reserves, fetching the price from oracle for each asset, and storing it in liquidator’s internal storage.

collaterals := getCollateralConfigurationsFromInternalStorage()
for each collateral in collaterals
  price := oracle.getPrice(collateral.id)
  updateCollateralPrices(collateral.id, price)

3. Building and executing buy transactions

With the collateral amount and value now stored in internal storage, we can begin buying the collateral. In this step, we can set any limitations we want, such as buying only specific collateral assets or avoiding buying if the value does not exceed a specific threshold. For simplicity, we’ll leave those configurations and algorithms for building transactions to optimize your return to you.

collaterals := getCollateralsToBuy()
for each collateral in collaterals
  amountToBuy := calculateAmountToBuy(collateral)
  marketContract.buyCollateral(collateral.id, amountToBuy)

Note

An important step in building transactions is calculating the price of each collateral amount we buy from the protocol. These collaterals are sold at a discount to incentivize users to repay the debts of liquidated users in base assets. The amount of collateral to sell to a user is based on the base asset the user sent with the transaction. This amount is either fetched from the contract or calculated off-chain. For example, you can find out how Swaylend performs this calculation here.

Sell collateral assets for profit

Now that we hold the collateral value in the Liquidator’s wallet, we can decide whether to sell or hold it. We leave this decision up to you. There are many options for how to approach this step. A simple example would be to check which DEX offers the best price, as shown in the pseudocode below.

collaterals := getCollateralConfigurationsFromInternalStorage()
baseAssetId := marketConfig.baseAssetId

for each collateral in collaterals
  uniswapPrice := uniswapContract.getPrice(collateral.id)
  balancerPrice := balancerContract.getPrice(collateral.id)
  balance := wallet.getBalance(collateral.id)
  dexContract := null

  if uniswapPrice > balancerPrice
    dexContract := uniswapContract
  else
    dexContract := balancerContract

  poolId := dexContract.getPoolId(baseAssetId, collateral.id)
  deadline := now() + 10000

  dexContract.swap(
    collateral.amountToBuy,
    collateral.id,
    poolId,
    deadline
  )

Future improvements

Future improvements could focus on optimizing transaction-building efficiency and the liquidator's performance. This includes optimizing the processes for building buy and sell transactions, storing transaction history, and the liquidator's overall profit realized. Additionally, implementing parallel execution of all main steps can significantly boost the bot's performance when other market makers arrive, and checking more DEXes can help identify the best platforms for selling the acquired collateral to maximize profit.

Conclusion

A liquidator automates identifying and liquidating insolvent users, purchasing discounted collateral waiting in the protocol, and immediately selling it for profit. The bot aims to run at optimal performance and profitability by utilizing advanced transaction-building strategies.


By Lutra Labs

Website | Discord | LinkedIn | Twitter/X | GitHub | E-Mail

Written by Urban Vidovič, COO

Subscribe to Lutra Labs
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from Lutra Labs

Skeleton

Skeleton

Skeleton