All articles
The BuildMay 17, 20268 min read

Lazy Interest Accrual: How Compound V2 Scales to Millions of Borrowers

Compound V2 accrues interest in O(1) per market, no matter how many borrowers exist. The trick is a global borrowIndex plus per-account snapshots. Walking through the math and what forks get wrong.

By Carlos (Bloqarl)

TL;DR

  • Compound V2 accrues interest in O(1) per market, not O(N borrowers). Updating one number per market settles every borrower's debt at once.
  • The mechanism is a global borrowIndex per cToken market, plus a per-account snapshot of the index value at the moment each borrow was opened.
  • Each borrower's current debt is reconstructed on demand: currentDebt = principal * (currentIndex / accountIndex). No iteration, no per-account state writes.
  • This is what lets one Compound V2 market support hundreds of thousands of borrowers without becoming a gas-grief target.
  • Forks routinely change the rate model, the accrual cadence, or the storage of the index, and break the math without realizing. The bug usually shows up months later when a power user notices their debt is wrong by a factor of 1e10.

Why this matters

A naive lending protocol would keep a debt mapping per borrower and update it every block. With 100,000 borrowers across a market, every block of interest accrual would cost 100,000 storage writes. Gas costs alone would put the market on a death spiral within hours.

Compound V2 sidesteps this with a clever indirection: the protocol does not store each borrower's current debt. It stores the snapshot of a global index at the moment each borrow was opened, and computes the current debt by ratio.

This is the kind of math that looks trivial in retrospect but is genuinely hard to design from scratch. The choices Compound V2 made about which numbers to store, when to update them, and what mantissa conventions to use all interact to produce the O(1) per-market property. Forks that modify any of those choices, even by a small amount, often break the property without noticing.

The mechanism

Two pieces of state matter for borrowing.

Per market (in the cToken contract):

uint public borrowIndex;          // global, monotonically increasing
uint public accrualBlockNumber;   // last block we accrued interest at
uint public totalBorrows;          // total debt outstanding, in underlying units

