Skip to content

Compound V3 (Comet) Liquidation Risk in Raw viem: What Leaving evmquery Actually Costs

The hand-rolled viem code behind evmquery's Compound V3 Comet liquidation-risk read: borrow balance, collateral, the two risk booleans, and the price-feed dependency evmquery hides.

evmquery team8 min read
Try it: Aave Health Factor CheckerHealth factor, collateral, debt and LTV on Ethereum, Base, Polygon. Free, no signup.

The Compound V3 (Comet) liquidation-risk guide on this blog reads a position with one evmquery expression: borrow balance, collateral, and the two risk booleans, batched into a single request. This post writes the raw viem equivalent, line by line, run live against the same market, to find out what the migration actually costs — and it surfaces a dependency evmquery’s one-request model quietly absorbs that a hand-rolled multicall batch cannot.

TL;DR

The core position read (borrowBalanceOf, userCollateral, isBorrowCollateralized, isLiquidatable) is a six-line cel.bind expression in evmquery vs. roughly 90 lines of viem once the full Comet ABI fragment is written out. The bigger cost isn’t line count: computing an actual liquidation buffer needs getAssetInfoByAddress to return a price feed address before getPrice can be called against it — a data dependency client.multicall cannot batch into one round trip, while evmquery’s cel.bind resolves it inside a single HTTP request.

What this migration actually costs

  • The position read (balance, collateral, both risk booleans) is one cel.bind expression in evmquery vs. a five-call client.multicall plus a roughly 90-line ABI fragment in viem.

  • The liquidation-buffer read (getAssetInfoByAddress → getPrice) is a genuine data dependency: the price feed address isn’t known until the first call returns, so client.multicall can’t batch both into one round trip the way it batches the five independent position reads. evmquery’s cel.bind resolves the same dependency server-side in a single request.

  • Both code paths in this post ran live against the same Compound V3 USDC market on Ethereum on 2026-11-09, about two minutes apart, and returned identical values: borrowCollateralFactor 0.825, liquidateCollateralFactor 0.88, and a WETH price of $2,140.61711931 to the last digit.

  • evmquery’s .map() macro over a watchlist of wallets returns a flat meta: null with units.consumed: 1 regardless of list length — a known quirk of this blog’s evmquery dialect, not a bug in the query.

The expression this post is replacing

The Compound V3 guide reads a wallet’s position against the USDC Comet market with one cel.bind expression:

cel.bind(weth, solAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), {
"borrowBalanceUSDC": dyn(formatUnits(comet.borrowBalanceOf(wallet), comet.decimals())),
"wethCollateral": dyn(formatUnits(comet.userCollateral(wallet, weth).balance, 18)),
"isCollateralized": dyn(comet.isBorrowCollateralized(wallet)),
"isLiquidatable": dyn(comet.isLiquidatable(wallet))
})

Run live for this post against a wallet with no Compound position:

{
"result": {
"value": {
"borrowBalanceUSDC": 0,
"wethCollateral": 0,
"isCollateralized": true,
"isLiquidatable": false
},
"type": "map<string, dyn>"
},
"meta": { "blockNumber": "26374835", "totalCalls": 4, "totalRounds": 1 },
"units": { "consumed": 5 }
}

Block 26,374,835, four on-chain calls, one round trip. Zero debt reads as trivially collateralized and never liquidatable — the same “no debt, not an error” shape the Aave health factor guide documents for healthFactor, just as booleans here instead of a 59-digit uint256.

The raw viem equivalent: five calls, one ABI fragment

Comet’s position-read surface needs seven ABI entries to cover everything the evmquery expression above touches, plus the two still to come. userCollateral is Solidity’s auto-generated getter for a mapping(address => mapping(address => UserCollateral)), where UserCollateral is { uint128 balance; uint128 _reserved; } — the getter returns both fields positionally, which is why the ABI below declares two outputs instead of one struct.

