31498 - [SC - High] Alchemix ALCX rewards are currently subject to...

Submitted on May 20th 2024 at 16:15:19 UTC by @Norah for Boost | Alchemix

Report ID: #31498

Report type: Smart Contract

Report severity: High

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

Impacts:

  • Temporary freezing of funds for 12 hours

Description

Brief/Intro

  • Upon the conclusion of the epoch/activePeriod, emissions are allocated to various components within the Alchemix system, including the voter contract.

  • These emissions are subsequently distributed to the respective gauges based on the voting outcomes from the preceding epoch.

  • The distribute() function of the voter contract can be invoked by anyone, triggering the minter to dispatch the emissions to the voter contract and other destinations, while also updating the Index to reflect the reward per voting weight, accounting for the newly injected rewards.

function distribute() external {
        uint256 start = 0;
        uint256 finish = pools.length;

        for (uint256 x = start; x < finish; x++) {
            // We don't revert if gauge is not alive since pools.length is not reduced
            if (isAlive[gauges[pools[x]]]) {
                _distribute(gauges[pools[x]]);
            }
        }
        IMinter(minter).updatePeriod();
    }
  • Following this, distribute() invokes the internal _distribute() function for each gauge within the voting contracts, updating the claimable[gauge] amount in accordance with the newly updated Index via internal call updateFor() call.

  • Lastly, the voter contract calls notifyRewardAmount() on the gauge contract to dispatch the rewards based on the claimable[gauge] amount.

Vulnerability Details

  • The vulnerability arises due to the caching of the claimable[gauge] value before the execution of the _updateFor(_gauge) function.

  • Consequently, the IBaseGauge(_gauge).notifyRewardAmount(_claimable) call passes a claimable[gauge] value that doesn't incorporate emissions from the previous epoch.

  • As a result, the latest emissions are not transferred to the respective gauge but are only updated in the claimable[gauge] mapping.

  • Only way to invoke IBaseGauge(_gauge).notifyRewardAmount(_claimable) is via distribute() routine, which can only be called once a epoch after epoch period ends.

Impact Details

  • This leads to rewards being inaccessible to any gauge and consequently users for at least two weeks, until the next invocation of these routines after the two-week epoch duration.

  • Although these emission rewards for gauges are not entirely lost, as they are updated in the claimable[gauge] mapping, they become temporarily unattainable for a two-week period due to the delay in the distribute() routine.

References

Add any relevant links to documentation or code

Recommendation

  • Change the code of internal _distribute() function so that _updateFor() is called before the caching the claimable[gauge] to be used as parameter for the call IBaseGauge(_gauge).notifyRewardAmount(_claimable) .

Proof of Concept

  • I've created a test to demonstrate that rewards for epoch X are only transferred during epoch X+1, meaning after two weeks.

  • Also, run this test after implementing the recommendations for better understanding as mentioned in POC.

  • Note: The rewards for the first epochs are zero due to a different bug (refer to report #31494).

  • I have attached output of the test in both the scenarios.

  • Add the following test into the voting.t.sol file of the test suite and execute it using the following command:

    • forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/{Alchemy-api-key} --match-test "testEmissionRewardsDistributionDelay" -vvv

Last updated

Was this helpful?