17 min read
Overview
Managing server-side infrastructure for blockchain projects can be complex and time-consuming. QuickNode's Functions offers a powerful solution to this challenge! With Functions, you can deploy pre-built or custom serverless solutions for your blockchain projects, eliminating the need to manage your own infrastructure. This streamlined approach lets you focus on building your application logic rather than worrying about hosting and scaling.
In this guide, we'll demonstrate how to monitor health factors on Aave, a leading lending protocol on Ethereum, using QuickNode's Streams and Functions. We'll set up Streams to listen for borrow events on the Aave V3 Pool contract. Then, we'll create a Function to process these events, extract addresses and health factors, and store this data in the Key-Value Store API. By the end of this guide, you'll have a system that automatically tracks and stores borrowers' addresses and health factors on the Aave V3 protocol.
Let's get started!
What You Will Need
- A QuickNode account
- Basic understanding of JavaScript and Ethereum transactions and events. Check out our guide on Ethereum transactions and events to learn more
- A QuickNode RPC endpoint on Ethereum mainnet
- A Webhook URL (You can get one here: https://typedwebhook.tools/)
What You Will Do
- Set up a Stream to capture real-time borrow events from the Aave V3 contract
- Develop a Function to process these events, extracting borrower addresses and sending them to a Webhook
- Use the Key-Value Store to persistently save the borrower address and its health factor on Aave
- Test your system by monitoring for new events on the Webhook and calling the Key-Value Store API via cURL
Streams
Streams is a blockchain data solution that delivers real-time and historical block data from multiple chains like Ethereum, Optimism, Base, and more. It offers flexible data routing to destinations such as Webhooks, S3 Buckets, PostgreSQL, Snowflake, and Functions. Streams provides customizable data schemas to meet specific needs and allows you to filter and transform data before sending it to your chosen destination. This powerful feature enables precise data parsing while Streams manages the underlying RPC infrastructure. For more details, visit the Streams Documentation page.
Functions
Next, let's explore what QuickNode's Functions offer. Functions solve a common headache for blockchain developers: managing and scaling server-side scripts that listen to the blockchain in real time. With Functions, you can focus on writing code instead of worrying about the infrastructure.
Functions can be created using JavaScript or Python, and external libraries like Web3 SDKs can be used (e.g., ethers.js, web3.js, QuickNode SDK, and more). This makes it easier to interact with and decode data from blockchain nodes. Here are some key features that make Functions stand out:
- API Ready: Your functions automatically become APIs, ready to be called from your front-end or other services.
- Blockchain Optimized: Functions work seamlessly with QuickNode Streams, activating when new blockchain data comes in.
- Storage Access: You can easily access and manage your QuickNode Storage data within your functions.
- Cost Effective: You only pay for what you use, with no upfront costs or long-term commitments.
- Performance at Scale: Functions run on globally balanced, auto-scaling infrastructure, ensuring smooth operation even during busy times.
Learn more on the Functions Documentation page.
Key-Value Store API
Another powerful tool you can combine with Functions is Key-Value store API. This allows you to give your stateless functions a state by storing and retrieving data as key-value pairs from within your Functions. You can store/retrieve these values not only via Functions but also via REST API, which allows you to call them from any application. You can see the different types of REST API methods available on the Key-Value Store Documentation page.
Now that we have a better understanding of all the QuickNode tools we'll be using to create a data pipeline to monitor borrow events and addresses on Aave V3, let's get started.
Aave Liquidations / Health Factor
Before getting into the code, let's recap how Aave and its liquidations system functions.
Aave is a collateralized lending protocol allowing anyone to borrow, lend, and participate in liquidations. An opportunity to liquidate a position (which is under-collateralized) becomes available when the health factor falls under {"1"}. To "liquidate" a position, a user would call the liquidateCall
function and pay back part of the debt owed (by the uncollateralized position) and receive discounted collateral in return. To monitor a position's health, you can call the getUserAccountData()
function on the Aave V3 pool contract, which will return data about an account, including its health factor across all borrowed assets.
This is where Functions come into play. We'll be creating a script that:
- Initializes a list in the Key-Value Store API
- Processes incoming borrowers from borrow events (note: not every block will contain a borrow event)
- Creates a set of (borrowerAddress, healthFactor) pairs to track each borrower's current position
- Stores this information in the Key-Value Store API
- Enables future retrieval of this data via REST API calls to the Key-Value Store API
Now that we understand all the concepts let's get into building the system.
Create a Stream on QuickNode
First, navigate to QuickNode Streams page in the Dashboard and click "Create Stream".
Next, create a Stream with the following settings:
- Chain: Ethereum
- Network: Mainnet
- Dataset: Transactions
- Stream start: Latest block (you can change this based on your needs)
- Stream payload: Modify the Stream before streaming
- Reorg Handling: Leave as-is
Select the option to modify the payload before streaming.
Next, copy and paste the following code to filter the streaming data for borrow events that occur on the Aave V3 pool smart contract.
function main(stream) {
try {
var transactions = stream[0]
const AAVE_V3_POOL_ADDRESS = '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2'.toLowerCase();
const BORROW_FUNCTION_SIGNATURE = '0xa415bcad'; // Function signature for borrow()
// Filter transactions
var filteredList = transactions.filter(tx => {
return tx.to &&
tx.to.toLowerCase() === AAVE_V3_POOL_ADDRESS &&
tx.input.startsWith(BORROW_FUNCTION_SIGNATURE);
});
// Extract borrower addresses directly from the 'from' field
var borrowers = filteredList.map(tx => {
return {
block: parseInt(tx.blockNumber, 16), // Convert hex blockNumber to decimal
transactionHash: tx.hash,
borrower: tx.from,
};
});
// Check if the borrowers array is empty
if (borrowers.length === 0) {
return {
block: parseInt(transactions[0].blockNumber, 16), // Convert hex blockNumber to decimal
message: "No borrow transactions found in this block"
};
}
return {
borrowers
};
} catch (e) {
return {
error: e.message
};
}
}
Let's recap the code before testing it.
The function main
takes in data from a Stream which contains transaction objects. It checks these transactions to identify borrowing activities on the Aave V3 lending protocol. The function begins by defining constants for the Aave V3 pool address and the function signature for the borrow operation. It then filters the incoming transactions, keeping only those sent to the Aave V3 pool address and whose input data starts with the borrow function signature. For each filtered transaction, the function extracts key information: the block number (converted from hexadecimal to decimal), the transaction hash, and the borrower's address (which is the 'from' address of the transaction). The function handles two possible outcomes. If borrowing transactions are found, it returns an array of borrower objects containing the extracted information. If no borrowing transactions are detected, it returns a message indicating this, along with the current block number.
Test the Stream filter
Click the "Run test" button to test your filter against a single block of data (you can adjust the block number above the code editor). Once the test is complete, you will see a sample of the data payload that the Stream will generate. For example:
{
"borrowers": [
{
"borrower": "0x23468ab702b1ad9e6ff85acfef4a319c9552d1ff"
}
]
}
or
{
"block": 20393979,
"message": "No borrow transactions found in this block"
}
Now, click the Next button and then choose "Functions" for your Stream destination. Then, in the Function dropdown, choose the "Create a new Function" option.
Create a Function on QuickNode
Once on the "Create Function" page, fill in the following details:
- Namespace: Click "create a new namespace" and name it anything, like: "AaveHealthMonitoring"
- Function name: Aave Liquidation Monitor
- Runtime: Node.js 20
- Description: A Function that monitors an address(s) health factor on Aave.
- Timeout limit: 60 (default)
- Memory limit: 256 (default)
After, click "Create Function" and you'll be forwarded to the Editor. Paste in the code below:
const https = require('https');
const ethers = require('ethers');
// Constants
const AAVE_V3_POOL_ADDRESS = '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2';
const RPC_URL = "RPC_URL";
const WEBHOOK_TOKEN = "WEBHOOK_URL";
const AAVE_V3_POOL_ABI = [
{
"inputs": [{"internalType": "address", "name": "user", "type": "address"}],
"name": "getUserAccountData",
"outputs": [
{"internalType": "uint256", "name": "totalCollateralBase", "type": "uint256"},
{"internalType": "uint256", "name": "totalDebtBase", "type": "uint256"},
{"internalType": "uint256", "name": "availableBorrowsBase", "type": "uint256"},
{"internalType": "uint256", "name": "currentLiquidationThreshold", "type": "uint256"},
{"internalType": "uint256", "name": "ltv", "type": "uint256"},
{"internalType": "uint256", "name": "healthFactor", "type": "uint256"}
],
"stateMutability": "view",
"type": "function"
}
];
let provider;
let aavePool;
function initializeContract() {
provider = new ethers.JsonRpcProvider(RPC_URL);
aavePool = new ethers.Contract(AAVE_V3_POOL_ADDRESS, AAVE_V3_POOL_ABI, provider);
}
async function convertToHealthFactor(userAddress) {
try {
const accountData = await aavePool.getUserAccountData(userAddress);
return ethers.formatUnits(accountData.healthFactor, 18);
} catch (error) {
console.error(`Error getting health factor for ${userAddress}:`, error);
return null;
}
}
async function main(params) {
console.log("Received params:", JSON.stringify(params, null, 2));
try {
initializeContract();
const data = params.data || params;
let result = {};
if (data.transactions && Array.isArray(data.transactions)) {
const borrowers = data.transactions
.filter(tx => tx.to === AAVE_V3_POOL_ADDRESS)
.map(tx => tx.from)
.filter(address => address !== null && address !== undefined);
if (borrowers.length > 0) {
const processedBorrowers = [];
for (const borrower of borrowers) {
if (typeof borrower !== 'string') {
console.error(`Invalid borrower address: ${borrower}`);
continue;
}
await qnLib.qnAddListItem(`borrowers-${AAVE_V3_POOL_ADDRESS}`, borrower);
try {
const healthFactor = await convertToHealthFactor(borrower);
await qnLib.qnAddSet(`borrower-${borrower}`, healthFactor);
processedBorrowers.push({ borrower, healthFactor });
} catch (error) {
console.error(`Error processing borrower ${borrower}:`, error);
processedBorrowers.push({ borrower, error: error.message });
}
}
result = {
message: `Processed ${borrowers.length} borrowers from block ${data.number}`,
borrowers: processedBorrowers
};
} else {
result = {
message: `No valid borrow transactions found in block ${data.number}`,
block: data.number
};
}
} else if (data.borrowers && Array.isArray(data.borrowers) && data.borrowers.length > 0) {
const processedBorrowers = [];
for (const borrowerObj of data.borrowers) {
const borrower = borrowerObj.borrower;
if (typeof borrower !== 'string') {
console.error(`Invalid borrower address: ${borrower}`);
continue;
}
await qnLib.qnAddListItem(`borrowers-${AAVE_V3_POOL_ADDRESS}`, borrower);
try {
const healthFactor = await convertToHealthFactor(borrower);
await qnLib.qnAddSet(`borrower-${borrower}`, healthFactor);
processedBorrowers.push({ borrower, healthFactor });
} catch (error) {
console.error(`Error processing borrower ${borrower}:`, error);
processedBorrowers.push({ borrower, error: error.message });
}
}
result = {
message: `Processed ${processedBorrowers.length} borrowers`,
borrowers: processedBorrowers
};
} else if (data.block && data.message && data.message.includes("No borrow transactions found")) {
result = {
message: `No borrow transactions found in block ${data.block}`,
block: data.block
};
} else {
result = {
message: "Invalid data format received",
receivedData: data
};
}
console.log("Result:", JSON.stringify(result, null, 2));
await sendToWebhook(result);
return result;
} catch (error) {
console.error("Error in main function:", error);
return {
message: "Error processing request",
error: error.message
};
}
}
async function sendToWebhook(result) {
const payload = JSON.stringify(result);
const options = {
hostname: 'typedwebhook.tools',
path: `/webhook/${WEBHOOK_TOKEN}`,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': payload.length
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, res => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
console.log('Webhook response:', data);
resolve(data);
});
});
req.on('error', error => {
console.error('Error sending to webhook:', error);
reject(error);
});
req.write(payload);
req.end();
});
}
module.exports = { main };
Next, let's recap the code.
The Function code above has three main functions:
- Calculate borrower's health factors
- Store borrower information (e.g., address and health factor) in the Key-Value Store
- Report results (i.e., send each new borrow event containing the address and health factor to the Webhook)
Before moving on, you must set two placeholders with valid values:
- Set
RPC_URL
with a valid Ethereum mainnet RPC URL. This allows the Function to communicate with an Ethereum node (i.e., to fetch data from Aave). You can get one from your QuickNode Dashboard. - Use a tool like https://typedwebhook.tools/ for fast API testing. Grab the token from the path and input in the
WEBHOOK_TOKEN
placeholder (e.g.,784f26f1-c50b-47b5-95db-8c3f03447bb7
). Alternatively, use an API like Express.js to set up a more production ready API (however, you will need to update the Webhook logic).
Then, click "Save and Close". Navigate back to the Streams page, and click "Create Stream", the previous Stream you created will be loaded. Click "Next", then select the Function you created in the dropdown list and click "Create a Stream".
The Stream and Function will be up and running; now we just need to wait for a borrowing event to occur. You can monitor your Webhook, and once you see an event occur, we can test the Key-Value Store in the next step.
Test the Key-Value Store API
Ensure that a borrow event has been sent to your Webhook before proceeding, else the Key-Value Store will be empty.
To call the Key-Value Store API via a REST call, we can call the /kv/rest/v1/lists/{key}
REST method and do the following:
curl -X GET \
"https://api.quicknode.com/kv/rest/v1/lists/{key}" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY"
Replace the key
placeholder with the proper key (i.e., borrowers-0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2
). In our example, it's the Aave V3 pool address we set when calling "qnLib.qnAddListItem(borrowers-${AAVE_V3_POOL_ADDRESS}
" previously in the Function code. Also, include your API key in the YOUR_API_KEY
field. This can be retrieved on your specific Function's dashboard, just click "Copy API Key".
The response will look something like:
{
"code": 200,
"msg": "",
"data": {
"items": [
"0xc4dd8e820ff8794301c67a74f4cd024676356532",
"0xa189da3bbc776bba711cd2bd24a4d37561c81747",
"0x260f8cc08003922f56b62b80aa354372672da36c",
"0xc6bed4a813b3360458091ddea94833aae196669f",
"0x18a76fb4dc54bba7559e39b9cdc28632b97b5221",
"0x5d7166ee0c633021cb502fa5302866316d86f701",
"0x9bd39a1e44e3b653f64ff2f1557d7a6e2eb02212",
"0xde13a331adf3b9b6f32017bc63dff2f61a926a5a",
"0xbc28d3fc96b8ebd95bc085018ed240db3b1a1c21",
"0xa052d039738d667f1e12180c388816308e9d022d",
"0x13ca2cf84365bd2daffd4a7e364ea11388607c37",
"0xc80a5430d22e8f96b9fbf0d4d0925bc9e60daa1c",
"0xf1e33195b419e1a1bb55cebc8d159ae5f665eb69",
"0xe0019d15f39ec9e8007acd7ca0776dd90eb3d333",
"0x92ef71cc811dba31dd2563ed68caadcc89cf3770",
"0x1f6f6ce7e24aefe5cb003d311c0e704aa95a1985",
"0x1053dd6084bf3f4889984d2d55f0e17c290a2c64",
"0x5c7441d0e3c55622b0017fa67666e8de6e80a034",
"0x6aa6a6ac0f07af2563bfc1b161ff3d8c31bffbd5",
"0x64147b131bfbf817ff6d38b4b3e0f4f5b4eeafc3",
"0x93fc589aa359b08b09495cc235deac65b281b5cb",
"0xb138549d11e3017cd5703c92295e3f4a95d1310d",
"0xa4218e648c07eb41f200f283e0dc6e7029d850c1",
"0x84a8dbf3d363449b88df7fced346b58d0ac96784",
"0x300f5c34a2b197b24131173ca7c677c1662f04f9",
"0x7db73d78d9239fb65bb0aea7dee45b9e9643e78a",
"0xfeb7f359b327be79328e36c087da5f847d032345",
"0xff3a6ccfb6e1381dd5262b2b650aadb0cd7a1eac",
"0x92bfb3bd9a4f1d0480dc513908b56d6c730e0ce6",
"0x44f9df8bf500df0c5fe3865c60b0c23747fdac9c"
]
}
}
This is the list of borrowers. Next, you can call the /kv/rest/v1/sets/{key}
REST method to retrieve the health factor for a specific address:
curl -X GET \
"https://api.quicknode.com/kv/rest/v1/sets/{key}" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY"
Ensure to replace key
with the borrower address in this format borrower-0xAddress
(e.g., borrower-0x44f9df8bf500df0c5fe3865c60b0c23747fdac9c
), and your API key with the same one you used above. The response will look like:
{
"code": 200,
"msg": "",
"data": {
"value": "7.76575528287429377"
}
}
Nice work! You just created a Stream and Function to store borrower addresses and health factors in the Key-Value Store API.
If you want to continue building upon this logic, try the following ideas:
- Implement a block height history, so you can check what a borrower's health score was on a particular block.
- Implement a polling system to get notified when a borrower's health score drops below a certain threshold.
Functions Library
Want to try out more Functions ideas? Check out this list of pre-built Functions you can easily deploy:
- Esimtate Gas Price: Esimate the gas price by averaging the gas prices of a few transactions of a block and return the average gas price in Wei and Gwei
- Check POAP Balance: Get POAP balances on Ethereum and Gnosis mainnet networks along with their token URIs, which contain the ERC-721 token metadata
- Whale Transaction Finder: Fetch transactions with native asset transfers and return the ones that exceed a given threshold..
- Compute Block Metrics: Query block, transactions, and transaction receipt data to get vital block analytics data like active addresses, gas price, ETH transferred, biggest transactions, etc.
Final Thoughts
Well done! This guide has demonstrated how to build a monitoring system for Aave V3 borrowers using QuickNode's Streams and Functions. By leveraging QuickNode's infrastructure, you can easily scale this solution to handle large volumes of data across multiple blockchains, enabling you to build robust and responsive blockchain applications.
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.