Anyone calls RevenueHandler.checkpoint to update the reward for the current epoch. checkpoint will calculate the reward based on the reward token balance of the contract. However, these balances include unclaimed rewards. In other words, the unclaimed rewards of some users are included in the rewards of the new epoch.
Vulnerability Details
Please look at the following code. When tokenConfig.poolAdapter is not set, thisBalance is directly used as the reward for the new epoch.
///// https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/RevenueHandler.sol#L245-L264
uint256 thisBalance = IERC20(token).balanceOf(address(this));
// If poolAdapter is set, the revenue token is an alchemic-token
if (tokenConfig.poolAdapter != address(0)) {
// Treasury only receives revenue if the token is an alchemic-token
treasuryAmt = (thisBalance * treasuryPct) / BPS;
IERC20(token).safeTransfer(treasury, treasuryAmt);
// Only melt if there is an alchemic-token to melt to
amountReceived = _melt(token);
// Update amount of alchemic-token revenue received for this epoch
epochRevenues[currentEpoch][tokenConfig.debtToken] += amountReceived;
} else {
// If the revenue token doesn't have a poolAdapter, it is not an alchemic-token
amountReceived = thisBalance;
// Update amount of non-alchemic-token revenue received for this epoch
epochRevenues[currentEpoch][token] += amountReceived;
}
Suggested fix
Record the balance as a reward instead of using all the balance directly
Impact Details
Users who claim rewards every epoch will receive more rewards, and other users will receive less rewards
FOUNDRY_PROFILE=default forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/VFefkgjj8h3SgRYcCvmtp9KoMJJij6gD --fork-block-number 17133822 -vvv --match-test testYttriumzzPocTemp
The log
$ FOUNDRY_PROFILE=default forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/VFefkgjj8h3SgRYcCvmtp9KoMJJij6gD --fork-block-number 17133822 -vvv --match-test testYttriumzzPocTemp
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for src/test/RevenueHandler.t.sol:RevenueHandlerTest
[PASS] testYttriumzzPocTemp() (gas: 3021917)
Logs:
claimable of user2 is 125000000000000000000, but revert
IERC20(bal).balanceOf(user1): 125000000000000000000
IERC20(bal).balanceOf(user2): 0
IERC20(bal).balanceOf(address(revenueHandler)): 75000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 35.59ms (19.80ms CPU time)
Ran 1 test suite in 1.81s (35.59ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)