Per account (in the cToken's accountBorrows mapping):

struct BorrowSnapshot {
    uint principal;          // outstanding debt in underlying, at last interaction
    uint interestIndex;      // borrowIndex value at the moment of last interaction
}
mapping(address => BorrowSnapshot) public accountBorrows;

The two indices, borrowIndex (current global) and interestIndex (per-account snapshot at last interaction), are the heart of the system.

Accrual: O(1) per market

When accrueInterest() runs (it is called by every state-changing entry point as a modifier), it does this:

uint blockDelta = currentBlockNumber - accrualBlockNumber;
uint borrowRate = interestRateModel.getBorrowRate(cash, totalBorrows, totalReserves);
uint interestFactor = borrowRate * blockDelta;            // both 1e18-scaled
uint interestAccumulated = (interestFactor * totalBorrows) / 1e18;

totalBorrows = totalBorrows + interestAccumulated;
borrowIndex   = borrowIndex + (interestFactor * borrowIndex) / 1e18;
totalReserves = totalReserves + (interestAccumulated * reserveFactor) / 1e18;
accrualBlockNumber = currentBlockNumber;

That is the entire accrual. Three storage writes (totalBorrows, borrowIndex, totalReserves), one block-number update. The cost is constant regardless of borrower count.

borrowIndex is the magic. It started at 1e18 when the market was deployed. Every accrual multiplies it by (1 + rate * timeDelta). Over years, it drifts upward. Today's index might be 1.073e18 (a 7.3% accumulated growth from genesis). Six months later, 1.078e18. The index is the "exchange rate" between principal-at-deposit and current debt.

Per-account read: also O(1)

When the protocol needs to know how much an account currently owes, it does not look up "current debt". It computes it:

function borrowBalanceCurrent(address account) external returns (uint) {
    accrueInterest();
    BorrowSnapshot storage snapshot = accountBorrows[account];
    if (snapshot.principal == 0) return 0;

    // Their original debt, scaled by the ratio of how much
    // the global index has grown since they last touched it.
    return snapshot.principal * borrowIndex / snapshot.interestIndex;
}

Three multiplications and a divide. Constant gas, constant time. This is the function the rest of the protocol calls when it needs to know an account's debt.

Per-account write: also O(1)

When a borrower interacts with their loan (borrow more, repay some, get liquidated), the protocol updates their snapshot to reflect the current state:

function borrowFresh(address borrower, uint borrowAmount) internal {
    accrueInterest();

    BorrowSnapshot storage snapshot = accountBorrows[borrower];
    uint accountBorrowsPrior = snapshot.principal * borrowIndex / snapshot.interestIndex;
    uint accountBorrowsNew   = accountBorrowsPrior + borrowAmount;

    snapshot.principal     = accountBorrowsNew;
    snapshot.interestIndex = borrowIndex;     // re-anchor to current index

    totalBorrows = totalBorrows + borrowAmount;
}

The borrower's debt is recomputed at current rates, the new borrow is added, and the snapshot is re-anchored to the current index. From this point forward, their debt grows at the new (post-borrow) rate.

The pattern is identical for repay: decrement the principal, re-anchor the index. There is no global iteration. Other borrowers' positions are completely untouched.

What forks get wrong

Compound V2 has been forked dozens of times. The mistakes cluster:

Forgetting to call accrueInterest() first. A new entry point (added for a fork-specific feature) updates accountBorrows[user] without first running accrual. The user's snapshot now anchors to a stale borrowIndex, and from this point on their debt grows at the wrong rate. This bug was caught in Canto v2's audit before deployment.

Modifying the rate model without re-deriving accrual. A fork swaps the JumpRateModel for a custom one. The custom model returns rates in different units, or scaled to different time bases. The accrual math now produces nonsense. Venus's H-01 (the blocksPerYear = 2,102,400 bug) is a flavor of this: Compound assumes 15-second blocks. BSC has 3-second blocks. The constant inherited unchanged means accrual happens 5x faster than the team intended, and the rate model that was supposed to charge 5% APR is actually charging closer to 25% APR.

Storing accountBorrows differently. A fork moves the snapshot to a different storage layout for "gas optimization". The optimization changes the data type from uint to uint128 or splits the struct across two slots. Suddenly the index can overflow at certain values, or the principal silently truncates. The bug is invisible in normal operation and only fires under unusual borrow sizes.

Using exchangeRateStored() somewhere it should be exchangeRateCurrent(). This is the same bug class as missing accrueInterest(), but for the supply side (cToken redemption). Forks introduce code paths that operate on stale exchange rates because they did not realize the cached value can be hours old.

The common factor: each of these bugs is invisible in unit tests that only deal with one borrower at a time and one block of interest accrual. The bugs only manifest at scale, when the index has drifted enough that the divergence between intended and actual interest is visible.

What the math protects against

Three properties hold for canonical Compound V2's accrual:

  1. No borrower's debt depends on any other borrower's actions. This is the privacy property: someone else borrowing or repaying does not change your debt.
  2. Total borrows never exceeds the sum of individual borrows. The accrual updates totalBorrows in the same proportion as each borrower's debt grows, so the invariant is preserved.
  3. Interest accumulates at exactly the borrow rate. A 5% APR market grows the index at the rate that compounds to 5% over a year of blocks.

When forks break property 1, you get cross-borrower exploits (one user can drain another's position). When they break property 2, the protocol becomes insolvent (sum of debts exceeds protocol's accounting). When they break property 3, the protocol over-charges or under-charges, and either users leave (over-charge) or the protocol becomes a free borrowing source (under-charge).

The lazy interest design is what makes all three properties cheap to maintain. It is also what makes them easy to break if you do not understand why each piece is there.

Related questions

What does borrowIndex represent intuitively? It is the "growth factor" of debt since market genesis. If borrowIndex is 1.073e18, then one unit of principal borrowed at genesis would now owe 1.073 units. The index is a multiplicative summary of all interest accrued in the market's lifetime.

Why does each account store an interestIndex snapshot? Because the protocol needs to reconstruct the account's current debt without iterating over every block since their last interaction. The snapshot is the anchor point for the ratio math.

What happens at borrow time? The account's prior debt is computed using the old snapshot, the new borrow amount is added, and the snapshot is re-anchored to the current borrowIndex. From that point, the debt grows at the new market rate.

Does this work for any rate model? Yes, as long as the rate is expressed as "per-block borrow rate" and the model is monotonic (rate never goes negative). Compound V2 ships with a JumpRateModel (kink at 80% utilization, base 2%, multiplier 20%, jump rate 200%). Forks routinely customize the model. The accrual machinery is independent of the model choice.

What if the contract is paused? accrueInterest() still runs (it is the first thing every entry point does). The clock keeps ticking on debt regardless of whether the market is open for new borrows.

Where to see it in the source

The accrual logic lives in CToken.sol::accrueInterest(). The borrower snapshot logic is in borrowFresh() and repayBorrowFresh(). The view function is borrowBalanceStored() and its non-stale sibling borrowBalanceCurrent().

In the Zealynx Academy Compound V2 module, you implement these yourself in section 4. The test suite has 207 tests across the full module; the interest-accrual ones are some of the trickiest because they require you to correctly handle block-time math, mantissa scaling, and the snapshot re-anchoring all at once. When all of them pass, you have an O(1) lending market that can support unlimited borrowers without breaking.

Tagged

Compound V2Solidity OptimizationLending