import { createPublicClient, http, type Abi } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({ chain: mainnet, transport: http() });
const COMET_ABI = [
{
name: "borrowBalanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
{
name: "userCollateral",
type: "function",
stateMutability: "view",
inputs: [
{ name: "", type: "address" },
{ name: "", type: "address" },
],
outputs: [
{ name: "balance", type: "uint128" },
{ name: "_reserved", type: "uint128" },
],
},
{
name: "isBorrowCollateralized",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "bool" }],
},
{
name: "isLiquidatable",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "bool" }],
},
{
name: "decimals",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "uint8" }],
},
] as const satisfies Abi;
const COMET_USDC: `0x${string}` = "0xc3d688B66703497DAA19211EEdff47f25384cdc3";
const WETH: `0x${string}` = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
async function readPosition(wallet: `0x${string}`) {
const [
borrowBalance,
collateral,
isCollateralized,
isLiquidatable,
decimals,
] = await client.multicall({
contracts: [
{
address: COMET_USDC,
abi: COMET_ABI,
functionName: "borrowBalanceOf",
args: [wallet],
},
{
address: COMET_USDC,
abi: COMET_ABI,
functionName: "userCollateral",
args: [wallet, WETH],
},
{
address: COMET_USDC,
abi: COMET_ABI,
functionName: "isBorrowCollateralized",
args: [wallet],
},
{
address: COMET_USDC,
abi: COMET_ABI,
functionName: "isLiquidatable",
args: [wallet],
},
{ address: COMET_USDC, abi: COMET_ABI, functionName: "decimals" },
],
allowFailure: false,
});
return {
borrowBalanceUSDC: Number(borrowBalance) / 10 ** decimals,
wethCollateral: Number(collateral[0]) / 1e18,
isCollateralized,
isLiquidatable,
};
}

Run live against the same wallet, about two minutes after the evmquery call, at block 26,374,847:

{
"borrowBalanceUSDC": 0,
"wethCollateral": 0,
"isCollateralized": true,
"isLiquidatable": false
}

Identical values, 11 blocks later, on Ethereum’s roughly 12-second block time — the same “no state changed in two minutes” result the Aave migration post sees for an idle wallet. client.multicall resolves the canonical Multicall3 deployment on Ethereum automatically, the same one-round-trip property evmquery’s resolver has by default.

The part a line count doesn’t capture: the price-feed dependency

The guide’s second query computes an actual liquidation buffer instead of a yes/no, by combining three values: WETH’s collateral factors and its USD price, both read off the Comet contract itself.

cel.bind(info, comet.getAssetInfoByAddress(solAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2")), {
"borrowCollateralFactor": dyn(formatUnits(info.borrowCollateralFactor, 18)),
"liquidateCollateralFactor": dyn(formatUnits(info.liquidateCollateralFactor, 18)),
"wethPriceUsd": dyn(formatUnits(comet.getPrice(info.priceFeed), 8))
})

Run live:

{
"borrowCollateralFactor": 0.825,
"liquidateCollateralFactor": 0.88,
"priceFeed": "0xc0053f3fbccd593758258334dfce24c2a9a673ad",
"wethPriceUsd": 2140.61711931
}

Look at what that expression actually does: getPrice takes info.priceFeed as its argument, and info is the struct getAssetInfoByAddress just returned. The price feed’s address isn’t a constant you can hardcode next to COMET_USDC and WETH above — it’s data the contract hands back, and the very next call depends on it. evmquery’s cel.bind resolves that dependency inside one HTTP request. client.multicall has no equivalent: a multicall batch is a flat list of independent calls sent together, not a pipeline where one call’s output feeds the next, so the two reads below are two sequential round trips, not one batched one.

