Add a governor extension that implements a proposal guardian (#5303)

Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com>
Co-authored-by: Ernesto García <ernestognw@gmail.com>
pull/5430/merge
Hadrien Croubois 4 days ago committed by GitHub
parent 495a287e9a
commit 8c1b0ca82d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/pretty-lobsters-tan.md
  2. 16
      contracts/governance/Governor.sol
  3. 10
      contracts/governance/IGovernor.sol
  4. 4
      contracts/governance/README.adoc
  5. 57
      contracts/governance/extensions/GovernorProposalGuardian.sol
  6. 27
      contracts/mocks/governance/GovernorProposalGuardianMock.sol
  7. 36
      test/governance/Governor.test.js
  8. 132
      test/governance/extensions/GovernorProposalGuardian.test.js

@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---
`GovernorProposalGuardian`: Add a governance extension that defines a proposal guardian who can cancel proposals at any stage in their lifecycle.

@ -484,11 +484,8 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
// changes it. The `getProposalId` duplication has a cost that is limited, and that we accept.
uint256 proposalId = getProposalId(targets, values, calldatas, descriptionHash);
// public cancel restrictions (on top of existing _cancel restrictions).
_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending));
if (_msgSender() != proposalProposer(proposalId)) {
revert GovernorOnlyProposer(_msgSender());
}
address caller = _msgSender();
if (!_validateCancel(proposalId, caller)) revert GovernorUnableToCancel(proposalId, caller);
return _cancel(targets, values, calldatas, descriptionHash);
}
@ -805,6 +802,15 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
}
}
/**
* @dev Check if the `caller` can cancel the proposal with the given `proposalId`.
*
* The default implementation allows the proposal proposer to cancel the proposal during the pending state.
*/
function _validateCancel(uint256 proposalId, address caller) internal view virtual returns (bool) {
return (state(proposalId) == ProposalState.Pending) && caller == proposalProposer(proposalId);
}
/**
* @inheritdoc IERC6372
*/

@ -39,11 +39,6 @@ interface IGovernor is IERC165, IERC6372 {
*/
error GovernorDisabledDeposit();
/**
* @dev The `account` is not a proposer.
*/
error GovernorOnlyProposer(address account);
/**
* @dev The `account` is not the governance executor.
*/
@ -112,6 +107,11 @@ interface IGovernor is IERC165, IERC6372 {
*/
error GovernorInvalidSignature(address voter);
/**
* @dev The given `account` is unable to cancel the proposal with given `proposalId`.
*/
error GovernorUnableToCancel(uint256 proposalId, address account);
/**
* @dev Emitted when a proposal is created.
*/

@ -48,6 +48,8 @@ Other extensions can customize the behavior or interface in multiple ways.
* {GovernorPreventLateQuorum}: Ensures there is a minimum voting period after quorum is reached as a security protection against large voters.
* {GovernorProposalGuardian}: Adds a proposal guardian that can cancel proposals at any stage in their lifecycle--this permission is passed on to the proposers if the guardian is not set.
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 ERC-6372 clock) 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.
@ -88,6 +90,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you
{{GovernorStorage}}
{{GovernorProposalGuardian}}
== Utils
{{Votes}}

@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Governor} from "../Governor.sol";
/**
* @dev Extension of {Governor} which adds a proposal guardian that can cancel proposals at any stage in the proposal's lifecycle.
*
* NOTE: if the proposal guardian is not configured, then proposers take this role for their proposals.
*/
abstract contract GovernorProposalGuardian is Governor {
address private _proposalGuardian;
event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian);
/**
* @dev Getter that returns the address of the proposal guardian.
*/
function proposalGuardian() public view virtual returns (address) {
return _proposalGuardian;
}
/**
* @dev Update the proposal guardian's address. This operation can only be performed through a governance proposal.
*
* Emits a {ProposalGuardianSet} event.
*/
function setProposalGuardian(address newProposalGuardian) public virtual onlyGovernance {
_setProposalGuardian(newProposalGuardian);
}
/**
* @dev Internal setter for the proposal guardian.
*
* Emits a {ProposalGuardianSet} event.
*/
function _setProposalGuardian(address newProposalGuardian) internal virtual {
emit ProposalGuardianSet(_proposalGuardian, newProposalGuardian);
_proposalGuardian = newProposalGuardian;
}
/**
* @dev Override {Governor-_validateCancel} to implement the extended cancellation logic.
*
* * The {proposalGuardian} can cancel any proposal at any point.
* * If no proposal guardian is set, the {IGovernor-proposalProposer} can cancel their proposals at any point.
* * In any case, permissions defined in {Governor-_validateCancel} (or another override) remains valid.
*/
function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) {
address guardian = proposalGuardian();
return
guardian == caller ||
(guardian == address(0) && caller == proposalProposer(proposalId)) ||
super._validateCancel(proposalId, caller);
}
}

