diff --git a/abi/dai.json b/abi/dai.json new file mode 100644 index 0000000..2528490 --- /dev/null +++ b/abi/dai.json @@ -0,0 +1,339 @@ +{ + "abi": [ + { + "inputs": [ + { "internalType": "uint256", "name": "chainId_", "type": "uint256" } + ], + "payable": false, + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "guy", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": true, + "inputs": [ + { + "indexed": true, + "internalType": "bytes4", + "name": "sig", + "type": "bytes4" + }, + { + "indexed": true, + "internalType": "address", + "name": "usr", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "arg1", + "type": "bytes32" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "arg2", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "LogNote", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "src", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "dst", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "wad", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "constant": true, + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "PERMIT_TYPEHASH", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { "internalType": "address", "name": "", "type": "address" }, + { "internalType": "address", "name": "", "type": "address" } + ], + "name": "allowance", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "usr", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "approve", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "usr", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "burn", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "guy", "type": "address" } + ], + "name": "deny", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "usr", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "mint", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "src", "type": "address" }, + { "internalType": "address", "name": "dst", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "move", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "nonces", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "holder", "type": "address" }, + { "internalType": "address", "name": "spender", "type": "address" }, + { "internalType": "uint256", "name": "nonce", "type": "uint256" }, + { "internalType": "uint256", "name": "expiry", "type": "uint256" }, + { "internalType": "bool", "name": "allowed", "type": "bool" }, + { "internalType": "uint8", "name": "v", "type": "uint8" }, + { "internalType": "bytes32", "name": "r", "type": "bytes32" }, + { "internalType": "bytes32", "name": "s", "type": "bytes32" } + ], + "name": "permit", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "usr", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "pull", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "usr", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "push", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "guy", "type": "address" } + ], + "name": "rely", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "dst", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "internalType": "address", "name": "src", "type": "address" }, + { "internalType": "address", "name": "dst", "type": "address" }, + { "internalType": "uint256", "name": "wad", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "version", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "internalType": "address", "name": "", "type": "address" }], + "name": "wards", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + } + ] + } diff --git a/contracts/Moloch.sol b/contracts/Endaoment.sol similarity index 50% rename from contracts/Moloch.sol rename to contracts/Endaoment.sol index 7fd19e4..bff0433 100644 --- a/contracts/Moloch.sol +++ b/contracts/Endaoment.sol @@ -4,13 +4,13 @@ import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol"; import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/IERC20.sol"; import "./GuildBank.sol"; -contract Moloch { +contract Endaoment { using SafeMath for uint256; /*************** GLOBAL CONSTANTS ***************/ - uint256 public periodDuration; // default = 17280 = 4.8 hours in seconds (5 periods per day) + uint256 public periodDuration; // default = 17280 = 4.8 hours in seconds (5 periods per day) uint256 public votingPeriodLength; // default = 35 periods (7 days) uint256 public gracePeriodLength; // default = 35 periods (7 days) uint256 public abortWindow; // default = 5 periods (1 day) @@ -34,8 +34,12 @@ contract Moloch { EVENTS ***************/ event SubmitProposal(uint256 proposalIndex, address indexed delegateKey, address indexed memberAddress, address indexed applicant, uint256 tokenTribute, uint256 sharesRequested); + event SubmitGrantProposal(uint256 proposalIndex, address indexed delegateKey, address indexed memberAddress, address indexed applicant, uint256 tokenGrant, uint256 grantDuration); + event SubmitRevocationProposal(uint256 proposalIndex, address indexed delegateKey, address indexed memberAddress, uint256 grantIndex); event SubmitVote(uint256 indexed proposalIndex, address indexed delegateKey, address indexed memberAddress, uint8 uintVote); event ProcessProposal(uint256 indexed proposalIndex, address indexed applicant, address indexed memberAddress, uint256 tokenTribute, uint256 sharesRequested, bool didPass); + event ProcessGrantProposal(uint256 indexed proposalIndex, address indexed applicant, address indexed memberAddress, uint256 tokenGrant, uint256 grantDuration, bool didPass); + event ProcessRevocationProposal(uint256 indexed proposalIndex, address indexed memberAddress, uint256 grantIndex, bool didPass); event Ragequit(address indexed memberAddress, uint256 sharesToBurn); event Abort(uint256 indexed proposalIndex, address applicantAddress); event UpdateDelegateKey(address indexed memberAddress, address newDelegateKey); @@ -53,6 +57,12 @@ contract Moloch { No } + enum ProposalKind { + Membership, + Grant, + Revocation + } + struct Member { address delegateKey; // the key responsible for submitting proposals and voting - defaults to member address unless updated uint256 shares; // the # of shares assigned to this member @@ -60,9 +70,17 @@ contract Moloch { uint256 highestIndexYesVote; // highest proposal index # on which the member voted YES } + struct Grant { + uint256 streamId; // Sablier Stream ID of the grant + uint256 proposalIndex; // Position of this Grant in the proposal queue + uint256 startDate; // When stream actually began + uint256 endDate; // When stream will/did end + bool wasRevoked; + } + struct Proposal { address proposer; // the member who submitted the proposal - address applicant; // the applicant who wishes to become a member - this key will be used for withdrawals + address applicant; // the applicant who wishes to become a member or receive a grant - this key will be used for withdrawals uint256 sharesRequested; // the # of shares the applicant is requesting uint256 startingPeriod; // the period in which voting can start for this proposal uint256 yesVotes; // the total number of YES votes for this proposal @@ -70,26 +88,29 @@ contract Moloch { bool processed; // true only if the proposal has been processed bool didPass; // true only if the proposal passed bool aborted; // true only if applicant calls "abort" fn before end of voting period - uint256 tokenTribute; // amount of tokens offered as tribute + uint256 tokenTribute; // amount of tokens offered as tribute or given as grant string details; // proposal details - could be IPFS hash, plaintext, or JSON uint256 maxTotalSharesAtYesVote; // the maximum # of total shares encountered at a yes vote on this proposal mapping (address => Vote) votesByMember; // the votes on this proposal by each member + ProposalKind kind; // the kind of proposal + uint256 grantMeta; // The grant duration for Grant proposals; the grant index for Revocation proposals } mapping (address => Member) public members; mapping (address => address) public memberAddressByDelegateKey; Proposal[] public proposalQueue; + Grant[] public grants; /******** MODIFIERS ********/ modifier onlyMember { - require(members[msg.sender].shares > 0, "Moloch::onlyMember - not a member"); + require(members[msg.sender].shares > 0, "Endaoment::onlyMember - not a member"); _; } modifier onlyDelegate { - require(members[memberAddressByDelegateKey[msg.sender]].shares > 0, "Moloch::onlyDelegate - not a delegate"); + require(members[memberAddressByDelegateKey[msg.sender]].shares > 0, "Endaoment::onlyDelegate - not a delegate"); _; } @@ -109,17 +130,17 @@ contract Moloch { string memory _name, string memory _description ) public { - require(summoner != address(0), "Moloch::constructor - summoner cannot be 0"); - require(_approvedToken != address(0), "Moloch::constructor - _approvedToken cannot be 0"); - require(_periodDuration > 0, "Moloch::constructor - _periodDuration cannot be 0"); - require(_votingPeriodLength > 0, "Moloch::constructor - _votingPeriodLength cannot be 0"); - require(_votingPeriodLength <= MAX_VOTING_PERIOD_LENGTH, "Moloch::constructor - _votingPeriodLength exceeds limit"); - require(_gracePeriodLength <= MAX_GRACE_PERIOD_LENGTH, "Moloch::constructor - _gracePeriodLength exceeds limit"); - require(_abortWindow > 0, "Moloch::constructor - _abortWindow cannot be 0"); - require(_abortWindow <= _votingPeriodLength, "Moloch::constructor - _abortWindow must be smaller than or equal to _votingPeriodLength"); - require(_dilutionBound > 0, "Moloch::constructor - _dilutionBound cannot be 0"); - require(_dilutionBound <= MAX_DILUTION_BOUND, "Moloch::constructor - _dilutionBound exceeds limit"); - require(_proposalDeposit >= _processingReward, "Moloch::constructor - _proposalDeposit cannot be smaller than _processingReward"); + require(summoner != address(0), "Endaoment::constructor - summoner cannot be 0"); + require(_approvedToken != address(0), "Endaoment::constructor - _approvedToken cannot be 0"); + require(_periodDuration > 0, "Endaoment::constructor - _periodDuration cannot be 0"); + require(_votingPeriodLength > 0, "Endaoment::constructor - _votingPeriodLength cannot be 0"); + require(_votingPeriodLength <= MAX_VOTING_PERIOD_LENGTH, "Endaoment::constructor - _votingPeriodLength exceeds limit"); + require(_gracePeriodLength <= MAX_GRACE_PERIOD_LENGTH, "Endaoment::constructor - _gracePeriodLength exceeds limit"); + require(_abortWindow > 0, "Endaoment::constructor - _abortWindow cannot be 0"); + require(_abortWindow <= _votingPeriodLength, "Endaoment::constructor - _abortWindow must be smaller than or equal to _votingPeriodLength"); + require(_dilutionBound > 0, "Endaoment::constructor - _dilutionBound cannot be 0"); + require(_dilutionBound <= MAX_DILUTION_BOUND, "Endaoment::constructor - _dilutionBound exceeds limit"); + require(_proposalDeposit >= _processingReward, "Endaoment::constructor - _proposalDeposit cannot be smaller than _processingReward"); approvedToken = IERC20(_approvedToken); @@ -155,22 +176,22 @@ contract Moloch { public onlyDelegate { - require(applicant != address(0), "Moloch::submitProposal - applicant cannot be 0"); + require(applicant != address(0), "Endaoment::submitProposal - applicant cannot be 0"); // Make sure we won't run into overflows when doing calculations with shares. // Note that totalShares + totalSharesRequested + sharesRequested is an upper bound // on the number of shares that can exist until this proposal has been processed. - require(totalShares.add(totalSharesRequested).add(sharesRequested) <= MAX_NUMBER_OF_SHARES, "Moloch::submitProposal - too many shares requested"); + require(totalShares.add(totalSharesRequested).add(sharesRequested) <= MAX_NUMBER_OF_SHARES, "Endaoment::submitProposal - too many shares requested"); totalSharesRequested = totalSharesRequested.add(sharesRequested); address memberAddress = memberAddressByDelegateKey[msg.sender]; - // collect proposal deposit from proposer and store it in the Moloch until the proposal is processed - require(approvedToken.transferFrom(msg.sender, address(this), proposalDeposit), "Moloch::submitProposal - proposal deposit token transfer failed"); + // collect proposal deposit from proposer and store it in the Endaoment until the proposal is processed + require(approvedToken.transferFrom(msg.sender, address(this), proposalDeposit), "Endaoment::submitProposal - proposal deposit token transfer failed"); - // collect tribute from applicant and store it in the Moloch until the proposal is processed - require(approvedToken.transferFrom(applicant, address(this), tokenTribute), "Moloch::submitProposal - tribute token transfer failed"); + // collect tribute from applicant and store it in the Endaoment until the proposal is processed + require(approvedToken.transferFrom(applicant, address(this), tokenTribute), "Endaoment::submitProposal - tribute token transfer failed"); // compute startingPeriod for proposal uint256 startingPeriod = max( @@ -191,7 +212,9 @@ contract Moloch { aborted: false, tokenTribute: tokenTribute, details: details, - maxTotalSharesAtYesVote: 0 + maxTotalSharesAtYesVote: 0, + kind: ProposalKind.Membership, + grantMeta: 0 }); // ... and append it to the queue @@ -201,21 +224,129 @@ contract Moloch { emit SubmitProposal(proposalIndex, msg.sender, memberAddress, applicant, tokenTribute, sharesRequested); } + // + // when users may ragequit. + // Call guildbank functions + // Test proposals work and emit expected events + // Integrate w/ Sablier and start a stream + + function submitGrantProposal( + address applicant, + uint256 tokenGrant, + uint256 grantDuration, + string memory details + ) + public + onlyDelegate + { + require(applicant != address(0), "Endaoment::submitProposal - applicant cannot be 0"); + require(tokenGrant <= approvedToken.balanceOf(address(guildBank)), "Endaoment::submitGrantProposal - grant is greater than treasury"); + + address memberAddress = memberAddressByDelegateKey[msg.sender]; + + // collect proposal deposit from proposer and store it in the Endaoment until the proposal is processed + require(approvedToken.transferFrom(msg.sender, address(this), proposalDeposit), "Endaoment::submitProposal - proposal deposit token transfer failed"); + + // compute startingPeriod for proposal + uint256 startingPeriod = max( + getCurrentPeriod(), + proposalQueue.length == 0 ? 0 : proposalQueue[proposalQueue.length.sub(1)].startingPeriod + ).add(1); + + // create proposal ... + Proposal memory proposal = Proposal({ + proposer: memberAddress, + applicant: applicant, + sharesRequested: 0, + startingPeriod: startingPeriod, + yesVotes: 0, + noVotes: 0, + processed: false, + didPass: false, + aborted: false, + tokenTribute: tokenGrant, + details: details, + maxTotalSharesAtYesVote: 0, + kind: ProposalKind.Grant, + grantMeta: grantDuration + }); + + // ... and append it to the queue + proposalQueue.push(proposal); + + uint256 proposalIndex = proposalQueue.length.sub(1); + emit SubmitGrantProposal(proposalIndex, msg.sender, memberAddress, applicant, tokenGrant, grantDuration); + } + + function submitRevocationProposal( + uint256 grantIndex, + string memory details + ) + public + onlyDelegate + { + Grant storage grant = grants[grantIndex]; + + uint256 proposalProcessDate = block.timestamp + .add(periodDuration) // until voting starts + .add(votingPeriodLength.mul(periodDuration)) // voting ends + .add(gracePeriodLength.mul(periodDuration)) // grace end + .add(periodDuration.mul(2)); // cushion + + // Ensure grant is not completed & exists at index (if index is invalid, endDate will be 0 & this fails) + require(grant.endDate > proposalProcessDate, "Endaoment::submitRevocationProposal - grant distribution too close to completion"); + + address memberAddress = memberAddressByDelegateKey[msg.sender]; + + // collect proposal deposit from proposer and store it in the Endaoment until the proposal is processed + require(approvedToken.transferFrom(msg.sender, address(this), proposalDeposit), "Endaoment::submitProposal - proposal deposit token transfer failed"); + + // compute startingPeriod for proposal + uint256 startingPeriod = max( + getCurrentPeriod(), + proposalQueue.length == 0 ? 0 : proposalQueue[proposalQueue.length.sub(1)].startingPeriod + ).add(1); + + // create proposal ... + Proposal memory proposal = Proposal({ + proposer: memberAddress, + applicant: address(0), + sharesRequested: 0, + startingPeriod: startingPeriod, + yesVotes: 0, + noVotes: 0, + processed: false, + didPass: false, + aborted: false, + tokenTribute: 0, + details: details, + maxTotalSharesAtYesVote: 0, + kind: ProposalKind.Revocation, + grantMeta: grantIndex + }); + + // ... and append it to the queue + proposalQueue.push(proposal); + + uint256 proposalIndex = proposalQueue.length.sub(1); + emit SubmitRevocationProposal(proposalIndex, msg.sender, memberAddress, grantIndex); + } + function submitVote(uint256 proposalIndex, uint8 uintVote) public onlyDelegate { address memberAddress = memberAddressByDelegateKey[msg.sender]; Member storage member = members[memberAddress]; - require(proposalIndex < proposalQueue.length, "Moloch::submitVote - proposal does not exist"); + require(proposalIndex < proposalQueue.length, "Endaoment::submitVote - proposal does not exist"); Proposal storage proposal = proposalQueue[proposalIndex]; - require(uintVote < 3, "Moloch::submitVote - uintVote must be less than 3"); + require(uintVote < 3, "Endaoment::submitVote - uintVote must be less than 3"); Vote vote = Vote(uintVote); - require(getCurrentPeriod() >= proposal.startingPeriod, "Moloch::submitVote - voting period has not started"); - require(!hasVotingPeriodExpired(proposal.startingPeriod), "Moloch::submitVote - proposal voting period has expired"); - require(proposal.votesByMember[memberAddress] == Vote.Null, "Moloch::submitVote - member has already voted on this proposal"); - require(vote == Vote.Yes || vote == Vote.No, "Moloch::submitVote - vote must be either Yes or No"); - require(!proposal.aborted, "Moloch::submitVote - proposal has been aborted"); + require(getCurrentPeriod() >= proposal.startingPeriod, "Endaoment::submitVote - voting period has not started"); + require(!hasVotingPeriodExpired(proposal.startingPeriod), "Endaoment::submitVote - proposal voting period has expired"); + require(proposal.votesByMember[memberAddress] == Vote.Null, "Endaoment::submitVote - member has already voted on this proposal"); + require(vote == Vote.Yes || vote == Vote.No, "Endaoment::submitVote - vote must be either Yes or No"); + require(!proposal.aborted, "Endaoment::submitVote - proposal has been aborted"); // store vote proposal.votesByMember[memberAddress] = vote; @@ -242,12 +373,13 @@ contract Moloch { } function processProposal(uint256 proposalIndex) public { - require(proposalIndex < proposalQueue.length, "Moloch::processProposal - proposal does not exist"); + require(proposalIndex < proposalQueue.length, "Endaoment::processProposal - proposal does not exist"); Proposal storage proposal = proposalQueue[proposalIndex]; - require(getCurrentPeriod() >= proposal.startingPeriod.add(votingPeriodLength).add(gracePeriodLength), "Moloch::processProposal - proposal is not ready to be processed"); - require(proposal.processed == false, "Moloch::processProposal - proposal has already been processed"); - require(proposalIndex == 0 || proposalQueue[proposalIndex.sub(1)].processed, "Moloch::processProposal - previous proposal must be processed"); + require(getCurrentPeriod() >= proposal.startingPeriod.add(votingPeriodLength).add(gracePeriodLength), "Endaoment::processProposal - proposal is not ready to be processed"); + require(proposal.processed == false, "Endaoment::processProposal - proposal has already been processed"); + require(proposalIndex == 0 || proposalQueue[proposalIndex.sub(1)].processed, "Endaoment::processProposal - previous proposal must be processed"); + require(proposal.kind == ProposalKind.Membership, "Endaoment::processProposal - not a membership proposal"); proposal.processed = true; totalSharesRequested = totalSharesRequested.sub(proposal.sharesRequested); @@ -288,7 +420,7 @@ contract Moloch { // transfer tokens to guild bank require( approvedToken.transfer(address(guildBank), proposal.tokenTribute), - "Moloch::processProposal - token transfer to guild bank failed" + "Endaoment::processProposal - token transfer to guild bank failed" ); // PROPOSAL FAILED OR ABORTED @@ -296,20 +428,20 @@ contract Moloch { // return all tokens to the applicant require( approvedToken.transfer(proposal.applicant, proposal.tokenTribute), - "Moloch::processProposal - failing vote token transfer failed" + "Endaoment::processProposal - failing vote token transfer failed" ); } // send msg.sender the processingReward require( approvedToken.transfer(msg.sender, processingReward), - "Moloch::processProposal - failed to send processing reward to msg.sender" + "Endaoment::processProposal - failed to send processing reward to msg.sender" ); // return deposit to proposer (subtract processing reward) require( approvedToken.transfer(proposal.proposer, proposalDeposit.sub(processingReward)), - "Moloch::processProposal - failed to return proposal deposit to proposer" + "Endaoment::processProposal - failed to return proposal deposit to proposer" ); emit ProcessProposal( @@ -322,14 +454,112 @@ contract Moloch { ); } + function processGrantProposal(uint256 proposalIndex) public { + require(proposalIndex < proposalQueue.length, "Endaoment::processProposal - proposal does not exist"); + Proposal storage proposal = proposalQueue[proposalIndex]; + + require(getCurrentPeriod() >= proposal.startingPeriod.add(votingPeriodLength).add(gracePeriodLength), "Endaoment::processProposal - proposal is not ready to be processed"); + require(proposal.processed == false, "Endaoment::processProposal - proposal has already been processed"); + require(proposalIndex == 0 || proposalQueue[proposalIndex.sub(1)].processed, "Endaoment::processProposal - previous proposal must be processed"); + require(proposal.kind == ProposalKind.Grant, "Endaoment::processGrantProposal - not a grant proposal"); + + proposal.processed = true; + + bool didPass = proposal.yesVotes > proposal.noVotes; + bool hasFunds = (proposal.tokenTribute < approvedToken.balanceOf(address(guildBank))); + + // PROPOSAL PASSED + if (didPass && !proposal.aborted && hasFunds) { + proposal.didPass = true; + + uint256 start = block.timestamp; + uint256 end = block.timestamp.add(proposal.grantMeta); + uint256 streamId = guildBank.initiateStream(proposal.applicant, proposal.tokenTribute, start, end); + + Grant memory grant = Grant({ + streamId: streamId, + proposalIndex: proposalIndex, + startDate: start, + endDate: end, + wasRevoked: false + }); + + grants.push(grant); + } + + // send msg.sender the processingReward + require( + approvedToken.transfer(msg.sender, processingReward), + "Endaoment::processProposal - failed to send processing reward to msg.sender" + ); + + // return deposit to proposer (subtract processing reward) + require( + approvedToken.transfer(proposal.proposer, proposalDeposit.sub(processingReward)), + "Endaoment::processProposal - failed to return proposal deposit to proposer" + ); + + emit ProcessGrantProposal( + proposalIndex, + proposal.applicant, + proposal.proposer, + proposal.tokenTribute, + proposal.grantMeta, + didPass + ); + } + + function processRevocationProposal(uint256 proposalIndex) public { + require(proposalIndex < proposalQueue.length, "Endaoment::processProposal - proposal does not exist"); + Proposal storage proposal = proposalQueue[proposalIndex]; + + require(getCurrentPeriod() >= proposal.startingPeriod.add(votingPeriodLength).add(gracePeriodLength), "Endaoment::processProposal - proposal is not ready to be processed"); + require(proposal.processed == false, "Endaoment::processProposal - proposal has already been processed"); + require(proposalIndex == 0 || proposalQueue[proposalIndex.sub(1)].processed, "Endaoment::processProposal - previous proposal must be processed"); + require(proposal.kind == ProposalKind.Revocation, "Endaoment::processRevocationProposal - not a grant proposal"); + + proposal.processed = true; + + bool didPass = proposal.yesVotes > proposal.noVotes; + + // PROPOSAL PASSED + if (didPass && !proposal.aborted) { + proposal.didPass = true; + + Grant storage grant = grants[proposal.grantMeta]; + grant.wasRevoked = true; + + guildBank.revokeStream(grant.streamId); + } + + // send msg.sender the processingReward + require( + approvedToken.transfer(msg.sender, processingReward), + "Endaoment::processProposal - failed to send processing reward to msg.sender" + ); + + // return deposit to proposer (subtract processing reward) + require( + approvedToken.transfer(proposal.proposer, proposalDeposit.sub(processingReward)), + "Endaoment::processProposal - failed to return proposal deposit to proposer" + ); + + emit ProcessRevocationProposal( + proposalIndex, + proposal.proposer, + proposal.grantMeta, + didPass + ); + } + function ragequit(uint256 sharesToBurn) public onlyMember { uint256 initialTotalShares = totalShares; Member storage member = members[msg.sender]; - require(member.shares >= sharesToBurn, "Moloch::ragequit - insufficient shares"); + require(member.shares >= sharesToBurn, "Endaoment::ragequit - insufficient shares"); - require(canRagequit(member.highestIndexYesVote), "Moloch::ragequit - cant ragequit until highest index proposal member voted YES on is processed"); + require(canRagequit(member.highestIndexYesVote), "Endaoment::ragequit - cant ragequit until highest index proposal member voted YES on is processed"); // burn shares member.shares = member.shares.sub(sharesToBurn); @@ -338,19 +568,19 @@ contract Moloch { // instruct guildBank to transfer fair share of tokens to the ragequitter require( guildBank.withdraw(msg.sender, sharesToBurn, initialTotalShares), - "Moloch::ragequit - withdrawal of tokens from guildBank failed" + "Endaoment::ragequit - withdrawal of tokens from guildBank failed" ); emit Ragequit(msg.sender, sharesToBurn); } function abort(uint256 proposalIndex) public { - require(proposalIndex < proposalQueue.length, "Moloch::abort - proposal does not exist"); + require(proposalIndex < proposalQueue.length, "Endaoment::abort - proposal does not exist"); Proposal storage proposal = proposalQueue[proposalIndex]; - require(msg.sender == proposal.applicant, "Moloch::abort - msg.sender must be applicant"); - require(getCurrentPeriod() < proposal.startingPeriod.add(abortWindow), "Moloch::abort - abort window must not have passed"); - require(!proposal.aborted, "Moloch::abort - proposal must not have already been aborted"); + require(msg.sender == proposal.applicant, "Endaoment::abort - msg.sender must be applicant"); + require(getCurrentPeriod() < proposal.startingPeriod.add(abortWindow), "Endaoment::abort - abort window must not have passed"); + require(!proposal.aborted, "Endaoment::abort - proposal must not have already been aborted"); uint256 tokensToAbort = proposal.tokenTribute; proposal.tokenTribute = 0; @@ -359,19 +589,19 @@ contract Moloch { // return all tokens to the applicant require( approvedToken.transfer(proposal.applicant, tokensToAbort), - "Moloch::processProposal - failed to return tribute to applicant" + "Endaoment::processProposal - failed to return tribute to applicant" ); emit Abort(proposalIndex, msg.sender); } function updateDelegateKey(address newDelegateKey) public onlyMember { - require(newDelegateKey != address(0), "Moloch::updateDelegateKey - newDelegateKey cannot be 0"); + require(newDelegateKey != address(0), "Endaoment::updateDelegateKey - newDelegateKey cannot be 0"); // skip checks if member is setting the delegate key to their member address if (newDelegateKey != msg.sender) { - require(!members[newDelegateKey].exists, "Moloch::updateDelegateKey - cant overwrite existing members"); - require(!members[memberAddressByDelegateKey[newDelegateKey]].exists, "Moloch::updateDelegateKey - cant overwrite existing delegate keys"); + require(!members[newDelegateKey].exists, "Endaoment::updateDelegateKey - cant overwrite existing members"); + require(!members[memberAddressByDelegateKey[newDelegateKey]].exists, "Endaoment::updateDelegateKey - cant overwrite existing delegate keys"); } Member storage member = members[msg.sender]; @@ -400,7 +630,7 @@ contract Moloch { // can only ragequit if the latest proposal you voted YES on has been processed function canRagequit(uint256 highestIndexYesVote) public view returns (bool) { - require(highestIndexYesVote < proposalQueue.length, "Moloch::canRagequit - proposal does not exist"); + require(highestIndexYesVote < proposalQueue.length, "Endaoment::canRagequit - proposal does not exist"); return proposalQueue[highestIndexYesVote].processed; } @@ -409,8 +639,8 @@ contract Moloch { } function getMemberProposalVote(address memberAddress, uint256 proposalIndex) public view returns (Vote) { - require(members[memberAddress].exists, "Moloch::getMemberProposalVote - member doesn't exist"); - require(proposalIndex < proposalQueue.length, "Moloch::getMemberProposalVote - proposal doesn't exist"); + require(members[memberAddress].exists, "Endaoment::getMemberProposalVote - member doesn't exist"); + require(proposalIndex < proposalQueue.length, "Endaoment::getMemberProposalVote - proposal doesn't exist"); return proposalQueue[proposalIndex].votesByMember[memberAddress]; } } \ No newline at end of file diff --git a/contracts/EndaomentFactory.sol b/contracts/EndaomentFactory.sol index 8b44939..d32969b 100644 --- a/contracts/EndaomentFactory.sol +++ b/contracts/EndaomentFactory.sol @@ -1,6 +1,6 @@ pragma solidity ^0.5.0; -import "./Moloch.sol"; +import "./Endaoment.sol"; contract EndaomentFactory { @@ -28,7 +28,7 @@ contract EndaomentFactory { string calldata _description ) external { - Moloch endaoment = new Moloch( + Endaoment endaoment = new Endaoment( summoner, _approvedToken, _periodDuration, diff --git a/contracts/GuildBank.sol b/contracts/GuildBank.sol index ccac1b3..c2621f1 100644 --- a/contracts/GuildBank.sol +++ b/contracts/GuildBank.sol @@ -1,18 +1,27 @@ pragma solidity ^0.5.0; -import "@openzeppelin/contracts-ethereum-package/contracts/ownership/Ownable.sol"; import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol"; -contract GuildBank is Ownable { +interface ISablier { + function createStream(address recipient, uint256 deposit, address tokenAddress, uint256 startTime, uint256 stopTime) external returns (uint256); + function cancelStream(uint256 streamId) external returns (bool); +} + +contract GuildBank { using SafeMath for uint256; + address public owner; IERC20 public approvedToken; // approved token contract reference + ISablier public sablier; event Withdrawal(address indexed receiver, uint256 amount); constructor(address approvedTokenAddress) public { + owner = msg.sender; approvedToken = IERC20(approvedTokenAddress); + sablier = ISablier(0xA4fc358455Febe425536fd1878bE67FfDBDEC59a); + approvedToken.approve(address(sablier), uint256(-1)); } function withdraw(address receiver, uint256 shares, uint256 totalShares) public onlyOwner returns (bool) { @@ -20,4 +29,17 @@ contract GuildBank is Ownable { emit Withdrawal(receiver, amount); return approvedToken.transfer(receiver, amount); } + + function initiateStream(address grantee, uint256 amount, uint256 startDate, uint256 endDate) public onlyOwner returns (uint256) { + return sablier.createStream(grantee, amount, address(approvedToken), startDate, endDate); + } + + function revokeStream(uint256 streamId) public onlyOwner { + sablier.cancelStream(streamId); + } + + modifier onlyOwner() { + require(msg.sender == owner, "Endaoment::GuildBank - Not Owner"); + _; + } } \ No newline at end of file diff --git a/contracts/Pool.sol b/contracts/Pool.sol deleted file mode 100644 index 9486bb4..0000000 --- a/contracts/Pool.sol +++ /dev/null @@ -1,258 +0,0 @@ -// Pool.sol -// - mints a pool share when someone donates tokens -// - syncs with Moloch proposal queue to mint shares for grantees -// - allows donors to withdraw tokens at any time - -pragma solidity ^0.5.0; - -import "./Moloch.sol"; -import "@openzeppelin/contracts-ethereum-package/contracts/math/SafeMath.sol"; -import "@openzeppelin/contracts-ethereum-package/contracts/token/ERC20/IERC20.sol"; - -contract MolochPool { - using SafeMath for uint256; - - event Sync ( - uint256 currentProposalIndex - ); - - event Deposit ( - address donor, - uint256 sharesMinted, - uint256 tokensDeposited - ); - - event Withdraw ( - address donor, - uint256 sharesBurned - ); - - event KeeperWithdraw ( - address donor, - uint256 sharesBurned, - address keeper - ); - - event AddKeepers ( - address donor, - address[] addedKeepers - ); - - event RemoveKeepers ( - address donor, - address[] removedKeepers - ); - - event SharesMinted ( - uint256 sharesToMint, - address recipient, - uint256 totalPoolShares - ); - - event SharesBurned ( - uint256 sharesToBurn, - address recipient, - uint256 totalPoolShares - ); - - uint256 public totalPoolShares = 0; // the total shares outstanding of the pool - uint256 public currentProposalIndex = 0; // the moloch proposal index that this pool has been synced to - - Moloch public moloch; // moloch contract reference - IERC20 public approvedToken; // approved token contract reference (copied from moloch contract) - - bool locked; // prevent re-entrancy - - uint256 constant MAX_NUMBER_OF_SHARES = 10**30; // maximum number of shares that can be minted - - struct Donor { - uint256 shares; - mapping (address => bool) keepers; - } - - // the amount of shares each pool shareholder has - mapping (address => Donor) public donors; - - modifier active { - require(totalPoolShares > 0, "MolochPool: Not active"); - _; - } - - modifier noReentrancy() { - require(!locked, "MolochPool: Reentrant call"); - locked = true; - _; - locked = false; - } - - constructor(address _moloch) public { - moloch = Moloch(_moloch); - approvedToken = IERC20(moloch.approvedToken()); - } - - function activate(uint256 initialTokens, uint256 initialPoolShares) public noReentrancy { - require(totalPoolShares == 0, "MolochPool: Already active"); - - require( - approvedToken.transferFrom(msg.sender, address(this), initialTokens), - "MolochPool: Initial tokens transfer failed" - ); - _mintSharesForAddress(initialPoolShares, msg.sender); - } - - // updates Pool state based on Moloch proposal queue - // - we only want to mint shares for grants, which are 0 tribute - // - mints pool shares to applicants based on sharesRequested / maxTotalSharesAtYesVote - // - use maxTotalSharesAtYesVote because: - // - cant read shares at the time of proposal processing (womp womp) - // - should be close enough if grant shares are small relative to total shares, which they should be - // - protects pool contributors if many Moloch members ragequit before the proposal is processed by reducing follow on funding - // - e.g. if 50% of Moloch shares ragequit after someone voted yes, the grant proposal would get 50% less follow-on from the pool - function sync(uint256 toIndex) public active noReentrancy { - require( - toIndex <= moloch.getProposalQueueLength(), - "MolochPool: Proposal index too high" - ); - - // declare proposal params - address applicant; - uint256 sharesRequested; - bool processed; - bool didPass; - bool aborted; - uint256 tokenTribute; - uint256 maxTotalSharesAtYesVote; - - uint256 i = currentProposalIndex; - - while (i < toIndex) { - - (, applicant, sharesRequested, , , , processed, didPass, aborted, tokenTribute, , maxTotalSharesAtYesVote) = moloch.proposalQueue(i); - - if (!processed) { break; } - - // passing grant proposal, mint pool shares proportionally on behalf of the applicant - if (!aborted && didPass && tokenTribute == 0 && sharesRequested > 0) { - // This can't revert: - // 1. maxTotalSharesAtYesVote > 0, otherwise nobody could have voted. - // 2. sharesRequested is <= 10**18 (see Moloch.sol:172), and - // totalPoolShares <= 10**30, so multiplying them is <= 10**48 and < 2**160 - uint256 sharesToMint = totalPoolShares.mul(sharesRequested).div(maxTotalSharesAtYesVote); // for a passing proposal, maxTotalSharesAtYesVote is > 0 - _mintSharesForAddress(sharesToMint, applicant); - } - - i++; - } - - currentProposalIndex = i; - - emit Sync(currentProposalIndex); - } - - // add tokens to the pool, mint new shares proportionally - function deposit(uint256 tokenAmount) public active noReentrancy { - - uint256 sharesToMint = totalPoolShares.mul(tokenAmount).div(approvedToken.balanceOf(address(this))); - - require( - approvedToken.transferFrom(msg.sender, address(this), tokenAmount), - "MolochPool: Deposit transfer failed" - ); - - _mintSharesForAddress(sharesToMint, msg.sender); - - emit Deposit( - msg.sender, - sharesToMint, - tokenAmount - ); - } - - // burn shares to proportionally withdraw tokens in pool - function withdraw(uint256 sharesToBurn) public active noReentrancy { - _withdraw(msg.sender, sharesToBurn); - - emit Withdraw( - msg.sender, - sharesToBurn - ); - } - - // keeper burns shares to withdraw on behalf of the donor - function keeperWithdraw(uint256 sharesToBurn, address recipient) public active noReentrancy { - require( - donors[recipient].keepers[msg.sender], - "MolochPool: Sender is not a keeper" - ); - - _withdraw(recipient, sharesToBurn); - - emit KeeperWithdraw( - recipient, - sharesToBurn, - msg.sender - ); - } - - function addKeepers(address[] calldata newKeepers) external active noReentrancy { - Donor storage donor = donors[msg.sender]; - - for (uint256 i = 0; i < newKeepers.length; i++) { - donor.keepers[newKeepers[i]] = true; - } - - emit AddKeepers(msg.sender, newKeepers); - } - - function removeKeepers(address[] calldata keepersToRemove) external active noReentrancy { - Donor storage donor = donors[msg.sender]; - - for (uint256 i = 0; i < keepersToRemove.length; i++) { - donor.keepers[keepersToRemove[i]] = false; - } - - emit RemoveKeepers(msg.sender, keepersToRemove); - } - - function _mintSharesForAddress(uint256 sharesToMint, address recipient) internal { - totalPoolShares = totalPoolShares.add(sharesToMint); - donors[recipient].shares = donors[recipient].shares.add(sharesToMint); - - require( - totalPoolShares <= MAX_NUMBER_OF_SHARES, - "MolochPool: Max number of shares exceeded" - ); - - emit SharesMinted( - sharesToMint, - recipient, - totalPoolShares - ); - } - - function _withdraw(address recipient, uint256 sharesToBurn) internal { - Donor storage donor = donors[recipient]; - - require( - donor.shares >= sharesToBurn, - "MolochPool: Not enough shares to burn" - ); - - uint256 tokensToWithdraw = approvedToken.balanceOf(address(this)).mul(sharesToBurn).div(totalPoolShares); - - totalPoolShares = totalPoolShares.sub(sharesToBurn); - donor.shares = donor.shares.sub(sharesToBurn); - - require( - approvedToken.transfer(recipient, tokensToWithdraw), - "MolochPool: Withdrawal transfer failed" - ); - - emit SharesBurned( - sharesToBurn, - recipient, - totalPoolShares - ); - } - -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c539aaa..bcf3325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19607,30 +19607,6 @@ "mimic-fn": "^2.1.0" } }, - "openzeppelin-test-helpers": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/openzeppelin-test-helpers/-/openzeppelin-test-helpers-0.5.1.tgz", - "integrity": "sha512-xIMusuVCKXfR+qwbyiXpV7UaqaW3H/Q3axQ2nSce8o3R9TQoxrZdbKvMZdJdg6Ro4cpRDQ3yZtniz4vSIbSZHg==", - "dev": true, - "requires": { - "@truffle/contract": "^4.0.35", - "ansi-colors": "^3.2.3", - "chai-bn": "^0.2.0", - "ethjs-abi": "^0.2.1", - "lodash.flatten": "^4.4.0", - "semver": "^5.6.0", - "web3": "^1.2.1", - "web3-utils": "^1.2.1" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true - } - } - }, "original-require": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/original-require/-/original-require-1.0.1.tgz", diff --git a/package.json b/package.json index b93d5ea..f95de38 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@openzeppelin/test-environment": "^0.1.4", "@openzeppelin/test-helpers": "^0.5.5", "chai": "^4.2.0", - "mocha": "^7.1.1", - "openzeppelin-test-helpers": "^0.5.1" + "mocha": "^7.1.1" } } diff --git a/test-environment.config.js b/test-environment.config.js index b102b27..1d5d922 100644 --- a/test-environment.config.js +++ b/test-environment.config.js @@ -4,7 +4,11 @@ const infura_key = process.env.INFURA_ID; module.exports = { node: { fork: `https://mainnet.infura.io/v3/${infura_key}`, - unlocked_accounts: [], + unlocked_accounts: [process.env.DAI_FUNDER], allowUnlimitedContractSize: true, // TODO remove the need for this + gasLimit: 200e6, + }, + contracts: { + defaultGas: 200e6, }, }; diff --git a/test/endaoment-test.js b/test/endaoment-test.js index 07f24f0..3080322 100644 --- a/test/endaoment-test.js +++ b/test/endaoment-test.js @@ -1,28 +1,155 @@ -const { accounts, contract } = require('@openzeppelin/test-environment'); +require('dotenv').config(); +const { accounts, contract, web3 } = require('@openzeppelin/test-environment'); +const { time, expectEvent, expectRevert } = require('@openzeppelin/test-helpers'); const { expect } = require('chai'); -const Moloch = contract.fromArtifact('Moloch'); +const Endaoment = contract.fromArtifact('Endaoment'); +const GuildBank = contract.fromArtifact('GuildBank'); +const { toWeiDai, stealDai, approveDai, daiBalance } = require('./helpers'); -describe('Moloch', () => { - const [ summoner ] = accounts; +const PERIOD_DURATION = 17280; +const VOTING_PERIODS = 35; +const GRACE_PERIODS = 35; +const ABORT_WINDOW = 5; +const VOTING_DURATION = VOTING_PERIODS * PERIOD_DURATION; +const GRACE_DURATION = GRACE_PERIODS * PERIOD_DURATION; + +const GrantDuration = 30*24*60*60; + +describe('Endaoment', () => { + const [ summoner, member1, member2, grantee1 ] = accounts; before(async () => { - this.instance = await Moloch.new( + this.instance = await Endaoment.new( summoner, - "0x6B175474E89094C44Da98b954EedeAC495271d0F", // address _approvedToken (DAI address) - 17280, // uint256 _periodDuration - 35, // uint256 _votingPeriodLength -- 35 periods? - 35, // uint256 _gracePeriodLength -- 35 periods? - 5, // uint256 _abortWindow -- 5 periods? + process.env.DAI_ADDR, // address _approvedToken (DAI address) + PERIOD_DURATION, // uint256 _periodDuration + VOTING_PERIODS, // uint256 _votingPeriodLength -- 35 periods? + GRACE_PERIODS, // uint256 _gracePeriodLength -- 35 periods? + ABORT_WINDOW, // uint256 _abortWindow -- 5 periods? "100000000000000000000", // uint256 _proposalDeposit -- 100 Dai 3, // uint256 _dilutionBound -- 3 "1000000000000000000", // uint256 _processingReward -- 1 Dai "COVID-19 Relief", "Donate funds to selected organizations helping with COVID-19 relief", - {from: summoner}); + {from: summoner} + ); + + const guildBankAddr = await this.instance.guildBank(); + this.guildBank = await GuildBank.at(guildBankAddr); + + await stealDai(1000, summoner); + await stealDai(20000, member1); + await stealDai(20000, member2); + await approveDai(summoner, this.instance.address); + await approveDai(member1, this.instance.address); + await approveDai(member2, this.instance.address); }); - it('should see the deployed Moloch contract', async () => { + it('should see the deployed Endaoment contract', async () => { expect(this.instance.address.startsWith('0x')).to.be.true; expect(this.instance.address.length).to.equal(42); }); + + it('should allow a membership proposal & vote', async () => { + await this.instance.submitProposal(member1, toWeiDai(2000), 2000, "member1", {from: summoner}); + const proposal = await this.instance.proposalQueue(0); + expect(proposal.details).to.equal("member1"); + + await time.increase(PERIOD_DURATION); + await this.instance.submitVote(0, 1, {from: summoner}); + await time.increase(VOTING_DURATION + GRACE_DURATION); + await this.instance.processProposal(0, {from: summoner}); + + const memberInfo = await this.instance.members(member1); + expect(memberInfo.shares.toString()).to.equal('2000'); + }); + + it('should allow another membership proposal & vote', async () => { + await this.instance.submitProposal(member2, toWeiDai(3000), 3000, "member2", {from: member1}); + const proposal = await this.instance.proposalQueue(1); + expect(proposal.details).to.equal("member2"); + + await time.increase(PERIOD_DURATION); + await this.instance.submitVote(1, 2, {from: summoner}); + await this.instance.submitVote(1, 1, {from: member1}); + await time.increase(VOTING_DURATION + GRACE_DURATION); + await this.instance.processProposal(1, {from: summoner}); + + const memberInfo = await this.instance.members(member2); + expect(memberInfo.shares.toString()).to.equal('3000'); + }); + + it('should not allow a grant proposal for more than guildbank owns', async () => { + await expectRevert( + this.instance.submitGrantProposal(grantee1, toWeiDai(10000), + 30*24*60*60, "grantee1", {from: member2}), + "Endaoment::submitGrantProposal - grant is greater than treasury" + ); + }); + + it('should allow a grant proposal from the guildbank', async () => { + const desiredAmount = new web3.utils.BN(toWeiDai(1000)); + const duration = new web3.utils.BN(GrantDuration); + const remainder = desiredAmount.mod(duration); + const divisibleAmount = desiredAmount.sub(remainder); + + await this.instance.submitGrantProposal(grantee1, divisibleAmount, GrantDuration, "grantee1", {from: member2}); + const proposal = await this.instance.proposalQueue(2); + expect(proposal.details).to.equal('grantee1'); + }); + + it('should allow memebers to vote on a grant proposal', async () => { + await time.increase(PERIOD_DURATION); + + await this.instance.submitVote(2, 2, {from: member1}); + await this.instance.submitVote(2, 1, {from: member2}); + + const proposal = await this.instance.proposalQueue(2); + + expect(proposal.yesVotes.toString()).to.equal('3000'); + expect(proposal.noVotes.toString()).to.equal('2000'); + }); + + it('should process a successful grant proposal', async () => { + await time.increase(VOTING_DURATION + GRACE_DURATION); + + await this.instance.processGrantProposal(2, {from: grantee1}); + const grant = await this.instance.grants(0); + + expect(grant.proposalIndex.toString()).to.equal('2'); + }); + + it('should allow a revocation proposal', async () => { + await this.instance.submitRevocationProposal(0, "revoke grantee1", {from: summoner}); + const proposal = await this.instance.proposalQueue(3); + expect(proposal.details).to.equal("revoke grantee1"); + }); + + it('should allow members to vote on a revocation proposal', async () => { + await time.increase(PERIOD_DURATION); + + await this.instance.submitVote(3, 2, {from: member1}); + await this.instance.submitVote(3, 1, {from: member2}); + + const proposal = await this.instance.proposalQueue(3); + + expect(proposal.yesVotes.toString()).to.equal('3000'); + expect(proposal.noVotes.toString()).to.equal('2000'); + }); + + it('should process a successful revocation proposal', async () => { + await time.increase(VOTING_DURATION + GRACE_DURATION); + + const initialGuildBalance = await daiBalance(this.guildBank.address); + + await this.instance.processRevocationProposal(3, {from: summoner}); + + const grant = await this.instance.grants(0); + const postGuildBalance = await daiBalance(this.guildBank.address); + + expect(grant.wasRevoked).to.be.true; + expect(postGuildBalance.gt(initialGuildBalance)).to.be.true; + }); + + // TODO: Test grant proposal fails if ragequitters deplete required funds }); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..66c8a2f --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,25 @@ +const { web3 } = require('@openzeppelin/test-environment'); +const daiAbi = require('../abi/dai.json').abi; + +const daiFunder = process.env.DAI_FUNDER; +const daiAddress = process.env.DAI_ADDR; + +exports.toWeiDai = (dai) => { + return web3.utils.toWei(dai.toString(), 'ether'); +}; + +exports.stealDai = async (amount, receiver) => { + const daiContract = new web3.eth.Contract(daiAbi, daiAddress); + await daiContract.methods.transfer(receiver, this.toWeiDai(amount)).send({from: daiFunder}); +}; + +exports.approveDai = async (holder, spender) => { + const daiContract = new web3.eth.Contract(daiAbi, daiAddress); + await daiContract.methods.approve(spender, this.toWeiDai(1000000000000)).send({from: holder}); +} + +exports.daiBalance = async(holder) => { + const daiContract = new web3.eth.Contract(daiAbi, daiAddress); + const stringBalance = await daiContract.methods.balanceOf(holder).call(); + return new web3.utils.BN(stringBalance); +}