From db5154a56b64e636cf2d51eea473d39562bbf043 Mon Sep 17 00:00:00 2001 From: Avory Date: Tue, 16 Dec 2025 22:25:16 +0200 Subject: [PATCH 1/4] Implement GovernorDelay contract for proposal delays This contract adds a configurable delay to successful proposals, enforcing a waiting period before execution. It includes functions to set and retrieve the delay, as well as checks to ensure the delay is met before executing operations. --- .../governance/extensions/GovernorDelay.sol | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 contracts/governance/extensions/GovernorDelay.sol diff --git a/contracts/governance/extensions/GovernorDelay.sol b/contracts/governance/extensions/GovernorDelay.sol new file mode 100644 index 00000000000..b137c75c99d --- /dev/null +++ b/contracts/governance/extensions/GovernorDelay.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.5.0) (governance/extensions/GovernorDelay.sol) + +pragma solidity ^0.8.24; + +import {IGovernor, Governor} from "../Governor.sol"; +import {SafeCast} from "../../utils/math/SafeCast.sol"; +import {Time} from "../../utils/types/Time.sol"; + +/** + * @dev Extension of {Governor} that adds a configurable delay to all successful proposals before they can be executed. + * + * This extension provides a simple way to add a delay to all proposals without requiring an external timelock contract. + * When a delay is set (greater than 0), all successful proposals must be queued and wait for the delay period to elapse + * before they can be executed. + * + * The delay is enforced by the Governor itself, unlike {GovernorTimelockControl} and {GovernorTimelockCompound} where + * the delay is enforced by an external timelock contract. + * + * NOTE: The delay is expressed in seconds and uses block.timestamp, regardless of the governor's clock mode. This is + * consistent with {proposalEta} which is documented to not follow ERC-6372 CLOCK_MODE and almost always be a timestamp. + * + * @custom:security-note This extension enforces delays at the Governor level. If you need more sophisticated delay + * mechanisms (e.g., cancellable operations, different delays per operation), consider using {GovernorTimelockAccess} + * with an {AccessManager}. + */ +abstract contract GovernorDelay is Governor { + using Time for *; + + uint32 private _delay; + + error GovernorUnmetDelay(uint256 proposalId, uint256 neededTimestamp); + + event DelaySet(uint32 oldDelay, uint32 newDelay); + + /** + * @dev Initialize the governor with an initial delay. + */ + constructor(uint32 initialDelay) { + _setDelay(initialDelay); + } + + /** + * @dev Returns the delay in seconds that must elapse before a queued proposal can be executed. + */ + function delay() public view virtual returns (uint32) { + return _delay; + } + + /** + * @dev Change the delay. This operation can only be performed through a governance proposal. + * + * Emits a {DelaySet} event. + */ + function setDelay(uint32 newDelay) public virtual onlyGovernance { + _setDelay(newDelay); + } + + /** + * @dev Internal function to set the delay without access control. + */ + function _setDelay(uint32 newDelay) internal virtual { + emit DelaySet(_delay, newDelay); + _delay = newDelay; + } + + /// @inheritdoc IGovernor + function proposalNeedsQueuing(uint256) public view virtual override returns (bool) { + return _delay > 0; + } + + /** + * @dev Function to queue a proposal with the configured delay. + */ + function _queueOperations( + uint256 /* proposalId */, + address[] memory /* targets */, + uint256[] memory /* values */, + bytes[] memory /* calldatas */, + bytes32 /* descriptionHash */ + ) internal virtual override returns (uint48) { + if (_delay == 0) { + return 0; + } + return Time.timestamp() + _delay; + } + + /** + * @dev Overridden version of the {Governor-_executeOperations} function that checks if the delay has elapsed. + */ + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal virtual override { + uint48 etaSeconds = SafeCast.toUint48(proposalEta(proposalId)); + if (etaSeconds > 0 && block.timestamp < etaSeconds) { + revert GovernorUnmetDelay(proposalId, etaSeconds); + } + + super._executeOperations(proposalId, targets, values, calldatas, descriptionHash); + } +} + From 4d855873619ff9720462895b8af20d450a70c379 Mon Sep 17 00:00:00 2001 From: Avory Date: Tue, 16 Dec 2025 22:26:24 +0200 Subject: [PATCH 2/4] Add GovernorDelayMock contract for testing governance --- .../mocks/governance/GovernorDelayMock.sol | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 contracts/mocks/governance/GovernorDelayMock.sol diff --git a/contracts/mocks/governance/GovernorDelayMock.sol b/contracts/mocks/governance/GovernorDelayMock.sol new file mode 100644 index 00000000000..6ce04c3bac4 --- /dev/null +++ b/contracts/mocks/governance/GovernorDelayMock.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {Governor} from "../../governance/Governor.sol"; +import {GovernorDelay} from "../../governance/extensions/GovernorDelay.sol"; +import {GovernorSettings} from "../../governance/extensions/GovernorSettings.sol"; +import {GovernorCountingSimple} from "../../governance/extensions/GovernorCountingSimple.sol"; +import {GovernorVotesQuorumFraction} from "../../governance/extensions/GovernorVotesQuorumFraction.sol"; + +abstract contract GovernorDelayMock is + GovernorSettings, + GovernorDelay, + GovernorVotesQuorumFraction, + GovernorCountingSimple +{ + function quorum(uint256 blockNumber) public view override(Governor, GovernorVotesQuorumFraction) returns (uint256) { + return super.quorum(blockNumber); + } + + function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) { + return super.proposalThreshold(); + } + + function proposalNeedsQueuing( + uint256 proposalId + ) public view virtual override(Governor, GovernorDelay) returns (bool) { + return super.proposalNeedsQueuing(proposalId); + } + + function _queueOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorDelay) returns (uint48) { + return super._queueOperations(proposalId, targets, values, calldatas, descriptionHash); + } + + function _executeOperations( + uint256 proposalId, + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + bytes32 descriptionHash + ) internal override(Governor, GovernorDelay) { + super._executeOperations(proposalId, targets, values, calldatas, descriptionHash); + } +} + From f76e77e78b43f71b07569a394397d165ee4f6ace Mon Sep 17 00:00:00 2001 From: Avory Date: Tue, 16 Dec 2025 22:26:55 +0200 Subject: [PATCH 3/4] Add tests for GovernorDelay functionality --- .../extensions/GovernorDelay.test.js | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 test/governance/extensions/GovernorDelay.test.js diff --git a/test/governance/extensions/GovernorDelay.test.js b/test/governance/extensions/GovernorDelay.test.js new file mode 100644 index 00000000000..ccb2bf12b5c --- /dev/null +++ b/test/governance/extensions/GovernorDelay.test.js @@ -0,0 +1,359 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs'); + +const { GovernorHelper } = require('../../helpers/governance'); +const { ProposalState, VoteType } = require('../../helpers/enums'); +const time = require('../../helpers/time'); + +const TOKENS = [ + { Token: '$ERC20Votes', mode: 'blocknumber' }, + { Token: '$ERC20VotesTimestampMock', mode: 'timestamp' }, +]; + +const name = 'OZ-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'); +const initialDelay = time.duration.hours(1n); + +describe('GovernorDelay', function () { + for (const { Token, mode } of TOKENS) { + const fixture = async () => { + const [admin, 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('$GovernorDelayMock', [ + name, + votingDelay, + votingPeriod, + 0n, + initialDelay, + token, + 0n, + ]); + + await admin.sendTransaction({ to: mock, value }); + await token.$_mint(admin, tokenSupply); + + const helper = new GovernorHelper(mock, mode); + await helper.connect(admin).delegate({ token, to: voter1, value: ethers.parseEther('10') }); + await helper.connect(admin).delegate({ token, to: voter2, value: ethers.parseEther('7') }); + await helper.connect(admin).delegate({ token, to: voter3, value: ethers.parseEther('5') }); + await helper.connect(admin).delegate({ token, to: voter4, value: ethers.parseEther('2') }); + + return { admin, voter1, voter2, voter3, voter4, other, receiver, token, mock, helper }; + }; + + describe(`using ${Token}`, function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + it('post 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); + expect(await this.mock.quorum(0n)).to.equal(0n); + expect(await this.mock.delay()).to.equal(initialDelay); + }); + + it('sets delay through governance', async function () { + const newDelay = time.duration.hours(2n); + + // Only through governance + await expect(this.mock.connect(this.voter1).setDelay(newDelay)) + .to.be.revertedWithCustomError(this.mock, 'GovernorOnlyExecutor') + .withArgs(this.voter1); + + this.proposal = await this.helper.setProposal( + [ + { + target: this.mock.target, + data: this.mock.interface.encodeFunctionData('setDelay', [newDelay]), + }, + ], + 'descr', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + await this.helper.queue(); + await this.helper.waitForEta(); + + await expect(this.helper.execute()) + .to.emit(this.mock, 'DelaySet') + .withArgs(initialDelay, newDelay); + + expect(await this.mock.delay()).to.equal(newDelay); + }); + + it('does not need to queue proposals with zero delay', async function () { + // Set delay to 0 through governance + this.proposal = await this.helper.setProposal( + [ + { + target: this.mock.target, + data: this.mock.interface.encodeFunctionData('setDelay', [0n]), + }, + ], + 'descr', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + await this.helper.queue(); + await this.helper.waitForEta(); + await this.helper.execute(); + + // Now create a new proposal + this.proposal2 = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr2', + ); + + await this.helper.propose(); + expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.false; + }); + + it('needs to queue proposals with non-zero delay', async function () { + this.proposal = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr', + ); + + await this.helper.propose(); + expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.true; + }); + + it('queues proposal and sets ETA correctly', async function () { + this.proposal = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + + const queueTx = this.helper.queue(); + const proposalId = this.helper.currentProposal.id; + await expect(queueTx) + .to.emit(this.mock, 'ProposalQueued') + .withArgs(proposalId, anyValue); + + const eta = await this.mock.proposalEta(proposalId); + expect(eta).to.be.gt(0n); + const currentTime = await time.clock.timestamp(); + expect(eta).to.be.gte(currentTime + initialDelay); + }); + + it('executes proposal after delay has elapsed', async function () { + this.proposal = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + await this.helper.queue(); + await this.helper.waitForEta(); + + await expect(this.helper.execute()) + .to.emit(this.mock, 'ProposalExecuted') + .withArgs(this.helper.currentProposal.id) + .to.emit(this.receiver, 'MockFunctionCalled'); + }); + + it('reverts when executing proposal before delay has elapsed', async function () { + this.proposal = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + await this.helper.queue(); + + // Try to execute immediately without waiting + const proposalId = this.helper.currentProposal.id; + await expect(this.helper.execute()) + .to.be.revertedWithCustomError(this.mock, 'GovernorUnmetDelay') + .withArgs(proposalId, await this.mock.proposalEta(proposalId)); + }); + + it('allows immediate execution when delay is zero', async function () { + // Set delay to 0 + this.proposal1 = await this.helper.setProposal( + [ + { + target: this.mock.target, + data: this.mock.interface.encodeFunctionData('setDelay', [0n]), + }, + ], + 'descr1', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + await this.helper.queue(); + await this.helper.waitForEta(); + await this.helper.execute(); + + // Now create a new proposal - should execute immediately + this.proposal2 = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr2', + ); + + await this.helper.propose(); + await this.helper.waitForSnapshot(); + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(); + + // Should be able to execute directly without queuing + expect(await this.mock.proposalNeedsQueuing(this.helper.currentProposal.id)).to.be.false; + await expect(this.helper.execute()) + .to.emit(this.mock, 'ProposalExecuted') + .withArgs(this.helper.currentProposal.id) + .to.emit(this.receiver, 'MockFunctionCalled'); + }); + + it('proposal state transitions correctly with delay', async function () { + this.proposal = await this.helper.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr', + ); + + await this.helper.propose(); + const proposalId = this.helper.currentProposal.id; + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Pending); + + await this.helper.waitForSnapshot(1n); // Add 1 to ensure we're past the snapshot + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Active); + + await this.helper.connect(this.voter1).vote({ support: VoteType.For }); + await this.helper.waitForDeadline(1n); // Add 1 to ensure we're past the deadline + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Succeeded); + + await this.helper.queue(); + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Queued); + + await this.helper.waitForEta(); + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Queued); + + await this.helper.execute(); + expect(await this.mock.state(proposalId)).to.equal(ProposalState.Executed); + }); + + it('handles multiple proposals with delay correctly', async function () { + // First proposal + const helper1 = new GovernorHelper(this.mock, mode); + await helper1.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr1', + ); + + await helper1.propose(); + const proposalId1 = helper1.currentProposal.id; + await helper1.waitForSnapshot(); + await helper1.connect(this.voter1).vote({ support: VoteType.For }); + await helper1.waitForDeadline(); + await helper1.queue(); + const eta1 = await this.mock.proposalEta(proposalId1); + + // Second proposal + const helper2 = new GovernorHelper(this.mock, mode); + await helper2.setProposal( + [ + { + target: this.receiver.target, + data: this.receiver.interface.encodeFunctionData('mockFunction'), + }, + ], + 'descr2', + ); + + await helper2.propose(); + const proposalId2 = helper2.currentProposal.id; + await helper2.waitForSnapshot(); + await helper2.connect(this.voter1).vote({ support: VoteType.For }); + await helper2.waitForDeadline(); + await helper2.queue(); + const eta2 = await this.mock.proposalEta(proposalId2); + + // Both should have valid ETAs + expect(eta1).to.be.gt(0n); + expect(eta2).to.be.gt(0n); + expect(eta2).to.be.gte(eta1); + + // Wait for first proposal's ETA and execute + await time.increaseTo.timestamp(eta1); + await helper1.execute(); + + // Wait for second proposal's ETA and execute + await time.increaseTo.timestamp(eta2); + await helper2.execute(); + }); + }); + } +}); + From 0f5d744f65816b218b256a4d049036bf18913656 Mon Sep 17 00:00:00 2001 From: Avory Date: Tue, 16 Dec 2025 22:27:26 +0200 Subject: [PATCH 4/4] Document GovernorDelay extension in README Added description for GovernorDelay extension in README. --- contracts/governance/README.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contracts/governance/README.adoc b/contracts/governance/README.adoc index 0901a5521da..ac88cdba676 100644 --- a/contracts/governance/README.adoc +++ b/contracts/governance/README.adoc @@ -36,6 +36,8 @@ Counting modules determine valid voting options. Timelock extensions add a delay for governance decisions to be executed. The workflow is extended to require a `queue` step before execution. With these modules, proposals are executed by the external timelock contract, thus it is the timelock that has to hold the assets that are being governed. +* {GovernorDelay}: Adds a simple configurable delay to all proposals without requiring an external timelock contract. The delay is enforced by the Governor itself, making it suitable for cases where a simple delay is needed without the complexity of external timelock contracts. + * {GovernorTimelockAccess}: Connects with an instance of an {AccessManager}. This allows restrictions (and delays) enforced by the manager to be considered by the Governor and integrated into the AccessManager's "schedule + execute" workflow. * {GovernorTimelockControl}: Connects with an instance of {TimelockController}. Allows multiple proposers and executors, in addition to the Governor itself. @@ -86,6 +88,8 @@ NOTE: Functions of the `Governor` contract do not include access control. If you === Extensions +{{GovernorDelay}} + {{GovernorTimelockAccess}} {{GovernorTimelockControl}}