Skip to main content

Description

Compound upgraded their comptroller contract to https://etherscan.io/address/0x374abb8ce19a73f2c4efad642bda76c797f19233 which had a one letter bug on L1217. The bug occurred when users supplied tokens to markets with zero COMP rewards before initialization. In these cases, the supplyIndex equaled compInitialIndex (1e36), but the reward calculation logic was skipped due to the incorrect comparison operator.
if (supplierIndex == 0 && supplyIndex > compInitialIndex) {
    // Covers the case where users supplied tokens before the market's supply state index was set.
    // Rewards the user with COMP accrued from the start of when supplier rewards were first
    // set for the market.
    supplierIndex = compInitialIndex;
}
The bug was caused by using > instead of >= in the check. This meant that when supplyIndex equaled compInitialIndex (1e36), the if block was skipped, leaving supplierIndex at 0. The large difference between supplierIndex (0) and supplyIndex (1e36) caused the protocol to pay out massive unintended rewards. The previous version worked because supplyIndex started at 0 instead of 1e36, though >= would have been more correct.

Proposed Solution

It would have made sense for the developers to make sure that the COMPS accrual never exceeds the maximum possible rate. This way even if a bug is introduced, it will be caught by the assertion. The assertion below calculates the maximum possible rate of COMP accrual and checks that a distribution does not exceed this rate.
The code below is conceptual pseudo code to illustrate the invariant that should be enforced. It uses simplified syntax for accessing storage mappings (compound.compAccrued[supplier]) that would not compile in actual Solidity. A real implementation would need to use proper getter functions or storage slot calculations via ph.load().
// CONCEPTUAL - Verify that COMP accrual never exceeds the maximum possible rate
function assertionValidCompAccrual() external view {
    PhEvm.CallInputs[] memory distributions = ph.getCallInputs(
        address(comptroller),
        Comptroller.distributeSupplierComp.selector
    );

    for (uint256 i = 0; i < distributions.length; i++) {
        // Decode call inputs (selector already stripped by cheatcode)
        (address cToken, address supplier) = abi.decode(distributions[i].input, (address, address));

        // Check COMP accrued before distribution
        ph.forkPreCall(distributions[i].id);
        uint256 preAccrued = comptroller.compAccrued(supplier);
        uint256 supplyIndex = comptroller.compSupplyState(cToken).index;
        uint256 maxDeltaPerToken = supplyIndex - comptroller.compInitialIndex();
        uint256 supplierTokens = CToken(cToken).balanceOf(supplier);
        uint256 maxIncrease = supplierTokens * maxDeltaPerToken;

        // Check COMP accrued after distribution
        ph.forkPostCall(distributions[i].id);
        uint256 postAccrued = comptroller.compAccrued(supplier);

        if (postAccrued > preAccrued) {
            uint256 increase = postAccrued - preAccrued;
            require(increase <= maxIncrease, "COMP accrual increase exceeds maximum possible rate");
        }
    }
}