Skip to main content

Monitor Aave V3 Borrower Health with Quicknode Streams

Updated on
Oct 20, 2026

19 min read

Overview​

A borrow transaction is a good starting point for monitoring an Aave position, but it doesn't tell the whole story. A borrower's health factor can change after that transaction as asset prices, debt, and collateral change. Checking only when someone borrows leaves those changes out.

In this guide, we'll use Quicknode Streams to discover borrowers on Aave V3, send their addresses to a webhook server, and save them in Key-Value Store. A refresh loop will check their health factors every minute, even when they haven't borrowed again. By the end, you'll have a small monitor you can stop, restart, and inspect without losing its watchlist.

This uses the same Streams-to-webhook approach as the Aave V3 Liquidation Tracker. That app records completed liquidations. Here, we're tracking borrower health before or after a liquidation, without building a dashboard or sending transactions.

Streams saves borrower discoveries through a signed webhook. A separate worker reads the saved watchlist, queries Aave, and writes refreshed health factors back to Key-Value Store.Streams saves borrower discoveries through a signed webhook. A separate worker reads the saved watchlist, queries Aave, and writes refreshed health factors back to Key-Value Store.

TLDR​

  • Discover debt owners from the Aave V3 Pool's Borrow event, including borrows made through other contracts.
  • Save each borrower before acknowledging the webhook so a failed write can be retried.
  • Refresh a watchlist of up to 20 borrowers every minute, using finalized Ethereum state.
  • Inspect stored health factors, observation times, and a configurable threshold result.

What You Will Do​

  • Configure a Stream that delivers only Aave borrowing events.
  • Verify incoming webhook signatures and persist a watchlist.
  • Read getUserAccountData with viem and check the results in Key-Value Store.
  • Replay a historical borrow, verify a later refresh, and restart the monitor.

What You Will Need​

  • A Quicknode account with access to Streams and Key-Value Store.
  • A Quicknode Ethereum mainnet RPC endpoint, available from the Endpoints dashboard.
  • A Quicknode API key with access to Streams and Key-Value Store. This is separate from the RPC endpoint and the Stream's destination security token.
  • Node.js 22 or later, npm, and a code editor.
  • cloudflared for a temporary HTTPS tunnel to the local server, or an existing HTTPS host.
  • Basic understanding of TypeScript, Ethereum transactions, and events.

We're reading Ethereum mainnet data. You don't need a wallet, private key, or ETH for this guide. Streams, RPC reads, and storage requests consume your account's credits. Start with the short replay below rather than a large historical range.

Understand Aave Borrowers and Health Factors​

Aave is a collateralized lending protocol that lets users supply assets, borrow against collateral, and liquidate eligible positions. The health factor compares collateral value, weighted by liquidation thresholds, with debt. A lower value means less collateral buffer, and a position can become eligible for liquidation when its health factor falls below one. A liquidator calls liquidationCall, repays debt, and receives collateral according to the protocol's rules.

To monitor a position's health, call getUserAccountData on the Aave V3 Pool. It returns account-wide data, including total debt and a health factor with 18 decimals. The example also checks total debt so a debt-free account isn't displayed with an enormous health factor.

One detail matters when finding borrowers: the transaction sender isn't always the debt owner. In the Pool's Borrow event, onBehalfOf identifies the account that receives the debt. We'll use that field rather than tx.from. Reading receipt logs also captures successful borrows routed through another contract.

Set Up the Borrower Monitor​

Let's build this in three parts: a storage helper, a webhook server, and a script to inspect the results. The webhook saves borrower addresses. The refresh loop handles RPC reads separately, so it doesn't hold up Stream delivery.

Create the project and install the dependencies:

mkdir aave-borrower-monitor
cd aave-borrower-monitor
npm init -y
npm pkg set type=module
npm install --save-exact viem@2.57.7
npm install --save-dev --save-exact tsx@4.23.15 typescript@5.9.3 @types/node@22.18.12
npm pkg set scripts.start="tsx --env-file=.env server.ts"
npm pkg set scripts.init="tsx --env-file=.env init.ts"
npm pkg set scripts.inspect="tsx --env-file=.env inspect.ts"
npm pkg set scripts.check="tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck *.ts"
printf '.env\nnode_modules/\n' > .gitignore

Create .env in your editor and fill in the placeholders. Choose a unique STATE_KEY that you aren't using for anything else. The initialization script writes an empty watchlist to that key.

