Our own post on how evmquery resolves a contract read named two guides on this blog that show only the evmquery call, with no hand-rolled equivalent published next to it: the Aave V3 health-factor guide and the Uniswap V3 pool-data guide. It said leaving evmquery for either one means writing the raw multicall yourself. This post writes it, for the Aave one, so that claim isn’t just a promise.
TL;DR
The evmquery expression behind the Aave V3 health-factor guide is 6 lines. The raw viem equivalent, decimals and the uint256 max edge case handled correctly, is 33 lines for a single wallet and adds a multicall block for a wallet list. Both were run live against the same contract on 2026-08-31; the numbers below aren’t estimated.
What this migration actually costs
- Single-wallet health factor: 6 lines of evmquery CEL vs. 33 lines of viem (ABI fragment,
readContractcall, decimal formatting,uint256max guard). - Multi-wallet batching: evmquery’s
wallets.map(...)needs zero extra code; viem needs a second code path built onclient.multicall, non-optional if you want one round trip instead of one per wallet. - The full 6-field ABI fragment for
getUserAccountDatais 12 lines and, once written, never changes for this function, since Aave hasn’t touched the signature since V3 launched. - Both code paths in this post were executed live on 2026-08-31: evmquery against the hosted API, viem against a public Ethereum RPC, same wallet, same contract, three blocks apart.
The expression this post is replacing
The Aave health-factor guide reads all six fields of getUserAccountData in one evmquery call:
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))})Re-run live for this post, against the same Aave V3 Pool on Ethereum (0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2), it still returns exactly what the decimals table in that guide says it should:
{ "healthFactor": "1.157920892373162e+59", "totalCollateralBase": "0", "totalDebtBase": "0", "availableBorrowsBase": "0", "currentLiquidationThreshold": "0", "ltv": "0"}Block 25,872,779, 1 on-chain call, 1 round, 2 units. The wallet queried has no open Aave position right now, so every base-currency field is zero and healthFactor lands on the special type(uint256).max case the guide already explains. That’s a real, current result, not a canned example, and it’s a useful one for this post specifically, because it’s the exact case a hand-rolled migration is most likely to get wrong.
The raw viem equivalent
Here’s the full replacement: the ABI fragment for getUserAccountData, a readContract call, and decimal formatting for all six fields.
import { createPublicClient, http, formatUnits, type Abi } from "viem";import { mainnet } from "viem/chains";
const client = createPublicClient({ chain: mainnet, transport: http() });
const AAVE_POOL: `0x${string}` = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const poolAbi = [ { name: "getUserAccountData", type: "function", stateMutability: "view", inputs: [{ name: "user", type: "address" }], outputs: [ { name: "totalCollateralBase", type: "uint256" }, { name: "totalDebtBase", type: "uint256" }, { name: "availableBorrowsBase", type: "uint256" }, { name: "currentLiquidationThreshold", type: "uint256" }, { name: "ltv", type: "uint256" }, { name: "healthFactor", type: "uint256" }, ], },] as const satisfies Abi;
const UINT256_MAX = 2n ** 256n - 1n;
function formatHealthFactor(raw: bigint): string { if (raw === UINT256_MAX) return "∞ (no active debt)"; return formatUnits(raw, 18);}
async function getHealthFactor(wallet: `0x${string}`) { const [ totalCollateralBase, totalDebtBase, availableBorrowsBase, currentLiquidationThreshold, ltv, healthFactor, ] = await client.readContract({ address: AAVE_POOL, abi: poolAbi, functionName: "getUserAccountData", args: [wallet], });
return { totalCollateralBase: formatUnits(totalCollateralBase, 8), totalDebtBase: formatUnits(totalDebtBase, 8), availableBorrowsBase: formatUnits(availableBorrowsBase, 8), currentLiquidationThreshold: formatUnits(currentLiquidationThreshold, 4), ltv: formatUnits(ltv, 4), healthFactor: formatHealthFactor(healthFactor), };}33 lines, 29 non-blank, count them in the block above. Run against the same wallet, against Ethereum mainnet, live for this post:
{ totalCollateralBase: '0', totalDebtBase: '0', availableBorrowsBase: '0', currentLiquidationThreshold: '0', ltv: '0', healthFactor: '∞ (no active debt)'}Block 25,872,795, three blocks after the evmquery call above (the two ran a few seconds apart against different infrastructure, not simultaneously; neither side is favored by the gap). No proxy resolution step shows up in this code, and that’s not an oversight: getUserAccountData is called against the proxy’s own address either way, evmquery’s or viem’s, because the EVM’s delegatecall handles the indirection at the protocol level. What the resolution step buys you, on evmquery’s side, isn’t correctness here; it’s not having to already know Aave’s Pool ABI in the first place. That is copy-pasted from Aave’s own public IPool.sol interface above, the same thing describe_schema would have handed back if you’d asked evmquery for it instead.
The trap: uint256 max, not a sentinel you’ll guess
The line that makes or breaks this migration is formatHealthFactor’s guard against 2n ** 256n - 1n. Skip it, and formatUnits(healthFactor, 18) on a zero-debt wallet returns the string "115792089237316195423570985008687907853269984665640564039457584007913129639935" formatted at 18 decimals, a 59-digit number, rendered straight to whatever UI reads this function’s output. Nothing throws. Nothing looks obviously wrong in a type checker or a unit test built around a “normal” wallet. It just quietly ships a 59-digit health factor to production the first time a real user with an open, undrawn credit line loads the page.
evmquery doesn’t remove this trap by magic, either; the Aave guide documents the exact same type(uint256).max behavior and tells you to compare against a sane ceiling before rendering. What migrating off evmquery changes is where that knowledge has to live: inside evmquery, in the sense that reading the guide once is enough to write formatHealthFactor correctly forever after; in raw viem, in the sense that every fresh integration with this contract has to know to write that guard, or find this guide, before it ships.
Batching a wallet list
The Aave guide’s second example scans a watchlist in one round trip with CEL’s map macro. The viem equivalent is client.multicall, and it’s a second code path, not a parameter change:
async function getHealthFactors(wallets: readonly `0x${string}`[]) { const results = await client.multicall({ contracts: wallets.map((wallet) => ({ address: AAVE_POOL, abi: poolAbi, functionName: "getUserAccountData", args: [wallet], })), allowFailure: false, });
return results.map(([, , , , , healthFactor]: (typeof results)[number]) => formatHealthFactor(healthFactor), );}Run live against the same wallet plus a second address, both currently without an open Aave position:
[ '∞ (no active debt)', '∞ (no active debt)' ]client.multicall defaults to the canonical Multicall3 deployment on any chain that has one, Ethereum included, so this is one on-chain round trip regardless of list length, same as evmquery’s .map(). The evmquery side of this comparison needed one extra word, list<sol_address> instead of sol_address, in a type declaration. The viem side needed an entirely separate function, because readContract and multicall are different client methods with different return shapes, and the tuple destructuring has to happen inside a .map() callback instead of being inline.
What this actually proves
Both code blocks above are complete and were run against live infrastructure while writing this post, not sketched from memory. That’s the same standard the evmquery vs. raw viem benchmark holds itself to, and the comparison lands in a similar place: for a well-documented function on a contract this widely integrated, the code you’d write by hand isn’t exotic. It’s 33 lines instead of 6, plus a second function for batching, and every line of it is public information: Aave’s own interface file, the standard type(uint256).max sentinel pattern, and viem’s own multicall action.
What’s genuinely different, and worth being honest about since this is a trust-pool post making a portability claim: this comparison was cheap to write because the Aave guide had already done the hard part, working out which of the six fields uses which of three decimal scales, and documenting the uint256 max case, before this post ever touched viem. That knowledge came from reading Aave’s Solidity source and testing against a live zero-debt wallet, not from evmquery’s resolver. A less-documented contract, one without a blog post like that guide already sitting on the internet, would cost you that research too, on top of the 33 lines above. That’s the part evmquery is actually selling: not the six lines of CEL, but not needing to have written the guide yourself first.
If you’re weighing this trade-off for your own developer stack, the honest version is: for a read this well-trodden, the exit cost is real but small. For a contract nobody’s written the decimals table for yet, the exit cost is whatever it takes to write that table.
Next steps
- Aave V3 health factor explained: the decimals table and
uint256max explanation this post’s viem code depends on. - How evmquery resolves a contract read: the post that named this gap and covers portability in general.
- evmquery vs. raw viem benchmark: the same hand-rolled-vs-evmquery comparison, run against three differently-proxied contracts instead of one.
- Multicall3 guide: the batching mechanics behind both
client.multicallabove and evmquery’s own round-trip collapsing.



