31076 - [SC - Critical] checkpointTotalSupply can checkpoint before a t...

Submitted on May 12th 2024 at 10:57:42 UTC by @Holterhus for Boost | Alchemix

Report ID: #31076

Report type: Smart Contract

Report severity: Critical

Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/RevenueHandler.sol

Impacts:

  • Permanent freezing of funds

Description

Brief/Intro

The checkpointTotalSupply() function is in the RewardsDistributor and is callable by anyone. The function has an incorrect comparison of > instead of >=, and this can lead to a checkpoint being recorded when a timestamp is not yet complete. This leads to mistakes in the internal accounting. In the worst case, a user can never successfully claim() again, which permanently freezes their BPT tokens that have been deposited into veALCX.

Vulnerability Details

The implementation of _checkpointTotalSupply() is as follows:

function _checkpointTotalSupply() internal {
    address ve = votingEscrow;
    uint256 t = timeCursor;
    uint256 roundedTimestamp = (block.timestamp / WEEK) * WEEK;
    IVotingEscrow(ve).checkpoint();

    for (uint256 i = 0; i < 20; i++) {
        if (t > roundedTimestamp) {
            break;
        } else {
            veSupply[t] = IVotingEscrow(ve).totalSupplyAtT(t);
        }
        t += WEEK;
    }
    timeCursor = t;
}

Notice that whenever veSupply[t] is assigned to a value, the t += WEEK increment happens immediately after, which permanently progresses the cursor so that veSupply[t] will never be assigned to again. Also, notice that the veSupply[t] assignment is only skipped if t > roundedTimestamp. This is incorrect. It should also be skipped if t == roundedTimestamp, otherwise this code can record the totalSupplyAtT() value before the timestamp itself is complete. This means the check should actually be: if (t >= roundedTimestamp).

Impact Details

In the scenario when t == roundedTimestamp, the code will incorrectly cache the totalSupplyAtT() of the current timestamp early. Any actions taken in veALCX after this (but on the same timestamp) will not be reflected in the veSupply[] value. On the other hand, the _claimable() function will correctly account for these last-second actions in each individual tokenId. As a result, it is possible for the balanceOf value below to contain deposits that did not contribute to the veSupply[weekCursor] value:

In the worst-case scenario, the first user of VotingEscrow can end up in a situation where veSupply[weekCursor] == 0 and balanceOf > 0. In this case, the claim() function will revert due to a division by zero, and it will permanently fail to progress past the broken week.

Since the withdraw() function in the veALCX contract has the following code:

it will always revert on this line, and users will be in a state where they are permanently unable to withdraw their BPT tokens. See the PoC for an example.

References

See the PoC below.

Proof of Concept

I have created the following test file and added it to the tests/ directory:

Running the command forge test -vvv --match-test testCheckpointTotalSupplyBugFreezing --rpc-url $ETH_RPC_URL gives the following result:

which shows that the user's attempt to call withdraw() indeed reverts and their deposited BPT tokens are permanently frozen.

Last updated

Was this helpful?