Liquidation Bot on ZKLend (Starknet) Part 2 — Are you liquidatable?

Kristian
14 min readJul 19, 2024

--

Myself and other key StarkNet players and MEV enthusiasts have created a Telegram channel to discuss MEV, educate, and increase awareness of MEV on StarkNet. Feel free to join, participate, and ask questions. We are here to point you in the right direction: Join our Telegram Channel.

Introduction

In the first part of our guide, we explored the fundamental concepts behind zkLend on Starknet and the motivations for liquidations within this ecosystem. We discussed how zkLend provides a decentralized platform for borrowing and lending assets with enhanced privacy and scalability through StarkNet’s zero-knowledge rollup technology. The core focus was on understanding the liquidation process, the types of assets involved, and the critical role of maintaining collateral to secure loans.

For this second part, we assume you are familiar with:

  • The StarkNet ecosystem
  • How lending/borrowing protocols work
  • Price oracles
  • The previous coding tutorials of part 1
  • Basic knowledge of JavaScript and blockchain technologies

Our focus now shifts to developing a liquidation bot, encompassing loan monitoring, identifying under-collateralized positions, and executing liquidations efficiently. I aim to create a solid foundation for an effective bot that ensures the stability and health of the zkLend platform.

Cairo 1.0 and smart contracts on Starknet

In this article, we’ll explore the smart contracts behind zkLend to understand how to liquidate unhealthy users. zkLend smart contracts are written in Cairo, the first Turing-complete language for creating provable programs for general computation. Cairo allows you to write these programs without needing deep knowledge of zero-knowledge (ZK) concepts.

While this guide doesn’t require prior knowledge of Cairo, I’ll provide easy-to-follow coding instructions. However, learning Cairo is essential to be competitive in this ecosystem. Here are some valuable resources to get started:

Understanding ZKLend implementation

To identify accounts susceptible to liquidation, we target those with a health factor below 1. This can be achieved either by performing calculations ourselves or querying the zkLend main contract directly. Fortunately, zkLend, like many other DeFi lending/borrowing protocols, offers a view function to check if a user is undercollateralized.

Let’s explore these functions to streamline the process of identifying at-risk accounts:

fn is_user_undercollateralized(
self: @ContractState, user: ContractAddress, apply_borrow_factor: bool
) -> bool {
let user_not_undercollateralized = internal::is_not_undercollateralized(
self, user, apply_borrow_factor
);

!user_not_undercollateralized
}
fn is_not_undercollateralized(
self: @ContractState, user: ContractAddress, apply_borrow_factor: bool
) -> bool {
// Skips expensive collateralization check if user has no debt at all
let has_debt = user_has_debt(self, user);
if !has_debt {
return true;
}

let UserCollateralData { collateral_value, collateral_required } =
calculate_user_collateral_data(
self, user, apply_borrow_factor
);
Into::<_, u256>::into(collateral_required) <= Into::<_, u256>::into(collateral_value)
}

fn calculate_user_collateral_data(
self: @ContractState, user: ContractAddress, apply_borrow_factor: bool
) -> UserCollateralData {
let reserve_cnt = self.reserve_count.read();
if reserve_cnt.is_zero() {
UserCollateralData { collateral_value: 0, collateral_required: 0 }
} else {
let flags: u256 = self.user_flags.read(user).into();

let UserCollateralData { collateral_value, collateral_required } =
calculate_user_collateral_data_loop(
self, user, apply_borrow_factor, flags, reserve_cnt, 0
);

UserCollateralData { collateral_value, collateral_required }
}
}

The is_user_undercollateralized function is vital for ensuring users maintain sufficient collateralization to avoid liquidation. This function will be called to determine if a user has an unhealthy position. To gain a deeper understanding, let's look at some of the internal functions it executes.

