All articles
The BuildMay 16, 20267 min read

accountLiquidity(): Where Compound V2 Audit Findings Cluster

Almost every critical bug in a Compound V2 fork lives in or around accountLiquidity(). Why this single function attracts oracle, exchange-rate, mantissa, and overflow risks all at once.

By Carlos (Bloqarl)

TL;DR

  • Almost every critical finding in a Compound V2 audit lives in or around accountLiquidity() (sometimes called getAccountLiquidity() in forks).
  • The reason is structural: it is the single solvency check every state-changing operation routes through. Borrow, redeem, transfer of cTokens, and liquidation all branch on its output.
  • That makes it a magnet for at least five distinct bug classes: wrong oracle prices, stale exchange rates, missing markets in the iteration loop, integer overflow in the multiplication chain, and mantissa mismatches between collateral and debt.
  • Compound V2 forks (Flux Finance, Canto v2, Venus, and many more) inherit all five risks as a baseline and tend to introduce new ones whenever they modify the function.
  • If you are auditing or rebuilding a Compound V2 fork, this is the function to read first, not last.

Why this matters

Compound V2 is one of the most copied codebases in DeFi. Three of the six audit targets in the Zealynx Academy Shadow Arena are Compound V2 forks: Flux Finance, Canto v2, and Venus. The pattern repeats across BSC, Optimism, Arbitrum, and Avalanche.

Forks rarely fail because of a novel attack. They fail because forking a complex codebase without understanding it concentrates risk into the load-bearing functions, and accountLiquidity() is the most load-bearing function in the entire system. Every borrower's open position, every redeem decision, every liquidation eligibility check, all of it goes through this one solvency calculator.

When an auditor opens a Compound V2 fork for the first time, this is where they go. When a hacker opens a Compound V2 fork for the first time, this is where they go too.

What the function actually does

accountLiquidity() answers a single question: given an account's current cToken positions across all entered markets, are they solvent enough to borrow more, or do they need to be liquidated?

The math, in pseudocode:

for each market the account has entered:
    cTokenBalance = cToken.balanceOf(account)
    borrowBalance = cToken.borrowBalanceStored(account)
    exchangeRate  = cToken.exchangeRateStored()
    underlyingPrice = oracle.getUnderlyingPrice(cToken)
    collateralFactor = markets[cToken].collateralFactorMantissa

    // collateral side
    tokensToDenom = collateralFactor * exchangeRate * underlyingPrice
    sumCollateral += cTokenBalance * tokensToDenom

    // debt side
    sumBorrowPlusEffects += borrowBalance * underlyingPrice

return (sumCollateral - sumBorrowPlusEffects)

The output is a single signed number. Positive: account has remaining borrow capacity. Negative: account is underwater and can be liquidated.

That is the function. It is not long. It does not look dangerous. And almost every critical Compound V2 finding ever published is somewhere in those eight lines.

Bug class 1: wrong oracle price

The function multiplies every cToken balance by oracle.getUnderlyingPrice(cToken). If that price is wrong, every solvency check is wrong.

Mango Markets lost $114M in 2022 when an attacker pumped the spot price of MNGO on a thin DEX, used the inflated price as collateral inside Mango's lending product, and borrowed the rest of the protocol against it. Cream Finance lost $130M in a similar pattern with re-entry into a deflated oracle reading.

In a Compound V2 fork, this looks like:

underlyingPrice = oracle.getUnderlyingPrice(cToken);
// what if the oracle is a single-block spot price feed?
// what if the underlying token has a uint8 decimals field
// the oracle didn't normalize for?
// what if the oracle returns 1 (raw) when it should return 1e18?

The Canto v2 audit findings in Shadow Arena include exactly this: H-03 was a price oracle returning 1 (raw) where the calling code expected 1e18 (mantissa-normalized). Solvency calculations broke for one specific market, leaving the protocol open to either over-borrowing or under-liquidation depending on which side of the mantissa the math fell.

Bug class 2: stale exchange rate

cToken exchange rates change every block as interest accrues. accountLiquidity() calls exchangeRateStored(), which reads the cached rate, not the current one.

If a fork exposes a code path that lets liquidity decisions execute without first calling accrueInterest(), the stored exchange rate is stale. A solvent borrower may appear underwater (false liquidation) or an underwater borrower may appear solvent (missed liquidation, protocol absorbs the bad debt).

