feat: cancel proposals (#13)

* fix: tests

* adding tests pt 1

* feat: cancelling a proposal

* fix: update tests so they work as usual

* feat: cancelation tests pt 1

* feat: cancelation tests pt 2
This commit is contained in:
RichΛrd 2020-04-13 10:04:26 -04:00 committed by GitHub
parent 4e3037b141
commit 6e763f667d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 208 additions and 19 deletions

View File

@ -24,18 +24,25 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
mapping(bool => uint) votes;
mapping(address => VoteStatus) voters;
mapping(bool => uint) cancelVotes;
mapping(address => VoteStatus) cancelVoters;
}
uint public proposalCount;
mapping(uint => Proposal) public proposals;
uint public proposalVoteLength; // Voting available during this period
uint public proposalExpirationLength; // Proposals should be executed up to 1 day after they have ended
uint public proposalExpirationLength; // Proposals should be executed up to some time after they have ended
uint public proposalCancelLength; // Voting for canceling a proposal can be done up to some time after the approval was done
uint public minimumParticipation; // Minimum participation percentage with 2 decimals 10000 == 100.00
uint public minimumParticipation; // Minimum participation percentage with 2 decimals. 10000 == 100.00
uint public minimumParticipationForCancel; // Minimum participation percentage with 2 decimals. Required to consider the call for cancel
uint public minimumCancelApprovalPercentage; // Minimum percentage to consider a approved proposal as canceled. Uses 2 decimals
event NewProposal(uint indexed proposalId);
event Vote(uint indexed proposalId, address indexed voter, VoteStatus indexed choice);
event CancelVote(uint indexed proposalId, address indexed voter, VoteStatus indexed choice);
event Execution(uint indexed proposalId);
event ExecutionFailure(uint indexed proposalId);
@ -44,20 +51,29 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
* @param _tokenAddress SNT token address
* @param _stakingPeriodLen Length in blocks for the period where user will be able to stake SNT
* @param _proposalVoteLength Length in blocks for the period where voting will be available for proposals
* @param _proposalCancelLength Length in blocks for the period where a proposal can be voted for cancel
* @param _proposalExpirationLength Length in blocks where a proposal must be executed after voting before it is considered expired
* @param _minimumParticipation Percentage of participation required for a proposal to be considered valid
* @param _minimumParticipationForCancel Percentage of participation required for a proposal to be considered canceled
* @param _minimumCancelApprovalPercentage Cancel votes should reach this percentage of the votes done in the cancel period for a proposal to be considered canceled
*/
constructor (
address _tokenAddress,
uint _stakingPeriodLen,
uint _proposalVoteLength,
uint _proposalCancelLength,
uint _proposalExpirationLength,
uint _minimumParticipation
uint _minimumParticipation,
uint _minimumParticipationForCancel,
uint _minimumCancelApprovalPercentage
) public
StakingPool(_tokenAddress, _stakingPeriodLen) {
proposalVoteLength = _proposalVoteLength;
proposalCancelLength = _proposalCancelLength;
proposalExpirationLength = _proposalExpirationLength;
minimumParticipation = _minimumParticipation;
minimumParticipationForCancel = _minimumParticipationForCancel;
minimumCancelApprovalPercentage = _minimumCancelApprovalPercentage;
}
/**
@ -68,6 +84,14 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
proposalVoteLength = _newProposalVoteLength;
}
/**
* @dev Set length in blocks where a proposal can be voted for cancel. Can only be executed by the contract's controller
* @param _proposalCancelLength Length in blocks where a proposal can be voted for cancel
*/
function setProposalCancelLength(uint _proposalCancelLength) public onlyController {
proposalCancelLength = _proposalCancelLength;
}
/**
* @dev Set length in blocks where a proposal must be executed before it is considered as expired. Can only be executed by the contract's controller
* @param _newProposalExpirationLength Length in blocks where a proposal must be executed after voting before it is considered expired
@ -84,6 +108,21 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
minimumParticipation = _newMinimumParticipation;
}
/**
* @dev Set minimum participation percentage for cancels to be considered valid. Can only be executed by the contract's controller
* @param _minimumParticipationForCancel Percentage of participation required for a proposal to be considered valid
*/
function setMinimumParticipationForCancel(uint _minimumParticipationForCancel) public onlyController {
minimumParticipationForCancel = _minimumParticipationForCancel;
}
/**
* @dev Set minimum percentage of votes for a proposal to be considered canceled
* @param _minimumCancelApprovalPercentage Cancel votes should reach this percentage of the votes done in the cancel period for a proposal to be considered canceled */
function setMinimumCancelApprovalPercentage(uint _minimumCancelApprovalPercentage) public onlyController {
minimumCancelApprovalPercentage = _minimumCancelApprovalPercentage;
}
/**
* @notice Adds a new proposal
* @param _destination Transaction target address
@ -122,7 +161,7 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
function vote(uint _proposalId, bool _choice) external {
Proposal storage proposal = proposals[_proposalId];
require(proposal.voteEndingBlock > block.number, "Proposal has already ended");
require(proposal.voteEndingBlock > block.number, "Proposal voting has already ended");
address sender = _msgSender();
@ -146,6 +185,39 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
emit Vote(_proposalId, sender, enumVote);
}
/**
* @notice Vote to cancel a proposal
* @param _proposalId Id of the proposal to vote
* @param _choice True for voting yes, False for no
*/
function cancel(uint _proposalId, bool _choice) external {
Proposal storage proposal = proposals[_proposalId];
require(proposal.voteEndingBlock + proposalCancelLength > block.number, "Proposal cancel period has already ended");
require(proposal.voteEndingBlock <= block.number, "Proposal cancel period has not started yet");
address sender = _msgSender();
uint voterBalance = balanceOfAt(sender, proposal.snapshotId);
require(voterBalance > 0, "Not enough tokens at the moment of proposal creation");
VoteStatus oldVote = proposal.cancelVoters[sender];
if(oldVote != VoteStatus.NONE){ // Reset
bool oldChoice = oldVote == VoteStatus.YES ? true : false;
proposal.cancelVotes[oldChoice] -= voterBalance;
}
VoteStatus enumVote = _choice ? VoteStatus.YES : VoteStatus.NO;
proposal.cancelVotes[_choice] += voterBalance;
proposal.cancelVoters[sender] = enumVote;
lastActivity[sender] = block.timestamp;
emit CancelVote(_proposalId, sender, enumVote);
}
/**
* @dev Execute a transaction
* @param _destination Transaction target address.
@ -181,13 +253,16 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
Proposal storage proposal = proposals[_proposalId];
require(proposal.executed == false, "Proposal already executed");
require(block.number > proposal.voteEndingBlock, "Voting is still active");
require(block.number <= proposal.voteEndingBlock + proposalExpirationLength, "Proposal is already expired");
require(block.number > proposal.voteEndingBlock + proposalCancelLength, "Voting is still active");
require(block.number <= proposal.voteEndingBlock + proposalCancelLength + proposalExpirationLength, "Proposal is already expired");
require(proposal.votes[true] > proposal.votes[false], "Proposal wasn't approved");
uint totalParticipation = ((proposal.votes[true] + proposal.votes[false]) * 10000) / totalSupply();
require(totalParticipation >= minimumParticipation, "Did not meet the minimum required participation");
require(!_isCancelled(proposal), "Proposal was canceled");
proposal.executed = true;
bool result = external_call(proposal.destination, proposal.value, proposal.data.length, proposal.data);
@ -195,6 +270,19 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
emit Execution(_proposalId);
}
function _isCancelled(Proposal storage proposal) internal view returns(bool) {
uint totalCancelVotes = proposal.cancelVotes[true] + proposal.cancelVotes[false];
uint totalCancelParticipation = (totalCancelVotes * 10000) / totalSupply();
if(totalCancelVotes > 0){
uint cancelApprovalPercentage = (proposal.cancelVotes[true] * 10000) / totalCancelVotes;
return totalCancelParticipation >= minimumParticipationForCancel && cancelApprovalPercentage >= minimumCancelApprovalPercentage;
}
return false;
}
/**
* @notice Get the number of votes for a proposal choice
* @param _proposalId Proposal ID
@ -216,19 +304,24 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
}
/**
* @notice Check if a proposal is approved or not
* @notice Check proposal status
* @param _proposalId Proposal ID
* @return approved Indicates if the proposal was approved or not
* @return executed Indicates if the proposal was executed or not
* @return canceled Indicates if the proposal was canceled or not
*/
function isProposalApproved(uint _proposalId) public view returns (bool approved, bool executed){
function proposalStatus(uint _proposalId) public view returns (bool approved, bool canceled, bool executed){
Proposal storage proposal = proposals[_proposalId];
uint totalParticipation = ((proposal.votes[true] + proposal.votes[false]) * 10000) / totalSupply();
if(block.number <= proposal.voteEndingBlock || totalParticipation < minimumParticipation) {
approved = false;
} else {
approved = proposal.votes[true] > proposal.votes[false];
}
canceled = block.number > proposal.voteEndingBlock + proposalCancelLength && _isCancelled(proposal);
executed = proposal.executed;
}
@ -249,6 +342,7 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
}
bytes4 constant VOTE_SIGNATURE = bytes4(keccak256("vote(uint256,bool)"));
bytes4 constant CANCEL_SIGNATURE = bytes4(keccak256("cancel(uint256,bool)"));
/**
* @dev Function returning if we accept or not the relayed call (do we pay or not for the gas)
@ -294,7 +388,7 @@ contract StakingPoolDAO is StakingPool, GSNRecipient, ERC20Snapshot, Controlled
uint _proposalId,
uint _gasPrice
) internal view returns (uint256, bytes memory) {
if(_functionSignature != VOTE_SIGNATURE) return _rejectRelayedCall(uint256(GSNErrorCodes.FUNCTION_NOT_AVAILABLE));
if(_functionSignature != VOTE_SIGNATURE || _functionSignature != CANCEL_SIGNATURE) return _rejectRelayedCall(uint256(GSNErrorCodes.FUNCTION_NOT_AVAILABLE));
Proposal storage proposal = proposals[_proposalId];

View File

@ -30,7 +30,6 @@ config({
},
"StakingPoolDAO": {
"deploy": false,
"args": ["$SNT"]
}
}
}
@ -60,9 +59,8 @@ contract("StakingPoolDAO", function () {
await SNT.methods.generateTokens(michael, "10000000000").send({from: iuri});
await SNT.methods.generateTokens(eric, "10000000000").send({from: iuri});
// Deploy Staking Pool
StakingPool = await StakingPoolDAO.deploy({ arguments: [SNT.options.address, 100, 20, 10, 0] }).send();
StakingPool = await StakingPoolDAO.deploy({ arguments: [SNT.options.address, 30, 20, 0, 10, 0, 0, 0] }).send();
const encodedCall = StakingPool.methods.stake("10000000000").encodeABI();
await web3.eth.sendTransaction({from: iuri, to: StakingPool.options.address, value: "100000000000000000"});
@ -75,7 +73,7 @@ contract("StakingPoolDAO", function () {
await SNT.methods.approveAndCall(StakingPool.options.address, "10000000000", encodedCall).send({from: eric});
// Mine 100 blocks
for(let i = 0; i < 100; i++){
for(let i = 0; i < 30; i++){
await mineAtTimestamp(12345678);
}
})
@ -146,7 +144,7 @@ contract("StakingPoolDAO", function () {
await mineAtTimestamp(12345678);
}
await assert.reverts(toSend, {from: richard}, "Returned error: VM Exception while processing transaction: revert Proposal has already ended");
await assert.reverts(toSend, {from: richard}, "Returned error: VM Exception while processing transaction: revert Proposal voting has already ended");
});
it("check that vote result matches what was voted", async () => {
@ -163,7 +161,7 @@ contract("StakingPoolDAO", function () {
let votesY = await StakingPool.methods.votes(proposalId, true).call();
let votesN = await StakingPool.methods.votes(proposalId, false).call();
const result = await StakingPool.methods.isProposalApproved(proposalId).call();
const result = await StakingPool.methods.proposalStatus(proposalId).call();
assert.strictEqual(votesY, "30000000000");
assert.strictEqual(votesN, "20000000000");
@ -205,7 +203,7 @@ contract("StakingPoolDAO", function () {
await mineAtTimestamp(12345678);
}
let result = await StakingPool.methods.isProposalApproved(proposalId).call();
let result = await StakingPool.methods.proposalStatus(proposalId).call();
assert.strictEqual(result.approved, true);
assert.strictEqual(result.executed, false);
@ -214,7 +212,7 @@ contract("StakingPoolDAO", function () {
const destinationBalance = await web3.eth.getBalance("0x00000000000000000000000000000000000000AA");
assert.strictEqual(destinationBalance, "12345");
result = await StakingPool.methods.isProposalApproved(proposalId).call();
result = await StakingPool.methods.proposalStatus(proposalId).call();
assert.strictEqual(result.executed, true);
});
@ -296,4 +294,101 @@ contract("StakingPoolDAO", function () {
await StakingPool.methods.executeTransaction(proposalId).send({from: iuri});
});
});
describe("proposal cancelation", () => {
let proposalId;
before(async () => {
// Setting values
await StakingPool.methods.setProposalCancelLength("10").send();
await StakingPool.methods.setMinimumParticipationForCancel("5000").send();
await StakingPool.methods.setMinimumCancelApprovalPercentage("7500").send();
});
beforeEach(async () => {
const receipt = await StakingPool.methods.addProposal("0x00000000000000000000000000000000000000CC", 12345, "0x", "0x").send({from: richard});
proposalId = receipt.events.NewProposal.returnValues.proposalId;
});
it("can not execute a proposal that is still in the cancelation period", async () => {
await StakingPool.methods.vote(proposalId, true).send({from: iuri});
await StakingPool.methods.vote(proposalId, true).send({from: richard});
await StakingPool.methods.vote(proposalId, true).send({from: pascal});
// Mine 25 blocks
for(let i = 0; i < 25; i++){
await mineAtTimestamp(12345678);
}
const toSend = StakingPool.methods.executeTransaction(proposalId);
await assert.reverts(toSend, {from: richard}, "Returned error: VM Exception while processing transaction: revert Voting is still active");
});
it("can execute a proposal with no cancelation votes", async() => {
await StakingPool.methods.vote(proposalId, true).send({from: iuri});
await StakingPool.methods.vote(proposalId, true).send({from: richard});
await StakingPool.methods.vote(proposalId, true).send({from: pascal});
// Mine 30 blocks
for(let i = 0; i < 30; i++){
await mineAtTimestamp(12345678);
}
await StakingPool.methods.executeTransaction(proposalId).send({from: richard});
});
it("can not vote to cancel a proposal before the voting period for it is enabled", async () => {
const toSend = StakingPool.methods.cancel(proposalId, true);
await assert.reverts(toSend, {from: richard}, "Returned error: VM Exception while processing transaction: revert Proposal cancel period has not started yet");
});
it("can execute a proposal that did not reach the required participation minimum", async () => {
await StakingPool.methods.vote(proposalId, true).send({from: iuri});
await StakingPool.methods.vote(proposalId, true).send({from: richard});
await StakingPool.methods.vote(proposalId, true).send({from: pascal});
// Mine 20 blocks
for(let i = 0; i < 20; i++){
await mineAtTimestamp(12345678);
}
await StakingPool.methods.cancel(proposalId, true).send({from: iuri});
await StakingPool.methods.cancel(proposalId, true).send({from: richard});
for(let i = 0; i < 10; i++){
await mineAtTimestamp(12345678);
}
await StakingPool.methods.executeTransaction(proposalId).send({from: richard});
});
it("cannot execute a proposal that reach the required participation and cancel votes", async () => {
await StakingPool.methods.vote(proposalId, true).send({from: iuri});
await StakingPool.methods.vote(proposalId, true).send({from: richard});
await StakingPool.methods.vote(proposalId, true).send({from: pascal});
// Mine 20 blocks
for(let i = 0; i < 20; i++){
await mineAtTimestamp(12345678);
}
await StakingPool.methods.cancel(proposalId, true).send({from: iuri});
await StakingPool.methods.cancel(proposalId, true).send({from: richard});
await StakingPool.methods.cancel(proposalId, true).send({from: pascal});
for(let i = 0; i < 10; i++){
await mineAtTimestamp(12345678);
}
const toSend = StakingPool.methods.executeTransaction(proposalId);
await assert.reverts(toSend, {from: richard}, "Returned error: VM Exception while processing transaction: revert Proposal was canceled");
const result = await StakingPool.methods.proposalStatus(proposalId).call();
assert.strictEqual(result.approved, true);
assert.strictEqual(result.canceled, true);
assert.strictEqual(result.executed, false);
});
});
});