.env
QUICKNODE_RPC_URL=YOUR_ETHEREUM_MAINNET_RPC_ENDPOINT
QUICKNODE_API_KEY=YOUR_QUICKNODE_API_KEY
STATE_KEY=aave-monitor-ethereum-mainnet-your-unique-name
CONFIRM_EMPTY_STATE=aave-monitor-ethereum-mainnet-your-unique-name
WEBHOOK_SECRET_KEY=REPLACE_WITH_YOUR_STREAM_SECURITY_TOKEN
HEALTH_FACTOR_THRESHOLD=1.1

Keep this file out of version control. You'll copy WEBHOOK_SECRET_KEY from the destination step during Stream creation, before starting the server. The threshold is a reporting threshold for this example; 1.1 is not Aave's liquidation threshold.

Save and Retrieve Borrower State​

Create store.ts. This helper uses the Key-Value Store REST API to store the watchlist and samples as one JSON string. A single value keeps this small example easy to inspect and restore. Addresses are normalized to lowercase because storage keys and values are case-sensitive.

store.ts
export function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Set ${name} in .env`);
return value;
}

export const pool = '0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2' as const;
export const stateKey = required('STATE_KEY');
const apiKey = required('QUICKNODE_API_KEY');
const base = 'https://api.quicknode.com/kv/rest/v1/values';

export type Sample = {
block: string;
blockHash: string;
observedAt: string;
totalDebtBase: string;
healthFactorRaw: string;
healthFactor: string | null;
status: 'no-debt' | 'below-threshold' | 'healthy';
};
export type Borrower = { address: `0x${string}`; sample?: Sample };
export type State = { chainId: 1; pool: typeof pool; borrowers: Borrower[] };

export async function load(): Promise<State> {
const response = await fetch(`${base}/${encodeURIComponent(stateKey)}`, {
headers: { 'x-api-key': apiKey }, signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Load state: HTTP ${response.status}`);
const body = await response.json();
if (body.code !== undefined && body.code !== 200) throw new Error(`Load state: code ${body.code}`);
const state = JSON.parse(body.data.value) as State;
if (state.chainId !== 1 || state.pool !== pool || !Array.isArray(state.borrowers) ||
state.borrowers.length > 20 || state.borrowers.some(b => !/^0x[0-9a-f]{40}$/.test(b.address)) ||
new Set(state.borrowers.map(b => b.address)).size !== state.borrowers.length) {
throw new Error('Invalid state. Use a dedicated STATE_KEY for this guide.');
}
return state;
}

