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:
parent
4e3037b141
commit
6e763f667d
|
@ -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 minimumParticipation; // Minimum participation percentage with 2 decimals 10000 == 100.00
|
||||
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 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];
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
@ -187,7 +185,7 @@ contract("StakingPoolDAO", function () {
|
|||
|
||||
it("unapproved proposals cant be executed", async () => {
|
||||
await StakingPool.methods.vote(proposalId, false).send({from: richard});
|
||||
|
||||
|
||||
// Mine 20 blocks
|
||||
for(let i = 0; i < 20; i++){
|
||||
await mineAtTimestamp(12345678);
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue