Liquidation Bot on ZKLend (Starknet) Part 1 — Introduction: who is borrowing?
Introduction
This article provides a comprehensive view of how searchers can leverage liquidations in ZKLend, one of the leading lending/borrowing platforms on StarkNet. While this bot may not be highly profitable (otherwise it wouldn’t be shared freely), it will give you the necessary tools and knowledge to build your own profitable liquidation bot.
I will split the entire guide into three different parts:
- Fundamentals of Liquidations and zkLend:
- This part will establish the basics of liquidations and explain zkLend’s specific mechanisms.
- It will include coding tutorials to fetch initial information from the blockchain.
2. Discovering Liquidation Opportunities:
- This section will cover how to identify liquidatable users in the protocol.
- It will provide strategies and code examples for finding these opportunities.
3. Executing Liquidations Using Flashbots:
- The final part will guide readers on executing liquidations using flashbots.
- It will include detailed instructions and code examples.
Liquidations are a key DeFi component for ensuring that the platform remains solvent and that lenders’ funds are protected. By liquidating undercollateralized positions, liquidators help maintain the stability of the ecosystem.
Participating in the liquidation ecosystem requires investment and funds to pay upfront. Fortunately, ZKLend also facilitates flash loans for quick access to capital within the same transaction. We will make use of this feature to execute our liquidations.
How Liquidations in zkLend Work
The liquidation process begins when a borrower’s Health Factor drops to 1 or below. This indicates that the value of their collateral is insufficient to cover their debt, creating potential risks for lenders and the zkLend protocol.
zkLend does not allow liquidators to fully liquidate a position, regardless of how low the health factor is. The current design allows liquidators to partially liquidate the undercollateralized position, ensuring the user remains undercollateralized after the liquidation. This means the health factor shouldn’t be exactly 1 or higher after the execution.
This system protects users from unfair liquidations by jut allowing liquidations to be small and progressive, hopefully giving the users more time to act and increase their collateral. This also presents a challenge to the MEV ecosystem, as the gains a liquidator can make are not as substantial as those in other protocol designs like Aave, where you can liquidate 50% or 100% depending on the borrower’s health factor.
Therefore, liquidators must be extremely sophisticated and optimized to profit from these small but consistent gains.
How is the Health Factor Calculated in zkLend?
The health factor is a critical metric in this project, and it is important to understand the math behind it. Based on their official documentation, the Health Factor in zkLend is a numerical representation of the safety of the borrower’s deposited assets in relation to borrowed assets. It is calculated by dividing a user’s Risk-adjusted Collateral by their Liabilities.
𝐻𝑒𝑎𝑙𝑡ℎ𝐹𝑎𝑐𝑡𝑜𝑟 = 𝑅𝐴𝐶𝑜𝑙𝑙𝑎𝑡𝑒𝑟𝑎𝑙 / 𝐿𝑖𝑎𝑏𝑖𝑙𝑖𝑡𝑖𝑒𝑠
Where:
- Risk-adjusted Collateral: Sum of the market value of a user’s collateral discounted by each asset’s Collateral Factor.
- Liabilities: Sum of the market value of a user’s outstanding borrowings and accrued interest.
How Do We Make the Cash?
When a liquidator repays a portion of the borrower’s debt, they receive a portion of the borrower’s collateral in return. This collateral is received at a discount, known as the liquidation bonus.
The difference between the market value of the collateral and the discounted price at which the liquidator acquires it represents the liquidator’s profit. For example, if a liquidator repays $100 of debt and receives $110 worth of collateral (based on market value), the $10 difference is their profit.
The liquidation bonus is a key factor to consider in the liquidation process. This bonus varies depending on the asset being liquidated, as different assets carry different levels of risk and other factors. Therefore, when liquidating a borrower’s position, it is crucial to target the collateral with the highest liquidation bonus.
Functionality Flow of the Bot
To understand the design if the bot, we need to ask the right quesitons. First, we need to identify which accounts have triggered the “borrow” event. This will give us all the historical data, all accounts that have ever borrowed in the protocol (yes, some other functions may also trigger the borrow event, such as flash loans, but we will cover that in due time).
Once we have those accounts identified, we need to determine if they have an active borrow position in the protocol, and if so, we should check if their Health Factor is currently below one by querying the contract directly.
If we receive a positive response from the previous step, we should proceed to find out the debt and collateral balances (they might have just one of each or several!). Then we should combine them, find out the USD price of each asset, and get the accumulated debt/collateral (for debt & collateral assets).
Once we have that information, we can calculate profitability (liquidation bonus amount minus gas cost fees). If proven profitable, we can execute the liquidation.
Hold on! Now we need some funds first. To bypass this limitation, we will deploy our own smart contract that will be in charge of triggering the flash loan, executing the liquidation, and paying back the loan plus fees.
Project Set up
For this first part, we will use the following stack(we will have to install more packages in the future):
- Node.js
- starknet.js, please see this link for installation: https://www.starknetjs.com/docs/guides/intro
Initiate your Node.js project
*npm init -y*
Install starknet.js
npm install starknet
Create a “src” directory and a “services” directory inside it, then create a .js file called getInitialBorrowers.js
.
Identifying Active Borrowers in the Protocol
To get the current active borrowers, we will need to fetch all the “borrowing” events emitted by zkLend. Every time a user borrows from the protocol, a borrowing event with the following information is emitted.
#[derive(Drop, PartialEq, starknet::Event)]
struct Borrowing {
user: ContractAddress,
token: ContractAddress,
raw_amount: felt252,
face_amount: felt252
}
As you can see, the borrower address (user) is one of the fields inside the event. By retrieving that information, we will identify our borrowers.
To get all borrowers, we need to go block by block and fetch all these events from block 0.
*Note: This isn’t the fastest way to retrieve a large amount of data from the blockchain. An indexer like Apibara may be able to get these events much faster, but this approach will suffice for now.
Importing the Necessary Elements from the Starknet Module
We need to import the required elements from the Starknet module:
// Imports
import { RpcProvider, num, hash} from 'starknet'; // we will use "num" and "hash" later in the code
import fs from 'fs'; // this will be needed to save out our borrowers
We also need a provider to connect to the blockchain and read from it. You can get a valid RPC URL from Infura or any other Node-as-a-service provider:
// initialise the provider
const myNodeUrl = '<http://your-node.url>';
const provider = new RpcProvider({ nodeUrl: `${myNodeUrl}` });
We will also need the main zkLend contract, the one the users interact with. We will pass this as an argument when calling the getEvents()
method on the provider:
// Contract address
const zkLendContratcAddress = '0x04c0a5193d58f74fbace4b74dcf65481e734ed1714121bdc571da345540efa05'
Now we have the provider and the main zkLend contract address. We can define our function to fetch all “Borrowing” events.
Before we proceed to the code, let’s look at what an event in Starknet looks like. The event contains information such as block hash, block number, data, from address, keys, and transaction hash. We are only interested in the first element of the data array, which contains the borrower's address.
{
block_hash: '0x33baa1c9ba2002f81f4f5ba97f392742a8a3c8c2195ce95a8600fcf70f54993',
block_hash: '0x33baa1c9ba2002f81f4f5ba97f392742a8a3c8c2195ce95a8600fcf70f54993',
block_number: 55898,
data: [
'0x76f6859580bb1a12c36085161680e77f8ea11d05e391b5415fa4f01e28371ad',
'0xda114221cb83fa859dbdb4c44beeaa0bb37c7537ad5ae66fe5e0efd20e6eb3',
'0x412f10b8401c74',
'0x417d17c30e4a9b'
],
from_address: '0x4c0a5193d58f74fbace4b74dcf65481e734ed1714121bdc571da345540efa05',
keys: [
'0xfa3f9acdb7b24dcf6d40d77ff2f87a87bca64a830a2169aebc9173db23ff41'
],
transaction_hash: '0x1ecef84095d848cc5bff76d6d67f0584b3cc11635692b34e7b242e68c7f2402'
}
The following function fetches the events and extracts the unique borrower addresses:
// Fetch the events and extract the unique borrowers addresses
async function getBorrowersByEvents() {
// get the last block number
let block = await provider.getBlock('latest');
// key filter - we need to format the event name ("Borrowing") into the right format expected by the provider
const keyFilter = [[num.toHex(hash.starknetKeccak('Borrowing'))]];
// define the continuation token and the array to store all the events
let continuationToken = null;
const uniqueAddresses = new Set();
const chunkSize = 100; // Number of events to fetch in each request
more code:
// Fetch the events and extract the unique borrowers addresses
async function getBorrowersByEvents() {
// get the last block number
let block = await provider.getBlock('latest');
// key filter - we need to format the event name ("Borrowing") into the right format expected by the provider
const keyFilter = [[num.toHex(hash.starknetKeccak('Borrowing'))]];
// define the continuation token and the array to store all the events
let continuationToken = null;
const uniqueAddresses = new Set();
const chunkSize = 100; // 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
});
//We only need the address of the borrower, not the entire event object
response.events.forEach(event => {
uniqueAddresses.add(event.data[0]);
console.log(event.data[0]); // log the address as the script runs
});
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(uniqueAddresses);
}
you can read more about how to interact with events here: https://www.starknetjs.com/docs/guides/events
This function will yield a lot of addresses in your terminal, but we want to store them somewhere. Let’s save them in a JSON file.
Important: The success of a liquidation bot relies on the speed of execution. For a production-ready version, we do not want to open/read from a JSON file, as this adds more computational effort and makes our bot less efficient. A better alternative is to keep these addresses inside an in-memory database like Redis or put them in a queue. For the simplicity of this guide, I will use a JSON file.
import fs from 'fs'; // make sure to import the fs module
const borrowers = await getBorrowersByEvents();
// Save the borrowers to a JSON file
fs.writeFileSync('zklend_liquidator_bot/uniqueBorrowerAddresses.json', JSON.stringify(borrowers, null, 2));
If you run the script, this might take a while, as we are querying all the way from block 0 to the latest.
The final output will look something like this, a very large file:
Now, we have a very long list of all historic borrowers at zkLend. This is a good starting point, but we still need to filter out all borrowers who no longer have active loans on the platform. To do this, we must query the zkLend smart contract directly and call the user_has_debt()
method on all these addresses.
Important: This is an easy but inefficient way to filter out inactive borrowers from our list. I will go ahead with this method, but you can try deploying a smart contract on StarkNet that queries the same method but in much larger batches of account addresses. For simplicity, I will explain the inefficient one.
To read the balance, you need to connect an RpcProvider and a Contract.
You have to call StarkNet using the meta-class method:
contract.function_name(params)
Please note that for these read operations, we do not need a funded account, but for the next part of the guide, we will need one.
You can create a new file under the “services” folder, initialize the provider, and declare important variables:
// initialise the provider
const myNodeUrl = '<http://your-node.url>';
const provider = new RpcProvider({ nodeUrl: `${myNodeUrl}` });
// ZKLend Contract address
const zkLendContratcAddress = '0x04c0a5193d58f74fbace4b74dcf65481e734ed1714121bdc571da345540efa05'
We also need the ABI of the contract we want to interact with.
// read abi of ZKLend contract
const { abi: zkLendABI } = await provider.getClassAt(zkLendContratcAddress);
if (zkLendABI === undefined) {
throw new Error('no abi.');
}
Now we can declare the contract object:
const zkLendContract = new Contract(zkLendABI, zkLendContratcAddress, provider);
Next, let’s proceed with getting all the historical borrowers we collected before and loop through them one by one. (Please be aware of your RPC provider limit calls)
// open JSON file with unique borrower addresses
const borrowers = JSON.parse(fs.readFileSync('/starknet_zklend_liquidator_bot/uniqueBorrowerAddresses.json', 'utf8'));
// loop through the borrowers and get Only the active ones
async function getOnlyActiveBorrowers(borrowers) {
console.log("Checking active borrowers...")
const activeBorrowers = [];
for (const borrower of borrowers) {
// this is necessary to get the calldata for the function call
const myCall = zkLendContract.populate('user_has_debt', [borrower]);
// execute the call
const res = await zkLendContract.user_has_debt(myCall.calldata);
const isBorrowerActive = await zkLendContract.user_has_debt(borrower);
// log the borrower address and the result
console.log(borrower, isBorrowerActive);
if (isBorrowerActive) {
activeBorrowers.push(borrower);
}
}
return activeBorrowers;
}
// save the active borrowers to a JSON file
function saveActiveBorrowers(activeBorrowers) {
fs.writeFileSync('starknet_zklend_liquidator_bot/onlyActiveBorrowers.json', JSON.stringify(activeBorrowers, null, 2));
}
async function main() {
const activeBorrowers = await getOnlyActiveBorrowers(borrowers);
saveActiveBorrowers(activeBorrowers);
}
After running the script, we should see something like this in our terminal, indicating that we’re successfully querying which users have outstanding debt in zkLend:
Now we have a final list of all currently active borrowers in zkLend. In the next release, we will look at ways to find out if any of these accounts are undercollateralized.
What to Expect Next
Thanks for reading. We’ve learned how to fetch historic data such as events from a full node and we also managed to query a StarkNet smart contract directly.
In the next release, we will interact with the zkLend contract to find out if any of these users are susceptible to liquidation and how to fetch all the necessary information to determine how to liquidate them.