export async function save(state: State): Promise<void> {
const response = await fetch(base, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({ key: stateKey, value: JSON.stringify(state) }),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Save state: HTTP ${response.status}`);
const body = await response.json();
if (body.code !== 200) throw new Error(`Save state: code ${body.code}`);
}

Create init.ts to initialize the unused key. CONFIRM_EMPTY_STATE must match STATE_KEY so the reset is explicit.

init.ts
import { pool, save, stateKey } from './store.js';

try {
if (process.env.CONFIRM_EMPTY_STATE !== stateKey) {
throw new Error('Set CONFIRM_EMPTY_STATE to your unused STATE_KEY in .env before initializing.');
}
await save({ chainId: 1, pool, borrowers: [] });
console.log(`Initialized ${stateKey}. Remove CONFIRM_EMPTY_STATE from .env now.`);
} catch (error) {
console.error(error instanceof Error ? error.message : 'Initialization failed');
process.exitCode = 1;
}

Run it once:

npm run init

You should see Initialized followed by your state key. Remove CONFIRM_EMPTY_STATE from .env immediately afterward. Don't run initialization against an existing monitor: it would erase its watchlist and samples. For a restart, you'll only run npm start.

Receive Borrowers and Refresh Health Factors​

Next, create server.ts. The server verifies a Hash-based Message Authentication Code (HMAC) made from the nonce, timestamp, and exact request body. Missing headers or an invalid signature are rejected before the payload reaches storage.

Once an address is saved, the refresh loop calls Aave at a finalized block every minute. It compares integer health factors against your threshold, then stores a formatted value and observation time. If an RPC read fails, the previous sample remains available. We'll check its age when inspecting the results.

View the complete server.ts
server.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
import { createPublicClient, formatUnits, http, parseAbi, parseUnits } from 'viem';
import { mainnet } from 'viem/chains';
import { load, pool, required, save, type State } from './store.js';

const secret = required('WEBHOOK_SECRET_KEY');
const port = Number(process.env.PORT ?? 3005);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT');
const client = createPublicClient({
chain: mainnet, transport: http(required('QUICKNODE_RPC_URL'), { timeout: 5000, retryCount: 0 }),
});
const threshold = parseUnits(process.env.HEALTH_FACTOR_THRESHOLD ?? '1.1', 18);
if (threshold <= 0n) throw new Error('HEALTH_FACTOR_THRESHOLD must be positive');
const abi = parseAbi([
'function getUserAccountData(address user) view returns (uint256 totalCollateralBase, uint256 totalDebtBase, uint256 availableBorrowsBase, uint256 currentLiquidationThreshold, uint256 ltv, uint256 healthFactor)',
]);

// One process owns this state key. All writes run in order.
let queue: Promise<unknown> = Promise.resolve();
let busy = false;
function exclusive<T>(task: () => Promise<T>): Promise<T> {
const result = queue.then(task);
queue = result.catch(() => {});
return result;
}

function parseBorrowers(value: unknown): `0x${string}`[] {
if (!value || typeof value !== 'object') throw new Error('Expected an object');
const payload = value as { chainId?: unknown; pool?: unknown; borrowers?: unknown };
if (payload.chainId !== 1 || payload.pool !== pool || !Array.isArray(payload.borrowers) ||
payload.borrowers.length === 0 || payload.borrowers.length > 100) throw new Error('Invalid payload');
return [...new Set(payload.borrowers.map(event => {
if (!event || typeof event !== 'object' ||
typeof event.borrower !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(event.borrower) ||
typeof event.transactionHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(event.transactionHash) ||
typeof event.blockHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(event.blockHash) ||
typeof event.block !== 'string' || !/^\d+$/.test(event.block) ||
!Number.isSafeInteger(event.logIndex) || event.logIndex < 0) throw new Error('Invalid Borrow event');
return event.borrower.toLowerCase() as `0x${string}`;
}))];
}

async function refresh(): Promise<void> {
if (busy) return;
busy = true;
try {
const state = await exclusive(load);
if (!state.borrowers.length) return;
const block = await client.getBlock({ blockTag: 'finalized' });
if (block.number === null || block.hash === null) throw new Error('Missing finalized block');
const updates = new Map<string, NonNullable<State['borrowers'][number]['sample']>>();
// Two concurrent reads keep a small watchlist from flooding the RPC endpoint.
for (let i = 0; i < state.borrowers.length; i += 2) {
await Promise.all(state.borrowers.slice(i, i + 2).map(async borrower => {
try {
const account = await client.readContract({
address: pool, abi, functionName: 'getUserAccountData',
args: [borrower.address], blockNumber: block.number,
});
const debt = account[1], health = account[5];
updates.set(borrower.address, {
block: block.number.toString(), blockHash: block.hash,
observedAt: new Date().toISOString(), totalDebtBase: debt.toString(),
healthFactorRaw: health.toString(), healthFactor: debt === 0n ? null : formatUnits(health, 18),
status: debt === 0n ? 'no-debt' : health < threshold ? 'below-threshold' : 'healthy',
});
} catch {
console.error(`RPC read failed for ${borrower.address}; keeping its previous sample`);
}
}));
}
if (!updates.size) return;
await exclusive(async () => {
const current = await load();
for (const borrower of current.borrowers) {
const sample = updates.get(borrower.address);
if (sample && (!borrower.sample || BigInt(sample.block) >= BigInt(borrower.sample.block))) {
borrower.sample = sample;
}
}
await save(current);
});
console.log(`Refreshed ${updates.size} ${updates.size === 1 ? 'borrower' : 'borrowers'} at finalized block ${block.number}`);
} catch (error) {
console.error('Refresh failed; check RPC access and Key-Value Store availability');
} finally {
busy = false;
}
}

async function start(): Promise<void> {
if (await client.getChainId() !== 1) throw new Error('Use an Ethereum mainnet RPC endpoint');
await load();
const server = createServer(async (req, res) => {
const reply = (status: number, message: string) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message }));
};
if (req.method === 'GET' && req.url === '/health') return reply(200, 'ok');
if (req.method !== 'POST' || req.url !== '/webhook') return reply(404, 'Not found');
if (req.headers['content-encoding'] && req.headers['content-encoding'] !== 'identity') {
return reply(415, 'Select payload compression None in Streams');
}
const nonce = req.headers['x-qn-nonce'], timestamp = req.headers['x-qn-timestamp'];
const signature = req.headers['x-qn-signature'];
if (typeof nonce !== 'string' || typeof timestamp !== 'string' || typeof signature !== 'string' ||
!nonce || !/^\d+$/.test(timestamp) || !/^[0-9a-fA-F]{64}$/.test(signature)) {
return reply(401, 'Missing or invalid signature headers');
}
try {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 262144) return reply(413, 'Payload too large');
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const expected = createHmac('sha256', secret).update(nonce + timestamp).update(body).digest();
if (!timingSafeEqual(expected, Buffer.from(signature, 'hex'))) return reply(401, 'Invalid signature');
let borrowers: `0x${string}`[];
try { borrowers = parseBorrowers(JSON.parse(body.toString('utf8'))); }
catch { return reply(400, 'Invalid Borrow payload'); }
await exclusive(async () => {
const state = await load();
const known = new Set(state.borrowers.map(b => b.address));
const added = borrowers.filter(address => !known.has(address));
if (state.borrowers.length + added.length > 20) throw new Error('Watchlist limit reached. Pause the Stream.');
if (added.length) {
state.borrowers.push(...added.map(address => ({ address })));
await save(state);
}
});
console.log(`Saved discovery for ${borrowers.length} ${borrowers.length === 1 ? 'borrower' : 'borrowers'}`);
reply(200, 'Borrowers saved');
} catch (error) {
console.error(error instanceof Error ? error.message : 'Webhook failed');
reply(503, 'Could not persist borrowers; retry delivery');
}
});
server.requestTimeout = 15_000;
server.listen(port, '0.0.0.0', () => console.log(`Listening on http://localhost:${port}`));
const timer = setInterval(() => void refresh(), 60_000);
void refresh();
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
clearInterval(timer);
server.close();
});
}
}

