Why Compound V2's Oracle Returns Zero (and That's a Security Feature)
When the oracle returns zero, every borrow and liquidation in that market reverts. That is not a bug. It is the safest possible failure mode, and the alternative cost Mango $114M and Cream $130M.
TL;DR
- In Compound V2, if the price oracle returns
0for an asset, every borrow and liquidation in that market reverts. - That is not a bug. It is the safest possible failure mode for a lending protocol that depends on accurate prices.
- The alternative, "fail open with a default value", killed Mango Markets ($114M, 2022) and Cream Finance ($130M, 2021). Both protocols accepted manipulated or stale prices and processed operations against them.
- A
0return is the oracle saying: I do not have a confident price right now. The protocol's job is to refuse to act on that, not to substitute a default or a last-known value. - When auditing or building a Compound V2 fork, the oracle's failure mode is one of the highest-leverage things to verify before deployment.
Why this matters
Lending protocols live or die by price oracles. Every solvency check, every borrow approval, every liquidation eligibility hinges on knowing how much the collateral is worth right now relative to the debt.
The temptation when designing an oracle is to always return something. Always return a number, even if you have to use a stale value, an estimate, or a last-known good price. That feels safer because the protocol stays "live". The price feed never goes down.
It is the opposite of safer. It is the most dangerous oracle design choice in DeFi.
When you accept a price you are not confident in, you are letting an attacker pick the moment to act. They wait for the oracle to drift, manipulate a thin venue, or exploit a price feed staleness window, and they extract value at the precise moment your oracle is wrong.
Compound V2's oracle pattern flips this. The oracle is allowed to say "I don't know". When it does, the protocol stops, not the price feed.
The mechanism
In Comptroller.sol, every solvency calculation reaches for the price like this:
underlyingPrice = oracle.getUnderlyingPrice(cToken);
if (underlyingPrice == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
// ...continue with normal solvency math
A zero from the oracle propagates as Error.PRICE_ERROR back to the caller. The caller (which is borrowAllowed, redeemAllowed, liquidateBorrowAllowed, or one of the other comptroller hooks) returns the error to the cToken contract, which reverts the transaction.
Effect: while the oracle is returning zero for any market, no one can borrow against that market, redeem from it, or liquidate positions in it. Existing positions stay frozen. New activity halts.
That is the entire mechanism. There is no fallback price. There is no last-known-good cache the protocol falls back to. There is no admin override. Zero means stop.
What "zero" really means
Compound's price oracle interface is deliberately simple:
interface PriceOracle {
function getUnderlyingPrice(CToken cToken) external view returns (uint);
}
A uint return type means the only way to signal "no price available" is the value 0. The protocol agreed: oracles return 0 to indicate failure. Zero prices are not real prices. No legitimate token has a market price of zero (assets at literal zero are off-market and uneconomic to interact with; a lending protocol does not need to support borrowing against them).
This convention is what allows the comptroller to treat zero as a signal rather than a value. Different oracle implementations sitting behind the interface (Compound's UniswapAnchoredView, fork-specific Chainlink wrappers, custom on-chain TWAP feeds) all share the same convention: zero means abort.
The two failure modes that have cost the most money
There are two common ways oracle integrations go wrong, and Compound V2 designed against both.
Failure mode 1: stale prices. A Chainlink-style oracle has not updated for many hours. The protocol is still reading the cached value. An attacker has positioned themselves to benefit from the staleness window.
Cream Finance's $130M loss in October 2021 had this flavor. The oracle reading on a key collateral asset was stale at the moment an attacker manipulated a thin spot venue and triggered a re-entry into Cream that priced collateral against the cached value while the attacker held the actual market.
The Compound V2 pattern protects against this because most oracle implementations check the staleness window themselves: if the cached price is more than X seconds old, return 0. The comptroller halts. The protocol does not transact against stale prices.
Forks routinely break this protection. They write their own oracle adapter that does not check staleness, or they pick a longer staleness window than canonical, or they interpret a stale read as "fall back to the previous value". Each of those choices reintroduces the failure mode Compound V2 designed against.
Failure mode 2: manipulated spot prices. An attacker manipulates the price source feeding the oracle, the oracle dutifully reports the manipulated price, and the protocol acts on it.
Mango Markets in October 2022 lost $114M when an attacker pumped MNGO on thin DEX venues, used the inflated MNGO as collateral inside Mango's lending product, and borrowed out the rest of the protocol against it. The oracle "worked" in the sense that it returned a price; the price was just not safe to act on.
Compound V2's design does not directly prevent this (an oracle that confidently reports a manipulated price will still flow through). But it gives the operator a kill switch: if the oracle is configured to return 0 when the underlying price source crosses a sanity threshold (deviation from a TWAP, deviation from a reference price), the protocol halts before the bad operations execute.
The forks that survive an oracle event are the ones that built sanity bands into their oracle contract. The ones that did not, did not.
What forks change and why it breaks
Three modifications recur across Compound V2 forks and each has shipped as a vulnerability:
1. Substituting a default for a zero. Some forks read from the oracle, see a zero, and replace it with a hardcoded default ("if price comes back zero, use $1"). This was framed as resilience. It removes the kill switch. The protocol now over- or under-collateralizes positions in markets where the oracle has failed, and an attacker who can engineer a zero-return on the oracle can engineer a known price for their attack.
2. Reading from the oracle after it's been deprecated. A fork upgrades to a new oracle contract but leaves a code path that reads from the old one. The old oracle is no longer maintained, returns stale or zero values, and the code path that reads it bypasses the protection because that code path didn't get the comptroller's check applied.
3. Trusting the oracle's mantissa convention. Compound's oracle convention is 1e18-scaled price-per-underlying-unit, adjusted for the underlying's decimals(). Forks integrating a non-canonical oracle (Chainlink natively returns 1e8-scaled prices, for instance) sometimes mis-handle the mantissa. The result is not necessarily zero; it is an off-by-1e10 price. Same security implication as failure mode 2.
The Canto v2 audit findings in Shadow Arena include exactly this class. H-03 was a price oracle returning 1 (a raw value) where the calling code expected 1e18 (a mantissa-scaled value). 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.
Designing your oracle to fail closed
If you are building or auditing a Compound V2 fork, the oracle should fail closed by default. Concretely:
- Always return zero on staleness. Pick a maximum age, return zero past that age, no exceptions. Even five minutes of grace creates a windowed exploit surface.
- Always return zero on circuit-breaker conditions. If the price has moved more than X% in Y minutes, return zero. The protocol halts; an admin investigates.
- Never silently substitute a default. If you cannot get a confident price, do not pretend you can. Zero is a feature; a substitute is a bug.
- Document the mantissa convention. Every oracle integration has a mantissa contract with the comptroller. Write it down, test it with explicit mantissa-aware tests, and don't let a fork drift from it.
- Test the failure mode. The failure mode is the security property. The unit test that proves "oracle returns zero -> all comptroller-routed operations revert" is more valuable than the unit test that proves "oracle returns the right number". Both should exist.
Related questions
What does the Compound V2 oracle return for a delisted asset? Zero, by convention. The protocol then halts borrows, redeems, and liquidations for that market. Existing positions remain frozen until the asset is re-listed (or the market is sunset by governance).
Does this design slow the protocol down? Only when the oracle cannot produce a confident price. In normal operation, the protocol runs at full speed. The "fail closed" property only fires when something is wrong.
Why not just use a circuit breaker that halts the whole protocol? Compound V2 prefers per-market halts because oracle failures are usually asset-specific. A staleness in the BAT/USD feed should not halt USDC/USD. The zero-return-per-asset approach gives you per-market granularity for free.
Is there a way to override the oracle? In canonical Compound V2, the comptroller admin can replace the oracle contract via _setPriceOracle(). That is the override mechanism. There is no "trust this stale price for now" override at the operation level, by design.
Does Aave do the same thing? Aave's oracle pattern also halts operations on price failures, but the implementation is different (Aave has explicit AaveOracle and FallbackOracle contracts with different fallback rules). The principle is the same: an oracle that says "I don't know" must halt the protocol, not get bypassed.
Where to see it in the source
The relevant code lives in Comptroller.sol::getHypotheticalAccountLiquidityInternal() and the various XxxAllowed() hooks. The check is roughly the same in every hook: read the oracle, abort on zero.
In the Zealynx Academy Compound V2 module, you build the price oracle and its comptroller integration in section 8. The test suite specifically checks that zero prices halt borrows, redeems, and liquidations. You cannot pass with a fail-open implementation. By the time you finish that section, you understand why "return zero" is the most secure thing an oracle can do.
Tagged