Skip to content

Commit 96eaa97

Browse files
Fix I-03: snapshot extension parameters at proposal creation (#77)
* feat: snapshot extension parameters at proposal creation * use checkpoints * - Clean up `_votingPeriodExtensionThresholdTriggered` - Add OZ-style timpoint based getter for `votingPeriodExtension` and `minorVetoExtensionThresholdPct` - Add unit tests --------- Co-authored-by: wildmolasses <changes@gmail.com>
1 parent 96e7683 commit 96eaa97

5 files changed

Lines changed: 200 additions & 25 deletions

src/BasicCouncilVetoGovernor.sol

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
GovernorTimelockControl,
1212
TimelockController
1313
} from "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
14+
import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
15+
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
1416

1517
// Internal Dependencies
1618
import {GovernorVetoOverride} from "src/extensions/GovernorVetoOverride.sol";
@@ -297,4 +299,19 @@ contract BasicCouncilVetoGovernor is
297299
{
298300
return true;
299301
}
302+
303+
/// @dev Resolves the conflict between GovernorExtendVetoPeriod and
304+
/// GovernorVotesVetoThresholdFraction which both define this function with identical
305+
/// implementations.
306+
function _optimisticUpperLookupRecent(Checkpoints.Trace208 storage ckpts, uint256 timepoint)
307+
internal
308+
view
309+
virtual
310+
override(GovernorExtendVetoPeriod, GovernorVotesVetoThresholdFraction)
311+
returns (uint256)
312+
{
313+
// Both parent implementations are identical, delegate to GovernorVotesVetoThresholdFraction
314+
return GovernorVotesVetoThresholdFraction._optimisticUpperLookupRecent(ckpts, timepoint);
315+
}
300316
}
317+

src/extensions/GovernorExtendVetoPeriod.sol

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pragma solidity ^0.8.30;
44
// External Dependencies
55
import {IGovernor, Governor} from "@openzeppelin/contracts/governance/Governor.sol";
66
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
7+
import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
8+
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
79

