From 6330d4c9a7e3ae19cc0b6f731449fe2cfa8b76c9 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Tue, 19 Nov 2024 11:09:10 +0100 Subject: [PATCH 01/30] Add a governor extension that implements a security council --- .../extensions/GovernorSecurityCouncil.sol | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 contracts/governance/extensions/GovernorSecurityCouncil.sol diff --git a/contracts/governance/extensions/GovernorSecurityCouncil.sol b/contracts/governance/extensions/GovernorSecurityCouncil.sol new file mode 100644 index 00000000000..c6fe639c416 --- /dev/null +++ b/contracts/governance/extensions/GovernorSecurityCouncil.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Governor} from "../Governor.sol"; + +/** + * @dev Extension of {Governor} for adds a security council that can cancel proposals at any stage of their lifecycle. + * + * Note: if the council is not configure, then proposers take over the + */ +abstract contract GovernorSecurityCouncil is Governor { + address private _council; + + event CouncilSet(address oldCouncil, address newCouncil); + + /** + * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. + * * council can cancel any proposal at any point in the lifecycle. + * * if no council is set, proposer can cancel their proposals at any point in the lifecycle. + * * if the council is set, proposer keep their ability to cancel during the pending stage (default behavior). + */ + function cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public virtual override returns (uint256) { + address caller = _msgSender(); + address authority = council(); + + if (authority == address(0)) { + // if there is no council + // ... only the proposer can cancel + // ... no restriction on when the proposer can cancel + uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash); + address proposer = proposalProposer(proposalId); + if (caller != proposer) revert GovernorOnlyProposer(caller); + return _cancel(targets, values, calldatas, descriptionHash); + } else if (authority == caller) { + // if there is a council, and the caller is the council + // ... just cancel + return _cancel(targets, values, calldatas, descriptionHash); + } else { + // if there is a council, and the caller is not the council + // ... apply default behavior + return super.cancel(targets, values, calldatas, descriptionHash); + } + } + + /** + * @dev Getter that returns the address of the council + */ + function council() public view virtual returns (address) { + return _council; + } + + /** + * @dev Update the council's address. This operation can only be performed through a governance proposal. + * + * Emits a {CouncilSet} event. + */ + function setCouncil(address newCouncil) public virtual onlyGovernance { + _setCouncil(newCouncil); + } + + /** + * @dev Internal setter for the council. + * + * Emits a {CouncilSet} event. + */ + function _setCouncil(address newCouncil) internal virtual { + emit CouncilSet(_council, newCouncil); + _council = newCouncil; + } +} From 578543d0750e8f1802fac09c786d11d552b60e9a Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:45:09 -0500 Subject: [PATCH 02/30] add tests --- .../GovernorSecurityCouncilMock.sol | 29 ++++ .../GovernorSecurityCouncil.test.js | 125 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 contracts/mocks/governance/GovernorSecurityCouncilMock.sol create mode 100644 test/governance/extensions/GovernorSecurityCouncil.test.js diff --git a/contracts/mocks/governance/GovernorSecurityCouncilMock.sol b/contracts/mocks/governance/GovernorSecurityCouncilMock.sol new file mode 100644 index 00000000000..9cb6d0151da --- /dev/null +++ b/contracts/mocks/governance/GovernorSecurityCouncilMock.sol @@ -0,0 +1,29 @@ +// 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 {GovernorSecurityCouncil} from "../../governance/extensions/GovernorSecurityCouncil.sol"; + +abstract contract GovernorSecurityCouncilMock is + GovernorSettings, + GovernorVotesQuorumFraction, + GovernorCountingSimple, + GovernorSecurityCouncil +{ + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + function cancel( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) public override(Governor, GovernorSecurityCouncil) returns (uint256) { + return super.cancel(targets, values, calldatas, descriptionHash); + } +} diff --git a/test/governance/extensions/GovernorSecurityCouncil.test.js b/test/governance/extensions/GovernorSecurityCouncil.test.js new file mode 100644 index 00000000000..39faa9b1786 --- /dev/null +++ b/test/governance/extensions/GovernorSecurityCouncil.test.js @@ -0,0 +1,125 @@ +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 = 'Security Council 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('GovernorSecurityCouncil', function () { + for (const { Token, mode } of TOKENS) { + const fixture = async () => { + const [owner, proposer, 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('$GovernorSecurityCouncilMock', [ + 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, 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'), + }, + ], + '', + ); + }); + + it('deployment check', async function () { + expect(await this.mock.name()).to.equal(name); + expect(await this.mock.token()).to.equal(this.token); + expect(await this.mock.votingDelay()).to.equal(votingDelay); + expect(await this.mock.votingPeriod()).to.equal(votingPeriod); + }); + + describe('set council', function () { + it('from governance', async function () { + const governorSigner = await ethers.getSigner(this.mock.target); + await expect(this.mock.connect(governorSigner).setCouncil(this.voter1.address)) + .to.emit(this.mock, 'CouncilSet') + .withArgs(ethers.ZeroAddress, this.voter1.address); + expect(this.mock.council()).to.eventually.equal(this.voter1.address); + }); + + it('from non-governance', async function () { + await expect(this.mock.setCouncil(this.voter1.address)) + .to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') + .withArgs(this.owner.address); + }); + }); + + describe('cancel proposal during active state', function () { + beforeEach(async function () { + await this.mock.$_setCouncil(this.voter1.address); + await this.helper.connect(this.proposer).propose(); + await this.helper.waitForSnapshot(1n); + expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); + }); + + it('from council', async function () { + await expect(this.helper.connect(this.voter1).cancel()) + .to.emit(this.mock, 'ProposalCanceled') + .withArgs(this.proposal.id); + }); + + it('from proposer when council is non-zero', async function () { + await expect(this.helper.connect(this.proposer).cancel()) + .to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState') + .withArgs( + this.proposal.id, + ProposalState.Active, + GovernorHelper.proposalStatesToBitMap([ProposalState.Pending]), + ); + }); + + it('from proposer when council is zero', async function () { + await this.mock.$_setCouncil(ethers.ZeroAddress); + await expect(this.helper.connect(this.proposer).cancel()) + .to.emit(this.mock, 'ProposalCanceled') + .withArgs(this.proposal.id); + }); + }); + }); + } +}); From fb6e8b7fcd10de974a8b5b071034cbcb75c2055a Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 13 Dec 2024 13:20:42 -0500 Subject: [PATCH 03/30] rename `GovernorSecurityCouncil` to `GovernorProposalGuardian` --- ...uncil.sol => GovernorProposalGuardian.sol} | 0 ...k.sol => GovernorProposalGuardianMock.sol} | 8 +++--- ...st.js => GovernorProposalGuardian.test.js} | 26 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) rename contracts/governance/extensions/{GovernorSecurityCouncil.sol => GovernorProposalGuardian.sol} (100%) rename contracts/mocks/governance/{GovernorSecurityCouncilMock.sol => GovernorProposalGuardianMock.sol} (77%) rename test/governance/extensions/{GovernorSecurityCouncil.test.js => GovernorProposalGuardian.test.js} (82%) diff --git a/contracts/governance/extensions/GovernorSecurityCouncil.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol similarity index 100% rename from contracts/governance/extensions/GovernorSecurityCouncil.sol rename to contracts/governance/extensions/GovernorProposalGuardian.sol diff --git a/contracts/mocks/governance/GovernorSecurityCouncilMock.sol b/contracts/mocks/governance/GovernorProposalGuardianMock.sol similarity index 77% rename from contracts/mocks/governance/GovernorSecurityCouncilMock.sol rename to contracts/mocks/governance/GovernorProposalGuardianMock.sol index 9cb6d0151da..8252a30a21a 100644 --- a/contracts/mocks/governance/GovernorSecurityCouncilMock.sol +++ b/contracts/mocks/governance/GovernorProposalGuardianMock.sol @@ -6,13 +6,13 @@ 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 {GovernorSecurityCouncil} from "../../governance/extensions/GovernorSecurityCouncil.sol"; +import {GovernorProposalGuardian} from "../../governance/extensions/GovernorProposalGuardian.sol"; -abstract contract GovernorSecurityCouncilMock is +abstract contract GovernorProposalGuardianMock is GovernorSettings, GovernorVotesQuorumFraction, GovernorCountingSimple, - GovernorSecurityCouncil + GovernorProposalGuardian { function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { return super.proposalThreshold(); @@ -23,7 +23,7 @@ abstract contract GovernorSecurityCouncilMock is uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash - ) public override(Governor, GovernorSecurityCouncil) returns (uint256) { + ) public override(Governor, GovernorProposalGuardian) returns (uint256) { return super.cancel(targets, values, calldatas, descriptionHash); } } diff --git a/test/governance/extensions/GovernorSecurityCouncil.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js similarity index 82% rename from test/governance/extensions/GovernorSecurityCouncil.test.js rename to test/governance/extensions/GovernorProposalGuardian.test.js index 39faa9b1786..00230970cf8 100644 --- a/test/governance/extensions/GovernorSecurityCouncil.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -11,7 +11,7 @@ const TOKENS = [ { Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, ]; -const name = 'Security Council Governor'; +const name = 'Proposal Guardian Governor'; const version = '1'; const tokenName = 'MockToken'; const tokenSymbol = 'MTKN'; @@ -20,14 +20,14 @@ const votingDelay = 4n; const votingPeriod = 16n; const value = ethers.parseEther('1'); -describe('GovernorSecurityCouncil', function () { +describe.only('GovernorProposalGuardian', function () { for (const { Token, mode } of TOKENS) { const fixture = async () => { const [owner, proposer, 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('$GovernorSecurityCouncilMock', [ + const mock = await ethers.deployContract('$GovernorProposalGuardianMock', [ name, // name votingDelay, // initialVotingDelay votingPeriod, // initialVotingPeriod @@ -73,17 +73,17 @@ describe('GovernorSecurityCouncil', function () { expect(await this.mock.votingPeriod()).to.equal(votingPeriod); }); - describe('set council', function () { + describe('set proposal guardian', function () { it('from governance', async function () { const governorSigner = await ethers.getSigner(this.mock.target); - await expect(this.mock.connect(governorSigner).setCouncil(this.voter1.address)) - .to.emit(this.mock, 'CouncilSet') + await expect(this.mock.connect(governorSigner).setProposalGuardian(this.voter1.address)) + .to.emit(this.mock, 'ProposalGuardianSet') .withArgs(ethers.ZeroAddress, this.voter1.address); - expect(this.mock.council()).to.eventually.equal(this.voter1.address); + expect(this.mock.proposalGuardian()).to.eventually.equal(this.voter1.address); }); it('from non-governance', async function () { - await expect(this.mock.setCouncil(this.voter1.address)) + await expect(this.mock.setProposalGuardian(this.voter1.address)) .to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') .withArgs(this.owner.address); }); @@ -91,19 +91,19 @@ describe('GovernorSecurityCouncil', function () { describe('cancel proposal during active state', function () { beforeEach(async function () { - await this.mock.$_setCouncil(this.voter1.address); + await this.mock.$_setProposalGuardian(this.voter1.address); await this.helper.connect(this.proposer).propose(); await this.helper.waitForSnapshot(1n); expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); }); - it('from council', async function () { + it('from proposal guardian', async function () { await expect(this.helper.connect(this.voter1).cancel()) .to.emit(this.mock, 'ProposalCanceled') .withArgs(this.proposal.id); }); - it('from proposer when council is non-zero', async function () { + it('from proposer when proposal guardian is non-zero', async function () { await expect(this.helper.connect(this.proposer).cancel()) .to.be.revertedWithCustomError(this.mock, 'GovernorUnexpectedProposalState') .withArgs( @@ -113,8 +113,8 @@ describe('GovernorSecurityCouncil', function () { ); }); - it('from proposer when council is zero', async function () { - await this.mock.$_setCouncil(ethers.ZeroAddress); + 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); From d613cc896af0019f0f030bbb6557afc28ebefdd5 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 13 Dec 2024 13:20:56 -0500 Subject: [PATCH 04/30] update `GovernorProposalGuardian` --- .../extensions/GovernorProposalGuardian.sol | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index c6fe639c416..d74f64c13cf 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -1,24 +1,23 @@ // SPDX-License-Identifier: MIT - pragma solidity ^0.8.20; import {Governor} from "../Governor.sol"; /** - * @dev Extension of {Governor} for adds a security council that can cancel proposals at any stage of their lifecycle. + * @dev Extension of {Governor} which adds a proposal guardian that can cancel proposals at any stage of their lifecycle. * - * Note: if the council is not configure, then proposers take over the + * Note: if the proposal guardian is not configured, then proposers take this role for their proposals. */ -abstract contract GovernorSecurityCouncil is Governor { - address private _council; +abstract contract GovernorProposalGuardian is Governor { + address private _proposalGuardian; - event CouncilSet(address oldCouncil, address newCouncil); + event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian); /** * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. - * * council can cancel any proposal at any point in the lifecycle. - * * if no council is set, proposer can cancel their proposals at any point in the lifecycle. - * * if the council is set, proposer keep their ability to cancel during the pending stage (default behavior). + * * proposal guardian can cancel any proposal at any point in the lifecycle. + * * if no proposal guardian is set, proposer can cancel their proposals at any point in the lifecycle. + * * if the proposal guardian is set, proposer keep their ability to cancel during the pending stage (default behavior). */ function cancel( address[] memory targets, @@ -27,10 +26,10 @@ abstract contract GovernorSecurityCouncil is Governor { bytes32 descriptionHash ) public virtual override returns (uint256) { address caller = _msgSender(); - address authority = council(); + address authority = proposalGuardian(); if (authority == address(0)) { - // if there is no council + // if there is no proposal guardian // ... only the proposer can cancel // ... no restriction on when the proposer can cancel uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash); @@ -38,39 +37,39 @@ abstract contract GovernorSecurityCouncil is Governor { if (caller != proposer) revert GovernorOnlyProposer(caller); return _cancel(targets, values, calldatas, descriptionHash); } else if (authority == caller) { - // if there is a council, and the caller is the council + // if there is a proposal guardian, and the caller is the proposal guardian // ... just cancel return _cancel(targets, values, calldatas, descriptionHash); } else { - // if there is a council, and the caller is not the council + // if there is a proposal guardian, and the caller is not the proposal guardian // ... apply default behavior return super.cancel(targets, values, calldatas, descriptionHash); } } /** - * @dev Getter that returns the address of the council + * @dev Getter that returns the address of the proposal guardian. */ - function council() public view virtual returns (address) { - return _council; + function proposalGuardian() public view virtual returns (address) { + return _proposalGuardian; } /** - * @dev Update the council's address. This operation can only be performed through a governance proposal. + * @dev Update the proposal guardian's address. This operation can only be performed through a governance proposal. * - * Emits a {CouncilSet} event. + * Emits a {ProposalGuardianSet} event. */ - function setCouncil(address newCouncil) public virtual onlyGovernance { - _setCouncil(newCouncil); + function setProposalGuardian(address newProposalGuardian) public virtual onlyGovernance { + _setProposalGuardian(newProposalGuardian); } /** - * @dev Internal setter for the council. + * @dev Internal setter for the proposal guardian. * - * Emits a {CouncilSet} event. + * Emits a {ProposalGuardianSet} event. */ - function _setCouncil(address newCouncil) internal virtual { - emit CouncilSet(_council, newCouncil); - _council = newCouncil; + function _setProposalGuardian(address newProposalGuardian) internal virtual { + emit ProposalGuardianSet(_proposalGuardian, newProposalGuardian); + _proposalGuardian = newProposalGuardian; } } From 25eeb0287bf337ea9df792eb8a24d99a88211a86 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:26:43 -0500 Subject: [PATCH 05/30] remove `.only` --- test/governance/extensions/GovernorProposalGuardian.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index 00230970cf8..ed4e6273ec5 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -20,7 +20,7 @@ const votingDelay = 4n; const votingPeriod = 16n; const value = ethers.parseEther('1'); -describe.only('GovernorProposalGuardian', function () { +describe('GovernorProposalGuardian', function () { for (const { Token, mode } of TOKENS) { const fixture = async () => { const [owner, proposer, voter1, voter2, voter3, voter4, other] = await ethers.getSigners(); From 5ba45967ab1040dfe60f8cfc83cc47270fa29c58 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 20 Dec 2024 15:38:07 -0500 Subject: [PATCH 06/30] use `getProposalId` instead of `hashProposal` --- contracts/governance/extensions/GovernorProposalGuardian.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index d74f64c13cf..e61ae760042 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -32,7 +32,7 @@ abstract contract GovernorProposalGuardian is Governor { // if there is no proposal guardian // ... only the proposer can cancel // ... no restriction on when the proposer can cancel - uint256 proposalId = hashProposal(targets, values, calldatas, descriptionHash); + uint256 proposalId = getProposalId(targets, values, calldatas, descriptionHash); address proposer = proposalProposer(proposalId); if (caller != proposer) revert GovernorOnlyProposer(caller); return _cancel(targets, values, calldatas, descriptionHash); From 954b03cc7246ba502a5b49daeb3463c3869f4b3d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:57:17 -0500 Subject: [PATCH 07/30] Update contracts/governance/extensions/GovernorProposalGuardian.sol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- contracts/governance/extensions/GovernorProposalGuardian.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index e61ae760042..7852e12101b 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -16,7 +16,7 @@ abstract contract GovernorProposalGuardian is Governor { /** * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. * * proposal guardian can cancel any proposal at any point in the lifecycle. - * * if no proposal guardian is set, proposer can cancel their proposals at any point in the lifecycle. + * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. * * if the proposal guardian is set, proposer keep their ability to cancel during the pending stage (default behavior). */ function cancel( From e56c8b2acee8f0bd7ac3f4d3dd77f96d505fef6a Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Mon, 23 Dec 2024 15:59:04 -0500 Subject: [PATCH 08/30] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernesto García --- .../extensions/GovernorProposalGuardian.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 7852e12101b..45c548e62ce 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -6,7 +6,7 @@ import {Governor} from "../Governor.sol"; /** * @dev Extension of {Governor} which adds a proposal guardian that can cancel proposals at any stage of their lifecycle. * - * Note: if the proposal guardian is not configured, then proposers take this role for their proposals. + * NOTE: if the proposal guardian is not configured, then proposers take this role for their proposals. */ abstract contract GovernorProposalGuardian is Governor { address private _proposalGuardian; @@ -15,9 +15,9 @@ abstract contract GovernorProposalGuardian is Governor { /** * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. - * * proposal guardian can cancel any proposal at any point in the lifecycle. + * * The {proposalGuardian} can cancel any proposal at any point in the lifecycle. * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. - * * if the proposal guardian is set, proposer keep their ability to cancel during the pending stage (default behavior). + * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-cancel} (calling `super`). */ function cancel( address[] memory targets, @@ -26,9 +26,9 @@ abstract contract GovernorProposalGuardian is Governor { bytes32 descriptionHash ) public virtual override returns (uint256) { address caller = _msgSender(); - address authority = proposalGuardian(); + address guardian = proposalGuardian(); - if (authority == address(0)) { + if (guardian == address(0)) { // if there is no proposal guardian // ... only the proposer can cancel // ... no restriction on when the proposer can cancel @@ -36,7 +36,7 @@ abstract contract GovernorProposalGuardian is Governor { address proposer = proposalProposer(proposalId); if (caller != proposer) revert GovernorOnlyProposer(caller); return _cancel(targets, values, calldatas, descriptionHash); - } else if (authority == caller) { + } else if (guardian == caller) { // if there is a proposal guardian, and the caller is the proposal guardian // ... just cancel return _cancel(targets, values, calldatas, descriptionHash); From db18886700a16b42f472d3e60c54962d57640491 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 21 Jan 2025 22:24:22 -0500 Subject: [PATCH 09/30] Add proposal guardian to docs --- contracts/governance/README.adoc | 4 ++++ contracts/governance/extensions/GovernorProposalGuardian.sol | 1 + 2 files changed, 5 insertions(+) diff --git a/contracts/governance/README.adoc b/contracts/governance/README.adoc index d390d1be82d..659f3585cf4 100644 --- a/contracts/governance/README.adoc +++ b/contracts/governance/README.adoc @@ -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: * <>: 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}} diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 45c548e62ce..aee252af4cd 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -15,6 +15,7 @@ abstract contract GovernorProposalGuardian is Governor { /** * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. + * * * The {proposalGuardian} can cancel any proposal at any point in the lifecycle. * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-cancel} (calling `super`). From f751ec6b5e6d81cb1ffa4e473acfd9c9c36dee89 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 9 Jan 2025 19:27:48 -0500 Subject: [PATCH 10/30] use `_validateCancel` instead of overriding `cancel` --- contracts/governance/Governor.sol | 14 +++++++++++--- .../extensions/GovernorProposalGuardian.sol | 19 +++++++------------ .../GovernorProposalGuardianMock.sol | 12 +++++------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/contracts/governance/Governor.sol b/contracts/governance/Governor.sol index f45aba096eb..ad404e3d1b2 100644 --- a/contracts/governance/Governor.sol +++ b/contracts/governance/Governor.sol @@ -470,6 +470,8 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72 } } + error UnableToCancel(); + /** * @dev See {IGovernor-cancel}. */ @@ -484,13 +486,19 @@ 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); + if (!_validateCancel(_msgSender(), proposalId)) revert UnableToCancel(); + + return _cancel(targets, values, calldatas, descriptionHash); + } + + function _validateCancel(address caller, uint256 proposalId) internal view virtual returns (bool) { // public cancel restrictions (on top of existing _cancel restrictions). _validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending)); - if (_msgSender() != proposalProposer(proposalId)) { - revert GovernorOnlyProposer(_msgSender()); + if (caller != proposalProposer(proposalId)) { + return false; } - return _cancel(targets, values, calldatas, descriptionHash); + return true; } /** diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index aee252af4cd..ed996cbdb3b 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -14,37 +14,32 @@ abstract contract GovernorProposalGuardian is Governor { event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian); /** - * @dev Override {IGovernor-cancel} that implements the extended cancellation logic. + * @dev Override `_validateCancel` that implements the extended cancellation logic. * * * The {proposalGuardian} can cancel any proposal at any point in the lifecycle. * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-cancel} (calling `super`). */ - function cancel( - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - bytes32 descriptionHash - ) public virtual override returns (uint256) { - address caller = _msgSender(); + function _validateCancel(address caller, uint256 proposalId) internal view virtual override returns (bool) { + // no additional validation is required + address guardian = proposalGuardian(); if (guardian == address(0)) { // if there is no proposal guardian // ... only the proposer can cancel // ... no restriction on when the proposer can cancel - uint256 proposalId = getProposalId(targets, values, calldatas, descriptionHash); address proposer = proposalProposer(proposalId); if (caller != proposer) revert GovernorOnlyProposer(caller); - return _cancel(targets, values, calldatas, descriptionHash); + return true; } else if (guardian == caller) { // if there is a proposal guardian, and the caller is the proposal guardian // ... just cancel - return _cancel(targets, values, calldatas, descriptionHash); + return true; } else { // if there is a proposal guardian, and the caller is not the proposal guardian // ... apply default behavior - return super.cancel(targets, values, calldatas, descriptionHash); + return super._validateCancel(caller, proposalId); } } diff --git a/contracts/mocks/governance/GovernorProposalGuardianMock.sol b/contracts/mocks/governance/GovernorProposalGuardianMock.sol index 8252a30a21a..54d33537302 100644 --- a/contracts/mocks/governance/GovernorProposalGuardianMock.sol +++ b/contracts/mocks/governance/GovernorProposalGuardianMock.sol @@ -18,12 +18,10 @@ abstract contract GovernorProposalGuardianMock is return super.proposalThreshold(); } - function cancel( - address[] memory targets, - uint256[] memory values, - bytes[] memory calldatas, - bytes32 descriptionHash - ) public override(Governor, GovernorProposalGuardian) returns (uint256) { - return super.cancel(targets, values, calldatas, descriptionHash); + function _validateCancel( + address caller, + uint256 proposalId + ) internal view override(Governor, GovernorProposalGuardian) returns (bool) { + return super._validateCancel(caller, proposalId); } } From d6f00324f8b6076b837a73248f4c9a6fce74954d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:12:31 -0500 Subject: [PATCH 11/30] fix test, move error to interface --- contracts/governance/Governor.sol | 8 +++----- contracts/governance/IGovernor.sol | 10 +++++----- .../governance/extensions/GovernorProposalGuardian.sol | 9 +++++---- .../mocks/governance/GovernorProposalGuardianMock.sol | 3 +-- test/governance/Governor.test.js | 4 ++-- .../extensions/GovernorProposalGuardian.test.js | 3 +-- 6 files changed, 17 insertions(+), 20 deletions(-) diff --git a/contracts/governance/Governor.sol b/contracts/governance/Governor.sol index ad404e3d1b2..cb8aa169b27 100644 --- a/contracts/governance/Governor.sol +++ b/contracts/governance/Governor.sol @@ -470,8 +470,6 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72 } } - error UnableToCancel(); - /** * @dev See {IGovernor-cancel}. */ @@ -486,15 +484,15 @@ 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); - if (!_validateCancel(_msgSender(), proposalId)) revert UnableToCancel(); + if (!_validateCancel(proposalId)) revert GovernorUnableToCancel(proposalId, _msgSender()); return _cancel(targets, values, calldatas, descriptionHash); } - function _validateCancel(address caller, uint256 proposalId) internal view virtual returns (bool) { + function _validateCancel(uint256 proposalId) internal view virtual returns (bool) { // public cancel restrictions (on top of existing _cancel restrictions). _validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending)); - if (caller != proposalProposer(proposalId)) { + if (_msgSender() != proposalProposer(proposalId)) { return false; } diff --git a/contracts/governance/IGovernor.sol b/contracts/governance/IGovernor.sol index 36ef099a7d5..702d2beb7f5 100644 --- a/contracts/governance/IGovernor.sol +++ b/contracts/governance/IGovernor.sol @@ -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. */ diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index ed996cbdb3b..0c31975fd14 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -18,19 +18,20 @@ abstract contract GovernorProposalGuardian is Governor { * * * The {proposalGuardian} can cancel any proposal at any point in the lifecycle. * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. - * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-cancel} (calling `super`). + * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-_validateCancel} (calling `super`). */ - function _validateCancel(address caller, uint256 proposalId) internal view virtual override returns (bool) { + function _validateCancel(uint256 proposalId) internal view virtual override returns (bool) { // no additional validation is required address guardian = proposalGuardian(); + address caller = _msgSender(); if (guardian == address(0)) { // if there is no proposal guardian // ... only the proposer can cancel // ... no restriction on when the proposer can cancel address proposer = proposalProposer(proposalId); - if (caller != proposer) revert GovernorOnlyProposer(caller); + if (caller != proposer) return false; return true; } else if (guardian == caller) { // if there is a proposal guardian, and the caller is the proposal guardian @@ -39,7 +40,7 @@ abstract contract GovernorProposalGuardian is Governor { } else { // if there is a proposal guardian, and the caller is not the proposal guardian // ... apply default behavior - return super._validateCancel(caller, proposalId); + return super._validateCancel(proposalId); } } diff --git a/contracts/mocks/governance/GovernorProposalGuardianMock.sol b/contracts/mocks/governance/GovernorProposalGuardianMock.sol index 54d33537302..c3758d8bc3c 100644 --- a/contracts/mocks/governance/GovernorProposalGuardianMock.sol +++ b/contracts/mocks/governance/GovernorProposalGuardianMock.sol @@ -19,9 +19,8 @@ abstract contract GovernorProposalGuardianMock is } function _validateCancel( - address caller, uint256 proposalId ) internal view override(Governor, GovernorProposalGuardian) returns (bool) { - return super._validateCancel(caller, proposalId); + return super._validateCancel(proposalId); } } diff --git a/test/governance/Governor.test.js b/test/governance/Governor.test.js index ea1c3a32457..ff17eba9e0f 100644 --- a/test/governance/Governor.test.js +++ b/test/governance/Governor.test.js @@ -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.address); }); it('after vote started', async function () { diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index ed4e6273ec5..05235ddf820 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -1,7 +1,7 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { impersonate } = require('../../helpers/account'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const { GovernorHelper } = require('../../helpers/governance'); const { ProposalState } = require('../../helpers/enums'); @@ -10,7 +10,6 @@ const TOKENS = [ { Token: '$ERC20Votes', mode: 'blocknumber' }, { Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, ]; - const name = 'Proposal Guardian Governor'; const version = '1'; const tokenName = 'MockToken'; From 5ffeab07240d02659e414fb245b360d57ceb99ce Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 21 Jan 2025 16:35:40 -0500 Subject: [PATCH 12/30] consistent cancellation errors --- contracts/governance/Governor.sol | 4 ++- .../extensions/GovernorProposalGuardian.sol | 14 ++++---- test/governance/Governor.test.js | 32 +++++-------------- 3 files changed, 17 insertions(+), 33 deletions(-) diff --git a/contracts/governance/Governor.sol b/contracts/governance/Governor.sol index cb8aa169b27..c31bbd9883c 100644 --- a/contracts/governance/Governor.sol +++ b/contracts/governance/Governor.sol @@ -491,7 +491,9 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72 function _validateCancel(uint256 proposalId) internal view virtual returns (bool) { // public cancel restrictions (on top of existing _cancel restrictions). - _validateStateBitmap(proposalId, _encodeStateBitmap(ProposalState.Pending)); + if (_encodeStateBitmap(state(proposalId)) & _encodeStateBitmap(ProposalState.Pending) == bytes32(0)) { + return false; + } if (_msgSender() != proposalProposer(proposalId)) { return false; } diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 0c31975fd14..03f1846e087 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -21,8 +21,6 @@ abstract contract GovernorProposalGuardian is Governor { * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-_validateCancel} (calling `super`). */ function _validateCancel(uint256 proposalId) internal view virtual override returns (bool) { - // no additional validation is required - address guardian = proposalGuardian(); address caller = _msgSender(); @@ -31,17 +29,17 @@ abstract contract GovernorProposalGuardian is Governor { // ... only the proposer can cancel // ... no restriction on when the proposer can cancel address proposer = proposalProposer(proposalId); - if (caller != proposer) return false; - return true; + if (caller == proposer) return true; } else if (guardian == caller) { // if there is a proposal guardian, and the caller is the proposal guardian // ... just cancel return true; - } else { - // if there is a proposal guardian, and the caller is not the proposal guardian - // ... apply default behavior - return super._validateCancel(proposalId); } + + // if there is no guardian and the caller isn't the proposer or + // there is a proposal guardian, and the caller is not the proposal guardian + // ... apply default behavior + return super._validateCancel(proposalId); } /** diff --git a/test/governance/Governor.test.js b/test/governance/Governor.test.js index ff17eba9e0f..6b92443c910 100644 --- a/test/governance/Governor.test.js +++ b/test/governance/Governor.test.js @@ -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.address); }); 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.address); }); 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.address); }); 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.address); }); }); }); From 9d378ace9f2792a10a016f4d7a1c1a39edcbe02d Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 21 Jan 2025 21:33:23 -0500 Subject: [PATCH 13/30] fix tests --- test/governance/Governor.test.js | 12 ++++++------ .../extensions/GovernorProposalGuardian.test.js | 8 ++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/test/governance/Governor.test.js b/test/governance/Governor.test.js index 6b92443c910..1645540ad54 100644 --- a/test/governance/Governor.test.js +++ b/test/governance/Governor.test.js @@ -40,7 +40,7 @@ async function deployToken(contractName) { } } -describe('Governor', function () { +describe.only('Governor', function () { for (const { Token, mode } of TOKENS) { const fixture = async () => { const [owner, proposer, voter1, voter2, voter3, voter4, userEOA] = await ethers.getSigners(); @@ -625,7 +625,7 @@ describe('Governor', function () { await expect(this.helper.connect(this.owner).cancel('external')) .to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') - .withArgs(this.proposal.id, this.owner.address); + .withArgs(this.proposal.id, this.owner); }); it('after vote started', async function () { @@ -634,7 +634,7 @@ describe('Governor', function () { await expect(this.helper.cancel('external')) .to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') - .withArgs(this.proposal.id, this.owner.address); + .withArgs(this.proposal.id, this.owner); }); it('after vote', async function () { @@ -644,7 +644,7 @@ describe('Governor', function () { await expect(this.helper.cancel('external')) .to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') - .withArgs(this.proposal.id, this.voter1.address); + .withArgs(this.proposal.id, this.voter1); }); it('after deadline', async function () { @@ -655,7 +655,7 @@ describe('Governor', function () { await expect(this.helper.cancel('external')) .to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') - .withArgs(this.proposal.id, this.voter1.address); + .withArgs(this.proposal.id, this.voter1); }); it('after execution', async function () { @@ -667,7 +667,7 @@ describe('Governor', function () { await expect(this.helper.cancel('external')) .to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') - .withArgs(this.proposal.id, this.voter1.address); + .withArgs(this.proposal.id, this.voter1); }); }); }); diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index 05235ddf820..b628b1c55d1 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -104,12 +104,8 @@ describe('GovernorProposalGuardian', function () { it('from proposer when proposal guardian is non-zero', async function () { await expect(this.helper.connect(this.proposer).cancel()) - .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.proposer); }); it('from proposer when proposal guardian is zero', async function () { From 13f18c5df0944414aea5035dd77b7745cda63a85 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Tue, 21 Jan 2025 21:42:33 -0500 Subject: [PATCH 14/30] remove `.only` --- test/governance/Governor.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/governance/Governor.test.js b/test/governance/Governor.test.js index 1645540ad54..0e4283c3bed 100644 --- a/test/governance/Governor.test.js +++ b/test/governance/Governor.test.js @@ -40,7 +40,7 @@ async function deployToken(contractName) { } } -describe.only('Governor', function () { +describe('Governor', function () { for (const { Token, mode } of TOKENS) { const fixture = async () => { const [owner, proposer, voter1, voter2, voter3, voter4, userEOA] = await ethers.getSigners(); From 409e4064b2f7c95f4de13c9d944ec1459c7db074 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:24:00 -0500 Subject: [PATCH 15/30] move caller into params --- contracts/governance/Governor.sol | 25 +++++++++---------- .../extensions/GovernorProposalGuardian.sol | 5 ++-- .../GovernorProposalGuardianMock.sol | 5 ++-- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/contracts/governance/Governor.sol b/contracts/governance/Governor.sol index c31bbd9883c..409a7c2a9a6 100644 --- a/contracts/governance/Governor.sol +++ b/contracts/governance/Governor.sol @@ -484,23 +484,12 @@ 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); - if (!_validateCancel(proposalId)) revert GovernorUnableToCancel(proposalId, _msgSender()); + address caller = _msgSender(); + if (!_validateCancel(proposalId, caller)) revert GovernorUnableToCancel(proposalId, caller); return _cancel(targets, values, calldatas, descriptionHash); } - function _validateCancel(uint256 proposalId) internal view virtual returns (bool) { - // public cancel restrictions (on top of existing _cancel restrictions). - if (_encodeStateBitmap(state(proposalId)) & _encodeStateBitmap(ProposalState.Pending) == bytes32(0)) { - return false; - } - if (_msgSender() != proposalProposer(proposalId)) { - return false; - } - - return true; - } - /** * @dev Internal cancel mechanism with minimal restrictions. A proposal can be cancelled in any state other than * Canceled, Expired, or Executed. Once cancelled a proposal can't be re-submitted. @@ -813,6 +802,16 @@ 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 (_encodeStateBitmap(state(proposalId)) & _encodeStateBitmap(ProposalState.Pending) != bytes32(0) && + caller == proposalProposer(proposalId)); + } + /** * @inheritdoc IERC6372 */ diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 03f1846e087..9902ff2490d 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -20,9 +20,8 @@ abstract contract GovernorProposalGuardian is Governor { * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-_validateCancel} (calling `super`). */ - function _validateCancel(uint256 proposalId) internal view virtual override returns (bool) { + function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) { address guardian = proposalGuardian(); - address caller = _msgSender(); if (guardian == address(0)) { // if there is no proposal guardian @@ -39,7 +38,7 @@ abstract contract GovernorProposalGuardian is Governor { // if there is no guardian and the caller isn't the proposer or // there is a proposal guardian, and the caller is not the proposal guardian // ... apply default behavior - return super._validateCancel(proposalId); + return super._validateCancel(proposalId, caller); } /** diff --git a/contracts/mocks/governance/GovernorProposalGuardianMock.sol b/contracts/mocks/governance/GovernorProposalGuardianMock.sol index c3758d8bc3c..5ed45d6c942 100644 --- a/contracts/mocks/governance/GovernorProposalGuardianMock.sol +++ b/contracts/mocks/governance/GovernorProposalGuardianMock.sol @@ -19,8 +19,9 @@ abstract contract GovernorProposalGuardianMock is } function _validateCancel( - uint256 proposalId + uint256 proposalId, + address caller ) internal view override(Governor, GovernorProposalGuardian) returns (bool) { - return super._validateCancel(proposalId); + return super._validateCancel(proposalId, caller); } } From 85c8c157f4be4d8bfd022c06dc94dd8110d06e23 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 22 Jan 2025 12:15:08 -0500 Subject: [PATCH 16/30] simplify --- contracts/governance/Governor.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/contracts/governance/Governor.sol b/contracts/governance/Governor.sol index 409a7c2a9a6..a7434b4454b 100644 --- a/contracts/governance/Governor.sol +++ b/contracts/governance/Governor.sol @@ -808,8 +808,7 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72 * 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 (_encodeStateBitmap(state(proposalId)) & _encodeStateBitmap(ProposalState.Pending) != bytes32(0) && - caller == proposalProposer(proposalId)); + return (state(proposalId) == ProposalState.Pending) && caller == proposalProposer(proposalId); } /** From 7a7674bf57cdf79068ca4c360bbd82dd5fd97dfb Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 22 Jan 2025 18:19:58 +0100 Subject: [PATCH 17/30] Update GovernorProposalGuardian.test.js --- test/governance/extensions/GovernorProposalGuardian.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index b628b1c55d1..e08ce2f13e4 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -1,8 +1,8 @@ const { ethers } = require('hardhat'); const { expect } = require('chai'); -const { impersonate } = require('../../helpers/account'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { impersonate } = require('../../helpers/account'); const { GovernorHelper } = require('../../helpers/governance'); const { ProposalState } = require('../../helpers/enums'); From ce162a6be3e5ab7ea0f9b3f0854e7d0f780e73de Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 22 Jan 2025 18:35:28 -0500 Subject: [PATCH 18/30] move internal function --- .../extensions/GovernorProposalGuardian.sol | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 9902ff2490d..e0553cbb5f8 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -13,34 +13,6 @@ abstract contract GovernorProposalGuardian is Governor { event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian); - /** - * @dev Override `_validateCancel` that implements the extended cancellation logic. - * - * * The {proposalGuardian} can cancel any proposal at any point in the lifecycle. - * * if no proposal guardian is set, the {proposalProposer} can cancel their proposals at any point in the lifecycle. - * * if the proposal guardian is set, the {proposalProposer} keeps their default rights defined in {IGovernor-_validateCancel} (calling `super`). - */ - function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) { - address guardian = proposalGuardian(); - - if (guardian == address(0)) { - // if there is no proposal guardian - // ... only the proposer can cancel - // ... no restriction on when the proposer can cancel - address proposer = proposalProposer(proposalId); - if (caller == proposer) return true; - } else if (guardian == caller) { - // if there is a proposal guardian, and the caller is the proposal guardian - // ... just cancel - return true; - } - - // if there is no guardian and the caller isn't the proposer or - // there is a proposal guardian, and the caller is not the proposal guardian - // ... apply default behavior - return super._validateCancel(proposalId, caller); - } - /** * @dev Getter that returns the address of the proposal guardian. */ @@ -66,4 +38,32 @@ abstract contract GovernorProposalGuardian is Governor { emit ProposalGuardianSet(_proposalGuardian, newProposalGuardian); _proposalGuardian = newProposalGuardian; } + + /** + * @dev Override `_validateCancel` that implements 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. + * * All other conditions are forwarded to super for default cancellation validation (as defined in {Governor-_validateCancel}). + */ + function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) { + address guardian = proposalGuardian(); + + if (guardian == address(0)) { + // if there is no proposal guardian + // ... only the proposer can cancel + // ... no restriction on when the proposer can cancel + address proposer = proposalProposer(proposalId); + if (caller == proposer) return true; + } else if (guardian == caller) { + // if there is a proposal guardian, and the caller is the proposal guardian + // ... just cancel + return true; + } + + // if there is no guardian and the caller isn't the proposer or + // there is a proposal guardian, and the caller is not the proposal guardian + // ... apply default behavior + return super._validateCancel(proposalId, caller); + } } From f83d972bed5a233443a176ef71981fec4bc3596f Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Wed, 22 Jan 2025 18:52:25 -0500 Subject: [PATCH 19/30] update docs --- contracts/governance/extensions/GovernorProposalGuardian.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index e0553cbb5f8..09f3def46b1 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -51,10 +51,8 @@ abstract contract GovernorProposalGuardian is Governor { if (guardian == address(0)) { // if there is no proposal guardian - // ... only the proposer can cancel // ... no restriction on when the proposer can cancel - address proposer = proposalProposer(proposalId); - if (caller == proposer) return true; + if (caller == proposalProposer(proposalId)) return true; } else if (guardian == caller) { // if there is a proposal guardian, and the caller is the proposal guardian // ... just cancel From 91fbe2447762acf053a9b6bcba67545c7ab2af14 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:19:12 -0500 Subject: [PATCH 20/30] Update contracts/governance/extensions/GovernorProposalGuardian.sol Co-authored-by: Hadrien Croubois --- .../extensions/GovernorProposalGuardian.sol | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 09f3def46b1..0ee3065ac3e 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -49,19 +49,8 @@ abstract contract GovernorProposalGuardian is Governor { function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) { address guardian = proposalGuardian(); - if (guardian == address(0)) { - // if there is no proposal guardian - // ... no restriction on when the proposer can cancel - if (caller == proposalProposer(proposalId)) return true; - } else if (guardian == caller) { - // if there is a proposal guardian, and the caller is the proposal guardian - // ... just cancel - return true; - } - - // if there is no guardian and the caller isn't the proposer or - // there is a proposal guardian, and the caller is not the proposal guardian - // ... apply default behavior - return super._validateCancel(proposalId, caller); + return guardian == caller + || (guardian == address(0) && caller == proposalProposer(proposalId)) + || super._validateCancel(proposalId, caller); } } From 5f71ccfb9c1a96de8b947f790c1ff061892de247 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Thu, 23 Jan 2025 13:21:35 -0500 Subject: [PATCH 21/30] fix lint and edit comments --- .../extensions/GovernorProposalGuardian.sol | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 0ee3065ac3e..47778bcd33a 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -4,7 +4,7 @@ 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 of their lifecycle. + * @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. */ @@ -40,7 +40,7 @@ abstract contract GovernorProposalGuardian is Governor { } /** - * @dev Override `_validateCancel` that implements the extended cancellation logic. + * @dev Override `_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. @@ -49,8 +49,9 @@ abstract contract GovernorProposalGuardian is Governor { 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); + return + guardian == caller || + (guardian == address(0) && caller == proposalProposer(proposalId)) || + super._validateCancel(proposalId, caller); } } From 855f819f8cfe9638e8343b87c8a06b36b49de2fb Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 24 Jan 2025 18:35:43 +0100 Subject: [PATCH 22/30] Update GovernorProposalGuardian.test.js --- .../GovernorProposalGuardian.test.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index e08ce2f13e4..836fecb37f7 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -22,7 +22,7 @@ const value = ethers.parseEther('1'); describe('GovernorProposalGuardian', function () { for (const { Token, mode } of TOKENS) { const fixture = async () => { - const [owner, proposer, voter1, voter2, voter3, voter4, other] = await ethers.getSigners(); + 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]); @@ -45,7 +45,7 @@ describe('GovernorProposalGuardian', function () { 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, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper }; + return { owner, proposer, guardian, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper }; }; describe(`using ${Token}`, function () { @@ -75,14 +75,14 @@ describe('GovernorProposalGuardian', function () { 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.voter1.address)) + await expect(this.mock.connect(governorSigner).setProposalGuardian(this.guardian)) .to.emit(this.mock, 'ProposalGuardianSet') - .withArgs(ethers.ZeroAddress, this.voter1.address); - expect(this.mock.proposalGuardian()).to.eventually.equal(this.voter1.address); + .withArgs(ethers.ZeroAddress, this.guardian); + expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian); }); it('from non-governance', async function () { - await expect(this.mock.setProposalGuardian(this.voter1.address)) + await expect(this.mock.connect(this.other).setProposalGuardian(this.guardian)) .to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') .withArgs(this.owner.address); }); @@ -90,19 +90,22 @@ describe('GovernorProposalGuardian', function () { describe('cancel proposal during active state', function () { beforeEach(async function () { - await this.mock.$_setProposalGuardian(this.voter1.address); await this.helper.connect(this.proposer).propose(); await this.helper.waitForSnapshot(1n); expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); }); it('from proposal guardian', async function () { - await expect(this.helper.connect(this.voter1).cancel()) + 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); @@ -110,6 +113,7 @@ describe('GovernorProposalGuardian', function () { 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); From 6a2030f57833b81d18d2774dbd216656f7fc620b Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Fri, 24 Jan 2025 18:39:23 +0100 Subject: [PATCH 23/30] Apply suggestions from code review --- .../extensions/GovernorProposalGuardian.test.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index 836fecb37f7..68e44a238d0 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -66,10 +66,10 @@ describe('GovernorProposalGuardian', function () { }); it('deployment check', async function () { - expect(await this.mock.name()).to.equal(name); - expect(await this.mock.token()).to.equal(this.token); - expect(await this.mock.votingDelay()).to.equal(votingDelay); - expect(await this.mock.votingPeriod()).to.equal(votingPeriod); + await expect(this.mock.name()).to.eventually.equal(name); + await expect(this.mock.token()).to.equal(eventually.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 () { @@ -78,7 +78,7 @@ describe('GovernorProposalGuardian', function () { await expect(this.mock.connect(governorSigner).setProposalGuardian(this.guardian)) .to.emit(this.mock, 'ProposalGuardianSet') .withArgs(ethers.ZeroAddress, this.guardian); - expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian); + await expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian); }); it('from non-governance', async function () { @@ -92,7 +92,7 @@ describe('GovernorProposalGuardian', function () { beforeEach(async function () { await this.helper.connect(this.proposer).propose(); await this.helper.waitForSnapshot(1n); - expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); + await expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); }); it('from proposal guardian', async function () { From 5621fa4e8e8f87d6a984b1bba1fa00c2fd7063bb Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:41:51 -0500 Subject: [PATCH 24/30] Update contracts/governance/extensions/GovernorProposalGuardian.sol Co-authored-by: Hadrien Croubois --- contracts/governance/extensions/GovernorProposalGuardian.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index 47778bcd33a..a74905f2535 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -40,7 +40,7 @@ abstract contract GovernorProposalGuardian is Governor { } /** - * @dev Override `_validateCancel` to implement the extended cancellation logic. + * @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. From 54ad4148857a6abf3b80281a090d0c1085b727fe Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:42:31 -0500 Subject: [PATCH 25/30] Update contracts/governance/extensions/GovernorProposalGuardian.sol Co-authored-by: Hadrien Croubois --- contracts/governance/extensions/GovernorProposalGuardian.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/governance/extensions/GovernorProposalGuardian.sol b/contracts/governance/extensions/GovernorProposalGuardian.sol index a74905f2535..339024a45b7 100644 --- a/contracts/governance/extensions/GovernorProposalGuardian.sol +++ b/contracts/governance/extensions/GovernorProposalGuardian.sol @@ -44,7 +44,7 @@ abstract contract GovernorProposalGuardian is Governor { * * * 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. - * * All other conditions are forwarded to super for default cancellation validation (as defined in {Governor-_validateCancel}). + * * 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(); From af352f502eda5b9ef6745d3b93130e0a7309e580 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:48:20 -0500 Subject: [PATCH 26/30] lint and fix tests --- test/governance/extensions/GovernorProposalGuardian.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index 68e44a238d0..c07b5704666 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -67,7 +67,7 @@ describe('GovernorProposalGuardian', function () { it('deployment check', async function () { await expect(this.mock.name()).to.eventually.equal(name); - await expect(this.mock.token()).to.equal(eventually.this.token); + 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); }); @@ -84,7 +84,7 @@ describe('GovernorProposalGuardian', function () { it('from non-governance', async function () { await expect(this.mock.connect(this.other).setProposalGuardian(this.guardian)) .to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') - .withArgs(this.owner.address); + .withArgs(this.other); }); }); @@ -113,7 +113,7 @@ describe('GovernorProposalGuardian', function () { 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); From 643e114aac1b624c1b410d761a6bf79be2f0446c Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:26:53 -0500 Subject: [PATCH 27/30] add test --- .../governance/extensions/GovernorProposalGuardian.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index c07b5704666..c8b4c6c4959 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -88,6 +88,13 @@ describe('GovernorProposalGuardian', function () { }); }); + it('cancel during pending state from proposer when proposal guardian is non-zero', async function () { + 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(); From b7a1a320125b10d2860db7ea5cecff4f2c1c0efb Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:27:12 -0500 Subject: [PATCH 28/30] lint --- test/governance/extensions/GovernorProposalGuardian.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index c8b4c6c4959..c333c2c9bcf 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -88,7 +88,7 @@ describe('GovernorProposalGuardian', function () { }); }); - it('cancel during pending state from proposer when proposal guardian is non-zero', async function () { + it('cancel proposal during pending state from proposer when proposal guardian is non-zero', async function () { await this.helper.connect(this.proposer).propose(); await expect(this.helper.connect(this.proposer).cancel()) .to.emit(this.mock, 'ProposalCanceled') From 0927af40f2371c1f84aeff96d98f551f076a3341 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:09:09 -0500 Subject: [PATCH 29/30] fix test --- test/governance/extensions/GovernorProposalGuardian.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/test/governance/extensions/GovernorProposalGuardian.test.js b/test/governance/extensions/GovernorProposalGuardian.test.js index c333c2c9bcf..1741072c398 100644 --- a/test/governance/extensions/GovernorProposalGuardian.test.js +++ b/test/governance/extensions/GovernorProposalGuardian.test.js @@ -89,6 +89,7 @@ describe('GovernorProposalGuardian', function () { }); 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') From 276185d27b92fd5cceae56887b10dd121903fd42 Mon Sep 17 00:00:00 2001 From: Arr00 <13561405+arr00@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:24:06 -0500 Subject: [PATCH 30/30] add changeset --- .changeset/pretty-lobsters-tan.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pretty-lobsters-tan.md diff --git a/.changeset/pretty-lobsters-tan.md b/.changeset/pretty-lobsters-tan.md new file mode 100644 index 00000000000..d3b8644ff5f --- /dev/null +++ b/.changeset/pretty-lobsters-tan.md @@ -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.