Platform: Code4rena
Start Date: 03/05/2023
Pot Size: $60,500 USDC
Total HM: 25
Participants: 114
Period: 8 days
Judge: Picodes
Total Solo HM: 6
Id: 234
League: ETH
Rank: 29/114
Findings: 3
Award: $326.09
🌟 Selected for report: 0
🚀 Solo Findings: 0
187.2333 USDC - $187.23
In StandardFunding.sol
, when _updateTreasury()
is called, it incorrectly adds the delegate rewards to the treasury as surplus. Over time, this additional balance causes more tokens to be distributed than intended.
In _updateTreasury()
, the total tokensRequested
is summed across all the proposals in the funded slate. The difference between this value and the total funds available for the distribution is added back to the treasury, enabling unspent funds to be distributed in future distributions.
function _updateTreasury(uint24 distributionId_) private { bytes32 fundedSlateHash = _distributions[distributionId_].fundedSlateHash; uint256 fundsAvailable = _distributions[distributionId_].fundsAvailable; uint256[] memory fundingProposalIds = _fundedProposalSlates[fundedSlateHash]; uint256 totalTokensRequested; uint256 numFundedProposals = fundingProposalIds.length; for (uint256 i = 0; i < numFundedProposals;) { Proposal memory proposal = _standardFundingProposals[fundingProposalIds[i]]; totalTokensRequested += proposal.tokensRequested; unchecked { ++i; } } treasury += (fundsAvailable - totalTokensRequested); _isSurplusFundsUpdated[distributionId_] = true; }
However, the issue is that 10% of the funds available in a distribution is always reserved and used as delegate rewards. The maximum total tokens that can be requested is 90% of the total funds available. This is enforced in _validateSlate()
.
// check if slate of proposals exceeded budget constraint ( 90% of GBC ) if (totalTokensRequested > ((gbc * 9) / 10)) { revert InvalidProposalSlate(); }
and in _getDelegateReward()
.
rewards_ = Maths.wdiv( Maths.wmul(currentDistribution_.fundsAvailable, votingPowerAllocatedByDelegatee), currentDistribution_.fundingVotePowerCast ) / 10;
Therefore, when the treasury is updated, you can see that the 10% delegate rewards are not accounted for, and always rolled back into the calculated treasury balance.
treasury += (fundsAvailable - totalTokensRequested);
Over time this miscalculated treasury balance will compound as distribution rounds are executed, resulting in corrupted accounting across the entirety of the grant system. The calculated treasury balance will drift further and further from the actual amount, causing the system to overspend, and potentially breaking future distributions entirely as transactions are unable to be executed.
Manual review
Account for delegate rewards when updating the treasury balance.
+ uint256 delegateRewards = fundsAvailable / 10; + treasury += (fundsAvailable - delegateRewards - totalTokensRequested); - treasury += (fundsAvailable - totalTokensRequested);
Other
#0 - c4-judge
2023-05-18T17:35:06Z
Picodes marked the issue as duplicate of #263
#1 - c4-judge
2023-05-30T18:09:15Z
Picodes marked the issue as duplicate of #263
#2 - c4-judge
2023-05-30T18:11:38Z
Picodes changed the severity to 3 (High Risk)
#3 - c4-judge
2023-05-30T18:14:48Z
Picodes marked the issue as satisfactory
🌟 Selected for report: aviggiano
Also found by: 0xSmartContract, 0xTheC0der, 0xcm, ABAIKUNANBAEV, Audinarey, Audit_Avengers, BGSecurity, Bauchibred, Dug, Evo, Haipls, Jerry0x, TS, bytes032, devscrooge, kenta, ladboy233, mrvincere, patitonar, sakshamguruji, tsvetanovv
15.5756 USDC - $15.58
In the event that the RewardsManager
token balance is not maintained, a staker can attempt to claim their rewards and receive no tokens, but be unable to claim them again should the balance ever be restored.
This results in a loss of funds for the staker.
In the _transferAnjaRewards()
function, you can see that even if the earned rewards are not transferred, the call still successfully resolves.
function _transferAjnaRewards(uint256 rewardsEarned_) internal { // check that rewards earned isn't greater than remaining balance // if remaining balance is greater, set to remaining balance uint256 ajnaBalance = IERC20(ajnaToken).balanceOf(address(this)); if (rewardsEarned_ > ajnaBalance) rewardsEarned_ = ajnaBalance; if (rewardsEarned_ != 0) { // transfer rewards to sender IERC20(ajnaToken).safeTransfer(msg.sender, rewardsEarned_); } }
This can result in a claim being made, but inadequate or no earned rewards being paid out. This makes it impossible for the staker to later claim their earned rewards should token balance be restored to the RewardsManager
in the future.
Manual review
A claim should not successfully resolve unless the earned rewards are actually transferred to the staker. This allows them to claim their rewards at a later date should the token balance become available.
Rug-Pull
#0 - c4-judge
2023-05-12T10:33:46Z
Picodes marked the issue as duplicate of #361
#1 - c4-judge
2023-05-29T20:55:38Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-05-29T20:55:46Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: 0xRobocop
Also found by: Dug, ktg, rvierdiiev, sces60107
123.2812 USDC - $123.28
The vote threshold is miscalculated in _extraordinaryProposalSucceeded()
creating invariants that result in proposals being drastically easier or more difficult to pass depending on context.
The Anja whitepaper describes how the vote threshold is determined for extraordinary proposals. The following example is from the whitepaper, showing how the vote threshold is calculated when the minimum threshold is at 50%:
a. A proposer requests tokens equivalent to 10% of the treasury i. 50% + 10% = 60% ii. If 65% of non-treasury tokens vote affirmatively, 10% of the treasury is released iii. If 59.9% of non-treasury tokens vote affirmatively, 0% of the treasury is released
The percentage of the treasury balance requested is added to the minimum threshold to determine the vote threshold.
However, the actual implementation of _extraordinaryProposalSucceeded()
does not match this description. The following is the actual implementation of how votes are validated:
return // succeeded if proposal's votes received doesn't exceed the minimum threshold required (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) // succeeded if tokens requested are available for claiming from the treasury && (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage));
Instead of the percentage of the treasury balance being added to the minimum threshold, the tokens requested is added to the vote threshold directly. This results in the vote threshold being drastically different than the whitepaper describes. The protocol team was unable to give input on how they expected tokens to be distributed, so I present two scenarios to consider below with differing token distributions, showing how this creates issues with both.
One scenario where the majority of tokens are held by the treasury:
- Minimum threshold: 50% - Total supply: 100,000 tokens - Treasury balance: 80,000 tokens - Non-treasury balance: 20,000 tokens - Tokens requested: 20,000 tokens
According to the whitepaper, because the requested amount is 25% of the treasury balance, the vote threshold should be 75% (50% + 25%) of non-treasury tokens, or 15,000 tokens. However, the actual implementation results in the vote threshold being impossible at 30,000 tokens (50% + 20,000 tokens).
Note that this proposal for 25% of the treasury balance is considered valid and is verified in proposeExtraordinary()
:
// check tokens requested are available for claiming from the treasury if (uint256(totalTokensRequested) > _getSliceOfTreasury(Maths.WAD - _getMinimumThresholdPercentage())) { revert InvalidProposal(); }
However, because of the miscalculation in _extraordinaryProposalSucceeded()
, the proposal will never pass because the vote threshold is calculated to be higher than the total non-treasury balance.
Another scenario, where the majority of tokens are in circulation:
- Minimum threshold: 50% - Total supply: 100,000 tokens - Treasury balance: 20,000 tokens - Non-treasury balance: 80,000 tokens - Tokens requested: 10,000 tokens
As the request is for 50% of the treasury balance, the vote threshold should be 100% (50% + 50%) of non-treasury tokens, or 80,000 tokens. However, the implementation results in the vote threshold being 60,000 tokens (50% + 20,000 tokens). This request represents taking the maximum possible amount from the treasury, which is intended to take 100% support from the circulating supply. However, because of the miscalculation, the proposal will succeed with only 75% support from the circulating supply, making it much easier to pass than intended.
Manual review
Add the percentage of the treasury balance requested to the minimum threshold to determine the vote threshold, as described in the whitepaper.
+ uint256 percentageRequested = tokensRequested_.wadDiv(treasury); return // succeeded if proposal's votes received doesn't exceed the minimum threshold required - (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) + (votesReceived >= percentageRequested + _getSliceOfNonTreasury(minThresholdPercentage)) // succeeded if tokens requested are available for claiming from the treasury && (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage));
Other
#0 - c4-judge
2023-05-17T12:32:50Z
Picodes marked the issue as duplicate of #184
#1 - c4-judge
2023-05-30T22:40:50Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-05-30T22:42:02Z
Picodes changed the severity to 2 (Med Risk)