Section 2 of 18
Fixed-Point Math Library
What You Are Building
Every interest rate and exchange rate calculation in Compound V2 uses fixed-point arithmetic. Solidity has no floating-point numbers. You cannot write 0.05 or 1.5. The ExponentialNoError contract solves this by representing all fractional values as integers scaled by 1e18. A "mantissa" of 1e18 means 1.0. A mantissa of 5e17 means 0.5. A mantissa of 1.05e18 means 1.05.
This is the first contract you build because every other contract in the protocol inherits from it. Get this wrong and every calculation downstream is broken.
Why Fixed-Point Math Exists
Solidity's integer division truncates. 3 / 2 equals 1, not 1.5. For a lending protocol that calculates interest rates like 0.0000000001 per block, this truncation would destroy all precision. The interest rate would round to zero, and nobody would earn anything.
The solution is to scale everything up by a large factor before doing math, then scale back down when you need the integer result. Compound uses 1e18 as the scale factor, matching the 18-decimal precision of most ERC-20 tokens. This is the same approach used by Uniswap V2's UQ112x112 fixed-point format, but Compound uses a simpler struct-based design instead of bit-packed encoding.
The Exp and Double Structs
Compound wraps its fixed-point values in structs to make the type system work for you:
struct Exp {
uint256 mantissa;
}
struct Double {
uint256 mantissa;
}
Both structs hold a single uint256 mantissa. The Exp type is for standard 18-decimal fixed-point values. The Double type is used for intermediate calculations where you need to avoid precision loss during multi-step operations. Wrapping in a struct means the compiler will not let you accidentally add an Exp to a raw uint256 without going through the proper scaling functions.
How mul_ and div_ Handle Scaling
The core challenge of fixed-point math is keeping the scale factor correct through operations.
Multiplying two Exp values: If you have a = 1.5e18 and b = 2.0e18 and multiply them naively, you get 3.0e36. The result has doubled the scale factor. To fix this, you divide by expScale after multiplying:
function mul_(Exp memory a, Exp memory b) internal pure returns (Exp memory) {
return Exp({ mantissa: (a.mantissa * b.mantissa + halfExpScale) / expScale });
}
The halfExpScale addition provides banker's rounding (round to nearest instead of always rounding down). Without it, systematic rounding errors would accumulate over thousands of interest accrual operations, slowly leaking value from the protocol.
Multiplying a uint by an Exp: When you compute 100 * 0.5 (100 tokens at a 50% rate), the uint has no scale factor and the Exp has 1e18. The result should be a uint (50). So you multiply, then divide by expScale:
function mul_(uint256 a, Exp memory b) internal pure returns (uint256) {
return (a * b.mantissa + halfExpScale) / expScale;
}
Dividing two Exp values: Division is the inverse problem. a / b where both are scaled by 1e18 would cancel the scale factors, giving an unscaled result. To keep the result as an Exp, you multiply a by expScale before dividing:
function div_(Exp memory a, Exp memory b) internal pure returns (Exp memory) {
return Exp({ mantissa: (a.mantissa * expScale + b.mantissa / 2) / b.mantissa });
}
Again, b.mantissa / 2 is added for rounding to nearest.
The truncate Function
When you need the integer part of a fixed-point value (discarding the fractional part), truncate divides by expScale:
function truncate(Exp memory exp) internal pure returns (uint256) {
return exp.mantissa / expScale;
}
This is used when converting exchange rates back to token amounts. If the exchange rate is 0.02e18 and you have 50,000 cTokens, the underlying value is 50000 * 0.02e18 / 1e18 = 1000 tokens.
Your Task
Build the ExponentialNoError contract. The starter code provides the struct definitions, constants, and function signatures.
- Implement
truncateto convert an Exp to its integer part - Implement
mul_ScalarTruncateto multiply then truncate in one step - Implement
mul_ScalarTruncateAddUIntto multiply, truncate, then add - Implement all four
mul_overloads (ExpExp, Expuint, uintExp, uintuint) - Implement all three
div_overloads (Exp/Exp, Exp/uint, uint/Exp) - Implement
add_andsub_for Exp values - Implement the comparison functions:
lessThanExp,lessThanOrEqualExp,greaterThanExp,isZeroExp
Pay attention to which overloads include rounding (halfExpScale) and which do not. The rule: rounding is needed when dividing by expScale or b.mantissa (operations that truncate). Plain addition, subtraction, and uint*uint do not need rounding.