Aave V3 Health Factor Explained: getUserAccountData, Decimals, and the Infinite Health Case

Decode Aave V3's getUserAccountData correctly: six struct fields, three decimal scales, and why a zero-debt wallet returns a healthFactor near 1.16e59.

evmquery team··7 min read
Share
Aave V3 health factor — getUserAccountData fields, base-currency decimals, and the infinite health case

Aave V3’s getUserAccountData(user) looks like a single, simple read. It returns six uint256 values in one call, no follow-up requests needed. The catch: those six values are not scaled the same way. Format all of them with the same decimals and you either display garbage or trigger a false liquidation alert the first time you hit a wallet with no debt.

TL;DR

Aave V3’s getUserAccountData returns three fields in 8-decimal base currency, two in 4-decimal ratio form, and healthFactor in an 18-decimal wad. A wallet with zero debt returns healthFactor as 2^256 / 1e18 (~1.16e59), not an error, not zero, not null.

The six fields getUserAccountData returns

Every Aave V3 Pool contract exposes this view function, and it is the single call behind every health-factor dashboard, liquidation bot, and risk widget built on the protocol:

function getUserAccountData(address user) external view returns (
uint256 totalCollateralBase,
uint256 totalDebtBase,
uint256 availableBorrowsBase,
uint256 currentLiquidationThreshold,
uint256 ltv,
uint256 healthFactor
);
Field Meaning Decimals
totalCollateralBase Total deposited collateral, valued in the market’s base currency 8
totalDebtBase Total borrowed debt, same base currency 8
availableBorrowsBase How much more the wallet could borrow before hitting its LTV limit 8
currentLiquidationThreshold Weighted average liquidation threshold across the wallet’s collateral 4
ltv Weighted average max loan-to-value across the wallet’s collateral 4
healthFactor Safety margin: (collateral * liquidationThreshold) / debt 18

Format every field with formatUnits(value, 18) and totalCollateralBase reads as a number ten billion times too small. This is the single most common mistake in DIY Aave integrations, and it is entirely a decimals problem, not a data problem.

Three decimal scales in one struct

Aave V3 mixes three different fixed-point conventions in one return value:

  1. 8-decimal base currency (totalCollateralBase, totalDebtBase, availableBorrowsBase). The “base currency” is whatever the market’s price oracle denominates prices in, USD on Ethereum, Base, and BNB Chain today, at the same 8 decimals Chainlink USD price feeds use. This is a deliberate choice: Aave’s oracle already returns prices at 8 decimals, so the accounting layer inherits that scale instead of converting to 18-decimal wei.
  2. 4-decimal ratios (currentLiquidationThreshold, ltv). These are basis-point-style percentages: a raw value of 7800 means 78.00%. Divide by 10000, or use formatUnits(value, 4) to get 0.78.
  3. 18-decimal wad (healthFactor). This is the one familiar convention, the same scale as an ERC-20 token with 18 decimals. formatUnits(value, 18) gives you the number you actually compare against 1.0.

To make the scaling concrete, here is a worked example with round numbers (illustrative, not a live wallet):

Raw totalCollateralBase = 1_500_000_000_000 (uint256)
formatUnits(1_500_000_000_000, 8) = 15,000.00 // $15,000 of collateral
Raw currentLiquidationThreshold = 8000 (uint256)
formatUnits(8000, 4) = 0.80 // 80% liquidation threshold
Raw healthFactor = 1_920_000_000_000_000_000 (uint256)
formatUnits(1_920_000_000_000_000_000, 18) = 1.92 // safely above 1.0

This changed between Aave versions

Aave V2’s equivalent function returned totalCollateralETH and availableBorrowsETH, denominated in ETH at 18 decimals, because V2 markets priced everything against ETH. Aave V3 generalized this to a per-market “base currency” and switched those three fields to 8 decimals to match the oracle’s own price precision. Code ported from a V2 integration that blindly reuses 18 decimals for collateral and debt will under-report every wallet by a factor of 10^10. healthFactor itself has stayed an 18-decimal wad across both versions.

A health factor of 1.92 means the position could absorb roughly a 48% drop in collateral value (relative to debt) before crossing 1.0 and becoming eligible for liquidation. Anything under 1.0 is liquidatable right now; the 1.01.5 band is the zone worth polling closely.

The infinite health factor: zero debt, not zero risk

The edge case every integration eventually hits: a wallet with collateral deposited but no active borrows. Aave’s Solidity code computes healthFactor as (collateral * threshold) / debt, and division by a zero debt would normally revert. Aave avoids that by special-casing it: when totalDebtBase is zero, healthFactor returns type(uint256).max, the largest possible uint256, which is 2^256 - 1.

