Implementation-grade · Ethereum lending

Compound V3 — Comet

Twenty modules from first principles to production smart-contract detail. Every formula derived, every scale defined, every step of arithmetic shown — with interactive Desmos graphs you can drag, canvas charts, real Comet.sol identifiers, and self-check questions at the end of each module.

Kinked ratesLazy accrualSigned principalTwo-phase liquidationMasterChef rewardsDesmos interactive

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.

V2 → V3 contrast

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 a TransparentUpgradeableProxy.
  • 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. Because Comet is near the 24 KB EVM contract-size limit, some functions (ERC-20 metadata, allowances) are delegatecalled to CometExt via 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 as immutable variables, so changing a collateral factor means deploying a brand-new Comet implementation and re-pointing the proxy.
  • CometRewards.sol — a separate contract for COMP distribution (Module 13).
Summary
  • 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.
Self-check
  1. Why can a single bad collateral asset not drain the whole V3 market the way it could in V2?
  2. What is the capital-efficiency cost V3 pays for isolating collateral?
  3. 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

mulFactor(n, factor)
\text{mulFactor}(n, f) = \left\lfloor \dfrac{n \cdot f}{\text{FACTOR\_SCALE}} \right\rfloor = \left\lfloor \dfrac{n \cdot f}{10^{18}} \right\rfloor
  • n — any quantity (a USD value, a balance)
  • f — a factor scaled by 1e18 (so 0.83 is stored as 0.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

mulPrice(balance, price, scale)
\text{mulPrice}(q, p, s) = \left\lfloor \dfrac{q \cdot p}{s} \right\rfloor
  • q — token balance in smallest units
  • p — 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

divBaseWei(n, baseWei)
\text{divBaseWei}(n, b) = \left\lfloor \dfrac{n \cdot \text{baseScale}}{b} \right\rfloor

Used inside reward-index updates (Module 13): it divides an emission by a pool size while preserving baseScale precision.

Worked example — pricing 10 WETH

WETH balance q = 10e18, price p = 3000e8, scale s = 1e18:

\text{mulPrice} = \dfrac{10\times10^{18} \cdot 3000\times10^{8}}{10^{18}} = 3\times10^{12} \;\Rightarrow\; \$30{,}000 \;(\text{since }3\times10^{12}/10^{8} = 30{,}000)

Now apply a borrow collateral factor of 0.83 with mulFactor:

\text{mulFactor}(3\times10^{12},\, 0.83\times10^{18}) = \dfrac{3\times10^{12}\cdot 0.83\times10^{18}}{10^{18}} = 2.49\times10^{12} \;\Rightarrow\; \$24{,}900
Summary
  • Factors are ×1e18, prices ×1e8, base indices ×1e15, the base token by its own baseScale.
  • mulFactor applies a ratio; mulPrice turns a token balance into a 1e8-scaled USD value; divBaseWei divides while keeping base precision.
  • All three floor — that floor direction is the seed of every "rounds in the protocol's favor" behavior later.
Self-check
  1. USDC has 6 decimals. What is baseScale and how is $1 of USDC represented?
  2. Compute the 1e8-scaled USD value of 0.5e8 WBTC (8 decimals) at $60,000.
  3. Why does flooring in mulFactor always 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:

solidity
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.

Why uint40 for time?

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

solidity
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

solidity
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.

Summary
  • Two exactly-packed global slots hold the four indices, two totals, the accrual time and pause flags.
  • Each user is one int104 signed principal plus reward bookkeeping and an assetsIn bitmap; 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.
Self-check
  1. How does a single int104 principal tell you whether an account is a lender or a borrower?
  2. Why is assetsIn a bitmap rather than an array, and what does it save?
  3. Both uint104 totals + uint40 + uint8 fit 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)

presentValueSupply / presentValueBorrow
\text{PV}_{\text{supply}}(p) = \left\lfloor \dfrac{p \cdot \text{baseSupplyIndex}}{\text{BASE\_INDEX\_SCALE}} \right\rfloor, \qquad \text{PV}_{\text{borrow}}(p) = \left\lfloor \dfrac{p \cdot \text{baseBorrowIndex}}{\text{BASE\_INDEX\_SCALE}} \right\rfloor
  • p — stored principal (unsigned magnitude), BASE_INDEX_SCALE = 1e15
  • The signed wrapper presentValue(p) picks supply-index if p ≥ 0, borrow-index if p < 0

