Guardian's review of PurrSwap ve33 for Abracadabra Money, published June 2025. The report records 74 findings, including 5 critical and 10 high.
- Published
- Review window
- May 19 to June 4, 2025
- Language
- Solidity
- Chains
- Hyperliquid
- Sector
- DEXs and AMMs, Governance
- 5 Critical
- 10 High
- 20 Medium
- 39 Low
- 0 Informational
Scope
-
GuardianOrg/purrswap-contractspurrswap-ve33-team1
d00d434216
Findings 74
-
C-01 Critical Broken Vote System As Gauge Weights Do Not Decay Logical Error Pending
Description
When a user votes for a gauge, the contract increases
weights[_gauge]by their current voting power. However, this gauge weight does not decay over time, even though the user's underlying voting power does. If the user does not vote again, the gauge retains an inflated weight indefinitely. This results in a broken system of weekly emissions, as gauges backed by inactive voters continue receiving rewards disproportionate to their actual voting power, while active voters are penalized. While thepokefunction exists to address this issue by updating stale weights, it has notable limitations:- Only the veNFT owner can call
poke. A malicious user can simply avoid calling it, indefinitely locking in inflated voting weight. - If the user's voting power has fully decayed,
pokereverts, making it impossible to clear the stale vote.
Recommendation
- Consider implementing epoch-based voting. Require users to re-cast their vote each epoch (e.g., weekly). This ensures weights are only valid within the epoch they were cast.
- Reframe
pokeas a convenience function that allows users to re-cast their vote for the current epoch, rather than a mechanism to correct abuse. The current access control (owner-only) can remain, but the misleading NatSpec comment:/// @notice To be called on voters abusing their voting powershould be removed.
- Only the veNFT owner can call
-
C-02 Critical Poke can be blocked Logical Error Pending
Description
The poke function in the Voter contract is intended to update a user’s voting power, especially in cases where users may be abusing the system by allowing outdated voting weights to persist. For instance, a user who votes once with a long-term lock (e.g., 4 years) retains their full initial voting weight indefinitely, unless they later call vote, poke, or reset. A separate issue (reported) restricts the ability to call poke to the token owner. However, even if this limitation is addressed (e.g., by allowing the protocol to call poke), users can still block the function under certain conditions. The issue lies in the internal _vote function. If a user’s voting power has fully decayed—such as after their lock has expired—the resulting gauge weights are all zero. Since _vote requires gauge weights to be greater than zero, the function reverts, preventing the update from proceeding via poke. A more impactful attack involves the user assigning a gauge weight just above zero for one of the gauges. As their voting power decays, the computed gauge weight eventually rounds down to zero. If poke is called at that point, it reverts due to the zero-weight gauge, effectively blocking the function. This allows the user to preserve outdated voting power and continue receiving disproportionate voting influence and rewards.
Recommendation
Update the poke logic to avoid reverting when a calculated gauge weight is zero—for example, by skipping such gauges rather than failing the entire transaction.
-
C-03 Critical
checkpointTotalSupplyCaches Supply Too Early Logical Error PendingDescription
The
checkpointTotalSupplyfunction caches the total voting points for a given week by callingVotingEscrow.pointHistoryand storing the value for use in reward calculations. However, it caches the value at the start of the epoch, not the end. Since users can lock or unlock tokens during the week, this cached supply can quickly become stale — opening the door for manipulation. Example attack:- Alice calls
checkpointTotalSupply, caching a supply of1e18. - She then locks
2e18worth of tokens, increasing the total supply to3e18. - When claiming rewards, her share is calculated as:
2e18 * rewards / 1e18 → she receives double the intended allocation.Because the system divides user balances by an outdated (lower) totalSupply, it inflates her share at the expense of others.
Recommendation
Update the loop condition to break when
t == roundedTimestamp:for (uint256 i = 0; i < MAX_PAST_PERIODS; i++) { if (t >= roundedTimestamp) { break; } - Alice calls
-
C-04 Critical Anyone can block Gauge withdrawals Logical Error Pending
Description
Withdrawals in Gauge.sol can be blocked by writing numerous supply checkpoints using the
getReward()function. The issue lies in the fact thatgetReward()updates the reward checkpointing of the specific token passed into it, not all of the reward tokens. This function writes a supply checkpoint at the end execution. Meanwhile, all deposit and withdraw functions update all reward tokens’ checkpoints. Updating a reward token checkpoint involves looping through each new supply checkpoint and writing to storage. Therefore, by creating numerous supply checkpoints while not updating the reward checkpoints of the other tokens, all user withdrawals will revert due to OOG error when the function attempts to update all of the reward checkpoints.Recommendation
Either call
_updateRewardForAllTokens();at the start of thegetReward()function or remove the supply checkpointing logic at the end of thegetReward()function. It is not necessary as supply does not change. -
C-05 Critical Weekly emissions can be blocked Unexpected Behavior Pending
Description
Each week
Minter.updatePeriod()is called to distribute weekly emissions, part of which are thegrowthemissions, calculated in_calculateGrowth(). That function fetches the totalbiasfor 1 second beforeactivePeriodby callingtotalSupplyAtT()ve.totalSupplyAtT(activePeriod - 1)The problem is that
totalSupplyAtT()will revert if it's called for a past timestamp and there is a checkpoint created at any point in time after that timestamp. This is because it subtracts the checkpoint timestamp from the desired timestamp, while both of them areuint256, resulting in a revert with underflow.tI - lastPoint.tsAnyone can call
ve.checkpoint()and record a checkpoint for a given timestamp. Therefore, if a checkpoint is created beforeupdatePeriod()is called, it will be impossible to distribute the rewards for that whole period. The next week the Minter will try distributing the same rewards, but this will affectPSPSlockers and bribes. Of course, this attack can be repeated again, keeping the emissions blocked.Recommendation
You can use the utility function
_findTimestampEpoch()from the other files to file the epoch which is before or atblock.timestamp - 1(notactivePeriod - 1, since_calculateGrowth()fetches the currenttotalSupply(). but uses locked value from the past). After that calculate the voting power by adjusting thebiasusing theslope. -
H-01 High Rewards Cannot Be Claimed After veNFT Expires Logical Error Pending
Description
In the current design, rewards for veNFT holders are distributed through a
claimfunction that forces the newly minted tokens to be deposited back into the lock. However, if a veNFT has passed its lock end time, any attempt to callIVotingEscrow(ve).depositFor(_tokenId, amount)will revert due to the following requirement insidedepositFor:require(oldLocked.end > block.timestamp, "cannot add to expired lock, withdraw");Once the veNFT is expired, users can no longer deposit newly claimed rewards into it. This reverts the entire reward claim, causing those tokens to remain stuck in the
VotingEscrowDistributorcontract. As a result, users lose access to their rewards if they attempt to claim after the lock expires. This issue affects theVotingEscrowDistributor.claimandVotingEscrowDistributor.claimManyfunctions.Recommendation
Allow expired veNFTs to claim and receive their rewards directly (rather than forcing
depositFor). A possible fix is to check whether the lock is expired. If it is, skip thedepositForcall and transfer the rewards to the original veNFT owner instead:if (oldLocked.end > block.timestamp) { IVotingEscrow(ve).depositFor(_tokenId, amount); } else { // Lock is expired // Transfer rewards directly to veNFT owner address nftOwner = IVotingEscrow(ve).ownerOf(_tokenId); token.safeTransfer(nftOwner, amount); }This preserves the intended lock mechanism for active veNFTs while letting expired locks still claim any accrued tokens.
-
H-02 High Lock Time Can Be Reduced via Reentrancy Logical Error Pending
Description
The
createLockForandcreateLockfunctions in theVotingEscrowcontract allow users to deposit tokens that are locked for a specified duration. These tokens can then be claimed by the designated receiver once the lock period has expired. For example, theMintercontract’sinitializefunction usescreateLockForto create maximum-duration locks (four years) for initial participants. The issue is that a user can exploit themergefunction to significantly reduce this lock period. Here's how:- User A is allocated 1,000 tokens via the
Mintercontract’s initialize function, which are locked for four years and assigned to token ID 1. - User A then calls createLockFor, locking 100 tokens for just one week. This creates a new lock under token ID 2.
- During this call, inside the _mint function, User A uses the onERC721Received callback to reenter the contract and call merge, combining token ID 1 (4-year lock) into token ID 2 (1-week lock). At this point, token ID 2 holds 1,000 tokens and retains the longer lock time (4 years).
- Once the merge completes, the original createLockFor call resumes. It continues to _depositFor, which adds the new 100-token deposit to token ID 2 and updates the lock time to the current call’s duration—just one week. As a result, token ID 2 now holds 1,100 tokens with a one-week lock, effectively bypassing the intended four-year restriction. This can also be abused to maximize voting power and rewards by first setting the lock to the maximum duration, then using this exploit to unlock early—gaining the benefits of a long-term lock without honoring its full term.
Recommendation
Apply the nonReentrant modifier to the merge function to prevent it from being called via reentrancy during functions that include external calls.
- User A is allocated 1,000 tokens via the
-
H-03 High Votes remain active in perpetuity with no way to reset Pending
Description
After a user votes, their vote power will be applied to gauges forever without the ability for another actor to reset the weights. After the user’s VE position expires, their vote power will remain attached to the gauge until the user intentionally calls
Voter.reset()BothVoter.reset()andVoter.poke()can only be called by the VE token owner or approved party. Therefore, there is no way to reset the token’s vote weight to 0 after expiry. This results in the ability to vote for gauges without having a true lock. The user can reap the benefits of voting while maintaining the ability to withdraw whenever desired.Recommendation
Remove the clause
require(IVotingEscrow(ve).isApprovedOrOwner(msg.sender, _tokenId), ErrUnauthorized());fromVoter.poke()to allow anyone the ability to reset vote weights. -
H-04 High Claim of growth emissions can be griefed DoS Pending
Description
There is a
primary issuewhich allows a malicious user to block claiming rewards of any other user and asecondary issuewhich makes it easier to keep the blocked state. Theprimary issueis thatVotingEscrow.depositFor()is permissionless. Any party can call it for atokenIdand write a checkpoint for it. This means that a malicious user can spamdepositForin every block to create a lot of locks for any user. In addition, the block time on Hyperliquid is ~0.2 seconds, which means they can technically create 5 checkpoints per second. When the fair user tries to claim their rewards, the for loop in_claimable()will try to find the closest checkpoint before theweekCursor. Once it finds it, it will move the cursor and continue. However, the loop is bounded byMAX_CLAIM_ITERATIONS = 50. Because the malicious user created a lot of checkpoints, the function will end without rewarding the user or moving the cursor. TheuserEpochwill be increased with50. Then the user may callclaim()a lot of times until they offset the malicious checkpoints. And here comes the secondary issue.Secondary issue- when theweekCursoris beyond thelastTokenTime, theuserEpochis being returned from_claimable. The value is then subtracted by 1 and saved inuserEpochOf. This can be leveraged by anyone to spamclaimcalls and reset theuserEpochOffor any user back to 0. Because of this the loop will have to do all the iterations again on the next claim.Recommendation
- Make
depositFor()callable only by the authorized accounts for thetokenIdand theVotingEscrowDistributoraccount. This will fix theprimary issue. - To fixed the
secondary issue, modify this if statement in the following way
- if (weekCursor >= lastTokenTime) { + if (weekCursor >= _lastTokenTime) { - return (weekCursor, maxUserEpoch, userEpoch, 0); + return (weekCursor, maxUserEpoch, userEpoch + 1, 0); }By replacing
lastTokenTime(not rounded to weeks) with_lastTokenTime(rounded to weeks), the code will stop earlier if a claim cannot happen and the for loop won't be entered. Then the1added touserEpochwill cancel out with the1subtracted in_claim()and theuserEpochOfwill not change. - Make
-
H-05 High Stuck Rewards in VotingEscrowDistributor Logical Error Pending
Description
The claim function in the VotingEscrowDistributor contract attempts to redeposit any claimed rewards for a token into the VotingEscrow contract by calling the depositFor function. However, if the token's lock has expired, this deposit will revert, as it is not possible to deposit into an expired lock. Additionally, the lock duration cannot be extended after expiration, since increasing the lock time is only permitted while the lock is still active. As a result, the user’s claim for that token becomes permanently stuck in the VotingEscrowDistributor contract.
Recommendation
Modify the claim function to check for expired locks and, if expired, transfer rewards directly to the token owner instead of attempting a deposit.
-
H-06 High Unsafe Casting Leads To Underflow Math Pending
Description
The
_claimableand_checkpointTotalSupplyfunctions inVotingEscrowDistributorcalculate user rewards and totalvetoken supply over time. Both perform decay calculations using:// _claimable() uint256 balanceOf = Math.max(uint256(int256(oldUserPoint.bias - dt * oldUserPoint.slope)), 0); // _checkpointTotalSupply() veSupply[t] = Math.max(uint256(int256(pt.bias - pt.slope * dt)), 0);However, if
dt * slope > bias, the subtraction results in a negative value. Casting this touint256causes an underflow, turning it into a large positive number.Math.maxthen returns this inflated value. As a result, for_claimablethe calculatedbalanceOfbecomes an extremely large number, causing the claim transaction to revert as the contract does not have sufficient balance. Similarly, whenveSupplyunderflows, it is set to a very large number. Since user rewards are distributed proportionally based onuserBalance / veSupply, the result effectively rounds down to zero. This causes all users to receive no rewards for that week, regardless of their actual stake.Recommendation
Do a
maxIntcomparison first then convert to uint128:// _claimable() uint256 balanceOf = uint128(int256(Math.maxInt((oldUserPoint.bias - dt * oldUserPoint.slope),0))); // _checkpointTotalSupply() veSupply[t] = uint256(int256(Math.maxInt((pt.bias - pt.slope * dt), 0))); -
H-07 High Incorrect Calculation Of Team Emissions Logical Error Pending
Description
The
updatePeriodfunction calculates weekly emissions for the Distributor, Voter, and Treasury (team). For the first 65 epochs, emissions are stored in both a local variable_emissionand the storage variableweekly. However, starting from epoch 66, theweeklystorage variable is no longer updated. Despite this, team emissions are still calculated usingweekly:uint256 _teamEmissions = (teamRateInBips * (_growth + _weekly)) / (BIPS - teamRateInBips);This uses an outdated value and ignores the freshly computed_emission. Over time, this results in team emissions being over-calculated, compounding each week and leading to exponential inflation.Recommendation
Update the calculation to use the local _emission variable instead of the stale weekly value:
uint256 _teamEmissions = (teamRateInBips * (_growth + _emission)) / (BIPS - teamRateInBips); -
H-08 High Staked LP Fees Cannot Be Claimed Logical Error Pending
Description
The MultiRewards contract allows users to stake PurrLP tokens to earn rewards and points. However, while these LP tokens remain staked, they continue to generate trading fees internally within the PurrLP pair contract. Currently, these accrued fees are not claimable, as MultiRewards does not expose any function to call
claimFeeson the underlying PurrLP. As a result:- Trading fees accumulate but remain unutilized.
- LPs miss out on a key source of yield, which may reduce the attractiveness of staking.
Recommendation
Implement a function in MultiRewards to allow the contract (or a designated role like owner or operator) to call the fee-claiming logic on the underlying PurrLP.
-
H-09 High Low-Decimal Token Rewards Can Be Griefed Rounding Pending
Description
In the Gauge contract, if the reward token is a low-decimal token like USDC (6 decimals), a low-cost griefing attack becomes possible. A malicious user can repeatedly call the
getRewardfunction every few blocks, triggering the_updateRewardfunction and effectively nullifying USDC rewards. Attack scenario:- A reward distributor sends 10,000 USDC to the Gauge via
notifyRewardAmount. TherewardRateis calculated as10_000e6 / 1 weeks, which is approximately16,534. - Every 60 seconds (roughly 30 blocks on Hyperliquid EVM, assuming 2-second block times), the attacker calls
getReward, which updates the reward accounting. - The reward per token is calculated in
_calcRewardPerTokenas:(currentTime - lastUpdateTime) * rewardRate[token] * 1e18 / derivedSupply. - If the time delta is small and the
rewardRateis low due to USDC’s 6 decimal places, the formula rounds down to zero. For example, assuming aderivedSupplyof 1 million tokens:60 * 16534 * 1e18 / 1_000_000e18 = 0.99204, which truncates to zero. - As
rewardPerTokenStoredremains zero, honest users receive little or no rewards when they try to claim. Over a one-week period, callinggetRewardevery 60 seconds would cost the attacker only:604800 / 60 * $0.01 = $100.80, assuming $0.01 per transaction on Hyperliquid. This makes the attack highly accessible and economically viable, while causing significant loss of rewards for legitimate participants.
Recommendation
Normalize the
rewardRateto1e18regardless of the reward token’s decimal precision. When claiming rewards, convert the earned rewards back to the original decimal precision. - A reward distributor sends 10,000 USDC to the Gauge via
-
H-10 High Checkpoint overload caused by
getRewardDoS PendingDescription
Whenever
Gauge.getReward()is called, all of the reward data for all of tokens the user wants to claim are updated and after that a total supply checkpoint is recorded. However, the user can pass an emptytokensarray to avoid the rewards update. This will result only in new checkpoint. If the user does that every block, they can spam the system with a lot of checkpoints without changinglastUpdateTimefor any of the rewards. This allows them to create e temporary DOS forGauge.depositandGauge.withdraw()because they call_updateRewardForAllTokens()which goes through all of the checkpoints from the last update until the current one and updates their data. Especially on HyperEVM, the block gas limit for fast blocks is only 2M. In result users will not be able to withdraw or deposit until batch updates for these checkpoints are executed.Recommendation
Consider changing the update logic in
getRewardso it also calls_updateRewardForAllTokens(). This will ensure every time the function is called, all of the rewards are updated and therefore there are no checkpoints left betweenlastUpdateTimeand the last checkpoint. -
M-01 Medium Malicious user can fill rewards array in gauge and bribes differently Pending
Description
The Bribe contract contain a function
notifyRewardAmount()which attempts to add the specified token to the reward tokens array if it is not already included. The function checks that the specified token is whitelisted in the voter contract.Voter.solallows anyone to list whitelist a token ifpermissionModeis set to false. Therefore, a user can whitelist many tokens and populate the reward tokens inBribe.solwith various tokens that are notIPurrLP(stake).BASE_TOKEN();andIPurrLP(stake).QUOTE_TOKEN();Subsequently, whenGauge.claimFees()is called, it will revert because the callbribe.notifyRewardAmount()will attempt to add the base and quote tokens to the rewards array that is already full.Recommendation
When creating the bribe contract, add the base and quote tokens of the pool to the rewards array to ensure that these tokens can be received as rewards.
-
M-02 Medium Incorrect Voting Power From balanceOfNFT Logical Error Pending
Description
The
balanceOfNFT(uint256 _tokenId, uint256 _t)function returns the voting power of a veNFT at timestamp_t, which is critical for snapshot-based voting. However, the function incorrectly handles historical timestamps. Specifically, when_tis earlier than the user's last checkpoint, it erroneously increases the reported voting power. This allows users who staked after the snapshot to receive non-zero voting power, violating the intended logic that only tokens staked before the snapshot should count. The root cause lies in this line:lastPoint.bias -= lastPoint.slope * int128(int256(_t) - int256(lastPoint.ts));If_t < lastPoint.ts, the subtraction becomes negative, and the result is effectively added to bias, artificially inflating the voting power. Example- User stakes at time
t = 10 - Snapshot for voting is taken at time
t = 5 - Voting power is calculated as:
bias - slope * (5 - 10) = bias + (5 * slope)This gives the user positive voting power att = 5, even though they had not staked yet. The earlier the snapshot, the higher the incorrect voting power becomes.
Recommendation
Guard against timestamps earlier than the user's last checkpoint by returning zero voting power in such cases.
- User stakes at time
-
M-03 Medium Improper Supply Update on Merge Logical Error Pending
Description
The
mergefunction combines two tokens into one by adding their amounts, using the later lock end time, and burning one of the token IDs. The problem is that after burning the source token, the function does not reduce thesupplyvariable by its value. It then proceeds to call_depositFor, which addsvalue0to the destination token and increases thesupplyby the same amount. Since no new tokens are being deposited—only merged—this results in an incorrect net increase in total supply. Because this occurs on every merge call, thesupplyvariable becomes increasingly inaccurate over time, diverging from the actual amount of tokens and breaking any logic that depends on a correct total supply.Recommendation
Subtract value0 from the supply variable during the merge function. Alternatively, modify _depositFor to skip increasing the supply variable when called as part of a merge.
-
M-04 Medium Rewards can be griefed for lower decimal tokens Rewards Pending
Description
The
rewardRateinMultiRewardsis calculated asamount / _rewardData[rewardToken].rewardsDuration, whereamountis in the token's decimals. For tokens will lower decimals precision (for example, 6), this value will be relatively small. The following calculation is then present inrewardPerToken()pendingRewardsPerToken = (timeElapsed * _rewardData[rewardToken].rewardRate * 1e18) / totalSupply;Whenever
timeElapsed * rewardRate * 1e18 < totalSupply,rewardPerToken()will return the same value as before. This is a problem because whenever_updateRewards()is called, bothrewardPerTokenStoredandlastUpdateTimeare updated. SincerewardPerToken()returns the old value and is assigned torewardPerTokenStored, this means onlylastUpdateTimewill be effectively updated. A malicious user can callgetRewards()as frequently as possible to trigger execution of_getRewards(). Over time, this will result in no updates forrewardPerTokenStored, onlylastUpdateTime. As a result, rewards are not accumulating and stakers won't be able to claim them.Recommendation
It's in notion
- Add a
scalingFactorfield to theRewardstruct
struct Reward { uint256 rewardsDuration; uint256 periodFinish; uint256 rewardRate; uint256 lastUpdateTime; uint256 rewardPerTokenStored; + uint256 scalingFactor; }- Modify
addReward()to accept how much decimals does the reward token have and calculate the correct scaling factor
- function addReward(address rewardToken, uint256 _rewardsDuration) public onlyOwner { + function addReward(address rewardToken, uint256 _rewardsDuration, uint256 decimals) public onlyOwner { if (rewardToken == address(0)) { revert ErrInvalidTokenAddress(); } if (_rewardData[rewardToken].rewardsDuration != 0) { revert ErrRewardAlreadyAdded(); } if (_rewardsDuration == 0) { revert ErrZeroDuration(); } rewardTokens.push(rewardToken); _rewardData[rewardToken].rewardsDuration = _rewardsDuration; + _rewardData[rewardToken].scalingFactor = _getScalingFactor(decimals); }- Create the
_getScalingFactor()function. If you want to, you can make18a constant so it's not a magic value.
function _getScalingFactor(uint256 decimals) internal returns(uint256) { if (decimals >= 18) { return 1; } return 10 ** (18 - decimals); }- Adjust
rewardRatebyscalingFactorinnotifyRewardAmount
function notifyRewardAmount(address rewardToken, uint256 amount) external onlyOwnerOrRoles(ROLE_REWARD_DISTRIBUTOR) { if (_rewardData[rewardToken].rewardsDuration == 0) { revert ErrInvalidTokenAddress(); } _updateRewards(address(0)); rewardToken.safeTransferFrom(msg.sender, address(this), amount); + uint256 scalingFactor = _rewardData[rewardToken].scalingFactor; + amount *= scalingFactor; // Take the remainder of the current rewards and add it to the amount for the next period if (block.timestamp < _rewardData[rewardToken].periodFinish) { amount += (_rewardData[rewardToken].periodFinish - block.timestamp) * _rewardData[rewardToken].rewardRate; } _rewardData[rewardToken].rewardRate = amount / _rewardData[rewardToken].rewardsDuration; _rewardData[rewardToken].lastUpdateTime = block.timestamp; _rewardData[rewardToken].periodFinish = block.timestamp + _rewardData[rewardToken].rewardsDuration; - emit LogRewardAdded(amount); + emit LogRewardAdded(amount / scalingFactor); }- Undo the scaling in
_getRewardsFor()
for (uint256 i; i < rewardTokens.length; ) { address rewardToken = rewardTokens[i]; - uint256 reward = rewards[user][rewardToken]; + uint256 reward = rewards[user][rewardToken] / _rewardData[rewardToken].scalingFactor; if (reward > 0) { rewards[user][rewardToken] = 0; rewardToken.safeTransfer(user, reward); emit LogRewardPaid(user, rewardToken, reward); } unchecked { ++i; } }- Finally, modify the
getRewardForDuration()view function to account for the scaling factor as well, so the result is accurate
function getRewardForDuration(address rewardToken) external view returns (uint256) { - return _rewardData[rewardToken].rewardRate * _rewardData[rewardToken].rewardsDuration; + return _rewardData[rewardToken].rewardRate * _rewardData[rewardToken].rewardsDuration / _rewardData[rewardToken].scalingFactor; } - Add a
-
M-05 Medium
veForAt()returning enormous value Unexpected Behavior PendingDescription
The
VotingEscrowDistributor.veForAt()function returns the voting power of the NFT at a given point in time. If thetimestampwhich we are checking for is beyond the lock expiry, the calculation will result in a negative number. That's why the code tries to cap the returned value to 0. However, it turns the negative result intouint256and passes it after that toMath.max(). This casting result in an unreasonably large number, leading to extremely large values returned byveForAt(). The same way of calculation is present in_checkpointTotalSupply()and_claimable().Recommendation
Introduce a new override of the
Math.max()function which works with signed integers and use it instead of the current one. -
M-06 Medium
updatePeriodReverts When No Votes Are Cast DoS PendingDescription
The
updatePeriodfunction inMinterhandles the distribution of protocol emissions, including:- Base emissions to
voterfor gauge rewards - Growth emissions to ve holders
- Team emissions as a percentage of new emissions
However, if no votes have been cast,
totalWeightin thevotercontract will be zero. This causes a division-by-zero revert innotifyRewardAmountwhen attempting to distribute base emissions. While uncommon, this can occur in the early stages of the protocol or with minimal voter participation. When triggered, all emissions — including growth and team allocations — are blocked.
Recommendation
Add a check to skip
voteremissions whentotalWeight == 0, allowing the remaining distributions to proceed uninterrupted. - Base emissions to
-
M-07 Medium Initial
veDistributorrewards not claimable Rewards PendingDescription
Each week the
veDistributorreceives emissions which it distributes to theveEscrowlockers. The reward claimable depends on thebiasthe locker holds at the very beginning of the given week. Because users won't have any locked balance at the very first week, nobody will be able to claim the rewards for the first 7 days.Recommendation
To rescue the funds, you can add a check in
VotingEscrowDistributorif the received rewards are for the very first epoch and refund them to thetreasury.else { function checkpointToken() { ... + if (thisWeek == startTime) { + uint256 amount = (toDistribute * (nextWeek - t)) / sinceLast; + psps.transfer(treasury, amount); + pspsLastBalance -= amount; + continue; + } tokensPerWeek[thisWeek] += (toDistribute * (nextWeek - t)) / sinceLast; } ... } -
M-08 Medium Merge Of Expired Locks Breaks Claims Logical Error Pending
Description
The
mergefunction allows combining two lock NFTs even if one is expired. This leads to unexpected behavior in reward distribution during claims. Example scenario:- Alice creates Lock A for 3 weeks and lets it expire without claiming rewards.
- Two weeks after Lock A expires, she creates Lock B and merges it into Lock A.
- When claiming rewards with Lock A,
_claimableiterates over the entire range between the original and new locks — including the 2-week gap where no lock existed. Two unexpected outcomes were observed during testing: - The
balanceOfcalculation can underflow due to decay over periods with no active lock. This interacts with the unsafe casting issue reported separately. VotingEscrowDistributor.sol#L318 - If no users were staked during gap epochs,
veSupply == 0, the calculation oftoDistributereverts due a division by zero. VotingEscrowDistributor.sol#L324
Recommendation
Restrict
mergeto only allow combining of non-expired locks. -
M-09 Medium Incorrect initialLastPoint Copy Breaks Snapshot Logical Error Pending
Description
In the
_checkpointfunction, the local variableinitialLastPointis intended to store a snapshot of the state at the time of the last checkpoint. It is initialized as follows:Point memory initialLastPoint = lastPoint;However, due to Solidity's memory semantics for structs, this line does not create an independent copy. Instead, bothinitialLastPointandlastPointrefer to the same memory location. As a result, any changes made tolastPointare unintentionally reflected ininitialLastPoint. This shared reference leads to a logic error in the following line:lastPoint.blk = initialLastPoint.blk + (blockSlope * (tI - initialLastPoint.ts)) / MULTIPLIER;Here,tI - initialLastPoint.tsis supposed to represent the time elapsed since the last checkpoint. But sinceinitialLastPoint.tshas already been mutated via changes tolastPoint, the difference is always zero. Consequently,lastPoint.blkdoes not advance as it should — it remains constant across all epochs. Every epoch ends up inheriting the same block number, regardless of when it was created. Impact:_findBlockEpochalways returns the latest epoch, regardless of the target block.totalSupplyAtandbalanceOfAtNFTreturn incorrect results for historical block queries.- Snapshot voting relying on historical ve balances is broken.
Recommendation
Ensure a true copy of
lastPointis made when initializinginitialLastPoint. This can be done manually by copying each field:Point memory initialLastPoint = Point({ bias: lastPoint.bias, slope: lastPoint.slope, ts: lastPoint.ts, blk: lastPoint.blk }); -
M-10 Medium Loss Of Unclaimed Rewards And Bribes Logical Error Pending
Description
When veNFT holders call
mergeorwithdraw, their tokens are burned without first claiming pending rewards from the Distributor contract. If they later attempt to claim, theclaimfunction reverts because it tries to calldepositForon a non-existent (burned) token. Even ifdepositForwere skipped, the contract would still not be able to determine the rightful recipient of the rewards, as the owner of the burned token cannot be retrieved. Similarly, the bribe rewards for these users are lost as well, as thegetRewardfunction checksve.isApprovedOrOwner(msgs.sender, tokenId)for the non-existent token.Recommendation
Add these calls:
Distributor.claim(tokenId)Bribe.getRewardwithin bothmergeandwithdrawfunctions, prior to burning, to guarantee that rewards and bribes are properly settled before burning the token.
-
M-11 Medium Griefing Via Bribe Rewards Token Length DoS Pending
Description
The
notifyRewardAmountfunction includes a check to ensure that the number of reward tokens does not exceed theMAX_REWARD_TOKENSlimit, which is set to 16. This limit is intended to prevent unbounded growth of therewardsarray. However, in the current test configuration,permissionModeinVoter.solis set tofalse, which allows anyone to whitelist a token as a reward token. As a result, a malicious actor can grief the system by depositing arbitrary or valueless tokens intoBribe.sol, filling up the rewards array and preventing legitimate reward tokens from being added.Recommendation
For production deployments, set
permissionMode = trueto ensure that only authorized accounts can whitelist reward tokens. Consider also using (or removing) theisBribemapping in Voter as it is currently able to be set by admin, but is not used anywhere. -
M-12 Medium killGauge May Reset Unsent Rewards Logical Error Pending
Description
The
killGaugefunction can be called by an admin to disable a gauge which callsdistributeto send out any remaining rewards before settingisLive[gauge] = falseand resettingclaimable[gauge]to zero. The problem is that insidedistribute, if theclaimableamount is less thanIGauge(_gauge).left(psps)or if_claimable / DURATIONrounds to zero, the funds are not sent. Despite this,killGaugewill still reset theclaimableamount to zero. Consequently, any remaining funds not distributed will be stuck in the Voter contract.Recommendation
Modify the killGauge function to send any non-zero claimable amount to a specified address before resetting the variable.
-
M-13 Medium Lost emissions due to killed gauges Rewards Pending
Description
When a gauge is killed, it stops accruing rewards in the
Voter, however its weight is still contributing towardstotalWeight. The received emissions inVoter.notifyRewardAmount()are divided bytotalWeightto calculate the new reward index. Because killed gauges are also counted, the rest of the gauges receive less rewards than they should and the surplus remains locked in the contract, leading to a loss of emissions and value for the gauges.Recommendation
You can add a
killedWeightvariable which is subtracted fromtotalWeightinnotifyRewardAmount(). ThekilledWeightvariable will be increased when a gauge is killed and when someone votes for a killed gauge and it will be decreased when a gauge is revived and when someone resets a vote from a killed gauge. In result, the killed gauges won't be considered during reward distribution and the whole amounts will be claimable. -
M-14 Medium Killed gauge may accrue rewards Unexpected Behavior Pending
Description
Voter.killGauge()allows the admin to kill a gauge. Once killed, the gauge should stop accruing rewards. Whenever_updateFor(gauge)is executed, the gauge's global reward index is updated, but itsclaimedmapping is left unchanged if killed. By updating the index, but not the amount claimable, the gauge is successfully skipped. However,reviveGauge()doesn't call_updateFor(gauge)before reviving the gauge. This creates a period of time between the last update for the killed gauge and the revival timestamp which is eligible for rewards. It's even possible that the gauge hasn't been updated for the whole duration from killing to reviving. In result, the killed gauge will receive from the rewards of the other gauges.Recommendation
Update the gauge before revival
function reviveGauge(address _gauge) external onlyAdmin { require(!isLive[_gauge], ErrGaugeAlreadyLive()); + _updateFor(_gauge); isLive[_gauge] = true; emit GaugeRevived(_gauge); } -
M-15 Medium Enabling autocompounding breaks the system Configuration Pending
Description
The PurrLP contract has its
AUTO_COMPOUND_LPset totrueand there is no call tosetAutoCompoundFeesin the deployment script. This is a problem because theGaugerelies on non-compounding fees to be claimed and send as rewards to theBribe. IfAUTO_COMPOUND_LPstaystrue, the fees will be rewarded to the token holders, which means that LPs can deposit their tokens in theGaugeand receive both weekly emissions and swap fees, effectively stealing from the gauge voters.Recommendation
You can change
_AUTO_COMPOUND_FEES_tofalse, but this solution removes the intended configuration flexibility of the pool. Instead, a better approach is to changePurrLPto have autocompounding per pool and not globally. Then you can use the pools with no autocompounding in the ve3,3 system while still being able to have modifiable pools. -
M-16 Medium Ineffective Burn of PSPS Tokens in
burnLogical Error PendingDescription
The
burnfunction inVotingEscrowis designed to permanently destroy a veNFT and the underlying locked PSPS tokens. However, it does so by transferring the locked PSPS tokens to the zero address:require(IERC20(token).transfer(address(0), value), ErrTransferFailed());Transferring tokens to the zero address does not reduce the reportedtotalSupplyof PSPS, creating a discrepancy between the actual circulating supply and the on-chain total supply. As a result:- PSPS emissions are over-calculated by the Minter, since they are based on an inflated
totalSupply - Market cap and FDV calculations become distorted, as many analytics platforms rely on totalSupply to compute these metrics, potentially impacting token price.
Additionally, the
burnfunction appears non-essential and introduces a potential footgun if mistakenly called.
Recommendation
If token burning is intended, modify the function to properly reduce PSPS total supply by exposing and calling a proper
_burnfunction in the PSPS token contract. Else, consider removing theburnfunction from VotingEscrow entirely to reduce complexity and eliminate potential misuse. - PSPS emissions are over-calculated by the Minter, since they are based on an inflated
-
M-17 Medium Precision Loss Leads To Irrecoverable Rewards Rounding Pending
Description
The
notifyRewardAmountfunction inGauge.solcalculates the reward rate for a token as:rewardRate[token] = amount / DURATION;This calculation truncates any remainder resulting in dust amounts of tokens being trapped in the contract. Assuming WBTC is the reward token, for 1 WBTC deposited, 0.000026 WBTC (approximately $3) is stuck. This unclaimable amount grows each week with every new reward deposit, across all reward tokens, resulting in a material loss over time.Recommendation
Implement a
recoverfunction, callable by an admin, to allow manual recovery of accumulated dust. -
M-18 Medium Restricted
pokecall frequency Unexpected Behavior PendingDescription
The
Voter.poke()function can be called once for each voting period, which is 7 days long. This is different from some other protocols that allow callingpoke()at any point in time. Because of this, the Voter will be using outdated weight of the user. For example, if emissions happen on day 0-7-14...., and two users create the exact same locks on day 1, but user A votes immediately while user B votes on the 6th day, user B will have less voting power than user A and user A cannot be poked because their token is locked until day 8. This will result in them directing bigger part of the emissions towards their desired gauge on day 7 when the actual distribution happens.Recommendation
Consider changing
poke()in such a way that it doesn't require the token to be expired by callingisVoteExpired. -
M-19 Medium Incorrect Epoch Accounting for Rewards Logical Error Pending
Description
The
earnedfunction in the Bribe contract calculates the amount of rewards earned per token per epoch. The issue lies in how the function determines the token balance and total supply for a given epoch: it uses_currTs + DURATIONas the timestamp, which corresponds to the very first second of the next epoch. As a result, any deposits made at the beginning of the next epoch are incorrectly included in the previous epoch. This is problematic because users who did not have a previous deposit will not be able to claim that epoch’s rewards, yet their deposited tokens are still factored into the reward calculation. Consequently, those rewards become unclaimable and remain stuck in the contract.Recommendation
Adjust the timestamp used in the earned function to reflect the actual end of the epoch (e.g.,
_currTs + DURATION - 1) to ensure only balances from the correct epoch are used in reward calculations. -
M-20 Medium Wrong reward validation in
BribeValidation PendingDescription
Bribe.notifyRewardAmount()validates the sent reward token is whitelisted by callingVoter.isWhitelisted(). This is not the correct validation because whitelisted tokens are tokens that are allowed to be used as base and quote for creating new pools. There is a separate mapping in theVotercontract calledisBribethat's used to enable bribe rewards. The current implementation is problematic because it leads to unexpected behaviors. For instance:- A few pools are created with tokens A and B. The voter admin wants to limit the creation of these pools, so he delists the two tokens, but he still wants to support the already created ones. Because
Bribechecks if the tokens are whitelisted, it won't be able to receive rewards anymore. - The voter admin wants to stop a bribe from receiving a particular token as reward so he sets
isBribetofalse, but this doesn't affect the Bribe and it can continue accruing such rewards.
Recommendation
Check for
isBribeinstead- require(voter.isWhitelisted(token), ErrTokenNotWhitelisted()); + require(voter.isBribe(address(this), token)), ErrTokenNotWhitelisted()); - A few pools are created with tokens A and B. The voter admin wants to limit the creation of these pools, so he delists the two tokens, but he still wants to support the already created ones. Because
-
L-01 Low Duplicate read from storage Pending
Description
The
approve()function performs two reads from the same storage slot.Recommendation
Use the
ownervariable instead of reading from storage a second time. -
L-02 Low Malicious Admin can frontrun whitelist() Pending
Description
When a user calls
whitelist(), they are required to pay alistingFeeParameters.feein the PSPS token. Since this value is not specified in the function call and the admin can update the fee value at any time, a malicious admin can frontrun the call and increase the amount. A user with a larger allowance of PSPS tokens could suffer losses.Recommendation
Create an
expectedFeeparameter and check it against the value oflistingFeeParameters.feein storage. Revert the call if they differ. -
L-03 Low Tokens that are delisted will remain active in existing gauges Pending
Description
When the admin delists a token, any gauges that are already configured with this token will remain active.
Recommendation
Consider adding a function to kill all gauges the rely on a specific token when delisting the token.
-
L-04 Low Permissionless pool and gauge deployments can DoS updates and distributions Pending
Description
Given that the deployments of pools and gauges are permissionless, a single actor can deploy numerous pools and gauges, causing the function to update and distribution for all gauges to fail due to an Out-Of-Gas error.
Recommendation
This is already mitigated due to the presence of the individual
updateFor()andupdateForRange()functions. However,updateForRange()is somewhat compromised by the over-deployment of gauges as well. The project team will need to maintain a list of legitimate pools and update through theupdateFor(address[] memory _gauges)function. The distribution functions will require the same monitoring as well to effectively distribute to all gauges. -
L-05 Low Inconsistent Flash-Loan Block Protection Across balanceOfNFT Variants Logical Error Pending
Description
In the
VotingEscrowcontract, thebalanceOfNFT(uint256 _tokenId)function enforces a flash‐loan protection preventing newly transferred veNFTs from being used for voting or gauge boosting in the same block by returning zero ifownershipChange[_tokenId] == block.number. However, the otherbalanceOfNFT(uint256 _tokenId, uint256 _t)andbalanceOfAtNFT(uint256 _tokenId, uint256 _block)functions that compute the NFT’s voting power do not enforce the same ownership‐change check. As a result, protocols or external calls that rely on them can see a non-zero voting balance even if the veNFT was transferred in the current block.Recommendation
Align the same‐block flash check in the
balanceOfNFT(uint256 _tokenId, uint256 _t)andfunction balanceOfAtNFT(uint256 _tokenId, uint256 _block)functions. -
L-06 Low Incorrect view function
listingFee()PendingDescription
listingFee()is supposed to return amount of PSPS required to list a new token by performing the following calculation:(IERC20Metadata(psps).totalSupply() - IERC20Metadata(ve).totalSupply()) / listingFeeParameters.fee;However, we see in the
whitelist()function that the true amount taken from the user in order to list a new token is simplyListingFeeParameters.fee:function whitelist(address _token) public checkPermissionMode { if (msg.sender != admin) { if (listingFeeParameters.fee > 0) { psps.safeTransferFrom(msg.sender, listingFeeParameters.recipient, listingFeeParameters.fee); } } _whitelist(_token); }Recommendation
Either use the public
listingFee()view function in thesafeTransferFrom()call or alter the view function to simply return theListingFeeParameters.feevalue. -
L-07 Low Two files with the same name Best Practices Pending
Description
There are currently two files with the same name, but different case -
contracts/interfaces/IPurrLpOracle.solandcontracts/interfaces/IPurrLPOracle.sol. The only difference is theLPandLp. The file used by the system isLP, so the other one can be deleted. The existence of such a file may cause problems on case-insensitive operating systems like MacOS and Windows - for exampleIPurrLpOracle.soloverridesIPurrLPOracle.solwhen the repository is cloned.Recommendation
Delete the
IPurrLpOracle.solfile -
L-08 Low
VotingEscrowis notERC721compliant Unexpected Behavior PendingDescription
The
VotingEscrow.supportsInterface()function returnstruewhen it's called with the interface id forERC721(0x80ac58cd). However, it doesn't implement it correctly because:balanceOf(address)doesn't revert when called withaddress(0).ownerOf()doesn't revert for invalid ids.transferFrom()andsafeTransferFrom()don't revert when NFTs are transferred to the 0 address.getApproved(uint256)doesn't revert when it receives invalid token id.
Recommendation
Fix the 4 issues from the report to make the contract fully compliant.
-
L-09 Low Duplicate function
distro()can be removed PendingDescription
There are two functions
distro()anddistribute()that are identical. However,distribute()is used in theGauge.solcontract.Recommendation
Remove the
distro()function. -
L-10 Low User can create false
emitWithdrawemissions PendingDescription
A user can deposit stake token into
Gauge.soland attach a VE token id. This will callIVoter(voter).emitDeposit(tokenId, msg.sender, amount);signaling that the user has deposited the specific amount with the specified token id. However, a user is able to withdraw their token id without withdrawing their stake token viawithdrawToken(). They are not required to withdraw their stake token. The user can then deposit another token id and withdraw all of their stake token while withdrawing the new token id. This will callIVoter(voter).emitWithdraw(tokenId, msg.sender, amount);signaling that the new token id withdrew the large stake amount, however, the stake was deposited with the previous token. This may present an inaccurate history of deposits and withdrawals based on token ids.Recommendation
When withdrawing the token, require that the user withdraw all of their stake.
-
L-11 Low Minter initializing can be blocked Informational Pending
Description
When
Minter.initialize()callscreateLockFor(), the execution flow will be given to each NFT recipient. If the recipient can't receive the NFT, either because the account doesn't support NFTs or on purpose, the initialize will fail and it has to be executed again.Recommendation
Keep that possibility in mind.
-
L-12 Low Minter doesn't follow the
CEIpattern Best Practices PendingDescription
The
Minter.initialize()function loops through all the lockers, callsve.createLockFor()for each one of them and sets the state of the contract only after that. SincecreateLockFor()will mint NFT, the execution flow will be given to the NFT recipient. There is no direct risk for the contract, but it's possible that a read-only reentrancy happens if any of the other contracts read fromMinterin the future.Recommendation
Consider moving the for loop to the end of the
initialize()function. -
L-13 Low Inaccurate Minter
MAX_LOCKMath PendingDescription
The Minter contract has defined a
MAX_LOCKconstant which should be equal to 4 years, the maximum time tokens can be locked for in the voting escrow. However, the value of this constant is52 * 4 weekswhich is125798400. In contrast, 4 years in the escrow are defined as4 * 365 * 86400 = 126144000. The locks created by the minter are with 4 days shorter. Because the timestamp is rounded to the nearest week, this results in a week difference than the actual max lock.Recommendation
- uint256 public constant MAX_LOCK = 52 * 4 weeks; // 4 years + uint256 public constant MAX_LOCK = 4 * 365 * 86400 -
L-14 Low listingFee Reverts If Fee Not Set Unexpected Behavior Pending
Description
The public view function
listingFeecomputes a fee by dividing bylistingFeeParameters.fee. SincelistingFeeParameters.feedefaults to zero, any call tolistingFeebefore an admin invokessetListingFeeParameterswill revert with a division‐by‐zero error. Front-end integrations expecting a numeric fee (even zero) will instead see unexpected reverts or broken UI.Recommendation
Guard against a zero denominator by returning 0 when
listingFeeParameters.feeis unset or zero. -
L-15 Low
initializeOverrides Intended Emissions Delay Logical Error PendingDescription
In the constructor,
activePeriodis set to defer emissions by 2 weeks, aligning with the comment:// The activePeriod is set to start 2 weeks from deployment (rounded to week boundary)However, this delay is overwritten in theinitializefunction with:activePeriod = (block.timestamp / WEEK) * WEEK;This resetsactivePeriodto the current week, effectively removing the intended 2-week delay. As a result, emissions can begin after just one week instead of two.Recommendation
Ensure
initializepreserves the constructor-setactivePeriodor re-implements the 2-week offset ifinitializeis intended to control it. -
L-16 Low Rewards claim underflows for invalid tokens Unexpected Behavior Pending
Description
VotingEscrowDistributor._claim()takes theuserEpochreturned by_claimable()and subtracts 1 from it. However,_claimable()returns 0 ifmaxUserEpoch == 0. Because it's anuint, the transaction will revert with anunderflow. This may be unexpected, especially for a call toclaimMany().Recommendation
You can implement the following change to avoid reverts:
if (maxUserEpoch == 0) { - return (0, 0, 0, 0); + return (0, 0, type(uint256).max, 0); }If reverts are the desired outcome, you can then check if
userEpoch == type(uint256).maxin the_claim()function and revert with a descriptive error message. -
L-17 Low Rewards can be recovered from
MultiRewardsRewards PendingDescription
MultiRewards.recover()allows the owner to rescue ERC20 tokens from the contracts, including reward tokens. This feature is useful for recovering reward tokens stuck in the contract due to the precision loss innotifyRewardAmountwhen calculating therate, but since there is no limit set to the amount to be recovered, the contract may be put in a state where it doesn't have enough funds to pay out rewards.Recommendation
Consider adding a
dustfield to theRewardstruct and update it innotifyRewardAmount()_rewardData[rewardToken].rewardRate = amount / _rewardData[rewardToken].rewardsDuration; _rewardData[rewardToken].lastUpdateTime = block.timestamp; _rewardData[rewardToken].periodFinish = block.timestamp + _rewardData[rewardToken].rewardsDuration; + uint256 claimableAmount = _rewardData[rewardToken].rewardRate * _rewardData[rewardToken].rewardsDuration; + _rewardData[rewardToken].dust += amount - claimableAmountThen don't allow pulling more reward tokens than
dustfunction recover(address _token, address _to, uint256 _amount) external onlyOwner { if (_token == stakingToken && _amount > stakingToken.balanceOf(address(this)) - totalSupply) { revert ErrSkimmingTooMuch(); } + if (_rewardData[_token].lastUpdateTime != 0) { + if (amount > _rewardData[_token].dust) { + revert ErrSkimmingTooMuch(); + } + _rewardData[_token].dust -= amount; + } if (_token == address(0)) { SafeTransferLib.safeTransferETH(_to, _amount); } else { _token.safeTransfer(_to, _amount); } emit LogRecovered(_token, _to, _amount); }You can also implement the
residualAmountapproach where any dust is added towards the next rewards distribution. -
L-18 Low Points can leave not whitelisted wallets Validation Pending
Description
Pointsis anERC20token which can be transferred only from or to a whitelisted address. This restriction is not applied iffrom == address(0)orto == address(0)to allow minting and burning. However, the token cannot be burned, since there is no exposed function in the ABI which calls_burn(). On the other hand, users can calltransferwithaddress(0)as recipient. The transaction will then go through and the tokens will leave their wallets. The users can also send them toMultiRewardswithout staking, but in this case they can be pulled out of it by callingrecover().Recommendation
If you want to prevent tokens leaving their wallets entirely and all the whitelisted addresses will be initiating the transfers, you can change the
_beforeTransferFunctiontofunction _beforeTokenTransfer(address from, address to, uint256 /*amount*/) internal view override { if (from != address(0) && !whitelisted[msg.sender]) { revert NotAllowed(); } } -
L-19 Low Part of MultiRewards may not be claimed Rewards Pending
Description
If
MultiRewards.notifyRewardAmount()is called when there are no active stakers, any amount from that moment until the first stake happens will not be claimable by anyone. The current implementation allows rescuing reward tokens, but if this finding is fixed, the rewards will remain stuck there.Recommendation
Plan carefully when to call
notifyRewardAmount() -
L-20 Low No rewards length cap in
MultiRewardsRewards PendingDescription
MultiRewardsdoesn't implement any limit for reward tokens length. This makes it possible to push a lot of reward tokens in therewardsarray by callingaddReward()and potentially causing the for loop in_updateRewards()to revert with OOG. Since_updateRewards()is called on each interaction, this will result in a DOS of the contract.Recommendation
Consider implementing a length limit for reward tokens.
-
L-21 Low
MultiRewardstokens can be blacklisted Rewards PendingDescription
The
MultiRewards.setRewardsDuration()function allows changing therewardsDurationof a reward token to a non-zero amount, even if the token has not been added. This behavior can be used to permanently disable adding a given token as a reward, sinceaddReward()requires that the currentrewardsDurationfor the new token is 0. If the blacklisting is used, 2 new problems emerge:notifyRewardAmount()can be called for the modified reward token. TherewardRate,lastUpdateTimeandperiodFinishfields will be updated, but since the token is not part ofrewardToken, the amount will never be paid out.isSupportedReward()will returntruefor the blacklisted token since it relies onrewardsDuration
Recommendation
If such blacklisting behavior is desirable, you need to adjust
isSupportedReward()andnotifyRewardAmount. If you implement thescalingFactorfix from this issue, the functions can be changed to use it instead ofrewardsDuration.function isSupportedReward(address rewardToken) external view returns (bool) { - return _rewardData[rewardToken].rewardsDuration != 0; + return _rewardData[rewardToken].scalingFactor != 0; }function notifyRewardAmount(address rewardToken, uint256 amount) external onlyOwnerOrRoles(ROLE_REWARD_DISTRIBUTOR) { - if (_rewardData[rewardToken].rewardsDuration == 0) { + if (_rewardData[rewardToken].scalingFactor == 0) { revert ErrInvalidTokenAddress(); } ... }The alternative and more concise solution is to simply not allow changing the
rewardsDurationfor tokens that are not added.function setRewardsDuration(address rewardToken, uint256 _rewardsDuration) external onlyOwner { if (block.timestamp <= _rewardData[rewardToken].periodFinish) { revert ErrRewardPeriodStillActive(); } if (_rewardsDuration == 0) { revert ErrZeroDuration(); } + if (_rewardData[rewardToken].rewardsDuration == 0) { + revert ErrInvalidTokenAddress(); + } _rewardData[rewardToken].rewardsDuration = _rewardsDuration; emit LogRewardsDurationUpdated(rewardToken, _rewardData[rewardToken].rewardsDuration); } -
L-22 Low No Removal Function for Inactive Reward Tokens Best Practices Pending
Description
The MultiRewards contract loops through the entire
rewardTokensarray each time a user claims rewards. While the owner can add new reward tokens viaaddRewardand effectively "disable" them by setting their duration to zero usingsetRewardsDuration, these deactivated tokens are not removed from the array. As a result, the array of reward tokens can grow indefinitely. Over time, this leads to unnecessary iterations during reward claims, increasing gas costs for users. In extreme cases, this could cause out-of-gas errors and make the contract unusable for claims.Recommendation
Implement a
removeTokenfunction to allow the owner to clean up inactive reward tokens from the array. -
L-23 Low Operator Receives Funds Instead of veNFT Owner Unexpected Behavior Pending
Description
In both VotingEscrow and Bribe contracts, some functions are gated by the common pattern:
require(isApprovedOrOwner(msg.sender, tokenId), "Not authorized");This allows an approved operator to act on behalf of a veNFT owner. However, functions likewithdraworgetRewardtransfer funds tomsg.sender, not the actual veNFT owner. This behavior may be unexpected or incorrect in certain cases—especially if the operator is a contract that cannot safely receive tokens or if the operator was merely automating on behalf of the user.Recommendation
Review whether this pattern is intentional. If not, consider changing the destination of transfers to
ownerOf(tokenId)rather thanmsg.sender. This ensures that funds always return to the veNFT owner, regardless of who initiates the action. -
L-24 Low
ownershipChangeCan Be Bypassed WithmergeLogical Error PendingDescription
The
ownershipChangemapping is intended to prevent flash attacks by disallowing voting in the same block a veNFT is minted or transferred. However, this protection can be bypassed by minting a new veNFT and immediately merging it into an existing one. Since themergefunction does not updateownershipChangefor the destinationtokenId, the merged veNFT can vote in the same block, defeating the purpose of the flash protection.Recommendation
In the
mergefunction, consider updating theownershipChangemapping for thetotokenId. -
L-25 Low Ambiguous event emission Events Pending
Description
Gauge.getReward()can be called by users to claim their rewards, but also from theVotercontract to claim any user's rewards. However, theClaimRewardsevent emitsmsg.senderasfrominstead ofaccount. This will result in theVoterbeing emitted asfrom, which is ambiguous because the rewards are sent toaccount.Recommendation
Consider whether you want the
fromfield to be the account receiving the rewards or the account initiating the claim. You can also add a new field to the event to have both of them. -
L-26 Low Precision Loss Leaves Reward Dust Rounding Pending
Description
In MultiRewards.sol, the
notifyRewardAmountfunction sets the reward rate as follows:_rewardData[rewardToken].rewardRate = amount / _rewardData[rewardToken].rewardsDuration;This calculation uses integer division and truncates any remainder. As a result, a small amount of reward tokens (i.e., dust) remains unallocated and is not distributed to users.Recommendation
While this does not result in permanent loss as the contract includes a
recoverfunction for ERC20 tokens, consider documenting this behavior for users and integrators to ensure transparency. -
L-27 Low Inconsistent veNFT Access Control in
depositAccess Control PendingDescription
Across the system, interactions involving a veNFT typically check:
IVotingEscrow(ve).isApprovedOrOwner(msg.sender, _tokenId)This allows approved operators to act on behalf of the veNFT owner, supporting delegated workflows. However, inGauge.sol, thedepositfunction enforces a stricter check:require(IVotingEscrow(ve).ownerOf(tokenId) == msg.sender, ErrInvalidTokenId());This hard requirement for the caller to be the exact owner blocks approved operators from depositing on the owner’s behalf, breaking consistency with the rest of the system and limiting the utility of delegation.Recommendation
Update
Gauge.solto useisApprovedOrOwnerin thedepositfunction, aligning it with the access pattern used elsewhere in the system. -
L-28 Low Duplicate import statement Pending
Description
IPurrFactory.solis imported twice.Recommendation
Remove the duplicate import.
-
L-29 Low First Epoch Emissions Over-Boosted Informational Pending
Description
In Minter.sol, the
updatePeriodfunction computes weekly emissions as follows:if (epochCount <= DECAY_EMISSION_DURATION_IN_EPOCHS) { //@audit should "_emission = _weekly;" be moved here if (epochCount <= BOOSTED_EMISSION_DURATION_IN_EPOCHS) { _weekly = (_weekly * BOOSTED_EMISSION_IN_BIPS) / BIPS; } else { _weekly = (_weekly * DECAY_EMISSION_IN_BIPS) / BIPS; } _emission = _weekly; weekly = _weekly; }This sets
_emissionafter applying the boost or decay adjustment to_weekly. In contrast, other Solidly forks typically assign_emission = _weeklybefore any adjustment. This subtle difference results in boosted emissions during the first epoch, which can increase_growthand cause emissions to compound more than expected across epochs. The result is a higher-than-intended inflation path, especially early in the distribution schedule, which may affect total supply projections and long-term tokenomics.Recommendation
Review whether the current ordering aligns with intended tokenomics. If the goal is for the first epoch to distribute unboosted emissions, consider assigning
_emission = _weeklybefore applying any boost or decay. -
L-30 Low
balanceOfAtNFT()is not fully accurate Warning PendingDescription
VotingEscrow.balanceOfAtNFT()increases theblockTimevariable with the timestamp delta between the desired block and the last recorded block. Since it works with block numbers, it calculates the average block time difference (dt / dBlock) and multiplies it by the block delta. While this approach should be close to the real voting power of the user at that block, it is not fully accurate because the time difference between the blocks may be more than 12 seconds, if a block was not proposed in a given epoch. For example, block 22523350 and block 22523349 have a gap of 24 seconds. This is a bigger problem whenbalanceOfAtNFT()enters the_epoch >= maxEpochcase because bothdtanddBlockare calculated using the current block. This means there may be difference whenbalanceOfAtNFT()is called onchain for the same block number, depending on when it is called.Recommendation
Document that:
balanceOfAtNFT(tokenID, block)is a close, but not exact value.balanceOfAtNFT(tokenID, block)is non-deterministic, i.e it may return different result even if it's called with the same parameters.
-
L-31 Low Wrong
ownerfor Minter deployment Configuration PendingDescription
The
ve.tsscript first deploys the VeSystem contract then checks if the deployer's address is the same as theOWNERconstant and if not, ownership is transferred to theOWNER. However, theMintercontract was deployed withOWNERas the owner. If the script is executed from any other deploying address besidesOWNER, the deployment will fail becauseMinter.initialize()which hasonlyOwnermodifier is called by the deployer, while the actual owner isOWNER.Recommendation
Pass the deployer as owner when deploying the minter
// ### 11. Deploy Minter const minter = await deploy("Minter", { from: deployer.address, args: [ voter.address, votingEscrow.address, veDistributor.address, - OWNER, // Owner address + deployer.address // Owner address ], log: true, waitConfirmations: 1, skipIfAlreadyDeployed: true, contract: "Minter", }); -
L-32 Low Lack of
artProxyConfiguration PendingDescription
VotingEscrow.tokenURI()calls theartProxyset by the owner to return the URI, but whenVotingEscrowis deployed there is noartProxyset. In result all calls toVotingEscrow.tokenURI()will fail until such a proxy is set.Recommendation
Consider setting an
artProxyas soon as possible after deployment. -
L-33 Low Only 2 tokens whitelisted Informational Pending
Description
Currently
ve.tswhitelists onlyWHYPEandPSPS, meaning only pools for these two tokens can be created.Recommendation
Consider if there should be more tokens whitelisted.
-
L-34 Low Incorrect Mapping and Detach Logic In Withdraw DoS Pending
Description
In
Gauge.sol, thewithdrawTokenfunction contains flawed logic that mishandlestokenIds[msg.sender]and improperly manages gauge attachment. When a user performs a partial withdrawal, the function unconditionally setstokenIds[msg.sender] = 0, even if the user still has a staked balance. This causes a revert on subsequent withdrawals due to therequire(tokenId == tokenIds[msg.sender])check. The only workaround is to call deposit again to restore the mapping, introducing friction and potential user confusion. Additionally, two more issues were identified:IVoter(voter).detachTokenFromGaugeis called during partial withdrawals. This is incorrect, as detachment should only occur when the user fully exits the gauge.- If
withdrawTokenis called withtokenId = 0, it enters theelsebranch. In this path, thetokenIdmapping is not cleared anddetachTokenFromGaugeis not called, leaving the veNFT in a stale attached state. This will later cause withdrawals in VotingEscrow to revert, since the token is still marked as attached to the gauge.
Recommendation
- Only reset
tokenIds[msg.sender]and calldetachTokenFromGaugeif the user’s balance after withdrawal is zero. - Ensure
detachTokenFromGaugeis never called on partial withdrawals. - In
withdrawToken, validate the tokenId and handle the zero case properly to guarantee correct cleanup and detachment.
-
L-35 Low Duplicate Reward Tokens Possible via Frontrun Frontrunning Pending
Description
The Gauge contract lacks a dedicated mechanism to explicitly deactivate a reward token. Instead, admins must use the
swapOutRewardfunction, which setsisReward[oldToken] = falseandisReward[newToken] = true. However, this design introduces a race condition that can be exploited by a griefers. Specifically, an attacker can frontrun the admin’sswapOutRewardtransaction and callnotifyRewardAmountwith a minimal amount (e.g., 1 wei) of thenewToken. This insertsnewTokeninto the rewards array prematurely. When the admin’s transaction executes afterward, it appendsnewTokenagain, resulting in duplicate entries in the rewards array.Recommendation
Add a dedicated function to explicitly deactivate a reward token, which ensures a clean removal from the rewards array.
-
L-36 Low Missing ERC20 Recover Functions in Contracts Informational Pending
Description
The Voter, Gauge, and Bribe contracts are designed to hold reward tokens, which users can claim through their respective claim functions. However, there is no
recoverERC20function that allows the admin to retrieve leftover tokens from these contracts. This limitation means that old or deprecated reward tokens, as well as small dust amounts that accumulate due to rounding, may become permanently stuck in the contracts.Recommendation
Add a
recoverERC20function to each of these contracts, restricted to the admin, to allow the retrieval of stranded tokens. -
L-37 Low Wrong
maxRunsusage Best Practices PendingDescription
The Gauge functions
_batchRewardPerTokenand_updateRewardPerToken()accept amaxRunsparameter which should be used to determine how much iterations should be performed at most. However, it's used incorrectly like thisuint256 _startIndex = getPriorSupplyIndex(_startTimestamp); uint256 _endIndex = Math.min(supplyNumCheckpoints - 1, maxRuns);If
maxRuns = 2, the code will loop until index 2, not 2 times. Which means that if thestartIndexis greater thanmaxRunsit won't loop at all.Recommendation
Consider either changing the name of the parameter to
maxIndexor change the logic to count the iterations. -
L-38 Low Some voter functions can be DOSed Informational Pending
Description
Creating gauges is a permissionless operation, the only two conditions that must be met are:
- The pool the gauge is associated with must be created through the factory
- The pool's tokens must be whitelisted
A Malicious user can create an infinite amount of pools by tweaking the configuration values and then use them to create a gauge for each of them in the
Votercontract. This will cause a huge growth in the size ofallGaugesand can effectively DOS all functions that rely on it -updateAll(),distroFees(),distro(),distribute()and also all functions usingVoter.length().
Recommendation
Acknowledge the risk.
-
L-39 Low
Bribe.left()is not necessary Informational PendingDescription
Currently,
Bribe.left()is used by theGaugeto send the accrued fees if they are greater than the returned value. This approach is not needed in theBribebecause rewards are added towardstokenRewardsPerEpoch, so even if the amount notified is less than that, it would be correctly assigned.Recommendation
Consider removing the
left()function from theBribeand its usage in theGauge.
No findings match.
More from Abracadabra Money
All 19 reports-
MagicGLP Upgrade
11 findings1 high 11 findings: 1 high, 10 low -
PurrSwap Aerodrome
105 findings9 high 105 findings: 9 high, 23 medium, 45 low, 28 informational -
Hyperliquid OFT Deployment
3 findings 3 findings: 1 low, 2 informational -
PurrSwap Final Review
6 findings1 high 6 findings: 1 high, 1 medium, 4 low
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
