Case: Compound COMP Over-Distribution (Proposal 62, September–October 2021)

“This is the case study to read whenever you are tempted to think a governance proposal is ‘just a config change’. Proposal 62 was a 70-line upgrade reviewed by Compound’s core team, signed off by token holders, queued behind a 2-day timelock, and executed onto one of the most-audited codebases in DeFi. It still over-paid ~$80–150M worth of COMP because a single comparison operator and a single mis-typed accounting branch survived the entire review pipeline. There was no reentrancy. There was no oracle manipulation. There was no flash loan. The protocol’s own governance signed the bug into mainnet — and once executed, an immutable, timelocked, decentralized system had no built-in way to claw it back. The aftermath, Leshner’s tweet, the IRS-disclosure threat, the partial returns — that’s the second lesson: governance-bug aftermath looks nothing like a contract-bug aftermath.”

Tags: case-study compound governance proposal-bug defi distribution historical upgradeable-contract incentive-bug Related: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-14-Governance-DAO-Security · Tuan-15-Audit-Methodology-Tooling · Case-The-DAO-Reentrancy-2016 · Case-Parity-Multisig-2017


1. At a Glance

FieldValue
DateProposal 62 executed September 29, 2021 (~UTC). Drain continued until governance patched it on October 7–9, 2021 with Proposal 64 / Proposal 65 [verify exact dates against Compound governance feed].
ProtocolCompound Finance — money-market protocol on Ethereum. Pool-based lending; users deposit collateral, borrow against it, earn COMP rewards proportional to supply/borrow activity. TVL at the time: ~$10–12B.
Loss~280,000 COMP over-distributed, valued at ~320 COMP) and quoted as high as ~80M–$150M depending on COMP price snapshot used.
Funds-at-risk totalThe vulnerable contract — the Reservoir — held ~480,000 COMP at execution time (0x2775b1c75658Be0F640272CCb8c72ac986009e38 [verify]), representing months of accumulated COMP-distribution buffer. ~280k was drained; ~200k remained at risk until governance closed the leak.
Attack classGovernance proposal logic bug — a comparison-operator / accounting-branch error in a routine, scheduled distribution-mechanics upgrade. Not an exploit of a pre-existing contract; the bug shipped as part of the upgrade itself, reviewed by governance, queued through the timelock, and executed by the protocol against its own treasury.
Root causeIn the upgraded Comptroller accrual logic — specifically the refactored distributeSupplierComp / distributeBorrowerComp path that handled the legacy “Reservoir drip” → “Comp split between supply and borrow” model — a comparison used the wrong index for “has the supplier already been credited”, causing users who had never claimed COMP to be paid as if they had supplied since the start of the program, irrespective of their actual supply duration. Equivalent in shape to a missing accounting-update step combined with a comparison that read stale state.
OutcomePatched 8–9 days after execution. Compound’s founder Robert Leshner publicly asked recipients to return funds, first politely, then under threat of IRS 1099-MISC reporting. Roughly ~80–90k COMP returned voluntarily; the remainder (~190–200k COMP, ~$60–100M) was kept by recipients. Treasury absorbed the loss; no insolvency; no fork; no on-chain claw-back.
Funds recoveredPartial — 30–35% returned. Numbers across reports vary: at least one summary cites “84M still outstanding”; another cites ”~$70M returned cumulatively by mid-October”. [verify against on-chain Reservoir return-tx logs]
Lasting consequenceIndustry-defining demonstration that a governance proposal IS a code deployment and must be audited as one. Spawned best-practice norms: mandatory simulator (Tenderly fork) before queue, public proposal review window, calldata-diff bots, and the modern conventions around “proposal description must match calldata behavior” (later codified by Tally, OZ Defender, Compound’s own governance dashboard).

2. Background

2.1 What Compound was at the time

Compound is a pool-based money market on Ethereum. By September 2021 it was the second-largest lending protocol by TVL (Aave was first, briefly), with ~$10–12B in deposits, support for ~10 major assets (ETH, WBTC, USDC, USDT, DAI, UNI, COMP, BAT, ZRX, REP, …), and an active user base spanning retail through institutional desks.

The mechanism at the heart of this case is COMP distribution, also known as “liquidity mining”:

  • The protocol mints — well, distributes pre-allocated — COMP tokens to users who supply or borrow assets in the protocol.
  • Distribution is per-market and per-block: each cToken market (cETH, cUSDC, cDAI, etc.) has a compSpeed (COMP per block) that is split between suppliers and borrowers.
  • Users accrue COMP into a per-user balance (compAccrued[user]) on every interaction. Users explicitly call claimComp() to transfer accrued COMP from the protocol to their wallet.

This distribution machinery is the single most economically active part of Compound. Every supply, withdraw, borrow, and repay triggers a COMP-accrual update for both the supplier and borrower index. Errors in the accrual logic ripple across every cToken market and every active user simultaneously.

2.2 The Reservoir contract — the funding source

To avoid one giant _grantComp allocation event, Compound’s COMP distribution is funded incrementally by a contract called the Reservoir (Reservoir.sol), at address 0x2775b1c75658Be0F640272CCb8c72ac986009e38 [verify].

The Reservoir is conceptually trivial:

// Simplified — actual code is similar to this shape
contract Reservoir {
    uint256 public dripRate;            // COMP tokens per block, hardcoded at deploy
    uint256 public lastDripBlock;       // last block we dripped on
    EIP20Interface public token;        // COMP
    address public target;              // the Comptroller
 
    function drip() external returns (uint256) {
        uint256 blockNumber_ = block.number;
        uint256 reservoirBalance_ = token.balanceOf(address(this));
        uint256 deltaDrip_ = mul_(dripRate, blockNumber_ - lastDripBlock);
        uint256 toDrip_ = min(reservoirBalance_, deltaDrip_);
        lastDripBlock = blockNumber_;
        require(token.transfer(target, toDrip_), "Reservoir::drip: transfer failed");
        return toDrip_;
    }
}

In other words: the Reservoir held a pile of COMP, dripped a fixed amount per block to the Comptroller, and the Comptroller distributed that drip pro-rata to suppliers/borrowers via the indexes. This had been working flawlessly since COMP launched in June 2020.

