Compound V2 Proposal 62 COMP distribution index-initialization bug (2021-09-29)
- Name: Compound V2 Proposal 62 COMP distribution index-initialization bug
- Date: 2021-09-29
- Protocol: Compound V2
- Chain: Ethereum
- Category: reward-index initialization failure / incentive-accounting bug / governance-upgrade regression / flywheel sentinel mismatch
- Loss: no user principal was at risk, but Compound’s reward distributor was. Compound later said
163k COMPhad already been incorrectly claimed and returned to the community timelock, about130k COMPstill sat in the Comptroller and could have been incorrectly claimed, and later cleanup analysis identified74affected accounts with about70.514M COMPof over-accrual on paper, dominated by one address at about70.025M COMP; most of that accounting fiction was never withdrawable only because the Comptroller did not actually hold that much COMP - Summary: Compound Proposal 62 split COMP emissions into separate supplier-side and borrower-side speeds and, during upgrade, initialized market reward indices to
compInitialIndex = 1e36. The bug was thatdistributeSupplierComp()anddistributeBorrowerComp()only rewrote a user’s zero index tocompInitialIndexwhen the market index was strictly greater thancompInitialIndex. On market sides where the global index was exactly1e36, the equality case fell through. A user withsupplierIndex == 0orborrowerIndex == 0was then treated as if they had missed the full jump from0to1e36, and the flywheel multiplied that fake delta by the user’s full supply or borrow balance. The officially identified affected markets werecTUSD,cMKR,cSUSHI,cYFI,cAAVE, andcSAI. The result was absurd COMP accrual entries and a drain on the real COMP inventory held by the Comptroller. - Trigger / entry point:
- Proposal 62 executed on 2021-09-29 and upgraded Compound’s Comptroller reward logic.
- The upgrade path
_upgradeSplitCompRewards()initialized market supply and borrow state indices tocompInitialIndexwhere they had previously been0. - Later reward-touch paths
distributeSupplierComp()anddistributeBorrowerComp()handled user indices with the boundary checkif (supplierIndex == 0 && supplyIndex > compInitialIndex)and the analogous borrower-side condition. - That strict
>comparison missed the exact case created by initialization:marketIndex == compInitialIndex.
- Root cause:
- Compound overloaded numeric sentinel values instead of using a clearer explicit initialization flag.
- The upgrade correctly initialized market-wide reward state, but lazy user initialization disagreed with that new state at the equality boundary.
- The flywheel therefore treated
user never initializedanduser should begin at market baselineas different states in some places and the same state in others. - The governance process also failed to catch the migration bug before execution; Proposal 64’s own retrospective analysis explicitly identified the missing equality checks as the defect.
- Exploit path:
- Proposal 62 executed and moved Compound to split supplier/borrower COMP speeds.
- As part of the upgrade, markets with uninitialized reward state had their
compSupplyState.indexandcompBorrowState.indexset to1e36(compInitialIndex). - Some accounts still had per-user checkpoints
compSupplierIndex[market][user] == 0orcompBorrowerIndex[market][user] == 0when they next touched the flywheel. - On first touch,
distributeSupplierComp()/distributeBorrowerComp()updated the stored user index to the current market index, but because the branch used>instead of>=, it failed to normalize the localsupplierIndex/borrowerIndexvariable from0up to1e36before computing the delta. - The delta index was therefore treated as roughly
1e36 - 0rather than1e36 - 1e36. - Compound multiplied that gigantic fake delta by the user’s token balance or borrow balance and credited bogus COMP accrual.
- Any user who then claimed COMP could pull real COMP out of the Comptroller until the distributor balance ran out.
- Proposal 64 later patched the boundary (
>→>=) and temporarily blocked potentially affected accounts from transferring COMP, while Proposal 65 corrected stored over-accruals.
- What the attacker needed to be true:
- They needed to touch a market side whose global reward index had been initialized to
compInitialIndexwhile their personal reward index still read0. - The affected claim or interaction path needed to compute reward using the mismatched local checkpoint before a later patch corrected it.
- The Comptroller needed to still hold real COMP inventory for bogus accrual to turn into an actual outflow.
- No flash loan, price manipulation, governance capture, or reentrancy was required once the bug was live; ordinary flywheel interaction was enough.
- They needed to touch a market side whose global reward index had been initialized to
- Technical anatomy worth remembering:
- This was not an emission-rate bug in the sense of
governance voted too much COMP. It was a checkpoint-initialization bug in the reward ledger itself. - Proposal 62’s own rollout notes said the new split-speed flywheel and initialization changes had been live on Ropsten for nearly a month, which makes the incident a good reminder that testnet soak is not the same thing as fork-state migration testing against real dormant users.
- The equality case mattered. Proposal 64’s critical patch was literally changing
>to>=in both supplier and borrower distribution code. - Proposal 64 also shows how incident response stayed constrained by integration reality: Compound did not simply revert all
claimComp()calls, because third-party integrations depended on claim behavior. Instead it restored claims only for users outside the affected markets and added a pessimistic transfer guard while the cleanup list was prepared. - Compound’s own Proposal 64 patch diff is a particularly useful primary artifact because it shipped both the one-character boundary fix and a forked
hypothetical_mainnet_upgrade.scenreplay. That simulation explicitly reproduced the bug on mainnet-fork state (Geoffclaiming about988 COMPfrom the bad path), then showed the patched Comptroller refusing payout for an affected-market claimer while still allowing an unaffected-market claimer to receive ordinary rewards. - The incident is a strong reminder that on-paper reward corruption can be much larger than the immediately liquid token balance at risk. Proposal 65’s later cleanup list showed about
70.514M COMPof over-accrual across74accounts, even though actual Comptroller inventory was far smaller. - One address,
0xb3bd459e0598dde1fe84b1d0a1430be175b5d5be, was later listed as over-accrued by about70,024,581.827215461300286636 COMP, which makes the accounting failure memorable even if the real distributor balance capped extraction far below that amount.
- This was not an emission-rate bug in the sense of
- Representative governance artifacts and addresses:
- Governance change that introduced the bug: Proposal
62(Split COMP rewards distribution and bug fixes) — executed2021-09-29, proposerTyler Loewen,729,781votes for and0against per Compound’s governance record. - Emergency follow-up proposal that would have paused transfers but was later canceled: Proposal
63(Temporarily Disable COMP Rewards) — canceled2021-10-06after1,045,810votes for and162,221against. - Main patch proposal: Proposal
64(Fix COMP Accrual Bug) — executed2021-10-08, proposerTyler Loewen,1,037,107votes for and0against. - Cleanup / correction proposal: Proposal
65(Correct Over-Accrued COMP) — executed2021-10-19, introduced the one-offfixBadAccruals()cleanup path pluscompReceivablebookkeeping and theCompReceivableUpdated/CompAccruedAdjustedevents. - Comptroller targeted by Proposals 62 / 64 / 65:
0x3d9819210A31b4961b30EF54bE2aeD79B9c9Cd3B - Major over-accrual address later enumerated in Proposal 65 discussion:
0xb3bd459e0598dde1fe84b1d0a1430be175b5d5be - Key upgrade helper removed by the patch diff:
_upgradeSplitCompRewards() - Key affected functions:
distributeSupplierComp(),distributeBorrowerComp(), and latergrantCompInternal() - Critical constant:
compInitialIndex = 1e36 - Officially identified affected markets during containment:
cTUSD,cMKR,cSUSHI,cYFI,cAAVE,cSAI - Critical code fix from Proposal 64:
if (supplierIndex == 0 && supplyIndex > compInitialIndex)→if (supplierIndex == 0 && supplyIndex >= compInitialIndex)and the analogous borrower-side change
- Governance change that introduced the bug: Proposal
- Response / mitigation:
- Compound publicly disclosed unusual COMP distribution activity shortly after Proposal 62 execution and emphasized that supplied and borrowed user funds were not at risk.
- Proposal 63 attempted to stop COMP transfers entirely but was canceled because reverting
claimComp()would have broken integrations. - Proposal 64 instead patched the boundary bug, restored claims only for unaffected users / markets, and added a temporary guard in
grantCompInternal()that refused transfers for accounts likely affected by the bug. - Proposal 65, executed on 2021-10-19, later corrected over-accrued accounting, introduced record-keeping for amounts owed back (
compReceivable), emitted explicit adjustment events, and restored normal claims once the cleanup list had been applied. - Compound’s own treasury update publicly summarized the realized and still-at-risk balances as
163k COMPincorrectly claimed and returned,130k COMPstill in the Comptroller, and roughly200kmore COMP misallocated on paper, giving a useful primary-source checkpoint for the incident’s real versus theoretical damage. - The community and Compound contributors explicitly treated the incident as a process failure as well as a code failure, calling for more rigorous simulation and review of governance upgrades.
Pattern tags
- reward-index-initialization-failure
- Governance-delivered reward-ledger regression / proposal execution changed market initialization semantics without proving first-touch user checkpoints still agreed at the equality boundary
- Sentinel-state mismatch /
0 == uninitialized userand1e36 == initialized marketwere treated as compatible in some paths and incompatible in others - Bounded-outflow but unbounded-ledger-fiction / the accounting error could print absurd COMP accrual entries far beyond actual Comptroller inventory, with realized loss capped only by how much COMP the distributor really held
- Reward-payout rail trusted freshly migrated accrual entries as if they were already economic truth / the transfer path had no independent plausibility bound once a first-touch checkpoint disagreement printed impossible COMP
Why the exploit worked
- Proposal 62 changed the flywheel’s state machine in two different places that no longer agreed.
_upgradeSplitCompRewards()eagerly moved market supply and borrow indices tocompInitialIndex = 1e36, but lazy first-touch logic for users still reasoned about0checkpoints with a strict>boundary. That meant Compound had one answer formarket initializedand another foruser initializedat exactly the same baseline value. - The equality case was economically decisive. When
marketIndex == compInitialIndexanduserIndex == 0,distributeSupplierComp()anddistributeBorrowerComp()should have normalized the user to the same baseline before computing any delta. Because they did not, the flywheel treated an untouched account as if it had missed the entire synthetic jump from0to1e36. - The bug paid out real value because bogus accrual entries were still trusted by the payout rail. Once the accounting layer credited impossible COMP,
claimComp()andgrantCompInternal()would transfer real distributor inventory until the Comptroller balance ran out. - This was therefore not mainly an emissions-parameter mistake; it was a coherence failure between migration logic, lazy checkpoint initialization, and payout authorization. Proposal governance was only the delivery rail for a flywheel whose sentinel conventions had already become unsafe.
Three reward-authority promotions this incident collapsed
-
The market-side flywheel published a baseline reward index
- Proposal 62’s
_upgradeSplitCompRewards()moved affectedcompSupplyState.indexandcompBorrowState.indexvalues from0tocompInitialIndex = 1e36. - That only established that the market side now had a live baseline. It did not yet prove that every dormant supplier and borrower checkpoint meant the same thing.
- Proposal 62’s
-
Compound treated dormant user checkpoints as if they were already normalized to that same baseline
- The dangerous disagreement lived here.
distributeSupplierComp()anddistributeBorrowerComp()stored the new market index into the user checkpoint, but because the local normalization branch used>instead of>=, the computation still treateduserIndex == 0as if the user had missed the entire jump from0to1e36. - In other words,
market initializedwas over-promoted intothis user is aligned to the initialized baseline, even though the equality case had not actually been reconciled.
- The dangerous disagreement lived here.
-
The payout rail still honored that synthetic delta as immediately transferable COMP
- Once the flywheel wrote the impossible accrual entry,
claimComp()andgrantCompInternal()were still willing to move real COMP until the Comptroller balance ran out. - Proposal 64’s temporary guard is the clean proof of where the third promotion lived: after the bug was understood, Compound did not just fix the equality boundary; it also taught the payout rail to distrust accounts that had interacted with the affected zero-original-speed markets.
- Once the flywheel wrote the impossible accrual entry,
The expensive review mistake here was flattening all three promotions into one vague rewards were initialized story. The sharper split is the market baseline exists, this exact user checkpoint has been normalized to that baseline, and the payout rail may now spend against that computed delta.
Detection and audit pivots
- Read migration helpers and first-touch reward paths as one state machine. The core review question is not
does the upgrade initialize market indices?ordoes the claim path initialize user indices?in isolation, butdo both paths assign the same meaning to zero, baseline, and accrued states? - Search reward code for magic-number sentinels and exact-boundary branches. Any pattern like
userIndex == 0 && marketIndex > initialIndexshould force an explicit review of the== initialIndexcase on both supplier and borrower sides. - Run forked-state simulations with dormant real users already holding balances in markets whose reward semantics are being changed. Clean-room tests miss precisely the pre-upgrade accounts that later realize the synthetic
0 -> initialIndexjump. - Compare post-upgrade accrual against physically plausible emissions and live distributor inventory. If one first-touch interaction can mint more rewards than the market could have emitted since initialization, the reward rail needs to fail closed long before cleanup governance is prepared.
- Ask whether payout authorization has any second opinion at all. If
claimComp()will honor a freshly migrated accrual entry without a plausibility cap, affected-market quarantine, or per-account anomaly guard, then one sentinel mismatch is already treasury risk rather than merely accounting noise. - Treat governance upgrades to reward systems like treasury-affecting migrations, not cosmetic emissions tweaks. Proposal 62 looked small at the source diff level, but it changed initialization semantics on a path that could directly empty the Comptroller’s COMP balance.
Failure-prevention checklist
- Treat reward migrations as state-machine changes, not parameter changes. If a proposal initializes market indices, splits speed maps, or changes first-touch behavior, require one review that walks market state, user state, and payout state together.
- Replace overloaded numeric sentinels with explicit initialization flags where practical. If
0and1e36still carry lifecycle meaning, review and test the exact equality boundary on both supplier and borrower paths. - Fork-test governance upgrades against live dormant accounts in every market whose reward semantics change. Proposal 62’s failure mode lived in pre-existing user checkpoints, not in fresh test accounts.
- Normalize stale user checkpoints during migration when safe to do so, instead of assuming lazy first-touch logic will reconstruct the correct baseline later.
- Put plausibility bounds at the payout rail. A first-touch claim should not be able to transfer more COMP than plausible emissions, route-local inventory, or a sane per-account threshold without failing closed or paging responders.
- Add route-local containment for newly affected markets and users. Proposal 64’s temporary transfer guard is the right shape of lesson: a reward bug should be isolatable by market or checkpoint class without needing to brick every claim path integration-wide.
- Ship scenario tests that force
marketIndex == compInitialIndexwithuserIndex == 0on both supply and borrow sides and assert zero accrual. The critical branch here was a one-character boundary change. - Reconcile distributor inventory against recorded accrual after upgrades so impossible paper balances do not remain hidden behind the fact that the treasury is smaller than the accounting error.
Audit prompts
- What exact values mean
uninitialized,initialized at baseline, andalready accruedforcompSupplyState,compBorrowState,compSupplierIndex, andcompBorrowerIndex, and do all code paths agree on those meanings? - If a market-side index is initialized to
compInitialIndex, what exact first-touch paths prove a user with checkpoint0cannot ever realize a synthetic0 -> 1e36reward delta? - Were governance simulations run against forked mainnet state with dormant suppliers and borrowers in every newly initialized reward market, or only against clean-room scenario users?
- Can
claimComp()orgrantCompInternal()pay out fresh accrual immediately after migration without any plausibility check, affected-market quarantine, or anomaly guard? - Do supplier and borrower migrations share the same sentinel semantics, or could one side still preserve an unsafe
>/>=boundary even if the other is fixed? - If the accounting layer writes an absurd reward balance, what concrete control limits loss first: inventory exhaustion, a market-local blocklist, a claim pause, or nothing until governance lands?
- What tests cover markets with zero original COMP speed, dormant users, and state that predates the migration helper
_upgradeSplitCompRewards()? - If reviewers mutate only the equality boundary around
compInitialIndex, do end-to-end tests fail loudly enough to make the risk obvious before proposal execution?
Lessons that should already be standard
- Governance-delivered code can still be migration code, and migration code is treasury code when it feeds a live payout rail.
- Reward systems need one coherent answer to
initializedacross global state, user checkpoints, and transfer logic. If those answers differ, users can often cash the disagreement. The Comptroller only held so much COMPis not a comforting control. Inventory limits cap realized loss; they do not make the accounting model healthy.- Equality boundaries deserve production-grade paranoia. A missing
=can be the whole exploit when sentinel values encode state transitions. - Reward claims need a second opinion after major upgrades. Plausibility bounds, market-local quarantine, and anomaly monitoring are not optional once migrated accrual can immediately become transferable tokens.
- Testnet soak and unit tests are weaker than fork-state replay when the real risk lives in dormant accounts and mixed pre/post-upgrade checkpoint state.
Why this incident matters
- Compound preserves a canonical reward-flywheel migration failure where the exploit surface was not APR math, not a dust denominator, and not a price oracle. It was one wrong sentinel boundary in a live governance upgrade.
- It is especially useful because the bug was mechanically tiny and economically huge: a single missing equality check let a normal rewards system disagree with itself about what
initializedmeant. - It is also a durable bounded-loss lesson. The accounting fiction became spendable because the payout rail trusted the new accrual entry immediately, so the live distributor inventory became the only thing containing a logically much larger error.
- The note also matters for governance practice. Proposal systems often feel safer than ad hoc admin pushes, but this incident shows that reward-index migrations still need forked-state simulations with real user balances and boundary-state tests.
Internal linkages
- Core recurring pattern: reward-index-initialization-failure.
- Positive-design companion: reward-flywheel-initialization-and-checkpoint-coherence — the sharper healthy-side follow-up is how market initialization, user-checkpoint normalization, and payout authorization should have behaved like one reward-state machine instead of three loosely coupled sentinel conventions.
- Positive-design companion: reward-entitlement-identity-and-batch-canonicalization — useful when the follow-up question is how payout rails should treat one accrued reward right as a canonical entitlement object instead of blindly trusting whichever freshly written accounting row says
owed now. - Adjacent reward-accounting family: reward-debt-desynchronization-denominator-collapse-reward-amplification.
- Adjacent governance/process family: governance-capture is not the right home mechanically, but this incident is a good reminder that governance execution can still be the delivery path for catastrophic accounting bugs without governance itself being the exploit primitive.
Control surface
-
The decisive leverage sat in Compound’s reward-index migration boundary: how
_upgradeSplitCompRewards()initialized market state, howdistributeSupplierComp()anddistributeBorrowerComp()interpreted zero user indices, and whethergrantCompInternal()would honor bogus accrual entries with real COMP. -
The protocol did not fail because users discovered some exotic economic loop. It failed because the flywheel’s market state and user state stopped agreeing on what
initializedmeant at exactly1e36. -
Sources:
- https://compound.finance/governance/proposals/62
- https://compound.finance/governance/proposals/64
- https://compound.finance/governance/proposals/65
- https://www.comp.xyz/t/analysis-of-proposal-64/2384
- https://www.comp.xyz/t/proposal-65-correct-over-accrued-comp/2428
- https://compound.substack.com/p/compound-treasury-updates-comp-bug
- https://github.com/compound-finance/compound-protocol/pull/165.diff
- https://x.com/compoundfinance/status/1443359184897069060
-
Last reviewed: 2026-08-03 UTC