Formatted at 18 decimals, that constant becomes 2^256 / 1e18, approximately 1.16 × 10^59. Here is that exact case, queried live against the Aave V3 Pool on Ethereum mainnet:

{
"healthFactor": "1.157920892373162e+59",
"totalCollateralBase": "11379.62417936",
"totalDebtBase": "0",
"availableBorrowsBase": "8534.71813452",
"currentLiquidationThreshold": "0.78",
"ltv": "0.75"
}

That result is real, read from the live Aave V3 Pool contract (0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 on Ethereum) at block 25690712, for a wallet holding open collateral with no borrows against it. Nothing reverted, nothing errored. The 59-digit healthFactor is Aave’s honest answer to “how close is this position to liquidation,” when the answer is “there is no debt to liquidate against.”

Handle it explicitly

Don’t render the raw 59-digit number to users, and don’t treat it as an overflow bug. Compare against a sane ceiling (anything ~1e30 or higher is effectively infinite for display purposes) and show an infinity symbol or “no active debt” message instead. Comparing healthFactor < 1.5 still works correctly without any special-casing, since 1.16e59 is trivially above any real threshold.

Query it with evmquery

evmquery exposes Aave’s Solidity struct fields through dot notation, so a single expression reads whichever fields you need without a separate ABI import or six manual calls:

aave_pool.getUserAccountData(wallet).healthFactor

That expression, run against a wallet with an open zero-debt position, returns the same 1.157920892373162e+59 value shown above, live-validated on the current Ethereum mainnet deployment. To pull all six fields (scaled correctly) in one round trip, bind the struct once with cel.bind and format each field with its own decimal count:

import os
import requests
AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" # Aave V3 Pool, Ethereum
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_pool": {"address": AAVE_POOL}},
"context": {"wallet": "sol_address"},
},
"context": {"wallet": "0xYourWalletAddress"},
"expression": (
'cel.bind(d, aave_pool.getUserAccountData(wallet), {'
' "healthFactor": string(formatUnits(d.healthFactor, 18)),'
' "totalCollateralBase": string(formatUnits(d.totalCollateralBase, 8)),'
' "totalDebtBase": string(formatUnits(d.totalDebtBase, 8)),'
' "availableBorrowsBase": string(formatUnits(d.availableBorrowsBase, 8)),'
' "currentLiquidationThreshold": string(formatUnits(d.currentLiquidationThreshold, 4)),'
' "ltv": string(formatUnits(d.ltv, 4))'
" })"
),
},
timeout=10,
)
resp.raise_for_status()
print(resp.json()["result"]["value"])

cel.bind(d, aave_pool.getUserAccountData(wallet), {...}) calls the contract once and reuses the returned struct for every field, instead of six separate eth_calls. Each field gets string(formatUnits(...)) with its own decimal count, since CEL map literals require every value to share a type. Swap AAVE_POOL for 0xA238DD80C259a72e81d7e4664a9801593F98d1c5 on Base to run the identical query against Aave V3’s Base deployment.

If you’d rather not write the request by hand, evmquery’s Aave health factor checker runs this exact expression against any wallet and any of the three supported chains, and already handles the infinite-health display case described above.

Watch a whole list of wallets in one request

A liquidation bot rarely cares about a single wallet. The CEL map macro applies the same expression across a list of addresses in one HTTP round trip, instead of one request per wallet:

import os
import requests
AAVE_POOL = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2" # Aave V3 Pool, Ethereum
WATCHLIST = [
"0xWallet1...",
"0xWallet2...",
"0xWallet3...",
]
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_pool": {"address": AAVE_POOL}},
"context": {"wallets": "list<sol_address>"},
},
"context": {"wallets": WATCHLIST},
"expression": "wallets.map(w, formatUnits(aave_pool.getUserAccountData(w).healthFactor, 18))",
},
timeout=10,
)
resp.raise_for_status()
factors = resp.json()["result"]["value"] # one healthFactor per wallet, same order as WATCHLIST

This is a live-validated pattern: run against two real addresses, it returns a list<double> with one healthFactor per input wallet, in order, including the ~1.16e59 constant for any zero-debt wallet in the list, so cap or filter it before it distorts a lowest-health-factor calculation. The type declaration matters here, "list<sol_address>", not "sol_address", since the runtime value is an array. Getting that singular/plural distinction wrong produces a type error at query time rather than at build time.

If you’re wiring this into a scheduled job rather than a one-off script, see evmquery for automation for running the same expression on a timer instead of babysitting a cron job by hand.

Next steps

Share

Read a health factor without the ABI

Free tier, no credit card. Grab a key and run the query below against any wallet.