Skip to content

Commit 8c1b0ca

Browse files
Amxxarr00ernestognw
authored
Add a governor extension that implements a proposal guardian (#5303)
Co-authored-by: Arr00 <[email protected]> Co-authored-by: Ernesto García <[email protected]>
1 parent 495a287 commit 8c1b0ca

File tree

8 files changed

+251
-36
lines changed

8 files changed

+251
-36
lines changed

.changeset/pretty-lobsters-tan.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': minor
3+
---
4+
5+
`GovernorProposalGuardian`: Add a governance extension that defines a proposal guardian who can cancel proposals at any stage in their lifecycle.

contracts/governance/Governor.sol

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -484,11 +484,8 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
484484
// changes it. The `getProposalId` duplication has a cost that is limited, and that we accept.
485485
uint256 proposalId = getProposalId(targets, values, calldatas, descriptionHash);
486486

487-
// public cancel restrictions (on top of existing _cancel restrictions).
488-
_validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending));
489-
if (_msgSender() != proposalProposer(proposalId)) {
490-
revert GovernorOnlyProposer(_msgSender());
491-
}
487+
address caller = _msgSender();
488+
if (!_validateCancel(proposalId, caller)) revert GovernorUnableToCancel(proposalId, caller);
492489

493490
return _cancel(targets, values, calldatas, descriptionHash);
494491
}
@@ -805,6 +802,15 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
805802
}
806803
}
807804

805+
/**
806+
* @dev Check if the `caller` can cancel the proposal with the given `proposalId`.
807+
*
808+
* The default implementation allows the proposal proposer to cancel the proposal during the pending state.
809+
*/
810+
function _validateCancel(uint256 proposalId, address caller) internal view virtual returns (bool) {
811+
return (state(proposalId) == ProposalState.Pending) && caller == proposalProposer(proposalId);
812+
}
813+
808814
/**
809815
* @inheritdoc IERC6372
810816
*/

