Aave Position Watcher: Health Factor Alerts

Printing health factor in a terminal is useful until the terminal closes or the RPC stops answering. I want the watcher to tell me when the position needs attention and when its own reads are failing.

This version tries a fallback RPC and sends a webhook when health factor crosses a threshold. It can notify me; I still have to arrange the repayment or collateral deposit.

The one on-chain read that matters

Aave’s Pool is the main contract for supply, borrowing, repayment, and liquidation. The Pool’s getUserAccountData(address) view function returns aggregated collateral, debt, available borrowing power, liquidation threshold, LTV, and health factor for a wallet. Aave V3 Pool reference

The watcher uses that one read instead of rebuilding health-factor math from individual token balances and price feeds. That reduces the number of moving parts and means the alert uses the same aggregate value that Aave exposes to integrators.

flowchart LR
    W["Watcher process"] -->|"eth_call"| R1["Primary RPC"]
    R1 -->|"getUserAccountData"| P["Aave V3 Pool"]
    W -->|"on RPC failure"| R2["Fallback RPC"]
    W -->|"threshold transition only"| H["Webhook endpoint"]
    W -->|"no signer or key"| L["Local log"]

The Pool returns base-currency figures with the market’s configured decimals, while healthFactor is scaled by 1e18. A value below 1.0 is liquidatable; that is far too late for an alert threshold. I use 1.50 as a warning and 1.25 as critical for a small learning position. Those are operating thresholds, not universal Aave settings. A correlated stablecoin eMode position may need a different buffer, and a volatile collateral position may need more.

A read-only Aave keeper script

The script uses Ethers v6 over HTTPS. Ethers documents JsonRpcProvider as its common HTTP/HTTPS JSON-RPC provider. Ethers JSON-RPC provider reference

Install the only dependency in a scratch directory or scripts repository:

npm install ethers@^6

Save this as watch-aave-position.mjs:

import { Contract, JsonRpcProvider, getAddress } from "ethers";

const CHAIN_ID = 42161n; // Arbitrum One
const WALLET = getAddress(requiredEnv("WALLET"));
const POOL = getAddress(requiredEnv("AAVE_POOL"));
const RPC_URLS = requiredEnv("RPC_URLS")
  .split(",")
  .map((url) => url.trim())
  .filter(Boolean);

const WARNING_HF = Number(process.env.WARNING_HF ?? "1.50");
const CRITICAL_HF = Number(process.env.CRITICAL_HF ?? "1.25");
const POLL_SECONDS = Number(process.env.POLL_SECONDS ?? "60");
const WEBHOOK_URL = process.env.WEBHOOK_URL;

const POOL_ABI = [
  "function getUserAccountData(address user) view returns (uint256 totalCollateralBase, uint256 totalDebtBase, uint256 availableBorrowsBase, uint256 currentLiquidationThreshold, uint256 ltv, uint256 healthFactor)",
];