The Reservoir’s role here is structural: it is the well that the bug drained. The Reservoir does not contain the bug. The Comptroller’s distribution logic, after the Proposal 62 upgrade, is what drank from the well too fast.

2.3 Proposal 62 — what the upgrade actually proposed

Proposal 62 (“Distribute COMP to Users”, filed by Geoffrey Hayes of Compound Labs) had a stated goal that was, by 2021 standards, routine to the point of mundane:

  • Refactor the COMP-distribution mechanism in the Comptroller.
  • Split the per-block compSpeed of each market into a compSupplySpeed and a compBorrowSpeed — i.e., allow governance to incentivize supplying or borrowing of a given asset asymmetrically, rather than the prior uniform 50/50 split. (Previously, every market’s per-block COMP was split 50/50 supply/borrow; this proposal changed that to an explicit per-side configuration.)
  • Replace the old _setCompSpeed(cToken, speed) admin function with a new _setCompSpeeds(cTokens[], supplySpeeds[], borrowSpeeds[]) batched setter.
  • Migrate existing markets to the new fields with sensible defaults.

The proposal description (still readable on Tally and the Compound governance feed) is short and reads like a parameter-config change. Crucially: the proposal carried a Comptroller implementation upgrade — i.e., the Unitroller proxy’s implementation pointer was set to a new Comptroller address as part of the proposal’s execution payload. This is not a parameter change — it is a code deployment.

The diff against the prior Comptroller was ~70 lines. It touched four functions:

  • distributeSupplierComp(address cToken, address supplier) — supply-side accrual.
  • distributeBorrowerComp(address cToken, address borrower, uint marketBorrowIndex) — borrow-side accrual.
  • updateCompSupplyIndex(address cToken) — global supply index update.
  • updateCompBorrowIndex(address cToken, uint marketBorrowIndex) — global borrow index update.

Plus the new _setCompSpeeds(...) admin entry point and removal of the old _setCompSpeed(...).

2.4 The governance pipeline that approved it

StepWhat happenedDate / window
Forum discussion”Distribute COMP to Users” thread on comp.xyz community forum. Code shared, rationale explained. Light technical discussion.Mid-September 2021
Proposal postedCalldata published on Compound Governance dashboard; voting opens after ~2-day delay.~Sept 22–24, 2021
VotePassed comfortably (FOR » AGAINST). Quorum met.Sept 24–26, 2021
Timelock queue2-day timelock delay before execution per Compound Governance config.Sept 26–28, 2021
Executeexecute(proposalId) on the Governor → Timelock → Unitroller _setPendingImplementation + _acceptImplementation. New Comptroller implementation live.Sept 29, 2021 [verify exact block]

Two days of public discussion, public vote, public timelock — and the bug was in the code from day one. No reviewer caught it. This is the same shape as The DAO (publicly visible code with a publicly knowable bug), but with the additional aggravation that the bug shipped through governance itself.


3. The Vulnerability

3.1 The accrual model — necessary background

Before reading the bug, internalize the per-user accrual model. Compound’s COMP rewards work like a dividend-index scheme (similar to Synthetix’s StakingRewards.sol and Sushi’s MasterChef):

For supply rewards in market cToken:

compSupplyState[cToken].index   // ever-increasing global "COMP per supplied cToken"
compSupplierIndex[cToken][user] // snapshot of global index at user's last interaction

When the user interacts (or when claimComp is called for them), the protocol:

  1. Calls updateCompSupplyIndex(cToken) — bumps the global index by (blocks_since_last × compSupplySpeed × 1e36) / totalSupply.
  2. Calls distributeSupplierComp(cToken, user) — computes deltaIndex = globalIndex - userIndex, multiplies by user_supply_balance, divides by 1e36, and credits that into compAccrued[user].
  3. Sets compSupplierIndex[cToken][user] = globalIndex — so on next interaction, only fresh deltas accrue.

Symmetric logic for borrowers via compBorrowState / compBorrowerIndex.

This is a textbook dividend-index design — every DeFi engineer who has read MasterChef understands the shape. The key invariant:

distributeSupplierComp must use the current compSupplyState[cToken].index as the “to” point, and the per-user compSupplierIndex[cToken][user] as the “from” point. After crediting, set the user’s index to the global index. Any deviation breaks the dividend invariant.

3.2 The bug — what changed and what broke

The Proposal 62 upgrade rewrote distributeSupplierComp (and symmetric distributeBorrowerComp) to accommodate the supply/borrow speed split. The new code looked approximately like this (simplified; the actual code is in Comptroller.sol and ComptrollerG7.sol at the relevant commit) [verify against the post-upgrade Comptroller source on Etherscan]:

// POST-PROPOSAL-62 logic (BUGGY) — simplified for clarity
function distributeSupplierComp(address cToken, address supplier) internal {
    CompMarketState storage supplyState = compSupplyState[cToken];
    uint256 supplyIndex = supplyState.index;
    uint256 supplierIndex = compSupplierIndex[cToken][supplier];
 
    // BUG: this branch — meant to be a sanity gate — is incorrect
    if (supplierIndex == 0 && supplyIndex >= compInitialIndex) {
        // Treat new supplier as if they joined at compInitialIndex
        supplierIndex = compInitialIndex;
    }
 
    compSupplierIndex[cToken][supplier] = supplyIndex;
 
    uint256 deltaIndex = supplyIndex - supplierIndex;
    uint256 supplierTokens = CToken(cToken).balanceOf(supplier);
    uint256 supplierDelta = (supplierTokens * deltaIndex) / 1e36;
    uint256 supplierAccrued = compAccrued[supplier] + supplierDelta;
    compAccrued[supplier] = supplierAccrued;
    // emit DistributedSupplierComp(...)
}

The intent of the supplierIndex == 0 && supplyIndex >= compInitialIndex branch was reasonable on its face: if this user has never been seen before (their per-user index is zero), assume they “joined” at the program’s initial index so they don’t receive retroactive rewards going back to genesis.

What broke in practice — there are two equivalent framings, and the auditor must hold both in mind:

Framing 1 — comparison-operator + branch error