Here’s how the functions work:

  1. Debt Check: The function first checks if the user has any debt by calling user_has_debt(self, user). If the user has no debt, the function returns true, indicating that the user is not undercollateralized.
  2. Collateral Calculation: If the user has debt, the function proceeds to calculate the user’s collateral value and the required collateral. This is done using the calculate_user_collateral_data function, which takes into account whether to apply the borrow factor.
  3. Collateral Comparison: Finally, the function compares the required collateral with the actual collateral. It converts both values into a common unit (u256) and checks if the actual collateral is equal to or greater than the required collateral. If it is, the function returns true, indicating the user is not undercollateralized. If not, the function returns false, signifying the user is at risk of liquidation.

To encapsulate this process into a concise formula, zkLend calculates the Health Factor as follows:

𝐻𝑒𝑎𝑙𝑡ℎ𝐹𝑎𝑐𝑡𝑜𝑟 = 𝑅𝐴𝐶𝑜𝑙𝑙𝑎𝑡𝑒𝑟𝑎𝑙 / 𝐿𝑖𝑎𝑏𝑖𝑙𝑖𝑡𝑖𝑒𝑠

Where:

  • Risk-adjusted Collateral: The aggregate market value of a user’s collateral, adjusted by each asset’s Collateral Factor.
  • Liabilities: The total market value of a user’s outstanding borrowings and accrued interest.

Discovery of opportunities

With a solid grasp of zkLend’s method for evaluating undercollateralized users, we can now proceed to call the is_user_undercollateralized function to identify at-risk accounts. Assuming you are familiar with setting up a provider to query the blockchain and installing Starknet.js, refer to Part 1 if you need a refresher.

Now, let’s instantiate the zkLend contract to interact with it. First, ensure you have the necessary imports and setup:

import { RpcProvider,Contract} from 'starknet';

export async function instantiateZKLendContract(provider) {
// ZKLend Contract address
const zkLendContratcAddress = '0x04c0a5193d58f74fbace4b74dcf65481e734ed1714121bdc571da345540efa05'
// read abi of ZKLend contract
const { abi: zkLendABI } = await provider.getClassAt(zkLendContratcAddress);
if (zkLendABI === undefined) {
throw new Error('no abi.');
}

const zkLendContract = new Contract(zkLendABI, zkLendContratcAddress, provider);

console.log("ZKLend contract instantiated");
return zkLendContract;
}

Assuming we have saved the active borrowers into a JSON file (part 1), we will now loop through each of them to check for undercollateralized positions using the is_user_undercollateralized function.

// read the onlyActiveBorrowers.json file to get the active borrowers
const borrowers = JSON.parse(fs.readFileSync('/starknet_zklend_liquidator_bot/onlyActiveBorrowers.json', 'utf8'));