start().catch(error => {
console.error('Startup failed; check the mainnet RPC endpoint and initialized state key');
process.exitCode = 1;
});

The webhook returns 200 only after borrower discovery is stored. Duplicate deliveries won't append duplicate addresses. If storage fails, it returns 503 so Streams can retry. The periodic worker also saves its results before reporting a successful refresh.

Keep the Example Small

Run one server process per STATE_KEY. This example serializes writes within that process and caps the watchlist at 20 borrowers. If a delivery would exceed the limit, the whole delivery fails and Streams retries, then pauses according to its retry settings. Pause the Stream when you finish the test. For a larger monitor, use a database or durable queue with coordinated writes and a scheduled worker.

Inspect Stored Health Factors​

Create inspect.ts to retrieve the same value through the REST API and display each borrower. A sample older than three minutes is marked stale. This age is measured from the successful RPC read; the sampled chain state is from a finalized block, which is older than the chain tip. This checks whether the worker is keeping up, independently of whether the HTTP server is reachable.

inspect.ts
import { load } from './store.js';

try {
const state = await load();
console.log(JSON.stringify(state, null, 2));
console.table(state.borrowers.map(b => ({
address: b.address,
healthFactor: b.sample?.healthFactor ?? 'pending or no debt',
status: b.sample?.status ?? 'pending',
observedAt: b.sample?.observedAt ?? 'not sampled yet',
stale: !b.sample || Date.now() - Date.parse(b.sample.observedAt) > 180_000,
})));
} catch (error) {
console.error(error instanceof Error ? error.message : 'Read failed');
process.exitCode = 1;
}

Check the TypeScript files before creating the Stream:

npm run check
npm run inspect

The initial state should contain "borrowers": [].

Configure a Stream for Aave Borrow Events​

Open Streams in the Quicknode dashboard and choose Create Stream. Set:

SettingValue
Chain and networkEthereum mainnet
DatasetBlocks with Receipts
Start block20393978
End block20393979
Batch size1
Modify payloadEnabled, JavaScript

The historical range is deliberately short. It contains a Borrow event and lets you test delivery without waiting for another borrow or starting an ongoing backfill.

Decode the Borrow Event​

The filter uses decodeEVMReceipts, a helper available inside Streams, to decode logs using the event's Application Binary Interface (ABI). It checks the emitting Pool address, extracts onBehalfOf, and includes the event's transaction, block, and log details. It processes every block in a batch and returns null when there are no matches.

Paste this into the filter editor:

