Add a governor extension that implements a proposal guardian (#5303)
Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com> Co-authored-by: Ernesto García <ernestognw@gmail.com>pull/5430/merge
parent
495a287e9a
commit
8c1b0ca82d
@ -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. |
@ -0,0 +1,57 @@ |
|||||||
|
// SPDX-License-Identifier: MIT |
||||||
|
pragma solidity ^0.8.20; |
||||||
|
|
||||||
|
import {Governor} from "../Governor.sol"; |
||||||
|
|
||||||
|
/** |
||||||
|
* @dev Extension of {Governor} which adds a proposal guardian that can cancel proposals at any stage in the proposal's lifecycle. |
||||||
|
* |
||||||
|
* NOTE: if the proposal guardian is not configured, then proposers take this role for their proposals. |
||||||
|
*/ |
||||||
|
abstract contract GovernorProposalGuardian is Governor { |
||||||
|
address private _proposalGuardian; |
||||||
|
|
||||||
|
event ProposalGuardianSet(address oldProposalGuardian, address newProposalGuardian); |
||||||
|
|
||||||
|
/** |
||||||
|
* @dev Getter that returns the address of the proposal guardian. |
||||||
|
*/ |
||||||
|
function proposalGuardian() public view virtual returns (address) { |
||||||
|
return _proposalGuardian; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @dev Update the proposal guardian's address. This operation can only be performed through a governance proposal. |
||||||
|
* |
||||||
|
* Emits a {ProposalGuardianSet} event. |
||||||
|
*/ |
||||||
|
function setProposalGuardian(address newProposalGuardian) public virtual onlyGovernance { |
||||||
|
_setProposalGuardian(newProposalGuardian); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @dev Internal setter for the proposal guardian. |
||||||
|
* |
||||||
|
* Emits a {ProposalGuardianSet} event. |
||||||
|
*/ |
||||||
|
function _setProposalGuardian(address newProposalGuardian) internal virtual { |
||||||
|
emit ProposalGuardianSet(_proposalGuardian, newProposalGuardian); |
||||||
|
_proposalGuardian = newProposalGuardian; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @dev Override {Governor-_validateCancel} to implement the extended cancellation logic. |
||||||
|
* |
||||||
|
* * The {proposalGuardian} can cancel any proposal at any point. |
||||||
|
* * If no proposal guardian is set, the {IGovernor-proposalProposer} can cancel their proposals at any point. |
||||||
|
* * In any case, permissions defined in {Governor-_validateCancel} (or another override) remains valid. |
||||||
|
*/ |
||||||
|
function _validateCancel(uint256 proposalId, address caller) internal view virtual override returns (bool) { |
||||||
|
address guardian = proposalGuardian(); |
||||||
|
|
||||||
|
return |
||||||
|
guardian == caller || |
||||||
|
(guardian == address(0) && caller == proposalProposer(proposalId)) || |
||||||
|
super._validateCancel(proposalId, caller); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
// SPDX-License-Identifier: MIT |
||||||
|
|
||||||
|
pragma solidity ^0.8.20; |
||||||
|
|
||||||
|
import {Governor} from "../../governance/Governor.sol"; |
||||||
|
import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol"; |
||||||
|
import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol"; |
||||||
|
import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol"; |
||||||
|
import {GovernorProposalGuardian} from "../../governance/extensions/GovernorProposalGuardian.sol"; |
||||||
|
|
||||||
|
abstract contract GovernorProposalGuardianMock is |
||||||
|
GovernorSettings, |
||||||
|
GovernorVotesQuorumFraction, |
||||||
|
GovernorCountingSimple, |
||||||
|
GovernorProposalGuardian |
||||||
|
{ |
||||||
|
function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { |
||||||
|
return super.proposalThreshold(); |
||||||
|
} |
||||||
|
|
||||||
|
function _validateCancel( |
||||||
|
uint256 proposalId, |
||||||
|
address caller |
||||||
|
) internal view override(Governor, GovernorProposalGuardian) returns (bool) { |
||||||
|
return super._validateCancel(proposalId, caller); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,132 @@ |
|||||||
|
const { ethers } = require('hardhat'); |
||||||
|
const { expect } = require('chai'); |
||||||
|
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); |
||||||
|
|
||||||
|
const { impersonate } = require('../../helpers/account'); |
||||||
|
const { GovernorHelper } = require('../../helpers/governance'); |
||||||
|
const { ProposalState } = require('../../helpers/enums'); |
||||||
|
|
||||||
|
const TOKENS = [ |
||||||
|
{ Token: '$ERC20Votes', mode: 'blocknumber' }, |
||||||
|
{ Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, |
||||||
|
]; |
||||||
|
const name = 'Proposal Guardian Governor'; |
||||||
|
const version = '1'; |
||||||
|
const tokenName = 'MockToken'; |
||||||
|
const tokenSymbol = 'MTKN'; |
||||||
|
const tokenSupply = ethers.parseEther('100'); |
||||||
|
const votingDelay = 4n; |
||||||
|
const votingPeriod = 16n; |
||||||
|
const value = ethers.parseEther('1'); |
||||||
|
|
||||||
|
describe('GovernorProposalGuardian', function () { |
||||||
|
for (const { Token, mode } of TOKENS) { |
||||||
|
const fixture = async () => { |
||||||
|
const [owner, proposer, guardian, voter1, voter2, voter3, voter4, other] = await ethers.getSigners(); |
||||||
|
const receiver = await ethers.deployContract('CallReceiverMock'); |
||||||
|
|
||||||
|
const token = await ethers.deployContract(Token, [tokenName, tokenSymbol, tokenName, version]); |
||||||
|
const mock = await ethers.deployContract('$GovernorProposalGuardianMock', [ |
||||||
|
name, // name
|
||||||
|
votingDelay, // initialVotingDelay
|
||||||
|
votingPeriod, // initialVotingPeriod
|
||||||
|
0n, // initialProposalThreshold
|
||||||
|
token, // tokenAddress
|
||||||
|
10n, // quorumNumeratorValue
|
||||||
|
]); |
||||||
|
|
||||||
|
await impersonate(mock.target); |
||||||
|
await owner.sendTransaction({ to: mock, value }); |
||||||
|
await token.$_mint(owner, tokenSupply); |
||||||
|
|
||||||
|
const helper = new GovernorHelper(mock, mode); |
||||||
|
await helper.connect(owner).delegate({ token, to: voter1, value: ethers.parseEther('10') }); |
||||||
|
await helper.connect(owner).delegate({ token, to: voter2, value: ethers.parseEther('7') }); |
||||||
|
await helper.connect(owner).delegate({ token, to: voter3, value: ethers.parseEther('5') }); |
||||||
|
await helper.connect(owner).delegate({ token, to: voter4, value: ethers.parseEther('2') }); |
||||||
|
|
||||||
|
return { owner, proposer, guardian, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper }; |
||||||
|
}; |
||||||
|
|
||||||
|
describe(`using ${Token}`, function () { |
||||||
|
beforeEach(async function () { |
||||||
|
Object.assign(this, await loadFixture(fixture)); |
||||||
|
|
||||||
|
// default proposal
|
||||||
|
this.proposal = this.helper.setProposal( |
||||||
|
[ |
||||||
|
{ |
||||||
|
target: this.receiver.target, |
||||||
|
value, |
||||||
|
data: this.receiver.interface.encodeFunctionData('mockFunction'), |
||||||
|
}, |
||||||
|
], |
||||||
|
'<proposal description>', |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('deployment check', async function () { |
||||||
|
await expect(this.mock.name()).to.eventually.equal(name); |
||||||
|
await expect(this.mock.token()).to.eventually.equal(this.token); |
||||||
|
await expect(this.mock.votingDelay()).to.eventually.equal(votingDelay); |
||||||
|
await expect(this.mock.votingPeriod()).to.eventually.equal(votingPeriod); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('set proposal guardian', function () { |
||||||
|
it('from governance', async function () { |
||||||
|
const governorSigner = await ethers.getSigner(this.mock.target); |
||||||
|
await expect(this.mock.connect(governorSigner).setProposalGuardian(this.guardian)) |
||||||
|
.to.emit(this.mock, 'ProposalGuardianSet') |
||||||
|
.withArgs(ethers.ZeroAddress, this.guardian); |
||||||
|
await expect(this.mock.proposalGuardian()).to.eventually.equal(this.guardian); |
||||||
|
}); |
||||||
|
|
||||||
|
it('from non-governance', async function () { |
||||||
|
await expect(this.mock.connect(this.other).setProposalGuardian(this.guardian)) |
||||||
|
.to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') |
||||||
|
.withArgs(this.other); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('cancel proposal during pending state from proposer when proposal guardian is non-zero', async function () { |
||||||
|
await this.mock.$_setProposalGuardian(this.guardian); |
||||||
|
await this.helper.connect(this.proposer).propose(); |
||||||
|
await expect(this.helper.connect(this.proposer).cancel()) |
||||||
|
.to.emit(this.mock, 'ProposalCanceled') |
||||||
|
.withArgs(this.proposal.id); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('cancel proposal during active state', function () { |
||||||
|
beforeEach(async function () { |
||||||
|
await this.helper.connect(this.proposer).propose(); |
||||||
|
await this.helper.waitForSnapshot(1n); |
||||||
|
await expect(this.mock.state(this.proposal.id)).to.eventually.equal(ProposalState.Active); |
||||||
|
}); |
||||||
|
|
||||||
|
it('from proposal guardian', async function () { |
||||||
|
await this.mock.$_setProposalGuardian(this.guardian); |
||||||
|
|
||||||
|
await expect(this.helper.connect(this.guardian).cancel()) |
||||||
|
.to.emit(this.mock, 'ProposalCanceled') |
||||||
|
.withArgs(this.proposal.id); |
||||||
|
}); |
||||||
|
|
||||||
|
it('from proposer when proposal guardian is non-zero', async function () { |
||||||
|
await this.mock.$_setProposalGuardian(this.guardian); |
||||||
|
|
||||||
|
await expect(this.helper.connect(this.proposer).cancel()) |
||||||
|
.to.be.revertedWithCustomError(this.mock, 'GovernorUnableToCancel') |
||||||
|
.withArgs(this.proposal.id, this.proposer); |
||||||
|
}); |
||||||
|
|
||||||
|
it('from proposer when proposal guardian is zero', async function () { |
||||||
|
await this.mock.$_setProposalGuardian(ethers.ZeroAddress); |
||||||
|
|
||||||
|
await expect(this.helper.connect(this.proposer).cancel()) |
||||||
|
.to.emit(this.mock, 'ProposalCanceled') |
||||||
|
.withArgs(this.proposal.id); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
Loading…
Reference in new issue