Principal value (present → principal) — the inverse, with a twist

principalValueSupply (floor) vs principalValueBorrow (ceiling)
\text{PrV}_{\text{supply}}(v) = \left\lfloor \dfrac{v \cdot \text{BASE\_INDEX\_SCALE}}{\text{baseSupplyIndex}} \right\rfloor
\text{PrV}_{\text{borrow}}(v) = \left\lceil \dfrac{v \cdot \text{BASE\_INDEX\_SCALE}}{\text{baseBorrowIndex}} \right\rceil = \left\lfloor \dfrac{v \cdot \text{BASE\_INDEX\_SCALE} + \text{baseBorrowIndex} - 1}{\text{baseBorrowIndex}} \right\rfloor

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⌉. ∎

Why borrow rounds up

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.

Worked example — index growth & the rounding gap

Say baseBorrowIndex = 1.05e15 (5% accrued) and a borrower owes a present value v = 5,000e6 (USDC, $5,000):

\text{PrV}_{\text{borrow}} = \left\lceil \dfrac{5000\times10^{6}\cdot 10^{15}}{1.05\times10^{15}} \right\rceil = \lceil 4{,}761.9047\ldots\times10^{6} \rceil = 4{,}761{,}904{,}762 \;(\text{principal units})

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.

Summary
  • 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 for principalValueBorrow.
Self-check
  1. With baseSupplyIndex = 1.2e15, what present value does a supply principal of 1,000e6 have?
  2. Why would symmetric flooring (rounding both down) slowly leak value to borrowers?
  3. 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

getUtilization()
U = \dfrac{\text{PV}_{\text{borrow}}(\text{totalBorrowBase})}{\text{PV}_{\text{supply}}(\text{totalSupplyBase})} \quad (\text{returns } 0 \text{ if totalSupply} = 0)

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.

getBorrowRate(U) — per-second
r_b(U) = \begin{cases} r_b^{0} + s_b^{\text{low}}\cdot U & U \le U_b^{*} \\[4pt] r_b^{0} + s_b^{\text{low}}\cdot U_b^{*} + s_b^{\text{high}}\cdot (U - U_b^{*}) & U > U_b^{*} \end{cases}
  • r_b^0 = borrowPerSecondInterestRateBase (per-second, ×1e18)
  • s_b^low = borrowPerSecondInterestRateSlopeLow, s_b^high = …SlopeHigh
  • U_b* = borrowKink (×1e18, governance-set, typically ~0.90–0.93)
getSupplyRate(U) — per-second (same shape, supply params, supplyKink)
r_s(U) = \begin{cases} r_s^{0} + s_s^{\text{low}}\cdot U & U \le U_s^{*} \\[4pt] r_s^{0} + s_s^{\text{low}}\cdot U_s^{*} + s_s^{\text{high}}\cdot (U - U_s^{*}) & U > U_s^{*} \end{cases}

Per-year ↔ per-second conversion

Governance configures rates per year; the contract stores them per second as immutables:

r_{\text{perSecond}} = \dfrac{r_{\text{perYear}}}{\text{SECONDS\_PER\_YEAR}} = \dfrac{r_{\text{perYear}}}{31{,}536{,}000}

APR vs APY

simple APR vs compounded APY
\text{APR} = r_{\text{perSecond}} \cdot \text{SECONDS\_PER\_YEAR}, \qquad \text{APY} = \left(1 + r_{\text{perSecond}}\right)^{31{,}536{,}000} - 1

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:

reserves accrue at
\dot{R} = r_b \cdot \text{PV}_{\text{borrow}} - r_s \cdot \text{PV}_{\text{supply}} = \big(r_b \cdot U - r_s\big)\cdot \text{PV}_{\text{supply}}
\text{implied reserve factor} = \dfrac{r_b \cdot U - r_s}{r_b \cdot U} = 1 - \dfrac{r_s}{r_b\,U}
Worked example — reserves from the spread

Pool: PV_supply = $20,000, PV_borrow = $12,000U = 0.60. Suppose at this U the curves give borrow APR 5.30% and supply APR 2.40%:

\dot{R}_{\text{yr}} = 0.0530\cdot 12{,}000 - 0.0240\cdot 20{,}000 = 636 - 480 = \$156\;/\text{yr}
\text{implied reserve factor} = 1 - \dfrac{0.0240\cdot 20{,}000}{0.0530\cdot 12{,}000} = 1 - \dfrac{480}{636} = 24.5\%

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.

at U=0.60 → borrow supply spread
Summary
  • 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_s between the borrow and supply curves.
Self-check
  1. At U = 0 what are r_b and r_s in terms of the parameters?
  2. Why does the borrow slope steepen past the kink rather than staying linear?
  3. 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

accruedInterestIndices(timeElapsed)
\Delta t = \text{now} - \text{lastAccrualTime}
\text{baseSupplyIndex} \mathrel{+}= \left\lfloor \text{baseSupplyIndex}\cdot r_s(U)\cdot \Delta t \right\rfloor, \quad \text{baseBorrowIndex} \mathrel{+}= \left\lfloor \text{baseBorrowIndex}\cdot r_b(U)\cdot \Delta t \right\rfloor

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 = balance − net base owed to users
R = \underbrace{\text{baseToken.balanceOf(Comet)}}_{\text{actual cash held}} - \Big(\text{PV}_{\text{supply}}(\text{totalSupplyBase}) - \text{PV}_{\text{borrow}}(\text{totalBorrowBase})\Big)

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.

Worked example — a multi-second index trace

Start: baseBorrowIndex = 1.000000e15, borrow rate r_b = 1.5e−9/sec (≈4.73% APR). Three gaps:

\begin{aligned} t{+}10\text{s}:&\; I \mathrel{+}= I\cdot 1.5\times10^{-9}\cdot 10 = 1.000000015\times10^{15}\\ t{+}50\text{s}:&\; I \mathrel{+}= I\cdot 1.5\times10^{-9}\cdot 40 = 1.000000075\times10^{15}\\ t{+}3600\text{s}:&\; I \mathrel{+}= I\cdot 1.5\times10^{-9}\cdot 3550 \approx 1.000005400\times10^{15} \end{aligned}

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.

final index final debt interest
Summary
  • Accrual is lazy: indices fast-forward by Δt·rate only when the protocol is touched.
  • Four indices advance together — two for interest, two for COMP rewards — all ×1e15.
  • Reserves are not stored; getReserves derives cash − net user liabilities and may be negative.
Self-check
  1. If nobody touches a market for a week, are borrowers accruing interest? Where does that interest "live"?
  2. Why does applying a simple per-second rate to the index still produce compounding over many gaps?
  3. Give a state where getReserves is 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:

repayAndSupplyAmount(oldPrincipal, amount)
\text{repayAmount} = \min\!\big(\max(-\text{oldPrincipalPV},\,0),\; \text{amount}\big), \quad \text{supplyAmount} = \text{amount} - \text{repayAmount}

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.

solidity
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.

V2 → V3 contrast

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.

Worked example — supplying base while in debt

Alice owes $300 base (principal ≈ −300e6 at index 1) and supplies $500 USDC:

  • repayAmount = min(300, 500) = $300 → clears the debt, totalBorrowBase −= 300e6
  • supplyAmount = 500 − 300 = $200 → new positive supply, totalSupplyBase += 200e6
  • Her signed principal crosses zero: −300e6 → +200e6
Summary
  • Base supply auto-repays existing debt first; the surplus becomes new supply, and the signed principal may cross zero.
  • doTransferIn credits the actually-received amount, making fee-on-transfer tokens safe.
  • Collateral supply is inert — capped, bit-flagged in assetsIn, and never interest-bearing.
Self-check
  1. A borrower owing $1,000 supplies exactly $1,000 of base. What's their resulting principal?
  2. Why must doTransferIn measure balance deltas instead of trusting amount?
  3. What does updateAssetsIn do 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

withdrawAndBorrowAmount(oldPrincipal, amount)
\text{withdrawAmount} = \min\!\big(\max(\text{oldPrincipalPV},\,0),\; \text{amount}\big), \quad \text{borrowAmount} = \text{amount} - \text{withdrawAmount}

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, revert BorrowTooSmall. 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 reverts NotCollateralized.

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.