contracts/governance/IGovernor.sol

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,6 @@ interface IGovernor is IERC165, IERC6372 {
3939
*/
4040
error GovernorDisabledDeposit();
4141

42-
/**
43-
* @dev The `account` is not a proposer.
44-
*/
45-
error GovernorOnlyProposer(address account);
46-
4742
/**
4843
* @dev The `account` is not the governance executor.
4944
*/
@@ -112,6 +107,11 @@ interface IGovernor is IERC165, IERC6372 {
112107
*/
113108
error GovernorInvalidSignature(address voter);
114109

110+
/**
111+
* @dev The given `account` is unable to cancel the proposal with given `proposalId`.
112+
*/
113+
error GovernorUnableToCancel(uint256 proposalId, address account);
114+
115115
/**
116116
* @dev Emitted when a proposal is created.
117117
*/

contracts/governance/README.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Other extensions can customize the behavior or interface in multiple ways.
4848

4949
* {GovernorPreventLateQuorum}: Ensures there is a minimum voting period after quorum is reached as a security protection against large voters.
5050

51+
* {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.
52+
5153
In addition to modules and extensions, the core contract requires a few virtual functions to be implemented to your particular specifications:
5254

5355
* <<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
8890

8991
{{GovernorStorage}}
9092

93+
{{GovernorProposalGuardian}}
94+
9195
== Utils
9296

9397
{{Votes}}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.20;
3+
4+
import {Governor} from "../Governor.sol";
5+
6+
/**
7+
* @dev Extension of {Governor} which adds a proposal guardian that can cancel proposals at any stage in the proposal's lifecycle.
8+
*
9+
* NOTE: if the proposal guardian is not configured, then proposers take this role for their proposals.
10+
*/
11+
abstract contract GovernorProposalGuardian is Governor {
12+
address private _proposalGuardian;
13+
14+
event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian);
15+
16+
/**
17+
* @dev Getter that returns the address of the proposal guardian.
18+
*/
19+
function proposalGuardian() public view virtual returns (address) {
20+
return _proposalGuardian;
21+
}
22+
23+
/**
24+
* @dev Update the proposal guardian's address. This operation can only be performed through a governance proposal.
25+
*
26+
* Emits a {ProposalGuardianSet} event.
27+
*/
28+
function setProposalGuardian(address newProposalGuardian) public virtual onlyGovernance {
29+
_setProposalGuardian(newProposalGuardian);
30+
}
31+
32+
/**
33+
* @dev Internal setter for the proposal guardian.
34+
*
35+
* Emits a {ProposalGuardianSet} event.
36+
*/
37+
function _setProposalGuardian(address newProposalGuardian) internal virtual {
38+
emit ProposalGuardianSet(_proposalGuardian, newProposalGuardian);
39+
_proposalGuardian = newProposalGuardian;
40+
}
41+
42+
/**
43+
* @dev Override {Governor-_validateCancel} to implement the extended cancellation logic.
44+
*
45+
* * The {proposalGuardian} can cancel any proposal at any point.
46+
* * If no proposal guardian is set, the {IGovernor-proposalProposer} can cancel their proposals at any point.
47+
* * In any case, permissions defined in {Governor-_validateCancel} (or another override) remains valid.
48+
*/
49+
function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) {
50+
address guardian = proposalGuardian();
51+
52+
return
53+
guardian == caller ||
54+
(guardian == address(0) && caller == proposalProposer(proposalId)) ||
55+
super._validateCancel(proposalId, caller);
56+
}
57+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.20;
4+
5+
import {Governor} from "../../governance/Governor.sol";
6+
import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol";
7+
import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol";
8+
import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol";
9+
import {GovernorProposalGuardian} from "../../governance/extensions/GovernorProposalGuardian.sol";
10+
11+
abstract contract GovernorProposalGuardianMock is
12+
GovernorSettings,
13+
GovernorVotesQuorumFraction,
14+
GovernorCountingSimple,
15+
GovernorProposalGuardian
16+
{
17+
function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {
18+
return super.proposalThreshold();
19+
}
20+
21+
function _validateCancel(
22+
uint256 proposalId,
23+
address caller
24+
) internal view override(Governor, GovernorProposalGuardian) returns (bool) {
25+
return super._validateCancel(proposalId, caller);
26+
}
27+
}

test/governance/Governor.test.js

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -624,21 +624,17 @@ describe('Governor', function () {
624624
await this.helper.connect(this.proposer).propose();
625625

626626
await expect(this.helper.connect(this.owner).cancel('external'))
627-
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyProposer')
628-
.withArgs(this.owner);
627+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
628+
.withArgs(this.proposal.id, this.owner);
629629
});
630630

631631
it('after vote started', async function () {
632632
await this.helper.propose();
633633
await this.helper.waitForSnapshot(1n); // snapshot + 1 block
634634

635635
await expect(this.helper.cancel('external'))
636-
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
637-
.withArgs(
638-
this.proposal.id,
639-
ProposalState.Active,
640-
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
641-
);
636+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
637+
.withArgs(this.proposal.id, this.owner);
642638
});
643639

644640
it('after vote', async function () {
@@ -647,12 +643,8 @@ describe('Governor', function () {
647643
await this.helper.connect(this.voter1).vote({ support: VoteType.For });
648644

649645
await expect(this.helper.cancel('external'))
650-
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
651-
.withArgs(
652-
this.proposal.id,
653-
ProposalState.Active,
654-
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
655-
);
646+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
647+
.withArgs(this.proposal.id, this.voter1);
656648
});
657649

658650
it('after deadline', async function () {
@@ -662,12 +654,8 @@ describe('Governor', function () {
662654
await this.helper.waitForDeadline();
663655

664656
await expect(this.helper.cancel('external'))
665-
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
666-
.withArgs(
667-
this.proposal.id,
668-
ProposalState.Succeeded,
669-
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
670-
);
657+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
658+
.withArgs(this.proposal.id, this.voter1);
671659
});
672660

673661
it('after execution', async function () {
@@ -678,12 +666,8 @@ describe('Governor', function () {
678666
await this.helper.execute();
679667

680668
await expect(this.helper.cancel('external'))
681-
.to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState')
682-
.withArgs(
683-
this.proposal.id,
684-
ProposalState.Executed,
685-
GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]),
686-
);
669+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
670+
.withArgs(this.proposal.id, this.voter1);
687671
});
688672
});
689673
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const { ethers } = require('hardhat');
2+
const { expect } = require('chai');
3+
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
4+
5+
const { impersonate } = require('../../helpers/account');
6+
const { GovernorHelper } = require('../../helpers/governance');
7+
const { ProposalState } = require('../../helpers/enums');
8+
9+
const TOKENS = [
10+
{ Token: '$ERC20Votes', mode: 'blocknumber' },
11+
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' },
12+
];
13+
const name = 'Proposal Guardian Governor';
14+
const version = '1';
15+
const tokenName = 'MockToken';
16+
const tokenSymbol = 'MTKN';
17+
const tokenSupply = ethers.parseEther('100');
18+
const votingDelay = 4n;
19+
const votingPeriod = 16n;
20+
const value = ethers.parseEther('1');
21+
22+
describe('GovernorProposalGuardian', function () {
23+
for (const { Token, mode } of TOKENS) {
24+
const fixture = async () => {
25+
const [owner, proposer, guardian, voter1, voter2, voter3, voter4, other] = await ethers.getSigners();
26+
const receiver = await ethers.deployContract('CallReceiverMock');
27+
28+
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, tokenName, version]);
29+
const mock = await ethers.deployContract('$GovernorProposalGuardianMock', [
30+
name, // name
31+
votingDelay, // initialVotingDelay
32+
votingPeriod, // initialVotingPeriod
33+
0n, // initialProposalThreshold
34+
token, // tokenAddress
35+
10n, // quorumNumeratorValue
36+
]);
37+
38+
await impersonate(mock.target);
39+
await owner.sendTransaction({ to: mock, value });
40+
await token.$_mint(owner, tokenSupply);
41+
42+
const helper = new GovernorHelper(mock, mode);
43+
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') });
44+
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') });
45+
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') });
46+
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') });
47+
48+
return { owner, proposer, guardian, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper };
49+
};
50+
51+
describe(`using ${Token}`, function () {
52+
beforeEach(async function () {
53+
Object.assign(this, await loadFixture(fixture));
54+
55+
// default proposal
56+
this.proposal = this.helper.setProposal(
57+
[
58+
{
59+
target: this.receiver.target,
60+
value,
61+
data: this.receiver.interface.encodeFunctionData('mockFunction'),
62+
},
63+
],
64+
'<proposal description>',
65+
);
66+
});
67+
68+
it('deployment check', async function () {
69+
await expect(this.mock.name()).to.eventually.equal(name);
70+
await expect(this.mock.token()).to.eventually.equal(this.token);
71+
await expect(this.mock.votingDelay()).to.eventually.equal(votingDelay);
72+
await expect(this.mock.votingPeriod()).to.eventually.equal(votingPeriod);
73+
});
74+
75+
describe('set proposal guardian', function () {
76+
it('from governance', async function () {
77+
const governorSigner = await ethers.getSigner(this.mock.target);
78+
await expect(this.mock.connect(governorSigner).setProposalGuardian(this.guardian))
79+
.to.emit(this.mock, 'ProposalGuardianSet')
80+
.withArgs(ethers.ZeroAddress, this.guardian);
81+
await expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian);
82+
});
83+
84+
it('from non-governance', async function () {
85+
await expect(this.mock.connect(this.other).setProposalGuardian(this.guardian))
86+
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor')
87+
.withArgs(this.other);
88+
});
89+
});
90+
91+
it('cancel proposal during pending state from proposer when proposal guardian is non-zero', async function () {
92+
await this.mock.$_setProposalGuardian(this.guardian);
93+
await this.helper.connect(this.proposer).propose();
94+
await expect(this.helper.connect(this.proposer).cancel())
95+
.to.emit(this.mock, 'ProposalCanceled')
96+
.withArgs(this.proposal.id);
97+
});
98+
99+
describe('cancel proposal during active state', function () {
100+
beforeEach(async function () {
101+
await this.helper.connect(this.proposer).propose();
102+
await this.helper.waitForSnapshot(1n);
103+
await expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active);
104+
});
105+
106+
it('from proposal guardian', async function () {
107+
await this.mock.$_setProposalGuardian(this.guardian);
108+
109+
await expect(this.helper.connect(this.guardian).cancel())
110+
.to.emit(this.mock, 'ProposalCanceled')
111+
.withArgs(this.proposal.id);
112+
});
113+
114+
it('from proposer when proposal guardian is non-zero', async function () {
115+
await this.mock.$_setProposalGuardian(this.guardian);
116+
117+
await expect(this.helper.connect(this.proposer).cancel())
118+
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel')
119+
.withArgs(this.proposal.id, this.proposer);
120+
});
121+
122+
it('from proposer when proposal guardian is zero', async function () {
123+
await this.mock.$_setProposalGuardian(ethers.ZeroAddress);
124+
125+
await expect(this.helper.connect(this.proposer).cancel())
126+
.to.emit(this.mock, 'ProposalCanceled')
127+
.withArgs(this.proposal.id);
128+
});
129+
});
130+
});
131+
}
132+
});

0 commit comments

Comments
 (0)