filter.js
function main(stream) {
const pool = '0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2';
const abi = '[{"type":"event","name":"Borrow","anonymous":false,"inputs":[{"name":"reserve","type":"address","indexed":true},{"name":"user","type":"address","indexed":false},{"name":"onBehalfOf","type":"address","indexed":true},{"name":"amount","type":"uint256","indexed":false},{"name":"interestRateMode","type":"uint8","indexed":false},{"name":"borrowRate","type":"uint256","indexed":false},{"name":"referralCode","type":"uint16","indexed":true}]}]';
if (!Array.isArray(stream.data)) throw new Error('Expected Blocks with Receipts');
const borrowers = [];
for (const entry of stream.data) {
if (!entry.block || !Array.isArray(entry.receipts)) throw new Error('Missing block or receipts');
const receipts = decodeEVMReceipts(entry.receipts, [abi]);
for (const receipt of receipts) {
if (BigInt(receipt.status) !== 1n) continue;
for (const log of receipt.decodedLogs || []) {
if (log.name !== 'Borrow' || log.address.toLowerCase() !== pool) continue;
borrowers.push({
borrower: log.onBehalfOf.toLowerCase(),
block: BigInt(entry.block.number).toString(),
blockHash: entry.block.hash,
transactionHash: receipt.transactionHash,
logIndex: Number(BigInt(log.logIndex)),
});
}
}
}
return borrowers.length ? { chainId: 1, pool, borrowers } : null;
}

Run the filter test with block 20393978. The result includes:

{
"chainId": 1,
"pool": "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2",
"borrowers": [
{
"borrower": "0x23468ab702b1ad9e6ff85acfef4a319c9552d1ff",
"block": "20393978",
"blockHash": "0x6c4d5637be9bd983cef14ae3395d1526c028970077aaa6f011816f429fb50588",
"transactionHash": "0xc45df7636174ca7c89282f74eae3eda3b19cffe21b1eda38ca74450c344a99a8",
"logIndex": 333
}
]
}

Test block 20393979 as well. It should return null. This skips webhook delivery for a block without a matching event. Errors aren't converted to empty results, so a broken filter remains visible during testing.

Connect the Webhook Destination​

In a second terminal, start the tunnel:

cloudflared tunnel --url http://localhost:3005 --no-autoupdate

Copy the HTTPS address printed by cloudflared and append /webhook. Back in the Stream setup:

  1. Select Webhook as the destination and enter that address.
  2. Set Payload compression to None. The example intentionally rejects compressed requests.
  3. Set the destination timeout to 30 seconds, retry interval to 3 seconds, and maximum failed retries to 3.
  4. In the destination step, copy the Security token into WEBHOOK_SECRET_KEY in .env. Leave the creation wizard open while you start the server and verify that it's reachable.

Start the server from your project terminal:

npm start

You should see:

Listening on http://localhost:3005

In another terminal, confirm the local health endpoint:

curl --fail http://localhost:3005/health

It returns {"message":"ok"}. Check the tunnel's HTTPS address with /health as well. This tests reachability; it doesn't prove that a webhook signature or a storage write works. Don't send an unsigned request to /webhook as a health check: the server will reject it.

We're using the Webhook destination of Streams, which delivers to your HTTP server. You don't need to create a separate Quicknode Webhooks product resource for this guide.

Test the Monitor End to End​

With the server and tunnel running and both health checks passing, return to the dashboard and finish creating the Stream. It will replay the short historical range and stop at the end of the range.

Look for these messages in your server terminal:

Saved discovery for 1 borrower
Refreshed 1 borrower at finalized block ...

The refresh happens on the next minute interval. Leave the server running and inspect the stored value from another terminal inside aave-borrower-monitor:

npm run inspect

Check that:

  1. The watchlist contains 0x23468ab702b1ad9e6ff85acfef4a319c9552d1ff only once.
  2. Its sample includes block, blockHash, observedAt, healthFactorRaw, and status.
  3. stale is false after a successful refresh. The health factor and status depend on the account's state when you run the guide. An account that repaid its debt will show no-debt and a null formatted health factor.
Historical Discovery and Current Health

The Borrow event is historical, but the refresh queries current finalized account state. Its sample block is the block used by the RPC read, not the original Borrow block. Finalized data also lags the chain tip. This example doesn't reconstruct historical health factors or execute liquidations.

The replayed borrower may have repaid its debt since this block, in which case the sample shows no-debt and no health factor. Borrowers discovered while the Stream follows the chain tip (see Continue Monitoring or Clean Up) usually still carry debt, so their samples include a formatted health factor and a healthy or below-threshold status.