const COMET_ABI_EXT = [
...COMET_ABI,
{
name: "getAssetInfoByAddress",
type: "function",
stateMutability: "view",
inputs: [{ name: "asset", type: "address" }],
outputs: [
{
name: "",
type: "tuple",
components: [
{ name: "offset", type: "uint8" },
{ name: "asset", type: "address" },
{ name: "priceFeed", type: "address" },
{ name: "scale", type: "uint64" },
{ name: "borrowCollateralFactor", type: "uint64" },
{ name: "liquidateCollateralFactor", type: "uint64" },
{ name: "liquidationFactor", type: "uint64" },
{ name: "supplyCap", type: "uint128" },
],
},
],
},
{
name: "getPrice",
type: "function",
stateMutability: "view",
inputs: [{ name: "priceFeed", type: "address" }],
outputs: [{ name: "", type: "uint256" }],
},
] as const satisfies Abi;
async function readLiquidationBuffer(collateralAsset: `0x${string}`) {
// getPrice needs the priceFeed address getAssetInfoByAddress returns —
// a second, sequential round trip, not a second entry in the same multicall.
const assetInfo = await client.readContract({
address: COMET_USDC,
abi: COMET_ABI_EXT,
functionName: "getAssetInfoByAddress",
args: [collateralAsset],
});
const price = await client.readContract({
address: COMET_USDC,
abi: COMET_ABI_EXT,
functionName: "getPrice",
args: [assetInfo.priceFeed],
});
return {
borrowCollateralFactor: Number(assetInfo.borrowCollateralFactor) / 1e18,
liquidateCollateralFactor:
Number(assetInfo.liquidateCollateralFactor) / 1e18,
priceUsd: Number(price) / 1e8,
};
}

Run live, immediately after the evmquery call above:

borrowCollateralFactor 0.825
liquidateCollateralFactor 0.88
priceFeed 0xc0053f3FBcCD593758258334Dfce24C2A9A673aD
wethPriceUsd 2140.61711931

Every digit matches the evmquery result, including the price feed address once case-normalized — the strongest kind of parity check this blog’s migration posts run: two independent implementations, two different RPC paths, landing on the exact same values. The difference isn’t correctness, it’s shape. evmquery’s single request hides a sequential dependency that the raw viem version makes an unavoidable second network call. For a one-off dashboard read that’s an extra few dozen milliseconds. For a liquidation monitor polling many collateral assets across many markets on a schedule, it’s the difference between one request per asset and two — see evmquery for automation for the polling-job side of that trade-off.

Don't try to flatten this with Promise.all either

Promise.all([client.readContract(assetInfo), ...]) doesn’t help here — Promise.all runs independent promises concurrently, it can’t start a call that needs a value from a promise that hasn’t resolved yet. The sequential dependency is structural, not a missed optimization; the only way to remove it in viem is to already know the price feed address ahead of time and hardcode it, which works until Compound re-points a market at a different feed.

Batching a watchlist: where multicall still wins outright

Not every Comet read has this problem. Checking isLiquidatable across a list of wallets is embarrassingly parallel — no wallet’s result depends on another’s — so this is exactly the shape client.multicall was built for, matching evmquery’s list<sol_address>.map() one-for-one.

async function readWatchlist(wallets: `0x${string}`[]) {
return client.multicall({
contracts: wallets.map((wallet) => ({
address: COMET_USDC,
abi: COMET_ABI,
functionName: "isLiquidatable" as const,
args: [wallet] as const,
})),
allowFailure: false,
});
}

Run live against two real addresses, both sides return [false, false] for the same watchlist — evmquery’s wallets.map(w, comet.isLiquidatable(w)) and viem’s client.multicall collapse to the identical one-round-trip shape here, because there’s no data dependency between calls to hide. The price-feed case above is the exception, not the rule.

What this actually proves

Three Comet reads, three different migration costs: the position read is a line-count difference and nothing more, the watchlist read is a wash, and the liquidation-buffer read is the one place evmquery’s single-request model does something a naive client.multicall port genuinely cannot replicate without either an extra round trip or a hardcoded address. That’s a more honest comparison than “evmquery is N lines shorter,” which is true but incomplete — the evmquery vs. raw viem benchmark makes the same point across differently-proxied contracts, and this post’s price-feed dependency is a sharper, protocol-specific version of it.

None of this makes the raw viem version wrong. A developer who already knows Comet’s ABI can write readPosition and readWatchlist above in twenty minutes, and readLiquidationBuffer in thirty once they notice the dependency. What they’re trading away is evmquery resolving that dependency for them inside one HTTP call, and not needing to remember which of their reads are safe to flatten into a multicall and which one has a hidden sequential edge.

Next steps

Share

Skip the migration, for now

The three queries benchmarked in this post are live. Get a free key and run them against a real Comet market before you decide whether to hand-roll it.