The fix in canonical Compound V2 is the accrueInterest() modifier on every entry point. Forks routinely skip this on one or two entry points, often the ones added for protocol-specific features. Those new entry points become the attack surface.

Bug class 3: missing markets in the iteration loop

The function iterates over accountAssets[account], the list of markets the account has explicitly entered. The protocol relies on the invariant that this list contains every market where the account has either collateral or borrow.

When forks add new market types (Canto v2 added cNote, a stablecoin-collateralized cToken; Flux Finance added CASH from Ondo with KYC restrictions), they sometimes forget to update the entry/exit hooks that maintain accountAssets. The result: an account can have collateral or borrow in a market that does not appear in their iteration, and accountLiquidity() returns a value that ignores it.

This bug is hard to spot in a code review because it is a bug of omission. The function looks correct. The market enumeration looks correct. The bug is in the seam between the new market type and the existing accounting hooks.

Bug class 4: integer overflow in the multiplication chain

The collateral side of the math chains four multiplications:

collateralFactor * exchangeRate * underlyingPrice * cTokenBalance

Each multiplicand has a different mantissa convention. collateralFactor is 1e18-scaled. exchangeRate for cTokens is also 1e18-scaled but adjusts for underlying decimals. underlyingPrice is 1e18-scaled but adjusts for the underlying token's decimals(). cTokenBalance is in raw cToken units (8 decimals in canonical Compound).

In canonical Compound this works out, because the function uses Compound's Exp library to chain mul_ operations with controlled mantissa accounting. Forks that simplify the math (replacing Exp with raw *, or migrating to Solidity 0.8 without unchecked blocks) often break the precision in subtle ways. Sometimes overflow. Sometimes truncation. Always wrong.

Bug class 5: mantissa mismatch between collateral and debt

The borrow side of the math is simpler: borrowBalance * underlyingPrice. The collateral side is heavier. If a fork's borrow accounting changed (rate model swapped, indexing convention changed) but the liquidity calculator was not updated to match, the two sides drift.

A drift of 1e10 (a missing mantissa shift) means an account that should have $100 of solvency room actually has $100 * 1e10. They can borrow billions against pennies of collateral. This has shipped to production more than once. Each time the post-mortem cites "we modified the rate model and didn't fully trace the implications".

The Canto v2 H-04 finding is exactly this class: a per-year vs per-block confusion in the rate model that propagated into the solvency math. The team caught it before deployment, but only because the audit forced them to walk through accountLiquidity() line by line.

Why this clusters here specifically

Five different categories of bug, all touching the same eight lines of code. The reason is that accountLiquidity() is the system's choke point: every decision about user solvency goes through it, so every assumption the protocol makes about prices, rates, balances, and units gets tested at this function.

Test coverage helps but does not fix this. You can have 100% line coverage on accountLiquidity() and still ship a mantissa bug, because the bug is not in the code, it is in the agreement between this function and another function the audit didn't read.

The mitigation is structural: when reviewing a Compound V2 fork, treat accountLiquidity() as an integration test for the rest of the system. If you understand every term in the math, you understand the protocol. If you don't, you don't.

Related questions

What is accountLiquidity() in Compound V2? The single solvency-check function in the Comptroller contract. It takes an account address and returns how much additional borrow capacity the account has, or how much it is underwater. Every borrow, redeem, transfer, and liquidation call routes through it.

Why does it touch the oracle directly? Because solvency is denominated in a unit (typically USD-scaled) that requires converting both collateral and debt out of their native token units. The oracle provides the conversion factor.

Can accountLiquidity() be view-only? In canonical Compound it reads from exchangeRateStored() and borrowBalanceStored(), which are view-only but possibly stale. The non-stale variants (exchangeRateCurrent(), borrowBalanceCurrent()) trigger accrueInterest() and are not view. Forks sometimes mix the two without thinking through the staleness implications.

Is this also true for Compound V3? No. V3 uses a single-borrow-asset architecture and a different solvency formula. This article is V2-specific.

Where to see it in the source

The canonical implementation is in Comptroller.sol::getHypotheticalAccountLiquidityInternal(), which getAccountLiquidity() and the various entry-point hooks call into. In the Zealynx Academy Compound V2 module, you implement it yourself in section 10. The test suite forces you to handle every one of the five bug classes above before it lets you pass.

That is the correct way to learn this function: implement it, get every test red, and turn each red test green by understanding what it is testing for. After that, you stop forgetting the mantissa shift.

Tagged

Compound V2Smart Contract SecurityLending