Worked example — supply, then borrow through zero

Bob supplies $2,000 base (principal +2,000e6), then withdraws $5,000:

  • withdrawAmount = min(2,000, 5,000) = $2,000totalSupplyBase −= 2,000e6, principal → 0
  • borrowAmount = 5,000 − 2,000 = $3,000totalBorrowBase += ~3,000e6 (ceiling principal), principal → ≈ −3,000e6
  • Post-action isBorrowCollateralized must hold given his collateral, or the call reverts.
Correction worth flagging

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.

Summary
  • Borrowing = withdrawing base past your supply; the principal simply crosses into negative territory.
  • withdrawAndBorrowAmount splits the call into a supply-reduction part and a debt-creation part.
  • Two gates protect the protocol: baseBorrowMin (no dust loans) and a post-action isBorrowCollateralized health check.
Self-check
  1. Why does V3 not need a separate borrow() function?
  2. What error reverts a $5 borrow if baseBorrowMin is $100, and why does that floor exist?
  3. When is isBorrowCollateralized evaluated 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 / transferAssetFromtransferCollateral: 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.

solidity
// 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.

Architecture note

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.

Summary
  • 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.
  • allowBySig is EIP-712 with malleability guards, a monotonic nonce, and an expiry.
Self-check
  1. Why does transferring the base asset run a health check but supplying it never does?
  2. What three things stop an allowBySig signature from being replayed or forged?
  3. 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.

USD values (each ×1e8)
V_{\text{borrow}} = B\cdot \dfrac{p_{\text{base}}}{\text{baseScale}}, \qquad V_i = q_i\cdot \dfrac{p_i}{\text{scale}_i}
  • B — present value of the borrow (the magnitude of negative principal), in base units
  • q_i — collateral balance of asset i; p_i — price ×1e8; scale_i = 10^(decimals)
the two health checks
\textbf{isBorrowCollateralized} \iff \sum_i \tfrac{V_i\, f_i^{b}}{\text{FACTOR\_SCALE}} \;\ge\; V_{\text{borrow}}
\textbf{isLiquidatable} \iff \sum_i \tfrac{V_i\, f_i^{l}}{\text{FACTOR\_SCALE}} \;<\; V_{\text{borrow}}

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.

Health at WETH = $3,000 (scaled-integer machinery)
trace
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):

\textbf{Liquidation trigger:}\quad -5000 + 10P\cdot 0.90 < 0 \;\Rightarrow\; 9.0\,P < 5000 \;\Rightarrow\; P < \$555.56
\textbf{Borrow gate fails:}\quad -5000 + 10P\cdot 0.83 < 0 \;\Rightarrow\; 8.3\,P < 5000 \;\Rightarrow\; P < \$602.41

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:

trace
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.

borrow-gate price liquidation price buffer band
Summary
  • 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.
Self-check
  1. For Alice at 10 WETH / $5,000 debt / LCF 0.90, re-derive the liquidation price.
  2. Why does the health loop early-exit, and what does that save?
  3. 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

absorbInternal — per seized asset
\Delta V_i = \text{mulFactor}\!\big(V_i,\; f_i^{l}\big) = V_i \cdot \dfrac{\text{liquidationFactor}_i}{10^{18}}
\text{newPrincipal} = \big(-\,\text{debt} + \textstyle\sum_i \Delta V_i\big)\ \text{converted to base principal}
\text{if newPrincipal} < 0 \;\Rightarrow\; \text{clamp to } 0 \quad (\textbf{bad-debt path: no supply created})
  • V_i — USD value of seized collateral asset i
  • f_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.
Worked example — Alice's price drop into liquidation

Alice: 10 WETH, $5,000 debt, liquidationFactor LF = 0.93.

Situation 1 — healthy seizure (WETH = $550)
\sum \Delta V = 10\cdot 550\cdot 0.93 = \$5{,}115 \;>\; \$5{,}000 \;\Rightarrow\; \text{newPrincipal} = +\$115 \text{ supply to Alice}

Debt cleared; Alice keeps $115 of borrower-supply; 10 WETH moves to collateral reserves.

