Add a governor module to protect against late quorum (#2973)
Co-authored-by: Francisco Giordano <frangio.1@gmail.com>pull/2996/head
parent
6089f11c2f
commit
abf6024faf
@ -0,0 +1,106 @@ |
||||
// SPDX-License-Identifier: MIT |
||||
|
||||
pragma solidity ^0.8.0; |
||||
|
||||
import "../Governor.sol"; |
||||
import "../../utils/math/Math.sol"; |
||||
|
||||
/** |
||||
* @dev A module that ensures there is a minimum voting period after quorum is reached. This prevents a large voter from |
||||
* swaying a vote and triggering quorum at the last minute, by ensuring there is always time for other voters to react |
||||
* and try to oppose the decision. |
||||
* |
||||
* If a vote causes quorum to be reached, the proposal's voting period may be extended so that it does not end before at |
||||
* least a given number of blocks have passed (the "vote extension" parameter). This parameter can be set by the |
||||
* governance executor (e.g. through a governance proposal). |
||||
* |
||||
* _Available since v4.5._ |
||||
*/ |
||||
abstract contract GovernorPreventLateQuorum is Governor { |
||||
using SafeCast for uint256; |
||||
using Timers for Timers.BlockNumber; |
||||
|
||||
uint64 private _voteExtension; |
||||
mapping(uint256 => Timers.BlockNumber) private _extendedDeadlines; |
||||
|
||||
/// @dev Emitted when a proposal deadline is pushed back due to reaching quorum late in its voting period. |
||||
event ProposalExtended(uint256 indexed proposalId, uint64 extendedDeadline); |
||||
|
||||
/// @dev Emitted when the {lateQuorumVoteExtension} parameter is changed. |
||||
event LateQuorumVoteExtensionSet(uint64 oldVoteExtension, uint64 newVoteExtension); |
||||
|
||||
/** |
||||
* @dev Initializes the vote extension parameter: the number of blocks that are required to pass since a proposal |
||||
* reaches quorum until its voting period ends. If necessary the voting period will be extended beyond the one set |
||||
* at proposal creation. |
||||
*/ |
||||
constructor(uint64 initialVoteExtension) { |
||||
_setLateQuorumVoteExtension(initialVoteExtension); |
||||
} |
||||
|
||||
/** |
||||
* @dev Returns the proposal deadline, which may have been extended beyond that set at proposal creation, if the |
||||
* proposal reached quorum late in the voting period. See {Governor-proposalDeadline}. |
||||
*/ |
||||
function proposalDeadline(uint256 proposalId) public view virtual override returns (uint256) { |
||||
return Math.max(super.proposalDeadline(proposalId), _extendedDeadlines[proposalId].getDeadline()); |
||||
} |
||||
|
||||
/** |
||||
* @dev Casts a vote and detects if it caused quorum to be reached, potentially extending the voting period. See |
||||
* {Governor-_castVote}. |
||||
* |
||||
* May emit a {ProposalExtended} event. |
||||
*/ |
||||
function _castVote( |
||||
uint256 proposalId, |
||||
address account, |
||||
uint8 support, |
||||
string memory reason |
||||
) internal virtual override returns (uint256) { |
||||
uint256 result = super._castVote(proposalId, account, support, reason); |
||||
|
||||
Timers.BlockNumber storage extendedDeadline = _extendedDeadlines[proposalId]; |
||||
|
||||
if (extendedDeadline.isUnset() && _quorumReached(proposalId)) { |
||||
uint64 extendedDeadlineValue = block.number.toUint64() + lateQuorumVoteExtension(); |
||||
|
||||
if (extendedDeadlineValue > proposalDeadline(proposalId)) { |
||||
emit ProposalExtended(proposalId, extendedDeadlineValue); |
||||
} |
||||
|
||||
extendedDeadline.setDeadline(extendedDeadlineValue); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
/** |
||||
* @dev Returns the current value of the vote extension parameter: the number of blocks that are required to pass |
||||
* from the time a proposal reaches quorum until its voting period ends. |
||||
*/ |
||||
function lateQuorumVoteExtension() public view virtual returns (uint64) { |
||||
return _voteExtension; |
||||
} |
||||
|
||||
/** |
||||
* @dev Changes the {lateQuorumVoteExtension}. This operation can only be performed by the governance executor, |
||||
* generally through a governance proposal. |
||||
* |
||||
* Emits a {LateQuorumVoteExtensionSet} event. |
||||
*/ |
||||
function setLateQuorumVoteExtension(uint64 newVoteExtension) public virtual onlyGovernance { |
||||
_setLateQuorumVoteExtension(newVoteExtension); |
||||
} |
||||
|
||||
/** |
||||
* @dev Changes the {lateQuorumVoteExtension}. This is an internal function that can be exposed in a public function |
||||
* like {setLateQuorumVoteExtension} if another access control mechanism is needed. |
||||
* |
||||
* Emits a {LateQuorumVoteExtensionSet} event. |
||||
*/ |
||||
function _setLateQuorumVoteExtension(uint64 newVoteExtension) internal virtual { |
||||
emit LateQuorumVoteExtensionSet(_voteExtension, newVoteExtension); |
||||
_voteExtension = newVoteExtension; |
||||
} |
||||
} |
@ -0,0 +1,60 @@ |
||||
// SPDX-License-Identifier: MIT |
||||
|
||||
pragma solidity ^0.8.0; |
||||
|
||||
import "../governance/extensions/GovernorPreventLateQuorum.sol"; |
||||
import "../governance/extensions/GovernorSettings.sol"; |
||||
import "../governance/extensions/GovernorCountingSimple.sol"; |
||||
import "../governance/extensions/GovernorVotes.sol"; |
||||
|
||||
contract GovernorPreventLateQuorumMock is |
||||
GovernorSettings, |
||||
GovernorVotes, |
||||
GovernorCountingSimple, |
||||
GovernorPreventLateQuorum |
||||
{ |
||||
uint256 private _quorum; |
||||
|
||||
constructor( |
||||
string memory name_, |
||||
ERC20Votes token_, |
||||
uint256 votingDelay_, |
||||
uint256 votingPeriod_, |
||||
uint256 quorum_, |
||||
uint64 voteExtension_ |
||||
) |
||||
Governor(name_) |
||||
GovernorSettings(votingDelay_, votingPeriod_, 0) |
||||
GovernorVotes(token_) |
||||
GovernorPreventLateQuorum(voteExtension_) |
||||
{ |
||||
_quorum = quorum_; |
||||
} |
||||
|
||||
function quorum(uint256) public view virtual override returns (uint256) { |
||||
return _quorum; |
||||
} |
||||
|
||||
function proposalDeadline(uint256 proposalId) |
||||
public |
||||
view |
||||
virtual |
||||
override(Governor, GovernorPreventLateQuorum) |
||||
returns (uint256) |
||||
{ |
||||
return super.proposalDeadline(proposalId); |
||||
} |
||||
|
||||
function proposalThreshold() public view virtual override(Governor, GovernorSettings) returns (uint256) { |
||||
return super.proposalThreshold(); |
||||
} |
||||
|
||||
function _castVote( |
||||
uint256 proposalId, |
||||
address account, |
||||
uint8 support, |
||||
string memory reason |
||||
) internal virtual override(Governor, GovernorPreventLateQuorum) returns (uint256) { |
||||
return super._castVote(proposalId, account, support, reason); |
||||
} |
||||
} |
@ -0,0 +1,247 @@ |
||||
const { BN, expectEvent, expectRevert, time } = require('@openzeppelin/test-helpers'); |
||||
const Enums = require('../../helpers/enums'); |
||||
|
||||
const { |
||||
runGovernorWorkflow, |
||||
} = require('../GovernorWorkflow.behavior'); |
||||
|
||||
const Token = artifacts.require('ERC20VotesCompMock'); |
||||
const Governor = artifacts.require('GovernorPreventLateQuorumMock'); |
||||
const CallReceiver = artifacts.require('CallReceiverMock'); |
||||
|
||||
contract('GovernorPreventLateQuorum', function (accounts) { |
||||
const [ owner, proposer, voter1, voter2, voter3, voter4 ] = accounts; |
||||
|
||||
const name = 'OZ-Governor'; |
||||
// const version = '1';
|
||||
const tokenName = 'MockToken'; |
||||
const tokenSymbol = 'MTKN'; |
||||
const tokenSupply = web3.utils.toWei('100'); |
||||
const votingDelay = new BN(4); |
||||
const votingPeriod = new BN(16); |
||||
const lateQuorumVoteExtension = new BN(8); |
||||
const quorum = web3.utils.toWei('1'); |
||||
|
||||
beforeEach(async function () { |
||||
this.owner = owner; |
||||
this.token = await Token.new(tokenName, tokenSymbol); |
||||
this.mock = await Governor.new( |
||||
name, |
||||
this.token.address, |
||||
votingDelay, |
||||
votingPeriod, |
||||
quorum, |
||||
lateQuorumVoteExtension, |
||||
); |
||||
this.receiver = await CallReceiver.new(); |
||||
await this.token.mint(owner, tokenSupply); |
||||
await this.token.delegate(voter1, { from: voter1 }); |
||||
await this.token.delegate(voter2, { from: voter2 }); |
||||
await this.token.delegate(voter3, { from: voter3 }); |
||||
await this.token.delegate(voter4, { from: voter4 }); |
||||
}); |
||||
|
||||
it('deployment check', async function () { |
||||
expect(await this.mock.name()).to.be.equal(name); |
||||
expect(await this.mock.token()).to.be.equal(this.token.address); |
||||
expect(await this.mock.votingDelay()).to.be.bignumber.equal(votingDelay); |
||||
expect(await this.mock.votingPeriod()).to.be.bignumber.equal(votingPeriod); |
||||
expect(await this.mock.quorum(0)).to.be.bignumber.equal(quorum); |
||||
expect(await this.mock.lateQuorumVoteExtension()).to.be.bignumber.equal(lateQuorumVoteExtension); |
||||
}); |
||||
|
||||
describe('nominal is unaffected', function () { |
||||
beforeEach(async function () { |
||||
this.settings = { |
||||
proposal: [ |
||||
[ this.receiver.address ], |
||||
[ 0 ], |
||||
[ this.receiver.contract.methods.mockFunction().encodeABI() ], |
||||
'<proposal description>', |
||||
], |
||||
proposer, |
||||
tokenHolder: owner, |
||||
voters: [ |
||||
{ voter: voter1, weight: web3.utils.toWei('1'), support: Enums.VoteType.For, reason: 'This is nice' }, |
||||
{ voter: voter2, weight: web3.utils.toWei('7'), support: Enums.VoteType.For }, |
||||
{ voter: voter3, weight: web3.utils.toWei('5'), support: Enums.VoteType.Against }, |
||||
{ voter: voter4, weight: web3.utils.toWei('2'), support: Enums.VoteType.Abstain }, |
||||
], |
||||
}; |
||||
}); |
||||
|
||||
afterEach(async function () { |
||||
expect(await this.mock.hasVoted(this.id, owner)).to.be.equal(false); |
||||
expect(await this.mock.hasVoted(this.id, voter1)).to.be.equal(true); |
||||
expect(await this.mock.hasVoted(this.id, voter2)).to.be.equal(true); |
||||
|
||||
await this.mock.proposalVotes(this.id).then(result => { |
||||
for (const [key, value] of Object.entries(Enums.VoteType)) { |
||||
expect(result[`${key.toLowerCase()}Votes`]).to.be.bignumber.equal( |
||||
Object.values(this.settings.voters).filter(({ support }) => support === value).reduce( |
||||
(acc, { weight }) => acc.add(new BN(weight)), |
||||
new BN('0'), |
||||
), |
||||
); |
||||
} |
||||
}); |
||||
|
||||
const startBlock = new BN(this.receipts.propose.blockNumber).add(votingDelay); |
||||
const endBlock = new BN(this.receipts.propose.blockNumber).add(votingDelay).add(votingPeriod); |
||||
expect(await this.mock.proposalSnapshot(this.id)).to.be.bignumber.equal(startBlock); |
||||
expect(await this.mock.proposalDeadline(this.id)).to.be.bignumber.equal(endBlock); |
||||
|
||||
expectEvent( |
||||
this.receipts.propose, |
||||
'ProposalCreated', |
||||
{ |
||||
proposalId: this.id, |
||||
proposer, |
||||
targets: this.settings.proposal[0], |
||||
// values: this.settings.proposal[1].map(value => new BN(value)),
|
||||
signatures: this.settings.proposal[2].map(() => ''), |
||||
calldatas: this.settings.proposal[2], |
||||
startBlock, |
||||
endBlock, |
||||
description: this.settings.proposal[3], |
||||
}, |
||||
); |
||||
|
||||
this.receipts.castVote.filter(Boolean).forEach(vote => { |
||||
const { voter } = vote.logs.find(Boolean).args; |
||||
expectEvent( |
||||
vote, |
||||
'VoteCast', |
||||
this.settings.voters.find(({ address }) => address === voter), |
||||
); |
||||
expectEvent.notEmitted( |
||||
vote, |
||||
'ProposalExtended', |
||||
); |
||||
}); |
||||
expectEvent( |
||||
this.receipts.execute, |
||||
'ProposalExecuted', |
||||
{ proposalId: this.id }, |
||||
); |
||||
await expectEvent.inTransaction( |
||||
this.receipts.execute.transactionHash, |
||||
this.receiver, |
||||
'MockFunctionCalled', |
||||
); |
||||
}); |
||||
runGovernorWorkflow(); |
||||
}); |
||||
|
||||
describe('Delay is extended to prevent last minute take-over', function () { |
||||
beforeEach(async function () { |
||||
this.settings = { |
||||
proposal: [ |
||||
[ this.receiver.address ], |
||||
[ 0 ], |
||||
[ this.receiver.contract.methods.mockFunction().encodeABI() ], |
||||
'<proposal description>', |
||||
], |
||||
proposer, |
||||
tokenHolder: owner, |
||||
voters: [ |
||||
{ voter: voter1, weight: web3.utils.toWei('0.2'), support: Enums.VoteType.Against }, |
||||
{ voter: voter2, weight: web3.utils.toWei('1.0') }, // do not actually vote, only getting tokens
|
||||
{ voter: voter3, weight: web3.utils.toWei('0.9') }, // do not actually vote, only getting tokens
|
||||
], |
||||
steps: { |
||||
wait: { enable: false }, |
||||
execute: { enable: false }, |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
afterEach(async function () { |
||||
expect(await this.mock.state(this.id)).to.be.bignumber.equal(Enums.ProposalState.Active); |
||||
|
||||
const startBlock = new BN(this.receipts.propose.blockNumber).add(votingDelay); |
||||
const endBlock = new BN(this.receipts.propose.blockNumber).add(votingDelay).add(votingPeriod); |
||||
expect(await this.mock.proposalSnapshot(this.id)).to.be.bignumber.equal(startBlock); |
||||
expect(await this.mock.proposalDeadline(this.id)).to.be.bignumber.equal(endBlock); |
||||
|
||||
// wait until the vote is almost over
|
||||
await time.advanceBlockTo(endBlock.subn(1)); |
||||
expect(await this.mock.state(this.id)).to.be.bignumber.equal(Enums.ProposalState.Active); |
||||
|
||||
// try to overtake the vote at the last minute
|
||||
const tx = await this.mock.castVote(this.id, Enums.VoteType.For, { from: voter2 }); |
||||
|
||||
// vote duration is extended
|
||||
const extendedBlock = new BN(tx.receipt.blockNumber).add(lateQuorumVoteExtension); |
||||
expect(await this.mock.proposalDeadline(this.id)).to.be.bignumber.equal(extendedBlock); |
||||
|
||||
expectEvent( |
||||
tx, |
||||
'ProposalExtended', |
||||
{ proposalId: this.id, extendedDeadline: extendedBlock }, |
||||
); |
||||
|
||||
// vote is still active after expected end
|
||||
await time.advanceBlockTo(endBlock.addn(1)); |
||||
expect(await this.mock.state(this.id)).to.be.bignumber.equal(Enums.ProposalState.Active); |
||||
|
||||
// Still possible to vote
|
||||
await this.mock.castVote(this.id, Enums.VoteType.Against, { from: voter3 }); |
||||
|
||||
// proposal fails
|
||||
await time.advanceBlockTo(extendedBlock.addn(1)); |
||||
expect(await this.mock.state(this.id)).to.be.bignumber.equal(Enums.ProposalState.Defeated); |
||||
}); |
||||
runGovernorWorkflow(); |
||||
}); |
||||
|
||||
describe('setLateQuorumVoteExtension', function () { |
||||
beforeEach(async function () { |
||||
this.newVoteExtension = new BN(0); // disable voting delay extension
|
||||
}); |
||||
|
||||
it('protected', async function () { |
||||
await expectRevert( |
||||
this.mock.setLateQuorumVoteExtension(this.newVoteExtension), |
||||
'Governor: onlyGovernance', |
||||
); |
||||
}); |
||||
|
||||
describe('using workflow', function () { |
||||
beforeEach(async function () { |
||||
this.settings = { |
||||
proposal: [ |
||||
[ this.mock.address ], |
||||
[ web3.utils.toWei('0') ], |
||||
[ this.mock.contract.methods.setLateQuorumVoteExtension(this.newVoteExtension).encodeABI() ], |
||||
'<proposal description>', |
||||
], |
||||
proposer, |
||||
tokenHolder: owner, |
||||
voters: [ |
||||
{ voter: voter1, weight: web3.utils.toWei('1.0'), support: Enums.VoteType.For }, |
||||
], |
||||
}; |
||||
}); |
||||
afterEach(async function () { |
||||
expectEvent( |
||||
this.receipts.propose, |
||||
'ProposalCreated', |
||||
{ proposalId: this.id }, |
||||
); |
||||
expectEvent( |
||||
this.receipts.execute, |
||||
'ProposalExecuted', |
||||
{ proposalId: this.id }, |
||||
); |
||||
expectEvent( |
||||
this.receipts.execute, |
||||
'LateQuorumVoteExtensionSet', |
||||
{ oldVoteExtension: lateQuorumVoteExtension, newVoteExtension: this.newVoteExtension }, |
||||
); |
||||
expect(await this.mock.lateQuorumVoteExtension()).to.be.bignumber.equal(this.newVoteExtension); |
||||
}); |
||||
runGovernorWorkflow(); |
||||
}); |
||||
}); |
||||
}); |
Loading…
Reference in new issue