MODULE 00 Architecture & why V3
The single-base-asset model, isolated collateral, account-level accounting — and the V2 problem each one solves.
The one-sentence mental model
A Compound V3 market (called a Comet) is a pool of one borrowable asset — the base asset (e.g. USDC) — that lenders supply and borrowers take out against a basket of isolated, non-yielding collateral assets. That's it. Everything else is bookkeeping on top of that single idea.
In V2, every asset was simultaneously suppliable, borrowable, and usable as
collateral, all sharing one risk pool through cTokens. A bug or price
manipulation in one long-tail asset could drain the whole protocol (this
is exactly how several V2-fork exploits happened). V3 deliberately gives up that
flexibility: collateral is isolated (it can never be borrowed, only seized), and
only the base asset earns/charges interest.
The four design pillars
| Pillar | V2 problem it fixes | Tradeoff introduced |
|---|---|---|
| Single base asset per market | Cross-asset contagion: any borrowable asset could sink the pool | Less capital-efficient; you need a separate market per base asset |
| Isolated collateral (no rehypothecation) | Collateral was lent out (cToken rehypothecation), so a collateral run could
cause insolvency |
Collateral earns zero yield — pure risk capital |
| Account-level accounting (signed principal) | Separate supply/borrow balances and dual interest accrual per user | One signed number per account; borrowing = a negative base balance |
| Immutable per-asset config (in bytecode) | Expensive storage reads on every risk check | Changing any parameter = redeploying the whole implementation |
Contract layout (reference implementation)
Comet.sol— the main implementation: supply / withdraw / borrow / absorb / buyCollateral, accrual, health checks. Sits behind aTransparentUpgradeableProxy.CometCore.sol— internal math/accounting primitives:presentValue*,principalValue*,mulFactor, shared constants.CometStorage.sol— the storage layout (structs & mappings).CometMath.sol— safe casts (signed104,unsigned104, …) for moving between signed/unsigned without silent overflow.CometMainInterface.sol/CometInterface.sol— external interface, events, custom errors (Paused,NotCollateralized,NotLiquidatable,BorrowTooSmall,SupplyCapExceeded, …).CometExt.sol— an extension delegate. BecauseCometis near the 24 KB EVM contract-size limit, some functions (ERC-20 metadata, allowances) aredelegatecalled toCometExtvia the fallback. So the "Comet you interact with" is really proxy → Comet implementation → (fallback) → CometExt.Configurator.sol+CometFactory.sol+CometProxyAdmin.sol— the upgrade/config pipeline. Per-asset config is compiled into the implementation asimmutablevariables, so changing a collateral factor means deploying a brand-newCometimplementation and re-pointing the proxy.CometRewards.sol— a separate contract for COMP distribution (Module 13).
- A Comet = one borrowable base asset + isolated, non-yielding collateral; collateral can be seized but never borrowed.
- Each account is a single signed base principal: positive = lender, negative = borrower. There is no separate borrow function.
- Per-asset risk parameters are
immutable(in bytecode) — cheap to read, but governance changes require an implementation redeploy.
- Why can a single bad collateral asset not drain the whole V3 market the way it could in V2?
- What is the capital-efficiency cost V3 pays for isolating collateral?
- If governance wants to raise WETH's borrow collateral factor, what literally has to happen on-chain?
Show answers
1) Collateral is never lent out or borrowable — it only sits as seizable backing — so its
failure caps losses at the positions it backs, not the base liquidity. 2) Collateral
earns no interest, so suppliers of collateral assets forgo yield (pure risk capital). 3)
A new Comet implementation is deployed via CometFactory with
the new immutable, and the Configurator/ProxyAdmin re-points
the proxy to it.
MODULE 01 Scales & helper math
The fixed-point constants and the three multiply helpers that every other formula is built on.
The scale constants
Solidity has no floats. Comet represents fractions as integers multiplied by a fixed scale. Get these wrong and every downstream number is off by orders of magnitude.
| Constant | Value | Meaning |
|---|---|---|
FACTOR_SCALE |
1e18 |
Collateral factors, liquidation factors, storeFrontPriceFactor — all "0…1"
ratios ×1e18 |
PRICE_SCALE |
1e8 |
Chainlink-style USD prices (8 decimals) |
BASE_INDEX_SCALE |
1e15 |
Scale of baseSupplyIndex / baseBorrowIndex |
baseScale |
10^(base decimals) |
e.g. 1e6 for USDC. Immutable per market |
TRACKING_INDEX_SCALE |
1e15 |
Scale of the COMP tracking indices |
SECONDS_PER_YEAR |
31,536,000 |
365 × 24 × 3600 — used for APR↔per-second conversions |
Helper 1 — mulFactor
n— any quantity (a USD value, a balance)f— a factor scaled by1e18(so 0.83 is stored as0.83e18)
It multiplies a number by a ratio that's stored ×1e18 and divides the scale back out. Floor division (Solidity default for unsigned).
Helper 2 — mulPrice / signedMulPrice
q— token balance in smallest unitsp— price ×1e8(USD per whole token)s— the token's scale,10^(decimals)
Result is a USD value scaled by 1e8. The signed variant keeps the sign of
q (needed for the negative base principal of a borrower).
Helper 3 — divBaseWei
Used inside reward-index updates (Module 13): it divides an emission by a pool size while
preserving baseScale precision.
WETH balance q = 10e18, price p = 3000e8, scale
s = 1e18:
Now apply a borrow collateral factor of 0.83 with mulFactor:
- Factors are ×
1e18, prices ×1e8, base indices ×1e15, the base token by its ownbaseScale. mulFactorapplies a ratio;mulPriceturns a token balance into a 1e8-scaled USD value;divBaseWeidivides while keeping base precision.- All three floor — that floor direction is the seed of every "rounds in the protocol's favor" behavior later.
- USDC has 6 decimals. What is
baseScaleand how is $1 of USDC represented? - Compute the 1e8-scaled USD value of
0.5e8WBTC (8 decimals) at $60,000. - Why does flooring in
mulFactoralways favor the protocol when applied to a borrower's collateral?
Show answers
1) baseScale = 1e6; $1 = 1e6 base units. 2)
mulPrice(0.5e8, 60000e8, 1e8) = 0.5e8·60000e8/1e8 = 3e12 = $30,000. 3)
Flooring a collateral×factor product can only reduce the credited value, never
inflate it — so the borrower never gets credited more backing than they truly have.
MODULE 02 Storage layout
Every packed global slot, the per-user struct, the collateral structs, and the immutables — with the bit-packing arithmetic.
The two packed global slots
Comet packs its hottest globals into two storage slots so a single SLOAD fetches
several values. The TotalsBasic struct:
struct TotalsBasic {
uint64 baseSupplyIndex; // ×1e15, grows with supply interest
uint64 baseBorrowIndex; // ×1e15, grows with borrow interest
uint64 trackingSupplyIndex; // ×1e15, COMP accrual for suppliers
uint64 trackingBorrowIndex; // ×1e15, COMP accrual for borrowers
uint104 totalSupplyBase; // total positive principal
uint104 totalBorrowBase; // total negative principal (stored positive)
uint40 lastAccrualTime; // unix seconds of last accrue()
uint8 pauseFlags; // bitfield of pause toggles
}
The four uint64 indices fit one slot (4×64 = 256 bits). The two uint104
totals + uint40 time + uint8 flags fit the next (104+104+40+8 = 256
bits). Exact packing — no wasted bits.
uint40 caps at 2^40 − 1 ≈ year 36812 in raw seconds, but
Comet's getNowInternal() casts block.timestamp to
uint40 and will overflow-revert past ~2106 if it ever exceeded — a
deliberate, harmless ceiling. (Common myth: people say uint40 = year 2106; the 2106 number
is the uint32 unix limit; uint40 is far larger but Comet documents the safety check anyway.)
The per-user struct — UserBasic
struct UserBasic {
int104 principal; // SIGNED: + = lender, − = borrower
uint64 baseTrackingIndex; // snapshot of trackingIndex at last update
uint64 baseTrackingAccrued; // COMP banked but unclaimed (6-dec scaled)
uint16 assetsIn; // bitmap: which collateral assets are enabled
uint8 _reserved;
}
The single int104 principal is the heart of V3: one signed number replaces V2's
separate supply and borrow balances. assetsIn is a 16-bit bitmap — bit
i set ⇔ the user holds collateral asset i — so health checks iterate
only the assets actually held.
Collateral structs
struct UserCollateral { uint128 balance; uint128 _reserved; }
struct TotalsCollateral { uint128 totalSupplyAsset; uint128 _reserved; }
struct LiquidatorPoints { uint32 numAbsorbs; uint64 numAbsorbed; uint128 approxSpend; uint32 _reserved; }
The mappings
mapping(address => UserBasic) userBasic— each account's signed base position.mapping(address => mapping(address => UserCollateral)) userCollateral— per-user, per-asset collateral.mapping(address => mapping(address => bool)) isAllowed— operator permissions (Module 8).mapping(address => LiquidatorPoints) liquidatorPoints— keeper stats.mapping(address => TotalsCollateral) totalsCollateral— protocol-wide per-asset collateral.
Immutables (baked into bytecode)
baseToken, baseTokenPriceFeed, baseScale,
numAssets, decimals, extensionDelegate (the
CometExt address), every rate-model parameter, baseTrackingSupplySpeed
/ baseTrackingBorrowSpeed, storeFrontPriceFactor,
targetReserves, baseBorrowMin, baseMinForRewards,
trackingIndexScale, and the packed asset configs read via
getAssetInfo(i) / getPackedAssetInternal. None of these cost an
SLOAD — they're constants in code.
- Two exactly-packed global slots hold the four indices, two totals, the accrual time and pause flags.
- Each user is one
int104signed principal plus reward bookkeeping and anassetsInbitmap; collateral lives in a separate per-asset mapping. - All risk parameters are immutables in bytecode — that's the structural reason config changes need a redeploy.
- How does a single
int104 principaltell you whether an account is a lender or a borrower? - Why is
assetsIna bitmap rather than an array, and what does it save? - Both
uint104totals +uint40+uint8fit one slot — verify the bit count.
Show answers
1) Sign of principal: > 0 lender, < 0
borrower, 0 neither. 2) A 16-bit bitmap lets health checks iterate only
enabled assets in one cheap word, avoiding dynamic-array storage and unbounded loops. 3)
104 + 104 + 40 + 8 = 256 bits = exactly one slot.
MODULE 03 Accounting primitives
principal ↔ present value, the asymmetric rounding, and why borrow debt rounds up.
The core idea
A user's stored principal is frozen at the index value when they last touched the
protocol. Their present value — what they actually owe or own right now — is recovered
by scaling that principal by how much the relevant index has grown since.
Present value (principal → present)
p— stored principal (unsigned magnitude),BASE_INDEX_SCALE = 1e15- The signed wrapper
presentValue(p)picks supply-index ifp ≥ 0, borrow-index ifp < 0
Principal value (present → principal) — the inverse, with a twist
Derivation of the ceiling trick
For positive integers, ⌈a/b⌉ = ⌊(a + b − 1)/b⌋. Proof: write a = qb + r
with 0 ≤ r < b. If r = 0, (a+b−1)/b = q + (b−1)/b
floors to q = a/b. If r > 0,
a + b − 1 = qb + (r + b − 1) and since b ≤ r + b − 1 < 2b, the
floor is q + 1 = ⌈a/b⌉. ∎
Rounding a borrower's principal up means the protocol records a hair more debt than the exact value — always in the protocol's favor, never the borrower's. Supply rounds down for the symmetric reason: a lender is credited a hair less. Both directions protect reserves from dust-rounding leakage.
Say baseBorrowIndex = 1.05e15 (5% accrued) and a borrower owes a present value
v = 5,000e6 (USDC, $5,000):
Converting back: PV = ⌊4,761,904,762 · 1.05e15 / 1e15⌋ = 5,000,000,000 = $5,000
✓ (the ceiling guaranteed we never under-record).
Dust edge: a present value of 1 base unit with index 2e15
gives PrV_supply = ⌊1·1e15/2e15⌋ = 0 — sub-index dust rounds to zero principal,
which is why tiny supplies can vanish.
- Stored principal × (index / 1e15) = present value; the inverse divides it back out.
- Supply rounds down, borrow rounds up — the protocol never under-counts debt or over-counts credit.
⌈a/b⌉ = ⌊(a+b−1)/b⌋is the exact integer ceiling Comet uses forprincipalValueBorrow.
- With
baseSupplyIndex = 1.2e15, what present value does a supply principal of1,000e6have? - Why would symmetric flooring (rounding both down) slowly leak value to borrowers?
- Show a present value + index pair where supply principal rounds to 0.
Show answers
1) ⌊1000e6·1.2e15/1e15⌋ = 1,200e6 = $1,200. 2) Flooring a borrower's
principal would record slightly less debt than owed, so repayment closes the
position while leaving the protocol short by the dropped remainder. 3)
v = 1, index = 2e15 → ⌊1·1e15/2e15⌋ = 0.
MODULE 04 Interest rate model
The kinked utilization curve, per-second compounding, the supply/borrow spread, and the implicit reserve factor.
Utilization — the input to everything
U ∈ [0, 1] in principle (1e18-scaled on-chain). At U = 0 nobody's
borrowing; near U = 1 the pool is fully lent out and withdrawals can fail.
The kinked rate curves
Both supply and borrow rates are piecewise-linear in U with a single kink.
Below the kink the slope is gentle; above it, steep — punishing high utilization so the pool
keeps a withdrawal buffer.
r_b^0=borrowPerSecondInterestRateBase(per-second, ×1e18)s_b^low=borrowPerSecondInterestRateSlopeLow,s_b^high=…SlopeHighU_b*=borrowKink(×1e18, governance-set, typically ~0.90–0.93)
Per-year ↔ per-second conversion
Governance configures rates per year; the contract stores them per second as immutables:
APR vs APY
Comet's index advances by a simple per-second rate inside each accrual gap (Module 5), but because gaps are tiny and frequent, the realized return compounds — so the APY a borrower truly pays is a touch above the quoted APR.
The spread → the implicit reserve factor
Borrowers pay r_b; suppliers receive r_s < r_b. The protocol keeps
the difference as reserves. There's no explicit "reserveFactor" variable in V3 — it's
implied by the gap between the two curves:
Pool: PV_supply = $20,000, PV_borrow = $12,000 ⇒
U = 0.60. Suppose at this U the curves give borrow APR 5.30% and supply APR
2.40%:
Interactive · the kinked rate curves
Drag the sliders — the
Desmos graph redraws both rate curves vs utilization U∈[0,1]. The vertical
line is the kink; watch the supply curve (mint) stay under the borrow curve (orchid),
and the shaded gap = the protocol's spread.
- Utilization drives two piecewise-linear curves with one kink each; above the kink the slope steepens to defend the withdrawal buffer.
- Rates are stored per-second (governance sets per-year, divided by
SECONDS_PER_YEAR); frequent simple accrual realizes a compounded APY. - V3 has no explicit reserve factor — it's the implied gap
r_b·U − r_sbetween the borrow and supply curves.
- At
U = 0what arer_bandr_sin terms of the parameters? - Why does the borrow slope steepen past the kink rather than staying linear?
- If suppliers earned the full borrow rate, what would the reserve factor be — and why is that unsafe?
Show answers
1) r_b = borrowBase, r_s = supplyBase (the intercepts) — only
the base term survives at U=0. 2) The steep upper slope makes borrowing
rapidly more expensive near full utilization, pushing U back down so
lenders can still withdraw. 3) Reserve factor would be 0 — the protocol would accrue no
equity buffer to absorb bad debt, so any liquidation shortfall becomes an unbacked loss.
MODULE 05 Accrual engine
Lazy accrual, the index-update formula, the four indices,
getReserves as a derived identity, and a live index trace.
"Lazy" accrual
Comet does not update interest every block. It stores lastAccrualTime and, whenever
someone touches the protocol, computes the elapsed seconds and fast-forwards all four indices in
one shot. Between touches, the stated balances are stale but the true balances
are recoverable by re-deriving the indices.
The index update
Each index is multiplied by (1 + rate·Δt) — a simple per-second rate
applied to the current index. Because the index itself already embeds past growth, applying
simple interest to it repeatedly produces compounding across gaps. Utilization
U is read once at the start of the gap, so the model is exact w.r.t. utilization
within an unbroken interval; the only approximation is simple-vs-compound inside that single
gap.
The four indices & what they bank
| Index | Scaled by | What it tracks |
|---|---|---|
baseSupplyIndex |
1e15 | Interest owed to suppliers |
baseBorrowIndex |
1e15 | Interest owed by borrowers |
trackingSupplyIndex |
1e15 | COMP rewards accrued per unit supply (Module 13) |
trackingBorrowIndex |
1e15 | COMP rewards accrued per unit borrow |
getReserves() as a derived identity
Reserves aren't stored — they're computed on demand as cash minus net liabilities. They can be negative (bad debt). We return to this in Module 12.
Start: baseBorrowIndex = 1.000000e15, borrow rate r_b = 1.5e−9/sec
(≈4.73% APR). Three gaps:
A borrower whose principal is 5,000e6 now owes
PV = ⌊5,000e6 · 1.0000054e15 / 1e15⌋ = 5,000,027,000 = $5,000.027 after the
hour.
Interactive · accrual engine playground
Set a borrow rate and a time
horizon; the canvas traces how baseBorrowIndex and a sample borrower's debt
grow. Toggle simple-vs-compound to see the gap.
- Accrual is lazy: indices fast-forward by
Δt·rateonly when the protocol is touched. - Four indices advance together — two for interest, two for COMP rewards — all ×1e15.
- Reserves are not stored;
getReservesderives cash − net user liabilities and may be negative.
- If nobody touches a market for a week, are borrowers accruing interest? Where does that interest "live"?
- Why does applying a simple per-second rate to the index still produce compounding over many gaps?
- Give a state where
getReservesis negative.
Show answers
1) Yes economically — the unrealized interest lives in the gap between the stored (stale)
index and what accruedInterestIndices(now − lastAccrualTime) would compute;
it's realized on the next touch. 2) Each gap multiplies the already-grown
index, so growth stacks multiplicatively across gaps even though within one gap it's
linear. 3) After a bad-debt absorb where debt was cleared but seized collateral was
worth less than the debt — cash held is below net base owed, so R < 0.
MODULE 06 Supply path
supply / supplyTo / supplyFrom, the base-vs-collateral branch,
repayAndSupplyAmount, caps, and fee-on-transfer handling.
Entry points
supply(asset, amount), supplyTo(dst, asset, amount),
supplyFrom(from, dst, asset, amount) all funnel into supplyInternal,
gated by hasPermission(from, operator). The internal branches on whether
asset == baseToken.
Base supply — the repay-then-supply split
If you supply the base asset while you currently owe base (negative principal), the
deposit first repays the debt and only the surplus becomes new supply. That split is
repayAndSupplyAmount:
Mechanically: convert amount to a principal delta, add it to the signed principal
(which may cross zero from negative to positive), update totalBorrowBase down by
the repaid part and totalSupplyBase up by the supplied part.
doTransferIn & fee-on-transfer
doTransferIn measures the contract's balance before and after the
transferFrom and credits the actual received amount — so a fee-on-transfer
token credits only what truly arrived, never the requested amount.
function doTransferIn(address asset, address from, uint amount) internal returns (uint) {
uint preBalance = IERC20(asset).balanceOf(address(this));
IERC20(asset).safeTransferFrom(from, address(this), amount);
uint postBalance = IERC20(asset).balanceOf(address(this));
return postBalance - preBalance; // actual received, fee-on-transfer safe
}
Collateral supply
supplyCollateral increments userCollateral[user][asset].balance and
totalsCollateral[asset], checks the supplyCap (revert
SupplyCapExceeded), sets the asset's bit in assetsIn via
updateAssetsIn, and is blocked when isSupplyPaused. Collateral never
touches the base indices — it earns no interest.
In V2 you'd mint a cToken and that collateral was itself lent out.
In V3 collateral is inert: it sits in userCollateral, earns nothing, and exists
only to back a borrow.
Alice owes $300 base (principal ≈ −300e6 at index 1) and supplies
$500 USDC:
repayAmount = min(300, 500) = $300→ clears the debt,totalBorrowBase −= 300e6supplyAmount = 500 − 300 = $200→ new positive supply,totalSupplyBase += 200e6- Her signed principal crosses zero:
−300e6 → +200e6
- Base supply auto-repays existing debt first; the surplus becomes new supply, and the signed principal may cross zero.
doTransferIncredits the actually-received amount, making fee-on-transfer tokens safe.- Collateral supply is inert — capped, bit-flagged in
assetsIn, and never interest-bearing.
- A borrower owing $1,000 supplies exactly $1,000 of base. What's their resulting principal?
- Why must
doTransferInmeasure balance deltas instead of trustingamount? - What does
updateAssetsIndo on the first deposit of a new collateral asset?
Show answers
1) Exactly 0 — the full deposit repays the debt, leaving no surplus to supply. 2)
Fee-on-transfer / rebasing tokens deliver less than requested; crediting
amount would over-credit the user and silently drain reserves. 3) It sets
the asset's bit in the 16-bit assetsIn bitmap so future health checks
include it.
MODULE 07 Withdraw / borrow path
There is no borrow function — borrowing is withdrawing base past zero. The split, the min-borrow floor, and the post-action health gate.
The unifying insight
V3 has no borrow(). You call withdraw(baseToken, amount); if you have
supply it comes out of supply, and if you withdraw beyond your supply your principal
goes negative — that negative balance is the loan. One function, one signed
number.
The withdraw-then-borrow split
The withdraw part reduces totalSupplyBase; the borrow part increases
totalBorrowBase. The signed principal can cross zero from positive to negative.
Two guards on borrowing
baseBorrowMin— if the resulting borrow present value is below this floor, revertBorrowTooSmall. This stops dust borrows that would be unprofitable to ever liquidate.isBorrowCollateralized(Module 9) — checked after the action; if the account isn't collateralized post-withdraw, the whole call revertsNotCollateralized.
Collateral withdrawal
withdrawCollateral decrements userCollateral, runs the
collateralization check on the source account, and clears the asset's assetsIn bit
when the balance hits zero. doTransferOut sends the tokens; a base withdrawal is
also liquidity-limited — you can't withdraw cash the pool doesn't physically hold.
Bob supplies $2,000 base (principal +2,000e6), then withdraws
$5,000:
withdrawAmount = min(2,000, 5,000) = $2,000→totalSupplyBase −= 2,000e6, principal → 0borrowAmount = 5,000 − 2,000 = $3,000→totalBorrowBase += ~3,000e6(ceiling principal), principal → ≈−3,000e6- Post-action
isBorrowCollateralizedmust hold given his collateral, or the call reverts.
A subtle point: the baseBorrowMin check applies to the resulting borrow
position's present value, not to the withdrawal amount. A large supplier withdrawing a small
surplus that leaves them still net-positive never triggers it — only an actual
new/increased borrow below the floor does.
- Borrowing = withdrawing base past your supply; the principal simply crosses into negative territory.
withdrawAndBorrowAmountsplits the call into a supply-reduction part and a debt-creation part.- Two gates protect the protocol:
baseBorrowMin(no dust loans) and a post-actionisBorrowCollateralizedhealth check.
- Why does V3 not need a separate
borrow()function? - What error reverts a $5 borrow if
baseBorrowMinis $100, and why does that floor exist? - When is
isBorrowCollateralizedevaluated relative to the state change — and why does the order matter?
Show answers
1) A signed principal already encodes both sides; withdrawing past zero produces the
loan, so one function covers both. 2) BorrowTooSmall; tiny loans are
uneconomical to liquidate (gas > bounty), so they'd accumulate as un-cleanable risk.
3) After the mutation — the post-state must be healthy, so a withdrawal that
would leave the account under-collateralized reverts atomically.
MODULE 08 Transfers & permissions
The ERC-20 base surface, collateral transfers with a health check, and the
boolean operator model via allow / allowBySig (EIP-712).
Base transfers
transfer / transferFrom on the base asset route to
transferBase: the source principal decreases, the destination increases. A base
transfer can therefore create a borrow on the sender (if they send more than they hold)
— so it runs the same collateralization gate as a withdrawal.
Collateral transfers
transferAsset / transferAssetFrom → transferCollateral:
moves userCollateral from src to dst and then checks
isBorrowCollateralized(src) — you can't transfer away collateral that's backing
your own loan.
The permission model (boolean, not allowance)
Unlike ERC-20 numeric allowances, Comet uses a boolean operator model:
isAllowed[owner][operator] is simply true/false. An approved operator can act on
the owner's entire position. Set it with allow(operator, bool) or
off-chain via signature with allowBySig.
// EIP-712 typed-data authorization (gasless approval)
bytes32 internal constant AUTHORIZATION_TYPEHASH = keccak256(
"Authorization(address owner,address manager,bool isAllowed,uint256 nonce,uint256 expiry)"
);
function allowBySig(
address owner, address manager, bool isAllowed_,
uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s
) external {
require(uint256(s) <= 0x7FFF...A0, "invalid value s"); // malleability guard
require(v == 27 || v == 28, "invalid value v");
bytes32 structHash = keccak256(abi.encode(AUTHORIZATION_TYPEHASH, owner, manager, isAllowed_, nonce, expiry));
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash));
address signatory = ecrecover(digest, v, r, s);
require(signatory != address(0) && owner == signatory, "bad signatory");
require(nonce == userNonce[signatory]++, "bad nonce");
require(block.timestamp < expiry, "signature expired");
isAllowed[owner][manager] = isAllowed_;
}
The s-value and v checks are the standard ECDSA malleability
guards — without them a second valid signature could be forged from a known one. The
nonce is consumed monotonically to prevent replay; expiry bounds the
signature's lifetime.
Several of these surface functions (ERC-20 allowance compatibility, metadata) actually live
in CometExt and are reached by delegatecall from Comet's fallback
— the size-limit workaround from Module 0. So allowBySig running "in Comet" may
physically execute in CometExt's code against Comet's storage.
- Base transfers can create a borrow on the sender and so run the collateralization gate; collateral transfers check the source's health.
- Permissions are boolean operators (all-or-nothing over a position), not numeric allowances.
allowBySigis EIP-712 with malleability guards, a monotonic nonce, and an expiry.
- Why does transferring the base asset run a health check but supplying it never does?
- What three things stop an
allowBySigsignature from being replayed or forged? - How is the boolean operator model riskier than an ERC-20 numeric allowance?
Show answers
1) A base transfer can push the sender's principal negative (a new borrow); supplying
only increases principal, which can't reduce health. 2) The malleability guard (bounded
s, valid v), the monotonic nonce, and the
expiry timestamp. 3) A boolean operator can act over the entire
position with no cap, whereas a numeric allowance limits exposure to a fixed amount.
MODULE 09 Collateralization & health
The two health inequalities, the signed-liquidity summation, the early-exit loop, and Alice's exact liquidation price.
Two factors, two thresholds
Every collateral asset has two governance-set factors, both ×1e18: the borrow collateral
factor f_i^b (the action gate) and the liquidate collateral factor
f_i^l (the seizure trigger), with f_i^b < f_i^l always — leaving a
deliberate buffer band.
B— present value of the borrow (the magnitude of negative principal), in base unitsq_i— collateral balance of asseti;p_i— price ×1e8;scale_i = 10^(decimals)
isBorrowCollateralized is the action gate (must hold after borrowing or
withdrawing collateral). isLiquidatable is the trigger. The loop walks
the assetsIn bitmap, accumulating signed liquidity, and early-exits the
instant liquidity turns positive — so a well-covered account never even reads the later
oracles.
Worked example — Alice, and her exact liquidation price
Alice: 10 WETH collateral, present borrow $5,000 (USDC base). WETH
BCF = 0.83, LCF = 0.90. Scales: WETH 1e18, baseScale 1e6,
PRICE_SCALE 1e8, FACTOR_SCALE 1e18.
base: signedMulPrice(-5e9, 1e8, 1e6) = -5e9 × 1e8 / 1e6 = -5e11 (= -$5,000)
WETH: mulPrice(10e18, 3000e8, 1e18) = 10e18 × 3e11 / 1e18 = 3e12 (= $30,000)
BCF: mulFactor(3e12, 0.83e18) = 3e12 × 0.83 = 2.49e12 (= $24,900)
LCF: mulFactor(3e12, 0.90e18) = 3e12 × 0.90 = 2.70e12 (= $27,000)
isBorrowCollateralized: liquidity = -5e11 + 2.49e12 = 1.99e12 = $19,900 ≥ 0 → TRUE
isLiquidatable: liquidity = -5e11 + 2.70e12 = 2.20e12 = $22,000 ≥ 0 → FALSE
Healthy on both with room to spare. Now solve the thresholds (let WETH price =
P, collateral USD = 10·P):
So as WETH falls from $3,000: at $602.41 Alice can no longer
borrow more; between $555.56 and $602.41 she's in the buffer band
(under-collateralized for borrowing but not yet liquidatable); below
$555.56 she becomes liquidatable. If interest had grown her debt
to $5,350, the liquidation price rises to 5,350/9 = $594.44
— interest pushes the threshold up over time.
Multi-asset behavior (aggregate + early exit)
Give Alice 10 WETH ($3,000) and 0.5 WBTC ($60,000), same $5,000 borrow:
i=0 WETH: liquidity -$5,000 < 0 → price it: $30,000 × 0.83 = $24,900 → liquidity = $19,900
i=1 WBTC: liquidity $19,900 ≥ 0 → EARLY EXIT (return true). WBTC oracle is NOT read.
Health is the aggregate of all collateral, but the loop stops once positive. Conversely, if WETH alone crashed, the loop continues to WBTC and adds its value — so one asset tanking doesn't liquidate Alice if her other collateral covers the debt.
Interactive · Alice's health vs WETH price
Drag WETH collateral, debt, and the two factors. The Desmos graph plots BCF-weighted liquidity (orchid) and LCF-weighted liquidity (mint) vs WETH price; where each crosses zero is the borrow-gate / liquidation price. Vertical markers show both thresholds.
- Two factors per asset: BCF gates new risk, LCF triggers liquidation, with a guaranteed buffer band between them.
- Health is a signed sum of factor-weighted collateral USD minus borrow USD; the loop early-exits once positive.
- Solving the inequality for price gives the exact liquidation level — and rising interest raises that level over time.
- For Alice at 10 WETH / $5,000 debt / LCF 0.90, re-derive the liquidation price.
- Why does the health loop early-exit, and what does that save?
- What happens to the liquidation price as borrow interest accrues?
Show answers
1) 10·P·0.90 = 5,000 → P = 5,000/9 = $555.56. 2) Once
cumulative liquidity is positive the account is provably healthy, so it
stops reading further oracles — saving gas and oracle calls. 3) It
rises: higher debt B means P = B/(q·LCF)
increases, so the position becomes liquidatable at a higher price.
MODULE 10 Liquidation · phase 1: absorb()
Seizing an underwater position, the liquidationFactor haircut, the bad-debt clamp, and the reserve impact.
What absorb does
Liquidation in V3 is two phases. Phase 1 — absorb(absorber, accounts[]) —
lets anyone hand an underwater account to the protocol: the protocol clears the
borrower's debt, seizes all their collateral into protocol reserves, and credits the
borrower with any surplus as a base supply balance. No keeper capital is needed here; the
protocol takes the position onto its own book.
The math
V_i— USD value of seized collateral assetif_i^l— the liquidationFactor (a haircut, distinct from LCF in some configs) applied when valuing seized collateral- If the haircut collateral value exceeds the debt, the borrower keeps the surplus as base supply; if it falls short, the shortfall becomes protocol bad debt.
Alice: 10 WETH, $5,000 debt, liquidationFactor LF = 0.93.
Situation 1 — healthy seizure (WETH = $550)
Debt cleared; Alice keeps $115 of borrower-supply; 10 WETH moves to collateral reserves.
Situation 2 — bad debt (WETH = $400)
Debt cleared, no supply created; the protocol eats the $1,280 shortfall (realized later — Module 12).
Bookkeeping & LiquidatorPoints
The absorber's liquidatorPoints[absorber] increments
(numAbsorbs++, numAbsorbed += value,
approxSpend += gas) — a reputation/accounting record.
userCollateral and totalsCollateral zero out for the seized
account; totalSupplyBase/totalBorrowBase adjust. Blocked when
isAbsorbPaused.
absorbhands an underwater account to the protocol: debt cleared, all collateral seized to reserves, surplus returned to the borrower as supply.- Seized collateral is valued at the
liquidationFactorhaircut; if that's below the debt,newPrincipalclamps to 0 and the shortfall is bad debt. - Phase 1 needs no keeper capital — it just moves the position onto the protocol's
book and records
LiquidatorPoints.
- At what WETH price does Alice's absorb exactly break even (surplus = 0) with LF 0.93?
- Why does the protocol clamp
newPrincipalto 0 instead of letting it go negative? - Who supplies the capital in phase 1 — and where does the collateral go?
Show answers
1) 10·P·0.93 = 5,000 → P = $537.63. 2) A negative
newPrincipal would mean the protocol owes the borrower for
collateral it didn't have — the clamp records the loss as protocol bad debt
instead of crediting the borrower. 3) Nobody — the protocol itself absorbs the
position; seized collateral becomes protocol collateral reserves, sold later via
buyCollateral.
MODULE 11 Liquidation · phase 2: buyCollateral()
The store-front discount, the storeFrontPriceFactor split, keeper profit, partial buys, and unsold collateral.
What buyCollateral does
Phase 2 sells the seized collateral to keepers at a discount, in exchange for base asset —
recapitalizing reserves. A keeper calls
buyCollateral(asset, minAmount, baseAmount, recipient): pays
baseAmount of base in (doTransferIn), receives discounted collateral
out (doTransferOut), with minAmount as a slippage guard. Blocked when
isBuyPaused.
The discount math
storeFrontPriceFactor— governance knob ×1e18; splits the liquidation penalty between keeper and reserves- At
storeFront = 1keepers get the whole penalty (fast clearing); at0there's no discount and no keeper incentive at all
WETH market $550, LF 0.93, storeFront 0.5. Reserves are below
target (the absorb drained them), so the store front is open.
Formula check:
1e8 × 5,307.5e6 × 1e18 / (530.75e8) / 1e6 = 1e19 = 10e18 = 10 WETH ✓. The
$5,307.50 the keeper paid flows into protocol base reserves.
Partial buys & unsold collateral
Partial: a keeper can buy any slice — e.g. 4 WETH for 4×530.75 = $2,123 (set
minAmount = 3.98e18 for slippage). The other 6 WETH stays in
getCollateralReserves(WETH) for the next buyer. Unsold: if nobody buys, the
collateral simply sits in reserves indefinitely — base reserves remain depressed (or negative in
the bad-debt case) until a keeper finds the spread profitable.
Raising storeFrontPriceFactor toward 1 hands more of the penalty to keepers —
stronger incentive, faster clearing. Lowering it toward 0 keeps more for reserves, but at
exactly 0 there's no discount, so no keeper ever acts and seized collateral never clears.
Interactive · keeper discount & profit
Adjust market price, liquidationFactor and storeFrontPriceFactor. The Desmos graph plots keeper profit vs amount bought; readouts show the discount, discounted price, and break-even.
- Keepers buy seized collateral at
marketPrice·(1 − storeFront·(1 − LF)), paying base that recapitalizes reserves. storeFrontPriceFactorsplits the penalty: more to keepers = faster clearing, more to reserves = slower but cheaper.- Buys can be partial; unsold collateral sits in reserves until the spread is profitable.
- Compute the discounted price for WETH $1,000, LF 0.90, storeFront 0.6.
- Why does
buyCollateralexist as a separate phase instead of paying keepers directly inabsorb? - What happens to keeper incentive as
storeFrontPriceFactor → 0?
Show answers
1) discountFactor = 0.6·(1−0.90) = 0.06;
price = 1,000·0.94 = $940. 2) Separating phases lets the protocol take the
position immediately (stopping further loss) without needing a keeper ready with
capital; the sale happens whenever a profitable keeper appears. 3) Discount → 0, so
there's no arbitrage spread and no keeper ever buys — seized collateral never clears.
MODULE 12 Reserves & protocol accounting
getReserves as a live equity identity, how every action moves it,
targetReserves as a recapitalization throttle, and withdrawReserves.
Reserves are derived, not stored
Cash held minus net base owed to users. It can be negative (bad debt). Collateral reserves are
tracked separately by getCollateralReserves(asset) and are invisible to
this base-only number until sold.
targetReserves — the counter-cyclical throttle
if (reserves >= 0 && uint(reserves) >= targetReserves) revert NotForSale();
Collateral is for sale only when base reserves are below targetReserves (or
negative). The store front recapitalizes — once reserves are back at target, it closes. So the
discount turns on precisely when the protocol needs base, and off in calm, well-reserved times.
The check is at entry, so one large buy that overshoots target is allowed; the next
call reverts.
withdrawReserves — three guards
Governor-only; cannot withdraw when reserves are negative (you can't harvest bad debt);
cannot withdraw more than current reserves. It moves base only — collateral reserves
are realized through buyCollateral or managed by governance separately. No
Comet-level event; observers watch the base token's Transfer.
1 · Spread accrual (no liquidation), one year
2 · Healthy liquidation (Alice S1: WETH $550, LF 0.93, storeFront 0.5)
absorb: Δbase reserves = -Δsupply + Δborrow = -115 + (-5,000) = -$5,115 (FALL)
Δcollateral reserves(WETH) = +10 WETH
buyCollateral: keeper pays $5,307.50 base, receives 10 WETH
Δbase reserves = +$5,307.50 ; Δcollateral reserves(WETH) = -10 WETH
NET: base reserves +$192.50 ; collateral reserves 0
The protocol ends $192.50 richer — its 3.5% slice of the borrower's $385 penalty — holding no leftover collateral.
3 · Bad-debt liquidation (Alice S2: WETH $400)
absorb: deltaValue = 4,000×0.93 = $3,720 ; newBalance = -5,000+3,720 = -$1,280 → clamp 0
Δbase reserves = -0 + (-5,000) = -$5,000 ; Δcollateral(WETH) = +10 WETH ($4,000 mkt, invisible)
buyCollateral: discountedPrice = 400×(1-0.035)=$386 ; keeper pays $3,860 for 10 WETH
Δbase reserves = +$3,860 ; collateral → 0
NET: base reserves -$1,140
Realized loss $1,140 = $1,000 economic bad debt + $140 keeper discount. After absorb,
getReserves read −$5,000 even though the protocol held $4,000 of WETH (true
equity −$1,000) — the collateral value was simply invisible until sale. If nobody
buys, base reserves stay −$5,000 while 10 WETH sits unsold; getReserves
reads −$5,000 but true position is −$1,000.
4 · The donation edge
Benign — you can donate to reserves or top up a bad-debt market; it's why
balance can exceed what positions imply.
- Reserves = cash − net base owed; derived live, can be negative, and exclude collateral value until it's sold.
targetReservesgatesbuyCollateralso sales happen only while reserves are below target — counter-cyclical recapitalization.withdrawReservesis governor-only, base-only, and blocked on negative or over-withdrawal.
- Right after a bad-debt absorb, why does
getReservesunderstate the protocol's true equity? - Why can't the governor withdraw reserves when they're negative?
- What stops
buyCollateralfrom running once reserves are healthy?
Show answers
1) The seized collateral has market value but is invisible to the base-only
getReserves until sold, so the number reflects only the cleared debt, not
the held collateral. 2) There's nothing real to withdraw — negative reserves mean
liabilities exceed assets; the guard prevents "harvesting" non-existent equity. 3) The
NotForSale() revert when reserves ≥ targetReserves.
MODULE 13 COMP rewards
The two-stage tracking-index pipeline, the MasterChef reward-debt pattern, the rescaleFactor, and the fixed-pie emission property.
Two stages: accrue in Comet, claim in CometRewards
Comet itself only advances trackingSupplyIndex / trackingBorrowIndex
and banks each user's baseTrackingAccrued. The actual COMP transfer happens in the
separate CometRewards contract on claim(). COMP is never
minted on claim — it's paid from a DAO-funded balance held by CometRewards.
The tracking index update
if (totalSupplyBase >= baseMinForRewards)
trackingSupplyIndex += safe64(divBaseWei(baseTrackingSupplySpeed * timeElapsed, totalSupplyBase));
if (totalBorrowBase >= baseMinForRewards)
trackingBorrowIndex += safe64(divBaseWei(baseTrackingBorrowSpeed * timeElapsed, totalBorrowBase));
Per-user accrual — the MasterChef reward-debt pattern
Each user stores a snapshot baseTrackingIndex (their "reward debt"). When their
principal changes, the protocol banks the delta between the global index and their snapshot,
then re-snapshots — exactly the MasterChef accounting that lets one global index serve all users
in O(1).
The fixed-pie property (derivation)
Sum the per-user accrual over all suppliers (Σ principal = totalSupplyBase):
The totalSupplyBase cancels — the speed alone determines total emission; pool
size only sets each person's slice. Double the pool and the pie is unchanged (each
supplier earns half per dollar); double the speed and everyone's rewards double.
rescaleFactor
Accrued tracking units are 6-decimal-scaled (matching base); COMP is 18-decimal.
CometRewards applies a rescaleFactor on claim to upscale 6→18 decimals
so the right COMP amount transfers. Distribution is timestamp-driven per second (not per
block), so emission is consistent across chains with different block times.
Interactive · COMP rewards playground (3 charts)
Adjust the supply speed and pool size. Chart 1: total emission (flat — the fixed pie). Chart 2: per-dollar reward rate (falls as the pool grows). Chart 3: a sample supplier's cumulative COMP over time.
- Comet advances tracking indices and banks
baseTrackingAccrued;CometRewardspays COMP from a DAO-funded balance — never minted on claim. updateBasePrincipalis MasterChef reward-debt accounting: one global index serves all users in O(1).- Emission speed fixes the total pie independent of pool size;
baseMinForRewardsgates distribution and protects the index denominator.
- If
totalSupplyBasedoubles with speed unchanged, what happens to total COMP emitted and to your per-dollar rate? - Is COMP minted when you claim? Where does it come from?
- Why is distribution timestamp-driven rather than per-block?
Show answers
1) Total emission is unchanged (fixed pie); your per-dollar rate halves. 2) No — it's
transferred from a DAO-funded balance in CometRewards. 3) Per-second
timestamps give identical emission regardless of block time, so the schedule is
consistent across chains and through block-time changes.
MODULE 14 Pause guardian & flags
The pauseFlags bitfield, the five independently-toggleable actions,
and how each check reads a single bit.
One byte, five switches
Comet packs five pause toggles into the single uint8 pauseFlags field of
TotalsBasic. A guardian (or governance) can freeze any subset of actions
independently — e.g. pause borrowing during an oracle incident while still allowing repayment.
uint8 internal constant PAUSE_SUPPLY_OFFSET = 0;
uint8 internal constant PAUSE_TRANSFER_OFFSET = 1;
uint8 internal constant PAUSE_WITHDRAW_OFFSET = 2;
uint8 internal constant PAUSE_ABSORB_OFFSET = 3;
uint8 internal constant PAUSE_BUY_OFFSET = 4;
function pause(bool supplyPaused, bool transferPaused, bool withdrawPaused,
bool absorbPaused, bool buyPaused) external {
require(hasPermission(msg.sender) || msg.sender == pauseGuardian, "Unauthorized");
totals.pauseFlags =
uint8(0)
| (toUInt8(supplyPaused) << PAUSE_SUPPLY_OFFSET)
| (toUInt8(transferPaused) << PAUSE_TRANSFER_OFFSET)
| (toUInt8(withdrawPaused) << PAUSE_WITHDRAW_OFFSET)
| (toUInt8(absorbPaused) << PAUSE_ABSORB_OFFSET)
| (toUInt8(buyPaused) << PAUSE_BUY_OFFSET);
}
function isSupplyPaused() public view returns (bool) {
return toBool(totals.pauseFlags & (uint8(1) << PAUSE_SUPPLY_OFFSET));
}
Each entry point begins with its guard: supplyInternal checks
isSupplyPaused, withdrawInternal checks isWithdrawPaused,
absorb checks isAbsorbPaused, buyCollateral checks
isBuyPaused, transfers check isTransferPaused. Note that
repaying is a supply of base — so pausing supply also pauses repayment, a deliberate
consideration in incident response.
- Five pause bits live in one
uint8; each action's entry point reads its own bit. - The guardian can freeze any subset independently for targeted incident response.
- Because repay = supply of base, pausing supply also halts repayment — a tradeoff to weigh.
- How many independent action-pause states fit in
pauseFlags, and why a bitfield? - If a guardian pauses supply, can a borrower still repay? Why?
- Write the boolean test for "is withdraw paused" given offset 2.
Show answers
1) Five (offsets 0–4); a bitfield reads/writes all toggles in one cheap
uint8 already co-packed with the totals slot. 2) No — repaying base is a
supply operation, so the supply pause blocks it too. 3)
(pauseFlags & (1 << 2)) != 0.
MODULE 15 Price feeds & oracles
getPrice, the 8-decimal Chainlink convention, the base price feed,
and staleness considerations.
The price surface
Every priced asset (each collateral and the base token) has an immutable priceFeed
address. Comet reads it through getPrice(priceFeed), which calls the feed's
latestRoundData() and returns the answer as a uint scaled by
PRICE_SCALE = 1e8 — the Chainlink USD convention.
function getPrice(address priceFeed) public view returns (uint256) {
(, int256 price, , , ) = IPriceFeed(priceFeed).latestRoundData();
if (price <= 0) revert BadPrice();
return uint256(price); // already 1e8-scaled; feeds are validated at config time
}
Why 1e8 everywhere
Standardising on 1e8 means mulPrice (Module 1) can combine any token
balance with any feed without per-asset scale juggling: USD_value = q · p / scale,
where p is always 1e8 and scale is the token's own decimals. The base
token also has its own baseTokenPriceFeed so a non-USD base (rare) is still priced.
Comet trusts the feed's latest answer and reverts only on a non-positive price
(BadPrice). It does not itself enforce a heartbeat/staleness window in
the core read — that risk is managed by choosing robust feeds at config time and by the
isolated-collateral design (Module 19), which caps how much a single manipulated feed can
extract. For USD-pegged bases, a fixed 1e8 "constant price feed" is sometimes
used so the base is treated as exactly $1.
WBTC balance 0.5e8 (8 decimals), feed returns 60000e8:
- Each priced asset has an immutable feed;
getPricereturns a 1e8-scaled USD answer and reverts on non-positive prices. - The universal 1e8 convention lets
mulPricecombine any balance with any feed using only the token's own scale. - Core reads trust the latest answer; staleness robustness comes from feed choice and isolated-collateral design, and pegged bases may use a constant $1 feed.
- Why does standardising prices to 1e8 simplify
mulPriceacross assets of different decimals? - What does
getPricerevert on, and what does it not check? - How does isolated collateral limit the damage of a single bad feed?
Show answers
1) Only the token's own scale varies; the price is always 1e8, so one
formula q·p/scale works everywhere. 2) Reverts on price ≤ 0
(BadPrice); it doesn't enforce a staleness/heartbeat window in the core
read. 3) A manipulated feed only affects positions using that one collateral, capping
extractable value to those positions rather than the whole pool.
MODULE 16 Config & upgrade pipeline
Why parameters are immutables, the Configurator → Factory → ProxyAdmin flow, and the Configuration struct.
Immutables, not storage
Recall from Module 0: per-asset risk parameters are compiled into the Comet
implementation as immutable variables, baked into bytecode. Reads cost no
SLOAD — but changing any parameter means deploying a brand-new
implementation. This is the structural reason a "governance parameter change" in V3 is a
redeploy, not a storage write.
The pipeline
governance
│ setConfiguration(...) / updateAsset(...) on Configurator.sol
▼
Configurator ──holds──► Configuration struct (the editable params, in storage)
│ deploy()
▼
CometFactory ──clone──► new Comet implementation (params baked as immutables)
│ returns address
▼
CometProxyAdmin.upgrade(proxy, newImpl) ◄── proxy now points at the new code
│
▼
TransparentUpgradeableProxy → users keep the same Comet address & storage
The Configuration struct (in CometConfiguration.sol) holds the
editable source of truth: governor, pauseGuardian, baseToken, baseTokenPriceFeed, the
rate-model params, the per-asset AssetConfig[] (priceFeed, decimals, borrowCF,
liquidateCF, liquidationFactor, supplyCap), storeFrontPriceFactor,
targetReserves, the tracking speeds, etc. Configurator reads this and
CometFactory compiles it into a fresh implementation.
It trades upgrade convenience for runtime cheapness and safety: risk checks (run on every borrow/withdraw/liquidation) never pay storage-read gas, and there's no risk of a parameter being mutated mid-transaction. The cost is that governance changes are heavier (a deploy + upgrade), which also slows down rushed or malicious parameter tweaks.
- Risk params are immutables in the implementation's bytecode — cheap reads, but changes require a redeploy.
- The flow is Configurator (editable
Configurationstruct) → CometFactory (compiles a new impl) → ProxyAdmin (re-points the proxy). - Users keep the same proxy address and storage across upgrades.
- Why don't on-chain risk checks pay any storage-read gas for collateral factors?
- Trace the three contracts a collateral-factor change passes through.
- What stays constant for users across an upgrade?
Show answers
1) The factors are immutable constants in bytecode, read directly from code,
not via SLOAD. 2) Configurator (edit the
Configuration struct) → CometFactory (deploy new impl with new
immutables) → CometProxyAdmin (upgrade the proxy). 3) The proxy address and
all storage (balances, principals, collateral) — only the implementation code changes.
MODULE 17 CometExt & the ERC-20 surface
The delegatecall extension that dodges the size limit, and the ERC-20 view surface it serves.
The size-limit problem
The EVM caps deployed contracts at 24,576 bytes (EIP-170). Comet's core logic alone
nearly fills that, so the ERC-20 niceties — name, symbol,
decimals, totalSupply, balanceOf, allowance,
approve — are moved into a companion contract, CometExt, reached via
the fallback.
// Comet's fallback delegatecalls unknown selectors to the extension
fallback() external payable {
address delegate = extensionDelegate; // immutable CometExt address
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), delegate, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
Because it's delegatecall, CometExt executes against Comet's
storage — so balanceOf there reads the same userBasic mapping. The
"Comet" address you interact with is functionally proxy → Comet → (fallback) → CometExt.
What the ERC-20 surface reports
balanceOf(account)— the account's present-value supply (positive principal × supply index); a borrower reads 0 here.borrowBalanceOf(account)— the present-value borrow (the magnitude of negative principal).totalSupply()—PV_supply(totalSupplyBase);totalBorrow()the borrow side.decimals()— the base token's decimals; the Comet "token" mirrors the base asset.
In V2 your supply was a transferable cToken (an actual ERC-20 with its
own exchange rate). In V3 there's no separate receipt token — your supply is a signed entry
in userBasic, and the ERC-20 surface is just a read-only view over
that, presenting the base-asset balance you can claim.
CometExtholds the ERC-20 view surface, reached by a delegatecall fallback to stay under the 24 KB limit.- Running via delegatecall, it reads/writes Comet's own storage, so views are consistent with core state.
- There's no cToken — supply is a signed principal entry, and the ERC-20 surface is a read-only projection of it.
- Why must
CometExtbe reached bydelegatecallrather than a plaincall? - What does
balanceOfreturn for an account that is currently borrowing? - How does V3's "supply token" differ from a V2 cToken?
Show answers
1) Only delegatecall runs the extension's code against Comet's storage, so
the views see the same balances; a plain call would execute in CometExt's own (empty)
storage. 2) 0 — a borrower has non-positive principal, so present-value supply is zero
(their debt shows via borrowBalanceOf). 3) A cToken is a real transferable
ERC-20 with an exchange rate; V3 supply is a signed storage entry, and the ERC-20
functions are just a view over it.
MODULE 18 Rounding & precision
Every rounding direction in one place, why each protects reserves, and the signed-cast guards.
The rounding ledger
| Operation | Direction | Who it favors | Why |
|---|---|---|---|
presentValueSupply |
floor | protocol | Lender credited no more than exact |
presentValueBorrow |
floor | — | Used in totals; paired with ceiling on the inverse |
principalValueSupply |
floor | protocol | Supply principal never over-recorded |
principalValueBorrow |
ceiling | protocol | Debt principal never under-recorded |
mulFactor (collateral × CF) |
floor | protocol | Backing value never inflated |
mulPrice |
floor | protocol | USD value never inflated |
divBaseWei (rewards) |
floor | protocol | Reward index never over-advances |
The single invariant behind every row: round so the protocol is never short. Credited collateral and supply round down; recorded debt rounds up. The asymmetry is deliberate and is what stops dust from leaking value over millions of operations.
The ceiling identity (re-derived)
Used only on the borrow principal. Everywhere else Solidity's native floor division already gives the protective direction.
Signed-cast guards (CometMath)
Because principal is int104 but many intermediates are unsigned, Comet uses explicit
safe casts — signed104, unsigned104, signed256,
safe64 — each of which reverts on overflow instead of silently wrapping. This
matters at zero-crossings (a repay turning a borrow into a supply) where a naive cast could flip
sign catastrophically.
Supply present value v = 1 base unit, baseSupplyIndex = 3e15:
The dust supply records as 0 principal — it can't be withdrawn, and the rounding keeps the lost third-of-a-unit in reserves rather than letting the user reclaim more than recorded.
- One rule explains every rounding choice: never leave the protocol short — credit rounds down, debt rounds up.
- The borrow-principal ceiling is the only non-floor; everywhere else native floor division is already protective.
- Safe signed/unsigned casts in
CometMathrevert on overflow, critical at principal zero-crossings.
- State the one-line invariant that fixes every rounding direction in Comet.
- Which single quantity rounds up, and what would break if it rounded down?
- Why are explicit safe casts essential at a borrow→supply zero-crossing?
Show answers
1) Round so the protocol is never short (credit down, debt up). 2)
principalValueBorrow (debt principal); flooring it would record less debt
than owed, letting borrowers repay slightly less than they took and leaking reserves. 3)
The signed/unsigned conversion around zero could wrap and flip a small debt into a huge
supply (or vice-versa); safe casts revert instead of wrapping.
MODULE 19 Attack vectors & edge cases
The donation edge, oracle-manipulation resistance, dust borrows, utilization extremes, paused states, and partial absorbs — collected with numbers.
Oracle manipulation — bounded by isolation
V3's biggest structural defense: collateral is isolated. A manipulated feed on one
collateral asset only affects positions using that asset, and the borrow collateral
factor (< 1) plus the liquidation buffer cap how much value a single bad print
can extract. Compare V2, where a manipulated borrowable asset could drain the shared pool. The
fix isn't perfect feeds — it's blast-radius containment.
The donation edge (benign)
You can inflate balanceOf(Comet) by sending tokens directly.
getReserves rises with no offsetting liability — harmless, and useful for topping
up a bad-debt market. The takeaway: balance can legitimately exceed what positions
imply, so never assume balance == derivable reserves.
Utilization extremes
- U = 0%:
getUtilizationreturns 0;r_s = supplyBase(often 0),r_b = borrowBase. Suppliers earn ~nothing; no reserve accrual. - U → 100%: the steep upper slope makes borrowing very expensive; withdrawals can fail
because the cash is lent out (liquidity-limited
doTransferOut). The kink exists precisely to keep U off this edge. - totalSupply = 0: utilization is defined as 0 to avoid divide-by-zero.
Dust borrows & baseBorrowMin
A loan below baseBorrowMin reverts BorrowTooSmall — tiny positions are
uneconomical to liquidate (gas > bounty), so the floor prevents an accumulation of
un-cleanable risk.
Paused states & partial absorb
- Pausing supply also pauses repay (repay is a base supply) — incident responders must weigh this.
absorbalways seizes the whole position (there's no partial absorb), butbuyCollateralcan be partial — keepers take any slice, leaving the rest in reserves.- If reserves are healthy,
buyCollateralrevertsNotForSale, so seized collateral can sit unsold whilegetReservesunderstates true equity (Module 12).
Reentrancy posture
State mutations (principal, totals, indices) are written before external token transfers
(doTransferOut) in the withdraw/buy paths, following checks-effects-interactions.
Combined with the boolean operator model (no numeric allowance to race) this limits classic
reentrancy surfaces; fee-on-transfer is handled by measuring received amounts (Module 6).
- Isolated collateral contains oracle-manipulation blast radius — the core defense isn't perfect feeds but limited damage.
- Direct donations raise reserves with no liability, so
balance ≠ derivable reservesin general. - Floors (
baseBorrowMin), the kink, full-position absorb vs partial buys, and checks-effects-interactions together harden the edges.
- Why does isolating collateral limit oracle-manipulation damage compared to V2?
- Can
absorbseize only part of a position? CanbuyCollateralbuy only part? - Give a concrete reason
balanceOf(Comet)can exceed the reserves implied by positions.
Show answers
1) A bad feed only mis-prices positions using that one collateral, and BCF<1 plus the
liquidation buffer cap extractable value — versus V2 where a manipulated borrowable
asset could drain the shared pool. 2) absorb takes the whole position (no
partial); buyCollateral can be partial. 3) Someone transferred base tokens
directly to the contract (the donation edge), raising balance with no
matching liability.
References
- compound-finance/comet — the canonical source:
Comet.sol,CometCore.sol,CometStorage.sol,CometMath.sol,CometExt.sol,Configurator.sol,CometFactory.sol,CometRewards.sol. - Compound III Documentation — protocol reference and parameter explanations.
- EIP-170 — the
24,576-byte contract-size limit (the reason
CometExtexists). - EIP-712 — typed-data
signing used by
allowBySig.