diff --git a/.env.example b/.env.example index 109cd764..cf0e42f7 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ ## Ethereum node endpoint ## -ETH_RPC_URL = \ No newline at end of file +ETH_RPC_URL= \ No newline at end of file diff --git a/src/grants/GrantFund.sol b/src/grants/GrantFund.sol index 6f776cb1..f21188b2 100644 --- a/src/grants/GrantFund.sol +++ b/src/grants/GrantFund.sol @@ -98,6 +98,43 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { /*** Voting Functions ***/ /************************/ + /** + * @notice Cast an array of funding votes in one transaction. + * @dev Calls out to StandardFunding._fundingVote(). + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. + * @param voteParams_ The array of votes on proposals to cast. + * @return votesCast_ The total number of votes cast across all of the proposals. + */ + function fundingVotesMulti(FundingVoteParams[] memory voteParams_) external returns (uint256 votesCast_) { + uint256 currentDistributionId = distributionIdCheckpoints.latest(); + QuarterlyDistribution storage currentDistribution = distributions[currentDistributionId]; + QuadraticVoter storage voter = quadraticVoters[currentDistribution.id][msg.sender]; + uint256 screeningStageEndBlock = _getScreeningStageEndBlock(currentDistribution); + + // this is the first time a voter has attempted to vote this period + if (voter.votingPower == 0) { + voter.votingPower = Maths.wpow(_getVotesSinceSnapshot(msg.sender, screeningStageEndBlock - VOTING_POWER_SNAPSHOT_DELAY, screeningStageEndBlock), 2); + voter.remainingVotingPower = voter.votingPower; + } + + uint256 numVotesCast = voteParams_.length; + for (uint256 i = 0; i < numVotesCast; ) { + Proposal storage proposal = standardFundingProposals[voteParams_[i].proposalId]; + + // cast each successive vote + votesCast_ += _fundingVote( + currentDistribution, + proposal, + msg.sender, + voter, + voteParams_[i] + ); + + unchecked { ++i; } + } + } + /** * @notice Vote on a proposal in the screening or funding stage of the Distribution Period. * @dev Override channels all other castVote methods through here. @@ -112,33 +149,32 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { // standard funding mechanism if (mechanism == FundingMechanism.Standard) { Proposal storage proposal = standardFundingProposals[proposalId_]; - QuarterlyDistribution memory currentDistribution = distributions[proposal.distributionId]; - uint256 screeningPeriodEndBlock = currentDistribution.endBlock - 72000; + QuarterlyDistribution storage currentDistribution = distributions[proposal.distributionId]; + uint256 screeningStageEndBlock = _getScreeningStageEndBlock(currentDistribution); // screening stage - if (block.number >= currentDistribution.startBlock && block.number <= screeningPeriodEndBlock) { + if (block.number >= currentDistribution.startBlock && block.number <= screeningStageEndBlock) { uint256 votes = _getVotes(account_, block.number, bytes("Screening")); votesCast_ = _screeningVote(account_, proposal, votes); } // funding stage - else if (block.number > screeningPeriodEndBlock && block.number <= currentDistribution.endBlock) { + else if (block.number > screeningStageEndBlock && block.number <= currentDistribution.endBlock) { QuadraticVoter storage voter = quadraticVoters[currentDistribution.id][account_]; // this is the first time a voter has attempted to vote this period - if (voter.votingWeight == 0) { - voter.votingWeight = Maths.wpow(_getVotesSinceSnapshot(account_, screeningPeriodEndBlock - 33, screeningPeriodEndBlock), 2); - voter.budgetRemaining = int256(voter.votingWeight); + if (voter.votingPower == 0) { + voter.votingPower = Maths.wpow(_getVotesSinceSnapshot(account_, screeningStageEndBlock - VOTING_POWER_SNAPSHOT_DELAY, screeningStageEndBlock), 2); + voter.remainingVotingPower = voter.votingPower; } - // amount of quadratic budget to allocated to the proposal - int256 budgetAllocation = abi.decode(params_, (int256)); - - // check if the voter has enough budget remaining to allocate to the proposal - if (Maths.abs(budgetAllocation) > voter.budgetRemaining) revert InsufficientBudget(); + // decode the amount of votes to allocated to the proposal + int256 votes = abi.decode(params_, (int256)); + FundingVoteParams memory newVote = FundingVoteParams(proposalId_, votes); - votesCast_ = _fundingVote(proposal, account_, voter, budgetAllocation); + // allocate the votes to the proposal + votesCast_ = _fundingVote(currentDistribution, proposal, account_, voter, newVote); } } @@ -162,20 +198,21 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { // within screening period 1 token 1 vote if (keccak256(params_) == keccak256(bytes("Screening"))) { - // calculate voting weight based on the number of tokens held before the start of the distribution period - availableVotes_ = _getVotesSinceSnapshot(account_, currentDistribution.startBlock - 33, currentDistribution.startBlock); + // calculate voting weight based on the number of tokens held at the snapshot blocks of the screening stage + availableVotes_ = _getVotesSinceSnapshot(account_, currentDistribution.startBlock - VOTING_POWER_SNAPSHOT_DELAY, currentDistribution.startBlock); } // else if in funding period quadratic formula squares the number of votes else if (keccak256(params_) == keccak256(bytes("Funding"))) { QuadraticVoter memory voter = quadraticVoters[currentDistribution.id][account_]; // voter has already allocated some of their budget this period - if (voter.votingWeight != 0) { - availableVotes_ = uint256(voter.budgetRemaining); + if (voter.votingPower != 0) { + availableVotes_ = voter.remainingVotingPower; } - // this is the first time a voter has attempted to vote this period + // voter hasn't yet called _castVote in this period else { - availableVotes_ = Maths.wpow(_getVotesSinceSnapshot(account_, currentDistribution.endBlock - 72033, currentDistribution.endBlock - 72000), 2); + uint256 screeningStageEndBlock = _getScreeningStageEndBlock(currentDistribution); + availableVotes_ = Maths.wpow(_getVotesSinceSnapshot(account_, screeningStageEndBlock - VOTING_POWER_SNAPSHOT_DELAY, screeningStageEndBlock), 2); } } else { @@ -185,8 +222,9 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { // one token one vote for extraordinary funding if (proposalId != 0) { + // get the number of votes available to voters at the start of the proposal, and 33 blocks before the start of the proposal uint256 startBlock = extraordinaryFundingProposals[proposalId].startBlock; - availableVotes_ = _getVotesSinceSnapshot(account_, startBlock - 33, startBlock); + availableVotes_ = _getVotesSinceSnapshot(account_, startBlock - VOTING_POWER_SNAPSHOT_DELAY, startBlock); } } // voting is not possible for non-specified pathways @@ -200,15 +238,17 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { * @notice Retrieve the voting power of an account. * @dev Voteing power is the minimum of the amount of votes available at a snapshot block 33 blocks prior to voting start, and at the vote starting block. * @param account_ The voting account. - * @param snapshot_ One of block numbers to retrieve the voting power at. 33 blocks prior to the vote starting block. - * @param voteStartBlock_ The block number the vote started at. + * @param snapshot_ One of the block numbers to retrieve the voting power at. 33 blocks prior to the block at which a proposal is available for voting. + * @param voteStartBlock_ The block number the proposal became available for voting. * @return The voting power of the account. */ function _getVotesSinceSnapshot(address account_, uint256 snapshot_, uint256 voteStartBlock_) internal view returns (uint256) { + // calculate the number of votes available at the snapshot block uint256 votes1 = token.getPastVotes(account_, snapshot_); // enable voting weight to be calculated during the voting period's start block voteStartBlock_ = voteStartBlock_ != block.number ? voteStartBlock_ : block.number - 1; + // calculate the number of votes available at the stage's start block uint256 votes2 = token.getPastVotes(account_, voteStartBlock_); return Maths.min(votes2, votes1); @@ -232,21 +272,16 @@ contract GrantFund is IGrantFund, ExtraordinaryFunding, StandardFunding { if (mechanism == FundingMechanism.Standard) { Proposal memory proposal = standardFundingProposals[proposalId_]; QuarterlyDistribution memory currentDistribution = distributions[proposal.distributionId]; - uint256 screeningPeriodEndBlock = currentDistribution.endBlock - 72000; + uint256 screeningStageEndBlock = _getScreeningStageEndBlock(currentDistribution); // screening stage - if (block.number >= currentDistribution.startBlock && block.number <= screeningPeriodEndBlock) { + if (block.number >= currentDistribution.startBlock && block.number <= screeningStageEndBlock) { hasVoted_ = hasVotedScreening[proposal.distributionId][account_]; } // funding stage - else if (block.number > screeningPeriodEndBlock && block.number <= currentDistribution.endBlock) { - QuadraticVoter storage voter = quadraticVoters[currentDistribution.id][account_]; - - // Check if voter has voted - if (uint256(voter.budgetRemaining) < voter.votingWeight) { - hasVoted_ = true; - } + else if (block.number > screeningStageEndBlock && block.number <= currentDistribution.endBlock) { + hasVoted_ = quadraticVoters[currentDistribution.id][account_].votesCast.length != 0; } } else { diff --git a/src/grants/base/ExtraordinaryFunding.sol b/src/grants/base/ExtraordinaryFunding.sol index 4f677f6d..be7fb3e0 100644 --- a/src/grants/base/ExtraordinaryFunding.sol +++ b/src/grants/base/ExtraordinaryFunding.sol @@ -47,12 +47,12 @@ abstract contract ExtraordinaryFunding is Funding, IExtraordinaryFunding { } // check if the proposal has received more votes than minimumThreshold and tokensRequestedPercentage of all tokens - if (proposal.votesReceived >= proposal.tokensRequested + getSliceOfNonTreasury(_getMinimumThresholdPercentage())) { - proposal.succeeded = true; - } else { - proposal.succeeded = false; + if (proposal.votesReceived < proposal.tokensRequested + getSliceOfNonTreasury(_getMinimumThresholdPercentage())) revert ExecuteExtraordinaryProposalInvalid(); - } + proposal.succeeded = true; + + // check tokens requested are available for claiming from the treasury + if (proposal.tokensRequested > getSliceOfTreasury(Maths.WAD - _getMinimumThresholdPercentage())) revert ExtraordinaryFundingProposalInvalid(); fundedExtraordinaryProposals.push(proposal.proposalId); @@ -82,7 +82,7 @@ abstract contract ExtraordinaryFunding is Funding, IExtraordinaryFunding { uint256 totalTokensRequested = _validateCallDatas(targets_, values_, calldatas_); - // check tokens requested is within limits + // check tokens requested are available for claiming from the treasury if (totalTokensRequested > getSliceOfTreasury(Maths.WAD - _getMinimumThresholdPercentage())) revert ExtraordinaryFundingProposalInvalid(); // store newly created proposal diff --git a/src/grants/base/Funding.sol b/src/grants/base/Funding.sol index 34127abf..570c406c 100644 --- a/src/grants/base/Funding.sol +++ b/src/grants/base/Funding.sol @@ -80,6 +80,12 @@ abstract contract Funding is Governor, ReentrancyGuard { */ mapping(uint256 => mapping(address => bool)) internal hasVotedScreening; + /** + * @notice Number of blocks prior to a given voting stage to check an accounts voting power. + * @dev Prevents flashloan attacks or duplicate voting with multiple accounts. + */ + uint256 internal constant VOTING_POWER_SNAPSHOT_DELAY = 33; + /** * @notice Total funds available for Funding Mechanism */ @@ -91,6 +97,7 @@ abstract contract Funding is Governor, ReentrancyGuard { /** * @notice Verifies proposal's targets, values, and calldatas match specifications. + * @dev Counters incremented in an unchecked block due to being bounded by array length. * @param targets_ The addresses of the contracts to call. * @param values_ The amounts of ETH to send to each target. * @param calldatas_ The calldata to send to each target. @@ -128,9 +135,7 @@ abstract contract Funding is Governor, ReentrancyGuard { // update tokens requested for additional calldata tokensRequested_ += tokensRequested; - unchecked { - ++i; - } + unchecked { ++i; } } } } diff --git a/src/grants/base/StandardFunding.sol b/src/grants/base/StandardFunding.sol index 6ae7f594..1bf65b92 100644 --- a/src/grants/base/StandardFunding.sol +++ b/src/grants/base/StandardFunding.sol @@ -27,11 +27,24 @@ abstract contract StandardFunding is Funding, IStandardFunding { */ uint256 internal constant GLOBAL_BUDGET_CONSTRAINT = 0.02 * 1e18; + /** + * @notice Length of the challengephase of the distribution period in blocks. + * @dev Roughly equivalent to the number of blocks in 7 days. + * @dev The period in which funded proposal slates can be checked in checkSlate. + */ + uint256 internal constant CHALLENGE_PERIOD_LENGTH = 50400; + /** * @notice Length of the distribution period in blocks. - * @dev Equivalent to the number of blocks in 90 days. Blocks come every 12 seconds. + * @dev Roughly equivalent to the number of blocks in 90 days. + */ + uint256 internal constant DISTRIBUTION_PERIOD_LENGTH = 648000; + + /** + * @notice Length of the funding phase of the distribution period in blocks. + * @dev Roughly equivalent to the number of blocks in 10 days. */ - uint256 internal constant DISTRIBUTION_PERIOD_LENGTH = 648000; // 90 days + uint256 internal constant FUNDING_PERIOD_LENGTH = 72000; /** * @notice ID of the current distribution period. @@ -60,36 +73,49 @@ abstract contract StandardFunding is Funding, IStandardFunding { mapping(uint256 => uint256[]) internal topTenProposals; /** - * @notice Mapping of quarterly distributions to a hash of a proposal slate to a list of funded proposals. - * @dev distributionId => slate hash => proposalId[] + * @notice Mapping of a hash of a proposal slate to a list of funded proposals. + * @dev slate hash => proposalId[] */ - mapping(uint256 => mapping(bytes32 => uint256[])) internal fundedProposalSlates; + mapping(bytes32 => uint256[]) internal fundedProposalSlates; /** * @notice Mapping of quarterly distributions to voters to a Quadratic Voter info struct. * @dev distributionId => voter address => QuadraticVoter */ - mapping (uint256 => mapping(address => QuadraticVoter)) internal quadraticVoters; + mapping(uint256 => mapping(address => QuadraticVoter)) internal quadraticVoters; /** * @notice Mapping of distributionId to whether surplus funds from distribution updated into treasury * @dev distributionId => bool */ - mapping (uint256 => bool) internal isSurplusFundsUpdated; + mapping(uint256 => bool) internal isSurplusFundsUpdated; /** * @notice Mapping of distributionId to user address to whether user has claimed his delegate reward * @dev distributionId => address => bool */ - mapping (uint256 => mapping (address => bool)) public hasClaimedReward; + mapping(uint256 => mapping (address => bool)) public hasClaimedReward; /*****************************************/ /*** Distribution Management Functions ***/ /*****************************************/ - /// @inheritdoc IStandardFunding - function getSlateHash(uint256[] calldata proposalIds_) external pure returns (bytes32) { - return keccak256(abi.encode(proposalIds_)); + /** + * @notice Get the block number at which this distribution period's challenge stage ends. + * @param distribution The quarterly distribution to get the challenge stage end block for. + * @return The block number at which this distribution period's challenge stage ends. + */ + function _getChallengeStageEndBlock(QuarterlyDistribution memory distribution) internal pure returns (uint256) { + return distribution.endBlock + CHALLENGE_PERIOD_LENGTH; + } + + /** + * @notice Get the block number at which this distribution period's screening stage ends. + * @param distribution The quarterly distribution to get the screening stage end block for. + * @return The block number at which this distribution period's screening stage ends. + */ + function _getScreeningStageEndBlock(QuarterlyDistribution memory distribution) internal pure returns (uint256) { + return distribution.endBlock - FUNDING_PERIOD_LENGTH; } /** @@ -111,16 +137,18 @@ abstract contract StandardFunding is Funding, IStandardFunding { */ function _updateTreasury(uint256 distributionId_) private { QuarterlyDistribution memory currentDistribution = distributions[distributionId_]; - uint256[] memory fundingProposalIds = fundedProposalSlates[distributionId_][currentDistribution.fundedSlateHash]; + uint256[] memory fundingProposalIds = fundedProposalSlates[currentDistribution.fundedSlateHash]; uint256 totalTokensRequested; - for (uint i = 0; i < fundingProposalIds.length; ) { + uint256 numFundedProposals = fundingProposalIds.length; + + for (uint i = 0; i < numFundedProposals; ) { Proposal memory proposal = standardFundingProposals[fundingProposalIds[i]]; totalTokensRequested += proposal.tokensRequested; unchecked { ++i; } } - // update treasury with non distributed tokens + // readd non distributed tokens to the treasury treasury += (currentDistribution.fundsAvailable - totalTokensRequested); isSurplusFundsUpdated[distributionId_] = true; } @@ -129,12 +157,13 @@ abstract contract StandardFunding is Funding, IStandardFunding { function startNewDistributionPeriod() external returns (uint256 newDistributionId_) { // check that there isn't currently an active distribution period uint256 currentDistributionId = distributionIdCheckpoints.latest(); - if (block.number <= distributions[currentDistributionId].endBlock) revert DistributionPeriodStillActive(); + QuarterlyDistribution memory currentDistribution = distributions[currentDistributionId]; + if (block.number <= currentDistribution.endBlock) revert DistributionPeriodStillActive(); // update Treasury with unused funds from last two distributions { - // Check if any last distribution exists and its challenge period is over - if ( currentDistributionId > 0 && (block.number > distributions[currentDistributionId].endBlock + 50400)) { + // Check if any last distribution exists and its challenge stage is over + if ( currentDistributionId > 0 && (block.number > _getChallengeStageEndBlock(currentDistribution))) { // Add unused funds from last distribution to treasury _updateTreasury(currentDistributionId); } @@ -161,25 +190,46 @@ abstract contract StandardFunding is Funding, IStandardFunding { uint256 gbc = Maths.wmul(treasury, GLOBAL_BUDGET_CONSTRAINT); newDistributionPeriod.fundsAvailable = gbc; - // update treasury + // decrease the treasury by the amount that is held for allocation in the new distribution period treasury -= gbc; emit QuarterlyDistributionStarted(newDistributionId_, startBlock, endBlock); } /** - * @notice Calculates the sum of quadratic budgets allocated to a list of proposals. + * @notice Calculates the sum of funding votes allocated to a list of proposals. + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. * @param proposalIdSubset_ Array of proposal Ids to sum. - * @return sum_ The sum of the budget across the given proposals. + * @return sum_ The sum of the funding votes across the given proposals. */ - function _sumBudgetAllocated(uint256[] memory proposalIdSubset_) internal view returns (uint256 sum_) { + function _sumProposalFundingVotes(uint256[] memory proposalIdSubset_) internal view returns (uint256 sum_) { for (uint i = 0; i < proposalIdSubset_.length;) { - sum_ += uint256(standardFundingProposals[proposalIdSubset_[i]].qvBudgetAllocated); + sum_ += uint256(standardFundingProposals[proposalIdSubset_[i]].fundingVotesReceived); + + unchecked { ++i; } + } + } + + /** + * @notice Check an array of proposalIds for duplicate IDs. + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. + * @param proposalIds_ Array of proposal Ids to check. + * @return Boolean indicating the presence of a duplicate. True if it has a duplicate; false if not. + */ + function _hasDuplicates(uint256[] calldata proposalIds_) internal pure returns (bool) { + uint256 numProposals = proposalIds_.length; + for (uint i = 0; i < numProposals; ) { + for (uint j = i + 1; j < numProposals; ) { + if (proposalIds_[i] == proposalIds_[j]) return true; + unchecked { ++j; } - unchecked { - ++i; } + unchecked { ++i; } + } + return false; } /// @inheritdoc IStandardFunding @@ -187,25 +237,29 @@ abstract contract StandardFunding is Funding, IStandardFunding { QuarterlyDistribution storage currentDistribution = distributions[distributionId_]; // check that the function is being called within the challenge period - if (block.number <= currentDistribution.endBlock || block.number > currentDistribution.endBlock + 50400) { + if (block.number <= currentDistribution.endBlock || block.number > _getChallengeStageEndBlock(currentDistribution)) { return false; } + // check that the slate has no duplicates + if (_hasDuplicates(proposalIds_)) return false; + uint256 gbc = currentDistribution.fundsAvailable; uint256 sum = 0; uint256 totalTokensRequested = 0; + uint256 numProposalsInSlate = proposalIds_.length; - for (uint i = 0; i < proposalIds_.length; ) { + for (uint i = 0; i < numProposalsInSlate; ) { // check if Proposal is in the topTenProposals list if (_findProposalIndex(proposalIds_[i], topTenProposals[distributionId_]) == -1) return false; Proposal memory proposal = standardFundingProposals[proposalIds_[i]]; - // account for qvBudgetAllocated possibly being negative - if (proposal.qvBudgetAllocated < 0) return false; + // account for fundingVotesReceived possibly being negative + if (proposal.fundingVotesReceived < 0) return false; // update counters - sum += uint256(proposal.qvBudgetAllocated); + sum += uint256(proposal.fundingVotesReceived); totalTokensRequested += proposal.tokensRequested; // check if slate of proposals exceeded budget constraint ( 90% of GBC ) @@ -213,9 +267,7 @@ abstract contract StandardFunding is Funding, IStandardFunding { return false; } - unchecked { - ++i; - } + unchecked { ++i; } } // get pointers for comparing proposal slates @@ -224,17 +276,16 @@ abstract contract StandardFunding is Funding, IStandardFunding { // check if slate of proposals is new top slate bool newTopSlate = currentSlateHash == 0 || - (currentSlateHash!= 0 && sum > _sumBudgetAllocated(fundedProposalSlates[distributionId_][currentSlateHash])); + (currentSlateHash!= 0 && sum > _sumProposalFundingVotes(fundedProposalSlates[currentSlateHash])); + // if slate of proposals is new top slate, update state if (newTopSlate) { - uint256[] storage existingSlate = fundedProposalSlates[distributionId_][newSlateHash]; - for (uint i = 0; i < proposalIds_.length; ) { + uint256[] storage existingSlate = fundedProposalSlates[newSlateHash]; + for (uint i = 0; i < numProposalsInSlate; ) { // update list of proposals to fund existingSlate.push(proposalIds_[i]); - unchecked { - ++i; - } + unchecked { ++i; } } // update hash to point to the new leading slate of proposals @@ -245,6 +296,30 @@ abstract contract StandardFunding is Funding, IStandardFunding { return newTopSlate; } + /** + * @notice Calculate the delegate rewards that have accrued to a given voter, in a given distribution period. + * @dev Voter must have voted in both the screening and funding stages, and is proportional to their share of votes across the stages. + * @param currentDistribution Struct of the distribution period to calculat rewards for. + * @param voter Struct of the funding stages voter. + * @return rewards_ The delegate rewards accrued to the voter. + */ + function _getDelegateReward(QuarterlyDistribution memory currentDistribution, QuadraticVoter memory voter) internal pure returns (uint256 rewards_) { + // calculate the total voting power available to the voter that was allocated in the funding stage + uint256 votingPowerAllocatedByDelegatee = voter.votingPower - voter.remainingVotingPower; + // if none of the voter's voting power was allocated, they recieve no rewards + if (votingPowerAllocatedByDelegatee == 0) return 0; + + // calculate reward + // delegateeReward = 10 % of GBC distributed as per delegatee Voting power allocated + rewards_ = Maths.wdiv( + Maths.wmul( + currentDistribution.fundsAvailable, + votingPowerAllocatedByDelegatee + ), + currentDistribution.fundingVotePowerCast + ) / 10; + } + /// @inheritdoc IStandardFunding function claimDelegateReward(uint256 distributionId_) external returns(uint256 rewardClaimed_) { // Revert if delegatee didn't vote in screening stage @@ -253,20 +328,15 @@ abstract contract StandardFunding is Funding, IStandardFunding { QuarterlyDistribution memory currentDistribution = distributions[distributionId_]; // Check if Challenge Period is still active - if(block.number < currentDistribution.endBlock + 50400) revert ChallengePeriodNotEnded(); + if(block.number < _getChallengeStageEndBlock(currentDistribution)) revert ChallengePeriodNotEnded(); // check rewards haven't already been claimed if(hasClaimedReward[distributionId_][msg.sender]) revert RewardAlreadyClaimed(); QuadraticVoter memory voter = quadraticVoters[distributionId_][msg.sender]; - // Total number of quadratic votes delegatee has voted - uint256 quadraticVotesUsed = voter.votingWeight - uint256(voter.budgetRemaining); - - uint256 gbc = currentDistribution.fundsAvailable; - - // delegateeReward = 10 % of GBC distributed as per delegatee Vote share - rewardClaimed_ = Maths.wdiv(Maths.wmul(gbc, quadraticVotesUsed), currentDistribution.quadraticVotesCast) / 10; + // calculate rewards earned for voting + rewardClaimed_ = _getDelegateReward(currentDistribution, voter); emit DelegateRewardClaimed(msg.sender, distributionId_, rewardClaimed_); @@ -286,7 +356,7 @@ abstract contract StandardFunding is Funding, IStandardFunding { Proposal memory proposal = standardFundingProposals[proposalId_]; // check that the distribution period has ended, and one week has passed to enable competing slates to be checked - if (block.number <= distributions[proposal.distributionId].endBlock + 50400) revert ExecuteProposalInvalid(); + if (block.number <= _getChallengeStageEndBlock(distributions[proposal.distributionId])) revert ExecuteProposalInvalid(); super.execute(targets_, values_, calldatas_, descriptionHash_); standardFundingProposals[proposalId_].executed = true; @@ -338,48 +408,88 @@ abstract contract StandardFunding is Funding, IStandardFunding { /************************/ /** - * @notice Vote on a proposal in the funding stage of the Distribution Period. - * @dev Votes can be allocated to multiple proposals, quadratically, for or against. - * @param proposal_ The current proposal being voted upon. - * @param account_ The voting account. - * @param voter_ The voter data struct tracking available votes. - * @param budgetAllocation_ The amount of votes being allocated to the proposal. - * @return budgetAllocated_ The amount of votes allocated to the proposal. + * @notice Sum the square of each vote cast by a voter. + * @dev Used to calculate if a voter has enough voting power to cast their votes. + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. + * @param votesCast_ The array of votes cast by a voter. + * @return votesCastSumSquared_ The sum of the square of each vote cast. */ - function _fundingVote(Proposal storage proposal_, address account_, QuadraticVoter storage voter_, int256 budgetAllocation_) internal returns (uint256 budgetAllocated_) { + function _sumSquareOfVotesCast(FundingVoteParams[] memory votesCast_) internal pure returns (uint256 votesCastSumSquared_) { + uint256 numVotesCast = votesCast_.length; + for (uint256 i = 0; i < numVotesCast; ) { + votesCastSumSquared_ += Maths.wpow(uint256(Maths.abs(votesCast_[i].votesUsed)), 2); - uint256 currentDistributionId = distributionIdCheckpoints.latest(); - QuarterlyDistribution storage currentDistribution = distributions[currentDistributionId]; + unchecked { ++i; } + } + } + /** + * @notice Vote on a proposal in the funding stage of the Distribution Period. + * @dev Votes can be allocated to multiple proposals, quadratically, for or against. + * @param currentDistribution_ The current distribution period. + * @param proposal_ The current proposal being voted upon. + * @param account_ The voting account. + * @param voter_ The voter data struct tracking available votes. + * @param voteParams_ The amount of votes being allocated to the proposal. Not squared. If less than 0, vote is against. + * @return incrementalVotesUsed_ The amount of funding stage votes allocated to the proposal. + */ + function _fundingVote(QuarterlyDistribution storage currentDistribution_, Proposal storage proposal_, address account_, QuadraticVoter storage voter_, FundingVoteParams memory voteParams_) internal returns (uint256 incrementalVotesUsed_) { uint8 support = 1; uint256 proposalId = proposal_.proposalId; - // case where voter is voting against the proposal - if (budgetAllocation_ < 0) { - support = 0; + // determine if voter is voting for or against the proposal + voteParams_.votesUsed < 0 ? support = 0 : support = 1; + + // the total amount of voting power used by the voter before this vote executes + uint256 voterPowerUsedPreVote = voter_.votingPower - voter_.remainingVotingPower; + + // check that the voter hasn't already voted on a proposal by seeing if it's already in the votesCast array + int256 voteCastIndex = _findProposalIndexOfVotesCast(proposalId, voter_.votesCast); + if (voteCastIndex != -1) { + FundingVoteParams storage existingVote = voter_.votesCast[uint256(voteCastIndex)]; - // update voter budget remaining - voter_.budgetRemaining += budgetAllocation_; + // can't change the direction of a previous vote + if (support == 0 && existingVote.votesUsed > 0 || support == 1 && existingVote.votesUsed < 0) { + // if the vote is in the opposite direction of a previous vote, + // and the proposal is already in the votesCast array, revert can't change direction + revert FundingVoteInvalid(); + } + else { + // update the votes cast for the proposal + existingVote.votesUsed += voteParams_.votesUsed; + } } - // voter is voting in support of the proposal + // add the newly cast vote to the voter's votesCast array else { - // update voter budget remaining - voter_.budgetRemaining -= budgetAllocation_; + voter_.votesCast.push(voteParams_); } - // update total vote cast - currentDistribution.quadraticVotesCast += uint256(Maths.abs(budgetAllocation_)); + + // calculate the cumulative cost of all votes made by the voter + uint256 cumulativeVotePowerUsed = _sumSquareOfVotesCast(voter_.votesCast); + + // check that the voter has enough voting power remaining to cast the vote + if (cumulativeVotePowerUsed > voter_.votingPower) revert InsufficientVotingPower(); + + // update voter voting power accumulator + voter_.remainingVotingPower = voter_.votingPower - cumulativeVotePowerUsed; + + // calculate the change in voting power used by the voter in this vote in order to accurately track the total voting power used in the funding stage + uint256 incrementalVotingPowerUsed = cumulativeVotePowerUsed - voterPowerUsedPreVote; + + // update accumulator for total voting power used in the funding stage in order to calculate delegate rewards + currentDistribution_.fundingVotePowerCast += incrementalVotingPowerUsed; // update proposal vote tracking - proposal_.qvBudgetAllocated += budgetAllocation_; + proposal_.fundingVotesReceived += voteParams_.votesUsed; - // update top ten proposals - uint256[] memory topTen = topTenProposals[proposal_.distributionId]; - uint256 proposalIndex = uint256(_findProposalIndex(proposalId, topTen)); - standardFundingProposals[topTen[proposalIndex]].qvBudgetAllocated = proposal_.qvBudgetAllocated; + // the incremental additional votes cast on the proposal + // used as a return value and emit value + incrementalVotesUsed_ = uint256(Maths.abs(voteParams_.votesUsed)); // emit VoteCast instead of VoteCastWithParams to maintain compatibility with Tally - budgetAllocated_ = uint256(Maths.abs(budgetAllocation_)); - emit VoteCast(account_, proposalId, support, budgetAllocated_, ""); + // emits the amount of incremental votes cast for the proposal, not the voting power cost or total votes on a proposal + emit VoteCast(account_, proposalId, support, incrementalVotesUsed_, ""); } /** @@ -442,13 +552,21 @@ abstract contract StandardFunding is Funding, IStandardFunding { function _standardFundingVoteSucceeded(uint256 proposalId_) internal view returns (bool) { Proposal memory proposal = standardFundingProposals[proposalId_]; uint256 distributionId = proposal.distributionId; - return _findProposalIndex(proposalId_, fundedProposalSlates[distributionId][distributions[distributionId].fundedSlateHash]) != -1; + return _findProposalIndex(proposalId_, fundedProposalSlates[distributions[distributionId].fundedSlateHash]) != -1; } /**************************/ /*** External Functions ***/ /**************************/ + /// @inheritdoc IStandardFunding + function getDelegateReward(uint256 distributionId_, address voter_) external view returns (uint256 rewards_) { + QuarterlyDistribution memory currentDistribution = distributions[distributionId_]; + QuadraticVoter memory voter = quadraticVoters[distributionId_][voter_]; + + rewards_ = _getDelegateReward(currentDistribution, voter); + } + /// @inheritdoc IStandardFunding function getDistributionIdAtBlock(uint256 blockNumber) external view returns (uint256) { return distributionIdCheckpoints.getAtBlock(blockNumber); @@ -464,7 +582,7 @@ abstract contract StandardFunding is Funding, IStandardFunding { QuarterlyDistribution memory distribution = distributions[distributionId_]; return ( distribution.id, - distribution.quadraticVotesCast, + distribution.fundingVotePowerCast, distribution.startBlock, distribution.endBlock, distribution.fundsAvailable, @@ -473,8 +591,18 @@ abstract contract StandardFunding is Funding, IStandardFunding { } /// @inheritdoc IStandardFunding - function getFundedProposalSlate(uint256 distributionId_, bytes32 slateHash_) external view returns (uint256[] memory) { - return fundedProposalSlates[distributionId_][slateHash_]; + function getFundedProposalSlate(bytes32 slateHash_) external view returns (uint256[] memory) { + return fundedProposalSlates[slateHash_]; + } + + /// @inheritdoc IStandardFunding + function getFundingPowerVotes(uint256 votingPower) external pure returns (uint256) { + return Maths.wsqrt(votingPower); + } + + /// @inheritdoc IStandardFunding + function getSlateHash(uint256[] calldata proposalIds_) external pure returns (bytes32) { + return keccak256(abi.encode(proposalIds_)); } /// @inheritdoc IStandardFunding @@ -485,7 +613,7 @@ abstract contract StandardFunding is Funding, IStandardFunding { proposal.distributionId, proposal.votesReceived, proposal.tokensRequested, - proposal.qvBudgetAllocated, + proposal.fundingVotesReceived, proposal.executed ); } @@ -496,11 +624,12 @@ abstract contract StandardFunding is Funding, IStandardFunding { } /// @inheritdoc IStandardFunding - function getVoterInfo(uint256 distributionId_, address account_) external view returns (uint256, int256) { + function getVoterInfo(uint256 distributionId_, address account_) external view returns (uint256, uint256, uint256) { QuadraticVoter memory voter = quadraticVoters[distributionId_][account_]; return ( - voter.votingWeight, - voter.budgetRemaining + voter.votingPower, + voter.remainingVotingPower, + voter.votesCast.length ); } @@ -515,21 +644,47 @@ abstract contract StandardFunding is Funding, IStandardFunding { /** * @notice Identify where in an array of proposalIds the proposal exists. + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. + * @param proposalId The proposalId to search for. + * @param array The array of proposalIds to search. * @return index_ The index of the proposalId in the array, else -1. */ function _findProposalIndex(uint256 proposalId, uint256[] memory array) internal pure returns (int256 index_) { index_ = -1; // default value indicating proposalId not in the array + int256 arrayLength = int256(array.length); - for (int256 i = 0; i < int256(array.length);) { + for (int256 i = 0; i < arrayLength;) { //slither-disable-next-line incorrect-equality if (array[uint256(i)] == proposalId) { index_ = i; break; } - unchecked { - ++i; + unchecked { ++i; } + } + } + + /** + * @notice Identify where in an array of FundingVoteParams structs the proposal exists. + * @dev Only iterates through a maximum of 10 proposals that made it through the screening round. + * @dev Counters incremented in an unchecked block due to being bounded by array length. + * @param proposalId_ The proposalId to search for. + * @param voteParams_ The array of FundingVoteParams structs to search. + * @return index_ The index of the proposalId in the array, else -1. + */ + function _findProposalIndexOfVotesCast(uint256 proposalId_, FundingVoteParams[] memory voteParams_) internal pure returns (int256 index_) { + index_ = -1; // default value indicating proposalId not in the array + + int256 numVotesCast = int256(voteParams_.length); + for (int256 i = 0; i < numVotesCast; ) { + //slither-disable-next-line incorrect-equality + if (voteParams_[uint256(i)].proposalId == proposalId_) { + index_ = i; + break; } + + unchecked { ++i; } } } @@ -538,7 +693,9 @@ abstract contract StandardFunding is Funding, IStandardFunding { * @dev Implements the descending insertion sort algorithm. */ function _insertionSortProposalsByVotes(uint256[] storage arr) internal { - for (int i = 1; i < int(arr.length); i++) { + int256 arrayLength = int256(arr.length); + + for (int i = 1; i < arrayLength; i++) { Proposal memory key = standardFundingProposals[arr[uint(i)]]; int j = i; diff --git a/src/grants/interfaces/IStandardFunding.sol b/src/grants/interfaces/IStandardFunding.sol index 08b99c29..944b5cd7 100644 --- a/src/grants/interfaces/IStandardFunding.sol +++ b/src/grants/interfaces/IStandardFunding.sol @@ -28,9 +28,14 @@ interface IStandardFunding { error FinalizeDistributionInvalid(); /** - * @notice User attempted to vote with more qvBudget than was available to them. + * @notice User attempted to change the direction of a subsequent funding vote on the same proposal. */ - error InsufficientBudget(); + error FundingVoteInvalid(); + + /** + * @notice User attempted to vote with more voting power than was available to them. + */ + error InsufficientVotingPower(); /** * @notice Delegatee attempted to claim delegate reward before the challenge period ended. @@ -87,32 +92,41 @@ interface IStandardFunding { * @notice Contains proposals that made it through the screening process to the funding stage. */ struct QuarterlyDistribution { - uint256 id; // id of the current quarterly distribution - uint256 quadraticVotesCast; // total number of votes cast in funding stage that quarter - uint256 startBlock; // block number of the quarterly distributions start - uint256 endBlock; // block number of the quarterly distributions end - uint256 fundsAvailable; // maximum fund (including delegate reward) that can be taken out that quarter - bytes32 fundedSlateHash; // hash of list of proposals to fund + uint256 id; // id of the current quarterly distribution + uint256 fundingVotePowerCast; // total number of voting power allocated in funding stage that quarter + uint256 startBlock; // block number of the quarterly distributions start + uint256 endBlock; // block number of the quarterly distributions end + uint256 fundsAvailable; // maximum fund (including delegate reward) that can be taken out that quarter + bytes32 fundedSlateHash; // hash of list of proposals to fund } /** * @notice Contains information about proposals in a distribution period. */ struct Proposal { - uint256 proposalId; // OZ.Governor proposalId. Hash of proposeStandard inputs - uint256 distributionId; // Id of the distribution period in which the proposal was made - uint256 votesReceived; // accumulator of screening votes received by a proposal - uint256 tokensRequested; // number of Ajna tokens requested in the proposal - int256 qvBudgetAllocated; // accumulator of QV budget allocated - bool executed; // whether the proposal has been executed + uint256 proposalId; // OZ.Governor proposalId. Hash of proposeStandard inputs + uint256 distributionId; // Id of the distribution period in which the proposal was made + uint256 votesReceived; // accumulator of screening votes received by a proposal + uint256 tokensRequested; // number of Ajna tokens requested in the proposal + int256 fundingVotesReceived; // accumulator of funding votes allocated to the proposal. + bool executed; // whether the proposal has been executed + } + + /** + * @notice Contains information about voters during a vote made by a QuadraticVoter in the Funding stage of a distribution period. + */ + struct FundingVoteParams { + uint256 proposalId; + int256 votesUsed; } /** * @notice Contains information about voters during a distribution period's funding stage. */ struct QuadraticVoter { - uint256 votingWeight; // amount of votes originally available to the voter - int256 budgetRemaining; // remaining voting budget in the given period + uint256 votingPower; // amount of votes originally available to the voter, equal to the sum of the square of their initial votes + uint256 remainingVotingPower; // remaining voting power in the given period + FundingVoteParams[] votesCast; // array of votes cast by the voter } /*****************************************/ @@ -180,6 +194,14 @@ interface IStandardFunding { /*** View Functions ***/ /**********************/ + /** + * @notice Retrieve the delegate reward accrued to a voter in a given distribution period. + * @param distributionId_ The distributionId to calculate rewards for. + * @param voter_ The address of the voter to calculate rewards for. + * @return rewards_ The rewards earned by the voter for voting in that distribution period. + */ + function getDelegateReward(uint256 distributionId_, address voter_) external view returns (uint256 rewards_); + /** * @notice Retrieve the QuarterlyDistribution distributionId at a given block. * @param blockNumber The block number to check. @@ -197,7 +219,7 @@ interface IStandardFunding { * @notice Mapping of distributionId to {QuarterlyDistribution} struct. * @param distributionId_ The distributionId to retrieve the QuarterlyDistribution struct for. * @return distributionId The retrieved struct's distributionId. - * @return quadraticVotesCast The total number of votes cast in the distribution period's funding round. + * @return fundingVotesCast The total number of votes cast in the distribution period's funding round. * @return startBlock The block number of the distribution period's start. * @return endBlock The block number of the distribution period's end. * @return fundsAvailable The maximum amount of funds that can be taken out of the distribution period. @@ -207,11 +229,19 @@ interface IStandardFunding { /** * @notice Get the funded proposal slate for a given distributionId, and slate hash - * @param distributionId_ The distributionId of the distribution period to check. * @param slateHash_ The slateHash to retrieve the funded proposals from. * @return The array of proposalIds that are in the funded slate hash. */ - function getFundedProposalSlate(uint256 distributionId_, bytes32 slateHash_) external view returns (uint256[] memory); + function getFundedProposalSlate(bytes32 slateHash_) external view returns (uint256[] memory); + + /** + * @notice Get the number of discrete votes that can be cast on proposals given a specified voting power. + * @dev This is calculated by taking the square root of the voting power, and adjusting for WAD decimals. + * @dev This approach results in precision loss, and prospective users should be careful. + * @param votingPower The provided voting power to calculate discrete votes for. + * @return The square root of the votingPower as a WAD. + */ + function getFundingPowerVotes(uint256 votingPower) external pure returns (uint256); /** * @notice Mapping of proposalIds to {Proposal} structs. @@ -238,10 +268,11 @@ interface IStandardFunding { * @notice Get the current state of a given voter in the funding stage. * @param distributionId_ The distributionId of the distribution period to check. * @param account_ The address of the voter to check. - * @return votingWeight The voter's voting weight in the funding round. Equal to the square of their tokens in the voting snapshot. - * @return budgetRemaining The voter's remaining quadratic vote budget in the given distribution period's funding round. + * @return votingPower The voter's voting power in the funding round. Equal to the square of their tokens in the voting snapshot. + * @return remainingVotingPower The voter's remaining quadratic voting power in the given distribution period's funding round. + * @return votesCast The voter's number of proposals voted on in the funding stage. */ - function getVoterInfo(uint256 distributionId_, address account_) external view returns (uint256, int256); + function getVoterInfo(uint256 distributionId_, address account_) external view returns (uint256, uint256, uint256); /** * @notice Get the current maximum possible distribution of Ajna tokens that will be released from the treasury this quarter. diff --git a/src/grants/libraries/Maths.sol b/src/grants/libraries/Maths.sol index 8bbe8a07..6da39e9e 100644 --- a/src/grants/libraries/Maths.sol +++ b/src/grants/libraries/Maths.sol @@ -9,6 +9,27 @@ library Maths { 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; } @@ -37,4 +58,5 @@ library Maths { } } } + } diff --git a/src/token/AjnaToken.sol b/src/token/AjnaToken.sol index 739b5eba..c4612a1f 100644 --- a/src/token/AjnaToken.sol +++ b/src/token/AjnaToken.sol @@ -44,7 +44,6 @@ contract AjnaToken is ERC20, ERC20Burnable, ERC20Permit, ERC20Votes { * @notice Called by an owner of AJNA tokens to enable their tokens to be transferred by a spender address without making a seperate permit call * @param from_ The address of the current owner of the tokens * @param to_ The address of the new owner of the tokens - * @param spender_ The address of the third party who will execute the transaction involving an owners tokens * @param value_ The amount of tokens to transfer * @param deadline_ The unix timestamp by which the permit must be called * @param v_ Component of secp256k1 signature @@ -52,9 +51,9 @@ contract AjnaToken is ERC20, ERC20Burnable, ERC20Permit, ERC20Votes { * @param s_ Component of secp256k1 signature */ function transferFromWithPermit( - address from_, address to_, address spender_, uint256 value_, uint256 deadline_, uint8 v_, bytes32 r_, bytes32 s_ + address from_, address to_, uint256 value_, uint256 deadline_, uint8 v_, bytes32 r_, bytes32 s_ ) external { - permit(from_, spender_, value_, deadline_, v_, r_, s_); + permit(from_, msg.sender, value_, deadline_, v_, r_, s_); transferFrom(from_, to_, value_); } } diff --git a/test/AjnaToken.t.sol b/test/AjnaToken.t.sol index a403e668..2113f12c 100644 --- a/test/AjnaToken.t.sol +++ b/test/AjnaToken.t.sol @@ -133,7 +133,7 @@ contract AjnaTokenTest is Test { digest = _sigUtils.getTypedDataHash(permit); (v, r, s) = vm.sign(ownerPrivateKey, digest); - _token.transferFromWithPermit(owner, newOwner, spender, amount_, permit.deadline, v, r, s); + _token.transferFromWithPermit(owner, newOwner, amount_, permit.deadline, v, r, s); // check owner and spender balances after 2nd transfer with permit assertEq(_token.balanceOf(owner), 0); assertEq(_token.balanceOf(spender), 0); @@ -153,7 +153,7 @@ contract AjnaTokenTest is Test { (v, r, s) = vm.sign(ownerPrivateKey, digest); vm.expectRevert("ERC20: transfer amount exceeds balance"); - _token.transferFromWithPermit(owner, newOwner, spender, 1, permit.deadline, v, r, s); + _token.transferFromWithPermit(owner, newOwner, 1, permit.deadline, v, r, s); } /*********************/ diff --git a/test/BurnWrappedToken.t.sol b/test/BurnWrappedToken.t.sol index 9caf9c09..2a64700b 100644 --- a/test/BurnWrappedToken.t.sol +++ b/test/BurnWrappedToken.t.sol @@ -16,14 +16,17 @@ contract BurnWrappedTokenTest is Test { address internal _ajnaAddress = 0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079; // mainnet ajna token address address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; // mainnet token deployer address internal _tokenHolder = makeAddr("_tokenHolder"); - uint256 _initialAjnaTokenSupply = 2_000_000_000 * 1e18; + uint256 _initialAjnaTokenSupply = 1_000_000_000 * 1e18; event Transfer(address indexed from, address indexed to, uint256 value); event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); function setUp() external { + // create mainnet fork vm.createSelectFork(vm.envString("ETH_RPC_URL")); + // set fork block to block before token distribution + vm.rollFork(16527772); // reference mainnet deployment _token = AjnaToken(_ajnaAddress); @@ -65,7 +68,7 @@ contract BurnWrappedTokenTest is Test { assertEq(_wrappedToken.balanceOf(address(_tokenDeployer)), 0); // check initial token supply - assertEq(_token.totalSupply(), 2_000_000_000 * 10 ** _token.decimals()); + assertEq(_token.totalSupply(), 1_000_000_000 * 10 ** _token.decimals()); assertEq(_wrappedToken.totalSupply(), 0); // transfer some tokens to the test address @@ -89,7 +92,7 @@ contract BurnWrappedTokenTest is Test { assertEq(_wrappedToken.balanceOf(address(_tokenDeployer)), 0); // check token supply after wrapping - assertEq(_token.totalSupply(), 2_000_000_000 * 10 ** _token.decimals()); + assertEq(_token.totalSupply(), 1_000_000_000 * 10 ** _token.decimals()); assertEq(_wrappedToken.totalSupply(), tokensToWrap); } diff --git a/test/ExtraordinaryFunding.t.sol b/test/ExtraordinaryFunding.t.sol index 7337652f..2e5eea2d 100644 --- a/test/ExtraordinaryFunding.t.sol +++ b/test/ExtraordinaryFunding.t.sol @@ -521,8 +521,8 @@ contract ExtraordinaryFundingGrantFundTest is GrantFundTestHelper { // set token required to be 10% of treasury uint256 tokenRequested = 500_000_000 * 1e18 / 10; - // create and submit N Extra Ordinary proposals - TestProposalExtraordinary[] memory testProposal = _getNExtraOridinaryProposals(noOfProposals, _grantFund, _tokenHolder1, _token, tokenRequested); + // create and submit N Extraordinary proposals + TestProposalExtraordinary[] memory testProposal = _getNExtraOridinaryProposals(noOfProposals, _grantFund, _tokenHolder1, _token, tokenRequested); // each tokenHolder(fixed in setup) votes on all proposals for(uint i = 0; i < _votersArr.length; i++) { @@ -542,14 +542,26 @@ contract ExtraordinaryFundingGrantFundTest is GrantFundTestHelper { // execute all proposals for(uint i = 0; i < noOfProposals; i++) { - /* first 7 proposals are executed successfully and from 8th proposal each one will fail - as non-treasury amount and minimum threshold increases with each proposal execution */ - if (i >= 7) { - // check state has been marked as Defeated - assertEq(uint8(_grantFund.state(testProposal[i].proposalId)), uint8(IGovernor.ProposalState.Defeated)); - - vm.expectRevert(IExtraordinaryFunding.ExecuteExtraordinaryProposalInvalid.selector); - _grantFund.executeExtraordinary(testProposal[i].targets, testProposal[i].values, testProposal[i].calldatas, keccak256(bytes(testProposal[i].description))); + /* first 5 proposals are executed successfully and from 6th proposal each one will fail + as non-treasury amount and minimum threshold increases with each proposal execution, + and the tokens available in the treasury decrease. + */ + if (i >= 6) { + // check that proposals which have enough votes won't pass if they requested too many tokens from the treasury + (, uint256 tokensRequested, , , uint256 votesReceived, , ) = _grantFund.getExtraordinaryProposalInfo(testProposal[i].proposalId); + + if (votesReceived >= tokensRequested + _grantFund.getSliceOfNonTreasury(_grantFund.getMinimumThresholdPercentage())) { + vm.expectRevert(IExtraordinaryFunding.ExtraordinaryFundingProposalInvalid.selector); + _grantFund.executeExtraordinary(testProposal[i].targets, testProposal[i].values, testProposal[i].calldatas, keccak256(bytes(testProposal[i].description))); + continue; + } + else { + // check state has been marked as Defeated + assertEq(uint8(_grantFund.state(testProposal[i].proposalId)), uint8(IGovernor.ProposalState.Defeated)); + + vm.expectRevert(IExtraordinaryFunding.ExecuteExtraordinaryProposalInvalid.selector); + _grantFund.executeExtraordinary(testProposal[i].targets, testProposal[i].values, testProposal[i].calldatas, keccak256(bytes(testProposal[i].description))); + } } else { _executeExtraordinaryProposal(_grantFund, _token, testProposal[i]); diff --git a/test/Maths.t.sol b/test/Maths.t.sol index dba29f25..6e76af33 100644 --- a/test/Maths.t.sol +++ b/test/Maths.t.sol @@ -53,6 +53,12 @@ contract MathsTest is Test { function testScaleConversions() external { assertEq(Maths.wad(153), 153 * 1e18); - } + } + + function testSqrt() external { + assertEq(Maths.wsqrt(Maths.wad(100)), 10 * 1e18); + assertEq(Maths.wsqrt(Maths.wad(25)), 5 * 1e18); + assertEq(Maths.wsqrt(11_000.143012091382543917 * 1e18), 104.881566598000000000 * 1e18); + } } diff --git a/test/StandardFunding.t.sol b/test/StandardFunding.t.sol index acb17233..d4709666 100644 --- a/test/StandardFunding.t.sol +++ b/test/StandardFunding.t.sol @@ -68,6 +68,9 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { uint256[] internal potentialProposalsSlate; uint256 treasury = 500_000_000 * 1e18; + // declare this to avoid stack too deep in end to end test + uint256 delegateRewards = 0; + function setUp() external { vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock); @@ -167,7 +170,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { vm.roll(_startBlock + 600_000); // check initial voting power - uint256 votingPower = _grantFund.getVotesWithParams(_tokenHolder1, block.number, "Funding"); + uint256 votingPower = _getFundingVotes(_grantFund, _tokenHolder1); assertEq(votingPower, 2_500_000_000_000_000 * 1e18); // check voting power won't change with token transfer to an address that didn't make it into the snapshot @@ -175,16 +178,29 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { changePrank(_tokenHolder1); _token.transfer(nonVotingAddress, 10_000_000 * 1e18); - votingPower = _grantFund.getVotesWithParams(nonVotingAddress, block.number, "Funding"); + votingPower = _getFundingVotes(_grantFund, nonVotingAddress); assertEq(votingPower, 0); - votingPower = _grantFund.getVotesWithParams(_tokenHolder1, block.number, "Funding"); + votingPower = _getFundingVotes(_grantFund, _tokenHolder1); assertEq(votingPower, 2_500_000_000_000_000 * 1e18); + assertEq(_grantFund.getFundingPowerVotes(2_500_000_000_000_000 * 1e18), 50_000_000 * 1e18); + + // incremental votes that will be added to proposals accumulator is the sqrt of the voter's voting power + _fundingVote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 25_000_000 * 1e18); - _fundingVote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 500_000_000_000_000 * 1e18); - // voting power reduced when voted in funding stage - votingPower = _grantFund.getVotesWithParams(_tokenHolder1, block.number, "Funding"); - assertEq(votingPower, 2_000_000_000_000_000 * 1e18); + votingPower = _getFundingVotes(_grantFund, _tokenHolder1); + assertEq(votingPower, 1_875_000_000_000_000 * 1e18); + assertEq(_grantFund.getFundingPowerVotes(625_000_000_000_000 * 1e18), 25_000_000 * 1e18); + + // check that additional votes on the same proposal will calculate an accumulated square + _fundingVote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 10_000_000 * 1e18); + votingPower = _getFundingVotes(_grantFund, _tokenHolder1); + assertEq(votingPower, 1_275_000_000_000_000 * 1e18); + assertEq(_grantFund.getFundingPowerVotes(1_225_000_000_000_000 * 1e18), 35_000_000 * 1e18); + + // check revert if additional votes exceed the budget + vm.expectRevert(IStandardFunding.InsufficientVotingPower.selector); + _grantFund.castVoteWithReasonAndParams(proposal.proposalId, 1, "", abi.encode(16_000_000 * 1e18)); } function testPropose() external { @@ -373,7 +389,8 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { hasVoted = _grantFund.hasVoted(testProposals[1].proposalId, _tokenHolder1); assertFalse(hasVoted); - _fundingVote(_grantFund, _tokenHolder1, testProposals[1].proposalId, voteYes, 500_000_000_000_000 * 1e18); + // voter allocates all of their voting power in support of the proposal + _fundingVote(_grantFund, _tokenHolder1, testProposals[1].proposalId, voteYes, 50_000_000 * 1e18); // check if user vote is updated after voting in funding stage hasVoted = _grantFund.hasVoted(testProposals[1].proposalId, _tokenHolder1); assertTrue(hasVoted); @@ -497,7 +514,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { * @dev Maximum quarterly distribution is 10_000_000. * @dev Funded slate is executed. * @dev Reverts: - * - IStandardFunding.InsufficientBudget + * - IStandardFunding.InsufficientVotingPower * - IStandardFunding.ExecuteProposalInvalid * - "Governor: proposal not successful" */ @@ -543,6 +560,10 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { _vote(_grantFund, _tokenHolder9, testProposals[4].proposalId, voteYes, 100); _vote(_grantFund, _tokenHolder10, testProposals[5].proposalId, voteYes, 100); + /*********************/ + /*** Funding Stage ***/ + /*********************/ + // skip time to move from screening period to funding period vm.roll(_startBlock + 600_000); @@ -563,48 +584,104 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertEq(screenedProposals[5].votesReceived, 50_000_000 * 1e18); // funding period votes for two competing slates, 1, or 2 and 3 - _fundingVote(_grantFund, _tokenHolder1, screenedProposals[0].proposalId, voteYes, 2_500_000_000_000_000 * 1e18); - screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - _fundingVote(_grantFund, _tokenHolder2, screenedProposals[1].proposalId, voteYes, 2_500_000_000_000_000 * 1e18); + _fundingVote(_grantFund, _tokenHolder1, screenedProposals[0].proposalId, voteYes, 50_000_000 * 1e18); screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - _fundingVote(_grantFund, _tokenHolder3, screenedProposals[2].proposalId, voteYes, 1_250_000_000_000_000 * 1e18); + _fundingVote(_grantFund, _tokenHolder2, screenedProposals[1].proposalId, voteYes, 50_000_000 * 1e18); screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - _fundingVote(_grantFund, _tokenHolder3, screenedProposals[4].proposalId, voteYes, 1_250_000_000_000_000 * 1e18); - screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - _fundingVote(_grantFund, _tokenHolder4, screenedProposals[3].proposalId, voteYes, 2_000_000_000_000_000 * 1e18); - screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - _fundingVote(_grantFund, _tokenHolder4, screenedProposals[5].proposalId, voteNo, -500_000_000_000_000 * 1e18); + + // tokenholder 3 votes on all proposals in one transactions + IStandardFunding.FundingVoteParams[] memory fundingVoteParams = new IStandardFunding.FundingVoteParams[](6); + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[0].proposalId, + votesUsed: 21_000_000 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[1].proposalId, + votesUsed: 21_000_000 * 1e18 + }); + fundingVoteParams[2] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[2].proposalId, + votesUsed: 21_000_000 * 1e18 + }); + fundingVoteParams[3] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[3].proposalId, + votesUsed: 21_000_000 * 1e18 + }); + fundingVoteParams[4] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[4].proposalId, + votesUsed: 21_000_000 * 1e18 + }); + fundingVoteParams[5] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[5].proposalId, + votesUsed: -10_000_000 * 1e18 + }); + _fundingVoteMulti(_grantFund, fundingVoteParams, _tokenHolder3); screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); - vm.expectRevert(IStandardFunding.InsufficientBudget.selector); - _grantFund.castVoteWithReasonAndParams(screenedProposals[3].proposalId, 1, "", abi.encode(2_500_000_000_000_000 * 1e18)); + // tokenholder4 votes on two proposals in one transaction, but tries to use more than their available budget + fundingVoteParams = new IStandardFunding.FundingVoteParams[](2); + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[3].proposalId, + votesUsed: 40_000_000 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[5].proposalId, + votesUsed: -40_000_000 * 1e18 + }); + changePrank(_tokenHolder4); + vm.expectRevert(IStandardFunding.InsufficientVotingPower.selector); + _grantFund.fundingVotesMulti(fundingVoteParams); + + // tokenholder4 divides their full votingpower into two proposals in one transaction + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[3].proposalId, + votesUsed: 40_000_000 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: screenedProposals[5].proposalId, + votesUsed: -30_000_000 * 1e18 + }); + _fundingVoteMulti(_grantFund, fundingVoteParams, _tokenHolder4); + screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); changePrank(_tokenHolder5); - vm.expectRevert(IStandardFunding.InsufficientBudget.selector); - _grantFund.castVoteWithReasonAndParams(screenedProposals[3].proposalId, 0, "", abi.encode(-2_600_000_000_000_000 * 1e18)); + vm.expectRevert(IStandardFunding.InsufficientVotingPower.selector); + _grantFund.castVoteWithReasonAndParams(screenedProposals[3].proposalId, voteNo, "", abi.encode(-2_600_000_000_000_000 * 1e18)); - // check tokerHolder partial vote budget calculations - _fundingVote(_grantFund, _tokenHolder5, screenedProposals[5].proposalId, voteNo, -500_000_000_000_000 * 1e18); + // check tokenHolder5 partial vote budget calculations + _fundingVote(_grantFund, _tokenHolder5, screenedProposals[5].proposalId, voteNo, -45_000_000 * 1e18); screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); + // should revert if tokenHolder5 attempts to change the direction of a vote + changePrank(_tokenHolder5); + vm.expectRevert(IStandardFunding.FundingVoteInvalid.selector); + _grantFund.castVoteWithReasonAndParams(screenedProposals[5].proposalId, voteYes, "", abi.encode(5_000_000 * 1e18)); + // check remaining votes available to the above token holders - (uint256 voterWeight, int256 budgetRemaining) = _grantFund.getVoterInfo(distributionId, _tokenHolder1); - assertEq(voterWeight, 2_500_000_000_000_000 * 1e18); - assertEq(budgetRemaining, 0); - (voterWeight, budgetRemaining) = _grantFund.getVoterInfo(distributionId, _tokenHolder2); - assertEq(voterWeight, 2_500_000_000_000_000 * 1e18); - assertEq(budgetRemaining, 0); - (voterWeight, budgetRemaining) = _grantFund.getVoterInfo(distributionId, _tokenHolder3); - assertEq(voterWeight, 2_500_000_000_000_000 * 1e18); - assertEq(budgetRemaining, 0); - (voterWeight, budgetRemaining) = _grantFund.getVoterInfo(distributionId, _tokenHolder4); - assertEq(voterWeight, 2_500_000_000_000_000 * 1e18); - assertEq(budgetRemaining, 0); - assertEq(uint256(budgetRemaining), _grantFund.getVotesWithParams(_tokenHolder4, block.number, bytes("Funding"))); - (voterWeight, budgetRemaining) = _grantFund.getVoterInfo(distributionId, _tokenHolder5); - assertEq(voterWeight, 2_500_000_000_000_000 * 1e18); - assertEq(budgetRemaining, 2_000_000_000_000_000 * 1e18); - assertEq(uint256(budgetRemaining), _grantFund.getVotesWithParams(_tokenHolder5, block.number, bytes("Funding"))); + (uint256 voterPower, uint256 votingPowerRemaining, uint256 votesCast) = _grantFund.getVoterInfo(distributionId, _tokenHolder1); + assertEq(voterPower, 2_500_000_000_000_000 * 1e18); + assertEq(votingPowerRemaining, 0); + assertEq(votesCast, 1); + (voterPower, votingPowerRemaining, votesCast) = _grantFund.getVoterInfo(distributionId, _tokenHolder2); + assertEq(voterPower, 2_500_000_000_000_000 * 1e18); + assertEq(votingPowerRemaining, 0); + assertEq(votesCast, 1); + (voterPower, votingPowerRemaining, votesCast) = _grantFund.getVoterInfo(distributionId, _tokenHolder3); + assertEq(voterPower, 2_500_000_000_000_000 * 1e18); + assertEq(votingPowerRemaining, 195_000_000_000_000 * 1e18); + assertEq(votesCast, 6); + (voterPower, votingPowerRemaining, votesCast) = _grantFund.getVoterInfo(distributionId, _tokenHolder4); + assertEq(voterPower, 2_500_000_000_000_000 * 1e18); + assertEq(votingPowerRemaining, 0); + assertEq(uint256(votingPowerRemaining), _grantFund.getVotesWithParams(_tokenHolder4, block.number, bytes("Funding"))); + assertEq(votesCast, 2); + (voterPower, votingPowerRemaining, votesCast) = _grantFund.getVoterInfo(distributionId, _tokenHolder5); + assertEq(voterPower, 2_500_000_000_000_000 * 1e18); + assertEq(votingPowerRemaining, 475_000_000_000_000 * 1e18); + assertEq(uint256(votingPowerRemaining), _grantFund.getVotesWithParams(_tokenHolder5, block.number, bytes("Funding"))); + assertEq(votesCast, 1); + + assertEq(_grantFund.getFundingPowerVotes(2_500_000_000_000_000 * 1e18), 50_000_000 * 1e18); uint256[] memory potentialProposalSlate = new uint256[](2); potentialProposalSlate[0] = screenedProposals[0].proposalId; @@ -613,22 +690,26 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { potentialProposalSlate[1] = testProposals[6].proposalId; // ensure checkSlate won't allow if called before DistributionPeriod starts - bool validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertFalse(validSlate); + bool proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); - // skip to the DistributionPeriod + /************************/ + /*** Challenge Period ***/ + /************************/ + + // skip to the end of the DistributionPeriod vm.roll(_startBlock + 650_000); // ensure checkSlate won't allow if slate has a proposal that is not in topTenProposal (funding Stage) - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertFalse(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); // Updating potential Proposal Slate to include proposal that is in topTenProposal (funding Stage) potentialProposalSlate[1] = screenedProposals[1].proposalId; // ensure checkSlate won't allow exceeding the GBC - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertFalse(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); (, , , , , bytes32 slateHash) = _grantFund.getDistributionPeriodInfo(distributionId); assertEq(slateHash, 0); @@ -637,18 +718,18 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { potentialProposalSlate[0] = screenedProposals[3].proposalId; vm.expectEmit(true, true, false, true); emit FundedSlateUpdated(distributionId, _grantFund.getSlateHash(potentialProposalSlate)); - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertTrue(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertTrue(proposalSlateUpdated); // should not update slate if current funding slate is same as potentialProposalSlate - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertFalse(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); // check slate hash (, , , , , slateHash) = _grantFund.getDistributionPeriodInfo(distributionId); assertEq(slateHash, 0x53dc2b0b8c3787b3384472e1d449bb35e20089a01306e21d59ec6d080cdcd1a8); // check funded proposal slate matches expected state - GrantFund.Proposal[] memory fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(distributionId, slateHash)); + GrantFund.Proposal[] memory fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(slateHash)); assertEq(fundedProposalSlate.length, 1); assertEq(fundedProposalSlate[0].proposalId, screenedProposals[3].proposalId); @@ -658,30 +739,37 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { potentialProposalSlate[1] = screenedProposals[4].proposalId; vm.expectEmit(true, true, false, true); emit FundedSlateUpdated(distributionId, _grantFund.getSlateHash(potentialProposalSlate)); - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertTrue(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertTrue(proposalSlateUpdated); // check slate hash (, , , , , slateHash) = _grantFund.getDistributionPeriodInfo(distributionId); assertEq(slateHash, 0x1baa18a2d105ff81cc846882b7cc083ac252a82d2082db41156a83ae5d6a2436); // check funded proposal slate matches expected state - fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(distributionId, slateHash)); + fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(slateHash)); assertEq(fundedProposalSlate.length, 2); assertEq(fundedProposalSlate[0].proposalId, screenedProposals[3].proposalId); assertEq(fundedProposalSlate[1].proposalId, screenedProposals[4].proposalId); + // check that the slate isn't updated if a slate contains duplicate proposals + potentialProposalSlate = new uint256[](2); + potentialProposalSlate[0] = screenedProposals[0].proposalId; + potentialProposalSlate[1] = screenedProposals[0].proposalId; + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); + // ensure an additional update can be made to the optimized slate potentialProposalSlate = new uint256[](2); potentialProposalSlate[0] = screenedProposals[0].proposalId; potentialProposalSlate[1] = screenedProposals[4].proposalId; vm.expectEmit(true, true, false, true); emit FundedSlateUpdated(distributionId, _grantFund.getSlateHash(potentialProposalSlate)); - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertTrue(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertTrue(proposalSlateUpdated); // check slate hash (, , , , , slateHash) = _grantFund.getDistributionPeriodInfo(distributionId); assertEq(slateHash, 0x6d2192bdd3e08d75d683185db5947cd199403513241eddfa5cf8a36256f27c40); // check funded proposal slate matches expected state - fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(distributionId, slateHash)); + fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(slateHash)); assertEq(fundedProposalSlate.length, 2); assertEq(fundedProposalSlate[0].proposalId, screenedProposals[0].proposalId); assertEq(fundedProposalSlate[1].proposalId, screenedProposals[4].proposalId); @@ -690,12 +778,12 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { potentialProposalSlate = new uint256[](2); potentialProposalSlate[0] = screenedProposals[2].proposalId; potentialProposalSlate[1] = screenedProposals[3].proposalId; - validSlate = _grantFund.checkSlate(potentialProposalSlate, distributionId); - assertFalse(validSlate); + proposalSlateUpdated = _grantFund.checkSlate(potentialProposalSlate, distributionId); + assertFalse(proposalSlateUpdated); // check funded proposal slate wasn't updated (, , , , , slateHash) = _grantFund.getDistributionPeriodInfo(distributionId); assertEq(slateHash, 0x6d2192bdd3e08d75d683185db5947cd199403513241eddfa5cf8a36256f27c40); - fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(distributionId, slateHash)); + fundedProposalSlate = _getProposalListFromProposalIds(_grantFund, _grantFund.getFundedProposalSlate(slateHash)); assertEq(fundedProposalSlate.length, 2); assertEq(fundedProposalSlate[0].proposalId, screenedProposals[0].proposalId); assertEq(fundedProposalSlate[1].proposalId, screenedProposals[4].proposalId); @@ -708,7 +796,11 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { vm.expectRevert(IStandardFunding.ChallengePeriodNotEnded.selector); _grantFund.claimDelegateReward(distributionId); - // skip to the end of the DistributionPeriod + /********************************/ + /*** Execute Funded Proposals ***/ + /********************************/ + + // skip to the end of the Distribution's challenge period vm.roll(_startBlock + 700_000); // should revert if called execute method of governer contract @@ -727,45 +819,60 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { vm.expectRevert("Governor: proposal not successful"); _grantFund.executeStandard(testProposals[0].targets, testProposals[0].values, testProposals[0].calldatas, keccak256(bytes(testProposals[0].description))); + /******************************/ + /*** Claim Delegate Rewards ***/ + /******************************/ + // Claim delegate reward for all delegatees + // delegates who didn't vote with their full power recieve fewer rewards + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder1); + assertEq(delegateRewards, 211_327.134404057480980557 * 1e18); _claimDelegateReward( { grantFund_: _grantFund, voter_: _tokenHolder1, distributionId_: distributionId, - claimedReward_: 238095.238095238095238095 * 1e18 + claimedReward_: delegateRewards } ); + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder2); + assertEq(delegateRewards, 211_327.134404057480980557 * 1e18); _claimDelegateReward( { grantFund_: _grantFund, voter_: _tokenHolder2, distributionId_: distributionId, - claimedReward_: 238095.238095238095238095 * 1e18 + claimedReward_: delegateRewards } ); + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder3); + assertEq(delegateRewards, 194_843.617920540997464074 * 1e18); _claimDelegateReward( { grantFund_: _grantFund, voter_: _tokenHolder3, distributionId_: distributionId, - claimedReward_: 238095.238095238095238095 * 1e18 + claimedReward_: delegateRewards } ); + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder4); + assertEq(delegateRewards, 211_327.134404057480980557 * 1e18); _claimDelegateReward( { grantFund_: _grantFund, voter_: _tokenHolder4, distributionId_: distributionId, - claimedReward_: 238095.238095238095238095 * 1e18 + claimedReward_: delegateRewards } ); + delegateRewards = _grantFund.getDelegateReward(distributionId, _tokenHolder5); + assertEq(delegateRewards, 171_174.978867286559594251 * 1e18); _claimDelegateReward( { grantFund_: _grantFund, voter_: _tokenHolder5, distributionId_: distributionId, - claimedReward_: 47619.047619047619047619 * 1e18 + claimedReward_: delegateRewards } ); @@ -827,7 +934,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertEq(screenedProposals_distribution1.length, 1); // funding period votes - _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution1[0].proposalId, voteYes, 2_500_000_000_000_000 * 1e18); + _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution1[0].proposalId, voteYes, 50_000_000 * 1e18); // skip to the Challenge period vm.roll(_startBlock + 650_000); @@ -873,7 +980,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertEq(screenedProposals_distribution2.length, 1); // funding period votes - _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution2[0].proposalId, voteYes, 2_500_000_000_000_000 * 1e18); + _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution2[0].proposalId, voteYes, 50_000_000 * 1e18); // skip to the Challenge period vm.roll(_startBlock + 1_350_000); @@ -919,7 +1026,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertEq(screenedProposals_distribution3.length, 1); // funding period votes - _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution3[0].proposalId, voteYes, 2_500_000_000_000_000 * 1e18); + _fundingVote(_grantFund, _tokenHolder1, screenedProposals_distribution3[0].proposalId, voteYes, 50_000_000 * 1e18); // skip to the Challenge period vm.roll(_startBlock + 2_000_000); @@ -946,6 +1053,168 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { assertEq(gbc_distribution4, 9_526_000 * 1e18); } + // test that three people with fewer tokens should be able to out vote 1 person with more + function testQuadraticVotingTally() external { + // create new test address just for this test + address testAddress1 = makeAddr("testAddress1"); + address testAddress2 = makeAddr("testAddress2"); + address testAddress3 = makeAddr("testAddress3"); + address testAddress4 = makeAddr("testAddress4"); + _votersArr = new address[](4); + _votersArr[0] = testAddress1; + _votersArr[1] = testAddress2; + _votersArr[2] = testAddress3; + _votersArr[3] = testAddress4; + + // transfer ajna tokens to the new address + changePrank(_tokenDeployer); + _token.transfer(testAddress1, 4 * 1e18); + _token.transfer(testAddress2, 3 * 1e18); + _token.transfer(testAddress3, 3 * 1e18); + _token.transfer(testAddress4, 3 * 1e18); + + // new addresses self delegate + changePrank(testAddress1); + _token.delegate(testAddress1); + changePrank(testAddress2); + _token.delegate(testAddress2); + changePrank(testAddress3); + _token.delegate(testAddress3); + changePrank(testAddress4); + _token.delegate(testAddress4); + + vm.roll(_startBlock + 150); + + // start distribution period + _startDistributionPeriod(_grantFund); + uint256 distributionId = _grantFund.getDistributionId(); + + // 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)", + testAddress1, + 10 * 1e18 + ); + TestProposal memory proposal = _createProposalStandard(_grantFund, testAddress1, ajnaTokenTargets, values, proposalCalldata, "Proposal for Ajna token transfer to tester address 1"); + + proposalCalldata = new bytes[](1); + proposalCalldata[0] = abi.encodeWithSignature( + "transfer(address,uint256)", + testAddress2, + 7 * 1e18 + ); + TestProposal memory proposal2 = _createProposalStandard(_grantFund, testAddress2, ajnaTokenTargets, values, proposalCalldata, "Proposal 2 for Ajna token transfer to tester address 2"); + + proposalCalldata = new bytes[](1); + proposalCalldata[0] = abi.encodeWithSignature( + "transfer(address,uint256)", + testAddress3, + 6 * 1e18 + ); + TestProposal memory proposal3 = _createProposalStandard(_grantFund, testAddress3, ajnaTokenTargets, values, proposalCalldata, "Proposal 2 for Ajna token transfer to tester address 2"); + + vm.roll(_startBlock + 300); + + // screening period votes + _vote(_grantFund, testAddress1, proposal.proposalId, voteYes, 1); + _vote(_grantFund, testAddress2, proposal2.proposalId, voteYes, 1); + _vote(_grantFund, testAddress3, proposal3.proposalId, voteYes, 1); + + // skip forward to the funding stage + vm.roll(_startBlock + 600_000); + + GrantFund.Proposal[] memory screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId)); + assertEq(screenedProposals.length, 3); + assertEq(screenedProposals[0].proposalId, proposal.proposalId); + assertEq(screenedProposals[0].votesReceived, 4 * 1e18); + assertEq(screenedProposals[1].proposalId, proposal2.proposalId); + assertEq(screenedProposals[1].votesReceived, 3 * 1e18); + assertEq(screenedProposals[2].proposalId, proposal3.proposalId); + assertEq(screenedProposals[2].votesReceived, 3 * 1e18); + + // check initial voting power + uint256 votingPower = _grantFund.getVotesWithParams(testAddress1, block.number, "Funding"); + assertEq(votingPower, 16 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress2, block.number, "Funding"); + assertEq(votingPower, 9 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress3, block.number, "Funding"); + assertEq(votingPower, 9 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress4, block.number, "Funding"); + assertEq(votingPower, 9 * 1e18); + + _fundingVote(_grantFund, testAddress1, proposal.proposalId, voteYes, 4 * 1e18); + + IStandardFunding.FundingVoteParams[] memory fundingVoteParams = new IStandardFunding.FundingVoteParams[](2); + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: proposal2.proposalId, + votesUsed: 2 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: proposal3.proposalId, + votesUsed: 2 * 1e18 + }); + _fundingVoteMulti(_grantFund, fundingVoteParams, testAddress2); + + fundingVoteParams = new IStandardFunding.FundingVoteParams[](2); + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: proposal2.proposalId, + votesUsed: 2 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: proposal3.proposalId, + votesUsed: 2 * 1e18 + }); + _fundingVoteMulti(_grantFund, fundingVoteParams, testAddress3); + + fundingVoteParams = new IStandardFunding.FundingVoteParams[](2); + fundingVoteParams[0] = IStandardFunding.FundingVoteParams({ + proposalId: proposal2.proposalId, + votesUsed: 2 * 1e18 + }); + fundingVoteParams[1] = IStandardFunding.FundingVoteParams({ + proposalId: proposal3.proposalId, + votesUsed: 2 * 1e18 + }); + _fundingVoteMulti(_grantFund, fundingVoteParams, testAddress4); + + // check voting power after voting + votingPower = _grantFund.getVotesWithParams(testAddress1, block.number, "Funding"); + assertEq(votingPower, 0 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress2, block.number, "Funding"); + assertEq(votingPower, 1 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress3, block.number, "Funding"); + assertEq(votingPower, 1 * 1e18); + votingPower = _grantFund.getVotesWithParams(testAddress4, block.number, "Funding"); + assertEq(votingPower, 1 * 1e18); + + // skip to the DistributionPeriod + vm.roll(_startBlock + 650_000); + + // verify that even without using their full voting power, + // several smaller token holders are able to outvote a larger token holder + uint256[] memory proposalSlate = new uint256[](1); + proposalSlate[0] = proposal.proposalId; + assertTrue(_grantFund.checkSlate(proposalSlate, distributionId)); + + proposalSlate = new uint256[](1); + proposalSlate[0] = proposal2.proposalId; + assertTrue(_grantFund.checkSlate(proposalSlate, distributionId)); + + proposalSlate = new uint256[](2); + proposalSlate[0] = proposal2.proposalId; + proposalSlate[1] = proposal3.proposalId; + assertTrue(_grantFund.checkSlate(proposalSlate, distributionId)); + } + function testGovernerViewMethods() external { uint256 delay = _grantFund.votingDelay(); @@ -1028,7 +1297,7 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { } // if there are 10 proposals in topTenProposalIds, check new proposal has more votes than the last proposal in topTenProposalIds - else if( noOfVotesOnProposal[topTenProposalIds[lengthOfArray - 1]] < votesOnCurrentProposal) { + else if(noOfVotesOnProposal[topTenProposalIds[lengthOfArray - 1]] < votesOnCurrentProposal) { // remove last proposal with least no of vote in topTenProposalIds topTenProposalIds.pop(); @@ -1074,8 +1343,8 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { } // calculate and allocate all qvBudget of the voter to the proposal - uint256 budgetAllocated = Maths.wpow(votes[i], 2); - totalBudgetAllocated += budgetAllocated; + uint256 budgetAllocated = votes[i]; + totalBudgetAllocated += Maths.wpow(budgetAllocated, 2); _fundingVote(_grantFund, voters[i], proposalId, voteYes, int256(budgetAllocated)); } @@ -1095,10 +1364,8 @@ contract StandardFundingGrantFundTest is GrantFundTestHelper { // claim delegate reward for each voter for(uint i = 0; i < noOfVoters; i++) { - uint256 budgetAllocated = Maths.wpow(votes[i], 2); - // calculate delegate reward for each voter - uint256 reward = Maths.wdiv(Maths.wmul(gbc, budgetAllocated), totalBudgetAllocated) / 10; + uint256 reward = Maths.wdiv(Maths.wmul(gbc, Maths.wpow(votes[i], 2)), totalBudgetAllocated) / 10; totalDelegationReward += reward; // check whether reward calculated is correct diff --git a/test/utils/GrantFundTestHelper.sol b/test/utils/GrantFundTestHelper.sol index 5a05391a..92d73fda 100644 --- a/test/utils/GrantFundTestHelper.sol +++ b/test/utils/GrantFundTestHelper.sol @@ -7,6 +7,7 @@ import { Test } from "@std/Test.sol"; import { GrantFund } from "../../src/grants/GrantFund.sol"; import { IStandardFunding } from "../../src/grants/interfaces/IStandardFunding.sol"; +import { Maths } from "../../src/grants/libraries/Maths.sol"; import { IAjnaToken } from "./IAjnaToken.sol"; @@ -289,6 +290,16 @@ abstract contract GrantFundTestHelper is Test { grantFund_.castVoteWithReasonAndParams(proposalId_, support_, reason, params); } + function _fundingVoteMulti(GrantFund grantFund_, IStandardFunding.FundingVoteParams[] memory voteParams_, address voter_) internal { + for (uint256 i = 0; i < voteParams_.length; ++i) { + uint8 support = voteParams_[i].votesUsed < 0 ? 0 : 1; + vm.expectEmit(true, true, false, true); + emit VoteCast(voter_, voteParams_[i].proposalId, support, uint256(Maths.abs(voteParams_[i].votesUsed)), ""); + } + changePrank(voter_); + grantFund_.fundingVotesMulti(voteParams_); + } + // Returns a random proposal Index from all proposals function _getRandomProposal(uint256 noOfProposals_) internal returns(uint256 proposal_) { // calculate random proposal Index between 0 and noOfProposals_ @@ -305,6 +316,15 @@ abstract contract GrantFundTestHelper is Test { return voters_; } + function _getScreeningVotes(GrantFund grantFund_, address voter_) internal view returns (uint256 votes) { + votes = grantFund_.getVotesWithParams(voter_, block.number, bytes("Screening")); + } + + function _getFundingVotes(GrantFund grantFund_, address voter_) internal view returns (uint256 votes) { + votes = grantFund_.getVotesWithParams(voter_, block.number, bytes("Funding")); + } + + // TODO: rename this method // Transfers a random amount of tokens to N voters and self delegates votes function _getVotes(uint256 noOfVoters_, address[] memory voters_, IAjnaToken token_, address tokenDeployer_) internal returns(uint256[] memory) { uint256[] memory votes_ = new uint256[](noOfVoters_); @@ -356,7 +376,7 @@ abstract contract GrantFundTestHelper is Test { proposals[i].distributionId, proposals[i].votesReceived, proposals[i].tokensRequested, - proposals[i].qvBudgetAllocated, + proposals[i].fundingVotesReceived, proposals[i].executed ) = grantFund_.getProposalInfo(proposalIds_[i]); }