if (!(CRITICAL_HF > 1 && WARNING_HF > CRITICAL_HF && POLL_SECONDS >= 15)) {
  throw new Error("Use WARNING_HF > CRITICAL_HF > 1 and POLL_SECONDS >= 15");
}

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required environment variable: ${name}`);
  return value;
}

function sleep(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

function severityFor(healthFactor, debtBase) {
  if (debtBase === 0n) return "no-debt";
  if (healthFactor < CRITICAL_HF) return "critical";
  if (healthFactor < WARNING_HF) return "warning";
  return "ok";
}

async function readPosition() {
  const failures = [];

  for (const url of RPC_URLS) {
    try {
      const provider = new JsonRpcProvider(url);
      const network = await provider.getNetwork();
      if (network.chainId !== CHAIN_ID) {
        throw new Error(`expected chain ${CHAIN_ID}, got ${network.chainId}`);
      }

      const pool = new Contract(POOL, POOL_ABI, provider);
      const block = await provider.getBlockNumber();
      const data = await pool.getUserAccountData(WALLET);

      return {
        block,
        rpc: new URL(url).host,
        collateralBase: data.totalCollateralBase,
        debtBase: data.totalDebtBase,
        availableBorrowsBase: data.availableBorrowsBase,
        healthFactor: Number(data.healthFactor) / 1e18,
      };
    } catch (error) {
      failures.push(`${new URL(url).host}: ${error.message}`);
    }
  }

  throw new Error(`All RPC reads failed: ${failures.join(" | ")}`);
}

async function notify(snapshot, severity) {
  const message = {
    severity,
    chainId: CHAIN_ID.toString(),
    wallet: WALLET,
    pool: POOL,
    healthFactor: snapshot.healthFactor.toFixed(4),
    collateralBase: snapshot.collateralBase.toString(),
    debtBase: snapshot.debtBase.toString(),
    block: snapshot.block,
    rpc: snapshot.rpc,
    observedAt: new Date().toISOString(),
  };

  console.log(JSON.stringify(message));
  if (!WEBHOOK_URL) return;

  const response = await fetch(WEBHOOK_URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(message),
  });
  if (!response.ok) throw new Error(`Webhook returned HTTP ${response.status}`);
}

async function main() {
  let previousSeverity = null;

  while (true) {
    try {
      const snapshot = await readPosition();
      const severity = severityFor(snapshot.healthFactor, snapshot.debtBase);

      // Log every successful read, but alert only when the state changes.
      console.log(
        `[${new Date().toISOString()}] ${severity} HF=${snapshot.healthFactor.toFixed(4)} block=${snapshot.block} rpc=${snapshot.rpc}`,
      );

      if (["warning", "critical"].includes(severity) && severity !== previousSeverity) {
        await notify(snapshot, severity);
      }
      if (severity === "ok" && ["warning", "critical"].includes(previousSeverity)) {
        await notify(snapshot, "recovered");
      }
      previousSeverity = severity;
    } catch (error) {
      console.error(`[${new Date().toISOString()}] watcher error: ${error.message}`);
    }

    await sleep(POLL_SECONDS * 1_000);
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Set the configuration in the shell where the watcher will run. AAVE_POOL must be the Pool proxy for the exact Aave V3 market and chain; do not copy a Pool address from a different network. Aave recommends consuming its address book as a Solidity or JavaScript package, which is a better source than a blog post or an old config file. Aave deployed-contract addresses

export WALLET=0xYourWalletAddress
export AAVE_POOL=0xYourAaveV3PoolAddress
export RPC_URLS=https://your-primary-rpc.example,https://your-fallback-rpc.example
export WEBHOOK_URL=https://your-alert-receiver.example/hooks/aave

node watch-aave-position.mjs

The webhook receives a small JSON object with the health factor, raw base-currency collateral and debt, block number, RPC hostname, and observation time. I keep the payload generic so it can go to a tiny receiver I control. If you use Slack, Discord, Telegram, or another alerting service directly, adapt notify() to that service’s documented payload format and keep its secret URL out of the repository.

What an automatic repayment would add

A keeper is simply an off-chain process that observes state and takes a predefined action when conditions are met. This one has the useful parts of a keeper—repeated reads, explicit thresholds, fallback infrastructure, state-transition alerts, and logs—but its only action is notification.

That distinction is intentional. Aave’s Pool allows liquidation only once a position’s health factor is below one, and liquidators need funds plus an approval to cover debt. Aave liquidation-call reference Adding automatic repayment or a flash-loan deleverage path would add a signer, gas estimation, transaction replacement, slippage limits, and failure recovery. Each of those needs a separate test before a watcher can safely submit transactions.

Thresholds and alert behavior I would use

The thresholds need to leave time for the entire recovery path: receive the alert, verify it, move funds if necessary, submit a transaction, and wait for inclusion. A 60-second interval is reasonable for a sub-$1k learning position; it costs no gas because all reads are eth_call-style RPC requests, but it is not a promise that every position change will be noticed within one minute.

StateHealth factorWatcher behaviorMy response
No debtn/aLog onlyConfirm that the wallet and Pool are correct.
OK≥ 1.50Log onlyKeep the process running.
Warning1.25–1.4999One webhook on entryCheck collateral price, debt, and current network conditions.
Critical1.0001–1.2499One webhook on entryPrepare to repay or add collateral; do not wait for another poll.
Liquidatable≤ 1.0Critical alertTreat it as an incident; liquidation may already be in progress.

The script suppresses repeat alerts while a state is unchanged and sends one recovered event when the state returns to OK. That stops a temporary warning from turning into 1,440 messages per day at a 60-second interval. A process restart resets that in-memory state, so a real deployment should persist the last severity in a small local database or accept one fresh alert after restart.

Risk analysis

Technical risk: a read can be stale, wrong, or unavailable

Two public RPC URLs are better than one but do not make the data infallible. Both may rate-limit, lag, share upstream infrastructure, or fail during a market event. The script checks the returned chain ID before each read and reports which endpoint answered, but it cannot prove that a node is current. Use providers with independent infrastructure for a position where minutes matter.

Technical risk: the address and market can be wrong

The script accepts a Pool address as configuration because Aave markets are chain-specific. A valid-looking address on the wrong chain can produce misleading output or an immediate failure. Verify AAVE_POOL, the wallet, and CHAIN_ID from the official address source, then do a dry run with a wallet that has no debt. The script intentionally throws if a provider reports a network other than Arbitrum One.

Economic risk: alerting is not protection

Health factor can fall faster than a person can react, especially when collateral is volatile or debt is large. Oracle updates, price gaps, debt interest, and a liquidator’s transaction all continue while an alert is travelling. Maintain a buffer that fits the collateral and have repayment funds already on the same chain. For this learning step, I would keep the position well below $1,000 and the debt small enough that a manual repay is routine.

Operational risk: the watcher can be off when it matters

An open laptop is not monitoring. Sleep, an expired terminal, a crashed process, a changed webhook URL, or a network outage can silently stop alerts. Run it on infrastructure you check, add an external heartbeat if the position becomes meaningful, and test the webhook with a non-sensitive sample payload before relying on it.

Security risk: webhooks and logs leak position metadata

The wallet address, health factor, collateral, debt, and webhook URL are sensitive operational data. Do not put real values in shell history, screenshots, public repositories, or a shared logging service. A read-only watcher has no signing key to drain, but exposing a webhook can still let someone spam or observe your alerts.

Test the alert and the manual repayment path

The useful upgrade from a terminal loop is not more complex math. It is making failure modes explicit: verify the chain, try another RPC, alert on a transition, and preserve enough context to investigate the reading.

Start with a read-only watcher and one small position. Test the warning path by temporarily setting WARNING_HF above the current health factor, confirm that the receiver sees the payload, then restore a real threshold. Do not test the critical path by intentionally approaching liquidation.

The watcher is useful only if I receive the alert and can act on it. I would rehearse a manual repayment and check the receiver before leaving it running.