Canto v2's Oracle Mantissa Confusion: 1 vs 1e18
Canto v2's oracle uses a 1 mantissa where Compound V2 expects 1e18. The result: 18-decimal-place errors in liquidation math. A pattern that recurs in cross-chain forks.
TL;DR
- Canto v2 is a Compound V2 fork on the Canto chain, with the cNote stablecoin replacing cUSDC. It's one of six Shadow Arena audit targets (2,000 SLOC, intermediate, 10 documented findings).
- H-03: the oracle returns prices with a 1 mantissa (raw integer values) where Compound V2's math expects 1e18 (18-decimal-fixed-point). The collateral and liquidation calculations then run on values 18 decimal places off from intended.
- H-04 is a cousin: per-year vs per-block confusion in the rate model. Different mistake class (time units vs scaling), same root cause: porting Compound V2 across context without re-verifying assumptions.
- Pattern across our six Shadow Arena targets: every Compound V2 fork has at least one units/parameter mismatch carried over unchanged from Ethereum mainnet. Forks port code; they often don't port the assumptions the code relies on.
- The audit takeaway: when reviewing any Compound V2 fork, build a units checklist and walk through every parameter against it. The bug is almost always somewhere on the list.
Why this matters
Mantissa errors don't look dramatic on paper. "Off by 18 decimal places" sounds abstract. In production, an 18-decimal-place error in a liquidation calculation means:
- A borrower with $1,000 of collateral is treated as having $1e21 (a sextillion) of collateral.
- They can borrow against that fictional collateral indefinitely.
- The protocol issues debt that can never be liquidated because liquidation thresholds depend on the same broken oracle.
- Eventually someone notices, pauses the market, and lenders realize their deposits are uncollateralized.
This is not theoretical. Production lending protocol forks have shipped exactly this bug class. The Canto v2 case is in our Shadow Arena dataset because it was caught during the protocol's own audit, but similar bugs have made it past audits in other forks and ended up in production.
If you're auditing a Compound V2 fork (or operating one), the mantissa check is among the highest-yield single audit checks you can do.
What mantissa means in Compound V2
Compound V2 uses 18-decimal fixed-point arithmetic almost everywhere. A "mantissa" in their terminology is a uint256 value scaled by 1e18. Canonical examples:
closeFactorMantissa = 0.5e18represents the close factor 0.5 (50%).liquidationIncentiveMantissa = 1.08e18represents 1.08 (8% bonus).collateralFactorMantissafor an asset is its loan-to-value cap, scaled by 1e18.getUnderlyingPrice()returns price scaled by 1e18 times an asset-specific decimal adjustment.
The decimal adjustment is the part that trips forks up. Compound V2's PriceOracle::getUnderlyingPrice(cToken) returns:
price = (USD value of 1 unit of underlying) * 1e18 * 10^(18 - underlyingDecimals)
That nested formula does two things at once:
- Scale to 18 decimals: multiply by 1e18.
- Account for underlying decimals: multiply by
10^(18 - underlyingDecimals). For USDC (6 decimals), this multiplier is 1e12. For ETH (18 decimals), it's 1.
The combined factor for USDC is 1e18 * 1e12 = 1e30. So getUnderlyingPrice(cUSDC) returns USDC's USD price multiplied by 1e30. For ETH, the factor is just 1e18.
This is so that downstream math can multiply price by raw underlying balance (in native units) and get a value in 1e36-scaled USD, which it then divides back by 1e18 to land on 1e18-scaled USD. The factors cancel cleanly if the oracle returns the right scale.
If the oracle returns the price with mantissa 1 instead of 1e18 (or 1e30 for USDC), the cancellation fails. The downstream math runs on a number 18 to 30 decimal places off.
How the H-03 bug manifests
Canto v2's oracle implementation returned the underlying token's price with mantissa 1 (raw value, no scaling) instead of the Compound-expected mantissa 1e18 (or 1e18 * 10^(18 - decimals) for non-18-decimal tokens).
The function used by liquidation, liquidateCalculateSeizeTokens, computes:
seizeAmount = (repayAmount * priceBorrowed * liquidationIncentive)
/ (priceCollateral * exchangeRate)
If both priceBorrowed and priceCollateral are off by the same factor of 1e18, the factors cancel in the division, and seizeAmount looks correct. So liquidation between two assets with the same broken oracle scaling appears to work.
The bug emerges in accountLiquidity, which sums collateral values across markets and compares them to debt:
accountLiquidity = sum(collateralValue) - sum(debtValue)
collateralValue is cTokenBalance * exchangeRate * collateralFactor * priceCollateral / 1e18^N. With a broken price scale, the value is off by 18 to 30 decimal places, which means:
- If the bug undershoots: every position looks insolvent. Borrows are blocked. Liquidations fire on healthy positions.
- If the bug overshoots: every position looks healthy. Underwater borrowers can't be liquidated. Bad debt accumulates.
In Canto v2's specific case, the direction of the error depended on which oracle was being queried. Some markets had the bug; others didn't. The mixed scaling meant liquidation between affected and unaffected markets produced wildly wrong seizeAmount values, which liquidators discovered and exploited (favorable side) or got rejected on (unfavorable side).
The cousin: H-04 per-year vs per-block
Canto v2 H-04 is a different bug in the same fork, with the same root cause. The interest rate model uses per-block rates internally (Compound V2's pattern), computed from per-year rates by dividing by blocksPerYear.
The Canto v2 fork stored a per-year rate in the variable that the math expected to be per-block. Result: interest accrues blocksPerYear times faster than intended. With Canto's blocksPerYear ≈ 5,256,000 (assuming 6-second blocks), a 5% APR market behaved like a 26,280,000% APR market.
This is a different mistake class than the mantissa bug (time units vs scaling), but the same fork inheritance pattern: parameter assumed compatible across context, when it wasn't.
The Canto v2 fork is the clearest single example of the pattern. We've documented similar issues in the Venus blocksPerYear article. The bug class repeats anywhere a Compound V2 fork crosses chain or token-decimal boundaries.
Why this keeps happening
Compound V2's parameter system is implicit. Many of its constants encode assumptions about Ethereum (15-second blocks → 2,102,400 blocks per year), about USD-quoted oracles (price returned with 1e18 mantissa), about 18-decimal token defaults (decimal adjustment factor of 1).
When you fork Compound V2, you inherit the code that uses these assumptions. You don't inherit the list of assumptions to verify. The assumptions live in:
- Comments scattered throughout the source.
- The Compound V2 whitepaper, which not all fork teams read.
- Tribal knowledge of Compound's engineers and early auditors.
- Discord threads from 2020.
Without an explicit checklist, fork teams discover the assumptions by hitting them in production. That's the wrong time.
Audit checklist for Compound V2 forks
Built from the bugs we've documented across our 6 Shadow Arena targets (3 of which are Compound V2 forks: Flux Finance, Canto v2, Venus). When auditing or operating a fork, work through this list in order:
1. blocksPerYear matches the deployment chain
For each market's interest rate model, confirm blocksPerYear is computed as (seconds in a year) / (deployment chain block time). Examples:
- Ethereum (~12-15s blocks): 2,102,400 (Compound V2's value).
- BSC (3s blocks): 10,512,000.
- Polygon (2s blocks): 15,768,000.
- Optimism (2s blocks): 15,768,000.
- Arbitrum (variable, treat as 1s effective): 31,536,000 OR use a different rate-accrual scheme.
- Canto (6s blocks): 5,256,000.
If the deployed value is the Ethereum default but the chain isn't Ethereum, you've found Venus's bug.
2. Oracle price scale
For each price oracle, confirm:
- The return value is in 1e18-scaled USD.
- For tokens with non-18 decimals, the return value also includes the
10^(18 - decimals)adjustment factor. - The same scaling is used consistently across all markets.
If the oracle returns raw mantissa-1 prices, you've found Canto v2's H-03.
3. Per-year vs per-block units
For each rate parameter (baseRate, multiplier, jumpMultiplier), confirm:
- The stored value is per-block (per-year value divided by
blocksPerYear). - The annualization-by-comment matches the storage convention.
- No code path multiplies a per-year value by
blocksPerYearagain (the reverse mistake).
If the stored value is a per-year rate when per-block is expected, you've found Canto v2's H-04.
4. Mantissa width consistency
For each Mantissa-suffixed variable in the codebase, confirm the value is scaled by 1e18. Watch specifically for:
- Bounds checks that compare a 1e18-scaled value to a literal like
1or100(suggests author confused mantissa for percentage). - Multiplication chains that produce 1e36-scaled intermediates without dividing back to 1e18.
- Storage of computed mantissas without an explicit scaling step.
5. Token decimal handling
For each cToken, confirm the underlying's decimals are accounted for in:
- The exchange rate calculation.
- The oracle price scaling.
- Any direct balance-to-USD conversion.
A USDC market that treats USDC as 18-decimal will be off by a factor of 1e12 in every value calculation.
6. Cross-market interactions
For each function that touches multiple markets in one call (accountLiquidity, liquidateBorrow, etc.), confirm the unit-consistency holds across markets. If market A uses correct mantissa and market B uses broken mantissa, the cross-market math is wrong.
Related questions
What's the difference between mantissa and decimals? Decimals are how many fractional digits a token uses (USDC = 6, ETH = 18). Mantissa is the scaling factor in fixed-point arithmetic (Compound V2 uses 1e18). They interact: a mantissa-1e18 USD price for a 6-decimal token requires an additional 1e12 multiplier to reconcile units.
Could the bugs be caught by a generic Solidity static analyzer? No. Slither, Aderyn, and Mythril don't track unit semantics; they see all uint256 values as interchangeable. Mantissa errors are a domain-knowledge bug, not a code-pattern bug.
Did Canto v2's auditors catch H-03 and H-04? They were caught during the audit, which is why they're in our Shadow Arena dataset (which uses public audit reports as ground truth). The bugs were fixed pre-deployment. The pattern repeats in other forks that didn't have the same audit attention.
What's the fastest way to spot mantissa bugs in a fork? Run a small test: compute the USD value of 1 wei of each underlying token using the protocol's own helper functions. If the result is off by a power of 10, you have a mantissa bug. This single test catches most of the issues in this category.
Does Compound V3 have the same parameter risk? Compound V3 (Comet) is a much smaller protocol with explicit per-asset config and per-deployment constants. It's harder to misconfigure, but not impossible. Cross-chain Comet deployments have their own version of the chain-time-assumption issue.
Where to see this in Academy
Canto v2 is one of six Shadow Arena audit targets in Zealynx Academy. You can audit the same code with the bug list available, and practice the systematic checklist above. Two of its 10 findings (H-03 and H-04) come specifically from the unit-mismatch pattern; the other 8 cover access control, accumulator math, and Solidly-fork-style reward distribution.
If you've also audited Flux Finance and Venus (the other two Compound V2 forks in Shadow Arena), you'll start to see the pattern repeat: every fork has at least one units mismatch. The training value is recognizing the class so quickly that future forks you audit get this check first, before anything else.
When you rebuild Compound V2 yourself in Zealynx Academy, you implement the mantissa-aware math from scratch. The test suite catches mantissa errors as off-by-1e18 numerical mismatches, which makes the class viscerally clear in a way that reading Canto's code won't.
Tagged