This is the message the script sends when a position gets close to liquidation:
Liquidation risk: Aave V3 health factor 1.245wallet 0xFa8d...3531It arrives once, when the position crosses the line. If the health factor stays low for three days you do not get 864 more of them, and when it climbs back you get one message saying so. The script reads Aave V3, Compound V3 and Morpho Blue for the same wallet in a single request and runs from cron.
TL;DR
One evmquery request returns the Aave health factor, the Morpho health factor
and Compound’s collateral flag for a wallet. A short Python script compares
each result with the last status saved in state.json and calls Telegram only
when a status changes.
Prerequisites
- Python 3.10 or newer and
pip install requests - An evmquery API key from the free tier, saved as
EVMQUERY_API_KEYin a.envfile next to the script - A Telegram bot token from @BotFather, saved as
TELEGRAM_BOT_TOKENin the same.env - The chat id the bot should write to, saved as
TELEGRAM_CHAT_IDin the same.env. Send your bot a message, then openhttps://api.telegram.org/bot<token>/getUpdatesand copymessage.chat.id. - A wallet that has a borrow position somewhere. The listings use a public address that had open positions on Aave and Morpho when this was written. Replace it with your own.
Deciding when to alert
Polling a health factor is the easy half. The part that decides whether you keep the bot or mute it after a day is deciding when a message is worth sending. The rule used here: every protocol has a status, ok or alert, stored in a file. A message goes out only when the status changes.
Two thresholds keep the status from flapping. The script moves to alert below a health factor of 1.25 and returns to ok only at 1.35 or above. With a single threshold, a position hovering around 1.25 would alert and recover every few runs as prices tick. The gap between the two numbers is the cheapest fix for that.
I am not re-deriving the health factor formulas here. Aave V3, Compound V3 and Morpho Blue each have a post on what the fields mean. If you only need a simple threshold check against one protocol, blockchain monitoring in Python covers that with Slack. This post is about three protocols and the memory between runs.
Reading three protocols in one request
The three protocols do not agree on what “health” is. Aave returns a number, Morpho returns raw shares you have to turn into a number, and Comet returns a boolean. The request below normalizes that into one small map, all on Ethereum:
import jsonimport osfrom pathlib import Path
import requests
WALLET = "0xFa8d2f7dB0842691af4bEa55628A2bbB59e73531"ALERT_BELOW = 1.25RECOVER_ABOVE = 1.35STATE_FILE = Path(__file__).with_name("state.json")
MORPHO_MARKET = "0xc54d7acf14de29e0e5527cabd7a576506870346a78a11a6762e2cca66322ec41"MORPHO_LLTV = 0.945
EXPRESSION = f"""{{ "aave": dyn(formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)), "compound_ok": dyn(comet.isBorrowCollateralized(wallet)), "morpho": dyn(cel.bind(id, hexBytes("{MORPHO_MARKET}"), cel.bind(pos, morpho.position(id, wallet), cel.bind(m, morpho.market(id), pos.borrowShares == 0 ? 1e30 : formatUnits(pos.collateral, 18) * formatUnits(oracle.price(), 36) * {MORPHO_LLTV} / formatUnits(pos.borrowShares * m.totalBorrowAssets / m.totalBorrowShares, 18) ))))}}"""evmquery takes named contracts and a CEL expression. Aave’s getUserAccountData is one call and the health factor is the sixth field, formatted from 18 decimals. For Comet, I use isBorrowCollateralized instead of isLiquidatable. The first flips to false before the second, because Comet checks borrowing against a stricter collateral factor than liquidation. That gives you a warning while there is still time to repay, at the cost of alerting on positions that are not yet liquidatable. Swap in isLiquidatable if you only want the last-moment alert.
Morpho is the long expression. It reads position and market for the market id, takes the borrower’s share of totalBorrowAssets, and divides collateral value at the oracle price, times the LLTV, by that debt. The market here is wstETH collateral against WETH, which is why both sides use 18 decimals and the LLTV of 0.945 is hard-coded. Another market means a different id, oracle address, LLTV and possibly different decimals. A wallet with no borrow shares gets 1e30, an arbitrary “no debt” value that sits far above any threshold, the same idea as the huge number Aave returns for a debt-free wallet.
Everything is on evm_ethereum, so this is one request and one batch of on-chain reads. Watching the same wallet on Base means a second request with that chain’s addresses, listed in the Morpho and Comet posts.
def read_positions() -> dict: resp = requests.post( "https://api.evmquery.com/api/v1/query", headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]}, json={ "chain": "evm_ethereum", "schema": { "contracts": { "aave": {"address": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"}, "comet": {"address": "0xc3d688B66703497DAA19211EEdff47f25384cdc3"}, "morpho": {"address": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb"}, "oracle": {"address": "0x2a01EB9496094dA03c4E364Def50f5aD1280AD72"}, }, "context": {"wallet": "sol_address"}, }, "context": {"wallet": WALLET}, "expression": EXPRESSION, }, timeout=20, ) resp.raise_for_status() return resp.json()["result"]["value"]Wrap each value in dyn() so one map can carry a float and a boolean together. The doubled curly braces in the earlier listing are Python’s f-string escaping, so the expression arrives with single ones.
Sending the message and remembering what you sent
def send_telegram(text: str) -> None: token = os.environ["TELEGRAM_BOT_TOKEN"] resp = requests.post( f"https://api.telegram.org/bot{token}/sendMessage", json={"chat_id": os.environ["TELEGRAM_CHAT_ID"], "text": text}, timeout=10, ) resp.raise_for_status()
def next_status(previous: str, health_factor: float) -> str: if previous == "ok" and health_factor < ALERT_BELOW: return "alert" if previous == "alert" and health_factor >= RECOVER_ABOVE: return "ok" return previoussend_telegram is the Telegram Bot API’s sendMessage and nothing else. next_status is the whole hysteresis logic: from ok it needs the low number to flip, from alert it needs the high number.
def main() -> None: state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {} values = read_positions() compound_ok = values["compound_ok"] checks = { "aave": ( next_status(state.get("aave", "ok"), values["aave"]), f"Aave V3 health factor {values['aave']:.4g}", ), "morpho": ( next_status(state.get("morpho", "ok"), values["morpho"]), f"Morpho Blue health factor {values['morpho']:.4g}", ), "compound": ( "ok" if compound_ok else "alert", f"Compound V3 USDC market, borrow-collateralized: {'yes' if compound_ok else 'no'}", ), }
for name, (current, description) in checks.items(): if current != state.get(name, "ok"): prefix = "Liquidation risk" if current == "alert" else "Recovered" send_telegram(f"{prefix}: {description}\nwallet {WALLET[:6]}...{WALLET[-4:]}") state[name] = current STATE_FILE.write_text(json.dumps(state))
if __name__ == "__main__": main()Two details in main matter. The message is sent first and the state file is written afterwards, inside the loop, once per protocol. If Telegram fails, raise_for_status() stops the script before the state changes, so the message is retried on the next run and earlier protocols are already recorded. And a missing key defaults to ok, so a freshly installed bot reports a position that is already in danger on its first run.
Running it
Put the three listings in one file called alert.py, put the four variables in .env as plain KEY=value lines, load them with set -a; . ./.env; set +a and run python3 alert.py. Printing the values that the request returned shows what the script saw (these are from when this was written):
{'aave': 1.2454213, 'compound_ok': True, 'morpho': 1.8813907}Aave sits at 1.245, under the 1.25 threshold, so the first run sends the message from the top of this post and writes {"aave": "alert", "morpho": "ok", "compound": "ok"} to state.json. Run it again and nothing is sent. To see the recovery path without waiting for a market move, I lowered RECOVER_ABOVE to 1.2 in a test and ran it once more:
Recovered: Aave V3 health factor 1.245wallet 0xFa8d...3531I tested the script in a separate file with send_telegram replaced by print, so I could watch the transitions without a bot. The same trick works if you want to try your own wallet before wiring up Telegram.
Scheduling with cron
Add this with crontab -e, using the interpreter path from which python3:
*/5 * * * * cd /home/you/liquidation-alert && set -a; . ./.env; set +a; /usr/bin/python3 alert.py >> alert.log 2>&1Cron has no PATH or shell profile, so the line uses an absolute interpreter path and loads the variables itself. The cd makes .env and alert.log resolve; STATE_FILE is found from the script’s own location. Five minutes is a reasonable cadence for health factors that move with prices. For other ways to run a read like this on a schedule, see evmquery for automation.
Or skip the cron: run it as a monitor
The Aave check can also run as an evmquery monitor instead of a cron job. Pass the wallet as a context value and compare the health factor against the alert threshold:
formatUnits(aave.getUserAccountData(wallet).healthFactor, 18) < 1.25Use a condition trigger. It fires when the expression goes from false to true and stays quiet while it remains true. For the 1.35 recovery step, add a second monitor with formatUnits(aave.getUserAccountData(wallet).healthFactor, 18) >= 1.35. Your send_telegram code becomes the webhook receiver. evmquery retries failed deliveries and pauses the monitor with an alert after repeated errors. The Comet check works as a bool condition with !comet.isBorrowCollateralized(wallet); the Morpho expression needs its own monitor.
Edge cases in the alert logic
Zero debt is a huge number, not a null. Aave returns about 1.16e59 for a wallet with collateral and no borrows, and it is a legitimate answer. It compares fine against 1.25. It only becomes a problem if you format it for a message, which is why the script uses :.4g instead of :.3f.
Comet’s flag has no hysteresis. It is a boolean, so there is no 1.25 to 1.35 band for it. It can flip back and forth near the boundary. If that is noisy for you, switch to the Aave-style ratio built in the Comet post and run it through next_status.
Next steps
- Blockchain monitoring in Python with evmquery: the simpler threshold polling loop with Slack alerts
- Aave V3 health factor explained: what the six
getUserAccountDatafields mean - Morpho Blue health factor and liquidation price: how to find market ids and the oracle for other markets
- Aave health factor checker: the same read as a free web tool
- Monitors: run the health factor condition on a schedule and receive it as an email or a webhook that your bot can forward to Telegram