810
/// @title GovernorExtendVetoPeriod
911
/// @author [ScopeLift](https://scopelift.co)
@@ -13,16 +15,20 @@ import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
1315
/// (contracts/governance/extensions/GovernorPreventLateQuorum.sol) with behavior adapted for
1416
/// veto-counting.
1517
abstract contract GovernorExtendVetoPeriod is Governor {
18+
using Checkpoints for Checkpoints.Trace208;
19+
1620
/// @notice Emitted when a proposal deadline is pushed back due to reaching its minor veto
1721
/// threshold.
18-
event ProposalExtended(uint256 indexed proposalId, uint64 extendedDeadline);
22+
event ProposalExtended(uint256 indexed proposalId, uint256 extendedDeadline);
1923

2024
/// @notice Emitted when the {_votingPeriodExtension} parameter is changed.
21-
event VotingPeriodExtensionSet(uint64 oldVotingPeriodExtension, uint64 newVotingPeriodExtension);
25+
event VotingPeriodExtensionSet(
26+
uint256 oldVotingPeriodExtension, uint256 newVotingPeriodExtension
27+
);
2228

2329
/// @notice Emitted when the {_votingPeriodExtensionThresholdPct} parameter is changed.
2430
event MinorVetoExtensionThresholdPctSet(
25-
uint16 oldVotingPeriodExtensionThresholdPct, uint16 newVotingPeriodExtensionThresholdPct
31+
uint256 oldVotingPeriodExtensionThresholdPct, uint256 newVotingPeriodExtensionThresholdPct
2632
);
2733

2834
/// @dev Reverts when a minor veto extension threshold exceeds the percent denominator.
@@ -31,11 +37,11 @@ abstract contract GovernorExtendVetoPeriod is Governor {
3137

3238
/// @dev The extra time (seconds or blocks, depending on the governor clock mode) that may be
3339
/// added when the minor veto threshold is met.
34-
uint48 private _votingPeriodExtension;
40+
Checkpoints.Trace208 private _votingPeriodExtension;
3541

3642
/// @dev The minor threshold in percentage points of veto threshold required to trigger an
3743
/// extension.
38-
uint16 private _minorVetoExtensionThresholdPct;
44+
Checkpoints.Trace208 private _minorVetoExtensionThresholdPct;
3945

4046
/// @dev Mapping of proposal ID to extended deadline.
4147
mapping(uint256 proposalId => uint48) private _extendedDeadlines;
@@ -56,16 +62,31 @@ abstract contract GovernorExtendVetoPeriod is Governor {
5662
return Math.max(super.proposalDeadline(_proposalId), _extendedDeadlines[_proposalId]);
5763
}
5864

59-
/// @notice Returns the current voting period extension duration applied when the minor veto
60-
/// threshold is triggered.
61-
function votingPeriodExtension() public view virtual returns (uint48) {
62-
return _votingPeriodExtension;
65+
/// @notice Returns the latest voting period extension duration.
66+
function votingPeriodExtension() public view virtual returns (uint256) {
67+
return _votingPeriodExtension.latest();
6368
}
6469

65-
/// @notice Returns the minor veto threshold expressed in percentage points of the real veto
66-
/// threshold that must be reached to extend the voting period.
67-
function minorVetoExtensionThresholdPct() public view virtual returns (uint16) {
68-
return _minorVetoExtensionThresholdPct;
70+
/// @notice Returns the voting period extension duration at a specific timepoint.
71+
/// @dev Use {proposalSnapshot} for snapshot-based semantics.
72+
function votingPeriodExtension(uint256 _timepoint) public view virtual returns (uint256) {
73+
return _optimisticUpperLookupRecent(_votingPeriodExtension, _timepoint);
74+
}
75+
76+
/// @notice Returns the latest minor veto threshold percentage.
77+
function minorVetoExtensionThresholdPct() public view virtual returns (uint256) {
78+
return _minorVetoExtensionThresholdPct.latest();
79+
}
80+
81+
/// @notice Returns the minor veto threshold percentage at a specific timepoint.
82+
/// @dev Use {proposalSnapshot} for snapshot-based semantics.
83+
function minorVetoExtensionThresholdPct(uint256 _timepoint)
84+
public
85+
view
86+
virtual
87+
returns (uint256)
88+
{
89+
return _optimisticUpperLookupRecent(_minorVetoExtensionThresholdPct, _timepoint);
6990
}
7091

7192
/// @dev Returns the minor veto extension threshold denominator. Defaults to 100, but may be
@@ -113,13 +134,14 @@ abstract contract GovernorExtendVetoPeriod is Governor {
113134
virtual
114135
returns (bool)
115136
{
116-
if (_minorVetoExtensionThresholdPct == 0) return false;
137+
uint256 _proposalSnapshot = proposalSnapshot(_proposalId);
117138

139+
uint16 _minorThresholdPct = SafeCast.toUint16(minorVetoExtensionThresholdPct(_proposalSnapshot));
140+
if (_minorThresholdPct == 0) return false;
118141
uint256 _minorThreshold = Math.mulDiv(
119-
vetoThreshold(proposalSnapshot(_proposalId)),
120-
_minorVetoExtensionThresholdPct,
121-
minorVetoExtensionThresholdDenominator()
142+
vetoThreshold(_proposalSnapshot), _minorThresholdPct, minorVetoExtensionThresholdDenominator()
122143
);
144+
123145
return proposalVotes(_proposalId) >= _minorThreshold;
124146
}
125147

@@ -135,7 +157,8 @@ abstract contract GovernorExtendVetoPeriod is Governor {
135157
) {
136158
// Lock in the first extension decision even if it does not lengthen the deadline so later
137159
// tallies cannot attempt to extend the same proposal again.
138-
uint48 extendedDeadline = clock() + votingPeriodExtension();
160+
uint48 extendedDeadline =
161+
clock() + SafeCast.toUint48(votingPeriodExtension(proposalSnapshot(_proposalId)));
139162

140163
if (extendedDeadline > proposalDeadline(_proposalId)) {
141164
emit ProposalExtended(_proposalId, extendedDeadline);
@@ -149,9 +172,9 @@ abstract contract GovernorExtendVetoPeriod is Governor {
149172
/// event.
150173
/// @param _newVotingPeriodExtension Duration to extend when triggered.
151174
function _setVotingPeriodExtension(uint48 _newVotingPeriodExtension) internal virtual {
152-
emit VotingPeriodExtensionSet(_votingPeriodExtension, _newVotingPeriodExtension);
153-
154-
_votingPeriodExtension = _newVotingPeriodExtension;
175+
(uint208 oldValue, uint208 newValue) =
176+
_votingPeriodExtension.push(clock(), SafeCast.toUint208(_newVotingPeriodExtension));
177+
emit VotingPeriodExtensionSet(oldValue, newValue);
155178
}
156179

157180
/// @dev Internal setter for {_minorVetoExtensionThresholdPct}. Emits a
@@ -166,9 +189,24 @@ abstract contract GovernorExtendVetoPeriod is Governor {
166189
if (_newMinorVetoExtensionThresholdPct > minorVetoExtensionThresholdDenominator()) {
167190
revert GovernorExtendVetoPeriod_InvalidThreshold(_newMinorVetoExtensionThresholdPct);
168191
}
169-
emit MinorVetoExtensionThresholdPctSet(
170-
_minorVetoExtensionThresholdPct, _newMinorVetoExtensionThresholdPct
192+
(uint208 oldValue, uint208 newValue) = _minorVetoExtensionThresholdPct.push(
193+
clock(), SafeCast.toUint208(_newMinorVetoExtensionThresholdPct)
171194
);
172-
_minorVetoExtensionThresholdPct = _newMinorVetoExtensionThresholdPct;
195+
emit MinorVetoExtensionThresholdPctSet(oldValue, newValue);
196+
}
197+
198+
/**
199+
* @dev Returns the numerator at a specific timepoint.
200+
*/
201+
function _optimisticUpperLookupRecent(Checkpoints.Trace208 storage ckpts, uint256 timepoint)
202+
internal
203+
view
204+
virtual
205+
returns (uint256)
206+
{
207+
// If trace is empty, key and value are both equal to 0.
208+
// In that case `key <= timepoint` is true, and it is ok to return 0.
209+
(, uint48 key, uint208 value) = ckpts.latestCheckpoint();
210+
return key <= timepoint ? value : ckpts.upperLookupRecent(SafeCast.toUint48(timepoint));
173211
}
174212
}

src/extensions/GovernorVotesVetoThresholdFraction.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ abstract contract GovernorVotesVetoThresholdFraction is GovernorVotes {
124124
function _optimisticUpperLookupRecent(Checkpoints.Trace208 storage ckpts, uint256 timepoint)
125125
internal
126126
view
127+
virtual
127128
returns (uint256)
128129
{
129130
// If trace is empty, key and value are both equal to 0.

test/GovernorExtendVetoPeriod.t.sol

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ contract GovernorVetoExtensionTest is Test {
108108
vetoMock.forceTriggerVotingPeriodExtensionThreshold();
109109
vetoMock.exposed_TallyUpdated(_proposalId);
110110
}
111+
112+
function _triggerExtensionInWindow(uint256 _proposalId, uint48 _extension)
113+
internal
114+
returns (uint256 _timestamp)
115+
{
116+
uint256 _deadline = vetoMock.proposalDeadline(_proposalId);
117+
_timestamp = bound(block.timestamp, _deadline - _extension, _deadline);
118+
vm.warp(_timestamp);
119+
vetoMock.forceTriggerVotingPeriodExtensionThreshold();
120+
vetoMock.exposed_TallyUpdated(_proposalId);
121+
}
111122
}
112123

113124
contract Constructor is GovernorVetoExtensionTest {
@@ -136,6 +147,49 @@ contract VotingPeriodExtension is GovernorVetoExtensionTest {
136147
function test_ReturnsVotingPeriodExtension() public view {
137148
assertEq(vetoMock.votingPeriodExtension(), INITIAL_VETO_PERIOD_EXTENSION);
138149
}
150+
151+
function testFuzz_PendingProposalUseNewVotingPeriodExtension(
152+
address _target,
153+
uint256 _value,
154+
bytes memory _calldata,
155+
uint48 _newExtension
156+
) public {
157+
uint48 _oldExtension = uint48(vetoMock.votingPeriodExtension());
158+
vm.assume(_oldExtension != _newExtension);
159+
160+
uint256 _proposalId = _createProposal(_target, _value, _calldata);
161+
vm.assume(_newExtension < vetoMock.proposalDeadline(_proposalId));
162+
vetoMock.exposed_SetVotingPeriodExtension(_newExtension);
163+
164+
// Return the timepoint when extension is trigged and voting period is extended
165+
uint256 ts = _triggerExtensionInWindow(_proposalId, _newExtension);
166+
167+
assertEq(vetoMock.proposalDeadline(_proposalId), ts + _newExtension);
168+
assertNotEq(vetoMock.proposalDeadline(_proposalId), ts + _oldExtension);
169+
}
170+
171+
function testFuzz_ProposalBeyondActiveUseCheckpointedValueWhenCalculatingProposalDeadline(
172+
address _target,
173+
uint256 _value,
174+
bytes memory _calldata,
175+
uint48 _newExtension
176+
) public {
177+
uint48 _oldExtension = uint48(vetoMock.votingPeriodExtension());
178+
vm.assume(_oldExtension != _newExtension);
179+
180+
uint256 _proposalId = _createProposal(_target, _value, _calldata);
181+
uint256 _snapshot = vetoMock.proposalSnapshot(_proposalId);
182+
183+
vm.warp(_snapshot + 1);
184+
185+
vetoMock.exposed_SetVotingPeriodExtension(_newExtension);
186+
187+
// Return the time when extension is trigged and voting period is extended
188+
uint256 ts = _triggerExtensionInWindow(_proposalId, _oldExtension);
189+
190+
assertEq(vetoMock.proposalDeadline(_proposalId), ts + _oldExtension);
191+
assertNotEq(vetoMock.proposalDeadline(_proposalId), ts + _newExtension);
192+
}
139193
}
140194

141195
contract VotingPeriodExtensionThreshold is GovernorVetoExtensionTest {
@@ -223,6 +277,71 @@ contract _votingPeriodExtensionThresholdTriggered is GovernorVetoExtensionTest {
223277
assertTrue(vetoMock.exposed_VotingPeriodExtensionThresholdTriggered(_proposalId));
224278
}
225279

280+
function testFuzz_PendingProposalUseNewVetoExtensionThresholdPct(
281+
address _target,
282+
uint256 _value,
283+
bytes memory _calldata,
284+
uint16 _lowThresholdPct,
285+
address _voter,
286+
uint256 _voteWeight
287+
) public {
288+
uint256 _highThresholdPct = vetoMock.minorVetoExtensionThresholdPct();
289+
_lowThresholdPct = uint16(bound(_lowThresholdPct, 1, _highThresholdPct - 1));
290+
291+
vetoMock.exposed_setMinorVetoExtensionThresholdPct(_lowThresholdPct);
292+
uint256 _proposalId = _createProposal(_target, _value, _calldata);
293+
294+
uint256 _lowMinorThreshold = Math.mulDiv(
295+
vetoMock.vetoThreshold(vetoMock.proposalSnapshot(_proposalId)),
296+
_lowThresholdPct,
297+
vetoMock.minorVetoExtensionThresholdDenominator()
298+
);
299+
300+
uint256 _highMinorThreshold = Math.mulDiv(
301+
vetoMock.vetoThreshold(vetoMock.proposalSnapshot(_proposalId)),
302+
_highThresholdPct,
303+
vetoMock.minorVetoExtensionThresholdDenominator()
304+
);
305+
306+
_voteWeight = bound(_voteWeight, _lowMinorThreshold, _highMinorThreshold - 1);
307+
_castVoteOnProposal(_proposalId, _voter, _voteWeight);
308+
309+
assertTrue(vetoMock.exposed_VotingPeriodExtensionThresholdTriggered(_proposalId));
310+
}
311+
312+
function testFuzz_CheckpointedValueUsedWhenCalculatingVetoExtensionThresholdPct(
313+
address _target,
314+
uint256 _value,
315+
bytes memory _calldata,
316+
uint16 _highThresholdPct,
317+
uint16 _lowThresholdPct,
318+
address _voter,
319+
uint256 _voteWeight
320+
) public {
321+
_highThresholdPct = uint16(
322+
bound(_highThresholdPct, 51, vetoMock.minorVetoExtensionThresholdDenominator())
323+
);
324+
_lowThresholdPct = uint16(bound(_lowThresholdPct, 1, _highThresholdPct - 1));
325+
326+
vetoMock.exposed_setMinorVetoExtensionThresholdPct(_highThresholdPct);
327+
uint256 _proposalId = _createProposal(_target, _value, _calldata);
328+
uint256 _proposalSnapshot = vetoMock.proposalSnapshot(_proposalId);
329+
330+
vm.warp(_proposalSnapshot + 1);
331+
vetoMock.exposed_setMinorVetoExtensionThresholdPct(_lowThresholdPct);
332+
333+
uint256 _highMinorThreshold = Math.mulDiv(
334+
vetoMock.vetoThreshold(_proposalSnapshot),
335+
_highThresholdPct,
336+
vetoMock.minorVetoExtensionThresholdDenominator()
337+
);
338+
339+
_voteWeight = bound(_voteWeight, 1, _highMinorThreshold - 1);
340+
_castVoteOnProposal(_proposalId, _voter, _voteWeight);
341+
342+
assertFalse(vetoMock.exposed_VotingPeriodExtensionThresholdTriggered(_proposalId));
343+
}
344+
226345
function test_ExtensionDisabledWhenThresholdIsZero(
227346
address _target,
228347
uint256 _value,

test/mocks/GovernorExtendVetoPeriodMock.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {GovernorVetoCountingSimple} from "src/extensions/GovernorVetoCountingSim
77

88
/// @title GovernorExtendVetoPeriodMock
99
/// @author [ScopeLift](https://scopelift.co)
10-
contract GovernorExtendVetoPeriodMock is GovernorExtendVetoPeriod, GovernorVetoCountingSimple {
10+
contract GovernorExtendVetoPeriodMock is GovernorVetoCountingSimple, GovernorExtendVetoPeriod {
1111
constructor(uint48 _initialVotingPeriodExtension, uint16 _initialVotingPeriodExtensionThreshold)
1212
Governor("GovernorExtendVetoPeriodMock")
1313
GovernorExtendVetoPeriod(_initialVotingPeriodExtension, _initialVotingPeriodExtensionThreshold)

0 commit comments

Comments
 (0)