In the new flow, supplierIndex == 0 was no longer a reliable signal of “first-time supplier”. Why?

  • Before the upgrade, the per-user index was bumped on every supply/withdraw/transfer of cTokens, so any user who had ever interacted had a non-zero compSupplierIndex.
  • After the upgrade, certain operations no longer touched the per-user index — specifically, the refactor moved index updates around such that users who held cTokens but had not transacted since the upgrade still had their old (now-considered-zero, in the new branching logic’s frame) per-user index from the pre-upgrade accounting, but the global supplyIndex had jumped forward.

Equivalently: the >= (some sources cite > vs >=) and the assumption that supplierIndex == 0 ⇔ never-seen-before were both wrong post-upgrade for a real subset of users.

Concrete consequence: the “treat as joining at compInitialIndex” path fired for users who actually had supplied for months, causing deltaIndex = supplyIndex - compInitialIndex — an enormous delta covering essentially the entire history of the market — to be multiplied by the user’s full current supply balance. Result: months of COMP credited in a single accrual.

Framing 2 — missing accounting-update step

A cleaner mental model: the upgrade introduced a path where compSupplierIndex[cToken][supplier] was set to supplyIndex after the delta computation, but a related per-market initialization step was not run for markets that pre-existed the upgrade. Markets newly listed after the upgrade got correctly initialized; markets that existed before did not. The “treat as new supplier” branch fired for users in pre-existing markets who had never explicitly called claimComp (since compSupplierIndex snapshotted only on certain code paths in the old flow).

The two framings describe the same on-chain symptom: users who had not claimed COMP could call claimComp(user, cTokens[]) and receive vastly more COMP than their pro-rata share of the per-block emission since the upgrade. In some traces, individual addresses received single-digit-millions worth of COMP from one claimComp call.

3.3 Why the bug fired for some users and not others

Three buckets of users:

BucketBehavior after Proposal 62Why
Active claimers (claimed COMP regularly pre-upgrade)Mostly unaffected.Their compSupplierIndex was non-zero and up-to-date at upgrade time. The buggy branch didn’t fire for them.
Long-time suppliers who never claimedAffected — massively over-paid.compSupplierIndex was stale / zero relative to the post-upgrade global; the “treat as new supplier” branch fired, giving them history they hadn’t earned.
New users post-upgradeCorrectly handled.The new path included proper initialization for fresh suppliers.

The bug was effectively a selection function: it identified users who had supplied for a long time and had never claimed, and over-paid them by approximately the entire historical COMP emission of the market.

Auditor’s reflex on reading this: any upgrade to an indexed-accrual system must include an invariant check of the form “sum of compAccrued across all users + sum of COMP already paid out ≤ total COMP distributed by the protocol over the same period”. The post-upgrade sum violated this invariant on day one. A 30-line off-chain sanity script run against a Tenderly fork would have caught it.

3.4 The trust assumption that failed

The Compound Labs team’s implicit model on review:

  • “This is a refactor — the math is the same, we’re just splitting one knob into two.”
  • “We’ve audited Compound a dozen times; the accrual logic is well-understood.”
  • “The governance review window is plenty long for the community to catch issues.”

What they missed:

  • The math is not “the same” when you move where indices are initialized. The bug lived in a single conditional that appeared defensive (gating against zero) but encoded an assumption (zero ⇔ never-seen) that the refactor invalidated.
  • “Well-understood” accrual logic is a liability, not an asset — reviewers’ eyes glaze over familiar code and stop simulating it. The bug was sitting where everyone expected boring boilerplate.
  • Governance review windows are not audits. Community members read the proposal description, glance at the diff, vote. They are not running Echidna against the new code; they are not simulating it on a forked mainnet. Compound’s audit firm (OpenZeppelin, historically) had presumably reviewed prior versions of the Comptroller; this specific diff’s review intensity is unclear to outsiders [verify whether Proposal 62 carried a formal OZ audit attestation].

The deep failure mode here: Compound treated Proposal 62 as a governance event (community-reviewed) when it was structurally a code deployment (which warrants a full audit). The two are not equivalent. We will return to this in §7.


4. The Attack — actually, the “drain”

This case differs from DAO/Parity/Euler in one important way: there is no canonical “attacker”. There is no malicious contract authored before the upgrade with attack intent. There is no single black-hat extractor. Instead, the bug created a sudden, public, observable economic asymmetry — and opportunistic users called the function that paid them more than they were due, exactly as the contract told them to.

4.1 The discovery (within hours of execution)

Proposal 62 executed on September 29, 2021. Within hours, the on-chain DistributedSupplierComp and Transfer(comptroller→user) event logs showed individual claimComp calls transferring tens of thousands of COMP each — orders of magnitude above expected.

  • Whale watchers on Twitter (Banteg, Igor Igamberdiev, and others) noticed the unusually-large transfers and posted alerts within ~hours.
  • Compound’s own Discord lit up. Leshner went on Twitter.
  • Robert Leshner’s tweet (paraphrased; verify against Twitter archives, since deleted/edited):

    “There is a bug in the COMP distributor that has resulted in some users receiving more COMP than they should. The funds at risk are the COMP in the Reservoir contract — a separate pool not user funds. We are working on a fix.” [verify exact wording]

This early tweet is the canonical example of “user funds are safe, just our treasury is on fire” — technically true, deeply embarrassing, and the Compound community’s response was a mix of relief (no user deposits at risk) and exasperation (a basic config-upgrade lost a 9-figure treasury).

4.2 The drain in real time

Over the following hours and days:

  1. Word spread. As more wallets called claimComp and received outsize amounts, the existence of the bug became common knowledge across DeFi Twitter. Anyone who had supplied to Compound for months and not claimed could trigger the over-payment.
  2. Bots joined. Within a day, automated callers were scanning historical Compound suppliers and triggering claimComp on their behalf — pulling the over-payment into the supplier’s wallet whether the supplier asked for it or not. (Recall: claimComp(address[] holders, CToken[] cTokens, bool borrowers, bool suppliers) is permissionless — anyone can call it for any user.)
  3. The Reservoir continued to drip COMP into the Comptroller, replenishing the pool that the bug was draining. This is the part that escalated the damage: even though the over-distribution per claimComp call was bounded by the historical deltaIndex, the Reservoir kept refilling COMP, and the bug continued misallocating it.
  4. Governance moved to patch. Leshner and Compound Labs proposed a fix (Proposal 64, then refined into Proposal 65). Critically, the timelock applied to the fix too — there was a mandatory 2+ day delay between proposing the patch and being able to execute it. This is the most painful single fact in the case: the bug was visible, the patch was written, and the protocol’s own decentralization prevented immediate remediation.