@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Governor} from "../../governance/Governor.sol";
import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol";
import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol";
import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol";
import {GovernorProposalGuardian} from "../../governance/extensions/GovernorProposalGuardian.sol";
abstract contract GovernorProposalGuardianMock is
GovernorSettings,
GovernorVotesQuorumFraction,
GovernorCountingSimple,
GovernorProposalGuardian
{
function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {
return super.proposalThreshold();
}
function _validateCancel(
uint256 proposalId,
address caller
) internal view override(Governor, GovernorProposalGuardian) returns (bool) {
return super._validateCancel(proposalId, caller);
}
}

@ -624,8 +624,8 @@ describe('Governor', function () {
await this.helper.connect(this.proposer).propose();
await expect(this.helper.connect(this.owner).cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyProposer')
.withArgs(this.owner);
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.owner);
});
it('after vote started', async function () {
@ -633,12 +633,8 @@ describe('Governor', function () {
await this.helper.waitForSnapshot(1n); // snapshot + 1 block
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.owner);
});
it('after vote', async function () {
@ -647,12 +643,8 @@ describe('Governor', function () {
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Active,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.voter1);
});
it('after deadline', async function () {
@ -662,12 +654,8 @@ describe('Governor', function () {
await this.helper.waitForDeadline();
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Succeeded,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.voter1);
});
it('after execution', async function () {
@ -678,12 +666,8 @@ describe('Governor', function () {
await this.helper.execute();
await expect(this.helper.cancel('external'))
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
.withArgs(
this.proposal.id,
ProposalState.Executed,
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
);
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.voter1);
});
});
});

@ -0,0 +1,132 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { impersonate } = require('../../helpers/account');
const { GovernorHelper } = require('../../helpers/governance');
const { ProposalState } = require('../../helpers/enums');
const TOKENS = [
{ Token: '$ERC20Votes', mode: 'blocknumber' },
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
];
const name = 'Proposal Guardian Governor';
const version = '1';
const tokenName = 'MockToken';
const tokenSymbol = 'MTKN';
const tokenSupply = ethers.parseEther('100');
const votingDelay = 4n;
const votingPeriod = 16n;
const value = ethers.parseEther('1');
describe('GovernorProposalGuardian', function () {
for (const { Token, mode } of TOKENS) {
const fixture = async () => {
const [owner, proposer, guardian, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
const receiver = await ethers.deployContract('CallReceiverMock');
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, tokenName, version]);
const mock = await ethers.deployContract('$GovernorProposalGuardianMock', [
name, // name
votingDelay, // initialVotingDelay
votingPeriod, // initialVotingPeriod
0n, // initialProposalThreshold
token, // tokenAddress
10n, // quorumNumeratorValue
]);
await impersonate(mock.target);
await owner.sendTransaction({ to: mock, value });
await token.$_mint(owner, tokenSupply);
const helper = new GovernorHelper(mock, mode);
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
return { owner, proposer, guardian, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper };
};
describe(`using ${Token}`, function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
// default proposal
this.proposal = this.helper.setProposal(
[
{
target: this.receiver.target,
value,
data: this.receiver.interface.encodeFunctionData('mockFunction'),
},
],
'<proposal description>',
);
});
it('deployment check', async function () {
await expect(this.mock.name()).to.eventually.equal(name);
await expect(this.mock.token()).to.eventually.equal(this.token);
await expect(this.mock.votingDelay()).to.eventually.equal(votingDelay);
await expect(this.mock.votingPeriod()).to.eventually.equal(votingPeriod);
});
describe('set proposal guardian', function () {
it('from governance', async function () {
const governorSigner = await ethers.getSigner(this.mock.target);
await expect(this.mock.connect(governorSigner).setProposalGuardian(this.guardian))
.to.emit(this.mock, 'ProposalGuardianSet')
.withArgs(ethers.ZeroAddress, this.guardian);
await expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian);
});
it('from non-governance', async function () {
await expect(this.mock.connect(this.other).setProposalGuardian(this.guardian))
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
.withArgs(this.other);
});
});
it('cancel proposal during pending state from proposer when proposal guardian is non-zero', async function () {
await this.mock.$_setProposalGuardian(this.guardian);
await this.helper.connect(this.proposer).propose();
await expect(this.helper.connect(this.proposer).cancel())
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
});
describe('cancel proposal during active state', function () {
beforeEach(async function () {
await this.helper.connect(this.proposer).propose();
await this.helper.waitForSnapshot(1n);
await expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active);
});
it('from proposal guardian', async function () {
await this.mock.$_setProposalGuardian(this.guardian);
await expect(this.helper.connect(this.guardian).cancel())
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
});
it('from proposer when proposal guardian is non-zero', async function () {
await this.mock.$_setProposalGuardian(this.guardian);
await expect(this.helper.connect(this.proposer).cancel())
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
.withArgs(this.proposal.id, this.proposer);
});
it('from proposer when proposal guardian is zero', async function () {
await this.mock.$_setProposalGuardian(ethers.ZeroAddress);
await expect(this.helper.connect(this.proposer).cancel())
.to.emit(this.mock, 'ProposalCanceled')
.withArgs(this.proposal.id);
});
});
});
}
});
Loading…
Cancel
Save