Situation 2 — bad debt (WETH = $400)
\sum \Delta V = 10\cdot 400\cdot 0.93 = \$3{,}720 \;<\; \$5{,}000 \;\Rightarrow\; \text{newPrincipal}=-1{,}280 \to \textbf{clamp } 0

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.

Summary
  • absorb hands 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 liquidationFactor haircut; if that's below the debt, newPrincipal clamps 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.
Self-check
  1. At what WETH price does Alice's absorb exactly break even (surplus = 0) with LF 0.93?
  2. Why does the protocol clamp newPrincipal to 0 instead of letting it go negative?
  3. 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

quoteCollateral — discount derivation
\text{discountFactor} = \text{storeFrontPriceFactor}\times\big(1 - \text{liquidationFactor}\big)
\text{discountedPrice} = \text{marketPrice}\times\big(1 - \text{discountFactor}\big)
  • storeFrontPriceFactor — governance knob ×1e18; splits the liquidation penalty between keeper and reserves
  • At storeFront = 1 keepers get the whole penalty (fast clearing); at 0 there's no discount and no keeper incentive at all
Worked example — Alice S1, keeper buys all 10 WETH

WETH market $550, LF 0.93, storeFront 0.5. Reserves are below target (the absorb drained them), so the store front is open.

\text{discountFactor} = 0.5\times(1-0.93) = 0.035 \;(3.5\%)
\text{discountedPrice} = 550\times(1-0.035) = \$530.75 \text{ per WETH}
\text{pay} = 10\times 530.75 = \$5{,}307.50 \text{ USDC}, \quad \text{receive} = 10\text{ WETH} = \$5{,}500 \text{ market}
\textbf{keeper profit} = 5{,}500 - 5{,}307.50 = \$192.50 \text{ (the 3.5\%), minus gas}

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.

Tuning intuition

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.

discount discounted price max profit
Summary
  • Keepers buy seized collateral at marketPrice·(1 − storeFront·(1 − LF)), paying base that recapitalizes reserves.
  • storeFrontPriceFactor splits 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.
Self-check
  1. Compute the discounted price for WETH $1,000, LF 0.90, storeFront 0.6.
  2. Why does buyCollateral exist as a separate phase instead of paying keepers directly in absorb?
  3. 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

getReserves() — the equity identity
R = \text{balanceOf(Comet)} - \big(\text{PV}_{\text{supply}}(\text{totalSupplyBase}) - \text{PV}_{\text{borrow}}(\text{totalBorrowBase})\big)

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

solidity
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.

Worked examples — reserves in motion (four situations)
1 · Spread accrual (no liquidation), one year
\Delta R = 0.0530\times 12{,}000 - 0.0240\times 20{,}000 = 636 - 480 = +\$156
2 · Healthy liquidation (Alice S1: WETH $550, LF 0.93, storeFront 0.5)
trace
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)
trace
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
\text{anyone sends }1{,}000\text{ USDC directly} \Rightarrow \Delta R = +\$1{,}000 \text{ (no liability created)}

Benign — you can donate to reserves or top up a bad-debt market; it's why balance can exceed what positions imply.

Summary
  • Reserves = cash − net base owed; derived live, can be negative, and exclude collateral value until it's sold.
  • targetReserves gates buyCollateral so sales happen only while reserves are below target — counter-cyclical recapitalization.
  • withdrawReserves is governor-only, base-only, and blocked on negative or over-withdrawal.
Self-check
  1. Right after a bad-debt absorb, why does getReserves understate the protocol's true equity?
  2. Why can't the governor withdraw reserves when they're negative?
  3. What stops buyCollateral from 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

in accrueInternal (gated by baseMinForRewards)
\Delta\text{trackingSupplyIndex} = \dfrac{\text{baseTrackingSupplySpeed}\times \Delta t\times \text{baseScale}}{\text{totalSupplyBase}} = \text{divBaseWei}(\text{speed}\cdot \Delta t,\ \text{totalSupplyBase})
solidity
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

updateBasePrincipal
\text{baseTrackingAccrued} \mathrel{+}= \dfrac{\text{principal}\times(\text{trackingIndex} - \text{baseTrackingIndex})}{\text{trackingIndexScale}\times \text{accrualDescaleFactor}}

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):