4.3 The two-day timelock window — watching the leak

For the ~7 days between Proposal 62 execution (Sept 29) and Proposal 65 execution (~Oct 7) [verify], the Compound community watched COMP flow out of the Reservoir in real time. Different sources tally the drain differently:

  • Initial estimate (Sept 30): ~280,000 COMP over-distributed (≈320 per COMP).
  • Updated estimate as price moved: ~400–$500 COMP. [verify]
  • Cumulative over-distribution after full Reservoir drain: some sources cite the bug at risk of distributing up to 490,000 COMP if not patched — the full Reservoir balance.

The community proposed several emergency measures during this window:

IdeaWhy it didn’t work / wasn’t used
Pause the ComptrollerNo pause guardian for the COMP-distribution paths. Compound has per-market pause but no protocol-wide kill switch on accrual.
Drain the Reservoir to a safe address pre-emptivelyWould itself require a governance proposal + timelock. By the time it could pass, the patch could too.
Coordinate with users via off-chain pressureTried (the “please return funds” tweet). Limited effect.
Modify the Reservoir’s target to a sinkReservoir’s target is set at construction and not changeable; would have required deploying a new Reservoir, transferring COMP — full governance cycle.

The lesson here is direct: decentralized governance with a 2-day timelock is, by design, slow to respond to its own bugs. The same property that protects against hostile takeovers makes self-inflicted bugs harder to patch.

4.4 The drain in numbers (best-effort tallies — verify each)

MetricValueSource / note
COMP over-distributed~280,000 COMPCompound team early estimate; widely cited [verify]
USD value at execution~$80–90MAt ~$320 COMP price
USD value at peak~$140–150MAt COMP highs during the week [verify]
Reservoir balance at execution~480,000 COMP[verify on-chain at block of Sept 29 execution]
Reservoir at risk if unpatched~490,000 COMP (full balance)Compound community estimate
COMP returned voluntarily (4 days post)30k–35k COMP ($10M)Per Leshner Twitter thread [verify]
COMP returned cumulative (mid-Oct)80–90k COMP ($25–30M)Mid-October tallies [verify]
COMP NOT returned (kept by recipients)190–200k COMP ($60–100M)Best-case for Compound treasury

The “loss” the protocol absorbed is the unreturned portion, plus the secondary cost of the COMP-price impact from sudden distribution of large amounts of free COMP (some recipients sold immediately).


5. Reproduction in Foundry

We will not reproduce all of Compound’s Comptroller — that’s ~2000 lines including legacy. Instead, we’ll build a minimal “DripPool with indexed accrual” that captures the bug pattern exactly, in ~100 lines.

This is the same approach we used for The DAO (Case-The-DAO-Reentrancy-2016 §5): not a full reproduction, but a faithful model of the bug class that you can run, mutate, and patch.

5.1 Victim contract — RewardDistributor.sol

The vulnerable pattern: an indexed-accrual reward pool where a “treat as new staker” branch is gated by a comparison that does not actually identify new stakers after an upgrade.

// src/RewardDistributor.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
/// @title RewardDistributor — a deliberately-vulnerable model of the Compound P62 bug
/// @notice DO NOT DEPLOY. Educational reproduction.
contract RewardDistributor {
    IERC20 public immutable rewardToken;
    IERC20 public immutable stakingToken;
 
    // Global state — Compound's compSupplyState.index analog
    uint256 public globalIndex;          // grows over time as rewards accrue
    uint256 public lastUpdateBlock;
    uint256 public rewardPerBlock;       // emission rate
    uint256 public totalStaked;
 
    // Per-user state — Compound's compSupplierIndex analog
    mapping(address => uint256) public userIndex;     // snapshot of globalIndex at user's last touch
    mapping(address => uint256) public stake;         // user's staked balance
    mapping(address => uint256) public accrued;       // unclaimed reward
 
    // The "initial index" used by the buggy "treat as new" branch
    uint256 public constant INITIAL_INDEX = 1e36;
 
    bool public buggyMode; // toggle to demonstrate fix
 
    constructor(IERC20 _reward, IERC20 _staking, uint256 _rewardPerBlock) {
        rewardToken = _reward;
        stakingToken = _staking;
        rewardPerBlock = _rewardPerBlock;
        globalIndex = INITIAL_INDEX;
        lastUpdateBlock = block.number;
        buggyMode = true;
    }
 
    function setBuggyMode(bool b) external { buggyMode = b; }
 
    // ---- core accrual ----
 
    function _updateGlobalIndex() internal {
        if (block.number == lastUpdateBlock) return;
        if (totalStaked == 0) { lastUpdateBlock = block.number; return; }
        uint256 blocksElapsed = block.number - lastUpdateBlock;
        uint256 rewardAccrued = blocksElapsed * rewardPerBlock;
        globalIndex += (rewardAccrued * 1e36) / totalStaked;
        lastUpdateBlock = block.number;
    }
 
    function _distributeToUser(address user) internal {
        uint256 userIdx = userIndex[user];
 
        if (buggyMode) {
            // ===== BUGGY BRANCH — the Compound P62 shape =====
            // Intended: "if user has never staked before, treat them as joining at INITIAL_INDEX"
            // Actual: "userIdx == 0" is NOT a reliable signal of new-staker after certain upgrades
            // that fail to initialize per-user index for pre-existing stakers.
            if (userIdx == 0 && globalIndex >= INITIAL_INDEX) {
                userIdx = INITIAL_INDEX;
            }
        } else {
            // ===== PATCHED BRANCH =====
            // Initialize per-user index at the moment of FIRST STAKE, not lazily on first claim.
            // (See `stakeFor` patched flow.) Here we only credit if userIdx is already set.
            if (userIdx == 0) {
                // user has never been initialized — credit zero and snapshot now
                userIndex[user] = globalIndex;
                return;
            }
        }
 
        uint256 delta = globalIndex - userIdx;
        uint256 reward = (stake[user] * delta) / 1e36;
        accrued[user] += reward;
        userIndex[user] = globalIndex;
    }
 
    // ---- user-facing entry points ----
 
    /// @notice Stake. Models a Compound supply-cToken interaction.
    function stakeFor(address user, uint256 amount) external {
        _updateGlobalIndex();
        _distributeToUser(user);
        require(stakingToken.transferFrom(msg.sender, address(this), amount), "transfer");
        stake[user] += amount;
        totalStaked += amount;
    }
 
    /// @notice Claim. Permissionless — anyone can claim for anyone (Compound shape).
    function claim(address user) external {
        _updateGlobalIndex();
        _distributeToUser(user);
        uint256 amt = accrued[user];
        accrued[user] = 0;
        if (amt > 0) require(rewardToken.transfer(user, amt), "transfer");
    }
 
    /// @notice Models the Proposal-62 UPGRADE: bumps globalIndex without initializing
    /// per-user indices for pre-existing stakers. This is the shape of the bug.
    function simulateUpgradeWithoutMigration(uint256 newGlobalIndex) external {
        // Imagine this is the new Comptroller implementation taking over — it
        // continues using globalIndex but forgets that some existing stakers have
        // userIndex[user] == 0 in this contract's frame.
        require(newGlobalIndex >= globalIndex, "no rewind");
        globalIndex = newGlobalIndex;
        lastUpdateBlock = block.number;
        // BUG: does NOT iterate over existing stakers to set userIndex[user] = globalIndex
    }
}

