Add a governor module to protect against late quorum (#2973)
Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
This commit is contained in:
@ -1,5 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
* `GovernorExtendedVoting`: add new module to ensure a minimum voting duration is available after the quorum is reached. ([#2973](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2973))
|
||||
|
||||
## Unreleased
|
||||
|
||||
* `GovernorTimelockControl`: improve the `state()` function to have it reflect cases where a proposal has been canceled directly on the timelock. ([#2977](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2977))
|
||||
|
||||
@ -110,24 +110,37 @@ abstract contract Governor is Context, ERC165, EIP712, IGovernor {
|
||||
* @dev See {IGovernor-state}.
|
||||
*/
|
||||
function state(uint256 proposalId) public view virtual override returns (ProposalState) {
|
||||
ProposalCore memory proposal = _proposals[proposalId];
|
||||
ProposalCore storage proposal = _proposals[proposalId];
|
||||
|
||||
if (proposal.executed) {
|
||||
return ProposalState.Executed;
|
||||
} else if (proposal.canceled) {
|
||||
}
|
||||
|
||||
if (proposal.canceled) {
|
||||
return ProposalState.Canceled;
|
||||
} else if (proposal.voteStart.getDeadline() >= block.number) {
|
||||
return ProposalState.Pending;
|
||||
} else if (proposal.voteEnd.getDeadline() >= block.number) {
|
||||
return ProposalState.Active;
|
||||
} else if (proposal.voteEnd.isExpired()) {
|
||||
return
|
||||
_quorumReached(proposalId) && _voteSucceeded(proposalId)
|
||||
? ProposalState.Succeeded
|
||||
: ProposalState.Defeated;
|
||||
} else {
|
||||
}
|
||||
|
||||
uint256 snapshot = proposalSnapshot(proposalId);
|
||||
|
||||
if (snapshot == 0) {
|
||||
revert("Governor: unknown proposal id");
|
||||
}
|
||||
|
||||
if (snapshot >= block.number) {
|
||||
return ProposalState.Pending;
|
||||
}
|
||||
|
||||
uint256 deadline = proposalDeadline(proposalId);
|
||||
|
||||
if (deadline >= block.number) {
|
||||
return ProposalState.Active;
|
||||
}
|
||||
|
||||
if (_quorumReached(proposalId) && _voteSucceeded(proposalId)) {
|
||||
return ProposalState.Succeeded;
|
||||
} else {
|
||||
return ProposalState.Defeated;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -42,6 +42,8 @@ Other extensions can customize the behavior or interface in multiple ways.
|
||||
|
||||
* {GovernorSettings}: Manages some of the settings (voting delay, voting period duration, and proposal threshold) in a way that can be updated through a governance proposal, without requiering an upgrade.
|
||||
|
||||
* {GovernorPreventLateQuorum}: Ensures there is a minimum voting period after quorum is reached as a security protection against large voters.
|
||||
|
||||
In addition to modules and extensions, the core contract requires a few virtual functions to be implemented to your particular specifications:
|
||||
|
||||
* <<Governor-votingDelay-,`votingDelay()`>>: Delay (in number of blocks) since the proposal is submitted until voting power is fixed and voting starts. This can be used to enforce a delay after a proposal is published for users to buy tokens, or delegate their votes.
|
||||
@ -74,6 +76,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you
|
||||
|
||||
{{GovernorSettings}}
|
||||
|
||||
{{GovernorPreventLateQuorum}}
|
||||
|
||||
{{GovernorCompatibilityBravo}}
|
||||
|
||||
=== Deprecated
|
||||
|
||||
106
contracts/governance/extensions/GovernorPreventLateQuorum.sol
Normal file
106
contracts/governance/extensions/GovernorPreventLateQuorum.sol
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
60
contracts/mocks/GovernorPreventLateQuorumMock.sol
Normal file
60
contracts/mocks/GovernorPreventLateQuorumMock.sol
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -65,7 +65,7 @@ function runGovernorWorkflow () {
|
||||
// vote
|
||||
if (tryGet(this.settings, 'voters')) {
|
||||
this.receipts.castVote = [];
|
||||
for (const voter of this.settings.voters) {
|
||||
for (const voter of this.settings.voters.filter(({ support }) => !!support)) {
|
||||
if (!voter.signature) {
|
||||
this.receipts.castVote.push(
|
||||
await getReceiptOrRevert(
|
||||
|
||||
247
test/governance/extensions/GovernorPreventLateQuorum.test.js
Normal file
247
test/governance/extensions/GovernorPreventLateQuorum.test.js
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user