\text{total reward/sec} = \sum_i p_i\cdot \dfrac{\text{speed}\times \text{baseScale}/\text{totalSupplyBase}}{\text{trackingIndexScale}\cdot \text{descale}} = \dfrac{\text{speed}\times \text{BASE\_ACCRUAL\_SCALE}}{\text{trackingIndexScale}}

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.

your share your COMP
Summary
  • Comet advances tracking indices and banks baseTrackingAccrued; CometRewards pays COMP from a DAO-funded balance — never minted on claim.
  • updateBasePrincipal is MasterChef reward-debt accounting: one global index serves all users in O(1).
  • Emission speed fixes the total pie independent of pool size; baseMinForRewards gates distribution and protects the index denominator.
Self-check
  1. If totalSupplyBase doubles with speed unchanged, what happens to total COMP emitted and to your per-dollar rate?
  2. Is COMP minted when you claim? Where does it come from?
  3. 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.

solidity
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));
}
reading bit i
\text{isXPaused} = \big(\text{pauseFlags}\ \&\ (1 \ll \text{offset}_X)\big) \ne 0

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.

Summary
  • 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.
Self-check
  1. How many independent action-pause states fit in pauseFlags, and why a bitfield?
  2. If a guardian pauses supply, can a borrower still repay? Why?
  3. 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.

solidity
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.

Staleness & edges

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.

Worked example — combining feed + balance

WBTC balance 0.5e8 (8 decimals), feed returns 60000e8:

V = \dfrac{0.5\times10^{8}\cdot 60000\times10^{8}}{10^{8}} = 3\times10^{12} \;\Rightarrow\; \$30{,}000
Summary
  • Each priced asset has an immutable feed; getPrice returns a 1e8-scaled USD answer and reverts on non-positive prices.
  • The universal 1e8 convention lets mulPrice combine 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.
Self-check
  1. Why does standardising prices to 1e8 simplify mulPrice across assets of different decimals?
  2. What does getPrice revert on, and what does it not check?
  3. 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

flow
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.

Why this design

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.

Summary
  • Risk params are immutables in the implementation's bytecode — cheap reads, but changes require a redeploy.
  • The flow is Configurator (editable Configuration struct) → CometFactory (compiles a new impl) → ProxyAdmin (re-points the proxy).
  • Users keep the same proxy address and storage across upgrades.
Self-check
  1. Why don't on-chain risk checks pay any storage-read gas for collateral factors?
  2. Trace the three contracts a collateral-factor change passes through.
  3. 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.

solidity
// 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.
V2 → V3 contrast

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.

Summary
  • CometExt holds 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.
Self-check
  1. Why must CometExt be reached by delegatecall rather than a plain call?
  2. What does balanceOf return for an account that is currently borrowing?
  3. 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)

\left\lceil \dfrac{a}{b} \right\rceil = \left\lfloor \dfrac{a + b - 1}{b} \right\rfloor \quad (a \ge 0,\ b > 0)

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.

Worked example — dust that rounds to zero

Supply present value v = 1 base unit, baseSupplyIndex = 3e15:

\text{PrV}_{\text{supply}} = \left\lfloor \dfrac{1\cdot 10^{15}}{3\times10^{15}} \right\rfloor = \lfloor 0.333\rfloor = 0

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.

Summary
  • 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 CometMath revert on overflow, critical at principal zero-crossings.
Self-check
  1. State the one-line invariant that fixes every rounding direction in Comet.
  2. Which single quantity rounds up, and what would break if it rounded down?
  3. 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)

\text{transfer base directly to Comet} \Rightarrow \Delta R = +\text{amount},\quad \text{no position created}

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%: getUtilization returns 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.
  • absorb always seizes the whole position (there's no partial absorb), but buyCollateral can be partial — keepers take any slice, leaving the rest in reserves.
  • If reserves are healthy, buyCollateral reverts NotForSale, so seized collateral can sit unsold while getReserves understates 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).

Summary
  • 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 reserves in general.
  • Floors (baseBorrowMin), the kink, full-position absorb vs partial buys, and checks-effects-interactions together harden the edges.
Self-check
  1. Why does isolating collateral limit oracle-manipulation damage compared to V2?
  2. Can absorb seize only part of a position? Can buyCollateral buy only part?
  3. 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 CometExt exists).
  • EIP-712 — typed-data signing used by allowBySig.