5.2 Foundry test — demonstrate the over-distribution

// test/RewardDistributor.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import {RewardDistributor} from "../src/RewardDistributor.sol";
import {MockERC20} from "./MockERC20.sol"; // standard OZ-based mintable ERC20
 
contract RewardDistributorTest is Test {
    RewardDistributor dist;
    MockERC20 reward;
    MockERC20 staking;
    address alice = address(0xA11CE);
    address bob   = address(0xB0B);
 
    function setUp() public {
        reward  = new MockERC20("REWARD", "REW", 18);
        staking = new MockERC20("STAKE",  "STK", 18);
        dist = new RewardDistributor(reward, staking, 1e18);  // 1 REW/block
        reward.mint(address(dist), 1_000_000e18);
 
        staking.mint(alice, 100e18);  staking.mint(bob, 100e18);
        vm.prank(alice); staking.approve(address(dist), type(uint256).max);
        vm.prank(bob);   staking.approve(address(dist), type(uint256).max);
        vm.prank(alice); dist.stakeFor(alice, 100e18);
        vm.prank(bob);   dist.stakeFor(bob,   100e18);
    }
 
    function test_buggyUpgradeOverpaysExistingStakers() public {
        vm.roll(block.number + 1000);  // 1000 REW accrue across 200 STK total
 
        // Simulate Proposal-62-style upgrade: per-user indices reset to zero
        // (slot 7 = userIndex mapping; verify with `forge inspect`).
        vm.store(address(dist), keccak256(abi.encode(alice, uint256(7))), bytes32(uint256(0)));
        vm.store(address(dist), keccak256(abi.encode(bob,   uint256(7))), bytes32(uint256(0)));
 
        dist.claim(alice);
        dist.claim(bob);
        uint256 totalPaid    = reward.balanceOf(alice) + reward.balanceOf(bob);
        uint256 totalEmitted = 1000e18;
        emit log_named_uint("Total paid (BUGGY)",   totalPaid);
        emit log_named_uint("Total emitted",        totalEmitted);
        assertGt(totalPaid, totalEmitted, "BUG: paid out more than emitted");
    }
 
    function test_patchedUpgradeDoesNotOverpay() public {
        vm.roll(block.number + 1000);
        dist.setBuggyMode(false);  // patched branch active
        dist.claim(alice);
        dist.claim(bob);
        uint256 totalPaid = reward.balanceOf(alice) + reward.balanceOf(bob);
        assertApproxEqAbs(totalPaid, 1000e18, 1e10, "paid should match emitted");
    }
}

Run with forge test -vvv. The buggy test asserts totalPaid > totalEmitted (invariant violated); the patched test asserts totalPaid ≈ totalEmitted.

5.3 The patch — what should have been in Proposal 62

Two complementary fixes, both of which Proposal 64/65 effectively implemented:

  1. Correct the branching predicate. Replace the “treat as new staker if userIndex==0” heuristic with a per-user hasBeenInitialized[user] bool, set explicitly on first stake. This eliminates the ambiguity.

  2. Migrate per-user indices at upgrade time. If you must lazy-initialize on first interaction post-upgrade, set the per-user index to the current globalIndex at that moment, not to INITIAL_INDEX. The user receives no retroactive rewards from before their first claim; they receive fresh rewards going forward. This is the “set userIndex[user] = globalIndex” line in the patched branch above.

A complete fix looks like:

function _distributeToUserPatched(address user) internal {
    uint256 userIdx = userIndex[user];
    if (userIdx == 0) {
        // First interaction post-upgrade — initialize to CURRENT global, not INITIAL.
        // No retroactive credit; future deltas accrue normally.
        userIndex[user] = globalIndex;
        return;
    }
    uint256 delta = globalIndex - userIdx;
    uint256 reward = (stake[user] * delta) / 1e36;
    accrued[user] += reward;
    userIndex[user] = globalIndex;
}

This is a two-line change. The bug that lost $80–150M was a two-line change.

5.4 Stretch lab

  • Extend to both supply-side and borrow-side accrual; reproduce on each side independently.
  • Add a Foundry invariant test: invariant_totalPaidPlusPendingLEqEmitted() — the canonical dividend invariant. The buggy version violates it within seconds; the patched version preserves it.
  • Add a verifyMigration() admin function that asserts every known staker’s userIndex is set post-upgrade. This is the missing step in Proposal 62.

6. Aftermath

6.1 The first 24 hours — Leshner’s tweets