// loop through the active borrowers and using the ZKLend contract, findout if thery're liquidatable
async function findLiquidatableBorrowers(borrowers) {
console.log("Checking liquidatable borrowers...")
for (const borrower of borrowers) {

// execute the call
const isBorrowerLiquidatable = await zkLendContract.is_user_undercollateralized(borrower, true); // true: apply borrow factor
// log the borrower address and the result
console.log(borrower, isBorrowerLiquidatable);

}

In my case, it only took a few seconds to identify potential liquidatable users. This quick feedback helps in efficiently targeting accounts that need to be addressed.

While querying accounts one by one is a good starting point, it’s not the most efficient. Think of it like a race; the bot that finds the undercollateralized user first wins. Consider what triggers a liquidation, such as a sudden price change, and try to get that information as quickly as possible. Although a detailed strategy is beyond this article’s scope, we will explore more sophisticated methods in future posts.

Accumulated debt

Now, every time we find an undercollateralized user, we need to determine their accumulated debt on the platform. In lending protocols, users supply assets in exchange for interest, while borrowers use these supplied assets as collateral to borrow other assets.

When calculating a borrower’s health, zkLend considers the accumulated value of the debt assets compared to the collateral assets. This helps in assessing whether a user’s position is at risk and eligible for liquidation.

Since the accumulated debt is ultimately converted to USD, our bot must first identify the debt per token for each user. Then, it converts these amounts into a common currency (USD) before aggregating them to assess the total debt. Here is a step-by-step to illustrate the process:

  1. Retrieve User’s scaled-up Debt:
  • Call the get_user_debt_for_token function to get the scaled-up debt of the user for each token they have borrowed. we do not know what token the user has borrowed, so we will have to query all of the available assets in the platform

2. Get Asset Price from Oracle:

  • Use the oracle to get the current price of each token. This is typically done by calling the get_price function of the oracle contract.

3. Convert Debt to Reserve Currency:

  • Multiply the scaled-up debt value by the asset price to get the debt value in terms of the reserve currency.

4. Sum Up Total Debt:

  • Sum up the debt values of all tokens in terms of the reserve currency to get the total accumulated debt.

Alright, let’s get into coding. Before we proceed with calculating the debt amount per token borrowed, we need to gather essential information known as reserves data. This data includes key details about every asset accepted on the platform, such as the liquidation bonus, whether an asset can be used as collateral, the collateral and borrow factors, flashloan fees, and much more. This information will be crucial for our calculations down the line:

 {
"enabled": true,
"decimals": "8",
"z_token_address": "0x2b9ea3acdb23da566cee8e8beae3125a1458e720dea68c4a9a7a2d8eb5bbb4a",
"interest_rate_model": "2855342199636818593874014612403855601512497132565197476913783759371225026446",
"collateral_factor": "700000000000000000000000000",
"borrow_factor": "910000000000000000000000000",
"reserve_factor": "150000000000000000000000000",
"last_update_timestamp": "1721226605",
"lending_accumulator": "1007387004466259724064781228",
"debt_accumulator": "1028097730566327867783567185",
"current_lending_rate": "3741953951432031748649301",
"current_borrowing_rate": "20074353197751228107857314",
"raw_total_debt": "582858725",
"flash_loan_fee": "900000000000000000000000",
"liquidation_bonus": "150000000000000000000000000",
"debt_limit": "600000000",
"reserve_address": "0x3fe2b97c1fd336e750087d68b9b867997fd64a2661ff3ca5a7c771641e8e7ac" // I have added this one myself
}

Execute the following script to retrieve the reserve data for all available assets. This script gathers critical information about each asset. Be sure to read the comments within the script to fully understand its functionality and how it collects the necessary reserve data.

// initialise the provider and contract, you should know that already
const provider = initialiseProvider();
const zkLendContract = await instantiateZKLendContract(provider);

const zkLendContratcAddress = zkLendContract.address;


// we first need to fo through all of the new reserves events and get the assets accepted in the platform
// then we need to get the reserves data for each of the assets

async function getReservesAddressesByEvents() {
// get the last block number
let block = await provider.getBlock('latest');

// key filter
const keyFilter = [[num.toHex(hash.starknetKeccak('NewReserve'))]];

// define the continuation token and the array to store all the events
let continuationToken = null;
const uniqueReservesAddresses = new Set();
const chunkSize = 1000; // Number of events to fetch in each request

// Get the events by calling the getEvents method of the provider
while (true) {
const response = await provider.getEvents({
from_block: { block_number: 0 },
to_block: { block_number: block.block_number },
address: zkLendContratcAddress,
keys: keyFilter, // Filter by Borrowing event key
chunk_size: chunkSize,
continuation_token: continuationToken
});

response.events.forEach(event => {
uniqueReservesAddresses.add(event.data[0]);
console.log(event.data[0]); // log the address
});

if (response.continuation_token) {
continuationToken = response.continuation_token;
} else {
break; // Exit the loop when there are no more events
}
}

// return the unique addresses array
return Array.from(uniqueReservesAddresses);
}

// with the reserves assets addresses, we can now get the reserves data, let's loop through them and query the contract
async function getReservesData(reservesAddresses) {
const reservesData = [];
for (const reserveAddress of reservesAddresses) {
const reserveData = await zkLendContract.get_reserve_data(reserveAddress);
// append the reserve address to the reserve data
reserveData.reserve_address = reserveAddress;
reservesData.push(reserveData);
}
return reservesData;
}

// serialise the bigInt data to string before saving it to a JSON file
// Custom replacer function to handle BigInt serialization
function replacer(key, value) {
if (typeof value === 'bigint') {
if (key === 'z_token_address') {
return '0x' + value.toString(16);
}
return value.toString();
}
return value;
}

async function main() {
const reservesAddresses = await getReservesAddressesByEvents();
console.log(reservesAddresses);
const reservesData = await getReservesData(reservesAddresses);
console.log(reservesData);
// save the data to a JSON file
fs.writeFileSync('/starknet_zklend_liquidator_bot/reservesData.json', JSON.stringify(reservesData, replacer, 2));
}

This script performs a series of tasks to gather and store reserve data for zkLend:

  1. Event Collection:
  • It initializes the provider and zkLend contract.
  • Retrieves “NewReserve” events from the blockchain to get addresses of newly accepted assets.

2. Contract Query:

  • Uses the get_reserve_data function to query the zkLend contract for detailed reserve data for each asset.

3. Data Serialization:

  • Adjusts data serialization to handle BigInt values correctly.

4. Saving Data:

  • Saves the collected reserve data as a JSON file for future use.

Now that we have gathered the essential reserve data for our bot, let’s shift our focus to developing the logic needed to determine the accumulated debt of any liquidatable user on the platform. This step is crucial for calculating the Health Factor ourselves.

The cornerstone of this function involves calling the get_user_debt_for_token function of the zkLend smart contract for all assets available on the platform. This will enable us to accurately assess the user's debt across different tokens. See smart contract function below:

fn get_user_debt_for_token(
self: @ContractState, user: ContractAddress, token: ContractAddress
) -> felt252 {
let debt_accumulator = get_debt_accumulator(self, token);
let raw_debt = self.raw_user_debts.read((user, token));

let scaled_up_debt = safe_decimal_math::mul(raw_debt, debt_accumulator);
scaled_up_debt
}

As you can see the function returns the scaled-up debt, by multiplying the raw debt and the debt accumulator. The debt accumulator is a factor used to adjust the raw debt of a user based on the accrued interest over time.

Now, let’s proceed to read the RESERVEDATA file we saved earlier. We’ll declare the base_reserve_decimals (the decimal factor the oracle uses when returning the price in USD) and define the function getDebtAssetsAmounts which will return the accumulated debt of a user.

// read the reservesData.json file to get the reserves data we fetched before
const RESERVESDATA = JSON.parse(fs.readFileSync('/starknet_zklend_liquidator_bot/reservesData.json', 'utf8'));
const BASE_RESERVE_DECIMALS = 10**8; // this is the decimals the oracle uses for the price output

async function getDebtAssetsAmounts(reservesData, borrower) {
console.log("// --------- ------------ ---------- Getting debt assets amounts for borrower: ", borrower); // remove later
const debtAssetsDataAccumulated = []; // this will hold the final debt price of each debt asset to be aggregated later
for (const reserveData of reservesData) {
const debtAssetAmount = await zkLendContract.get_user_debt_for_token(borrower, reserveData.reserve_address);
if (debtAssetAmount < 1){
continue;
}

console.log("reserve address: ", reserveData.reserve_address); // remove later
console.log("debt asset amount: ", debtAssetAmount); // remove later

// we need to find out the total debt amount the reserve currency of the platform (USDC)
// Let's call the oracle to get the price of the reserve currency and calculate the total debt amount
const reservePrice = await oracleContract.get_price(reserveData.reserve_address);
console.log("reserve price: ", reservePrice.price); // remove later

// now I have the asset price, I can calculate the total debt amount in USD
const totalDebtAmount = debtAssetAmount * reservePrice.price; // this gives me the scaled up debt value.
console.log("total debt amount: ", totalDebtAmount); // remove later
// oracle price output has 8 decimals, and the debtAssetAmount might have a different decimal factor
// find out the decimals of the debt asset and conver it to a BigInt
const debtAssetDecimals = BigInt(reserveData.decimals);
console.log("debt asset decimals: ", debtAssetDecimals); // remove later

// adjust the total debt amount to 8 decimals
const totalDebtAmountAdjusted8Decimals = totalDebtAmount / 10n**(debtAssetDecimals);
console.log("total debt amount price adjusted 8 decimals: ", totalDebtAmountAdjusted8Decimals); // remove later

// convert the totalDebtAmountAdjusted8Decimals to normal USD two decimal places without rounding
// then divide it by 10^8 to get the USD redeable form
const totalDebtAmountHumanForm = Number(totalDebtAmountAdjusted8Decimals) / BASE_RESERVE_DECIMALS;
console.log("total debt amount price adjusted USD (readable form): ", totalDebtAmountHumanForm); // remove later


// add the totalDebtAmountAdjusted8Decimals to the debtAssetsDataAccumulated array
debtAssetsDataAccumulated.push(totalDebtAmountAdjusted8Decimals);
console.log("------------------"); // remove later
}

// sum up all the values inside debtAssetsDataAccumulated to get the accumulated debt in USD
const accumulatedUSDTotalDebt8Decimals = debtAssetsDataAccumulated.reduce((acc, curr) => acc + curr, 0n);
console.log("accumulated TOTAL debt in USD: ", accumulatedUSDTotalDebt8Decimals); // remove later
// console log the accumulated debt in USD (readable form)
const accumulatedUSDTotalDebtHumanForm = Number(accumulatedUSDTotalDebt8Decimals) / BASE_RESERVE_DECIMALS;
console.log("accumulated TOTAL debt in USD (readable form): ", accumulatedUSDTotalDebtHumanForm); // remove later

return accumulatedUSDTotalDebt8Decimals;
}

After running this script, you might get something like this:

We only really care about the “accumulated TOTAL debt in USD” variable, the rest of the logs are necessary for us to understand the process and steps to get there.

Understanding zToken

Now that we have the accumulated debt, which is crucial for calculating the health factor, we need to determine the collateral supplied. To do this, we must understand the concept of zTokens.

zkLend tokenizes debts on the protocol with ERC20-compliant zTokens. When a user deposits an asset, they receive an equivalent amount of zTokens in their wallet. Each asset deposited is represented by its own zToken (e.g., zDAI, zETH, zWBTC). The value of each zToken corresponds to the current exchange rate relative to the underlying asset, reflecting both the principal and the interest accrued over time in the pool.

Accumulated collateral

zkLend does not provide a direct function to retrieve the collateral amount a user has on the platform. Instead, we need to determine the balance of the zTokens a user holds, which represent their supplied collateral. Once we have this balance, we can calculate the amount of collateral.

To proceed, we need to delve deeper into the smart contract’s handling of liquidations to understand the next steps. This will help us figure out how to accurately determine the accumulated collateral for a user.

Understanding theget_user_collateral_usd_value_for_token and calculate_user_collateral_data_loop function is essential for grasping how the platform calculates a user's accumulated collateral, which is later used to determine the health factor. These functions are called within the liquidate and assert_not_overcollateralized. By comprehending the checks performed by these methods, we can replicate the same behavior ourselves to accurately determine a user's total collateral amount. This understanding will be pivotal as we approach the liquidation phase.

note: The ultimate goal of the assert_not_overcollateralized function is to ensure the user remains undercollateralized after the liquidation call. This prevents unfair total liquidations by allowing only small, incremental liquidations until the health factor approaches one, but never allows the user to become overcollateralized. We will delve deeper into this concept as we get closer to the liquidation phase.

fn calculate_user_collateral_data_loop(
self: @ContractState,
user: ContractAddress,
apply_borrow_factor: bool,
flags: u256,
reserve_count: felt252,
reserve_index: felt252
) -> UserCollateralData {
if reserve_index == reserve_count {
return UserCollateralData { collateral_value: 0, collateral_required: 0 };
}

let UserCollateralData { collateral_value: collateral_value_of_rest,
collateral_required: collateral_required_of_rest } =
calculate_user_collateral_data_loop(
self, user, apply_borrow_factor, flags, reserve_count, reserve_index + 1
);

let reserve_slot: u256 = math::shl(1, reserve_index * 2).into();
let reserve_slot_and = BitAnd::bitand(flags, reserve_slot);

let reserve_token = self.reserve_tokens.read(reserve_index);

let current_collateral_required = get_collateral_usd_value_required_for_token(
self, user, reserve_token, apply_borrow_factor
);
let total_collateral_required = safe_math::add(
current_collateral_required, collateral_required_of_rest
);

if reserve_slot_and.is_zero() {
// Reserve not used as collateral
UserCollateralData {
collateral_value: collateral_value_of_rest,
collateral_required: total_collateral_required
}
} else {
let discounted_collateral_value = get_user_collateral_usd_value_for_token(
self, user, reserve_token
);
let total_collateral_value = safe_math::add(
discounted_collateral_value, collateral_value_of_rest
);

UserCollateralData {
collateral_value: total_collateral_value, collateral_required: total_collateral_required
}
}
}

fn get_user_collateral_usd_value_for_token(
self: @ContractState, user: ContractAddress, token: ContractAddress
) -> felt252 {
let reserve = self.reserves.read_for_get_user_collateral_usd_value_for_token(token);

if reserve.collateral_factor.is_zero() {
return 0;
}

// This value already reflects interests accured since last update
let collateral_balance = IZTokenDispatcher { contract_address: reserve.z_token_address }
.felt_balance_of(user);

// Fetches price from oracle
let oracle_addr = self.oracle.read();
let collateral_price = IPriceOracleDispatcher { contract_address: oracle_addr }
.get_price(token);

// `collateral_value` is represented in 8-decimal USD value
let collateral_value = safe_decimal_math::mul_decimals(
collateral_price, collateral_balance, reserve.decimals
);

// Discounts value by collateral factor
let discounted_collateral_value = safe_decimal_math::mul(
collateral_value, reserve.collateral_factor
);

discounted_collateral_value
}

let’s break these functions down: In the ZkLend protocol, the price of collateral assets is determined by querying the oracle with the actual collateral token address, not the zToken address. When the liquidation function is called, the protocol uses the oracle to get the price of both the debt token and the collateral token directly.

Here’s how the process works:

  1. Collateral Balance Calculation: The protocol calculates the collateral balance of a user by using the zToken balance, which represents the amount of collateral the user has supplied. The zToken balance is fetched via IZTokenDispatcher's felt_balance_of method.
  2. Oracle Price Fetching: The price of the collateral token is obtained from the oracle by calling IPriceOracleDispatcher's get_price method with the collateral token address.
  3. Collateral Value Calculation: The value of the collateral in USD is computed by multiplying the collateral token balance by its price from the oracle. This value is then adjusted for the token’s decimals and discounted by the collateral factor to get the risk-adjusted collateral value.
  4. Accumulated Collateral Value: If a user has multiple collateral tokens, the protocol accumulates the value of each collateral token. This is done in a loop within the calculate_user_collateral_data_loop function, which iteratively sums up the value of all collateral tokens.

In summary, the protocol ensures that each collateral token’s price is directly fetched from the oracle using the actual token address, and there seems to be a 1:1 correlation between the collateral tokens and their corresponding zTokens.

Conclusion

In this part, we learned how zkLend calculates the accumulated debt and collateral of users to determine their health factor. We explored the key functions involved, such as get_user_debt_for_token and calculate_user_collateral_data_loop, and understood how zTokens represent collateral. Additionally, we discussed how to fetch reserve data, determine accumulated debt in USD, and check for undercollateralized positions using the is_user_undercollateralized function.

In the next part, we will delve deeper into calculating the collateral ourselves, determining the health factor, and developing an algorithm to identify the optimal amount for liquidation. This will bring us closer to building a fully functional liquidation bot for zkLend. Stay tuned!

--

--

Kristian

Blockchain Development | Decentralised Finance | MEV