Guardian's review of PurrSwap Aerodrome for Abracadabra Money, published August 2025. The report records 105 findings across 2 review rounds, including 9 high and 23 medium.
- Published
- Review window
- June 27 to August 6, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Hyperliquid
- Sector
- DEXs and AMMs, Governance
- 0 Critical
- 9 High
- 23 Medium
- 45 Low
- 28 Informational
Scope
-
GuardianOrg/purrswap-contracts-purrswap-aerodrome-team1
ed3147a45becf8e47187
33 files in scope · 3,195 nSLOC
| File | nSLOC | Lines |
|---|---|---|
contracts/AirdropDistributor.sol | 38 | 48 |
contracts/EpochGovernor.sol | 19 | 34 |
contracts/factories/FactoryRegistry.sol | 68 | 108 |
contracts/factories/GaugeFactory.sol | 8 | 11 |
contracts/factories/ManagedRewardsFactory.sol | 11 | 15 |
contracts/factories/VotingRewardsFactory.sol | 10 | 14 |
contracts/Forwarder.sol | 99 | 156 |
contracts/Gauge.sol | 170 | 239 |
contracts/governance/GovernorCountingMajority.sol | 54 | 89 |
contracts/governance/GovernorSimple.sol | 253 | 472 |
contracts/governance/GovernorSimpleVotes.sol | 28 | 49 |
contracts/governance/VetoGovernor.sol | 298 | 530 |
contracts/governance/VetoGovernorCountingSimple.sol | 49 | 91 |
contracts/governance/VetoGovernorVotes.sol | 28 | 51 |
contracts/governance/VetoGovernorVotesQuorumFraction.sol | 51 | 119 |
contracts/libraries/BalanceLogicLibrary.sol | 89 | 132 |
contracts/libraries/DelegationLogicLibrary.sol | 82 | 140 |
contracts/libraries/ProtocolTimeLibrary.sol | 24 | 34 |
contracts/Minter.sol | 148 | 204 |
contracts/ProtocolForwarder.sol | 5 | 8 |
contracts/ProtocolGovernor.sol | 68 | 91 |
contracts/PSPS.sol | 19 | 29 |
contracts/PurrLPStakingForPoints.sol | 33 | 43 |
contracts/rewards/BribeVotingReward.sol | 15 | 23 |
contracts/rewards/FeesVotingReward.sol | 12 | 19 |
contracts/rewards/FreeManagedReward.sol | 21 | 33 |
contracts/rewards/LockedManagedReward.sol | 19 | 32 |
contracts/rewards/ManagedReward.sol | 15 | 24 |
contracts/rewards/Reward.sol | 169 | 247 |
contracts/rewards/VotingReward.sol | 22 | 33 |
contracts/RewardsDistributor.sol | 148 | 194 |
contracts/Voter.sol | 380 | 501 |
contracts/VotingEscrow.sol | 742 | 1214 |
Findings 105
Main Review
95 findings · June 27 to July 14, 2025-
H-01 High Voter.Poke Function Can Be Blocked to Lock in Permanent Voting Power DoS Resolved
Description
The
poke(uint256 _tokenId)function in theVotercontract is intended to refresh a user’s gauge‐voting power based on their current veNFT balance, especially important once locks decay or expire. However, its internal logic can be permanently blocked, allowing a user to carry stale voting weight indefinitely:- Voting with a very high amount of tokens but for a very short lock, trying to maximize the
slopecalculation. - As time passes, weight decays, but unless the user calls
vote,reset, orpoke, the protocol continues using the old, higher weight. - Once the veNFT expires, any call to
pokewill revert here, within the internal_votecall:if (_poolWeight == 0) revert ZeroBalance(); - This way the user has gained permanent voting power as noone can actually call
poketo readjust it. Whenever the user wants to exit, he just have to callresetandwithdraw.
By exploiting this, a user can effectively lock in permanent voting power for a veNFT without ever switching to a genuine “permanent lock” (
lockPermanent). Their decayed lock still behaves as if it were permanent and there is no way to remove it.Proof Of Concept
function test_POC() public { /* current timestamp 1750896000 - 3612 = 1750892388 next epoch starts at 1750896000 uNew.slope = _newLocked.amount / iMAXTIME; uNew.bias = uNew.slope * (_newLocked.end - block.timestamp).toInt128(); */ vm.startPrank(address(contract_Minter)); contract_PSPS.mint(user1, 1000000e18); vm.stopPrank(); vm.startPrank(user1); contract_PSPS.approve(address(contract_VotingEscrow), type(uint256).max); uint256 nId1 = contract_VotingEscrow.createLock(1000000e18, 3612); _consoleLogveNFTData(nId1); address[] memory pools = new address[](1); pools[0] = address(contract_purrLP_USDT0_USDE); uint256[] memory weights = new uint256[](1); weights[0] = 10000; contract_Voter.vote(1, pools, weights); console.log("\n3612 seconds later"); vm.warp(block.timestamp + 3612); vm.roll(block.number + 1); contract_Minter.updatePeriod(); console.log("\nTWO HOUR LATER"); // 1 hour of distribute window vm.warp(block.timestamp + 2 hours); vm.roll(block.number + (2 hours / 12 seconds)); contract_Voter.reset(nId1); contract_VotingEscrow.withdraw(nId1); vm.stopPrank(); }Recommendation
Consider updating the
pokefunction to avoid reverting when a calculated gauge weight is zero, for example, by skipping such gauges rather than failing the entire transaction. - Voting with a very high amount of tokens but for a very short lock, trying to maximize the
-
H-02 High Voter.depositManaged Function Does Not Call _reset For The Normal NFT Deposited Logical Error Resolved
Description
Voter.depositManagedmigrates aNORMALveNFT into aMANAGEDveNFT but never purges the old NFT’s voting records by calling the_resetfunction. Therefore:- When the
NORMALNFT had voted earlier,weights[pool],votes[_tokenId][pool],usedWeights[_tokenId]andtotalWeightstill include its weight W. - After the lock is transferred,
_mTokenIdholds the real voting power and_pokeadds another W to the chosen pools and tototalWeight. - The system now counts 2 × W although only W tokens are locked.
Because the original NFT’s weight is never withdrawn from the bribe and fee reward contracts, that placeholder continues to accrue its historical share of external incentives. Meanwhile the managed NFT starts earning a second share for the same underlying tokens. Both bribes and protocol-collected fees can therefore be harvested twice, one claim per token ID, until manual intervention removes the stale balance.
The inconsistency remains as
pokecan not clean it up because the call reverts once the old token’s weight is zero, and aLOCKEDNFT cannot vote again to trigger a reset. Unless the owner voluntarily un-manages the NFT and calls reset, or the protocol deploys a patch, the inflated weights persist across epochs.
Proof of Concept
function test_POC() public { vm.startPrank(address(contract_Minter)); contract_PSPS.mint(user1, 1337e18); vm.stopPrank(); vm.startPrank(user1); contract_PSPS.approve(address(contract_VotingEscrow), type(uint256).max); uint256 id1 = contract_VotingEscrow.createLock(1337e18, 4 * 365 days); vm.stopPrank(); vm.prank(allowedManager); uint256 mId = contract_VotingEscrow.createManagedLockFor(user1); // 1 block later vm.warp(block.timestamp + 12); vm.roll(block.number + 1); vm.startPrank(user1); address[] memory pools = new address[](1); pools[0] = address(contract_purrLP_USDT0_USDE); uint256[] memory weights = new uint256[](1); weights[0] = 10000; console.log("contract_Voter.weights(address(cåontract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); console.log("\n FIRST VOTE WITH NORMAL NFT"); contract_Voter.vote(id1, pools, weights); console.log("contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); // We can not deposit right away so we go into next epoch vm.warp(block.timestamp + 7 days); vm.roll(block.number + (7 days / 12 seconds)); contract_Minter.updatePeriod(); // Now we can contract_Voter.depositManaged(id1, mId); console.log("\n SECOND VOTE WITH MANAGED NFT"); contract_Voter.vote(mId, pools, weights); uint256 currentTimestamp = block.timestamp; console.log("contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); vm.stopPrank(); } /* [PASS] test_POC() (gas: 5497094) Logs: _deployAll() contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 0e0 FIRST VOTE WITH NORMAL NFT contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 1.333375142694024546e21 SECOND VOTE WITH MANAGED NFT contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 2.670375142694024546e21 */Recommendation
Consider calling
_reset(_tokenId)unconditionally within thedepositManagedfunction. - When the
-
H-03 High Team Emission Calculation Error Math Resolved
Description
In the tail-emission branch of
updatePeriod, the team’s share is computed using the stale weekly value rather than the actual emission amount delivered to voters:bool _tail = _weekly < TAIL_START; if (_tail) { _emission = (_totalSupply * tailEmissionRate) / MAX_BPS; // … uint256 _teamEmissions = (_rate * (_growth + _weekly)) / (MAX_BPS - _rate); }Because
_weeklyremains at its pre-tail threshold value, the team’s percentage share will not track the true emission volume. If actual emissions exceed_weekly, the team is underpaid, if emissions fall below_weekly, the team is overpaid. This misalignment can result in the team receiving more tokens than intended, diluting ve-locked holders and inflating total supply.Recommendation
Calculate the team’s allocation based on the actual _emission value (plus growth) to guarantee the correct percentage distribution each epoch.
-
H-04 High Voter.Poke Function Can Be Blocked to Lock in Permanent Voting Power DoS Resolved
Description
The
poke(uint256 _tokenId)function in theVotercontract is intended to refresh a user’s gauge‐voting power based on their current veNFT balance, especially important once locks decay or expire. However, its internal logic can be permanently blocked, allowing a user to carry stale voting weight indefinitely:- Voting with a very high amount of tokens but for a very short lock, trying to maximize the
slopecalculation. - As time passes, weight decays, but unless the user calls
vote,reset, orpoke, the protocol continues using the old, higher weight. - Once the veNFT expires, any call to
pokewill revert here, within the internal_votecall:if (_poolWeight == 0) revert ZeroBalance(); - This way the user has gained permanent voting power as noone can actually call
poketo readjust it. Whenever the user wants to exit, he just have to callresetandwithdraw. By exploiting this, a user can effectively lock in permanent voting power for a veNFT without ever switching to a genuine “permanent lock” (lockPermanent). Their decayed lock still behaves as if it were permanent and there is no way to remove it.
Proof Of Concept
function test_POC() public { /* current timestamp 1750896000 - 3612 = 1750892388 next epoch starts at 1750896000 uNew.slope = _newLocked.amount / iMAXTIME; uNew.bias = uNew.slope * (_newLocked.end - block.timestamp).toInt128(); */ vm.startPrank(address(contract_Minter)); contract_PSPS.mint(user1, 1000000e18); vm.stopPrank(); vm.startPrank(user1); contract_PSPS.approve(address(contract_VotingEscrow), type(uint256).max); uint256 nId1 = contract_VotingEscrow.createLock(1000000e18, 3612); _consoleLogveNFTData(nId1); address[] memory pools = new address[](1); pools[0] = address(contract_purrLP_USDT0_USDE); uint256[] memory weights = new uint256[](1); weights[0] = 10000; contract_Voter.vote(1, pools, weights); console.log("\n3612 seconds later"); vm.warp(block.timestamp + 3612); vm.roll(block.number + 1); contract_Minter.updatePeriod(); console.log("\nTWO HOUR LATER"); // 1 hour of distribute window vm.warp(block.timestamp + 2 hours); vm.roll(block.number + (2 hours / 12 seconds)); contract_Voter.reset(nId1); contract_VotingEscrow.withdraw(nId1); vm.stopPrank(); }Recommendation
Consider updating the
pokefunction to avoid reverting when a calculated gauge weight is zero, for example, by skipping such gauges rather than failing the entire transaction. - Voting with a very high amount of tokens but for a very short lock, trying to maximize the
-
H-05 High Voter.depositManaged Function Does Not Call _reset For The Normal NFT Deposited Logical Error Resolved
Description
Voter.depositManagedmigrates aNORMALveNFT into aMANAGEDveNFT but never purges the old NFT’s voting records by calling the_resetfunction. Therefore:- When the
NORMALNFT had voted earlier,weights[pool],votes[_tokenId][pool],usedWeights[_tokenId]andtotalWeightstill include its weight W. - After the lock is transferred,
_mTokenIdholds the real voting power and_pokeadds another W to the chosen pools and tototalWeight. - The system now counts 2 × W although only W tokens are locked.
Because the original NFT’s weight is never withdrawn from the bribe and fee reward contracts, that placeholder continues to accrue its historical share of external incentives. Meanwhile the managed NFT starts earning a second share for the same underlying tokens. Both bribes and protocol-collected fees can therefore be harvested twice, one claim per token ID, until manual intervention removes the stale balance.
The inconsistency remains as
pokecan not clean it up because the call reverts once the old token’s weight is zero, and aLOCKEDNFT cannot vote again to trigger a reset. Unless the owner voluntarily un-manages the NFT and calls reset, or the protocol deploys a patch, the inflated weights persist across epochs.
Proof of Concept
function test_POC() public { vm.startPrank(address(contract_Minter)); contract_PSPS.mint(user1, 1337e18); vm.stopPrank(); vm.startPrank(user1); contract_PSPS.approve(address(contract_VotingEscrow), type(uint256).max); uint256 id1 = contract_VotingEscrow.createLock(1337e18, 4 * 365 days); vm.stopPrank(); vm.prank(allowedManager); uint256 mId = contract_VotingEscrow.createManagedLockFor(user1); // 1 block later vm.warp(block.timestamp + 12); vm.roll(block.number + 1); vm.startPrank(user1); address[] memory pools = new address[](1); pools[0] = address(contract_purrLP_USDT0_USDE); uint256[] memory weights = new uint256[](1); weights[0] = 10000; console.log("contract_Voter.weights(address(cåontract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); console.log("\n FIRST VOTE WITH NORMAL NFT"); contract_Voter.vote(id1, pools, weights); console.log("contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); // We can not deposit right away so we go into next epoch vm.warp(block.timestamp + 7 days); vm.roll(block.number + (7 days / 12 seconds)); contract_Minter.updatePeriod(); // Now we can contract_Voter.depositManaged(id1, mId); console.log("\n SECOND VOTE WITH MANAGED NFT"); contract_Voter.vote(mId, pools, weights); uint256 currentTimestamp = block.timestamp; console.log("contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> %e", contract_Voter.weights(address(contract_purrLP_USDT0_USDE))); vm.stopPrank(); } /* [PASS] test_POC() (gas: 5497094) Logs: _deployAll() contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 0e0 FIRST VOTE WITH NORMAL NFT contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 1.333375142694024546e21 SECOND VOTE WITH MANAGED NFT contract_Voter.weights(address(contract_purrLP_USDT0_USDE)) -> 2.670375142694024546e21 */Recommendation
Consider calling
_reset(_tokenId)unconditionally within thedepositManagedfunction. - When the
-
H-06 High Team Emission Calculation Error Math Resolved
Description
In the tail-emission branch of
updatePeriod, the team’s share is computed using the stale weekly value rather than the actual emission amount delivered to voters:bool _tail = _weekly < TAIL_START; if (_tail) { _emission = (_totalSupply * tailEmissionRate) / MAX_BPS; // … uint256 _teamEmissions = (_rate * (_growth + _weekly)) / (MAX_BPS - _rate); }Because
_weeklyremains at its pre-tail threshold value, the team’s percentage share will not track the true emission volume. If actual emissions exceed_weekly, the team is underpaid, if emissions fall below_weekly, the team is overpaid. This misalignment can result in the team receiving more tokens than intended, diluting ve-locked holders and inflating total supply.Recommendation
Calculate the team’s allocation based on the actual _emission value (plus growth) to guarantee the correct percentage distribution each epoch.
-
H-07 High Deactivated Locks Remain Functional Logical Error Resolved
Description
According to the Velodrome specification: “Managed NFTs can be deactivated, a process which prevents the NFT from voting and from receiving deposits”. There are three problems with the way deactivated locks are handled:
- the votes they already casted stay as they are and since they are not decaying, the lock continues to vote
VotingEscrow.increaseAmount()allows increasing the amount of a deactivated lock, which can be used to further increase the voting power of the NFT.- Delegations for deactivated locks are not handled correctly because they are not reset, nor the
delegate()function checks if the delegator is deactivated. This allows a deactivated lock to bypass the restriction and vote regardless of its status. In result, deactivating a managed lock is insufficient action for stopping it from voting. The only limitation on it is that it cannot change the pools it votes to - they stay the same as the ones before the deactivation. Keep in mind that deactivated locks keep receiving rewards from theRewardsDistributorcontract.
Recommendation
Consider:
- Updating
pokein such a way that if the poked token is a deactivated managed lock, onlyreset()is executed. - Adding a require statement in
VotingEscrow.increaseAmount()which reverts if the managed lock is deactivated. - Clearing the delegation once the lock is deactivated.
- Disallowing delegating from an NFT that is deactivated.
-
M-01 Medium Growth Formula Rewards Early Lockers Disproportionately Math Pending
Description
The current
Minter.calculateGrowthimplementation completely reverses Velodrome’s reward logic, giving an outsized bonus to the very first lockers:// Original Velodrome: growth scales with locked share (Ve³) return ((((_minted * _veTotal) / _veloTotal) * _veTotal) / _veloTotal) * _veTotal) / _veloTotal / 2; // Current code: growth scales with circulating share ((Vtot‑Ve)²) return (((_minted * (_aeroTotal - _veTotal)) / _aeroTotal) * (_aeroTotal - _veTotal)) / _aeroTotal / 2;Velodrome: (Figure. See the PDF report.) Current formula: (Figure. See the PDF report.)
- Launch behaviour: With
ve.totalSupplyAt(activePeriod‑1) == 0, week 0 growth equals 50 % of emissions (≈ 5 M PSPS). All of it accrues to the wallets that receive the permanent‑lock NFTs immediately after, costing them nothing. - Long‑term behaviour:
- It does not incentivize locking because growth shrinks as the locked share rises, there is little incentive for the final 20–30 % of holders to join the protocol once a majority has already locked.
- At ~57 % locked, Velodrome’s cubic rule would pay more growth than this quadratic‑circulating rule and therefore the protection for late lockers is weaker.
- Economic impact:
- Early inflation: epoch 0 mints ≈ 15.8 M tokens instead of 10 M (+ 50 %).
- Governance concentration: early lockers rapidly accumulate supply, keeping governance control.
- APR whiplash: yields start extremely high, then decay sharply as more tokens lock.
Recommendation
If the goal is Velodrome‑style “lock‑more‑earn‑more” incentives: revert to the cubic‑locked formula. If fast boot‑strapping is intentional: keep the new formula but:
- impose an upper cap on growth (e.g., ≤ X % of current ve supply) to prevent extreme early windfalls.
- publicly document and justify the alternate incentive curve,and update economic models, emissions schedules and front‑end displays to match the revised supply dynamics.
- Launch behaviour: With
-
M-02 Medium Potential Loss Of A Portion Of Managed Locked Rewards Warning Resolved
Description
When a child veNFT is withdrawn from a managed lock,
VotingEscrow.withdrawManagedharvests the child’s share of locked‑managed rewards by callinglockedManagedReward.getReward(childId, [psps])before it burns the child’s weight. TheLockedManagedRewardcontract’searned(token, childId)function, however, only credits tokens from epochs that have already completed. Any rewards accrued during the still open epoch return zero. Because thegetRewardcall also updateslastEarn[token][childId]toblock.timestamp, the residual portion of the current epoch becomes permanently unclaimable: future earned calculations start counting from the moment of withdrawal, not from the epoch boundary. In effect, callingwithdrawManagedmid‑epoch irreversibly discards that week’s in‑progress yield for the withdrawing child, even though those tokens remain inside the managed lock and continue to influence the delegatee’s voting checkpoints. To collect every token ofLockedManagedRewardcurrently, users must: - CallRewardsDistributor.claim(managedTokenId)at the last block of the previous epoch. - CallwithdrawManagedat the beginning of the new epoch. However, if the followed the expected approach of callingRewardsDistributor.claim(managedTokenId)and right away callwithdrawManagedthey would lose all the just claimed yield, as that yield would only be available on the next epoch.Recommendation
Consider explicitly documenting this behaviour so users can withdraw following an optimal approach.
-
M-03 Medium Team Emission Rate Never Initialized Configuration Resolved
Description
The deployment script
ve.tscompletes the usual Minter initialization sequence, deploys the contract, wires inVotingEscrow, Voter, and RewardsDistributor, and sets the first emission period, but it never callsMinter.setTeamRate(uint256 newRate);As a result theteamRatevariable remains set to 0. During every weekly mint the protocol allocates “team share” according to that untouched default rather than the percentage defined in the published tokenomics. If the default is zero, the team receives no emissions at all; if it is a placeholder (e.g. 30 bp used for tests) the team siphons an unintended fraction of every distribution. In either case, circulating‑supply and treasury balances will diverge from what would be expected.Recommendation
Amend the deployment script to invoke
Minter.setTeamRatewith the production‑approved basis‑point value immediately afterMinteris initialized and before the first call toupdatePeriod. -
M-04 Medium Users Can Make Their veNFT Unpokeable By Voting For A To-Be-Killed Gauge Validation Resolved
Description
Voter.pokeis the mechanism a third party can use to force a veNFT’s vote weights to refresh when its voting power has decayed.pokere‑casts the veNFT’s existing gauge list and weights by piping them through the internal_votefunction:function poke(uint256 tokenId) external { ... _vote(tokenId, usedWeight, poolVote[tokenId], weights[tokenId]); }Inside
_voteevery target gauge must still be marked alive:address _gauge = gauges[_pool]; if (_gauge == address(0)) revert GaugeDoesNotExist(_pool); if (!isAlive[_gauge]) revert GaugeNotAlive(_gauge); // <-- hard revertA malicious user can therefore make their veNFT permanently unpokeable:
- Vote for any gauge that is likely to be “killed” soon.
- Once owner or governance calls
killGauge(gauge),isAlive[gauge]flips tofalse. - Any later attempt by an external actor to call
poke(tokenId)reverts at theGaugeNotAlivecheck, because the dead gauge is still inpoolVote[tokenId]. - The original vote weights, calculated with the veNFT’s old (larger) voting power, remain locked in place. Rewards and influence that should have decayed stay inflated until the NFT owner manually calls
resetor submits a fresh vote. This way holders can freeze exaggerated weight indefinitely, taking extra bribes and emissions unfairly. Moreover, the protocol‐level enforcement of fair decay is disabled for those positions.
Recommendation
Update the
pokefunction to ignore any gauge that hasisAlive == falseinstead of forwarding the call to_voteunchanged. A straightforward fix is to filter the stored gauge array in‑memory, skipping dead gauges before invoking_vote. -
M-05 Medium PurrLPStakingForPoints Stakers Lose LP Trading Fees Unexpected Behavior Acknowledged
Description
In
PurrLPStakingForPointsthe on‑chain swap fees that “belong” to liquidity providers are permanently redirected to the protocoltreasuryinstead of being distributed to the stakers who “earned” them as they were the ones that provided the liquidity in thePurrLPpool in the first place:function claimFees() external onlyOwnerOrRoles(ROLE_REWARD_DISTRIBUTOR) { PurrLP lpToken = PurrLP(stakingToken); (uint256 claimed0, uint256 claimed1) = lpToken.claimFees(); if (claimed0 > 0) { // BASE–token fees address baseToken = lpToken._BASE_TOKEN_(); baseToken.safeTransfer(treasury, claimed0); } if (claimed1 > 0) { // QUOTE–token fees address quoteToken = lpToken._QUOTE_TOKEN_(); quoteToken.safeTransfer(treasury, claimed1); } emit FeesClaimedToTreasury(claimed0, claimed1); }lpToken.claimFeesreturns the fee amounts owed to LP holders, tracked via supplyIndex and claimable inside each PurrLP clone. Instead of keeping those tokens in the staking contract and forwarding them as rewards to the stakers, the wrapper transfers 100 % ofclaimed0andclaimed1straight totreasuryaddress. Stakers therefore surrender all swap‑fee yield and are rewarded only with the off‑chain “Points” token.Recommendation
Redesign fee harvesting so that the amounts returned by
lpToken.claimFeesare credited to stakers either by leaving them inside the staking contract for pro‑rata claims or by auto‑compounding into the pool. If the protocol needs a treasury cut, take a clearly specified percentage before distribution. -
M-06 Medium Users Can Make Their veNFT Unpokeable By Voting For A To-Be-Killed Gauge Validation Resolved
Description
Voter.pokeis the mechanism a third party can use to force a veNFT’s vote weights to refresh when its voting power has decayed.pokere‑casts the veNFT’s existing gauge list and weights by piping them through the internal_votefunction:function poke(uint256 tokenId) external { ... _vote(tokenId, usedWeight, poolVote[tokenId], weights[tokenId]); }Inside
_voteevery target gauge must still be marked alive:address _gauge = gauges[_pool]; if (_gauge == address(0)) revert GaugeDoesNotExist(_pool); if (!isAlive[_gauge]) revert GaugeNotAlive(_gauge); // <-- hard revertA malicious user can therefore make their veNFT permanently unpokeable:
- Vote for any gauge that is likely to be “killed” soon.
- Once owner or governance calls
killGauge(gauge),isAlive[gauge]flips tofalse. - Any later attempt by an external actor to call
poke(tokenId)reverts at theGaugeNotAlivecheck, because the dead gauge is still inpoolVote[tokenId]. - The original vote weights, calculated with the veNFT’s old (larger) voting power, remain locked in place. Rewards and influence that should have decayed stay inflated until the NFT owner manually calls
resetor submits a fresh vote. This way holders can freeze exaggerated weight indefinitely, taking extra bribes and emissions unfairly. Moreover, the protocol‐level enforcement of fair decay is disabled for those positions.
Recommendation
Update the
pokefunction to ignore any gauge that hasisAlive == falseinstead of forwarding the call to_voteunchanged. A straightforward fix is to filter the stored gauge array in‑memory, skipping dead gauges before invoking_vote. -
M-07 Medium Bribe Rewards Distributed In Epochs With No Depositors Are Lost Logical Error Pending
Description
Rewards that are sent to
BribeVotingRewardvaults are lost if there are no vault depositors for that epoch. Anyone can send bribe voting rewards to vaults by callingBribeVotingReward.notifyRewardAmount. This updates the mappingtokenRewardsPerEpoch[token][epochStart]which stores the token rewards per epoch with the newly added amount. Since the rewards are stored and distributed in their entirety by epochs, they will be lost and irrecoverable if there are no vault depositors for those epochs.Recommendation
Consider implementing a new function
renotifyRewardAmountallowing for undistributed rewards to be redistributed. -
M-08 Medium Potential Loss Of A Portion Of Managed Locked Rewards Warning Resolved
Description
When a child veNFT is withdrawn from a managed lock,
VotingEscrow.withdrawManagedharvests the child’s share of locked‑managed rewards by callinglockedManagedReward.getReward(childId, [psps])before it burns the child’s weight. TheLockedManagedRewardcontract’searned(token, childId)function, however, only credits tokens from epochs that have already completed. Any rewards accrued during the still open epoch return zero. Because thegetRewardcall also updateslastEarn[token][childId]toblock.timestamp, the residual portion of the current epoch becomes permanently unclaimable: future earned calculations start counting from the moment of withdrawal, not from the epoch boundary. In effect, callingwithdrawManagedmid‑epoch irreversibly discards that week’s in‑progress yield for the withdrawing child, even though those tokens remain inside the managed lock and continue to influence the delegatee’s voting checkpoints. To collect every token ofLockedManagedRewardcurrently, users must: - CallRewardsDistributor.claim(managedTokenId)at the last block of the previous epoch. - CallwithdrawManagedat the beginning of the new epoch. However, if they followed the expected approach of callingRewardsDistributor.claim(managedTokenId)and right away callwithdrawManagedthey would lose all the just claimed yield, as that yield would only be available on the next epoch.Recommendation
Consider explicitly documenting this behaviour so users can withdraw following an optimal approach.
-
M-09 Medium Calldata-Length Execution Bypass In EpochGovernor Validation Resolved
Description
The
GovernorSimple.executefunction currently authorizes execution of proposals in theDefeatedorExpiredstate, andproposeonly verifies thatbytes4(calldatas[0]) == IMinter.nudge.selectorwithout checking the full length ofcalldatas[0]. As a result, an attacker can submit a proposal whose calldata is the 4-bytenudgeselector plus any extra bytes (enough to cause thehashProposalto return a total different new hash during theproposecall). Because the EVM dispatch mechanism ignores trailing data when routing to a no-argument function, theexecutecall succeeds, even for a defeated proposal, and invokesnudgewith the extra payload. Insidenudge, a defeated result triggers a decrement oftailEmissionRate, so an attacker with minimal voting weight can force repeated downward adjustments regardless of genuine community votes. For reference:// GovernorSimple.sol require( status == ProposalState.Succeeded || status == ProposalState.Defeated || status == ProposalState.Expired, "GovernorSimple: proposal not successful" ); // … (bool success, ) = targets[0].call{ value: values[0] }(calldatas[0]); Address.verifyCallResult(success, returndata);Because
nudgedoes not revert on extra calldata, this gap lets anyone bypass the intended governance checks and manipulate protocol parameters each epoch.Recommendation
Restrict
executeto only allowProposalState.Succeeded, and inproposeenforce thatcalldatas[0]is exactly 4 bytes long by addingrequire(bytes(calldatas[0]).length == 4). This ensures that only properly formed, successful proposals can invokenudgeand prevents abuse via trailing‐data payloads. -
M-10 Medium A Portion Of LP Fees Will Be Locked After A Gauge Kill Rewards Resolved
Description
In the PurrLP‐based
Gaugecontract, the_claimFeesfunction is only invoked vianotifyRewardAmount(called by theVoter). When a gauge is “killed” in theVotercontract, nonotifyRewardAmountis run beforehand, so any fees accrued in the underlyingPurrLPcontract never flow into the gauge’s fee‐reward pool. Those unclaimed fees remain stranded in thePurrLPcontract. Additionally, killed gauges may still hold user deposits and continue accruing new fees, but because the gauge is inactive,_claimFeescannot be triggered and those ongoing fees also become permanently inaccessible.Recommendation
Before marking a gauge as killed, the Voter should invoke
_claimFeesto sweep any outstanding PurrLP fees into the gauge’s reward pool. Additionally, consider unlocking or exposing a post‐kill fee‐claim path so that any future fees accrued to a deactivated gauge can still be claimed by the protocol or stakers. -
M-11 Medium A Portion Of LP Fees Will Be Locked After A Gauge Kill Rewards Resolved
Description
In the PurrLP‐based
Gaugecontract, the_claimFeesfunction is only invoked vianotifyRewardAmount(called by theVoter). When a gauge is “killed” in theVotercontract, nonotifyRewardAmountis run beforehand, so any fees accrued in the underlyingPurrLPcontract never flow into the gauge’s fee‐reward pool. Those unclaimed fees remain stranded in thePurrLPcontract. Additionally, killed gauges may still hold user deposits and continue accruing new fees, but because the gauge is inactive,_claimFeescannot be triggered and those ongoing fees also become permanently inaccessible.Recommendation
Before marking a gauge as killed, the Voter should invoke
_claimFeesto sweep any outstanding PurrLP fees into the gauge’s reward pool. Additionally, consider unlocking or exposing a post‐kill fee‐claim path so that any future fees accrued to a deactivated gauge can still be claimed by the protocol or stakers. -
M-12 Medium Wrong DOMAIN_TYPEHASH Definition In VotingEscrow Contract Best Practices Resolved
Description
The
delegateBySigfunction implemented in theVotingEscrowcontract is intended to allow off-chain signature delegation using the EIP-712, but the contract’s hard-codedDOMAIN_TYPEHASHdoes not match the fields actually encoded in the domain separator. The code defines:bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)");a type hash for three fields (
name,chainId,verifyingContract), yet indelegateBySigit does:bytes32 domainSeparator = keccak256( abi.encode( DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this) ) );which encodes four parameters (
name,version,chainId,verifyingContract). Because the type hash and the actual ABI-encoded layout diverge, any signature produced off-chain with the correct domain (including version) will compute a different digest than the contract expects, causing every call to revert with “invalid signature.” This breaks all signature-based delegation and prevents any off-chain wallet or service from delegating veNFT votes via signature.Recommendation
According to the EIP 712, in the definition of domainSeparator,
string versionrepresents the current major version of the signing domain. Signatures from different versions should never be compatible. Consider updating theDOMAIN_TYPEHASHto include theversionfield:bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); -
M-13 Medium Signatures Can Be Replayed In Governor Contracts Signatures Resolved
Description
The
castVoteBySigandcastVoteWithReasonAndParamsBySigfunctions allow users to delegate voting via EIP-712 signatures. However, these signatures only bind theproposalId,support,reasonandparamsvalues:_hashTypedDataV4( keccak256(abi.encode(EXTENDED_BALLOT_TYPEHASH, proposalId, support, keccak256(bytes(reason)), keccak256(params)))The
tokenIdwhich identifies the specific NFT used for voting is not included in the signed message. As a result, if a user owns multiple voting NFTs, an attacker can obtain a valid signature from the user for one token, but reuse that same signature to cast votes for all other NFTs owned by the user. Additionally, as a safeguard, a nonce should be added to the signature to prevent using the signature more than once for the sametokenId.Recommendation
Include
tokenIdand anoncein the EIP-712 signing schema to ensure that the signature is bound to a specific token and usable only once. -
M-14 Medium Calldata-Length Execution Bypass In GovernorSimple Validation Resolved
Description
The
GovernorSimple.executefunction currently authorizes execution of proposals in theDefeatedorExpiredstate, andproposeonly verifies thatbytes4(calldatas[0]) == IMinter.nudge.selectorwithout checking the full length ofcalldatas[0]. As a result, an attacker can submit a proposal whose calldata is the 4-bytenudgeselector plus any extra bytes (enough to cause thehashProposalto return a total different new hash during theproposecall). Because the EVM dispatch mechanism ignores trailing data when routing to a no-argument function, theexecutecall succeeds, even for a defeated proposal, and invokesnudgewith the extra payload. Insidenudge, a defeated result triggers a decrement oftailEmissionRate, so an attacker with minimal voting weight can force repeated downward adjustments regardless of genuine community votes. For reference:// GovernorSimple.sol require( status == ProposalState.Succeeded || status == ProposalState.Defeated || status == ProposalState.Expired, "GovernorSimple: proposal not successful" ); // … (bool success, ) = targets[0].call{ value: values[0] }(calldatas[0]); Address.verifyCallResult(success, returndata);Because
nudgedoes not revert on extra calldata, this gap lets anyone bypass the intended governance checks and manipulate protocol parameters each epoch.Recommendation
Restrict
executeto only allowProposalState.Succeeded, and inproposeenforce thatcalldatas[0]is exactly 4 bytes long by addingrequire(bytes(calldatas[0]).length == 4). This ensures that only properly formed, successful proposals can invokenudgeand prevents abuse via trailing‐data payloads. -
M-15 Medium Deactivated Locks Allowed In Governance Vote Logical Error Resolved
Description
In the current implementation of ProtocolGovernor, managed veNFTs that have been deactivated are still able to create and vote on proposals. This undermines the very purpose of deactivation. For example, a managed veNFT could be deactivated through a proposal due to suspected manipulation or abuse. However, since the deactivation does not restrict governance participation, the same veNFT can immediately create a new proposal to reinstate itself. If successful, it would then regain access to vote on gauge emissions and redirect rewards, effectively reversing the governance decision.
Recommendation
Consider disallowing deactivated managed veNFTs from voting in governance. Note however that this would only check presently deactivated veNFTs and would not consider past deactivations.
-
M-16 Medium Deployment Is Missing Key Operations Configuration Resolved
Description
The deployment script
ve.tscompletes the usual Minter initialization sequence, deploys the contract, wires inVotingEscrow, Voter, and RewardsDistributor, and sets the first emission period, but it never callsMinter.setTeamRate(uint256 newRate);As a result theteamRatevariable remains set to 0. During every weekly mint the protocol allocates “team share” according to that untouched default rather than the percentage defined in the published tokenomics. If the default is zero, the team receives no emissions at all; if it is a placeholder (e.g. 30 bp used for tests) the team siphons an unintended fraction of every distribution. In either case, circulating‑supply and treasury balances will diverge from what would be expected. On the other hand, thesetArtProxyfunction is never called in theVotingEscrowcontract. TheartProxyaddress is used in thetokenURIfunction, meaning any third parties—such as marketplaces—that rely on token metadata will receive no data. Finally, the script does not deploy either theAirdropDistributororPurrLPStakingForPointscontracts.Recommendation
Amend the deployment script to invoke
Minter.setTeamRatewith the production‑approved basis‑point value immediately afterMinteris initialized and before the first call toupdatePeriod. Consider also updating the deploy script to callsetArtProxyand also deploy the missing contracts (AirdropDistributorandPurrLPStakingForPoints) if they are intended to be included in the initial setup. -
M-17 Medium PurrLPStakingForPoints Stakers Lose LP Trading Fees Unexpected Behavior Acknowledged
Description
In
PurrLPStakingForPointsthe on‑chain swap fees that “belong” to liquidity providers are permanently redirected to the protocoltreasuryinstead of being distributed to the stakers who “earned” them as they were the ones that provided the liquidity in thePurrLPpool in the first place:function claimFees() external onlyOwnerOrRoles(ROLE_REWARD_DISTRIBUTOR) { PurrLP lpToken = PurrLP(stakingToken); (uint256 claimed0, uint256 claimed1) = lpToken.claimFees(); if (claimed0 > 0) { // BASE–token fees address baseToken = lpToken._BASE_TOKEN_(); baseToken.safeTransfer(treasury, claimed0); } if (claimed1 > 0) { // QUOTE–token fees address quoteToken = lpToken._QUOTE_TOKEN_(); quoteToken.safeTransfer(treasury, claimed1); } emit FeesClaimedToTreasury(claimed0, claimed1); }lpToken.claimFeesreturns the fee amounts owed to LP holders, tracked via supplyIndex and claimable inside each PurrLP clone. Instead of keeping those tokens in the staking contract and forwarding them as rewards to the stakers, the wrapper transfers 100 % ofclaimed0andclaimed1straight totreasuryaddress. Stakers therefore surrender all swap‑fee yield and are rewarded only with the off‑chain “Points” token.Recommendation
Redesign fee harvesting so that the amounts returned by
lpToken.claimFeesare credited to stakers either by leaving them inside the staking contract for pro‑rata claims or by auto‑compounding into the pool. If the protocol needs a treasury cut, take a clearly specified percentage before distribution. -
M-18 Medium Whitelist Restriction Bypassed Via poke Access Control Resolved
Description
The
Votercontract enforces a voting window by restricting non-whitelisted tokens from casting votes in the final hour of the epoch. This is implemented in thevotefunction, which checks whether a token is whitelisted before allowing votes during the restricted period. However, this restriction is not enforced in thepokefunction. Sincepokecan update a user's voting weight and recast their votes, users can bypass the intended restriction by first voting during the allowed window, then increasing their weight viadepositFororincreaseAmount, and callingpokeduring the restricted period. This enables them to amplify their influence after the voting window has closed—without needing to be whitelisted. Additionally, users can escalate the impact of this bypass by distributing minimal different votes across multiple tokens, then selecting a specific token to boost and callpokeon during the restricted window. Usingmergeandsplit(if permitted), they can efficiently repeat this behaviour, allowing them to manipulate vote outcomes and influence their reward allocations outside the intended voting window.Recommendation
Consider enforcing the same whitelist and time-based restrictions in the poke function to maintain consistent voting rules and prevent bypassing of the intended voting window.
-
M-19 Medium Incorrect Sender Handling In reset DoS Resolved
Description
The
Votercontract inherits fromERC2771Contextto support meta-transactions, correctly using_msgSender()in place ofmsg.senderthroughout the contract. However, theresetfunction is an exception—it directly checksmsg.senderwhen verifying whether the caller is the token owner or an approved address. As a result, if the function is called via a trusted forwarder,msg.senderwill resolve to the forwarder contract rather than the actual user. This causes the ownership check to fail, even if the user is properly authorized, effectively breaking support for meta-transactions on this function.Recommendation
Update the reset function to use _msgSender() instead of msg.sender to ensure consistent support for trusted forwarders and meta-transactions across the contract.
-
M-20 Medium Large Voter Can Trigger Quorum At Last Minute Logical Error Resolved
Description
Currently, there is no minimum voting period after a quorum is reached. This allows a large voter to sway a vote by triggering quorum at the last minute, before other voters have any time to react to try and oppose the decision. If a vote causes a quorum to be reached, some minimum time till execution should be enforced to prevent this.
Recommendation
Consider adopting Velodrome's
VetoGovernorPreventLateQuorum(link)[https://github.com/velodrome-finance/contracts/blob/main/contracts/governance/VetoGovernorPreventLateQuorum.sol] which extends the voting period if a vote causes quorum to be reached. -
L-01 Low Precision Loss in Slope Calculation Math Acknowledged
Description
VotingEscrow._checkpointderives the decay rate of every lock with two integer divisions:// iMAXTIME = 126144000 uOld.slope = _oldLocked.amount / iMAXTIME; ... uNew.slope = _newLocked.amount / iMAXTIME;Because both operands are plain integers, Solidity performs a floor‑division that discards the remainder. If a user locks an amount that is not an exact multiple of
iMAXTIME, the truncated remainder is permanently lost from the slope. The resulting error propagates touX.bias = uX.slope * (lockEnd ‑ now);so the longer the lock, the larger the
biasunder‑statement. For very small locks (amount < iMAXTIME) the computed slope becomes zero, meaning the NFT carries no voting power even though it has a positive balance. The inaccuracy is systematic (always rounding down) and therefore disadvantages small participants and marginally reduces total supply which in turn slightly over‑states relative power for larger locks.Recommendation
Merely informative. A possible solution would be to store slope with full fixed‑point precision multiplying
amountby a scalar (e.g.1e18) before dividing. -
L-02 Low Lack Of Decimal‑precision Check For Lock Token In VotingEscrow Warning Acknowledged
Description
The entire voting‑power model assumes the escrowed token uses 18 decimals, yet the contract never enforces that condition. Bias and slope are computed as
slope = amount / iMAXTIME; // iMAXTIME = 4 years in seconds bias = slope * (lockEnd – now);If the underlying token were deployed with 6 decimals, a common stable‑coin format, each “whole” token represents 10¹² wei fewer units. All balances, slopes and biases become
1e12times smaller than intended. A 100 USDC lock would be treated as 100 * 10⁶, divided byiMAXTIME, and truncated to zero. Therefore, the NFT would have no voting power, would not decay and growth/reward calculations that depend on global supply would break.Recommendation
During construction assert
IERC20Metadata(_token).decimals() == 18(or another constant your maths expects). Alternatively convert all arithmetic to a fixed 1e18 internal unit by multiplying amount by 10^(18‑decimals) on deposit and dividing on withdrawal, so the contract remains agnostic to token precision. -
L-03 Low depositManaged Skips Pending Reward Claims Warning Resolved
Description
When you call
depositManaged(tokenId, mTokenId)the contract tears down the user’s original veNFT by zeroing out its lock and checkpoint, then credits that weight to the new managed NFT. However, it never claims any bribe or fee rewards that had accumulated on the original lock. The code unconditionally does:_checkpoint(tokenId, _locked[tokenId], LockedBalance(0, 0, false)); _locked[tokenId] = LockedBalance(0, 0, false);and then moves straight on to minting into the managed lock. Any unclaimed rewards in the old bribe or fee reward contracts remain tied to the now-empty
tokenIdand become effectively stranded, because a zero-balance NFT can no longer earn or claim. Users who forget to pre-claim before callingdepositManagedwill permanently forfeit those tokens.Recommendation
Modify
depositManagedso that it automatically harvests all pending rewards for the originaltokenIdbefore zeroing its lock. In practice this means invoking each associatedIReward.getReward(tokenId, tokens[])(for bothmanagedToFeesandmanagedToBribe) or using their withdrawal hooks to sweep any accrued fees, bribes, or emissions into the user’s wallet prior to moving the lock. -
L-04 Low Flash-Vote Bypass via split() Validation Resolved
Description
The
VotingEscrow.splitfunction mints two brand-new veNFTs in the same block, each inheriting voting power from the original. Because these new token IDs have never been transferred, theirownershipChangeremains zero, meaning the flash-transfer protection (which zeroes out voting power for any NFT that changed hands in the current block) does not apply. Downstream voting logic (Voter.vote→balanceOfNFTAt) uses the raw checkpoint function, so a user can:- Acquire veNFT #1.
- In the same block, call
split(1, fullAmount). - Immediately call
Voter.votewith one of the fresh IDs (#2 or #3), capturing full voting power.
Recommendation
Consider updating the
splitfunction so after minting the child NFTs, setownershipChange[newId] = block.numberfor each new token. -
L-05 Low Unclaimed Rewards Are Not Counted In getPastVotes Computation Warning Pending
Description
The current
ProtocolGovernor/GovernorSimpleVotes/VetoGovernorVotesimplementation derives voting power exclusively from the raw veNFT checkpoints returned bytoken.getPastVotes(account, tokenId, timepoint). That helper reports only the veNFT’s locked balance at the requested timestamp. It does not include any rewards that have already been accrued to the veNFT but have not yet been claimed. In this system there are three escrow types:- NORMAL: A user-owned time lock created with createLock
- MANAGED: The “vault” NFT that aggregates locks deposited by
depositManaged - LOCKED: The placeholder left behind after a normal NFT is deposited Both NORMAL and MANAGED veNFTs can earn two classes of yield:
- Locked-managed rewards (rebases and compounded yield) that automatically flow into a managed NFT’s balance over time.
- Free rewards (bribes, trading fees, external incentives) that accumulate inside a reward contract and are released only when the owner calls
getReward. In every case the reward tokens are owed to the veNFT from the moment the reward period begins, even though the owner may choose toclaimthem later. Governance power is therefore intended to reflect the total claimable balance, locked principal plus earned but unwithdrawn rewards, at the instant a proposal’s snapshot is taken. BecauseGovernorSimpleVotes/VetoGovernorVotesignore these unclaimed rewards, the snapshot under-states true economic stake for both types of voting NFTs:
- For a NORMAL veNFT, any rebases accrued via the protocol’s rewards distributor between the last claim and the proposal’s snapshot are simply invisible.
- For a MANAGED veNFT, the problem is more severe. A managed lock’s entire purpose is to compound yield and distribute it pro rata to its depositors, yet the Governor counts only the initial deposit weight, not the accumulated locked yield, unless and until someone explicitly calls
claim. The result is that managed voters wield far less influence than their actual locked value warrants, distorting voting outcomes in favour of passive lockers and against active liquidity managers. - If delegation is involved, the discrepancy widens further. The delegator’s checkpoint reflects only the moment of delegation, any subsequent yield remains invisible to the delegatee’s vote weight even though it is contractually theirs. A malicious actor can exploit this gap. By timing proposals to land immediately after a large reward epoch starts, but before voters bother to claim, an attacker can submit and pass a proposal while honest holders’ unclaimed rewards remain uncounted, thereby suppressing their effective voting power. Conversely, an attacker can accumulate yield on their own veNFT, claim it just before the snapshot, and enjoy a sudden one-sided spike in recorded votes that other participants cannot replicate without advance knowledge of the snapshot block.
Recommendation
Replicate the original Velodrome logic in the Governor’s
_getVotesfunction so that the snapshot captures the full economic weight of a veNFT at the timepoint:/** * Read the voting weight from the token's built in snapshot mechanism (see {Governor-_getVotes}). */ function _getVotes( address account, uint256 tokenId, uint256 timepoint, bytes memory /*params*/ ) internal view virtual override returns (uint256) { IVotingEscrow.EscrowType escrowType = ve.escrowType(tokenId); require(escrowType != IVotingEscrow.EscrowType.MANAGED, "Governor: managed nft cannot vote"); if (escrowType == IVotingEscrow.EscrowType.NORMAL) { return token.getPastVotes(account, tokenId, timepoint); } // only allow locked veNFT voting if underlying nft not delegating at timepoint uint256 mTokenId = ve.idToManaged(tokenId); uint48 index = ve.getPastCheckpointIndex(mTokenId, timepoint); uint256 delegatee = ve.checkpoints(mTokenId, index).delegatee; if (delegatee == 0) { // if mveNFT not delegating, voting balance = delegated balance + // initial contribution to mveNFT + accrued locked rewards uint256 delegatedBalance = token.getPastVotes(account, tokenId, timepoint); uint256 weight = ve.weights(tokenId, mTokenId); // initial deposit weight uint256 _earned = ve.earned(mTokenId, tokenId, timepoint); // accrued rewards return weight + _earned + delegatedBalance; } // nft locked and underlying nft delegating // balance will only be delegated balance return token.getPastVotes(account, tokenId, timepoint); } // ----- DelegationHelperLibrary.sol ------------------------------------------------------- // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.19; import {IVotingEscrow} from "../interfaces/IVotingEscrow.sol"; import {IReward} from "contracts/interfaces/IReward.sol"; import {VelodromeTimeLibrary} from "contracts/libraries/VelodromeTimeLibrary.sol"; import {SafeCastLibrary} from "./SafeCastLibrary.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; library DelegationHelperLibrary { uint256 public constant DURATION = 7 days; /// Helper function to fetch the checkpoint for the last voting checkpoint prior to a timepoint /// Adapted from DelegationLogicLibrary.sol:getPastVotesIndex(uint256 tokenId, uint256 timestamp) function getPastCheckpointIndex( IVotingEscrow ve, uint256 mTokenId, uint256 timepoint ) internal view returns (uint48) { uint48 nCheckpoints = ve.numCheckpoints(mTokenId); if (nCheckpoints == 0) return 0; // First check most recent balance if (ve.checkpoints(mTokenId, nCheckpoints - 1).fromTimestamp <= timepoint) return (nCheckpoints - 1); // Next check implicit zero balance if (ve.checkpoints(mTokenId, 0).fromTimestamp > timepoint) return 0; uint48 lower = 0; uint48 upper = nCheckpoints - 1; IVotingEscrow.Checkpoint memory cp; while (upper > lower) { uint48 center = upper - (upper - lower) / 2; // ceil, avoiding overflow cp = ve.checkpoints(mTokenId, center); if (cp.fromTimestamp == timepoint) { return center; } else if (cp.fromTimestamp < timepoint) { lower = center; } else { upper = center - 1; } } return lower; } /// Helper function to calculate the locked balance owed to a locked nft at a certain timepoint /// It calculates the rewards until the end of the current epoch. As rewards are lagged by one epoch /// This means it includes rewards that the user is projected to get in the following epoch. /// These rewards are not immediately claimable, but the user can vote as if they claimed it. /// Adapted from Reward.sol:earned(address token, uint256 tokenId) function earned( IVotingEscrow ve, uint256 mTokenId, uint256 tokenId, uint256 timepoint ) internal view returns (uint256) { IReward lmr = IReward(ve.managedToLocked(mTokenId)); if (lmr.numCheckpoints(tokenId) == 0) { return 0; } address _rewardToken = ve.token(); uint256 reward = 0; uint256 _supply = 1; uint256 _currTs = VelodromeTimeLibrary.epochStart(lmr.lastEarn(_rewardToken, tokenId)); // take epoch last claimed in as starting point uint256 _index = lmr.getPriorBalanceIndex(tokenId, _currTs); (uint256 _cpTs, uint256 _cpBalanceOf) = lmr.checkpoints(tokenId, _index); // accounts for case where lastEarn is before first checkpoint _currTs = Math.max(_currTs, VelodromeTimeLibrary.epochStart(_cpTs)); // get epochs between end of the current epoch and first checkpoint in same epoch as last claim uint256 numEpochs = (VelodromeTimeLibrary.epochNext(timepoint) - _currTs) / DURATION; uint256 _priorSupply; if (numEpochs > 0) { for (uint256 i = 0; i < numEpochs; i++) { // get index of last checkpoint in this epoch _index = lmr.getPriorBalanceIndex(tokenId, _currTs + DURATION - 1); // get checkpoint in this epoch (_cpTs, _cpBalanceOf) = lmr.checkpoints(tokenId, _index); // get supply of last checkpoint in this epoch (, _priorSupply) = lmr.supplyCheckpoints(lmr.getPriorSupplyIndex(_currTs + DURATION - 1)); _supply = Math.max(_priorSupply, 1); reward += (_cpBalanceOf * lmr.tokenRewardsPerEpoch(_rewardToken, _currTs)) / _supply; _currTs += DURATION; } } return reward; } } -
L-06 Low Redundant Queued State Execution Gate Validation Resolved
Description
The
VetoGovernor.executefunction diverges from the Velodrome implementation by accepting bothSucceededandQueuedproposal states:require( status == ProposalState.Succeeded || status == ProposalState.Queued, "Governor: proposal not successful" );Yet the
statefunction used currently used is the un‑modified OpenZeppelin logic that never returnsProposalState.Queued; it can only evaluate toPending,Active,Succeeded,Defeated,Executed,CanceledorVetoed.if (_quorumReached(proposalId) && _voteSucceeded(proposalId)) { return ProposalState.Succeeded; } else { return ProposalState.Defeated; }Because
Queuedis unreachable, the extra branch inexecutecan be considered dead code.Recommendation
Consider removing the
ProposalState.Queuedcheck from theVetoGovernor.executefunction. -
L-07 Low withdrawManaged Skips Pending Reward Claims Warning Resolved
Description
RewardsDistributor.claim(mTokenId)is the only path that moves weekly rebases from the global distributor into a managed NFT’sLockedManagedRewardcontract. If a child veNFT is withdrawn before the parent managed lock has executedclaimfor the most recently finished epoch, that child’s share of those yet‑to‑be‑forwarded tokens is silently lost. Execution flow:- A rebase accrues;
RewardsDistributor.claimable(mTokenId)is non‑zero but no claim call is made. - The owner invokes
Voter.withdrawManaged(childId)which call to:lockedManagedReward.earned(childId)returnszerobecause the rebase has not been forwarded.- The child’s weight is burned and permanently removed from future accounting.
- Later, when
RewardsDistributor.claim(mTokenId)is finally called, the rebase arrives inLockedManagedReward, but the withdrawn child no longer participates in the weight distribution, so its rightful portion is redistributed to the remaining depositors. Therefore, any depositor who exits a managed lock without first triggering a parent‑levelRewardsDistributor.claimforfeits its pending epoch rewards forever.
Recommendation
Require withdrawManaged to proceed only when
RewardsDistributor.claimable(mTokenId) == 0, i.e. the manager has already forwarded the latest rebase. - A rebase accrues;
-
L-08 Low Managed NFTs Can Vote More Than Once Per Epoch Warning Resolved
Description
The owner of the managed NFT could break the invariant that an NFT can only vote once per epoch. Assume Bob owns the following 2 managed NFTs:
- Managed veNFT (called mNFTa) with 1 locked NFT (called lNFTa).
- Managed veNFT (called mNFTb) with 1 locked NFT (called lNFTb).
- The balance of lNFTa and lNFTb is the same.
Bob voted on
poolXwith mNFTa and mNFTb on the first hour of the epoch. At the last two hours of the voting windows of the current epoch, Bob changed his mind and decided to vote on thepoolY. Under normal circumstances, theonlyNewEpochmodifier will prevent mNFTa and mNFTb from triggering theVoter.votefunction because these two veNFTs have already voted in the current epoch and theirlastVotedis set to a timestamp within the current epoch. However, it is possible for Bob to bypass this control. Bob could callVoter.withdrawManagedfunction to withdraw lNFTa and lNFTb from mNFTa and mNFTb respectively. Since the weight becomes zero, thelastVotedfor both mNFTa and mNFTb will be cleared. As a result, they will be allowed to re-vote in the current epoch. Bob will callVoter.depositManagedto deposit lNFTb into mNFTa and lNFTa into mNFTb respectively to increase the weight of the managed NFTs. Bob then callsVoter.votewith mNFTa and mNFTb to vote onpoolY. Since thelastVotedis empty (cleared earlier), theonlyNewEpochmodifier will not revert the transaction.Recommendation
To prevent any managed NFT owner from abusing this issue, consider disallowing re-voting in the current epoch once the last NFT has been withdrawn from a managed NFT.
-
L-09 Low Bribe And Fee Token Emissions Can Be Gamed By Users Warning Pending
Description
A user may vote or reset their vote once per epoch. Votes persist across epochs and once a user has distributed their votes among their chosen pools, the
pokefunction may be called by any user to update the target user's decayed veNFT token balance. However, thepokefunction is not hooked into any of the reward distribution contracts. As a result, a user is incentivized to vote as soon as they create their lock and avoid re-voting in subsequent epochs. The amount deposited viaReward._depositdoes not decay linearly as how it is defined under veToken mechanics. Therefore, users could continue to earn trading fees and bribes even after their lock has expired. Simultaneously, users canpokeother users to lower their voting weight and maximize their own earnings.Recommendation
Re-designing the
FeesVotingRewardandBribeVotingRewardscontracts may be worth while decaying user deposits automatically. -
L-10 Low Wrong DOMAIN_TYPEHASH Definition In VotingEscrow Contract Best Practices Resolved
Description
The
delegateBySigfunction implemented in theVotingEscrowcontract is intended to allow off-chain signature delegation using the EIP-712, but the contract’s hard-codedDOMAIN_TYPEHASHdoes not match the fields actually encoded in the domain separator. The code defines:bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)");a type hash for three fields (
name,chainId,verifyingContract), yet indelegateBySigit does:bytes32 domainSeparator = keccak256( abi.encode( DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this) ) );which encodes four parameters (
name,version,chainId,verifyingContract). Because the type hash and the actual ABI-encoded layout diverge, any signature produced off-chain with the correct domain (including version) will compute a different digest than the contract expects, causing every call to revert with “invalid signature.” This breaks all signature-based delegation and prevents any off-chain wallet or service from delegating veNFT votes via signature.Recommendation
According to the EIP 712, in the definition of domainSeparator,
string versionrepresents the current major version of the signing domain. Signatures from different versions should never be compatible. Consider updating theDOMAIN_TYPEHASHto include theversionfield:bytes32 public constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); -
L-11 Low Forwarder EIP-7702 Compatability Warning Resolved
Description
The
Forwardercontract refunds any excess ETH to the signer of the transaction using atransfer()call. It assumes that's safe because if the account signed the transaction, it should be an EOA. However, after EIP-7702 was implemented, EOAs can have code as well. This means EOAs with code must have receive functions which logic execution cost doesn't surpass 2300 gas to safely use the Forwarder. This can also be used for griefing in the following way:- an EOA without receive functions uses the forwarder
- they send some value, but use a non refunding feature so they don't expect refund
- a malicious user frontruns their transaction by sending 1 wei to the
Forwarder, triggers the refund and the transaction reverts.
Recommendation
Warn the users about this limitation. You can also consider using
call()instead oftransfer() -
L-12 Low Killed Gauge May Accrue Rewards Rewards Resolved
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 reward is sent to the minter instead. 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 where the gauge is eligible for rewards. It's even possible that it hasn't been updated for the whole duration from killing to reviving. In result, the killed gauge will receive rewards.Recommendation
Update the gauge before reviving.
function reviveGauge(address _gauge) external { + _updateFor(_gauge); if (_msgSender() != emergencyCouncil) revert NotEmergencyCouncil(); if (isAlive[_gauge]) revert GaugeAlreadyRevived(); isAlive[_gauge] = true; emit GaugeRevived(_gauge); } -
L-13 Low
maxVotingNumcan be bypassed Validation ResolvedDescription
The
maxVotingNumin theVotercontract can be bypassed by callingpoke(). For example:maxVotingNum = 60- NFT 1 votes for 60 pools
- the governor sets
maxVotingNum = 20 - The owner of NFT 1 can now just keep increasing the lock time or the amount of their lock, call
poke()and vote again for all of the 60 pools.
Recommendation
Consider allowing anyone to call
reset()ifpoolVote[_tokenId].length > maxVotingNum. This will force the ve holder to vote again with the new restriction. -
L-14 Low Gauge Rewards Are Lost When
totalWeight == 0Rewards ResolvedDescription
Voter.notifyRewardAmount()accepts weekly rewards from the minter even iftotalWeight == 0. This will update the global index and in result all of the rewards will remain stuck in the contract because once a pool is voted for, its index will be set to the global index which has already been updated.Recommendation
Consider sending the rewards from the Minter only if there are votes for at least one pool:
+ if (voter.totalWeight() > 0) address(psps).safeApprove(address(voter), _emission); voter.notifyRewardAmount(_emission); + } -
L-15 Low AirdropDistributor’s safeTransferFrom Enables DOS DoS Resolved
Description
The
AirdropDistributor.distributeTokensloop usesve.safeTransferFrom(address(this), _wallet, _tokenId)for every recipient in_wallets. If any_walletis a contract that does not implementonERC721Received, that singlesafeTransferFromcall will revert and bubble up, aborting the entire distribution and leaving all remaining recipients unpaid.Recommendation
Switch from
safeTransferFromto the standardtransferFromfor airdrops, so that sending into non-compliant contracts does not revert. This change will allow the distributor to continue looping past any address that cannot accept ERC-721 callbacks, ensuring no single bad recipient can halt the entire batch. -
L-16 Low Missing Require Check In EpochGovernor.propose Function Validation Resolved
Description
The
proposefunction inGovernorSimpledoes not verify that thevaluesarray contains exactly one element set to zero. It only checkstargets.length == 1andcalldatas.length == 1, but omits any assertion on values. As a result, a caller can submit a proposal withvalues = [1](or any non-zero value), generating a distinct proposalId. That malformed proposal will always revert duringexecute, because it attempts to send ETH to the non-payablenudgefunction, but it does not block or interfere with properly formed proposals that usevalues = [0]. The missing check simply allows junk proposals that consume gas and clutter the proposal namespace.Recommendation
Validate in
proposethatvalues.length == 1and thatvalues[0]equals zero. Reject any proposal specifying a non‐zero ETH transfer, ensuringexecutecan always successfully invokenudgeand allow the emission rate to update as intended. -
L-17 Low Wrong Assumption In
Vote.withdrawManaged()Informational ResolvedDescription
Vote.withdrawManaged()assumes that if the last locked token in a managed lock has been withdrawn, the managed lock has 0 weight.// If the NORMAL veNFT was the last tokenId locked into _mTokenId, reset vote as there is // no longer voting power available to the _mTokenId. Otherwise, updating voting power to accurately // reflect the withdrawn voting power. uint256 _weight = IVotingEscrow(ve).balanceOfNFTAt(_mTokenId, block.timestamp); if (_weight == 0) { _reset(_mTokenId); // clear out lastVoted to allow re-voting in the current epoch delete lastVoted[_mTokenId]; } else { _poke(_mTokenId, _weight); }This won't be true most of the times because
VotingEscrow.withdrawManageddecreases the amount of the managed lock by_weight + _reward. Because of the way rewards are distributed, users that withdraw will almost always leave unclaimed rewards. In addition to that, there will be a precision loss happening. As a result, when the lock is withdrawn from the managed lock, the total deducted rewards won't be equal to the total deposited rewards and the managed lock'slastVotedwon't be reset. It’s also worth noting that this dust amount will compound over time and result in a largertotalSupplybeing reported, making it harder to reachquorumandproposalThresholdinVetoGovernorRecommendation
If reseting the
lastVotemapping is something than really needs to happen, you can track the number of locks deposited in the managed locks and reset it when they become 0. -
L-18 Low No Minimum Duration For Locks MEV Resolved
Description
VotingEscrowdoesn't have a minimum duration restriction for creating locks. This means users can sandwichMinter.updatePeriod()calls:- Create a short lock with big amount in the previous block
- Receive rewards
- Withdraw the lock
Since
Minter.updatePeriod()is permissionless, step 2 and 3 can be batched together in the same transaction. As a result, the user gets shares of the reward emissions with little to no commitment towards the system.
Recommendation
Consider adding a minimum duration for locks of for example 1 hour.
-
L-19 Low Unclaimed Rewards Become Inaccessible DoS Resolved
Description
The getReward function in the FreeManagedReward and VotingReward contracts allow users to claim rewards for a specific token, similar to how the claim function in the RewardsDistributor contract operates. However, a potential issue arises when a user withdraws, merges or splits their token before claiming their accumulated rewards. In such cases, the rewards associated with the token become inaccessible, effectively leaving them stuck in the contract with no way for the user to retrieve them.
Recommendation
Clearly document this to ensure users are aware that they must claim all pending rewards before withdrawing, merging, or splitting their token. Consider implementing safeguards or warnings in the UI to help prevent accidental loss of claimable rewards.
-
L-20 Low killGauge Forwards Rewards to Minter Rewards Resolved
Description
The
killGaugefunction allows theemergencyCouncilto disable a gauge. However, it does not callupdateForon the gauge before killing it, meaning any pending rewards accrued by the gauge remain unaccounted for in theclaimablemapping. Additionally, the function sends any rewards already recorded inclaimabledirectly to theminteraddress instead of the gauge. As a result, rewards that should have been distributed to users of the gauge are instead redirected to the minter address.Recommendation
Consider calling
_distributeon the gauge before modifying its state, to ensure pending rewards are correctly distributed to the gauge rather than redirected to the minter. If any rewards remain after the call, send the leftovers to the minter address. -
L-21 Low Missing Require Check In EpochGovernor.propose Function Validation Resolved
Description
The
proposefunction inGovernorSimpledoes not verify that thevaluesarray contains exactly one element set to zero. It only checkstargets.length == 1andcalldatas.length == 1, but omits any assertion on values. As a result, a caller can submit a proposal withvalues = [1](or any non-zero value), generating a distinctproposalId. That malformed proposal will always revert duringexecute, because it attempts to send ETH to the non-payablenudgefunction, but it does not block or interfere with properly formed proposals that usevalues = [0]. The missing check simply allows junk proposals that consume gas and clutter the proposal namespace.Recommendation
Validate in
proposethatvalues.length == 1and thatvalues[0]equals zero. Reject any proposal specifying a non‐zero ETH transfer, ensuringexecutecan always successfully invokenudgeand allow the emission rate to update as intended. -
L-22 Low Team Controls Proposal Threshold Without Governance Warning Pending
Description
In
ProtocolGovernor, thesetProposalNumeratorfunction allows only theteamaddress (queried fromIVotingEscrow(ve).team()) to update theproposalNumeratorvalue, which directly sets theproposalThresholdas a percentage of total supply (proposalNumerator / PROPOSAL_DENOMINATOR, starting at 0.02% and capped at 5%). This bypasses the governance process entirely, as proposals and votes from token holders cannot influence or vote on changes to this key parameter. Relevant code snippet:function setProposalNumerator(uint256 numerator) external { if (msg.sender != IVotingEscrow(ve).team()) revert NotTeam(); if (numerator > MAX_PROPOSAL_NUMERATOR) revert ProposalNumeratorTooHigh(); proposalNumerator = numerator; }Recommendation
Route
setProposalNumeratorthrough governance proposals, replacing the team check withonlyGovernancemodifier to ensure changes require voter approval. -
L-23 Low Incorrect
priorResults In Reward Contract Unexpected Behavior ResolvedDescription
Reward.solrecords balance and supply checkpoints for each epoch. It also exposes two view functions -getPriorBalanceIndex()andgetPriorSupplyIndex()- which return the index of the last checkpoint before or at a given timestamp. This result will be inaccurate because a new checkpoint is created for a each epoch, but not each timestamp. For example:- An epoch is 7 days long
- Alice deposits 300 on day 3. A checkpoint for epoch 1 is created with (
balance = 300; timestamp = day 3). - Alice deposits 500 on day 8. A checkpoint for epoch 2 is created with (
balance = 800; timestamp = day 8). - Alice deposits 1000 more on day 10. The same checkpoint for epoch 2 is updated with (
balance = 1800; timestamp = day 10) - Now if we call
getPriorBalanceIndex()fortimestamp = day 9, the function will return index 0, where the balance of Alice is300, even though it was800in reality.
Recommendation
Currently, these functions are called with
_currTs + DURATION - 1, where_currTsis a week cursor value. This is safe because we are looking at the last checkpoint in the given epoch. Any further checkpoints will create a new record since we would be in a different epoch. However, you should be really careful with the usage of these functions in the future. -
L-24 Low Potential OOG Revert For
withdrawManagedGas Optimization AcknowledgedDescription
When users call
withdrawManaged(), which will invokelockedRewardManaged.earned()twice and will loop through all the unclaimed epochs for the user to calculate the earned amount twice. This creates a dangerous possibility of the transaction going over the block gas limit and as result stuck lock for the user.Recommendation
Consider informing users that they should regularly redeposit their locks.
-
L-25 Low Some Proposals Cannot Be Executed Unexpected Behavior Resolved
Description
Any proposals created in the interval
[weekCursor - votingDelay - 1; weekCursor), whereweekCursoris the timestamp at which a new epoch begins, cannot be executed. Currently, that value is901. The reason is that for a proposal to be executed, at leastvotingDelay + votingPeriodhave to pass. SincevotingPeriodis 1 week, such proposals will become executable at least1 week + 15 minutes + 1seconds later. For example:- We create a proposal 901 seconds before week 0 ends. Its
proposalIdis calculated withepochStart = 1. - After 900 seconds pass, users can vote for it
- After
1 week + 1more seconds pass, the proposal becomes executable. However, we are now at the beginning of week 2 GovernanceSimple.execute()will use week 2 when calculating theproposalId. As a result, our old proposal becomes inaccessible
Recommendation
You can either:
- document this behavior so users know these proposals always fail
- revert the call to
propose()if we are in the failing interval
- We create a proposal 901 seconds before week 0 ends. Its
-
L-26 Low Flash-Vote Bypass via split() and merge() Validation Resolved
Description
The
VotingEscrow.splitfunction mints two brand-new veNFTs in the same block, each inheriting voting power from the original. Because these new token IDs have never been transferred, theirownershipChangeremains zero, meaning the flash-transfer protection (which zeroes out voting power for any NFT that changed hands in the current block) does not apply. Downstream voting logic (Voter.vote→balanceOfNFTAt) uses the raw checkpoint function, so a user can:- Acquire veNFT #1.
- In the same block, call
split(1, fullAmount). - Immediately call
Voter.votewith one of the fresh IDs (#2 or #3), capturing full voting power. This protection can also 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
Consider updating the
splitandmergefunctions to setownershipChange[newId] = block.numberfor each token created. -
L-27 Low Unnecessary Check In
GovernanceSimpleGas Optimization ResolvedDescription
GovernanceSimple.propose()has two require statements about thetargetsarray - the first one requires the length to be exactly 1 and the second one requires it to not be 0. The second one is unnecessary, since if the length was 0, the first one would have caught it.Recommendation
Remove the second
require()statementrequire(targets.length == 1, "GovernorSimple: only one target allowed"); require(address(targets[0]) == minter, "GovernorSimple: only minter allowed"); require(calldatas.length == 1, "GovernorSimple: only one calldata allowed"); require(bytes4(calldatas[0]) == IMinter.nudge.selector, "GovernorSimple: only nudge allowed"); bytes32 epochStart = bytes32(ProtocolTimeLibrary.epochStart(block.timestamp) + (1 weeks)); uint256 proposalId = hashProposal(targets, values, calldatas, epochStart); - require(targets.length > 0, "GovernorSimple: empty proposal"); -
L-28 Low No Flash-Loan Protection In balanceOfNFTAt Logical Error Resolved
Description
In the
VotingEscrowcontract, thebalanceOfNFTfunction 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, thebalanceOfAtNFTfunction that computes the NFT’s voting power does 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
balanceOfAtNFTfunction. -
L-29 Low Growth Formula Rewards Early Lockers Disproportionately Math Pending
Description
The current
Minter.calculateGrowthimplementation completely reverses Velodrome’s reward logic, giving an outsized bonus to the very first lockers:// Original Velodrome: growth scales with locked share (Ve³) return ((((_minted * _veTotal) / _veloTotal) * _veTotal) / _veloTotal) * _veTotal) / _veloTotal / 2; // Current code: growth scales with circulating share ((Vtot‑Ve)²) return (((_minted * (_aeroTotal - _veTotal)) / _aeroTotal) * (_aeroTotal - _veTotal)) / _aeroTotal / 2;Velodrome: (Figure. See the PDF report.) Current formula: (Figure. See the PDF report.)
- Launch behaviour: With
ve.totalSupplyAt(activePeriod‑1) == 0, week 0 growth equals 50 % of emissions (≈ 5 M PSPS). All of it accrues to the wallets that receive the permanent‑lock NFTs immediately after, costing them nothing. - Long‑term behaviour:
- It does not incentivize locking because growth shrinks as the locked share rises, there is little incentive for the final 20–30 % of holders to join the protocol once a majority has already locked.
- At ~57 % locked, Velodrome’s cubic rule would pay more growth than this quadratic‑circulating rule and therefore the protection for late lockers is weaker.
- Economic impact:
- Early inflation: epoch 0 mints ≈ 15.8 M tokens instead of 10 M (+ 50 %).
- Governance concentration: early lockers rapidly accumulate supply, keeping governance control.
- APR whiplash: yields start extremely high, then decay sharply as more tokens lock.
Recommendation
If the goal is Velodrome‑style “lock‑more‑earn‑more” incentives: revert to the cubic‑locked formula. If fast boot‑strapping is intentional: keep the new formula but:
- impose an upper cap on growth (e.g., ≤ X % of current ve supply) to prevent extreme early windfalls.
- publicly document and justify the alternate incentive curve,and update economic models, emissions schedules and front‑end displays to match the revised supply dynamics.
- Launch behaviour: With
-
L-30 Low Low-Decimal Tokens Can Be Griefed Rewards Acknowledged
Description
The
updateRewardfunction in theMultiRewardscontract updates bothrewardPerTokenStoredandlastUpdateTime, which are used to calculate rewards for stakers. TherewardPerTokenStoredvalue is computed using the formula:(timeElapsed * rewardRate * 1e18) / totalSupply. A precision loss edge case arises when(timeElapsed * rewardRate * 1e18)is less thantotalSupply. In such cases, the division rounds down to zero, leavingrewardPerTokenStoredunchanged. However,lastUpdateTimeis still updated, meaning any reward for that duration is effectively lost. This results in zero yield for that period, despite rewards having accrued. Because theupdateRewardfunction can be triggered permissionlessly—for example, via thegetRewardsfunction—an attacker can repeatedly invoke it to continually suppress rewards for a given_rewardsToken.Recommendation
Consider modifying the reward update logic to use a higher scale for low-decimal tokens to minimize precision loss and reduce the potential for griefing attacks.
-
L-31 Low Stale Proposals Can Be Revived Indefinitely Logical Error Resolved
Description
The
ProtocolGovernorcontract allows users to propose and vote on changes to core protocol parameters. If a proposal fails—either due to insufficient quorum or more "against" than "for" votes—it remains indefinitely active, with no permissionless way to finalize or expire it. This means failed proposals can be revived at any time simply by adding more votes, regardless of how much time has passed or how governance sentiment may have shifted. While avetofunction exists to manually finalize proposals, it relies on an active vetoer, which can be renounced—leaving no mechanism to clear out stale proposals.Recommendation
Consider adding an expiry or permissionless finalization or expiry mechanism for failed proposals to prevent indefinite revival.
-
L-32 Low Bribe Rewards Distributed In Epochs With No Depositors Are Lost Logical Error Pending
Description
Rewards that are sent to
BribeVotingRewardvaults are lost if there are no vault depositors for that epoch. Anyone can send bribe voting rewards to vaults by callingBribeVotingReward.notifyRewardAmount. This updates the mappingtokenRewardsPerEpoch[token][epochStart]which stores the token rewards per epoch with the newly added amount. Since the rewards are stored and distributed in their entirety by epochs, they will be lost and irrecoverable if there are no vault depositors for those epochs.Recommendation
Consider implementing a new function
renotifyRewardAmountallowing for undistributed rewards to be redistributed. -
L-33 Low Precision Loss Causes Irrecoverable Rewards Rewards Acknowledged
Description
The
_notifyRewardAmountfunction in theGaugecontract processes incoming reward amounts by updating therewardRatefor the given reward token. However, the calculationrewardRate = amount / timeUntilNextintroduces precision loss, leaving a residual amount equal toamount % timeUntilNext. While the entirerewardamount is transferred to the contract, a leftover remainder becomes permanently stuck. Over time, these unallocated "dust" amounts accumulate in the contract with no way to recover or redistribute them. This issue can also affect other contracts with similar reward logic, such as BribeVotingReward and FreeManagedReward.Recommendation
Implement a recover function, callable by an admin, to allow manual recovery of accumulated dust.
-
L-34 Low Valid Proposals Do Not Have Deadlines Unexpected Behavior Resolved
Description
Once a proposal passes in the
VetoGovernor, it becomes eligible for execution. If the transaction fails, it can be retried repeatedly until it eventually succeeds. This behavior can lead to unexpected situations—for example, a function call that initially reverted might be retried and executed much later, even after governance has chosen a different course of action. The presence of therelay()function increases the likelihood of such scenarios. While proposals can currently be vetoed to prevent this, oncerenounceVetoer()is called, there will be no mechanism left to block their execution.Recommendation
Consider adding a period of time after which the successful proposal expires.
-
L-35 Low Incorrect Logic In Voter/Distributor Check Logical Error Resolved
Description
The deployment script contains a logical error in the condition checking whether the voter and distributor addresses need to be updated in the
VotingEscrowcontract.if ( (await votingEscrowContract.voter()) !== voter.address && (await votingEscrowContract.distributor()) !== rewardsDistributor.address ) { await (await votingEscrowContract.setVoterAndDistributor(voter.address, rewardsDistributor.address)).wait(); } else { console.log("Voter and RewardsDistributor already set"); }The condition uses && (AND) instead of || (OR), which means the setVoterAndDistributor function will only be called if both addresses are different from the current values. This is problematic because:
- If only one address needs updating, the function won't be called. The contract may end up with mixed old/new addresses
- With
skipIfAlreadyDeployed: true, subsequent deployments may skip necessary updates
Recommendation
Change the condition to use || (OR) to ensure the function is called if either address needs updating:
-
L-36 Low Expired Token Can Be Merged to Extend Lock Logical Error Resolved
Description
The
mergefunction allows two token positions to be combined into one by summing their amounts, using the later of the two lock end times, and burning one of the token IDs. However, the function does not check whether the from token has already expired. This creates a bypass to theincreaseUnlockTimefunction, which explicitly prevents extending the lock on an expired token by requiring that the current lock end is greater thanblock.timestamp. By merging an expired token into an active one, the expired token’s lock can effectively be extended.Recommendation
Add a check in the
mergefunction to ensure that thefromtoken is not expired. -
L-37 Low EIP compliance issues Unexpected Behavior Resolved
Description
EIP721 compliance: 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. Users should not be able to transfer tokens toaddress(0)because that's howVotingEscrowdetermines if a token exists or not. Potential issues from that are:tokenURI()will revert because the new owner of the token isaddress(0)delegate()will also revert if an external integrator tries delegating to an NFT that has been transferred toaddress(0)EIP165 compliance:- Both
GovernorSimpleandVetoGovernorare able to receiveERC721andERC1155tokens. However, theirsupportsInterface()function includes onlyERC1155and notERC721. When external integrators check if the Governor contract is eligible to receive NFTs, they will getfalse. This result can lead to many unexpected outcomes, like the Governor not receiving NFTs that it should, reverting transactions and others depending on how the given system and the contract interact with each other. - The governance contracts also implement the EIP6372 clock, but
supportsInterfacedoesn't include that either.
Recommendation
Fix the issues mentioned in the EIP721 compliance section. For the EIP165 section, add the
IERC721Receiver.interfaceIdandIERC6372.interfaceIdtowards the supported ids. -
L-38 Low Poke Can Be Blocked By Transfer DoS Pending
Description
The
pokefunction in theVotercontract updates a user’s voting power by resetting their old votes and applying new ones based on their current locked balance. It fetches this updated weight usingbalanceOfNFTfrom theVotingEscrowcontract. ThebalanceOfNFTfunction includes a flash-loan protection mechanism that returns zero if the token’s ownership changed in the current block (ownershipChange[_tokenId] == block.number). While this check serves its intended purpose, it can be exploited: a user can front-run apokecall by transferring their token in the same block, causingbalanceOfNFTto return zero. As a result, the internal_votecall inpokereverts due to the zero-weight. This effectively allows users to block thepokefunction, preserving outdated vote weights and continuing to receive rewards and influence beyond what their actual balance should allow.Recommendation
Consider implementing safeguards to prevent transfers from interfering with poke, or update the poke logic to better handle zero-weight cases triggered by same-block transfers.
-
L-39 Low Team Controls Proposal Threshold Without Governance Warning Pending
Description
In
ProtocolGovernor, thesetProposalNumeratorfunction allows only theteamaddress (queried fromIVotingEscrow(ve).team()) to update theproposalNumeratorvalue, which directly sets theproposalThresholdas a percentage of total supply (proposalNumerator / PROPOSAL_DENOMINATOR, starting at 0.02% and capped at 5%). This bypasses the governance process entirely, as proposals and votes from token holders cannot influence or vote on changes to this key parameter. Relevant code snippet:function setProposalNumerator(uint256 numerator) external { if (msg.sender != IVotingEscrow(ve).team()) revert NotTeam(); if (numerator > MAX_PROPOSAL_NUMERATOR) revert ProposalNumeratorTooHigh(); proposalNumerator = numerator; }Same applies also to the
setTeamRateandtoggleSplitfunctions.Recommendation
Route
setProposalNumerator,setTeamRateandtoggleSplitfunctions through governance proposals, replacing the team check withonlyGovernancemodifier to ensure changes require voter approval. -
L-40 Low withdrawManaged Skips Pending Reward Claims Warning Resolved
Description
RewardsDistributor.claim(mTokenId)is the only path that moves weekly rebases from the global distributor into a managed NFT’sLockedManagedRewardcontract. If a child veNFT is withdrawn before the parent managed lock has executedclaimfor the most recently finished epoch, that child’s share of those yet‑to‑be‑forwarded tokens is silently lost. Execution flow:- A rebase accrues;
RewardsDistributor.claimable(mTokenId)is non‑zero but no claim call is made. - The owner invokes
Voter.withdrawManaged(childId)which call to:lockedManagedReward.earned(childId)returnszerobecause the rebase has not been forwarded.- The child’s weight is burned and permanently removed from future accounting.
- Later, when
RewardsDistributor.claim(mTokenId)is finally called, the rebase arrives inLockedManagedReward, but the withdrawn child no longer participates in the weight distribution, so its rightful portion is redistributed to the remaining depositors. Therefore, any depositor who exits a managed lock without first triggering a parent‑levelRewardsDistributor.claimforfeits its pending epoch rewards forever.
Recommendation
Require
withdrawManagedto proceed only whenRewardsDistributor.claimable(mTokenId) == 0, i.e. the manager has already forwarded the latest rebase. - A rebase accrues;
-
L-41 Low Zero Locked Supply Allows Bypass Of Quorum Warning Pending
Description
Based on the provided deployment
config.json, the Minter contract is initialized with zero locked tokens. This creates a issue in the ProtocolGovernor’s quorum calculation:function quorum(uint256 timepoint) public view virtual override returns (uint256) { return (token.getPastTotalSupply(timepoint) * quorumNumerator(timepoint)) / quorumDenominator(); }Since
getPastTotalSupplyreturns zero, the calculated quorum is also zero. This means any proposal can pass with zero votes, so long as it is not actively voted against during the voting period. An attacker could propose to set the quorum numerator to zero, or userelayto execute arbitrary transactions.Recommendation
During deployment, ensure hat a non-zero amount of tokens are locked. This guarantees a meaningful quorum threshold is enforced from the start, preventing governance from being bypassed due to a zero total supply.
-
L-42 Low Whitelisted Rewards Persist After Removal Logical Error Resolved
Description
The
BribeVotingRewardandFreeManagedRewardcontracts restrict thenotifyRewardAmountfunction to accept only tokens that are either already in theisRewardmapping or currently whitelisted by theVotercontract. If a new token is whitelisted in theVotercontract, it can be added to theisRewardmapping upon first use. However, once a token has been added toisReward, thenotifyRewardAmountfunction no longer checks whether the token is still whitelisted in theVotercontract. This means that if a token is later removed from the whitelist—due to governance decisions—it can still be used to add rewards. Additionally, there is no function to modify tokens in theisRewardmapping, making the status effectively permanent. As a result, tokens explicitly removed by governance can continue being distributed as rewards, bypassing intended access restrictions. If a token was removed for being malicious or unstable, this loophole poses a broader security and trust risk. Over time, therewardsarray may also become bloated with stale or invalid tokens, further reducing transparency and control over what tokens are being used for rewards.Recommendation
Consider updating the notifyRewardAmount logic to always check the token's current whitelist status or implementing a way to modify tokens in the isReward mapping and rewards array when they are no longer whitelisted.
-
I-01 Informational Unit‑mismatch In _claimFees Can Prevent Fee Distribution Logical Error Pending
Description
The
Gaugecontract releases swap fees to thefeesVotingRewardbribe only when the local variablesfees0orfees1exceed the constantDURATION, which is fixed at 7 days expressed in seconds (604800).// Gauge.sol (_claimFees) uint256 internal constant DURATION = 7 days; // 604800 if (_fees0 > DURATION) { // token‑amount > 604800 (seconds) fees0 = 0; _token0.safeApprove(feesVotingReward, _fees0); IReward(feesVotingReward).notifyRewardAmount(_token0, _fees0); } else { fees0 = _fees0; } if (_fees1 > DURATION) { fees1 = 0; _token1.safeApprove(feesVotingReward, _fees1); IReward(feesVotingReward).notifyRewardAmount(_token1, _fees1); } else { fees1 = _fees1; }_fees0and_fees1are ERC20 token amounts, not time values. Unless a pool accrues more than604800base‑units of the fee token between two calls, theifcondition will never be satisfied. In practice most tokens use 18 decimals so the threshold is 0.000604800 tokens, which is trivially passed, but for low‑decimals or LPs with small volume the opposite occurs: fees accumulate without limit because the comparison is meaningless.Recommendation
To correct the logic treat the threshold in the same unit as the variable being compared, or make the distribution time‑based rather than amount‑based. A minimal fix is to replace
DURATIONwith a constant that represents a token‑amount, for exampleMIN_FEE_TO_DISTRIBUTE = 1e18, or to distribute on a timer:uint256 public lastFeeCheckpoint; uint256 public constant MIN_INTERVAL = 1 hours; if (block.timestamp >= lastFeeCheckpoint + MIN_INTERVAL && _fees0 > 0) { fees0 = 0; _token0.safeApprove(feesVotingReward, _fees0); IReward(feesVotingReward).notifyRewardAmount(_token0, _fees0); lastFeeCheckpoint = block.timestamp; }Either approach removes the unit mismatch and ensures that collected LP fees reach their intended recipients.
-
I-02 Informational Incorrect Time-Constant Comparison In _distribute() Logical Error Pending
Description
In the
Voter._distributefunction, the code requires that the accrued reward balance_claimableexceed both the remaining gauge capacity and the constantDURATION, which is defined as seven days (604800 seconds):if (_claimable > IGauge(_gauge).left() && _claimable > DURATION)Because
DURATIONrepresents a time interval rather than a token quantity, any gauge with fewer than604800tokens pending will never satisfy the second condition. This misalignment of units appears to stem from a misplaced copy of a time-based check, but in the context of token distribution it prevents any legitimate payouts under the seven-day amount.Recommendation
Replace the existing conditional so that distribution occurs whenever there is a positive claimable balance above the gauge’s leftover capacity. For example:
- if (_claimable > IGauge(_gauge).left() && _claimable > DURATION) { + if (_claimable > IGauge(_gauge).left()) { claimable[_gauge] = 0; address(rewardToken).safeApprove(_gauge, _claimable); IGauge(_gauge).notifyRewardAmount(_claimable); address(rewardToken).safeApprove(_gauge, 0); emit DistributeReward(_msgSender(), _gauge, _claimable); } -
I-03 Informational Missing Contract-wallet Delegation Support Warning Pending
Description
The
delegateBySigfunction inVotingEscrowallows delegation via an ECDSA signature recovered withecrecover, but it rejects any attempts from smart-contract wallets because it never checks the ERC-1271isValidSignatureinterface. As a result, a user whose veNFT is held in a Gnosis Safe, Argent, or any account that signs via a contract can not delegate by signature. This limitation forces contract-wallet holders into on-chain transactions for every delegation change, undermining meta-transaction UX and discouraging participation from institutional or multisig users.Recommendation
Extend
delegateBySigto first attempt anisValidSignature(digest, signature)call on the recovered address ifecrecoverreturns an address with no private-key. Only fall back to the existing on-chain approval check if the contract does not implement ERC-1271. This will allow multisig and other contract wallets to delegate seamlessly via signed messages. -
I-04 Informational Unused Imports Warning Pending
Description
In the
VotingEscrowcontract a number of files are imported but never actually referenced in the code. For example, the top of the file includesimport {Base64} from "@solady/utils/Base64.sol"; import {Ownable} from "@solady/auth/Ownable.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";yet none of these types or libraries is ever invoked. Unused imports needlessly bloat the source and the compiler’s dependency graph. They obscure which modules actually drive core logic and slightly increase contract metadata size, which flows into artifact and deployment overhead.
Recommendation
Prune all imports that are not referenced by the contract. In particular, remove
Base64,OwnableandIERC20from the import list. Keeping only the truly used interfaces and libraries will make the codebase easier to navigate, minimize bytecode metadata and reduce the risk of carry-along vulnerabilities from unused modules. -
I-05 Informational Transient Storage Can Be Used For The ReentrancyGuard Warning Pending
Description
The library ReentrancyGuard is used. There is also a version that uses transient storage that is more efficient.
Recommendation
Consider using ReentrancyGuardTransient. Then check the chain on which the contract are deployed support transient storage.
-
I-06 Informational veNFT Splitting Is Disabled By Default Configuration Pending
Description
The
splitfunction inVotingEscrowlets a veNFT owner carve a single lock into two separate NFTs, but this capability is turned off for everyone until the on-chain team explicitly enables it. Under the hood, the contract checksif (!canSplit[owner] && !canSplit[address(0)]) revert SplitNotAllowed();and by default both
canSplit[owner]and the globalcanSplit[address(0)]arefalse. As a result, even if you hold a large lock and want to create smaller tranches for portfolio management, you cannot split your NFT until the team callsvotingEscrow.toggleSplit(address(0), true);Recommendation
Merely informative. Consider adding the
votingEscrow.toggleSplit(address(0), true);call to yourve.tsscript if this is an intended functionality you want to include since the protocol deployment. -
I-07 Informational Missaligment With Velodrome Tokenomics Warning Pending
Description
A review of the
Mintercontract against the official Velodrome specification (see SPECIFICATION.md#minter) reveals four key discrepancies that are purely informational but may confuse auditors or integrators: Initial Weekly Emission- Spec: 15 million PSPS per epoch
- Code:
weekly = 10_000_000 * 1e18; // 10 millionImpact: integrations expecting 15 M on day one will see 10 M instead. Growth vs. Decay Schedule
- Spec: emissions decay at 1% per epoch from the start
- Code:
if (epochCount < 15) { weekly = (weekly * WEEKLY_GROWTH) / MAX_BPS; // +3% for first 15 epochs } else { weekly = (weekly * WEEKLY_DECAY) / MAX_BPS; // –1% thereafter }Impact: emissions initially increase 3% weekly for 15 weeks, contrary to a constant 1% decline. Tail‐Emission Trigger
- Spec: tail mode begins once weekly falls below ~6 million
- Code:
uint256 public constant TAIL_START = 8_969_150 * 1e18; // ~8.97 M bool _tail = weekly < TAIL_START;Impact: tail schedule activates later than documented (~92 vs. ~134 epochs). Initial Tail Rate
- Spec: 30 basis points (0.30%) initial tail rate
- Code:
uint256 public tailEmissionRate = 67; // 67 bps = 0.67%Impact: tail emissions start at 0.67% instead of 0.30%.
Recommendation
This issue is informational: update any external documentation or auditor notes to reflect the on-chain parameters as implemented, and refer to the official spec in SPECIFICATION.md#minter for the source of truth.
-
I-08 Informational Unused _proposalProposer Internal View Function Warning Pending
Description
In the
VetoGovernorandGovernorVotescontracts, there is an internal helperfunction _proposalProposer(uint256 proposalId) internal view returns (address) { return _proposals[proposalId].proposer; }that simply reads the
proposerfield from theProposalstruct. This function is never exposed externally and its single‐line body adds no abstraction or reuse benefit, since any consumer within the contract could call_proposals[proposalId].proposerdirectly and off-chain tools already learn theproposerfrom theProposalCreatedevent.Recommendation
Consider removing the
_proposalProposerfunction from theVetoGovernorandGovernorVotescontracts. -
I-09 Informational Best Practices For PSPS Reward Distribution For PurrLPStakingForPoints Stakers Warning Pending
Description
The current launch scripts reference an
airdrop.jsonfile intended to list wallet/amount pairs for a PSPS airdrop, but no logic exists that actually produces that file or decides how the total airdrop pool will be split among the four upcomingPurrLPStakingForPointscontracts (USDT0/USDE, USDT0/feUSD, WHYPE/USDT0, WHYPE/UETH). Because each LP token will have a different value, an equal 25 %÷25 %÷25 %÷25 % split would over‑reward holders of the least valuable LP token and under‑reward those who provided the highest valuable LP tokens.Recommendation
Derive every pool’s share of the airdrop from its relative dollar value locked (TVL) at a well‑defined snapshot block. If pool A holds $10000 in LP Tokens staked and pool B holds $20000, pool B should receive twice the PSPS that pool A receives. Compute each LP token’s price safely.
-
I-10 Informational Accounts Can Own Multiple Managed NFTs Informational Pending
Description
VotingEscrow.createManagedLockFor()will create a new managed NFT and assign it to a given address. The function can be used multiple times to mint many managed NFTs to that same address. This is opposite to what's stated in the NatSpec of the function which says:Throws if address already owns a managed NFT.Recommendation
If the intention is to allow addresses to own multiple managed NFTs, remove that part of the NatSpec.
-
I-11 Informational Deceiving Comment In split Function Informational Pending
Description
One of the comments in the NatSpec for the
VotingEscrow.split()function says if the party calling the function is approved for that token, they won't be able to operate on the newly created tokens.If this is called by approved, approved will not have permissions to manipulate the newly created veNFTsWhile true for normal approvals, this statement is wrong for operators calling thesplitfunction - they will still be able to operate with the newly created NFTs because the same owner will receive them.Recommendation
Consider modifying the comment to make it clear that approved operators will be able to modify the newly created NFTs.
-
I-12 Informational Transfer to Zero Address Allowed Logical Error Pending
Description
Transfers of veNFT to the zero address are not prevented. Unlike a proper
_burn, when transferring a veNFT to the zero address, no checkpoint is created, so bias and slope remain in global checkpoint history until lock expiry, inflating total voting power. Rewards accruing to the veNFT (in the zero address) can still be claimed, as the fee distributor does not check for zero address ownership. However, once claimed, the underlying assets become permanently stuck in the VotingEscrow contract. Additionally, PSPSsupplyin VotingEscrow is not reduced, leading to incorrect accounting in external contracts relying on that variable.Recommendation
In
_transferFrom, add an explicit check to reject_to == address(0). -
I-13 Informational Request type is not closed properly Best Practices Pending
Description
Forwarder.registerRequestType()allows users registering their custom request type with additionalsuffixData. TherequestTypestring is opened with a(, but it's never closed. This means it's on the user to provide asuffixDataending with), otherwise the EIP-712 signing procedure won't work correctly.Recommendation
Consider closing the
requestTypestring properly, so the user can only provide the data they need. -
I-14 Informational Gauges With
isPool = falseNever Receive Fees Gas Optimization PendingDescription
Gauge._claimFees()won't send any rewards toFeeVotingRewardsifisPool == false. While this is intended, it's worth noting it creates some side effects, like paying more gas oncreateGaugebecause an unused contract is deployed, spending more gas on voting becausedeposit/withdraware called on that contract even though it won't generate rewards.Recommendation
Acknowledge the gas cost increases mentioned in the report.
-
I-15 Informational transfer And unlock Constraints Informational Pending
Description
When a permanent lock is unlocked, its delegation is deleted. A delegation can only be deleted if the delegator NFT wasn't transferred in the same block. As a result, if a user transfers their NFT, they cannot unlock it within that same block. This behavior is important to note for external integrators.
Recommendation
Document that behavior.
-
I-16 Informational
lockPermanentcan be applied to voting tokens Informational PendingDescription
VotingEscrow.lockPermanent()doesn't check if the token has voted, which means that actively voting tokens can be changed to permanent locks.Recommendation
Acknowledge this behavior.
-
I-17 Informational Auto‑compound Set To True Disables Gauge Fee Stream Warning Pending
Description
When
_AUTO_COMPOUND_FEES_is set totruein aPurrLPclone, the pool’s fee‑accrual mechanism places all swap fees directly back into the pool reserves, bypassing the per‑address accounting indexes that driveclaimFees:function _updateBaseFees(uint256 amount) internal returns (uint256) { if (!implementation._AUTO_COMPOUND_FEES_()) { // ← never executed _BASE_TOKEN_.safeTransfer(fees, amount); index0 += (amount * 1e18) / totalSupply(); } else if (_treasuryFeeBips > 0) { _BASE_TOKEN_.safeTransfer(_treasury, treasuryAmount); } }Because
index0andindex1remain constant,claimFeesalways returns (0, 0):uint256 _delta0 = index0 - supplyIndex0[recipient]; // always zero uint256 _delta1 = index1 - supplyIndex1[recipient]; // always zeroThe
Gaugecontract relies exclusively on(claimed0, claimed1) = IPurrLP(stakingToken).claimFees();to harvest these fees. With every call returning zero, the
Gauge’s internal fee buffers never fill, noIReward(feesVotingReward).notifyRewardAmountis triggered and the entire downstream fee‑sharing stream to theFeesVotingRewardcontract is silently cut off forever. Emissions still flow, but the protocol component that voters, dashboards and external participants expect, “swap fees distributed every epoch”, simply vanishes. The value of those fees is permanently locked into the LP’s reserves of theGaugecontract instead of reaching tokenholders who stake or vote, resulting in a non‑recoverable revenue loss and broken economic assumptions for the protocol. Switching_AUTO_COMPOUND_FEES_back tofalsecannot recover fees that were already compounded.Recommendation
Merely informative. Consider never setting
_AUTO_COMPOUND_FEES_to true in an underlyingPurrLppool supported by aGauge. -
I-18 Informational External Functions Naming Best Practices Pending
Description
The external
_deposit()and_withdraw()functions in theReward.solcontract are unconventionally prefixed with underscore, which confused the reader that they are internal functions.Recommendation
Consider removing the underscore of their names.
-
I-19 Informational
increaseAmount()Donates To Rewards Informational PendingDescription
VotingEscrow.increaseAmount()allows an approved party for the managed lock to increase the locked amount of PSPS. However, the amount sent is distributed as rewards to the depositors for the given lock, which means the user will potentially lose part of these tokens or all of them if they haven't deposited into the lock themselves.Recommendation
Make sure the users are aware of this behavior.
-
I-20 Informational Immediate Execution Lacks Timelock Delay Warning Pending
Description
The governance system executes succeeded proposals instantly via the
executefunction inVetoGovernor, without a queued state or timelock delay. Once a proposal reachesProposalState.Succeeded(quorum met andFor > Against), anyone can callexecuteto perform the actions, as the check only verifies status:function execute(...) { ProposalState status = state(proposalId); require(status == ProposalState.Succeeded, "Governor: proposal not successful"); _proposals[proposalId].executed = true; // Proceeds to _execute with target calls }With a short voting delay (15 minutes) and period (1 week), this allows immediate invocation of sensitive operations like parameter changes or fund transfers post-voting.
Recommendation
Consider integrating a timelock extension (e.g., inherit from OZ's
GovernorTimelockControl) with a minimum 48-hour delay between success and execution, adding a queued state for veto or community response. -
I-21 Informational Setters Lack Event Emissions Best Practices Pending
Description
Several setter functions in
ProtocolGovernor, such assetProposalNumerator,setVetoer,acceptVetoerandrenounceVetoer, update critical state variables without emitting events. This silently changes proposal thresholds, vetoer roles, or renounces veto power, making it hard for offchain tools, monitors or users to track alterations.Recommendation
Add custom events (e.g.,
emit ProposalNumeratorUpdated(old, new);) to all setters for offchain indexing and transparency. -
I-22 Informational
LOCKEDandMANAGEDDelegate Differences Informational PendingDescription
In Velodrome the
GovernorSimpleVotes._getVotes()function assigns the initial weight deposited + earned rewards towards the voting power of theLOCKEDescrows if the managed lock they are depositing into doesn't have a delegatee. To not double count votes, the team has also disabledMANAGEDescrow from voting. In Purrswap's case, it's possible to vote with all three escrow types. This means the voting power of theLOCKEDescrow types will always be equal to whatever is delegated to them, not counting their initial deposited weight and rewards earned. Furthermore, it also means if a managed lock holds a large amount of voting power, the owner may be able to vote and execute proposals unilaterally, creating centralization risk.Recommendation
- Users should be warned about losing their voting power once they deposit into a managed lock.
- Be aware of centralization risks if a managed lock accrues significant voting power
-
I-23 Informational Immediate Execution Lacks Timelock Delay Warning Pending
Description
The governance system executes succeeded proposals instantly via the
executefunction inVetoGovernor, without a queued state or timelock delay. Once a proposal reachesProposalState.Succeeded(quorum met andFor > Against), anyone can callexecuteto perform the actions, as the check only verifies status:function execute(...) { ProposalState status = state(proposalId); require(status == ProposalState.Succeeded, "Governor: proposal not successful"); _proposals[proposalId].executed = true; // Proceeds to _execute with target calls }With a short voting delay (15 minutes) and period (1 week), this allows immediate invocation of sensitive operations like parameter changes or fund transfers post-voting.
Recommendation
Consider integrating a timelock extension (e.g., inherit from OZ's
GovernorTimelockControl) with a minimum 48-hour delay between success and execution, adding a queued state for veto or community response. -
I-24 Informational Unbounded Reward List In MultiRewards DoS Pending
Description
The
MultiRewardscontract allows theownerto add reward tokens via theaddRewardfunction. However, there is no limit on how many reward tokens can be added, and no mechanism to remove tokens once they are included. Over time, this could lead to an excessively large reward list. Since the_updateRewardsfunction iterates through all reward tokens and is called within multiple critical functions (e.g.,stake,withdraw, andgetReward), a large list could cause these operations to run out of gas and fail. This introduces a potential denial-of-service risk for users attempting to interact with the contract.Recommendation
Consider introducing a cap on the number of reward tokens that can be added or implementing a mechanism to safely remove obsolete reward tokens from the list to avoid unbounded growth.
-
I-25 Informational No Quorum Requirement In EpochGovernor Best Practices Pending
Description
The EpochGovernor contract currently does not enforce any quorum threshold for proposals. As a result, proposals can be executed with minimal participation, even if only a small number of voters engage. This undermines the legitimacy of governance outcomes, as decisions may not reflect the broader consensus of stakeholders.
Recommendation
Consider whether a quorum threshold should be introduced to ensure that proposals require minimum participation before execution.
-
I-26 Informational Delayed Fee Distribution From _claimFees Logical Error Pending
Description
The
_claimFeesfunction in theGaugecontract is invoked duringnotifyRewardAmount, which itself is restricted to calls from theVotercontract. ThenotifyRewardAmountfunction is triggered within theVotercontract’sdistributefunction—but only when_claimable > IGauge(_gauge).left()and_claimable > DURATION. As a result, there are scenarios where thePurrLPcontract may have accrued fees ready for distribution, but_claimFeesis not called because the outer condition indistributeis not satisfied. This leads to delayed fee distribution, potentially missing active staking periods and causing some users to receive reduced or no rewards for the time they were staked.Recommendation
Consider providing a dedicated function to claim and distribute fees more promptly, regardless of emission thresholds.
Remediation Review
10 findings · August 1 to 6, 2025-
H-01 High Disabling managed lock mishandles delegations Logical Error Reported
Description
If the NFT being disabled by
VotingEscrow.setManagedState()is actively delegating, the code will remove its delegation_checkpointDelegator(_mTokenId, 0, _ownerOf(_mTokenId)); _checkpointDelegatee(currentDelegate, delegatedBalance, false);Notice that it calls both
checkpointDelegator()andcheckpointDelegatee(). The problem with this approach is that calling_checkpointDelegator()will internally callcheckpointDelegatee()to decrease the delegation for the current delegatee. Invoking that function a second time will result in the delegatee losing twice the power of the managed NFT. This behavior will cause accounting mismatch, where the sum of the power of all the delegators doesn't match the sum of the power of all the delegatees. In result the delegatee has less influence when voting than they should. Furthermore, it can be exploited by the owner of a malicious managed lock that's to be disabled. They can frontrun the disabling transaction and delegate to a desired lock to significantly reduce its voting power after the transaction is executed.Recommendation
Remove the call to
_checkpointDelegatee()insetManagedState(). For reference, see how_transferFrom()calls only_checkpointDelegator(). -
H-02 High Withdrawal Blocked for Deactivated NFTs DoS Reported
Description
The
_increaseAmountForfunction, invoked bydepositFor, now prevents calls involving deactivated managed NFTs. This restriction prevents further deposits into these NFTs, as intended. However, it also introduces unintended issues elsewhere in the system. Firstly, theRewardsDistributorcontract usesdepositForduring itsclaimprocess to allocate earned rewards. Since_increaseAmountFornow rejects deactivated NFTs, these tokens are unable to claim rewards earned prior to deactivation. More critically, thewithdrawManagedfunction in theVotercontract requires that all pending rewards be claimed before a token can be withdrawn from a managed NFT. This creates a deadlock: deactivated managed NFTs cannot claim rewards, and users cannot withdraw their tokens—effectively locking them in.Recommendation
Allow _increaseAmountFor to be called on deactivated managed NFTs only when invoked by the RewardsDistributor contract. Alternatively, remove the claim requirement from withdrawManaged, and clearly document that users should manually call claim on the managed NFT prior to withdrawing, to ensure all rewards are collected.
-
M-01 Medium Tail Emissions Cannot Be Maintained Or Decreased Logical Error Reported
Description
The change to the
executefunction inGovernorSimplerestricts execution to proposals in theSucceededstate only:require( status == ProposalState.Succeeded, "GovernorSimple: proposal not successful" );This function triggers the
nudgefunction inMinter, which adjusts tail emissions based on the proposal outcome. However,nudgeis designed to interpretSucceeded,Defeated, andExpiredstates as signals to increase, decrease, or maintain emissions respectively. Restricting execution to onlySucceededproposals disables the ability to vote for emission decreases (Defeated) or to maintain current levels (Expired). This change undermines the governance mechanism and prevents the full range of voter intent from being executed.Recommendation
Revert the condition in
GovernorSimple.executeto allow execution of proposals inDefeatedandExpiredstates -
M-02 Medium Vote Reset Blocks Reward Claims Rewards Reported
Description
The
depositManagedfunction calls_claimAllPendingRewardsto collect rewards from all pools the token has voted on before depositing it into a managed NFT. To do this, it uses thepoolVotefunction in theVotercontract to retrieve the list of voted pools and their associated reward contracts. However, the token’s votes are reset earlier within thedepositManaged, which clears all previous voting data before the rewards are fetched. As a result,poolVotereturns an empty list, and no rewards are claimed—even if the token had earned rewards prior to the deposit.Recommendation
Modify the depositManaged function in the Voter contract to call depositManaged in the VotingEscrow contract before resetting votes, ensuring the tokens voting data remains intact during reward collection. Alternatively, clearly document that users should manually claim rewards prior to calling depositManaged.
-
M-03 Medium Insufficient Approval Breaks Reward Claims Rewards Reported
Description
The
depositManagedfunction attempts to claim all pending rewards for a token before depositing it into a managed NFT. It does so by calling_claimAllPendingRewards, which retrieves the token’s voted pools via thepoolVotefunction in theVotercontract, then attempts to claim rewards from the associated reward contracts. However, the reward contracts restrictgetRewardcalls to only the token owner or an approved address. SincegetRewardis called from within theVotingEscrowcontract—which is neither the owner nor an approved operator—these reward claims consistently fail. This results in expected rewards not being collected when a user deposits into a managed NFT.Recommendation
Modify the getReward function to allow calls from the VotingEscrow contract, similar to how it currently allows calls from the Voter. Alternatively, ensure that frontends and documentation clearly instruct users to claim rewards manually before calling depositManaged.
-
L-01 Low Gauge Fees Cannot Be Claimed Post-Kill Logical Error Reported
Description
The fix for M-05 correctly ensures that fees are claimed before a gauge is killed. However, killed gauges are likely to still hold user deposits and continue accruing new fees. Due to a lack of post-kill fee-claim path, these fees are permanently inaccessible.
Recommendation
Consider allowing anyone to call the
claimFeesfunction, instead of just the Voter contract. -
L-02 Low FreeManagedReward Fails To Revalidate Token Whitelist Validation Reported
Description
In the current implementation of
FreeManagedReward.notifyRewardAmount, the contract checksIVoter(voter).isWhitelistedToken(token)only once, when the token is first added to the rewards list. After that initial pass, any further calls tonotifyRewardAmountfor that token bypass the whitelist check entirely, even if governance has since removed the token from the global whitelist. This “first-time only” pattern is evident in the code:function notifyRewardAmount(address token, uint256 amount) external override nonReentrant { address sender = _msgSender(); if (!isReward[token]) { if (!IVoter(voter).isWhitelistedToken(token)) revert NotWhitelisted(); isReward[token] = true; rewards.push(token); } _notifyRewardAmount(sender, token, amount); }Because the call to
isWhitelistedToken(token)is nested insideif (!isReward[token]), onceisReward[token]becomestruethe whitelist guard is never executed again. This allows old or malicious tokens to continue receiving reward notifications even after removal from the approved token list.Recommendation
Adopt the same pattern used in
BribeVotingReward.notifyRewardAmount, where the whitelist test occurs unconditionally at the top of the function. Move the call toIVoter(voter).isWhitelistedToken(token)outside of theisReward[token]check so that every invocation enforces the on-chain whitelist. For example:function notifyRewardAmount(address token, uint256 amount) external override nonReentrant { address sender = _msgSender(); if (!IVoter(voter).isWhitelistedToken(token)) revert NotWhitelisted(); if (!isReward[token]) { isReward[token] = true; rewards.push(token); } _notifyRewardAmount(sender, token, amount); } -
L-03 Low Deactivated NFTs Can Vote in EpochGovernor Validation Reported
Description
The
_getVotesfunction in theProtocolGovernorcontract was updated to restrict deactivated managed NFTs from participating in governance by returning zero voting power. This ensures that only active NFTs can create and vote on proposals. However, this restriction is not applied in theEpochGovernorcontract. As a result, deactivated managed NFTs can still create and vote on proposals withinEpochGovernor.Recommendation
Review whether the EpochGovernor contract should adopt the same restriction on deactivated managed NFTs to ensure consistent governance behavior across contracts.
-
I-01 Informational Full voting power redistributed to alive gauges Informational Reported
Description
The internal
_poke()function inVoter.solwas modified to not vote for any pools with inexistent or killed gauges. This approach ensures poke can be successfully called on atokenIdeven if one of the gauges it votes for has been killed. In result, whenpoke()is called on such NFT, its voting power will be fully utilized for the rest of pools. For example:- User voted 500 voting power in proportions 20/20/60
- Pools receive 100/100/300 weights
- The gauge for the second pool is killed
- Now when
poke()is called, the new weights for the pools will be 125/375, i.e the full voting power is utilized
Recommendation
Document this behavior.
-
I-02 Informational Redundant Condition Check in setManagedState Function Warning Reported
Description
In the
setManagedStatefunction, the conditionif (_state && !deactivated[_mTokenId])includes a redundant check for!deactivated[_mTokenId]. This redundancy arises because the function already reverts earlier ifdeactivated[_mTokenId] == _stateviarevert SameState(). When_stateistrue(deactivation mode), the revert ensures thatdeactivated[_mTokenId]must befalsefor the function to proceed. Therefore, the!deactivated[_mTokenId]check is alwaystruein this branch and serves no purpose.Recommendation
Remove the redundant check to simplify the code.
No findings match.
More from Abracadabra Money
All 19 reports-
MagicGLP Upgrade
11 findings1 high 11 findings: 1 high, 10 low -
PurrSwap ve33
74 findings5 critical · 10 high 74 findings: 5 critical, 10 high, 20 medium, 39 low -
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.