Robert Leshner, founder of Compound Labs (then CEO; later moved to Superstate), used his Twitter account as the primary communication channel.

The sequence (paraphrased; verify against archives — several tweets later deleted):

  1. Initial alert (~Sept 30): “There’s a bug in the COMP distributor; user funds are safe.” Important framing: emphasized that deposits/borrows were unaffected — only the Reservoir treasury was at risk.
  2. The “please return funds” tweet: ~24–36 hours later. Asked recipients to send the COMP back to the Compound Timelock, keep 10% as a “bug bounty”, and stated that funds returned promptly would be appreciated. This 10%-keep offer was widely discussed; it set a precedent for how protocols negotiate with opportunistic over-payment recipients.
  3. The IRS threat (the controversy): A few days later, Leshner tweeted that recipients of large amounts of COMP might be subject to IRS reporting — Compound Labs would file Form 1099 / report large recipients to the IRS. This tweet was widely criticized as a heavy-handed attempt to coerce returns through state-power threat, and was perceived as undermining the “code is law” framing that Compound (and DeFi broadly) had been promoting. Leshner subsequently walked back the tone but the underlying threat remained on the record.

The communications arc went from “please” to “please plus we’ll report you to the IRS” in approximately 4–5 days. Recipient response was mixed: some returned, citing community goodwill; some kept, citing that the contract had paid them per its own logic.

6.2 Proposal 63 — the off-by-one fix attempt

Compound’s first patch attempt was Proposal 63, intended to drain the Reservoir to a sink address as an emergency staunch.

Proposal 63 was withdrawn / failed [verify exact disposition]; in some accounts, it had its own bug or insufficient effect. Compound community pivoted to Proposal 64.

6.3 Proposal 64 and Proposal 65 — the real patches

  • Proposal 64 corrected the distributeSupplierComp / distributeBorrowerComp logic.
  • Proposal 65 (some sources combine these) addressed remaining edge cases and adjusted distribution accounting.

Both went through the standard pipeline:

  • Code published, community review.
  • ~2-day voting window.
  • ~2-day timelock.
  • Execution.

Total time from bug detection (Sept 29) to fully patched Comptroller: ~8–9 days, during which COMP continued to leak.

The total loss is approximately the area under the leak curve over those 8–9 days, modulated by who happened to call claimComp during the window.

6.4 The return campaign

Tally was attempted in real time on Etherscan as recipients sent COMP back to the Compound Timelock address (the conventional “return to protocol” sink).

WindowCumulative returnNotes
Days 1–430–35k COMP ($10M)Initial wave of voluntary returns
Days 5–1480–90k COMP ($25–30M)Returns continued after IRS-threat tweet
Days 15–30+100k COMP total ($30M)Returns largely stopped after first month
Long-tailA few additional returnsYears later, occasional drips

Net retained loss to Compound treasury: ~190–200k COMP, approximately $60–100M at then-prevailing prices. The protocol absorbed it; no insolvency.

6.5 No fork, no claw-back — and why that matters

Unlike The DAO, Compound did not consider a hard fork or any chain-level remediation. Three reasons:

  1. No existential threat. User deposits were untouched. The treasury could absorb the loss without breaking the lending mechanism.
  2. The bug was Compound’s own fault. Compound proposed the upgrade. The community voted for it. The Timelock executed it. There was no “external attacker” to fork around — the “attacker” was the protocol itself.
  3. Forks are an Ethereum-foundation lever, not a protocol-team lever. Compound has no authority to fork Ethereum; even asking would be community-political suicide. (Ethereum forked for The DAO because the DAO was 14% of total ETH supply; Compound’s $80M, while painful, is rounding error on Ethereum-scale.)

The non-fork response set a norm: post-DAO, “protocol-level losses get absorbed by the protocol or recovered via persuasion, not by chain-level remediation”. Subsequent incidents (Yearn, Indexed, Mango Markets, etc.) followed the same playbook.

6.6 Industry-level consequences

The Compound P62 incident shaped the modern proposal-review pipeline in several concrete ways:

  • Simulator requirement. Tally now requires (or strongly encourages) a Tenderly fork simulation alongside every proposal, demonstrating the calldata’s effect against current mainnet state. The Compound bug would have shown up in a fork simulator as “an arbitrary supplier can withdraw 1000× their expected COMP” — i.e., visible in seconds.
  • Calldata-diff bots. Projects like Watchpug, OpenZeppelin Defender, and various governance dashboards now run automated diffs between proposed calldata and “expected behavior” descriptions. Any new admin role grant, any change to a privileged target, any non-routine pattern is flagged.
  • “Proposal description must match calldata”. Tally enforces (via integration norms) that the human-readable description match the on-chain effect. Tornado Cash 2023 exploited the gap between description and calldata; Compound P62 exploited the gap between intent and behavior.
  • Mandatory third-party review of upgrade proposals. Many large DAOs now require an external audit (OZ, Trail of Bits, Spearbit, etc.) of any proposal that touches privileged functions or implementations. Compound itself routes major proposals through external audit firms post-P62.
  • Per-protocol “kill switch” debates. P62 reignited the debate around having an admin-controllable circuit breaker for distribution-style functions. Compound v3 includes a pause guardian for new accruals (different design from v2). [verify v3 details]
  • Recognition that “decentralized” ≠ “safe”. Pre-P62, “decentralized + audited + timelocked” was treated as a security posture. P62 demonstrated that this posture does not protect against bugs in the upgrade itself. Audits, timelocks, and decentralization are layers — each catches a different class of attack — but none catches “the team proposed a buggy refactor and the community didn’t read carefully”.

6.7 The lawsuit angle (verify)

Some recipients of over-distributed COMP later sold it; some held; a few were reportedly served with civil demands [verify — there were reports of Compound Labs sending lawyer letters to large recipients, but no high-profile settled case]. Tax treatment of the over-distribution was an open question for recipients: was it income (taxable at receipt), or a loan to be returned, or theft? The IRS angle from Leshner’s tweet remained a real concern for recipients, even if no high-profile prosecution followed.


7. Lessons for Auditors

7.1 A governance proposal is a code deployment

