diff --git a/contracts/colony/ColonyFunding.sol b/contracts/colony/ColonyFunding.sol index d16f0f774f..5e13c29884 100755 --- a/contracts/colony/ColonyFunding.sol +++ b/contracts/colony/ColonyFunding.sol @@ -277,8 +277,7 @@ contract ColonyFunding is ColonyStorage, PatriciaTreeProofs { // ignore-swc-123 function startNextRewardPayout(address _token, bytes memory key, bytes memory value, uint256 branchMask, bytes32[] memory siblings) public stoppable auth { - ITokenLocking tokenLocking = ITokenLocking(tokenLockingAddress); - uint256 totalLockCount = tokenLocking.lockToken(token); + uint256 totalLockCount = ITokenLocking(tokenLockingAddress).lockToken(token); uint256 thisPayoutAmount = sub(fundingPots[0].balance[_token], pendingRewardPayments[_token]); require(thisPayoutAmount > 0, "colony-reward-payout-no-rewards"); pendingRewardPayments[_token] = add(pendingRewardPayments[_token], thisPayoutAmount); @@ -444,7 +443,6 @@ contract ColonyFunding is ColonyStorage, PatriciaTreeProofs { // ignore-swc-123 require(mul(squareRoots[4], squareRoots[4]) <= numerator, "colony-reward-payout-invalid-parameter-numerator"); require(mul(squareRoots[5], squareRoots[5]) >= denominator, "colony-reward-payout-invalid-parameter-denominator"); - uint256 reward = (mul(squareRoots[4], squareRoots[6]) / squareRoots[5]) ** 2; return (payout.tokenAddress, reward); diff --git a/contracts/extensions/VotingBase.sol b/contracts/extensions/VotingBase.sol new file mode 100644 index 0000000000..9f91445d9f --- /dev/null +++ b/contracts/extensions/VotingBase.sol @@ -0,0 +1,1050 @@ +/* + This file is part of The Colony Network. + + The Colony Network is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + The Colony Network is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with The Colony Network. If not, see . +*/ + +pragma solidity 0.7.3; +pragma experimental ABIEncoderV2; + +import "./../colony/ColonyRoles.sol"; +import "./../colonyNetwork/IColonyNetwork.sol"; +import "./../patriciaTree/PatriciaTreeProofs.sol"; +import "./../tokenLocking/ITokenLocking.sol"; +import "./ColonyExtensionMeta.sol"; + + +abstract contract VotingBase is ColonyExtensionMeta, PatriciaTreeProofs { + + // Events + + event MotionCreated(uint256 indexed motionId, address creator, uint256 indexed domainId); + event MotionStaked(uint256 indexed motionId, address indexed staker, uint256 indexed vote, uint256 amount); + event MotionVoteSubmitted(uint256 indexed motionId, address indexed voter); + event MotionVoteRevealed(uint256 indexed motionId, address indexed voter, uint256 indexed vote); + event MotionFinalized(uint256 indexed motionId, bytes action, bool executed); + event MotionEscalated(uint256 indexed motionId, address escalator, uint256 indexed domainId, uint256 indexed newDomainId); + event MotionRewardClaimed(uint256 indexed motionId, address indexed staker, uint256 indexed vote, uint256 amount); + event MotionEventSet(uint256 indexed motionId, uint256 eventIndex); + + // Constants + + uint256 constant UINT128_MAX = 2**128 - 1; + + uint256 constant NAY = 0; + uint256 constant YAY = 1; + + uint256 constant STAKE_END = 0; + uint256 constant SUBMIT_END = 1; + uint256 constant REVEAL_END = 2; + + bytes32 constant ROOT_ROLES = ( + bytes32(uint256(1)) << uint8(ColonyDataTypes.ColonyRole.Recovery) | + bytes32(uint256(1)) << uint8(ColonyDataTypes.ColonyRole.Root) + ); + + bytes4 constant CHANGE_FUNCTION_SIG = bytes4(keccak256( + "setExpenditureState(uint256,uint256,uint256,uint256,bool[],bytes32[],bytes32)" + )); + + bytes4 constant OLD_MOVE_FUNDS_SIG = bytes4(keccak256( + "moveFundsBetweenPots(uint256,uint256,uint256,uint256,uint256,uint256,address)" + )); + + enum ExtensionState { Deployed, Active, Deprecated } + + // Data structures + + enum MotionState { Null, Staking, Submit, Reveal, Closed, Finalizable, Finalized, Failed } + + struct Motion { + uint64[3] events; // For recording motion lifecycle timestamps (STAKE, SUBMIT, REVEAL) + bytes32 rootHash; + uint256 domainId; + uint256 skillId; + uint256 paidVoterComp; + uint256[2] pastVoterComp; // [nay, yay] + uint256[2] stakes; // [nay, yay] + uint256[2][] votes; // [nay, yay] + uint256[] totalVotes; + uint256[] maxVotes; + address target; + bool escalated; + bool finalized; + bytes action; + } + + // Storage variables + + ExtensionState state; + + IColonyNetwork colonyNetwork; + ITokenLocking tokenLocking; + address token; + + // All `Fraction` variables are stored as WADs i.e. fixed-point numbers with 18 digits after the radix. So + // 1 WAD = 10**18, which is interpreted as 1. + + uint256 totalStakeFraction; // Fraction of the domain's reputation needed to stake on each side in order to go to a motion. + // This can be set to a maximum of 0.5. + uint256 voterRewardFraction; // Fraction of staked tokens paid out to voters as rewards. This will be paid from the staked + // tokens of the losing side. This can be set to a maximum of 0.5. + + uint256 userMinStakeFraction; // Minimum stake as fraction of required stake. 1 means a single user will be required to + // provide the whole stake on each side, which may not be possible depending on totalStakeFraction and the distribution of + // reputation in a domain. + uint256 maxVoteFraction; // Fraction of total domain reputation that needs to commit votes before closing to further votes. + // Setting this to anything other than 1 will mean it is likely not all those eligible to vote will be able to do so. + + // All `Period` variables are second-denominated + + uint256 stakePeriod; // Length of time for staking + uint256 submitPeriod; // Length of time for submitting votes + uint256 revealPeriod; // Length of time for revealing votes + uint256 escalationPeriod; // Length of time for escalating after a vote + + uint256 motionCount; + mapping (uint256 => Motion) motions; + mapping (uint256 => mapping (address => mapping (uint256 => uint256))) stakes; + mapping (uint256 => mapping (address => bytes32)) voteSecrets; + + mapping (bytes32 => uint256) expenditurePastVotes; // expenditure slot signature => voting power + + // Modifiers + + modifier onlyRoot() { + require(colony.hasUserRole(msg.sender, 1, ColonyDataTypes.ColonyRole.Root), "voting-not-root"); + _; + } + + // Virtual functions + + function postSubmit(uint256 _motionId, address _user) internal virtual; + function postReveal(uint256 _motionId, address _user) internal virtual; + function postClaim(uint256 _motionId, address _user) internal virtual; + + // Public functions + + /// @notice Install the extension + /// @param _colony Base colony for the installation + function install(address _colony) public override { + require(address(colony) == address(0x0), "extension-already-installed"); + + colony = IColony(_colony); + colonyNetwork = IColonyNetwork(colony.getColonyNetwork()); + tokenLocking = ITokenLocking(colonyNetwork.getTokenLocking()); + token = colony.getToken(); + } + + /// @notice Called when upgrading the extension + function finishUpgrade() public override auth {} // solhint-disable-line no-empty-blocks + + /// @notice Called when deprecating (or undeprecating) the extension + function deprecate(bool _deprecated) public override auth { + deprecated = _deprecated; + } + + /// @notice Called when uninstalling the extension + function uninstall() public override auth { + selfdestruct(address(uint160(address(colony)))); + } + + /// @notice Initialise the extension + /// @param _totalStakeFraction The fraction of the domain's reputation we need to stake + /// @param _userMinStakeFraction The minimum per-user stake as fraction of total stake + /// @param _maxVoteFraction The fraction of the domain's reputation which must submit for quick-end + /// @param _voterRewardFraction The fraction of the total stake paid out to voters as rewards + /// @param _stakePeriod The length of the staking period in seconds + /// @param _submitPeriod The length of the submit period in seconds + /// @param _revealPeriod The length of the reveal period in seconds + /// @param _escalationPeriod The length of the escalation period in seconds + function initialise( + uint256 _totalStakeFraction, + uint256 _voterRewardFraction, + uint256 _userMinStakeFraction, + uint256 _maxVoteFraction, + uint256 _stakePeriod, + uint256 _submitPeriod, + uint256 _revealPeriod, + uint256 _escalationPeriod + ) + public + onlyRoot + { + require(state == ExtensionState.Deployed, "voting-already-initialised"); + + string memory valueError = "voting-invalid-value"; + + require(_totalStakeFraction <= WAD / 2, valueError); + require(_voterRewardFraction <= WAD / 2, valueError); + + require(_userMinStakeFraction <= WAD, valueError); + require(_maxVoteFraction <= WAD, valueError); + + require(_stakePeriod <= 365 days, valueError); + require(_submitPeriod <= 365 days, valueError); + require(_revealPeriod <= 365 days, valueError); + require(_escalationPeriod <= 365 days, valueError); + + state = ExtensionState.Active; + + totalStakeFraction = _totalStakeFraction; + voterRewardFraction = _voterRewardFraction; + + userMinStakeFraction = _userMinStakeFraction; + maxVoteFraction = _maxVoteFraction; + + stakePeriod = _stakePeriod; + submitPeriod = _submitPeriod; + revealPeriod = _revealPeriod; + escalationPeriod = _escalationPeriod; + + emit ExtensionInitialised(); + } + + /// @notice Reveal a vote secret for a motion + /// @param _motionId The id of the motion + /// @param _salt The salt used to hash the vote + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + function internalRevealVote(uint256 _motionId, bytes32 _salt, uint256 _vote, uint256[] memory _influence) public { + + Motion storage motion = motions[_motionId]; + + require(getMotionState(_motionId) == MotionState.Reveal, "voting-not-reveal"); + require(_vote <= 1, "voting-bad-vote"); + + for (uint256 i; i < motion.votes.length; i++) { + motion.votes[i][_vote] = add(motion.votes[i][_vote], _influence[i]); + } + + bytes32 voteSecret = voteSecrets[_motionId][msg.sender]; + require(voteSecret == getVoteSecret(_salt, _vote), "voting-secret-no-match"); + delete voteSecrets[_motionId][msg.sender]; + + uint256 voterReward = getVoterReward(_motionId, msg.sender, _influence); + motion.paidVoterComp = add(motion.paidVoterComp, voterReward); + + emit MotionVoteRevealed(_motionId, msg.sender, _vote); + + postReveal(_motionId, msg.sender); + + tokenLockingTransfer(voterReward, msg.sender); + } + + function finalizeMotion(uint256 _motionId) public { + Motion storage motion = motions[_motionId]; + + require(getMotionState(_motionId) == MotionState.Finalizable, "voting-not-finalizable"); + + motion.finalized = true; + + uint256 sumVotes; + uint256 yayVotes; + + for (uint256 i; i < motion.votes.length; i++) { + sumVotes = add(sumVotes, add(motion.votes[i][NAY], motion.votes[i][YAY])); + yayVotes = add(yayVotes, motion.votes[i][YAY]); + } + + // Either we're fully staked YAY or we've gone to a vote + assert(motion.stakes[YAY] == getRequiredStake(_motionId) || sumVotes > 0); + + bool canExecute = true; + + // If we went to a vote, check if every sub-vote passed + if (sumVotes > 0) { + for (uint256 j; j < motion.votes.length && canExecute; j++) { + canExecute = canExecute && motion.votes[j][NAY] < motion.votes[j][YAY]; + } + } + + // Handle expenditure-related bookkeeping (claim delays, repeated vote checks) + if ( + getSig(motion.action) == CHANGE_FUNCTION_SIG && + motion.target == address(colony) + ) { + bytes memory claimDelayAction = createClaimDelayAction(motion.action, false); + // No require this time, since we don't want stakes to be permanently locked + executeCall(address(colony), claimDelayAction); + + bytes32 actionHash = hashExpenditureAction(motion.action); + uint256 votePower = (sumVotes > 0) ? yayVotes : motion.stakes[YAY]; + + if (expenditurePastVotes[actionHash] < votePower) { + expenditurePastVotes[actionHash] = votePower; + // slither-disable-next-line boolean-cst + canExecute = canExecute && true; + } else { + // slither-disable-next-line boolean-cst + canExecute = canExecute && false; + } + } + + bool executed; + + if (canExecute) { + executed = executeCall(motion.target, motion.action); + } + + emit MotionFinalized(_motionId, motion.action, executed); + } + + /// @notice Claim the staker's reward + /// @param _motionId The id of the motion + /// @param _permissionDomainId The domain where the extension has the arbitration permission + /// @param _childSkillIndex For the domain in which the motion is occurring + /// @param _staker The staker whose reward is being claimed + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + function claimReward( + uint256 _motionId, + uint256 _permissionDomainId, + uint256 _childSkillIndex, + address _staker, + uint256 _vote + ) + public + { + require( + getMotionState(_motionId) == MotionState.Finalized || + getMotionState(_motionId) == MotionState.Failed, + "voting-not-claimable" + ); + + (uint256 stakerReward, uint256 repPenalty) = getStakerReward(_motionId, _staker, _vote); + + require(stakes[_motionId][_staker][_vote] > 0, "voting-nothing-to-claim"); + delete stakes[_motionId][_staker][_vote]; + + postClaim(_motionId, _staker); + + tokenLockingTransfer(stakerReward, _staker); + + if (repPenalty > 0) { + colony.emitDomainReputationPenalty( + _permissionDomainId, + _childSkillIndex, + motions[_motionId].domainId, + _staker, + -int256(repPenalty) + ); + } + + emit MotionRewardClaimed(_motionId, _staker, _vote, stakerReward); + } + + // Public view functions + + /// @notice Get the total stake fraction + /// @return The total stake fraction + function getTotalStakeFraction() public view returns (uint256) { + return totalStakeFraction; + } + + /// @notice Get the voter reward fraction + /// @return The voter reward fraction + function getVoterRewardFraction() public view returns (uint256) { + return voterRewardFraction; + } + + /// @notice Get the user min stake fraction + /// @return The user min stake fraction + function getUserMinStakeFraction() public view returns (uint256) { + return userMinStakeFraction; + } + + /// @notice Get the max vote fraction + /// @return The max vote fraction + function getMaxVoteFraction() public view returns (uint256) { + return maxVoteFraction; + } + + /// @notice Get the stake period + /// @return The stake period + function getStakePeriod() public view returns (uint256) { + return stakePeriod; + } + + /// @notice Get the submit period + /// @return The submit period + function getSubmitPeriod() public view returns (uint256) { + return submitPeriod; + } + + /// @notice Get the reveal period + /// @return The reveal period + function getRevealPeriod() public view returns (uint256) { + return revealPeriod; + } + + /// @notice Get the escalation period + /// @return The escalation period + function getEscalationPeriod() public view returns (uint256) { + return escalationPeriod; + } + + /// @notice Get the total motion count + /// @return The total motion count + function getMotionCount() public view returns (uint256) { + return motionCount; + } + + /// @notice Get the data for a single motion + /// @param _motionId The id of the motion + /// @return motion The motion struct + function getMotion(uint256 _motionId) public view returns (Motion memory motion) { + motion = motions[_motionId]; + } + + /// @notice Get a user's stake on a motion + /// @param _motionId The id of the motion + /// @param _staker The staker address + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + /// @return The user's stake + function getStake(uint256 _motionId, address _staker, uint256 _vote) public view returns (uint256) { + return stakes[_motionId][_staker][_vote]; + } + + /// @notice Get the largest past vote on a single expenditure variable + /// @param _actionHash The hash of the particular expenditure action + /// @return The largest past vote on this variable + function getExpenditurePastVote(bytes32 _actionHash) public view returns (uint256) { + return expenditurePastVotes[_actionHash]; + } + + /// @notice Get the current state of the motion + /// @return The current motion state + function getMotionState(uint256 _motionId) public view returns (MotionState) { + Motion storage motion = motions[_motionId]; + + uint256 requiredStake = getRequiredStake(_motionId); + + // Check for valid motion Id + if (_motionId == 0 || _motionId > motionCount) { + + return MotionState.Null; + + // If finalized, we're done + } else if (motion.finalized) { + + return MotionState.Finalized; + + // Not fully staked + } else if (motion.stakes[YAY] < requiredStake || motion.stakes[NAY] < requiredStake) { + + // Are we still staking? + if (block.timestamp < motion.events[STAKE_END]) { + return MotionState.Staking; + // If not, did the YAY side stake? + } else if (motion.stakes[YAY] == requiredStake) { + return MotionState.Finalizable; + // If not, was there a prior (reputation) vote we can fall back on? + } else if ( + (identifier() == keccak256("VotingReputation") || + identifier() == keccak256("VotingReputation2")) && + add(motion.votes[0][NAY], motion.votes[0][YAY]) > 0 + ) { + return MotionState.Finalizable; + // Otherwise, the motion failed + } else { + return MotionState.Failed; + } + + // Fully staked, go to a vote + } else { + + if (block.timestamp < motion.events[SUBMIT_END]) { + return MotionState.Submit; + } else if (block.timestamp < motion.events[REVEAL_END]) { + return MotionState.Reveal; + } else if ( + motion.domainId > 1 && + block.timestamp < motion.events[REVEAL_END] + escalationPeriod + ) { + return MotionState.Closed; + } else { + return MotionState.Finalizable; + } + + } + } + + /// @notice Get the voter reward + /// NB This function will only return a meaningful value if in the reveal state. + /// Prior to the reveal state, getVoterRewardRange should be used. + /// @param _motionId The id of the motion + /// @param _user The address of the the voter + /// @return The voter reward + function getVoterReward(uint256 _motionId, address _user, uint256[] memory _influence) public view returns (uint256) { + Motion storage motion = motions[_motionId]; + + assert(_influence.length == motion.totalVotes.length); + + // Get the average per-influence fraction + uint256 fractionUserInfluence; + + for (uint256 i; i < _influence.length; i++) { + if (motion.totalVotes[i] > 0) { + fractionUserInfluence = add(fractionUserInfluence, wdiv(_influence[i], motion.totalVotes[i])); + } + } + + fractionUserInfluence = fractionUserInfluence / _influence.length; + + return wmul(wmul(fractionUserInfluence, add(motion.stakes[YAY], motion.stakes[NAY])), voterRewardFraction); + } + + /// @notice Get the range of potential rewards for a voter on a specific motion, intended to be + /// used when the motion is in the reveal state. + /// Once a motion is in the reveal state the reward is known, and getVoterRewardRange should be used. + /// @param _motionId The id of the motion + /// @param _user The address of the user + /// @return The voter reward + function getVoterRewardRange(uint256 _motionId, address _user, uint256[] memory _influence) public view returns (uint256, uint256) { + Motion storage motion = motions[_motionId]; + + assert(_influence.length == motion.totalVotes.length); + + uint256 minFractionUserInfluence; + uint256 maxFractionUserInfluence; + + // The minimum reward is when everyone has voted, with a total weight of motion.maxVotes + // The maximum reward is when this user is the only other person who votes (if they haven't already), + // aside from those who have already done so + + for (uint256 i; i < _influence.length; i++) { + // If user hasn't voted, add their influence to totalVotes + uint256 pendingVote = (voteSecrets[_motionId][_user] == bytes32(0)) ? _influence[i] : 0; + + if (motion.maxVotes[i] > 0) { + minFractionUserInfluence = add(minFractionUserInfluence, wdiv(_influence[i], motion.maxVotes[i])); + } + + if (add(motion.totalVotes[i], pendingVote) > 0) { + maxFractionUserInfluence = add(maxFractionUserInfluence, wdiv(_influence[i], add(motion.totalVotes[i], pendingVote))); + } + } + + minFractionUserInfluence = minFractionUserInfluence / _influence.length; + maxFractionUserInfluence = maxFractionUserInfluence / _influence.length; + + return ( + wmul(wmul(minFractionUserInfluence, add(motion.stakes[YAY], motion.stakes[NAY])), voterRewardFraction), + wmul(wmul(maxFractionUserInfluence, add(motion.stakes[YAY], motion.stakes[NAY])), voterRewardFraction) + ); + } + + /// @notice Get the staker reward + /// @param _motionId The id of the motion + /// @param _staker The staker's address + /// @param _vote The vote (0 = NAY, 1 = YAY) + /// @return The staker reward and the reputation penalty (if any) + function getStakerReward( + uint256 _motionId, + address _staker, + uint256 _vote + ) + public + view + returns (uint256, uint256) + { + Motion storage motion = motions[_motionId]; + + uint256 totalSideStake = add(motion.stakes[_vote], motion.pastVoterComp[_vote]); + if (totalSideStake == 0) { return (0, 0); } + + uint256 stakeFraction = wdiv(stakes[_motionId][_staker][_vote], totalSideStake); + uint256 realStake = wmul(stakeFraction, motion.stakes[_vote]); + + uint256 stakerReward; + uint256 repPenalty; + + bool wasVote; + + for (uint256 i; i < motion.votes.length && !wasVote; i++) { + wasVote = add(motion.votes[i][NAY], motion.votes[i][YAY]) > 0; + } + + if (wasVote) { + // Went to a vote, use vote to determine reward or penalty + (stakerReward, repPenalty) = getStakerRewardByVote(_motionId, _vote, stakeFraction, realStake); + } else { + // Determine rewards based on stakes alone + (stakerReward, repPenalty) = getStakerRewardByStake(_motionId, _vote, stakeFraction, realStake); + } + + return (stakerReward, repPenalty); + } + + // Internal functions + + function createMotionInternal( + uint256 _domainId, + uint256 _childSkillIndex, + address _target, + bytes memory _action, + uint256 _numInfluences + ) + internal + { + require(state == ExtensionState.Active, "voting-not-active"); + + address target = (_target == address(0x0)) ? address(colony) : _target; + bytes4 actionSig = getSig(_action); + uint256 skillId = getDomainSkillId(_domainId); + + require(actionSig != OLD_MOVE_FUNDS_SIG, "voting-bad-function"); + + if (ColonyRoles(target).getCapabilityRoles(actionSig) | ROOT_ROLES == ROOT_ROLES) { + + // A root or unpermissioned function + require(_domainId == 1 && _childSkillIndex == UINT256_MAX, "voting-invalid-domain"); + + } else { + + // A domain permissioned function + uint256 actionDomainSkillId = getActionDomainSkillId(_action); + + if (skillId != actionDomainSkillId) { + uint256 childSkillId = getChildSkillId(skillId, _childSkillIndex); + require(childSkillId == actionDomainSkillId, "voting-invalid-domain"); + } else { + require(_childSkillIndex == UINT256_MAX, "voting-invalid-domain"); + } + } + + motionCount += 1; + Motion storage motion = motions[motionCount]; + + motion.events[STAKE_END] = uint64(block.timestamp + stakePeriod); + motion.events[SUBMIT_END] = motion.events[STAKE_END] + uint64(submitPeriod); + motion.events[REVEAL_END] = motion.events[SUBMIT_END] + uint64(revealPeriod); + + motion.rootHash = colonyNetwork.getReputationRootHash(); + motion.domainId = _domainId; + motion.skillId = skillId; + + motion.target = target; + motion.action = _action; + + motion.votes = new uint256[2][](_numInfluences); + motion.totalVotes = new uint256[](_numInfluences); + motion.maxVotes = new uint256[](_numInfluences); + + emit MotionCreated(motionCount, msg.sender, _domainId); + } + + function internalStakeMotion( + uint256 _motionId, + uint256 _permissionDomainId, + uint256 _childSkillIndex, + uint256 _vote, + uint256 _amount, + uint256[] memory _influence + ) + internal + { + Motion storage motion = motions[_motionId]; + + require(_vote <= 1, "voting-bad-vote"); + require(getMotionState(_motionId) == MotionState.Staking, "voting-not-staking"); + + uint256 requiredStake = getRequiredStake(_motionId); + uint256 amount = min(_amount, sub(requiredStake, motion.stakes[_vote])); + require(amount > 0, "voting-bad-amount"); + + uint256 stakerTotalAmount = add(stakes[_motionId][msg.sender][_vote], amount); + + uint256 sumInfluence; + + for (uint256 i; i < _influence.length; i++) { + sumInfluence = add(sumInfluence, _influence[i]); + } + + require( + stakerTotalAmount <= sumInfluence, + "voting-insufficient-influence" + ); + require( + stakerTotalAmount >= wmul(requiredStake, userMinStakeFraction) || + add(motion.stakes[_vote], amount) == requiredStake, // To prevent a residual stake from being un-stakable + "voting-insufficient-stake" + ); + + // Update the stake + motion.stakes[_vote] = add(motion.stakes[_vote], amount); + stakes[_motionId][msg.sender][_vote] = stakerTotalAmount; + + // Increment counter & extend claim delay if staking for an expenditure state change + if ( + _vote == YAY && + !motion.escalated && + motion.stakes[YAY] == requiredStake && + getSig(motion.action) == CHANGE_FUNCTION_SIG && + motion.target == address(colony) + ) { + bytes memory claimDelayAction = createClaimDelayAction(motion.action, true); + require(executeCall(address(colony), claimDelayAction), "voting-lock-failed"); + } + + emit MotionStaked(_motionId, msg.sender, _vote, amount); + + // Move to vote submission once both sides are fully staked + if (motion.stakes[NAY] == requiredStake && motion.stakes[YAY] == requiredStake) { + motion.events[STAKE_END] = uint64(block.timestamp); + motion.events[SUBMIT_END] = motion.events[STAKE_END] + uint64(submitPeriod); + motion.events[REVEAL_END] = motion.events[SUBMIT_END] + uint64(revealPeriod); + + emit MotionEventSet(_motionId, STAKE_END); + + // Move to second staking window once one side is fully staked + } else if ( + (_vote == NAY && motion.stakes[NAY] == requiredStake) || + (_vote == YAY && motion.stakes[YAY] == requiredStake) + ) { + motion.events[STAKE_END] = uint64(block.timestamp + stakePeriod); + motion.events[SUBMIT_END] = motion.events[STAKE_END] + uint64(submitPeriod); + motion.events[REVEAL_END] = motion.events[SUBMIT_END] + uint64(revealPeriod); + + // New stake supersedes prior votes + for (uint256 j; j < motion.votes.length; j++) { + delete motion.votes[j]; + delete motion.totalVotes[j]; + } + + emit MotionEventSet(_motionId, STAKE_END); + } + + // Do the external bookkeeping + tokenLocking.deposit(token, 0, true); // Faux deposit to clear any locks + colony.obligateStake(msg.sender, motion.domainId, amount); + colony.transferStake(_permissionDomainId, _childSkillIndex, address(this), msg.sender, motion.domainId, amount, address(this)); + } + + /// @notice Submit a vote secret for a motion + /// @param _motionId The id of the motion + /// @param _voteSecret The hashed vote secret + function internalSubmitVote(uint256 _motionId, bytes32 _voteSecret, uint256[] memory _influence) internal { + Motion storage motion = motions[_motionId]; + + require(getMotionState(_motionId) == MotionState.Submit, "voting-not-open"); + require(_voteSecret != bytes32(0), "voting-invalid-secret"); + + // Add influence to totals if first submission + if (voteSecrets[_motionId][msg.sender] == bytes32(0)) { + for (uint256 i; i < motion.totalVotes.length; i++) { + motion.totalVotes[i] = add(motion.totalVotes[i], _influence[i]); + } + } + + voteSecrets[_motionId][msg.sender] = _voteSecret; + + emit MotionVoteSubmitted(_motionId, msg.sender); + + postSubmit(_motionId, msg.sender); + } + + function getStakerRewardByVote( + uint256 _motionId, + uint256 _vote, + uint256 _stakeFraction, + uint256 _realStake + ) + internal + view + returns (uint256, uint256) + { + Motion storage motion = motions[_motionId]; + + uint256 stakerReward; + uint256 repPenalty; + + bool yayWon = true; + uint256 winFraction; + + // Check if every sub-vote passed, and calculate the win fraction + for (uint256 i; i < motion.votes.length; i++) { + yayWon = yayWon && motion.votes[i][NAY] < motion.votes[i][YAY]; + + if (motion.totalVotes[i] > 0) { + winFraction = add(winFraction, wdiv(motion.votes[i][_vote], motion.totalVotes[i])); + } + } + + winFraction = winFraction / motion.votes.length; + + uint256 winnerStake; + uint256 loserStake; + + if (yayWon) { + winnerStake = motion.stakes[YAY]; + loserStake = sub(motion.stakes[NAY], motion.paidVoterComp); + } else { + winnerStake = motion.stakes[NAY]; + loserStake = sub(motion.stakes[YAY], motion.paidVoterComp); + } + + uint256 winShare = wmul(winFraction, 2 * WAD); // On a scale of 0-2 WAD + + if (winShare > WAD || (winShare == WAD && _vote == NAY)) { + stakerReward = wmul(_stakeFraction, add(winnerStake, wmul(loserStake, winShare - WAD))); + } else { + stakerReward = wmul(_stakeFraction, wmul(loserStake, winShare)); + repPenalty = sub(_realStake, stakerReward); + } + + return (stakerReward, repPenalty); + } + + function getStakerRewardByStake( + uint256 _motionId, + uint256 _vote, + uint256 _stakeFraction, + uint256 _realStake + ) + internal + view + returns (uint256, uint256) + { + Motion storage motion = motions[_motionId]; + assert(motion.paidVoterComp == 0); + + uint256 stakerReward; + uint256 repPenalty; + + uint256 requiredStake = getRequiredStake(_motionId); + + // Your side fully staked, receive 10% (proportional) of loser's stake + if ( + motion.stakes[_vote] == requiredStake && + motion.stakes[flip(_vote)] < requiredStake + ) { + + uint256 loserStake = motion.stakes[flip(_vote)]; + uint256 totalPenalty = wmul(loserStake, WAD / 10); + stakerReward = wmul(_stakeFraction, add(requiredStake, totalPenalty)); + + // Opponent's side fully staked, pay 10% penalty + } else if ( + motion.stakes[_vote] < requiredStake && + motion.stakes[flip(_vote)] == requiredStake + ) { + + uint256 loserStake = motion.stakes[_vote]; + uint256 totalPenalty = wmul(loserStake, WAD / 10); + stakerReward = wmul(_stakeFraction, sub(loserStake, totalPenalty)); + repPenalty = sub(_realStake, stakerReward); + + // Neither side fully staked (or no votes were revealed), no reward or penalty + } else { + + stakerReward = _realStake; + + } + + return (stakerReward, repPenalty); + } + + function getVoteSecret(bytes32 _salt, uint256 _vote) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(_salt, _vote)); + } + + function getRequiredStake(uint256 _motionId) public view returns (uint256) { + Motion storage motion = motions[_motionId]; + + uint256 sumMaxVotes; + for (uint256 i; i < motion.maxVotes.length; i++) { + sumMaxVotes = add(sumMaxVotes, motion.maxVotes[i]); + } + + return wmul(sumMaxVotes, totalStakeFraction); + } + + function flip(uint256 _vote) internal pure returns (uint256) { + return sub(1, _vote); + } + + function getDomainSkillId(uint256 _domainId) internal view returns (uint256) { + return colony.getDomain(_domainId).skillId; + } + + function getChildSkillId(uint256 _skillId, uint256 _index) internal view returns (uint256) { + return colonyNetwork.getChildSkillId(_skillId, _index); + } + + function tokenLockingTransfer(uint256 _amount, address _recipient) internal { + tokenLocking.transfer(token, _amount, _recipient, true); + } + + function getActionDomainSkillId(bytes memory _action) internal view returns (uint256) { + uint256 permissionDomainId; + uint256 childSkillIndex; + + // By convention, these are the first two arguments to the function + assembly { + permissionDomainId := mload(add(_action, 0x24)) + childSkillIndex := mload(add(_action, 0x44)) + } + + uint256 permissionSkillId = getDomainSkillId(permissionDomainId); + return getChildSkillId(permissionSkillId, childSkillIndex); + } + + function executeCall(address target, bytes memory action) internal returns (bool success) { + assembly { + // call contract at address a with input mem[in…(in+insize)) + // providing g gas and v wei and output area mem[out…(out+outsize)) + // returning 0 on error (eg. out of gas) and 1 on success + + // call(g, a, v, in, insize, out, outsize) + success := call(gas(), target, 0, add(action, 0x20), mload(action), 0, 0) + } + } + + function getSig(bytes memory action) internal returns (bytes4 sig) { + assembly { + sig := mload(add(action, 0x20)) + } + } + + function hashExpenditureAction(bytes memory action) internal returns (bytes32 hash) { + assembly { + // Hash all but the domain proof and value, so actions for the same + // storage slot return the same value. + // Recall: mload(action) gives length of bytes array + // So skip past the three bytes32 (length + domain proof), + // plus 4 bytes for the sig. Subtract the same from the end, less + // the length bytes32. The value itself is located at 0xe4, zero it out. + mstore(add(action, 0xe4), 0x0) + hash := keccak256(add(action, 0x64), sub(mload(action), 0x44)) + } + } + + function createClaimDelayAction(bytes memory action, bool increment) + public + returns (bytes memory) + { + // See https://solidity.readthedocs.io/en/develop/abi-spec.html#use-of-dynamic-types + // for documentation on how the action `bytes` is encoded + // In brief, the first byte32 is the length of the array. Then we have + // 4 bytes of function signature, following by an arbitrary number of + // additional byte32 arguments. 32 in hex is 0x20, so every increment + // of 0x20 represents advancing one byte, 4 is the function signature. + // So: 0x[length][sig][args...] + + bytes32 functionSignature; + uint256 permissionDomainId; + uint256 childSkillIndex; + uint256 expenditureId; + uint256 storageSlot; + + assembly { + functionSignature := mload(add(action, 0x20)) + permissionDomainId := mload(add(action, 0x24)) + childSkillIndex := mload(add(action, 0x44)) + expenditureId := mload(add(action, 0x64)) + storageSlot := mload(add(action, 0x84)) + } + + // If we are editing the main expenditure struct + if (storageSlot == 25) { + + uint256 claimDelay = colony.getExpenditure(expenditureId).globalClaimDelay; + claimDelay = increment ? add(claimDelay, 365 days) : sub(claimDelay, 365 days); + + bytes memory mainClaimDelayAction = new bytes(4 + 32 * 11); // 356 bytes + + assembly { + mstore(add(mainClaimDelayAction, 0x20), functionSignature) + mstore(add(mainClaimDelayAction, 0x24), permissionDomainId) + mstore(add(mainClaimDelayAction, 0x44), childSkillIndex) + mstore(add(mainClaimDelayAction, 0x64), expenditureId) + mstore(add(mainClaimDelayAction, 0x84), 25) // expenditure storage slot + mstore(add(mainClaimDelayAction, 0xa4), 0xe0) // mask location + mstore(add(mainClaimDelayAction, 0xc4), 0x120) // keys location + mstore(add(mainClaimDelayAction, 0xe4), claimDelay) + mstore(add(mainClaimDelayAction, 0x104), 1) // mask length + mstore(add(mainClaimDelayAction, 0x124), 1) // offset + mstore(add(mainClaimDelayAction, 0x144), 1) // keys length + mstore(add(mainClaimDelayAction, 0x164), 4) // globalClaimDelay offset + } + return mainClaimDelayAction; + + // If we are editing an expenditure slot + } else { + + uint256 expenditureSlot; + + assembly { + expenditureSlot := mload(add(action, 0x184)) + } + + uint256 claimDelay = colony.getExpenditureSlot(expenditureId, expenditureSlot).claimDelay; + claimDelay = increment ? add(claimDelay, 365 days) : sub(claimDelay, 365 days); + + bytes memory slotClaimDelayAction = new bytes(4 + 32 * 13); // 420 bytes + + assembly { + mstore(add(slotClaimDelayAction, 0x20), functionSignature) + mstore(add(slotClaimDelayAction, 0x24), permissionDomainId) + mstore(add(slotClaimDelayAction, 0x44), childSkillIndex) + mstore(add(slotClaimDelayAction, 0x64), expenditureId) + mstore(add(slotClaimDelayAction, 0x84), 26) // expenditureSlot storage slot + mstore(add(slotClaimDelayAction, 0xa4), 0xe0) // mask location + mstore(add(slotClaimDelayAction, 0xc4), 0x140) // keys location + mstore(add(slotClaimDelayAction, 0xe4), claimDelay) + mstore(add(slotClaimDelayAction, 0x104), 2) // mask length + mstore(add(slotClaimDelayAction, 0x124), 0) // mapping + mstore(add(slotClaimDelayAction, 0x144), 1) // offset + mstore(add(slotClaimDelayAction, 0x164), 2) // keys length + mstore(add(slotClaimDelayAction, 0x184), expenditureSlot) + mstore(add(slotClaimDelayAction, 0x1a4), 1) // claimDelay offset + } + return slotClaimDelayAction; + + } + } + + function getReputationFromProof( + uint256 _motionId, + address _who, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + internal view returns (uint256) + { + bytes32 impliedRoot = getImpliedRootHashKey(_key, _value, _branchMask, _siblings); + require(motions[_motionId].rootHash == impliedRoot, "voting-invalid-root-hash"); + + uint256 reputationValue; + address keyColonyAddress; + uint256 keySkill; + address keyUserAddress; + + assembly { + reputationValue := mload(add(_value, 32)) + keyColonyAddress := mload(add(_key, 20)) + keySkill := mload(add(_key, 52)) + keyUserAddress := mload(add(_key, 72)) + } + + require(keyColonyAddress == address(colony), "voting-invalid-colony"); + require(keySkill == motions[_motionId].skillId, "voting-invalid-skill"); + require(keyUserAddress == _who, "voting-invalid-user"); + + return reputationValue; + } + +} diff --git a/contracts/extensions/VotingHybrid.sol b/contracts/extensions/VotingHybrid.sol new file mode 100644 index 0000000000..547d7a8d60 --- /dev/null +++ b/contracts/extensions/VotingHybrid.sol @@ -0,0 +1,188 @@ +/* + This file is part of The Colony Network. + + The Colony Network is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + The Colony Network is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with The Colony Network. If not, see . +*/ + +pragma solidity 0.7.3; +pragma experimental ABIEncoderV2; + +import "./../common/ERC20Extended.sol"; +import "./VotingBase.sol"; + + +contract VotingHybrid is VotingBase { + + uint256 constant NUM_INFLUENCES = 2; + + /// @notice Returns the identifier of the extension + function identifier() public override pure returns (bytes32) { + return keccak256("VotingHybrid"); + } + + /// @notice Return the version number + /// @return The version number + function version() public pure override returns (uint256) { + return 1; + } + + // [motionId] => lockId + mapping (uint256 => uint256) lockIds; + + // Public + + /// @notice Get the user influence in the motion + /// @param _motionId The id of the motion + /// @param _user The user in question + /// @param _key Reputation tree key for the root domain + /// @param _value Reputation tree value for the root domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function getInfluence( + uint256 _motionId, + address _user, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + view + returns (uint256[] memory influence) + { + influence = new uint256[](NUM_INFLUENCES); + influence[0] = getReputationFromProof(_motionId, _user, _key, _value, _branchMask, _siblings); + influence[1] = add( + tokenLocking.getUserLock(token, _user).balance, + add(stakes[_motionId][_user][NAY], stakes[_motionId][_user][YAY]) + ); + } + + function postSubmit(uint256 _motionId, address _user) internal override {} + + /// @notice Perform post-reveal bookkeeping + /// @param _motionId The id of the motion + /// @param _user The user in question + function postReveal(uint256 _motionId, address _user) internal override { + if (lockIds[_motionId] == 0) { + // This is the first reveal that has taken place in this motion. + // We lock the token for everyone to avoid double-counting, + lockIds[_motionId] = colony.lockToken(); + } + + colony.unlockTokenForUser(_user, lockIds[_motionId]); + } + + /// @notice Perform post-claim bookkeeping + /// @param _motionId The id of the motion + /// @param _user The user in question + function postClaim(uint256 _motionId, address _user) internal override { + uint256 lockCount = tokenLocking.getUserLock(token, _user).lockCount; + + // Lock may have already been released during reveal + if (lockCount < lockIds[_motionId]) { + colony.unlockTokenForUser(_user, lockIds[_motionId]); + } + } + + /// @notice Create a motion in the root domain + /// @param _altTarget The contract to which we send the action (0x0 for the colony) + /// @param _action A bytes array encoding a function call + /// @param _key Reputation tree key for the root domain + /// @param _value Reputation tree value for the root domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function createMotion( + address _altTarget, + bytes memory _action, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + notDeprecated + { + createMotionInternal(1, UINT256_MAX, _altTarget, _action, NUM_INFLUENCES); + motions[motionCount].maxVotes[0] = getReputationFromProof(motionCount, address(0x0), _key, _value, _branchMask, _siblings); + motions[motionCount].maxVotes[1] = ERC20Extended(token).totalSupply(); + } + + /// @notice Stake on a motion + /// @param _motionId The id of the motion + /// @param _permissionDomainId The domain where the extension has the arbitration permission + /// @param _childSkillIndex For the domain in which the motion is occurring + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + /// @param _amount The amount of tokens being staked + /// @param _key Reputation tree key for the staker/domain + /// @param _value Reputation tree value for the staker/domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function stakeMotion( + uint256 _motionId, + uint256 _permissionDomainId, + uint256 _childSkillIndex, + uint256 _vote, + uint256 _amount, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalStakeMotion(_motionId, _permissionDomainId, _childSkillIndex, _vote, _amount, influence); + } + + /// @notice Submit a vote secret for a motion + /// @param _motionId The id of the motion + /// @param _voteSecret The hashed vote secret + /// @param _key Reputation tree key for the staker/domain + /// @param _value Reputation tree value for the staker/domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function submitVote( + uint256 _motionId, + bytes32 _voteSecret, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalSubmitVote(_motionId, _voteSecret, influence); + } + + function revealVote( + uint256 _motionId, + bytes32 _salt, + uint256 _vote, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalRevealVote(_motionId, _salt, _vote, influence); + } + + function getLockId(uint256 _motionId) public view returns (uint256) { + return lockIds[_motionId]; + } +} diff --git a/contracts/extensions/VotingReputation.sol b/contracts/extensions/VotingReputation.sol index 6346763ce7..2ee4e0c020 100644 --- a/contracts/extensions/VotingReputation.sol +++ b/contracts/extensions/VotingReputation.sol @@ -91,7 +91,9 @@ contract VotingReputation is ColonyExtension, PatriciaTreeProofs, BasicMetaTrans uint256 submitPeriod; // Length of time for submitting votes uint256 revealPeriod; // Length of time for revealing votes uint256 escalationPeriod; // Length of time for escalating after a vote + mapping(address => uint256) metatransactionNonces; + function getMetatransactionNonce(address userAddress) override public view returns (uint256 nonce){ return metatransactionNonces[userAddress]; } diff --git a/contracts/extensions/VotingReputation2.sol b/contracts/extensions/VotingReputation2.sol new file mode 100644 index 0000000000..44e85ab7f0 --- /dev/null +++ b/contracts/extensions/VotingReputation2.sol @@ -0,0 +1,295 @@ +/* + This file is part of The Colony Network. + + The Colony Network is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + The Colony Network is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with The Colony Network. If not, see . +*/ + +pragma solidity 0.7.3; +pragma experimental ABIEncoderV2; + +import "./VotingBase.sol"; + + +contract VotingReputation2 is VotingBase { + + uint256 constant NUM_INFLUENCES = 1; + + /// @notice Returns the identifier of the extension + function identifier() public override pure returns (bytes32) { + return keccak256("VotingReputation2"); + } + + /// @notice Return the version number + /// @return The version number + function version() public pure override returns (uint256) { + return 1; + } + + // Public + + /// @notice Create a motion + /// @param _domainId The domain where we vote on the motion + /// @param _childSkillIndex The childSkillIndex pointing to the domain of the action + /// @param _altTarget The contract to which we send the action (0x0 for the colony) + /// @param _action A bytes array encoding a function call + /// @param _key Reputation tree key for the root domain + /// @param _value Reputation tree value for the root domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function createMotion( + uint256 _domainId, + uint256 _childSkillIndex, + address _altTarget, + bytes memory _action, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + notDeprecated + { + createMotionInternal(_domainId, _childSkillIndex, _altTarget, _action, NUM_INFLUENCES); + motions[motionCount].maxVotes[0] = getReputationFromProof(motionCount, address(0x0), _key, _value, _branchMask, _siblings); + } + + /// @notice Deprecated + /// @notice Create a motion in the root domain + /// @param _altTarget The contract to which we send the action (0x0 for the colony) + /// @param _action A bytes array encoding a function call + /// @param _key Reputation tree key for the root domain + /// @param _value Reputation tree value for the root domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function createRootMotion( + address _altTarget, + bytes memory _action, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + notDeprecated + { + createMotionInternal(1, UINT256_MAX, _altTarget, _action, NUM_INFLUENCES); + Motion storage motion = motions[motionCount]; + + motion.maxVotes[0] = getReputationFromProof(motionCount, address(0x0), _key, _value, _branchMask, _siblings); + } + + /// @notice Deprecated + /// @notice Create a motion in any domain + /// @param _domainId The domain where we vote on the motion + /// @param _childSkillIndex The childSkillIndex pointing to the domain of the action + /// @param _action A bytes array encoding a function call + /// @param _key Reputation tree key for the domain + /// @param _value Reputation tree value for the domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function createDomainMotion( + uint256 _domainId, + uint256 _childSkillIndex, + bytes memory _action, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + notDeprecated + { + createMotionInternal(_domainId, _childSkillIndex, address(0x0), _action, NUM_INFLUENCES); + Motion storage motion = motions[motionCount]; + + motion.maxVotes[0] = getReputationFromProof(motionCount, address(0x0), _key, _value, _branchMask, _siblings); + } + + /// @notice Get the user influence in the motion + /// @param _motionId The id of the motion + /// @param _user The user in question + /// @param _key Reputation tree key for the user + /// @param _value Reputation tree value for the user + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function getInfluence( + uint256 _motionId, + address _user, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + view + returns (uint256[] memory influence) + { + influence = new uint256[](NUM_INFLUENCES); + influence[0] = getReputationFromProof(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + } + + function postSubmit(uint256 _motionId, address _user) internal override { + Motion storage motion = motions[_motionId]; + + bool submissionsComplete = true; + + for (uint256 i; i < motion.votes.length; i++) { + submissionsComplete = submissionsComplete && + motion.totalVotes[i] >= wmul(motion.maxVotes[i], maxVoteFraction); + } + + if (submissionsComplete) { + motion.events[SUBMIT_END] = uint64(block.timestamp); + motion.events[REVEAL_END] = uint64(block.timestamp + revealPeriod); + + emit MotionEventSet(_motionId, SUBMIT_END); + } + } + + function postReveal(uint256 _motionId, address _user) internal override { + Motion storage motion = motions[_motionId]; + + // See if reputation revealed matches reputation submitted + bool fullyRevealed = true; + + for (uint256 j; j < motion.totalVotes.length && fullyRevealed; j++) { + fullyRevealed = fullyRevealed && + add(motion.votes[j][NAY], motion.votes[j][YAY]) == motion.totalVotes[j]; + } + + if (fullyRevealed) { + motion.events[REVEAL_END] = uint64(block.timestamp); + + emit MotionEventSet(_motionId, REVEAL_END); + } + } + + function postClaim(uint256 _motionId, address _user) internal override {} + + /// @notice Stake on a motion + /// @param _motionId The id of the motion + /// @param _permissionDomainId The domain where the extension has the arbitration permission + /// @param _childSkillIndex For the domain in which the motion is occurring + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + /// @param _amount The amount of tokens being staked + /// @param _key Reputation tree key for the staker/domain + /// @param _value Reputation tree value for the staker/domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function stakeMotion( + uint256 _motionId, + uint256 _permissionDomainId, + uint256 _childSkillIndex, + uint256 _vote, + uint256 _amount, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalStakeMotion(_motionId, _permissionDomainId, _childSkillIndex, _vote, _amount, influence); + } + + /// @notice Submit a vote secret for a motion + /// @param _motionId The id of the motion + /// @param _voteSecret The hashed vote secret + /// @param _key Reputation tree key for the staker/domain + /// @param _value Reputation tree value for the staker/domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function submitVote( + uint256 _motionId, + bytes32 _voteSecret, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalSubmitVote(_motionId, _voteSecret, influence); + } + + function revealVote( + uint256 _motionId, + bytes32 _salt, + uint256 _vote, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender, _key, _value, _branchMask, _siblings); + internalRevealVote(_motionId, _salt, _vote, influence); + } + + /// @notice Escalate a motion to a higher domain + /// @param _motionId The id of the motion + /// @param _newDomainId The desired domain of escalation + /// @param _childSkillIndex For the current domain, relative to the escalated domain + /// @param _key Reputation tree key for the new domain + /// @param _value Reputation tree value for the new domain + /// @param _branchMask The branchmask of the proof + /// @param _siblings The siblings of the proof + function escalateMotion( + uint256 _motionId, + uint256 _newDomainId, + uint256 _childSkillIndex, + bytes memory _key, + bytes memory _value, + uint256 _branchMask, + bytes32[] memory _siblings + ) + public + { + Motion storage motion = motions[_motionId]; + + require(getMotionState(_motionId) == MotionState.Closed, "voting-not-closed"); + + uint256 newDomainSkillId = getDomainSkillId(_newDomainId); + uint256 childSkillId = getChildSkillId(newDomainSkillId, _childSkillIndex); + require(childSkillId == motion.skillId, "voting-invalid-domain-proof"); + + uint256 domainId = motion.domainId; + motion.domainId = _newDomainId; + motion.skillId = newDomainSkillId; + motion.maxVotes[0] = getReputationFromProof(_motionId, address(0x0), _key, _value, _branchMask, _siblings); + + uint256 loser = (motion.votes[0][NAY] < motion.votes[0][YAY]) ? NAY : YAY; + motion.stakes[loser] = sub(motion.stakes[loser], motion.paidVoterComp); + motion.pastVoterComp[loser] = add(motion.pastVoterComp[loser], motion.paidVoterComp); + delete motion.paidVoterComp; + + uint256 requiredStake = getRequiredStake(_motionId); + motion.events[STAKE_END] = (motion.stakes[NAY] < requiredStake || motion.stakes[YAY] < requiredStake) ? + uint64(block.timestamp + stakePeriod) : uint64(block.timestamp); + + motion.events[SUBMIT_END] = motion.events[STAKE_END] + uint64(submitPeriod); + motion.events[REVEAL_END] = motion.events[SUBMIT_END] + uint64(revealPeriod); + + motion.escalated = true; + + emit MotionEscalated(_motionId, msg.sender, domainId, _newDomainId); + + if (motion.events[STAKE_END] <= uint64(block.timestamp)) { + emit MotionEventSet(_motionId, STAKE_END); + } + } +} diff --git a/contracts/extensions/VotingToken.sol b/contracts/extensions/VotingToken.sol new file mode 100644 index 0000000000..96f4aea384 --- /dev/null +++ b/contracts/extensions/VotingToken.sol @@ -0,0 +1,132 @@ +/* + This file is part of The Colony Network. + + The Colony Network is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + The Colony Network is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with The Colony Network. If not, see . +*/ + +pragma solidity 0.7.3; +pragma experimental ABIEncoderV2; + +import "./../common/ERC20Extended.sol"; +import "./VotingBase.sol"; + + +contract VotingToken is VotingBase { + + uint256 constant NUM_INFLUENCES = 1; + + /// @notice Returns the identifier of the extension + function identifier() public override pure returns (bytes32) { + return keccak256("VotingToken"); + } + + /// @notice Return the version number + /// @return The version number + function version() public pure override returns (uint256) { + return 1; + } + + // [motionId] => lockId + mapping (uint256 => uint256) lockIds; + + // Public + + /// @notice Get the user influence in the motion + /// @param _motionId The id of the motion + /// @param _user The user in question + function getInfluence(uint256 _motionId, address _user) + public + view + returns (uint256[] memory influence) + { + influence = new uint256[](NUM_INFLUENCES); + influence[0] = add( + tokenLocking.getUserLock(token, _user).balance, + add(stakes[_motionId][_user][NAY], stakes[_motionId][_user][YAY]) + ); + } + + function postSubmit(uint256 _motionId, address _user) internal override {} + + function postReveal(uint256 _motionId, address _user) internal override { + if (lockIds[_motionId] == 0) { + // This is the first reveal that has taken place in this motion. + // We lock the token for everyone to avoid double-counting, + lockIds[_motionId] = colony.lockToken(); + } + + colony.unlockTokenForUser(_user, lockIds[_motionId]); + } + + function postClaim(uint256 _motionId, address _user) internal override { + uint256 lockCount = tokenLocking.getUserLock(token, _user).lockCount; + + // Lock may have already been released during reveal + if (lockCount < lockIds[_motionId]) { + colony.unlockTokenForUser(_user, lockIds[_motionId]); + } + } + + /// @notice Create a motion in the root domain + /// @param _altTarget The contract to which we send the action (0x0 for the colony) + /// @param _action A bytes array encoding a function call + function createMotion(address _altTarget, bytes memory _action) + public + notDeprecated + { + createMotionInternal(1, UINT256_MAX, _altTarget, _action, NUM_INFLUENCES); + motions[motionCount].maxVotes[0] = ERC20Extended(token).totalSupply(); + } + + /// @notice Stake on a motion + /// @param _motionId The id of the motion + /// @param _permissionDomainId The domain where the extension has the arbitration permission + /// @param _childSkillIndex For the domain in which the motion is occurring + /// @param _vote The side being supported (0 = NAY, 1 = YAY) + /// @param _amount The amount of tokens being staked + function stakeMotion( + uint256 _motionId, + uint256 _permissionDomainId, + uint256 _childSkillIndex, + uint256 _vote, + uint256 _amount + ) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender); + internalStakeMotion(_motionId, _permissionDomainId, _childSkillIndex, _vote, _amount, influence); + } + + /// @notice Submit a vote secret for a motion + /// @param _motionId The id of the motion + /// @param _voteSecret The hashed vote secret + function submitVote(uint256 _motionId, bytes32 _voteSecret) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender); + internalSubmitVote(_motionId, _voteSecret, influence); + } + + function revealVote(uint256 _motionId, bytes32 _salt, uint256 _vote) + public + { + uint256[] memory influence = getInfluence(_motionId, msg.sender); + internalRevealVote(_motionId, _salt, _vote, influence); + } + + function getLockId(uint256 _motionId) public view returns (uint256) { + return lockIds[_motionId]; + } + +} diff --git a/contracts/testHelpers/TestExtensions.sol b/contracts/testHelpers/TestExtensions.sol index c2992874df..7caaff7eb5 100644 --- a/contracts/testHelpers/TestExtensions.sol +++ b/contracts/testHelpers/TestExtensions.sol @@ -66,7 +66,7 @@ contract TestExtension3 is TestExtension { } contract TestVotingToken is TestExtension { - function identifier() public pure override returns (bytes32) { return keccak256("VotingToken"); } + function identifier() public pure override returns (bytes32) { return keccak256("TestVotingToken"); } function version() public pure override returns (uint256) { return 1; } function lockToken() public returns (uint256) { return colony.lockToken(); diff --git a/migrations/9_setup_extensions.js b/migrations/9_setup_extensions.js index 038e73ad22..c388bbd71c 100644 --- a/migrations/9_setup_extensions.js +++ b/migrations/9_setup_extensions.js @@ -9,6 +9,9 @@ const EvaluatedExpenditure = artifacts.require("./EvaluatedExpenditure"); const FundingQueue = artifacts.require("./FundingQueue"); const OneTxPayment = artifacts.require("./OneTxPayment"); const VotingReputation = artifacts.require("./VotingReputation"); +const VotingReputation2 = artifacts.require("./VotingReputation2"); +const VotingToken = artifacts.require("./VotingToken"); +const VotingHybrid = artifacts.require("./VotingHybrid"); const TokenSupplier = artifacts.require("./TokenSupplier"); const Whitelist = artifacts.require("./Whitelist"); @@ -40,6 +43,9 @@ module.exports = async function (deployer, network, accounts) { await addExtension(colonyNetwork, "FundingQueue", FundingQueue); await addExtension(colonyNetwork, "OneTxPayment", OneTxPayment); await addExtension(colonyNetwork, "VotingReputation", VotingReputation); + await addExtension(colonyNetwork, "VotingReputation2", VotingReputation2); + await addExtension(colonyNetwork, "VotingToken", VotingToken); + await addExtension(colonyNetwork, "VotingHybrid", VotingHybrid); await addExtension(colonyNetwork, "TokenSupplier", TokenSupplier); await addExtension(colonyNetwork, "Whitelist", Whitelist); }; diff --git a/package.json b/package.json index c611028401..2650fdd9b8 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "generate:test:contracts": "bash ./scripts/generate-test-contracts.sh", "clean:test:contracts": "rimraf ./contracts/*Updated*.*", "clean:contracts": "rimraf ./build/contracts/*", - "clean:ganache": "rimraf ./ganache-chain-db/", + "clean:ganache": "rimraf ./ganache-chain-db/; rm ganache-accounts.json", "provision:token:contracts": "truffle compile && truffle compile --contracts_directory 'lib/dappsys/[!note][!stop][!proxy][!thing][!token]*.sol' && bash ./scripts/provision-token-contracts.sh", "start:blockchain:client": "bash ./scripts/start-blockchain-client.sh", "stop:blockchain:client": "bash ./scripts/stop-blockchain-client.sh", diff --git a/scripts/check-recovery.js b/scripts/check-recovery.js index fc0b173570..0ff08cdce4 100644 --- a/scripts/check-recovery.js +++ b/scripts/check-recovery.js @@ -49,7 +49,11 @@ walkSync("./contracts/").forEach((contractName) => { "contracts/extensions/FundingQueue.sol", "contracts/extensions/OneTxPayment.sol", "contracts/extensions/TokenSupplier.sol", + "contracts/extensions/VotingBase.sol", + "contracts/extensions/VotingHybrid.sol", "contracts/extensions/VotingReputation.sol", + "contracts/extensions/VotingReputation2.sol", + "contracts/extensions/VotingToken.sol", "contracts/extensions/Whitelist.sol", "contracts/gnosis/MultiSigWallet.sol", "contracts/patriciaTree/Bits.sol", diff --git a/scripts/check-storage.js b/scripts/check-storage.js index f32c85c1c7..c2484f5741 100644 --- a/scripts/check-storage.js +++ b/scripts/check-storage.js @@ -32,7 +32,11 @@ walkSync("./contracts/").forEach((contractName) => { "contracts/extensions/ColonyExtensionMeta.sol", "contracts/extensions/OneTxPayment.sol", "contracts/extensions/TokenSupplier.sol", + "contracts/extensions/VotingBase.sol", + "contracts/extensions/VotingHybrid.sol", "contracts/extensions/VotingReputation.sol", + "contracts/extensions/VotingReputation2.sol", + "contracts/extensions/VotingToken.sol", "contracts/extensions/Whitelist.sol", "contracts/gnosis/MultiSigWallet.sol", // Not directly used by any colony contracts "contracts/patriciaTree/PatriciaTreeBase.sol", // Only used by mining clients diff --git a/test-smoke/colony-storage-consistent.js b/test-smoke/colony-storage-consistent.js index 8a83d7a29e..b79ad23395 100644 --- a/test-smoke/colony-storage-consistent.js +++ b/test-smoke/colony-storage-consistent.js @@ -154,8 +154,8 @@ contract("Contract Storage", (accounts) => { console.log("miningCycleStateHash:", miningCycleAccount.stateRoot.toString("hex")); console.log("tokenLockingStateHash:", tokenLockingAccount.stateRoot.toString("hex")); - expect(colonyNetworkAccount.stateRoot.toString("hex")).to.equal("cbe7c27231f4c94f1fdff92a685599c6ada69af16a0f32ab3a72e85334a4a2ca"); - expect(colonyAccount.stateRoot.toString("hex")).to.equal("b198bd282eac14f7dc4d71817c4184462c6db7ae11a8f7662edecd4afbba6234"); + expect(colonyNetworkAccount.stateRoot.toString("hex")).to.equal("171572a74d1573190a3764941638089a7ff51611172986ba5c7e7115eb7d5d08"); + expect(colonyAccount.stateRoot.toString("hex")).to.equal("b535ed20810ef45ca468bb02b0e277e8a461a58844248ffd5ccee877def42170"); expect(metaColonyAccount.stateRoot.toString("hex")).to.equal("c91229b9b01734f45e65feea0561ed90bee1365c953ceb187e87e80e9a96ef86"); expect(miningCycleAccount.stateRoot.toString("hex")).to.equal("f1ae4c855f083837446bc31b3355ca0291daa363dd9afb655de51829a46fc635"); expect(tokenLockingAccount.stateRoot.toString("hex")).to.equal("860aa632a7a9a21119b0e27c30d0c3f4da5916eb5263c7870d4dda3eb7162e32"); diff --git a/test/contracts-network/colony-network-extensions.js b/test/contracts-network/colony-network-extensions.js index 45419d6c35..aeb8eda09c 100644 --- a/test/contracts-network/colony-network-extensions.js +++ b/test/contracts-network/colony-network-extensions.js @@ -49,7 +49,7 @@ contract("Colony Network Extensions", (accounts) => { const USER = accounts[2]; const TEST_EXTENSION = soliditySha3("TestExtension"); - const TEST_VOTING_TOKEN = soliditySha3("VotingToken"); + const TEST_VOTING_TOKEN = soliditySha3("TestVotingToken"); before(async () => { testExtension0Resolver = await Resolver.new(); diff --git a/test/contracts-network/token-locking.js b/test/contracts-network/token-locking.js index 2ada308eb8..3ffc6d2d83 100644 --- a/test/contracts-network/token-locking.js +++ b/test/contracts-network/token-locking.js @@ -337,7 +337,7 @@ contract("Token Locking", (addresses) => { const testVotingTokenResolver = await Resolver.new(); const testVotingToken = await TestVotingToken.new(); await setupEtherRouter("TestVotingToken", { TestVotingToken: testVotingToken.address }, testVotingTokenResolver); - TEST_VOTING_TOKEN = soliditySha3("VotingToken"); + TEST_VOTING_TOKEN = soliditySha3("TestVotingToken"); const metaColonyAddress = await colonyNetwork.getMetaColony(); const metaColony = await IMetaColony.at(metaColonyAddress); diff --git a/test/extensions/voting-hybrid.js b/test/extensions/voting-hybrid.js new file mode 100644 index 0000000000..046ae9b1f4 --- /dev/null +++ b/test/extensions/voting-hybrid.js @@ -0,0 +1,1371 @@ +/* globals artifacts */ + +import BN from "bn.js"; +import chai from "chai"; +import bnChai from "bn-chai"; +import shortid from "shortid"; +import { ethers } from "ethers"; +import { soliditySha3 } from "web3-utils"; + +import { UINT256_MAX, WAD, MINING_CYCLE_DURATION, SECONDS_PER_DAY, DEFAULT_STAKE, SUBMITTER_ONLY_WINDOW } from "../../helpers/constants"; + +import { + checkErrorRevert, + web3GetCode, + makeReputationKey, + makeReputationValue, + getActiveRepCycle, + forwardTime, + encodeTxData, + bn2bytes32, + expectEvent, +} from "../../helpers/test-helper"; + +import { + setupColonyNetwork, + setupMetaColonyWithLockedCLNYToken, + setupRandomColony, + giveUserCLNYTokensAndStake, +} from "../../helpers/test-data-generator"; + +import { setupEtherRouter } from "../../helpers/upgradable-contracts"; + +import PatriciaTree from "../../packages/reputation-miner/patricia"; + +const { expect } = chai; +chai.use(bnChai(web3.utils.BN)); + +const IReputationMiningCycle = artifacts.require("IReputationMiningCycle"); +const TokenLocking = artifacts.require("TokenLocking"); +const VotingHybrid = artifacts.require("VotingHybrid"); +const Resolver = artifacts.require("Resolver"); + +const VOTING_HYBRID = soliditySha3("VotingHybrid"); + +contract("Voting Hybrid", (accounts) => { + let colony; + let token; + let domain1; + let metaColony; + let colonyNetwork; + let tokenLocking; + + let voting; + + let reputationTree; + + let requiredStake; + + let domain1Key; + let domain1Value; + let domain1Mask; + let domain1Siblings; + + let user0Key; + let user0Value; + let user0Mask; + let user0Siblings; + + let user1Key; + let user1Value; + let user1Mask; + let user1Siblings; + + const TOTAL_STAKE_FRACTION = WAD.divn(1000); // 0.1 % + const USER_MIN_STAKE_FRACTION = WAD.divn(10); // 10 % + + const MAX_VOTE_FRACTION = WAD.divn(10).muln(8); // 80 % + const VOTER_REWARD_FRACTION = WAD.divn(10); // 10 % + + const STAKE_PERIOD = SECONDS_PER_DAY * 3; + const SUBMIT_PERIOD = SECONDS_PER_DAY * 2; + const REVEAL_PERIOD = SECONDS_PER_DAY * 2; + const ESCALATION_PERIOD = SECONDS_PER_DAY; + + const USER0 = accounts[0]; + const USER1 = accounts[1]; + const USER2 = accounts[2]; + const MINER = accounts[5]; + + const SALT = soliditySha3({ type: "string", value: shortid.generate() }); + const FAKE = soliditySha3({ type: "string", value: shortid.generate() }); + + const NAY = 0; + const YAY = 1; + + // const NULL = 0; + const STAKING = 1; + const SUBMIT = 2; + // const REVEAL = 3; + // const CLOSED = 4; + // const EXECUTABLE = 5; + // const EXECUTED = 6; + // const FAILED = 7; + + const ADDRESS_ZERO = ethers.constants.AddressZero; + const WAD32 = bn2bytes32(WAD); + const HALF = WAD.divn(2); + const YEAR = SECONDS_PER_DAY * 365; + + before(async () => { + colonyNetwork = await setupColonyNetwork(); + ({ metaColony } = await setupMetaColonyWithLockedCLNYToken(colonyNetwork)); + + await giveUserCLNYTokensAndStake(colonyNetwork, MINER, DEFAULT_STAKE); + await colonyNetwork.initialiseReputationMining(); + await colonyNetwork.startNextCycle(); + + const tokenLockingAddress = await colonyNetwork.getTokenLocking(); + tokenLocking = await TokenLocking.at(tokenLockingAddress); + + const votingImplementation = await VotingHybrid.new(); + const resolver = await Resolver.new(); + await setupEtherRouter("VotingHybrid", { VotingHybrid: votingImplementation.address }, resolver); + await metaColony.addExtensionToNetwork(VOTING_HYBRID, resolver.address); + }); + + beforeEach(async () => { + ({ colony, token } = await setupRandomColony(colonyNetwork)); + domain1 = await colony.getDomain(1); + + await colony.installExtension(VOTING_HYBRID, 1); + const votingAddress = await colonyNetwork.getExtensionInstallation(VOTING_HYBRID, colony.address); + voting = await VotingHybrid.at(votingAddress); + + await voting.initialise( + TOTAL_STAKE_FRACTION, + VOTER_REWARD_FRACTION, + USER_MIN_STAKE_FRACTION, + MAX_VOTE_FRACTION, + STAKE_PERIOD, + SUBMIT_PERIOD, + REVEAL_PERIOD, + ESCALATION_PERIOD + ); + + await colony.setRootRole(voting.address, true); + await colony.setArbitrationRole(1, UINT256_MAX, voting.address, 1, true); + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, true); + + const user0Influence = WAD; + const user1Influence = WAD.muln(2); + const user2Influence = WAD.divn(10000); + const totalInfluence = user0Influence.add(user1Influence).add(user2Influence); + + // Setup reputation values + + reputationTree = new PatriciaTree(); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId), // Colony total + makeReputationValue(totalInfluence, 1) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER0), // User0 + makeReputationValue(user0Influence, 2) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER1), // User1 + makeReputationValue(user1Influence, 3) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER2), // User2 + makeReputationValue(user2Influence, 4) + ); + + domain1Key = makeReputationKey(colony.address, domain1.skillId); + domain1Value = makeReputationValue(totalInfluence, 1); + [domain1Mask, domain1Siblings] = await reputationTree.getProof(domain1Key); + + user0Key = makeReputationKey(colony.address, domain1.skillId, USER0); + user0Value = makeReputationValue(user0Influence, 2); + [user0Mask, user0Siblings] = await reputationTree.getProof(user0Key); + + user1Key = makeReputationKey(colony.address, domain1.skillId, USER1); + user1Value = makeReputationValue(user1Influence, 3); + [user1Mask, user1Siblings] = await reputationTree.getProof(user1Key); + + const rootHash = await reputationTree.getRootHash(); + const repCycle = await getActiveRepCycle(colonyNetwork); + await forwardTime(MINING_CYCLE_DURATION, this); + await repCycle.submitRootHash(rootHash, 0, "0x00", 10, { from: MINER }); + await forwardTime(SUBMITTER_ONLY_WINDOW + 1, this); + await repCycle.confirmNewHash(0); + + // Setup token values + + await token.mint(USER0, user0Influence); + await token.mint(USER1, user1Influence); + await token.mint(USER2, user2Influence); + await token.approve(tokenLocking.address, user0Influence, { from: USER0 }); + await token.approve(tokenLocking.address, user1Influence, { from: USER1 }); + await token.approve(tokenLocking.address, user2Influence, { from: USER2 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user0Influence, true, { from: USER0 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user1Influence, true, { from: USER1 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user2Influence, true, { from: USER2 }); + await colony.approveStake(voting.address, 1, user0Influence, { from: USER0 }); + await colony.approveStake(voting.address, 1, user1Influence, { from: USER1 }); + await colony.approveStake(voting.address, 1, user2Influence, { from: USER2 }); + + // Add both reputation and token influence + requiredStake = totalInfluence.muln(2).divn(1000); + }); + + function hashExpenditureSlot(action) { + const preamble = 2 + 8 + 64 * 2; + return soliditySha3(`0x${action.slice(preamble, preamble + 64 * 4)}${"0".repeat(64)}${action.slice(preamble + 64 * 5, action.length)}`); + } + + describe("managing the extension", async () => { + it("can install the extension manually", async () => { + voting = await VotingHybrid.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.install(colony.address), "extension-already-installed"); + + const identifier = await voting.identifier(); + const version = await voting.version(); + expect(identifier).to.equal(VOTING_HYBRID); + expect(version).to.eq.BN(1); + + await voting.finishUpgrade(); + await voting.deprecate(true); + await voting.uninstall(); + + const code = await web3GetCode(voting.address); + expect(code).to.equal("0x"); + }); + + it("can install the extension with the extension manager", async () => { + ({ colony } = await setupRandomColony(colonyNetwork)); + await colony.installExtension(VOTING_HYBRID, 1, { from: USER0 }); + + await checkErrorRevert(colony.installExtension(VOTING_HYBRID, 1, { from: USER0 }), "colony-network-extension-already-installed"); + await checkErrorRevert(colony.uninstallExtension(VOTING_HYBRID, { from: USER1 }), "ds-auth-unauthorized"); + + await colony.uninstallExtension(VOTING_HYBRID, { from: USER0 }); + }); + + it("can deprecate the extension if root", async () => { + let deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(false); + + await checkErrorRevert(colony.deprecateExtension(VOTING_HYBRID, true, { from: USER2 }), "ds-auth-unauthorized"); + await colony.deprecateExtension(VOTING_HYBRID, true); + + // Cant make new motions! + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await checkErrorRevert( + voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings), + "colony-extension-deprecated" + ); + + deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(true); + }); + + it("cannot make a motion before initialised", async () => { + voting = await VotingHybrid.new(); + await voting.install(colony.address); + + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await checkErrorRevert(voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings), "voting-not-active"); + }); + + it("cannot initialise twice or more if not root", async () => { + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-already-initialised"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR, { from: USER2 }), "voting-not-root"); + }); + + it("cannot initialise with invalid values", async () => { + voting = await VotingHybrid.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.initialise(HALF.addn(1), HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF.addn(1), WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD.addn(1), WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD.addn(1), YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR + 1, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR + 1, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR + 1, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR + 1), "voting-invalid-value"); + }); + + it("can initialised with valid values and emit expected event", async () => { + voting = await VotingHybrid.new(); + await voting.install(colony.address); + + await expectEvent(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "ExtensionInitialised", []); + }); + + it("can query for initialisation values", async () => { + const totalStakeFraction = await voting.getTotalStakeFraction(); + const voterRewardFraction = await voting.getVoterRewardFraction(); + const userMinStakeFraction = await voting.getUserMinStakeFraction(); + const maxVoteFraction = await voting.getMaxVoteFraction(); + const stakePeriod = await voting.getStakePeriod(); + const submitPeriod = await voting.getSubmitPeriod(); + const revealPeriod = await voting.getRevealPeriod(); + const escalationPeriod = await voting.getEscalationPeriod(); + + expect(totalStakeFraction).to.eq.BN(TOTAL_STAKE_FRACTION); + expect(voterRewardFraction).to.eq.BN(VOTER_REWARD_FRACTION); + expect(userMinStakeFraction).to.eq.BN(USER_MIN_STAKE_FRACTION); + expect(maxVoteFraction).to.eq.BN(MAX_VOTE_FRACTION); + expect(stakePeriod).to.eq.BN(STAKE_PERIOD); + expect(submitPeriod).to.eq.BN(SUBMIT_PERIOD); + expect(revealPeriod).to.eq.BN(REVEAL_PERIOD); + expect(escalationPeriod).to.eq.BN(ESCALATION_PERIOD); + }); + }); + + describe("creating motions", async () => { + it("can create a root motion", async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("does not lock the token when a motion is created", async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId = await voting.getMotionCount(); + + const lockId = await voting.getLockId(motionId); + expect(lockId).to.be.zero; + }); + + it("can create a motion with an alternative target", async () => { + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await voting.createMotion(voting.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + }); + }); + + describe("staking on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("can stake on a motion", async () => { + const requiredStakeOnChain = await voting.getRequiredStake(motionId); + expect(requiredStake).to.eq.BN(requiredStakeOnChain); + + const half = requiredStake.divn(2); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const motion = await voting.getMotion(motionId); + expect(motion.stakes[0]).to.be.zero; + expect(motion.stakes[1]).to.eq.BN(requiredStake); + + const stake0 = await voting.getStake(motionId, USER0, YAY); + const stake1 = await voting.getStake(motionId, USER1, YAY); + expect(stake0).to.eq.BN(half); + expect(stake1).to.eq.BN(half); + }); + + it("can update the motion states correctly", async () => { + let motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + }); + + it("can stake even with a locked token", async () => { + await token.mint(colony.address, WAD); + await colony.setRewardInverse(100); + await colony.claimColonyFunds(token.address); + await colony.startNextRewardPayout(token.address, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const lock = await tokenLocking.getUserLock(token.address, voting.address); + expect(lock.balance).to.eq.BN(requiredStake.muln(2)); + }); + + it("cannot stake 0", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, 0, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-amount" + ); + }); + + it("cannot stake a nonexistent side", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, 2, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-vote" + ); + }); + + it("cannot stake less than the minStake, unless there is less than minStake to go", async () => { + const minStake = requiredStake.divn(10); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-insufficient-stake" + ); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Unless there's less than the minStake to go! + + const stake = requiredStake.sub(minStake.muln(2)).addn(1); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, stake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + }); + + it("can update the expenditure globalClaimDelay if voting on expenditure state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditure; + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("does not update the expenditure globalClaimDelay if the target is another colony", async () => { + const { colony: otherColony } = await setupRandomColony(colonyNetwork); + await otherColony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await otherColony.getExpenditureCount(); + await otherColony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(otherColony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 25, + [true], + [bn2bytes32(new BN(3))], + WAD32, + ]); + + await voting.createMotion(otherColony.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const expenditure = await otherColony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + }); + + it("can update the expenditure slot claimDelay if voting on expenditure slot state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payoutModifier to 1 for expenditure slot 0 + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on expenditure payout state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payout to WAD for expenditure slot 0, internal token + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on multiple expenditure states", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + let action; + + // Motion 1 + // Set finalizedTimestamp to WAD + action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Motion 2 + // Set payoutModifier to 1 for expenditure slot 0 + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Motion 3 + // Set payout to WAD for expenditure slot 0, internal token + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + const expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("cannot update the expenditure slot claimDelay if given an invalid action", async () => { + // Create a poorly-formed action (no keys) + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, 1, 0, [], [], ethers.constants.HashZero]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-lock-failed" + ); + }); + + it("can accurately track the number of motions for a single expenditure", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set payoutModifier to 1 for expenditure slot 0 + const action1 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + // Set payout to WAD for expenditure slot 0, internal token + const action2 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ethers.constants.AddressZero, action1, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(ethers.constants.AddressZero, action2, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await forwardTime(STAKE_PERIOD, this); + await voting.finalizeMotion(motionId1); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await voting.finalizeMotion(motionId2); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + }); + + it("cannot stake with someone else's reputation", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER1 }), + "voting-invalid-user" + ); + }); + + it("cannot stake with insufficient reputation", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(WAD.divn(10000), 4); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user2Key, user2Value, user2Mask, user2Siblings, { from: USER2 }), + "voting-insufficient-influence" + ); + }); + + it("cannot stake once time runs out", async () => { + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-staking" + ); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }), + "voting-not-staking" + ); + }); + }); + + describe("voting on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + }); + + it("can rate and reveal for a motion", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + }); + + it("locks the token when the first reveal is made", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + let lockId = await voting.getLockId(motionId); + expect(lockId).to.be.zero; + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + lockId = await voting.getLockId(motionId); + expect(lockId).to.not.be.zero; + }); + + it("can unlock the token once revealed", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const lockId = await voting.getLockId(motionId); + const { lockCount } = await tokenLocking.getUserLock(token.address, USER0); + expect(lockCount).to.eq.BN(lockId); + }); + + it("can tally votes from two users", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + // See final counts + const { votes } = await voting.getMotion(motionId); + expect(votes[0][0]).to.be.zero; + expect(votes[0][1]).to.eq.BN(WAD.muln(3)); + }); + + it("can update votes, but just the last one counts", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + // Revealing first vote fails + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + + // Revealing second succeeds + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + }); + + it("can update votes, but the total reputation does not change", async () => { + let motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.be.zero; + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + }); + + it("cannot reveal an invalid vote", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, 2), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, 2, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-vote" + ); + }); + + it("cannot reveal a vote twice, and so cannot vote twice", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + }); + + it("can vote in two motions with two reputation states, with different proofs", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const oldRootHash = await reputationTree.getRootHash(); + + // Update reputation state + const user0Value2 = makeReputationValue(WAD.muln(2), 2); + await reputationTree.insert(user0Key, user0Value2); + + const [domain1Mask2, domain1Siblings2] = await reputationTree.getProof(domain1Key); + const [user0Mask2, user0Siblings2] = await reputationTree.getProof(user0Key); + const [user1Mask2, user1Siblings2] = await reputationTree.getProof(user1Key); + + // Set new rootHash + const rootHash = await reputationTree.getRootHash(); + expect(oldRootHash).to.not.equal(rootHash); + + await forwardTime(MINING_CYCLE_DURATION, this); + + const repCycle = await getActiveRepCycle(colonyNetwork); + await repCycle.submitRootHash(rootHash, 0, "0x00", 10, { from: MINER }); + await forwardTime(SUBMITTER_ONLY_WINDOW + 1, this); + await repCycle.confirmNewHash(0); + + // Create new motion with new reputation state + await voting.createMotion(ADDRESS_ZERO, FAKE, domain1Key, domain1Value, domain1Mask2, domain1Siblings2); + const motionId2 = await voting.getMotionCount(); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask2, user1Siblings2, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, NAY), user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId2, SALT, NAY, user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + }); + + it("cannot submit a null vote", async () => { + await checkErrorRevert( + voting.submitVote(motionId, "0x0", user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-invalid-secret" + ); + }); + + it("cannot submit a vote if voting is closed", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-open" + ); + }); + + it("cannot reveal a vote on a non-existent motion", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(0, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-invalid-root-hash" + ); + }); + + it("cannot reveal a vote during the submit period", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-reveal" + ); + }); + + it("cannot reveal a vote after the reveal period ends", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + await forwardTime(REVEAL_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-reveal" + ); + }); + + it("cannot reveal a vote with a bad secret", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + }); + }); + + describe("executing motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("cannot execute a non-existent motion", async () => { + await checkErrorRevert(voting.finalizeMotion(0), "voting-not-finalizable"); + }); + + it("motion has no effect if extension does not have permissions", async () => { + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, false); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + const tasksBefore = await colony.getTaskCount(); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + + const tasksAfter = await colony.getTaskCount(); + expect(tasksAfter).to.eq.BN(tasksBefore); + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, true); + }); + + it("cannot take an action if there is insufficient support", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake.subn(1), user0Key, user0Value, user0Mask, user0Siblings, { + from: USER0, + }); + + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if there is insufficient opposition", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake.subn(1), user1Key, user1Value, user1Mask, user1Siblings, { + from: USER1, + }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action with a return value", async () => { + // Returns a uint256 + const action = await encodeTxData(colony, "version", []); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action with an arbitrary target", async () => { + const { colony: otherColony, token: otherToken } = await setupRandomColony(colonyNetwork); + await otherToken.mint(otherColony.address, WAD, { from: USER0 }); + + const action = await encodeTxData(colony, "claimColonyFunds", [otherToken.address]); + await voting.createMotion(otherColony.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const balanceBefore = await otherColony.getFundingPotBalance(1, otherToken.address); + expect(balanceBefore).to.be.zero; + + await voting.finalizeMotion(motionId); + + const balanceAfter = await otherColony.getFundingPotBalance(1, otherToken.address); + expect(balanceAfter).to.eq.BN(WAD); + }); + + it("can take a nonexistent action", async () => { + const action = soliditySha3("foo"); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action during staking or voting", async () => { + let motionState; + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("cannot take an action twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if the motion passes", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Don't need to wait for the reveal period, since 100% of the secret is revealed + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("cannot take an action if the motion fails", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action if there is insufficient voting power (state change actions)", async () => { + // Set globalClaimDelay to WAD + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(4))], WAD32]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId1, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId1, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId1, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + let logs; + ({ logs } = await voting.finalizeMotion(motionId1)); + expect(logs[0].args.executed).to.be.true; + + // Create another motion for the same variable + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId2, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + ({ logs } = await voting.finalizeMotion(motionId2)); + expect(logs[0].args.executed).to.be.false; + }); + + it("can set vote power correctly after a vote", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(WAD.muln(2)); // USER0 had 1 WAD each of reputation and tokens + }); + + it("can use vote power correctly for different values of the same variable", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set finalizedTimestamp + const action1 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + const action2 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], "0x0"]); + + await voting.createMotion(ADDRESS_ZERO, action1, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(ADDRESS_ZERO, action2, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + // First motion goes through + await voting.finalizeMotion(motionId1); + let expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + + // Second motion does not because of insufficient vote power + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + }); + + it("can set vote power correctly if there is insufficient opposition", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(ethers.constants.AddressZero, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(requiredStake); + }); + }); + + describe("claiming rewards", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("cannot claim rewards from a non-existent motion", async () => { + await checkErrorRevert(voting.claimReward(0, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + + it("can let stakers claim rewards, based on the stake outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const nayStake = requiredStake.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + // Note that no voter rewards were paid out + const expectedReward0 = requiredStake.add(requiredStake.divn(20)); // 110% of stake + const expectedReward1 = requiredStake.divn(20).muln(9); // 90% of stake + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user0 has no penalty, while user1 has a 10% penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER1); + expect(repUpdate.amount).to.eq.BN(requiredStake.divn(20).neg()); + }); + + it("can let stakers claim rewards, based on the vote outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(REVEAL_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const motion = await voting.getMotion(motionId); + const loserStake = requiredStake.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.divn(3).muln(2).addn(1); // (stake * .8) * (winPct = 1/3 * 2) + dust + const expectedReward1 = requiredStake.add(loserStake.divn(3)); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user1 has no penalty, while user0 has a 1/3 penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER0); + expect(repUpdate.amount).to.eq.BN(requiredStake.sub(expectedReward0).neg()); + }); + + it("can let stakers claim their original stake if neither side fully staked", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const half = requiredStake.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, half, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + expect(numEntriesPrev).to.eq.BN(numEntriesPost); + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(half); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(half); + }); + + it("cannot claim rewards twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-nothing-to-claim"); + }); + + it("cannot claim rewards before a motion is finalized", async () => { + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + + it("can unlock the token after claiming", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(WAD.divn(10000), 4); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user2Key, user2Value, user2Mask, user2Siblings, { from: USER2 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user2Key, user2Value, user2Mask, user2Siblings, { from: USER2 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + let lockCount; + const lockId = await voting.getLockId(motionId); + + ({ lockCount } = await tokenLocking.getUserLock(token.address, USER1)); + expect(lockCount).to.be.zero; + + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + ({ lockCount } = await tokenLocking.getUserLock(token.address, USER1)); + expect(lockCount).to.eq.BN(lockId); + }); + }); +}); diff --git a/test/extensions/voting-rep-2.js b/test/extensions/voting-rep-2.js new file mode 100644 index 0000000000..da6cee8f11 --- /dev/null +++ b/test/extensions/voting-rep-2.js @@ -0,0 +1,2011 @@ +/* globals artifacts */ + +import BN from "bn.js"; +import chai from "chai"; +import bnChai from "bn-chai"; +import shortid from "shortid"; +import { ethers } from "ethers"; +import { soliditySha3 } from "web3-utils"; + +import { UINT256_MAX, WAD, MINING_CYCLE_DURATION, SECONDS_PER_DAY, DEFAULT_STAKE, SUBMITTER_ONLY_WINDOW } from "../../helpers/constants"; + +import { + checkErrorRevert, + web3GetCode, + makeReputationKey, + makeReputationValue, + getActiveRepCycle, + forwardTime, + encodeTxData, + bn2bytes32, + expectEvent, +} from "../../helpers/test-helper"; + +import { + setupColonyNetwork, + setupMetaColonyWithLockedCLNYToken, + setupRandomColony, + giveUserCLNYTokensAndStake, +} from "../../helpers/test-data-generator"; + +import { setupEtherRouter } from "../../helpers/upgradable-contracts"; + +import PatriciaTree from "../../packages/reputation-miner/patricia"; + +const { expect } = chai; +chai.use(bnChai(web3.utils.BN)); + +const IReputationMiningCycle = artifacts.require("IReputationMiningCycle"); +const TokenLocking = artifacts.require("TokenLocking"); +const VotingReputation2 = artifacts.require("VotingReputation2"); +const OneTxPayment = artifacts.require("OneTxPayment"); +const Resolver = artifacts.require("Resolver"); +const ColonyExtension = artifacts.require("ColonyExtension"); + +const VOTING_REPUTATION = soliditySha3("VotingReputation2"); + +contract("Voting Reputation 2", (accounts) => { + let colony; + let token; + let domain1; + let domain2; + let domain3; + let metaColony; + let colonyNetwork; + let tokenLocking; + let reputationVotingVersion; + + let voting; + + let reputationTree; + + let domain1Key; + let domain1Value; + let domain1Mask; + let domain1Siblings; + + let user0Key; + let user0Value; + let user0Mask; + let user0Siblings; + + let user1Key; + let user1Value; + let user1Mask; + let user1Siblings; + + const TOTAL_STAKE_FRACTION = WAD.divn(1000); // 0.1 % + const USER_MIN_STAKE_FRACTION = WAD.divn(10); // 10 % + + const MAX_VOTE_FRACTION = WAD.divn(10).muln(8); // 80 % + const VOTER_REWARD_FRACTION = WAD.divn(10); // 10 % + + const STAKE_PERIOD = SECONDS_PER_DAY * 3; + const SUBMIT_PERIOD = SECONDS_PER_DAY * 2; + const REVEAL_PERIOD = SECONDS_PER_DAY * 2; + const ESCALATION_PERIOD = SECONDS_PER_DAY; + + const USER0 = accounts[0]; + const USER1 = accounts[1]; + const USER2 = accounts[2]; + const MINER = accounts[5]; + + const SALT = soliditySha3({ type: "string", value: shortid.generate() }); + const FAKE = soliditySha3({ type: "string", value: shortid.generate() }); + + const NAY = 0; + const YAY = 1; + + // const NULL = 0; + const STAKING = 1; + const SUBMIT = 2; + // const REVEAL = 3; + // const CLOSED = 4; + const EXECUTABLE = 5; + // const EXECUTED = 6; + const FAILED = 7; + + const ADDRESS_ZERO = ethers.constants.AddressZero; + const REQUIRED_STAKE = WAD.muln(3).divn(1000); + const REQUIRED_STAKE_DOMAIN_2 = WAD.divn(1000); + const WAD32 = bn2bytes32(WAD); + const HALF = WAD.divn(2); + const YEAR = SECONDS_PER_DAY * 365; + + before(async () => { + colonyNetwork = await setupColonyNetwork(); + ({ metaColony } = await setupMetaColonyWithLockedCLNYToken(colonyNetwork)); + + await giveUserCLNYTokensAndStake(colonyNetwork, MINER, DEFAULT_STAKE); + await colonyNetwork.initialiseReputationMining(); + await colonyNetwork.startNextCycle(); + + const tokenLockingAddress = await colonyNetwork.getTokenLocking(); + tokenLocking = await TokenLocking.at(tokenLockingAddress); + + const votingImplementation = await VotingReputation2.new(); + const resolver = await Resolver.new(); + await setupEtherRouter("VotingReputation2", { VotingReputation2: votingImplementation.address }, resolver); + await metaColony.addExtensionToNetwork(VOTING_REPUTATION, resolver.address); + const versionSig = await resolver.stringToSig("version()"); + const target = await resolver.lookup(versionSig); + const extensionImplementation = await ColonyExtension.at(target); + reputationVotingVersion = await extensionImplementation.version(); + }); + + beforeEach(async () => { + ({ colony, token } = await setupRandomColony(colonyNetwork)); + + // 1 => { 2, 3 } + await colony.addDomain(1, UINT256_MAX, 1); + await colony.addDomain(1, UINT256_MAX, 1); + domain1 = await colony.getDomain(1); + domain2 = await colony.getDomain(2); + domain3 = await colony.getDomain(3); + + await colony.installExtension(VOTING_REPUTATION, reputationVotingVersion); + const votingAddress = await colonyNetwork.getExtensionInstallation(VOTING_REPUTATION, colony.address); + voting = await VotingReputation2.at(votingAddress); + + await voting.initialise( + TOTAL_STAKE_FRACTION, + VOTER_REWARD_FRACTION, + USER_MIN_STAKE_FRACTION, + MAX_VOTE_FRACTION, + STAKE_PERIOD, + SUBMIT_PERIOD, + REVEAL_PERIOD, + ESCALATION_PERIOD + ); + + await colony.setRootRole(voting.address, true); + await colony.setArbitrationRole(1, UINT256_MAX, voting.address, 1, true); + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, true); + + await token.mint(USER0, WAD); + await token.mint(USER1, WAD); + await token.mint(USER2, WAD); + await token.approve(tokenLocking.address, WAD, { from: USER0 }); + await token.approve(tokenLocking.address, WAD, { from: USER1 }); + await token.approve(tokenLocking.address, WAD, { from: USER2 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, WAD, true, { from: USER0 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, WAD, true, { from: USER1 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, WAD, true, { from: USER2 }); + await colony.approveStake(voting.address, 1, WAD, { from: USER0 }); + await colony.approveStake(voting.address, 1, WAD, { from: USER1 }); + await colony.approveStake(voting.address, 1, WAD, { from: USER2 }); + + reputationTree = new PatriciaTree(); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId), // Colony total + makeReputationValue(WAD.muln(3), 1) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER0), // User0 + makeReputationValue(WAD, 2) + ); + await reputationTree.insert( + makeReputationKey(metaColony.address, domain1.skillId, USER0), // Wrong colony + makeReputationValue(WAD, 3) + ); + await reputationTree.insert( + makeReputationKey(colony.address, 1234, USER0), // Wrong skill + makeReputationValue(WAD, 4) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER1), // User1 (and 2x value) + makeReputationValue(WAD.muln(2), 5) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain2.skillId), // Colony total, domain 2 + makeReputationValue(WAD, 6) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain3.skillId), // Colony total, domain 3 + makeReputationValue(WAD.muln(3), 7) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain1.skillId, USER2), // User2, very little rep + makeReputationValue(REQUIRED_STAKE.subn(1), 8) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain2.skillId, USER0), // User0, domain 2 + makeReputationValue(WAD.divn(3), 9) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain2.skillId, USER1), // User1, domain 2 + makeReputationValue(WAD.divn(3).muln(2), 10) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain3.skillId, USER0), // User0, domain 3 + makeReputationValue(WAD, 11) + ); + await reputationTree.insert( + makeReputationKey(colony.address, domain3.skillId, USER1), // User1, domain 3 + makeReputationValue(WAD.muln(2), 12) + ); + + domain1Key = makeReputationKey(colony.address, domain1.skillId); + domain1Value = makeReputationValue(WAD.muln(3), 1); + [domain1Mask, domain1Siblings] = await reputationTree.getProof(domain1Key); + + user0Key = makeReputationKey(colony.address, domain1.skillId, USER0); + user0Value = makeReputationValue(WAD, 2); + [user0Mask, user0Siblings] = await reputationTree.getProof(user0Key); + + user1Key = makeReputationKey(colony.address, domain1.skillId, USER1); + user1Value = makeReputationValue(WAD.muln(2), 5); + [user1Mask, user1Siblings] = await reputationTree.getProof(user1Key); + + const rootHash = await reputationTree.getRootHash(); + const repCycle = await getActiveRepCycle(colonyNetwork); + await forwardTime(MINING_CYCLE_DURATION, this); + await repCycle.submitRootHash(rootHash, 0, "0x00", 10, { from: MINER }); + await forwardTime(SUBMITTER_ONLY_WINDOW + 1, this); + await repCycle.confirmNewHash(0); + }); + + function hashExpenditureSlot(action) { + const preamble = 2 + 8 + 64 * 2; + return soliditySha3(`0x${action.slice(preamble, preamble + 64 * 4)}${"0".repeat(64)}${action.slice(preamble + 64 * 5, action.length)}`); + } + + describe("managing the extension", async () => { + it("can install the extension manually", async () => { + voting = await VotingReputation2.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.install(colony.address), "extension-already-installed"); + + const identifier = await voting.identifier(); + const version = await voting.version(); + expect(identifier).to.equal(VOTING_REPUTATION); + expect(version).to.eq.BN(reputationVotingVersion); + + const capabilityRoles = await voting.getCapabilityRoles("0x0"); + expect(capabilityRoles).to.equal(ethers.constants.HashZero); + + await voting.finishUpgrade(); + await voting.deprecate(true); + await voting.uninstall(); + + const code = await web3GetCode(voting.address); + expect(code).to.equal("0x"); + }); + + it("can install the extension with the extension manager", async () => { + ({ colony } = await setupRandomColony(colonyNetwork)); + await colony.installExtension(VOTING_REPUTATION, reputationVotingVersion, { from: USER0 }); + + await checkErrorRevert( + colony.installExtension(VOTING_REPUTATION, reputationVotingVersion, { from: USER0 }), + "colony-network-extension-already-installed" + ); + await checkErrorRevert(colony.uninstallExtension(VOTING_REPUTATION, { from: USER1 }), "ds-auth-unauthorized"); + + await colony.uninstallExtension(VOTING_REPUTATION, { from: USER0 }); + }); + + it("can deprecate the extension if root", async () => { + let deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(false); + + await checkErrorRevert(colony.deprecateExtension(VOTING_REPUTATION, true, { from: USER2 }), "ds-auth-unauthorized"); + await colony.deprecateExtension(VOTING_REPUTATION, true); + + // Can't make new motions! + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await checkErrorRevert( + voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings), + "colony-extension-deprecated" + ); + + deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(true); + }); + + it("can initialise with valid values and emit expected event", async () => { + voting = await VotingReputation2.new(); + await voting.install(colony.address); + + await expectEvent(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "ExtensionInitialised", []); + }); + + it("cannot make a motion before initialised", async () => { + voting = await VotingReputation2.new(); + await voting.install(colony.address); + + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await checkErrorRevert( + voting.createRootMotion(ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings), + "voting-not-active" + ); + }); + + it("cannot initialise twice or more if not root", async () => { + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-already-initialised"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR, { from: USER2 }), "voting-not-root"); + }); + + it("cannot initialise with invalid values", async () => { + voting = await VotingReputation2.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.initialise(HALF.addn(1), HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF.addn(1), WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD.addn(1), WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD.addn(1), YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR + 1, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR + 1, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR + 1, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR + 1), "voting-invalid-value"); + }); + + it("can query for initialisation values", async () => { + const totalStakeFraction = await voting.getTotalStakeFraction(); + const voterRewardFraction = await voting.getVoterRewardFraction(); + const userMinStakeFraction = await voting.getUserMinStakeFraction(); + const maxVoteFraction = await voting.getMaxVoteFraction(); + const stakePeriod = await voting.getStakePeriod(); + const submitPeriod = await voting.getSubmitPeriod(); + const revealPeriod = await voting.getRevealPeriod(); + const escalationPeriod = await voting.getEscalationPeriod(); + + expect(totalStakeFraction).to.eq.BN(TOTAL_STAKE_FRACTION); + expect(voterRewardFraction).to.eq.BN(VOTER_REWARD_FRACTION); + expect(userMinStakeFraction).to.eq.BN(USER_MIN_STAKE_FRACTION); + expect(maxVoteFraction).to.eq.BN(MAX_VOTE_FRACTION); + expect(stakePeriod).to.eq.BN(STAKE_PERIOD); + expect(submitPeriod).to.eq.BN(SUBMIT_PERIOD); + expect(revealPeriod).to.eq.BN(REVEAL_PERIOD); + expect(escalationPeriod).to.eq.BN(ESCALATION_PERIOD); + }); + }); + + describe("creating motions", async () => { + it("can create a root motion", async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("can create a domain motion in the root domain", async () => { + // Create motion in domain of action (1) + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("cannot create a domain motion in the root domain with an invalid reputation proof", async () => { + // Create motion in domain of action (1) + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await checkErrorRevert( + voting.createMotion(1, 0, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings), + "voting-invalid-domain" + ); + }); + + it("can create a domain motion in a child domain", async () => { + const key = makeReputationKey(colony.address, domain2.skillId); + const value = makeReputationValue(WAD, 6); + const [mask, siblings] = await reputationTree.getProof(key); + + // Create motion in domain of action (2) + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await voting.createMotion(2, UINT256_MAX, ADDRESS_ZERO, action, key, value, mask, siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain2.skillId); + }); + + it("can externally escalate a domain motion", async () => { + // Create motion in parent domain (1) of action (2) + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await voting.createMotion(1, 0, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("can create a root motion with an alternative target", async () => { + const { colony: otherColony } = await setupRandomColony(colonyNetwork); + + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, otherColony.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + }); + + it("can create a domain motion with an alternative target", async () => { + const oneTxPayment = await OneTxPayment.new(); + await oneTxPayment.install(colony.address); + + const action = await encodeTxData(oneTxPayment, "makePaymentFundedFromDomain", [1, 0, 1, 0, [USER0], [token.address], [10], 2, 0]); + await voting.createMotion(1, 0, oneTxPayment.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("cannot create a domain motion with an action in a higher domain", async () => { + const key = makeReputationKey(colony.address, domain2.skillId); + const value = makeReputationValue(WAD, 6); + const [mask, siblings] = await reputationTree.getProof(key); + + // Action in domain 1, motion in domain 2 + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + + await checkErrorRevert(voting.createMotion(2, UINT256_MAX, ADDRESS_ZERO, action, key, value, mask, siblings), "voting-invalid-domain"); + }); + + it("cannot externally escalate a domain motion with an invalid domain proof", async () => { + const key = makeReputationKey(colony.address, domain3.skillId); + const value = makeReputationValue(WAD.muln(3), 7); + const [mask, siblings] = await reputationTree.getProof(key); + + // Action in (2) but proof for (3) + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await checkErrorRevert(voting.createMotion(1, 1, ADDRESS_ZERO, action, key, value, mask, siblings), "voting-invalid-domain"); + }); + + it("can create a motion using the deprecated interfaces", async () => { + const rootAction = await encodeTxData(colony, "mintTokens", [WAD]); + const domainAction = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + + await voting.createRootMotion(ADDRESS_ZERO, rootAction, domain1Key, domain1Value, domain1Mask, domain1Siblings); + await voting.createDomainMotion(1, UINT256_MAX, domainAction, domain1Key, domain1Value, domain1Mask, domain1Siblings); + }); + + it("when creating a motion for moveFundsBetweenPots, permissions are correctly respected", async () => { + // Move funds between domain 2 and domain 3 pots using the old deprecated function + // This should not be allowed - it doesn't conform to the standard permission proofs, and so can't + // be checked + let action = await encodeTxData(colony, "moveFundsBetweenPots", [1, 0, 1, domain2.fundingPotId, domain3.fundingPotId, WAD, token.address]); + const key = makeReputationKey(colony.address, domain2.skillId); + const value = makeReputationValue(WAD, 6); + const [mask, siblings] = await reputationTree.getProof(key); + checkErrorRevert(voting.createMotion(2, UINT256_MAX, ADDRESS_ZERO, action, key, value, mask, siblings), "voting-bad-function"); + + // Now we make an action with the new moveFundsBetweenPots + action = await encodeTxData(colony, "moveFundsBetweenPots", [ + 1, + UINT256_MAX, + 1, + 0, + 1, + domain2.fundingPotId, + domain3.fundingPotId, + WAD, + token.address, + ]); + + // This is not allowed to be created in domain 2 + await checkErrorRevert(voting.createMotion(2, UINT256_MAX, ADDRESS_ZERO, action, key, value, mask, siblings), "voting-invalid-domain"); + + // But is in the root domain + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + }); + }); + + describe("staking on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("can stake on a motion", async () => { + const half = REQUIRED_STAKE.divn(2); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const motion = await voting.getMotion(motionId); + expect(motion.stakes[0]).to.be.zero; + expect(motion.stakes[1]).to.eq.BN(REQUIRED_STAKE); + + const stake0 = await voting.getStake(motionId, USER0, YAY); + const stake1 = await voting.getStake(motionId, USER1, YAY); + expect(stake0).to.eq.BN(half); + expect(stake1).to.eq.BN(half); + }); + + it("can update the motion states correctly", async () => { + let motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + }); + + it("can stake even with a locked token", async () => { + await token.mint(colony.address, WAD); + await colony.setRewardInverse(100); + await colony.claimColonyFunds(token.address); + await colony.startNextRewardPayout(token.address, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const lock = await tokenLocking.getUserLock(token.address, voting.address); + expect(lock.balance).to.eq.BN(REQUIRED_STAKE.muln(2)); + }); + + it("cannot stake 0", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, 0, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-amount" + ); + }); + + it("cannot stake a nonexistent side", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, 2, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-vote" + ); + }); + + it("cannot stake less than the minStake, unless there is less than minStake to go", async () => { + const minStake = REQUIRED_STAKE.divn(10); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-insufficient-stake" + ); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Unless there's less than the minStake to go! + + const stake = REQUIRED_STAKE.sub(minStake.muln(2)).addn(1); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, stake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + }); + + it("can update the expenditure globalClaimDelay if voting on expenditure state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditure; + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("does not update the expenditure globalClaimDelay if the target is another colony", async () => { + const { colony: otherColony } = await setupRandomColony(colonyNetwork); + await otherColony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await otherColony.getExpenditureCount(); + await otherColony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(otherColony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 25, + [true], + [bn2bytes32(new BN(3))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, otherColony.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const expenditure = await otherColony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + }); + + it("can update the expenditure slot claimDelay if voting on expenditure slot state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payoutModifier to 1 for expenditure slot 0 + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on expenditure payout state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payout to WAD for expenditure slot 0, internal token + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on multiple expenditure states", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + let action; + + // Motion 1 + // Set finalizedTimestamp to WAD + action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Motion 2 + // Set payoutModifier to 1 for expenditure slot 0 + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Motion 3 + // Set payout to WAD for expenditure slot 0, internal token + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + const expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("cannot update the expenditure slot claimDelay if given an invalid action", async () => { + // Create a poorly-formed action (no keys) + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, 1, 0, [], [], ethers.constants.HashZero]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-lock-failed" + ); + }); + + it("can accurately track the number of motions for a single expenditure", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set payoutModifier to 1 for expenditure slot 0 + const action1 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + // Set payout to WAD for expenditure slot 0, internal token + const action2 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action1, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action2, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await forwardTime(STAKE_PERIOD, this); + await voting.finalizeMotion(motionId1); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await voting.finalizeMotion(motionId2); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + }); + + it("cannot stake with someone else's reputation", async () => { + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER1 }), + "voting-invalid-user" + ); + }); + + it("cannot stake with insufficient reputation", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(REQUIRED_STAKE.subn(1), 8); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user2Key, user2Value, user2Mask, user2Siblings, { from: USER2 }), + "voting-insufficient-influence" + ); + }); + + it("cannot stake once time runs out", async () => { + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-staking" + ); + + await checkErrorRevert( + voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }), + "voting-not-staking" + ); + }); + }); + + describe("voting on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + }); + + it("can rate and reveal for a motion", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + + const expectedReward = REQUIRED_STAKE.muln(2).mul(VOTER_REWARD_FRACTION).div(WAD); + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward); + }); + + it("can tally votes from two users", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + // See final counts + const { votes } = await voting.getMotion(motionId); + expect(votes[0][0]).to.be.zero; + expect(votes[0][1]).to.eq.BN(WAD.muln(3)); + }); + + it("rewards users for voting appropriately", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + // Two users voted, check reward split appropriately + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const expectedReward0 = WAD.divn(3).mul(REQUIRED_STAKE).muln(2).mul(VOTER_REWARD_FRACTION).div(WAD).div(WAD); + const expectedReward1 = WAD.muln(2).divn(3).mul(REQUIRED_STAKE).muln(2).mul(VOTER_REWARD_FRACTION).div(WAD).div(WAD); + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + }); + + it("tells users what their potential reward range is", async () => { + const rewardBase = REQUIRED_STAKE.muln(2).mul(VOTER_REWARD_FRACTION).div(WAD); + + let { 0: rewardMin, 1: rewardMax } = await voting.getVoterRewardRange(motionId, USER0, [WAD]); + expect(rewardMin).to.eq.BN(WAD.divn(3).mul(rewardBase).div(WAD)); + expect(rewardMax).to.eq.BN(rewardBase); + + // They vote, expect no change + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + ({ 0: rewardMin, 1: rewardMax } = await voting.getVoterRewardRange(motionId, USER0, [WAD])); + expect(rewardMin).to.eq.BN(WAD.divn(3).mul(rewardBase).div(WAD)); + expect(rewardMax).to.eq.BN(rewardBase); + + // User 1 has no range, as they are the last to vote + ({ 0: rewardMin, 1: rewardMax } = await voting.getVoterRewardRange(motionId, USER1, [WAD.muln(2)])); + expect(rewardMin).to.eq.BN(WAD.muln(2).divn(3).mul(rewardBase).div(WAD)); + expect(rewardMax).to.eq.BN(WAD.muln(2).divn(3).mul(rewardBase).div(WAD)); + }); + + it("can update votes, but just the last one counts", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + // Revealing first vote fails + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + + // Revealing second succeeds + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + }); + + it("can update votes, but the total reputation does not change", async () => { + let motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.be.zero; + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + }); + + it("cannot reveal an invalid vote", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, 2), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, 2, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-bad-vote" + ); + }); + + it("cannot reveal a vote twice, and so cannot vote twice", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + }); + + it("can vote in two motions with two reputation states, with different proofs", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const oldRootHash = await reputationTree.getRootHash(); + + // Update reputation state + const user0Value2 = makeReputationValue(WAD.muln(2), 2); + await reputationTree.insert(user0Key, user0Value2); + + const [domain1Mask2, domain1Siblings2] = await reputationTree.getProof(domain1Key); + const [user0Mask2, user0Siblings2] = await reputationTree.getProof(user0Key); + const [user1Mask2, user1Siblings2] = await reputationTree.getProof(user1Key); + + // Set new rootHash + const rootHash = await reputationTree.getRootHash(); + expect(oldRootHash).to.not.equal(rootHash); + + await forwardTime(MINING_CYCLE_DURATION, this); + + const repCycle = await getActiveRepCycle(colonyNetwork); + await repCycle.submitRootHash(rootHash, 0, "0x00", 10, { from: MINER }); + await forwardTime(SUBMITTER_ONLY_WINDOW + 1, this); + await repCycle.confirmNewHash(0); + + // Create new motion with new reputation state + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask2, domain1Siblings2); + const motionId2 = await voting.getMotionCount(); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask2, user1Siblings2, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, NAY), user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId2, SALT, NAY, user0Key, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + }); + + it("cannot submit a null vote", async () => { + await checkErrorRevert( + voting.submitVote(motionId, "0x0", user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-invalid-secret" + ); + }); + + it("cannot submit a vote if voting is closed", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-open" + ); + }); + + it("cannot reveal a vote on a non-existent motion", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(0, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-invalid-root-hash" + ); + }); + + it("cannot reveal a vote during the submit period", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-reveal" + ); + }); + + it("cannot reveal a vote after the reveal period ends", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + await forwardTime(REVEAL_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-not-reveal" + ); + }); + + it("cannot reveal a vote with a bad secret", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert( + voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }), + "voting-secret-no-match" + ); + }); + }); + + describe("executing motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("cannot execute a non-existent motion", async () => { + await checkErrorRevert(voting.finalizeMotion(0), "voting-not-finalizable"); + }); + + it("motion has no effect if extension does not have permissions", async () => { + await colony.setRootRole(voting.address, false); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + + // Cleanup + await colony.setRootRole(voting.address, true); + }); + + it("cannot take an action if there is insufficient support", async () => { + const smallStake = REQUIRED_STAKE.subn(1); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, smallStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if there is insufficient opposition", async () => { + const smallStake = REQUIRED_STAKE.subn(1); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, smallStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action with a return value", async () => { + // Returns a uint256 + const action = await encodeTxData(colony, "version", []); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action to install an extension", async () => { + let installation = await colonyNetwork.getExtensionInstallation(soliditySha3("OneTxPayment"), colony.address); + expect(installation).to.be.equal(ADDRESS_ZERO); + + const oneTxPaymentImplementation = await OneTxPayment.new(); + const resolver = await Resolver.new(); + await setupEtherRouter("OneTxPayment", { OneTxPayment: oneTxPaymentImplementation.address }, resolver); + await metaColony.addExtensionToNetwork(soliditySha3("OneTxPayment"), resolver.address); + + const oneTxPaymentVersion = await oneTxPaymentImplementation.version(); + + const action = await encodeTxData(colony, "installExtension", [soliditySha3("OneTxPayment"), oneTxPaymentVersion]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[1].args.executed).to.be.true; + + installation = await colonyNetwork.getExtensionInstallation(soliditySha3("OneTxPayment"), colony.address); + expect(installation).to.not.be.equal(ADDRESS_ZERO); + }); + + it("can take an action with an arbitrary target", async () => { + const { colony: otherColony } = await setupRandomColony(colonyNetwork); + await token.mint(otherColony.address, WAD, { from: USER0 }); + + const action = await encodeTxData(colony, "claimColonyFunds", [token.address]); + await voting.createMotion(1, UINT256_MAX, otherColony.address, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const balanceBefore = await otherColony.getFundingPotBalance(1, token.address); + expect(balanceBefore).to.be.zero; + + await voting.finalizeMotion(motionId); + + const balanceAfter = await otherColony.getFundingPotBalance(1, token.address); + expect(balanceAfter).to.eq.BN(WAD); + }); + + it("can take a nonexistent action", async () => { + const action = soliditySha3("foo"); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action during staking or voting", async () => { + let motionState; + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("cannot take an action twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if the motion passes", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + // Don't need to wait for the reveal period, since 100% of the secret is revealed + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("cannot take an action if the motion fails", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action if there is insufficient voting power (state change actions)", async () => { + // Set globalClaimDelay to WAD + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(4))], WAD32]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId1, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId1, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId1, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + let logs; + ({ logs } = await voting.finalizeMotion(motionId1)); + expect(logs[0].args.executed).to.be.true; + + // Create another motion for the same variable + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId2, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + ({ logs } = await voting.finalizeMotion(motionId2)); + expect(logs[0].args.executed).to.be.false; + }); + + it("can set vote power correctly after a vote", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(WAD); // USER0 had 1 WAD of reputation + }); + + it("can use vote power correctly for different values of the same variable", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set finalizedTimestamp + const action1 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + const action2 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], "0x0"]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action1, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action2, domain1Key, domain1Value, domain1Mask, domain1Siblings); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + // First motion goes through + await voting.finalizeMotion(motionId1); + let expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + + // Second motion does not because of insufficient vote power + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + }); + + it("can set vote power correctly if there is insufficient opposition", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(REQUIRED_STAKE); + }); + }); + + describe("claiming rewards", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + }); + + it("cannot claim rewards from a non-existent motion", async () => { + await checkErrorRevert(voting.claimReward(0, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + + it("returns 0 for staker rewards if no-one staked on a side of a motion", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + const yayStakerReward = await voting.getStakerReward(motionId, USER0, YAY); + const nayStakerReward = await voting.getStakerReward(motionId, USER0, NAY); + + expect(yayStakerReward[0]).to.eq.BN(REQUIRED_STAKE); + expect(nayStakerReward[0]).to.eq.BN(new BN(0)); + }); + + it("can let stakers claim rewards, based on the stake outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const nayStake = REQUIRED_STAKE.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + // Note that no voter rewards were paid out + const expectedReward0 = REQUIRED_STAKE.add(REQUIRED_STAKE.divn(20)); // 110% of stake + const expectedReward1 = REQUIRED_STAKE.divn(20).muln(9); // 90% of stake + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user0 has no penalty, while user1 has a 10% penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER1); + expect(repUpdate.amount).to.eq.BN(REQUIRED_STAKE.divn(20).neg()); + }); + + it("can let stakers claim rewards, based on the vote outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const motion = await voting.getMotion(motionId); + const loserStake = REQUIRED_STAKE.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.divn(3).muln(2).addn(1); // (stake * .8) * (winPct = 1/3 * 2) + dust + const expectedReward1 = REQUIRED_STAKE.add(loserStake.divn(3)); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user1 has no penalty, while user0 has a 1/3 penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER0); + expect(repUpdate.amount).to.eq.BN(REQUIRED_STAKE.sub(expectedReward0).neg()); + }); + + it("can let stakers claim rewards, based on the vote outcome, with multiple losing stakers", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(REQUIRED_STAKE.subn(1), 8); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE.divn(3).muln(2), user1Key, user1Value, user1Mask, user1Siblings, { + from: USER1, + }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE.divn(3), user2Key, user2Value, user2Mask, user2Siblings, { + from: USER2, + }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPre = await tokenLocking.getUserLock(token.address, USER2); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER2, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPost = await tokenLocking.getUserLock(token.address, USER2); + + const motion = await voting.getMotion(motionId); + const loserStake = REQUIRED_STAKE.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.muln(2).divn(3); // (stake * .8) * (winPct = 1/3 * 2) + const expectedReward1 = REQUIRED_STAKE.add(loserStake.divn(3)).muln(2).divn(3); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + const expectedReward2 = REQUIRED_STAKE.add(loserStake.divn(3)).divn(3); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + expect(new BN(user2LockPost.balance).sub(new BN(user2LockPre.balance))).to.eq.BN(expectedReward2); + }); + + it("can let stakers claim rewards, based on the vote outcome, with multiple winning stakers", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(REQUIRED_STAKE.subn(1), 8); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE.divn(3).muln(2), user0Key, user0Value, user0Mask, user0Siblings, { + from: USER0, + }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE.divn(3), user2Key, user2Value, user2Mask, user2Siblings, { + from: USER2, + }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPre = await tokenLocking.getUserLock(token.address, USER2); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER2, YAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPost = await tokenLocking.getUserLock(token.address, USER2); + + const motion = await voting.getMotion(motionId); + const loserStake = REQUIRED_STAKE.sub(new BN(motion.paidVoterComp)); + // User 0 staked 2/3rds of the losing side. 1/3 of the total stake of that side has been + // removed due to that side only receiving a third of the vote + const expectedReward0 = loserStake.muln(2).divn(3).muln(2).divn(3); // (stake * .8) * (winPct = 1/3 * 2) + // User 1 staked all of the winning side, so gets that back plus a third of what is left + // on the losing side as a reward (as the winning side got 2/3rds of the vote) + const expectedReward1 = REQUIRED_STAKE.add(loserStake.muln(1).divn(3)); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + // Same as user 0, but they only staked 1/3 of the losing side. + const expectedReward2 = loserStake.muln(2).divn(3).divn(3); // (stake * .8) * (winPct = 1/3 * 2) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + expect(new BN(user2LockPost.balance).sub(new BN(user2LockPre.balance))).to.eq.BN(expectedReward2); + }); + + it("can let all stakers claim rewards, based on the vote outcome, with multiple winning stakers", async () => { + const user2Key = makeReputationKey(colony.address, domain1.skillId, USER2); + const user2Value = makeReputationValue(REQUIRED_STAKE.subn(1), 8); + const [user2Mask, user2Siblings] = await reputationTree.getProof(user2Key); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE.divn(3).muln(2), user0Key, user0Value, user0Mask, user0Siblings, { + from: USER0, + }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE.divn(6), user1Key, user1Value, user1Mask, user1Siblings, { + from: USER1, + }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE.divn(6), user2Key, user2Value, user2Mask, user2Siblings, { + from: USER2, + }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPre = await tokenLocking.getUserLock(token.address, USER2); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER2, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPost = await tokenLocking.getUserLock(token.address, USER2); + + const motion = await voting.getMotion(motionId); + const loserStake = REQUIRED_STAKE.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.muln(2).divn(3).muln(2).divn(3); // (stake * .8) * (winPct = 1/3 * 2) + const expectedReward1 = loserStake.muln(2).divn(3).divn(6); + const expectedReward2 = loserStake.divn(3).muln(2).divn(6); + + const expectedReward1B = REQUIRED_STAKE.add(loserStake.divn(3)); + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1.add(expectedReward1B)); + expect(new BN(user2LockPost.balance).sub(new BN(user2LockPre.balance))).to.eq.BN(expectedReward2); + }); + + it("can let stakers claim their original stake if neither side fully staked", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const half = REQUIRED_STAKE.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, half, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + expect(numEntriesPrev).to.eq.BN(numEntriesPost); + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(half); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(half); + }); + + it("cannot claim rewards twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await expectEvent(voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY), "MotionRewardClaimed", [motionId, USER1, NAY, 0]); + + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-nothing-to-claim"); + }); + + it("cannot claim rewards before a motion is finalized", async () => { + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + }); + + describe("escalating motions", async () => { + let motionId; + let votingPayout; + + beforeEach(async () => { + const domain2Key = makeReputationKey(colony.address, domain2.skillId); + const domain2Value = makeReputationValue(WAD, 6); + const [domain2Mask, domain2Siblings] = await reputationTree.getProof(domain2Key); + + const user0Key2 = makeReputationKey(colony.address, domain2.skillId, USER0); + const user0Value2 = makeReputationValue(WAD.divn(3), 9); + const [user0Mask2, user0Siblings2] = await reputationTree.getProof(user0Key2); + + const user1Key2 = makeReputationKey(colony.address, domain2.skillId, USER1); + const user1Value2 = makeReputationValue(WAD.divn(3).muln(2), 10); + const [user1Mask2, user1Siblings2] = await reputationTree.getProof(user1Key2); + + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await voting.createMotion(2, UINT256_MAX, ADDRESS_ZERO, action, domain2Key, domain2Value, domain2Mask, domain2Siblings); + motionId = await voting.getMotionCount(); + + await colony.approveStake(voting.address, 2, WAD, { from: USER0 }); + await colony.approveStake(voting.address, 2, WAD, { from: USER1 }); + + await voting.stakeMotion(motionId, 1, 0, NAY, REQUIRED_STAKE_DOMAIN_2, user0Key2, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + await voting.stakeMotion(motionId, 1, 0, YAY, REQUIRED_STAKE_DOMAIN_2, user1Key2, user1Value2, user1Mask2, user1Siblings2, { from: USER1 }); + + // Note that this is a passing vote + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key2, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user1Key2, user1Value2, user1Mask2, user1Siblings2, { from: USER1 }); + + await voting.revealVote(motionId, SALT, NAY, user0Key2, user0Value2, user0Mask2, user0Siblings2, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, user1Key2, user1Value2, user1Mask2, user1Siblings2, { from: USER1 }); + + const voter0reward = await voting.getVoterReward(motionId, USER0, [WAD.divn(3)]); + const voter1reward = await voting.getVoterReward(motionId, USER1, [WAD.divn(3).muln(2)]); + votingPayout = voter0reward.add(voter1reward); + }); + + it("can internally escalate a domain motion after a vote", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + }); + + it("cannot internally escalate a domain motion if not in a 'closed' state", async () => { + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + await checkErrorRevert( + voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER2 }), + "voting-not-closed" + ); + }); + + it("cannot internally escalate a domain motion with an invalid domain proof", async () => { + await checkErrorRevert( + voting.escalateMotion(motionId, 1, 1, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }), + "voting-invalid-domain-proof" + ); + }); + + it("cannot internally escalate a domain motion with an invalid reputation proof", async () => { + await checkErrorRevert(voting.escalateMotion(motionId, 1, 0, "0x0", "0x0", "0x0", [], { from: USER0 }), "voting-invalid-root-hash"); + }); + + it("can stake after internally escalating a domain motion", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const yayStake = REQUIRED_STAKE.sub(REQUIRED_STAKE_DOMAIN_2); + const nayStake = yayStake.add(REQUIRED_STAKE.divn(10)); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, yayStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + }); + + it("can execute after internally escalating a domain motion, if there is insufficient opposition", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const yayStake = REQUIRED_STAKE.sub(REQUIRED_STAKE_DOMAIN_2); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, yayStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("cannot execute after internally escalating a domain motion, if there is insufficient support", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(FAILED); + }); + + it("can fall back on the previous vote if both sides fail to stake", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + // Note that the previous vote succeeded + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can use the result of a new stake after internally escalating a domain motion", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const yayStake = REQUIRED_STAKE.sub(REQUIRED_STAKE_DOMAIN_2); + const nayStake = yayStake.add(votingPayout); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(FAILED); + + // Now check that the rewards come out properly + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-nothing-to-claim"); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + // REQUIRED_STAKE.div(REQUIRED_STAKE.add(votingPayout)) is the fraction of this side they staked + // REQUIRED_STAKE.add(REQUIRED_STAKE_DOMAIN_2).muln(2).divn(3) is what's being awarded to the whole of this side. + // The product tells us their expected reward. + + const expectedReward1 = nayStake.mul(REQUIRED_STAKE.add(REQUIRED_STAKE_DOMAIN_2.divn(10))).div(REQUIRED_STAKE.add(votingPayout)); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + }); + + it("can use the result of a new vote after internally escalating a domain motion", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const yayStake = REQUIRED_STAKE.sub(REQUIRED_STAKE_DOMAIN_2); + const nayStake = yayStake.add(votingPayout); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, yayStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + // Vote fails + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await forwardTime(ESCALATION_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + + // Now check that the rewards come out properly + // 1st voter reward paid by YAY (user0), 2nd paid by NAY (user1) + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const voter0reward2 = await voting.getVoterReward(motionId, USER0, [WAD]); + const voter1reward2 = await voting.getVoterReward(motionId, USER1, [WAD.muln(2)]); + const votingPayout2 = voter0reward2.add(voter1reward2); + + const loserStake = REQUIRED_STAKE.sub(votingPayout2); // Take out voter comp + + const expectedReward0 = loserStake.muln(2).divn(3).mul(yayStake).div(REQUIRED_STAKE); + const expectedReward1 = nayStake.mul(REQUIRED_STAKE.add(loserStake.divn(3))).div(REQUIRED_STAKE.add(votingPayout)); + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + }); + + it("can still claim rewards after a motion has been escalated but failed to stake", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + + // Now check that the rewards come out properly + // 1st voter reward paid by YAY (user0) + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, YAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const user0Domain2Rep = WAD.divn(3); + const user1Domain2Rep = WAD.divn(3).muln(2); + const loserStake = REQUIRED_STAKE_DOMAIN_2.sub(votingPayout); // Take out voter comp + + const winFraction = user0Domain2Rep.mul(WAD).div(user0Domain2Rep.add(user1Domain2Rep)); + + const expectedReward0 = loserStake.muln(2).mul(winFraction).div(WAD); + const expectedReward1 = REQUIRED_STAKE_DOMAIN_2.add(loserStake.mul(winFraction).div(WAD)); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + }); + + it("can still claim rewards after a motion has been escalated but not enough was staked", async () => { + await voting.escalateMotion(motionId, 1, 0, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const partialStake = REQUIRED_STAKE.sub(REQUIRED_STAKE_DOMAIN_2).divn(2); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, partialStake, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, partialStake.subn(1000), user1Key, user1Value, user1Mask, user1Siblings, { + from: USER1, + }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + + // Now check that the rewards come out properly + // 1st voter reward paid by YAY (user0) + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, YAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const loserStake = REQUIRED_STAKE_DOMAIN_2.divn(10).muln(8).add(partialStake); + const expectedReward0 = loserStake.divn(3).muln(2); + const expectedReward1 = REQUIRED_STAKE_DOMAIN_2.add(loserStake.divn(3)).add(partialStake.subn(1000)); + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + }); + + it("cannot escalate a motion in the root domain", async () => { + const action = await encodeTxData(colony, "mintTokens", [WAD]); + await voting.createMotion(1, UINT256_MAX, ADDRESS_ZERO, action, domain1Key, domain1Value, domain1Mask, domain1Siblings); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, REQUIRED_STAKE, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, REQUIRED_STAKE, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + await voting.revealVote(motionId, SALT, YAY, user0Key, user0Value, user0Mask, user0Siblings, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, user1Key, user1Value, user1Mask, user1Siblings, { from: USER1 }); + + const state = await voting.getMotionState(motionId); + expect(state).to.eq.BN(EXECUTABLE); + }); + + it("can skip the staking phase if no new stake is required", async () => { + voting = await VotingReputation2.new(); + await voting.install(colony.address); + await colony.setArbitrationRole(1, UINT256_MAX, voting.address, 1, true); + + await voting.initialise( + TOTAL_STAKE_FRACTION, + 0, // No voter compensation + USER_MIN_STAKE_FRACTION, + MAX_VOTE_FRACTION, + STAKE_PERIOD, + SUBMIT_PERIOD, + REVEAL_PERIOD, + ESCALATION_PERIOD + ); + + // Run a vote in domain 3, same rep as domain 1 + const domain3Key = makeReputationKey(colony.address, domain3.skillId); + const domain3Value = makeReputationValue(WAD.muln(3), 7); + const [domain3Mask, domain3Siblings] = await reputationTree.getProof(domain3Key); + + const user0Key3 = makeReputationKey(colony.address, domain3.skillId, USER0); + const user0Value3 = makeReputationValue(WAD, 11); + const [user0Mask3, user0Siblings3] = await reputationTree.getProof(user0Key3); + + const user1Key3 = makeReputationKey(colony.address, domain3.skillId, USER1); + const user1Value3 = makeReputationValue(WAD.muln(2), 12); + const [user1Mask3, user1Siblings3] = await reputationTree.getProof(user1Key3); + + const action = await encodeTxData(colony, "makeTask", [1, 1, FAKE, 3, 0, 0]); + await voting.createMotion(3, UINT256_MAX, ADDRESS_ZERO, action, domain3Key, domain3Value, domain3Mask, domain3Siblings); + motionId = await voting.getMotionCount(); + + await colony.approveStake(voting.address, 3, WAD, { from: USER0 }); + await colony.approveStake(voting.address, 3, WAD, { from: USER1 }); + + await voting.stakeMotion(motionId, 1, 1, NAY, REQUIRED_STAKE, user0Key3, user0Value3, user0Mask3, user0Siblings3, { from: USER0 }); + await voting.stakeMotion(motionId, 1, 1, YAY, REQUIRED_STAKE, user1Key3, user1Value3, user1Mask3, user1Siblings3, { from: USER1 }); + + // Note that this is a passing vote + await voting.submitVote(motionId, soliditySha3(SALT, NAY), user0Key3, user0Value3, user0Mask3, user0Siblings3, { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), user1Key3, user1Value3, user1Mask3, user1Siblings3, { from: USER1 }); + + await voting.revealVote(motionId, SALT, NAY, user0Key3, user0Value3, user0Mask3, user0Siblings3, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, user1Key3, user1Value3, user1Mask3, user1Siblings3, { from: USER1 }); + + // Now escalate, should go directly into submit phase + await voting.escalateMotion(motionId, 1, 1, domain1Key, domain1Value, domain1Mask, domain1Siblings, { from: USER0 }); + + const state = await voting.getMotionState(motionId); + expect(state).to.eq.BN(SUBMIT); + }); + }); +}); diff --git a/test/extensions/voting-token.js b/test/extensions/voting-token.js new file mode 100644 index 0000000000..313877593e --- /dev/null +++ b/test/extensions/voting-token.js @@ -0,0 +1,1290 @@ +/* globals artifacts */ + +import BN from "bn.js"; +import chai from "chai"; +import bnChai from "bn-chai"; +import shortid from "shortid"; +import { ethers } from "ethers"; +import { soliditySha3 } from "web3-utils"; + +import { UINT256_MAX, WAD, SECONDS_PER_DAY, DEFAULT_STAKE } from "../../helpers/constants"; +import { checkErrorRevert, web3GetCode, forwardTime, encodeTxData, bn2bytes32, expectEvent } from "../../helpers/test-helper"; + +import { + setupColonyNetwork, + setupMetaColonyWithLockedCLNYToken, + setupRandomColony, + giveUserCLNYTokensAndStake, +} from "../../helpers/test-data-generator"; + +import { setupEtherRouter } from "../../helpers/upgradable-contracts"; + +const { expect } = chai; +chai.use(bnChai(web3.utils.BN)); + +const IReputationMiningCycle = artifacts.require("IReputationMiningCycle"); +const TokenLocking = artifacts.require("TokenLocking"); +const VotingToken = artifacts.require("VotingToken"); +const Resolver = artifacts.require("Resolver"); + +const VOTING_TOKEN = soliditySha3("VotingToken"); + +contract("Voting Token", (accounts) => { + let colony; + let token; + let domain1; + let metaColony; + let colonyNetwork; + let tokenLocking; + + let voting; + let requiredStake; + + const TOTAL_STAKE_FRACTION = WAD.divn(1000); // 0.1 % + const USER_MIN_STAKE_FRACTION = WAD.divn(10); // 10 % + + const MAX_VOTE_FRACTION = WAD.divn(10).muln(6); // 60 % + const VOTER_REWARD_FRACTION = WAD.divn(10); // 10 % + + const STAKE_PERIOD = SECONDS_PER_DAY * 3; + const SUBMIT_PERIOD = SECONDS_PER_DAY * 2; + const REVEAL_PERIOD = SECONDS_PER_DAY * 2; + const ESCALATION_PERIOD = SECONDS_PER_DAY; + + const USER0 = accounts[0]; + const USER1 = accounts[1]; + const USER2 = accounts[2]; + const MINER = accounts[5]; + + const SALT = soliditySha3({ type: "string", value: shortid.generate() }); + const FAKE = soliditySha3({ type: "string", value: shortid.generate() }); + + const NAY = 0; + const YAY = 1; + + // const NULL = 0; + const STAKING = 1; + const SUBMIT = 2; + // const REVEAL = 3; + // const CLOSED = 4; + // const EXECUTABLE = 5; + // const EXECUTED = 6; + // const FAILED = 7; + + const ADDRESS_ZERO = ethers.constants.AddressZero; + const WAD32 = bn2bytes32(WAD); + const HALF = WAD.divn(2); + const YEAR = SECONDS_PER_DAY * 365; + + before(async () => { + colonyNetwork = await setupColonyNetwork(); + ({ metaColony } = await setupMetaColonyWithLockedCLNYToken(colonyNetwork)); + + await giveUserCLNYTokensAndStake(colonyNetwork, MINER, DEFAULT_STAKE); + await colonyNetwork.initialiseReputationMining(); + await colonyNetwork.startNextCycle(); + + const tokenLockingAddress = await colonyNetwork.getTokenLocking(); + tokenLocking = await TokenLocking.at(tokenLockingAddress); + + const votingImplementation = await VotingToken.new(); + const resolver = await Resolver.new(); + await setupEtherRouter("VotingToken", { VotingToken: votingImplementation.address }, resolver); + await metaColony.addExtensionToNetwork(VOTING_TOKEN, resolver.address); + }); + + beforeEach(async () => { + ({ colony, token } = await setupRandomColony(colonyNetwork)); + domain1 = await colony.getDomain(1); + + await colony.installExtension(VOTING_TOKEN, 1); + const votingAddress = await colonyNetwork.getExtensionInstallation(VOTING_TOKEN, colony.address); + voting = await VotingToken.at(votingAddress); + + await voting.initialise( + TOTAL_STAKE_FRACTION, + VOTER_REWARD_FRACTION, + USER_MIN_STAKE_FRACTION, + MAX_VOTE_FRACTION, + STAKE_PERIOD, + SUBMIT_PERIOD, + REVEAL_PERIOD, + ESCALATION_PERIOD + ); + + await colony.setRootRole(voting.address, true); + await colony.setArbitrationRole(1, UINT256_MAX, voting.address, 1, true); + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, true); + + const user0influence = WAD; + const user1influence = WAD.muln(2); + const user2influence = WAD; + + await token.mint(USER0, user0influence); + await token.mint(USER1, user1influence); + await token.mint(USER2, user2influence); + await token.approve(tokenLocking.address, user0influence, { from: USER0 }); + await token.approve(tokenLocking.address, user1influence, { from: USER1 }); + await token.approve(tokenLocking.address, user2influence, { from: USER2 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user0influence, true, { from: USER0 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user1influence, true, { from: USER1 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user2influence, true, { from: USER2 }); + await colony.approveStake(voting.address, 1, user0influence, { from: USER0 }); + await colony.approveStake(voting.address, 1, user1influence, { from: USER1 }); + await colony.approveStake(voting.address, 1, user2influence, { from: USER2 }); + + const totalSupply = await token.totalSupply(); + requiredStake = totalSupply.divn(1000); + }); + + function hashExpenditureSlot(action) { + const preamble = 2 + 8 + 64 * 2; + return soliditySha3(`0x${action.slice(preamble, preamble + 64 * 4)}${"0".repeat(64)}${action.slice(preamble + 64 * 5, action.length)}`); + } + + describe("managing the extension", async () => { + it("can install the extension manually", async () => { + voting = await VotingToken.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.install(colony.address), "extension-already-installed"); + + const identifier = await voting.identifier(); + const version = await voting.version(); + expect(identifier).to.equal(VOTING_TOKEN); + expect(version).to.eq.BN(1); + + await voting.finishUpgrade(); + await voting.deprecate(true); + await voting.uninstall(); + + const code = await web3GetCode(voting.address); + expect(code).to.equal("0x"); + }); + + it("can install the extension with the extension manager", async () => { + ({ colony } = await setupRandomColony(colonyNetwork)); + await colony.installExtension(VOTING_TOKEN, 1, { from: USER0 }); + + await checkErrorRevert(colony.installExtension(VOTING_TOKEN, 1, { from: USER0 }), "colony-network-extension-already-installed"); + await checkErrorRevert(colony.uninstallExtension(VOTING_TOKEN, { from: USER1 }), "ds-auth-unauthorized"); + + await colony.uninstallExtension(VOTING_TOKEN, { from: USER0 }); + }); + + it("can deprecate the extension if root", async () => { + let deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(false); + + await checkErrorRevert(colony.deprecateExtension(VOTING_TOKEN, true, { from: USER2 }), "ds-auth-unauthorized"); + await colony.deprecateExtension(VOTING_TOKEN, true); + + // Cant make new motions! + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + + await checkErrorRevert(voting.createMotion(ADDRESS_ZERO, action), "colony-extension-deprecated"); + + deprecated = await voting.getDeprecated(); + expect(deprecated).to.equal(true); + }); + + it("cannot make a motion before initialised", async () => { + voting = await VotingToken.new(); + await voting.install(colony.address); + + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await checkErrorRevert(voting.createMotion(ADDRESS_ZERO, action), "voting-not-active"); + }); + + it("cannot initialise twice or more if not root", async () => { + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-already-initialised"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR, { from: USER2 }), "voting-not-root"); + }); + + it("cannot initialise with invalid values", async () => { + voting = await VotingToken.new(); + await voting.install(colony.address); + + await checkErrorRevert(voting.initialise(HALF.addn(1), HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF.addn(1), WAD, WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD.addn(1), WAD, YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD.addn(1), YEAR, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR + 1, YEAR, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR + 1, YEAR, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR + 1, YEAR), "voting-invalid-value"); + await checkErrorRevert(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR + 1), "voting-invalid-value"); + }); + + it("can initialised with valid values and emit expected event", async () => { + voting = await VotingToken.new(); + await voting.install(colony.address); + + await expectEvent(voting.initialise(HALF, HALF, WAD, WAD, YEAR, YEAR, YEAR, YEAR), "ExtensionInitialised", []); + }); + + it("can query for initialisation values", async () => { + const totalStakeFraction = await voting.getTotalStakeFraction(); + const voterRewardFraction = await voting.getVoterRewardFraction(); + const userMinStakeFraction = await voting.getUserMinStakeFraction(); + const maxVoteFraction = await voting.getMaxVoteFraction(); + const stakePeriod = await voting.getStakePeriod(); + const submitPeriod = await voting.getSubmitPeriod(); + const revealPeriod = await voting.getRevealPeriod(); + const escalationPeriod = await voting.getEscalationPeriod(); + + expect(totalStakeFraction).to.eq.BN(TOTAL_STAKE_FRACTION); + expect(voterRewardFraction).to.eq.BN(VOTER_REWARD_FRACTION); + expect(userMinStakeFraction).to.eq.BN(USER_MIN_STAKE_FRACTION); + expect(maxVoteFraction).to.eq.BN(MAX_VOTE_FRACTION); + expect(stakePeriod).to.eq.BN(STAKE_PERIOD); + expect(submitPeriod).to.eq.BN(SUBMIT_PERIOD); + expect(revealPeriod).to.eq.BN(REVEAL_PERIOD); + expect(escalationPeriod).to.eq.BN(ESCALATION_PERIOD); + }); + }); + + describe("creating motions", async () => { + it("can create a root motion", async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + + const motionId = await voting.getMotionCount(); + const motion = await voting.getMotion(motionId); + expect(motion.skillId).to.eq.BN(domain1.skillId); + }); + + it("does not lock the token when a motion is created", async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + const motionId = await voting.getMotionCount(); + + const lockId = await voting.getLockId(motionId); + expect(lockId).to.be.zero; + }); + + it("can create a motion with an alternative target", async () => { + const action = await encodeTxData(colony, "makeTask", [1, 0, FAKE, 2, 0, 0]); + await voting.createMotion(voting.address, action); + }); + }); + + describe("staking on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + }); + + it("can stake on a motion", async () => { + const half = requiredStake.divn(2); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, { from: USER1 }); + + const motion = await voting.getMotion(motionId); + expect(motion.stakes[0]).to.be.zero; + expect(motion.stakes[1]).to.eq.BN(requiredStake); + + const stake0 = await voting.getStake(motionId, USER0, YAY); + const stake1 = await voting.getStake(motionId, USER1, YAY); + expect(stake0).to.eq.BN(half); + expect(stake1).to.eq.BN(half); + }); + + it("can update the motion states correctly", async () => { + let motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + }); + + it("cannot stake 0", async () => { + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, 0, { from: USER0 }), "voting-bad-amount"); + }); + + it("cannot stake a nonexistent side", async () => { + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, 2, requiredStake, { from: USER0 }), "voting-bad-vote"); + }); + + it("cannot stake less than the minStake, unless there is less than minStake to go", async () => { + const minStake = requiredStake.divn(10); + + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), { from: USER0 }), "voting-insufficient-stake"); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake, { from: USER0 }); + + // Unless there's less than the minStake to go! + + const stake = requiredStake.sub(minStake.muln(2)).addn(1); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, stake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, minStake.subn(1), { from: USER0 }); + }); + + it("can update the expenditure globalClaimDelay if voting on expenditure state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + let expenditure; + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("does not update the expenditure globalClaimDelay if the target is another colony", async () => { + const { colony: otherColony } = await setupRandomColony(colonyNetwork); + await otherColony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await otherColony.getExpenditureCount(); + await otherColony.finalizeExpenditure(expenditureId); + + // Set finalizedTimestamp to WAD + const action = await encodeTxData(otherColony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 25, + [true], + [bn2bytes32(new BN(3))], + WAD32, + ]); + + await voting.createMotion(otherColony.address, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + const expenditure = await otherColony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.be.zero; + }); + + it("can update the expenditure slot claimDelay if voting on expenditure slot state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payoutModifier to 1 for expenditure slot 0 + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on expenditure payout state", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + // Set payout to WAD for expenditure slot 0, internal token + const action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("can update the expenditure slot claimDelay if voting on multiple expenditure states", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + await colony.finalizeExpenditure(expenditureId); + + let action; + + // Motion 1 + // Set finalizedTimestamp to WAD + action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + // Motion 2 + // Set payoutModifier to 1 for expenditure slot 0 + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + // Motion 3 + // Set payout to WAD for expenditure slot 0, internal token + action = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + const expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.globalClaimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + const expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await checkErrorRevert(colony.claimExpenditurePayout(expenditureId, 0, token.address), "colony-expenditure-cannot-claim"); + }); + + it("cannot update the expenditure slot claimDelay if given an invalid action", async () => { + // Create a poorly-formed action (no keys) + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, 1, 0, [], [], ethers.constants.HashZero]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }), "voting-lock-failed"); + }); + + it("can accurately track the number of motions for a single expenditure", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set payoutModifier to 1 for expenditure slot 0 + const action1 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 26, + [false, true], + ["0x0", bn2bytes32(new BN(2))], + WAD32, + ]); + + // Set payout to WAD for expenditure slot 0, internal token + const action2 = await encodeTxData(colony, "setExpenditureState", [ + 1, + UINT256_MAX, + expenditureId, + 27, + [false, false], + ["0x0", bn2bytes32(new BN(token.address.slice(2), 16))], + WAD32, + ]); + + await voting.createMotion(ADDRESS_ZERO, action1); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(ADDRESS_ZERO, action2); + const motionId2 = await voting.getMotionCount(); + + let expenditureSlot; + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365 * 2); + + await forwardTime(STAKE_PERIOD, this); + await voting.finalizeMotion(motionId1); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.eq.BN(SECONDS_PER_DAY * 365); + + await voting.finalizeMotion(motionId2); + + expenditureSlot = await colony.getExpenditureSlot(expenditureId, 0); + expect(expenditureSlot.claimDelay).to.be.zero; + }); + + it("cannot stake with insufficient token balance", async () => { + const user3 = accounts[3]; + const user3influence = WAD.divn(1000); + + await token.mint(user3, user3influence); + await token.approve(tokenLocking.address, user3influence, { from: user3 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, user3influence, true, { from: user3 }); + await colony.approveStake(voting.address, 1, user3influence, { from: user3 }); + + const totalSupply = await token.totalSupply(); + requiredStake = totalSupply.divn(1000); + + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: user3 }), "voting-insufficient-influence"); + }); + + it("cannot stake once time runs out", async () => { + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert(voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }), "voting-not-staking"); + }); + }); + + describe("voting on motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + }); + + it("can rate and reveal for a motion", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, { from: USER0 }); + }); + + it("locks the token when the first reveal is made", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + let lockId = await voting.getLockId(motionId); + expect(lockId).to.be.zero; + + await voting.revealVote(motionId, SALT, NAY, { from: USER0 }); + + lockId = await voting.getLockId(motionId); + expect(lockId).to.not.be.zero; + }); + + it("can unlock the token once revealed", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, { from: USER0 }); + + const lockId = await voting.getLockId(motionId); + const { lockCount } = await tokenLocking.getUserLock(token.address, USER0); + expect(lockCount).to.eq.BN(lockId); + }); + + it("can tally votes from two users", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + await voting.revealVote(motionId, SALT, YAY, { from: USER1 }); + + // See final counts + const { votes } = await voting.getMotion(motionId); + expect(votes[0][0]).to.be.zero; + expect(votes[0][1]).to.eq.BN(WAD.muln(3)); + }); + + it("can update votes, but just the last one counts", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + // Revealing first vote fails + await checkErrorRevert(voting.revealVote(motionId, SALT, NAY, { from: USER0 }), "voting-secret-no-match"); + + // Revealing second succeeds + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + }); + + it("can update votes, but the totalVotes does not change", async () => { + let motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.be.zero; + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + + motion = await voting.getMotion(motionId); + expect(motion.totalVotes[0]).to.eq.BN(WAD); + }); + + it("cannot reveal an invalid vote", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, 2), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert(voting.revealVote(motionId, SALT, 2, { from: USER0 }), "voting-bad-vote"); + }); + + it("cannot reveal a vote twice, and so cannot vote twice", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + + await checkErrorRevert(voting.revealVote(motionId, SALT, YAY, { from: USER0 }), "voting-secret-no-match"); + }); + + it("can vote in two motions with two different locks", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + // Create new motion with new reputation state + await voting.createMotion(ADDRESS_ZERO, FAKE); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, { from: USER0 }); + await voting.revealVote(motionId2, SALT, NAY, { from: USER0 }); + }); + + it("cannot submit a null vote", async () => { + await checkErrorRevert(voting.submitVote(motionId, "0x0", { from: USER0 }), "voting-invalid-secret"); + }); + + it("cannot submit a vote if voting is closed", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert(voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }), "voting-not-open"); + }); + + it("cannot reveal a vote on a non-existent motion", async () => { + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert(voting.revealVote(0, SALT, YAY, { from: USER0 }), "voting-not-reveal"); + }); + + it("cannot reveal a vote during the submit period", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + await checkErrorRevert(voting.revealVote(motionId, SALT, YAY, { from: USER0 }), "voting-not-reveal"); + }); + + it("cannot reveal a vote after the reveal period ends", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + await forwardTime(REVEAL_PERIOD, this); + + await checkErrorRevert(voting.revealVote(motionId, SALT, NAY, { from: USER0 }), "voting-not-reveal"); + }); + + it("cannot reveal a vote with a bad secret", async () => { + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await checkErrorRevert(voting.revealVote(motionId, SALT, YAY, { from: USER0 }), "voting-secret-no-match"); + }); + }); + + describe("executing motions", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + }); + + it("cannot execute a non-existent motion", async () => { + await checkErrorRevert(voting.finalizeMotion(0), "voting-not-finalizable"); + }); + + it("motion has no effect if extension does not have permissions", async () => { + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, false); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + const tasksBefore = await colony.getTaskCount(); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + + const tasksAfter = await colony.getTaskCount(); + expect(tasksAfter).to.eq.BN(tasksBefore); + await colony.setAdministrationRole(1, UINT256_MAX, voting.address, 1, true); + }); + + it("cannot take an action if there is insufficient support", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake.subn(1), { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if there is insufficient opposition", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake.subn(1), { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action with a return value", async () => { + // Returns a uint256 + const action = await encodeTxData(colony, "version", []); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("can take an action with an arbitrary target", async () => { + const { colony: otherColony, token: otherToken } = await setupRandomColony(colonyNetwork); + await otherToken.mint(otherColony.address, WAD); + + const action = await encodeTxData(colony, "claimColonyFunds", [otherToken.address]); + await voting.createMotion(otherColony.address, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const balanceBefore = await otherColony.getFundingPotBalance(1, otherToken.address); + expect(balanceBefore).to.be.zero; + + await voting.finalizeMotion(motionId); + + const balanceAfter = await otherColony.getFundingPotBalance(1, otherToken.address); + expect(balanceAfter).to.eq.BN(WAD); + }); + + it("can take a nonexistent action", async () => { + const action = soliditySha3("foo"); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action during staking or voting", async () => { + let motionState; + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(STAKING); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + motionState = await voting.getMotionState(motionId); + expect(motionState).to.eq.BN(SUBMIT); + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("cannot take an action twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + + await checkErrorRevert(voting.finalizeMotion(motionId), "voting-not-finalizable"); + }); + + it("can take an action if the motion passes", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + + // Don't need to wait for the reveal period, since 100% of the secret is revealed + + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.true; + }); + + it("cannot take an action if the motion fails", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, NAY, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + const { logs } = await voting.finalizeMotion(motionId); + expect(logs[0].args.executed).to.be.false; + }); + + it("cannot take an action if there is insufficient voting power (state change actions)", async () => { + // Clear the locks + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, 0, true, { from: USER0 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, 0, true, { from: USER1 }); + + // Set globalClaimDelay to WAD + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(4))], WAD32]); + + await voting.createMotion(ADDRESS_ZERO, action); + const motionId1 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId1, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId1, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId1, SALT, YAY, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + let logs; + ({ logs } = await voting.finalizeMotion(motionId1)); + expect(logs[0].args.executed).to.be.true; + + // Create another motion for the same variable + await voting.createMotion(ADDRESS_ZERO, action); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId2, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId2, SALT, YAY, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(STAKE_PERIOD, this); + + ({ logs } = await voting.finalizeMotion(motionId2)); + expect(logs[0].args.executed).to.be.false; + }); + + it("can set vote power correctly after a vote", async () => { + // Clear the locks + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, 0, true, { from: USER0 }); + await tokenLocking.methods["deposit(address,uint256,bool)"](token.address, 0, true, { from: USER1 }); + + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(WAD); // USER0 had 1 WAD of reputation + }); + + it("can use vote power correctly for different values of the same variable", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + // Set finalizedTimestamp + const action1 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], WAD32]); + const action2 = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], [bn2bytes32(new BN(3))], "0x0"]); + + await voting.createMotion(ADDRESS_ZERO, action1); + const motionId1 = await voting.getMotionCount(); + + await voting.createMotion(ADDRESS_ZERO, action2); + const motionId2 = await voting.getMotionCount(); + + await voting.stakeMotion(motionId1, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId2, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + // First motion goes through + await voting.finalizeMotion(motionId1); + let expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + + // Second motion does not because of insufficient vote power + expenditure = await colony.getExpenditure(expenditureId); + expect(expenditure.finalizedTimestamp).to.eq.BN(WAD); + }); + + it("can set vote power correctly if there is insufficient opposition", async () => { + await colony.makeExpenditure(1, UINT256_MAX, 1); + const expenditureId = await colony.getExpenditureCount(); + + const action = await encodeTxData(colony, "setExpenditureState", [1, UINT256_MAX, expenditureId, 25, [true], ["0x0"], WAD32]); + + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + const slotHash = hashExpenditureSlot(action); + const pastVote = await voting.getExpenditurePastVote(slotHash); + expect(pastVote).to.eq.BN(requiredStake); + }); + }); + + describe("claiming rewards", async () => { + let motionId; + + beforeEach(async () => { + const action = await encodeTxData(colony, "makeTask", [1, UINT256_MAX, FAKE, 1, 0, 0]); + await voting.createMotion(ADDRESS_ZERO, action); + motionId = await voting.getMotionCount(); + }); + + it("cannot claim rewards from a non-existent motion", async () => { + await checkErrorRevert(voting.claimReward(0, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + + it("can let stakers claim rewards, based on the stake outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const nayStake = requiredStake.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, nayStake, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + // Note that no voter rewards were paid out + const expectedReward0 = requiredStake.add(requiredStake.divn(20)); // 110% of stake + const expectedReward1 = requiredStake.divn(20).muln(9); // 90% of stake + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user0 has no penalty, while user1 has a 10% penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER1); + expect(repUpdate.amount).to.eq.BN(requiredStake.divn(20).neg()); + }); + + it("can let stakers claim rewards, based on the vote outcome", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, { from: USER1 }); + + await forwardTime(REVEAL_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + const motion = await voting.getMotion(motionId); + const loserStake = requiredStake.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.divn(3).muln(2).subn(1); // (stake * .8) * (winPct = 1/3 * 2) + dust + const expectedReward1 = requiredStake.add(loserStake.divn(3)).subn(1); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + dust + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + + // Now check that user1 has no penalty, while user0 has a 1/3 penalty + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + expect(numEntriesPost.sub(numEntriesPrev)).to.eq.BN(1); + + const repUpdate = await repCycle.getReputationUpdateLogEntry(numEntriesPost.subn(1)); + expect(repUpdate.user).to.equal(USER0); + expect(repUpdate.amount).to.eq.BN(requiredStake.sub(expectedReward0).neg()); + }); + + it("can let stakers claim rewards, based on the vote outcome, with multiple losing stakers", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake.divn(3).muln(2), { from: USER1 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake.divn(3).addn(1), { from: USER2 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, { from: USER1 }); + + await forwardTime(REVEAL_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPre = await tokenLocking.getUserLock(token.address, USER2); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER2, NAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPost = await tokenLocking.getUserLock(token.address, USER2); + + const motion = await voting.getMotion(motionId); + const loserStake = requiredStake.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.divn(3).muln(2).subn(1); // (stake * .8) * (winPct = 1/3 * 2) + dust + const expectedReward1 = requiredStake.add(loserStake.divn(3)).divn(3).muln(2).subn(2); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + dust + const expectedReward2 = requiredStake.add(loserStake.divn(3)).divn(3); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + expect(new BN(user2LockPost.balance).sub(new BN(user2LockPre.balance))).to.eq.BN(expectedReward2); + }); + + it("can let stakers claim rewards, based on the vote outcome, with multiple winning stakers", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake.divn(3).muln(2), { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake.divn(3).addn(1), { from: USER2 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER1 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, { from: USER1 }); + + await forwardTime(REVEAL_PERIOD, this); + + await voting.finalizeMotion(motionId); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPre = await tokenLocking.getUserLock(token.address, USER2); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER2, YAY); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + const user2LockPost = await tokenLocking.getUserLock(token.address, USER2); + + const motion = await voting.getMotion(motionId); + const loserStake = requiredStake.sub(new BN(motion.paidVoterComp)); + const expectedReward0 = loserStake.divn(3).muln(2).divn(3).muln(2).subn(1); // (stake * .8) * (winPct = 1/3 * 2) + dust + const expectedReward1 = requiredStake.add(loserStake.divn(3)).subn(1); // stake + ((stake * .8) * (1 - (winPct = 2/3 * 2)) + dust + const expectedReward2 = loserStake.divn(3).muln(2).divn(3); // (stake * .8) * (winPct = 1/3 * 2) + + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(expectedReward0); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(expectedReward1); + expect(new BN(user2LockPost.balance).sub(new BN(user2LockPre.balance))).to.eq.BN(expectedReward2); + }); + + it("can let stakers claim their original stake if neither side fully staked", async () => { + const addr = await colonyNetwork.getReputationMiningCycle(false); + const repCycle = await IReputationMiningCycle.at(addr); + const numEntriesPrev = await repCycle.getReputationUpdateLogLength(); + + const half = requiredStake.divn(2); + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, half, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, half, { from: USER1 }); + + await forwardTime(STAKE_PERIOD, this); + + const user0LockPre = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPre = await tokenLocking.getUserLock(token.address, USER1); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + const numEntriesPost = await repCycle.getReputationUpdateLogLength(); + + const user0LockPost = await tokenLocking.getUserLock(token.address, USER0); + const user1LockPost = await tokenLocking.getUserLock(token.address, USER1); + + expect(numEntriesPrev).to.eq.BN(numEntriesPost); + expect(new BN(user0LockPost.balance).sub(new BN(user0LockPre.balance))).to.eq.BN(half); + expect(new BN(user1LockPost.balance).sub(new BN(user1LockPre.balance))).to.eq.BN(half); + }); + + it("cannot claim rewards twice", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + await voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY); + + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-nothing-to-claim"); + }); + + it("cannot claim rewards before a motion is finalized", async () => { + await checkErrorRevert(voting.claimReward(motionId, 1, UINT256_MAX, USER0, YAY), "voting-not-claimable"); + }); + + it("can unlock the token after claiming", async () => { + await voting.stakeMotion(motionId, 1, UINT256_MAX, YAY, requiredStake, { from: USER0 }); + await voting.stakeMotion(motionId, 1, UINT256_MAX, NAY, requiredStake, { from: USER1 }); + + await voting.submitVote(motionId, soliditySha3(SALT, YAY), { from: USER0 }); + await voting.submitVote(motionId, soliditySha3(SALT, NAY), { from: USER2 }); + + await forwardTime(SUBMIT_PERIOD, this); + + await voting.revealVote(motionId, SALT, YAY, { from: USER0 }); + await voting.revealVote(motionId, SALT, NAY, { from: USER2 }); + + await forwardTime(REVEAL_PERIOD, this); + await forwardTime(ESCALATION_PERIOD, this); + + await voting.finalizeMotion(motionId); + + let lockCount; + const lockId = await voting.getLockId(motionId); + + ({ lockCount } = await tokenLocking.getUserLock(token.address, USER1)); + expect(lockCount).to.be.zero; + + await voting.claimReward(motionId, 1, UINT256_MAX, USER1, NAY); + + ({ lockCount } = await tokenLocking.getUserLock(token.address, USER1)); + expect(lockCount).to.eq.BN(lockId); + }); + }); +});