Verify Refreshes and Restart Recovery​

Wait for another Refreshed message without replaying the Stream, then run npm run inspect again. observedAt should advance even though no other Borrow event was delivered. The finalized block may stay the same between intervals.

Stop the server with Ctrl+C, leave the Stream stopped, and start it again:

npm start

The startup refresh loads the saved watchlist and samples the borrower again. Run npm run inspect to confirm that the borrower remains and its observation time advances. Don't initialize the key again.

For an authentication check, send an unsigned request:

curl -i -X POST http://localhost:3005/webhook \
-H 'Content-Type: application/json' \
-d '{}'

The response should be 401 with Missing or invalid signature headers. Check that your stored watchlist hasn't changed.

Handle Failed Deliveries​

If the Stream pauses after failed retries, fix the cause before activating it again:

ResultWhat to check
401The destination security token matches .env; restart the server after changing it.
400The filter emits the object shown above, including the correct Pool, chain ID, and borrower fields.
415Destination compression is None.
503Storage access, API-key permissions, or the 20-borrower limit. The server logs the failed operation.
Old samplesRPC availability and worker logs. /health alone doesn't confirm successful refreshes.

Continue Monitoring or Clean Up​

To discover future borrowers, change the Stream to start at the latest block with no end block. For this continuous Stream, configure these settings that are available when following the chain tip:

SettingValue
Restream on reorgEnabled
Latest block delay128 blocks

Keep the other settings from the replay. Its watchlist will include borrowers discovered from that point onward, plus any addresses already saved during your replay. Starting at the latest block doesn't give you every existing Aave borrower.

Restreaming on reorg and delaying blocks from the tip reduce exposure to recent reorganizations. This example keeps discovered addresses even if an event is later replaced; finalized account reads determine their reported health. A canonical event index would need rollback handling keyed by block hash and event identity.

For cleanup:

  1. Pause or delete the test Stream in the dashboard.
  2. Stop the server and cloudflared with Ctrl+C in their terminals.
  3. Remove the test value from Key-Value Store when you no longer need the watchlist. Use the REST API script below with your existing .env:
node --env-file=.env --input-type=module <<'JS'
const url = new URL('https://api.quicknode.com/kv/rest/v1/values/');
url.pathname += encodeURIComponent(process.env.STATE_KEY);
const response = await fetch(url, {
method: 'DELETE',
headers: { 'x-api-key': process.env.QUICKNODE_API_KEY },
});
if (!response.ok) throw new Error(`Delete failed: HTTP ${response.status}`);
console.log('Removed the test state');
JS

The worker reads and rewrites the saved state each minute and queries each watched borrower. Check Streams billing and Key-Value Store costs before leaving it running. Filtering reduces delivered data, not the number of blocks Streams processes.

Final Thoughts​

You now have a Stream that discovers Aave borrowers, a webhook that saves them, and a worker that checks their health after the original transaction. The stored observation time makes it clear when a result was last refreshed, and the saved watchlist lets you resume monitoring after a restart.

Next Steps​

  • Add notifications for threshold crossings, with deduplication so the same position doesn't trigger a message every minute.
  • Move to a database and scheduled workers before expanding the watchlist or running multiple server instances.
  • Explore the Aave V3 Liquidation Tracker to build a dashboard for completed liquidations.

Frequently Asked Questions​

Why use onBehalfOf instead of the transaction sender?

The Borrow event identifies the debt owner in onBehalfOf. The transaction sender can be a router, smart account, or another account borrowing through credit delegation.

Does a health factor below 1.1 mean a position can be liquidated?

No. This example uses 1.1 as a configurable reporting threshold. Aave positions can become eligible for liquidation below a health factor of one, subject to the protocol rules. The monitor does not execute liquidations.

Does the historical replay return a historical health factor?

No. The Stream discovers the borrower from a historical event, then the worker reads current finalized account state. Each sample records its own block and observation time.

Does this monitor every Aave borrower?

No. It discovers addresses from Borrow events in the configured range and stores up to 20 unique borrowers. Broader coverage needs a deliberate backfill and a storage and worker design that can handle the resulting watchlist.

What happens if the same webhook is delivered twice?

The server normalizes and deduplicates borrower addresses before saving them. Repeated discovery does not append duplicates or replace a newer health sample. A failed storage operation returns a non-success response so Streams can retry.