This is the single most important lesson from P62. The Compound community treated the proposal as a config-change-equivalent (governance vote sufficient) when its substance was a Comptroller implementation upgrade (which warrants full audit).

For auditors:

  • When you receive a governance-proposal-review engagement, demand to see the calldata, not the description. The description is marketing; the calldata is the contract.
  • For every proposal that changes an implementation address, require the same scrutiny as a fresh deployment. That includes: code-diff review against the previous implementation, fork simulation, invariant tests, and a re-run of the protocol’s audit checklist on the new code.
  • For every proposal that grants a role or changes a parameter that controls a role, model the worst-case behavior of the holder of that role. Even a “trusted multisig” granted _setCompSpeed-equivalent power requires modeling.

The audit deliverable for a governance proposal is the same as for a fresh contract — a critical/high/medium/low report — with the additional requirement that the audit be completed before the proposal enters the voting window, since post-vote it’s too late to integrate findings.

7.2 Upgrade calldata review as a discipline

Specifically for upgrade-bearing proposals:

CheckWhat it looks forHow to do it
Calldata-vs-description matchDescription claims X; calldata does Y. Tornado-Cash-style hidden behavior.Decode calldata target-by-target; verify each target.call(data) is documented.
Implementation diffNew Comptroller / new Vault / new Module differs from old one.forge inspect storage layout; manual diff of source; check for re-arranged storage.
Storage-layout compatibilityNew impl reads the proxy’s storage with the same offsets as the old impl.OZ Upgrades plugin; manual slot-by-slot review for non-OZ proxies.
Per-user state migrationIf accrual indices, snapshots, or rewards bookkeeping changes shape, are existing users migrated?Read every initialization function on the new impl; ask “who triggers the migration for pre-existing users?” If the answer is “lazy on first interaction with a flawed predicate” — Compound P62 shape.
Invariant checkDoes an off-chain invariant hold after simulated execution?Fork mainnet at proposed execution block; apply proposal; iterate through known users; verify sum(accrued) + sum(claimed) ≤ sum(emitted).
Pause/halt fallbackIf executed and discovered buggy, can the bug be paused without another full proposal cycle?List every pausable function; confirm whether the affected logic is among them. P62: no.

The auditor’s worksheet for an upgrade proposal is, structurally, the audit checklist for the new contract plus a migration sub-section.

7.3 Indexed-accrual systems — the audit checklist

Compound P62 was an indexed-accrual bug. So were several smaller incidents at other lending forks. The category warrants its own checklist:

  • Where is the per-user index initialized? On first stake / supply / borrow? On first claim? Lazily?
  • What predicate detects “uninitialized”? Sentinel zero? Boolean flag? Explicit migration list?
  • Can the predicate be fooled by an upgrade? If the upgrade re-uses old storage slots but changes the meaning of “zero”, the predicate breaks.
  • What’s the “correct” initial value? Initial program index (P62 mistake) or current global index at moment of init (correct)? They differ by exactly the retroactive-history quantity.
  • Does the dividend invariant hold? sum(accrued[*]) + sum(paid[*]) ≤ cumulative_emission. Fuzz-test this.
  • Is claim permissionless? If so, an attacker can trigger the bug for the victim without victim consent — which is what happened to many pre-existing Compound suppliers post-P62 (bots claimed for them).
  • Can the protocol pause claim independent of pausing all accrual? P62 lacked this; useful for emergency response.

7.4 Reservoir-pattern review

The Reservoir is a funding-source contract that drips assets to a consumer. When you see this pattern:

  • Is the target immutable? (Compound: yes — both a strength against admin-key compromise and a weakness for emergency response.)
  • Can the dripRate be paused / zeroed? (Compound: no — once dripping, it drips.)
  • What’s the maximum loss if the consumer turns into a sink? (Compound: full Reservoir balance.)
  • Is there a sweep / rescue mechanism gated by governance? (Compound: no — would require full proposal cycle.)

A well-designed Reservoir-equivalent would include a governance-callable pause that immediately stops new drips, even if reconfiguring the drip recipient requires a full proposal. P62 demonstrated this gap.

7.5 Communication and recovery — the “soft” lessons

P62 also teaches non-technical lessons that auditors and protocols should internalize:

  1. Plan the communication channel before you need it. Leshner used Twitter and Discord ad-hoc. A pre-rehearsed protocol — “if X happens, our PR sequence is A, B, C” — produces calmer, less ambiguous messaging. The IRS-threat tweet would not have shipped if there had been a pre-approved comms playbook.
  2. The 10%-keep offer set a (debatable) industry precedent. Subsequent protocols (Yearn, others) have offered 10–20% bug-bounty-equivalent for return of accidentally-distributed funds. This is now a soft norm.
  3. “Code is law” is a marketing slogan, not a recovery strategy. Protocols that lean hard on “code is law” rhetoric find that rhetoric used against them when they ask for funds back. Be cautious about over-promoting immutability in normal times if you might need to ask for help in extraordinary times.

7.6 Why this bug class persists

If P62 was 2021 and the lessons are this clear, why do we still see indexed-accrual bugs and proposal-logic bugs in 2024–2026?

  1. Refactor-driven blindness. “We’re just refactoring; no functional change” is the audit anti-pattern that hides 70% of these bugs. Reviewers’ diligence drops on perceived-routine changes.
  2. Diff-blind review. Auditors look at the new code in isolation, not the new code overlaid on existing on-chain state. A bug that requires “pre-existing user with old non-zero index plus new code path that misreads it” never reproduces in a from-scratch deployment but reproduces immediately on forked mainnet.
  3. Governance-velocity pressure. DAOs ship proposals quickly to keep up with competition. Audit-grade review takes weeks; voting takes days. The mismatch is structural.
  4. The “treasury is not user funds” framing. Protocols often deprioritize protecting their own treasury versus protecting user deposits. Auditors who triage by user-funds-at-risk miss treasury-only attacks. P62 had zero user deposits at risk — and still lost $80M+.

8. What You Would Have Caught (Pre-Proposal Auditor Exercise)

If Proposal 62 landed in your inbox today, asking for a 4-hour governance-proposal-review engagement, here’s the playbook that should produce the finding before the voting window opens.

8.1 Immediate fires (under 60 seconds)

SignalWhy it fires
Proposal carries an implementation upgrade (_setPendingImplementation, _acceptImplementation calls in the proposal payload)Not a config change. Treat as fresh deployment. Demand the diff.
Diff touches accrual / index math (distributeSupplierComp, distributeBorrowerComp, updateCompSupplyIndex, etc.)Indexed-accrual math is invariant-critical. Every change must preserve sum(accrued) ≤ sum(emitted).
New branch with “treat as new” semantics (if (userIdx == 0) ...)Sentinel-based predicates break on upgrades. Demand justification of why userIdx == 0 reliably identifies new users in all upgrade paths.
No migration step for pre-existing usersIf the new code initializes per-user state lazily but the lazy init has a buggy predicate, pre-existing users are at risk. Demand an explicit migration or a non-sentinel-based init.
Permissionless claim function paying from a refilling poolAn exploitable accrual bug is exploitable by anyone, on behalf of anyone, including bots scanning historical suppliers.

8.2 Secondary signals (next 5 minutes)

  • Reservoir continues to drip post-execution. If the bug is in the consumer (Comptroller), the Reservoir continues feeding it. Demand a kill-switch on the drip or a pause on claimComp before the upgrade.
  • No invariant test for totalPaid ≤ totalEmitted in the proposed code’s test suite. Demand one. Run it on a forked mainnet.
  • Snapshot used for “new user joined at INITIAL_INDEX” is the program’s initial index, not the current global. The retroactive-credit blast radius is the entire history of the program. This single number — globalIndex - INITIAL_INDEX — multiplied by a whale’s supply balance, is the upper bound of the per-user payout.
  • Comment-driven trust: anywhere the diff has a comment like ”// New user — treat as if joined at program inception”, that comment is encoding an assumption. Test the assumption.

8.3 The 60-second auditor verdict

Critical — proposal logic bug. The diff at Comptroller.distributeSupplierComp introduces a branch if (supplierIndex == 0 && supplyIndex >= compInitialIndex) supplierIndex = compInitialIndex; intended to treat first-time suppliers as joining at the program’s inception index. After this upgrade is executed on mainnet state, this branch will incorrectly fire for any user who supplied to a pre-existing market but never explicitly claimed COMP — because the prior implementation’s accounting did not always set compSupplierIndex[cToken][user] for such users. The delta supplyIndex - compInitialIndex is approximately the entire historical COMP emission of the market; multiplied by the user’s current supply balance, this credits them with months-to-years of retroactive rewards. Estimated loss if executed: full Reservoir balance (~480k COMP, ~$150M). PoC: fork mainnet at the proposed execution block; apply the new implementation; iterate through the top-100 cUSDC and cDAI suppliers who have never called claimComp; observe over-distribution. Remediation: replace sentinel-zero detection with an explicit hasBeenInitialized[supplier] flag, OR initialize lazy users to the current supplyIndex (zero retroactive credit) rather than compInitialIndex. Both fixes are <20-line diffs.

That paragraph, plus the Foundry PoC from §5, is the deliverable. The patch is two lines.

8.4 What this teaches about audit methodology

P62 was reviewed by Compound’s core engineers and the community. None of them caught it. Why?

  1. Refactor-blindness. “We’re splitting compSpeed into supply/borrow speeds” reads as a config change. The actual implementation diff was reviewed as boilerplate.
  2. No mainnet-fork simulation. A Tenderly fork at the proposed execution block, with a script that iterates top-100 suppliers and runs claimComp for each, would have produced anomalous outputs in seconds. Compound did not require this in 2021. Most protocols do today.
  3. No invariant testing for sum(accrued) ≤ sum(emitted). This is the canonical dividend-system invariant. The Compound test suite tested function-level correctness; it did not test global accounting consistency post-upgrade.
  4. Distributed review responsibility. Compound Labs implemented; OZ may or may not have audited [verify]; the community reviewed via forum; voters voted. No single party owned the “this upgrade is safe to execute” assertion. This is the deep org-level failure: in a DAO, when responsibility is shared by everyone, it is owned by no one.

The modern post-P62 playbook is built on these lessons. Tally requires simulators. OZ Defender now provides automated diff-checks. Trail of Bits and Spearbit have governance-proposal-review service lines. Audit firms publish “governance security frameworks”. None of this existed in September 2021. P62 made it exist.


9. References

Primary post-mortems and on-chain artifacts

Press coverage and analyses

Governance / tooling that emerged post-P62


10. Auditor’s Worksheet — apply this to your next governance review

Twelve steps for every upgrade-bearing proposal:

  1. Decode the calldata target-by-target; confirm each target matches the description.
  2. Identify implementation upgrades (_setPendingImplementation, upgradeTo, setImplementation) → treat as fresh-deployment audit.
  3. Diff implementation against previous version (git diff + forge inspect storage).
  4. List every state-mutating function in the diff; for each, identify invariants it must preserve.
  5. For indexed-accrual systems, write the invariant: sum(accrued) + sum(claimed) ≤ sum(emitted).
  6. Identify “treat as default” / sentinel-based branches; test the predicate against actual mainnet state.
  7. Identify per-user state that exists pre-upgrade with values inconsistent with post-upgrade assumptions (the P62 shape).
  8. Fork mainnet at proposed execution block; apply the proposal; iterate top users; compare outputs.
  9. Run the existing test suite against forked + upgraded state.
  10. Identify pause / emergency paths; can the bug be staunched in <1 hour post-execution?
  11. Write the worst-case loss number explicitly.
  12. Deliver findings before the vote opens.

Any finding above LOW severity → proposal should be withdrawn, fixed, re-submitted. Not patched via a second proposal.


Last updated: 2026-05-16 See also: Tuan-05-Vulnerability-Classes-Part-1 · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-14-Governance-DAO-Security · Tuan-15-Audit-Methodology-Tooling · Case-The-DAO-Reentrancy-2016 · Case-Parity-Multisig-2017 · Case-Beanstalk-Governance-2022 · Case-Tornado-Cash-Governance-2023 · Case-Euler-Finance-2023 · audit-checklist-master · Roadmap · References