Skip to content

Commit ef329e3

Browse files
committed
feat: add governance early resolution threshold
1 parent 6d0cc1d commit ef329e3

6 files changed

Lines changed: 146 additions & 20 deletions

File tree

src/foundation/Errors.sol

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ library Errors {
279279
/// @notice Minimum voting threshold must be greater than zero
280280
error InvalidVotingThreshold();
281281

282+
/// @notice Early resolution threshold must be greater than or equal to the minimum voting threshold
283+
/// @param minVotingThreshold Minimum votes required for quorum
284+
/// @param earlyResolutionVoteThreshold Votes required to close voting early
285+
error InvalidEarlyResolutionVoteThreshold(uint128 minVotingThreshold, uint128 earlyResolutionVoteThreshold);
286+
282287
/// @notice Required proposer stake must be greater than zero
283288
error InvalidProposerStake();
284289

@@ -584,4 +589,3 @@ library Errors {
584589
/// @notice Operation is not supported
585590
error OperationNotSupported();
586591
}
587-

src/foundation/Types.sol

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,7 @@ struct Proposal {
132132
bool isResolved;
133133
/// @notice When proposal was resolved (microseconds)
134134
uint64 resolutionTime;
135+
/// @notice Optional early resolution vote threshold. Zero disables early resolution.
136+
/// @dev If yesVotes or noVotes reaches this threshold, voting closes before expiration.
137+
uint128 earlyResolutionVoteThreshold;
135138
}

src/governance/Governance.sol

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Proposal, ProposalState } from "../foundation/Types.sol";
77
import { SystemAddresses } from "../foundation/SystemAddresses.sol";
88
import { Errors } from "../foundation/Errors.sol";
99
import { IStaking } from "../staking/IStaking.sol";
10+
import { IValidatorManagement } from "../staking/IValidatorManagement.sol";
1011
import { ITimestamp } from "../runtime/ITimestamp.sol";
1112
import { requireAllowed } from "../foundation/SystemAccessControl.sol";
1213
import { Ownable2Step, Ownable } from "@openzeppelin/access/Ownable2Step.sol";
@@ -154,6 +155,11 @@ contract Governance is IGovernance, Ownable2Step {
154155
return IStaking(SystemAddresses.STAKING);
155156
}
156157

158+
/// @notice Get validator manager contract
159+
function _validatorManager() internal pure returns (IValidatorManagement) {
160+
return IValidatorManagement(SystemAddresses.VALIDATOR_MANAGER);
161+
}
162+
157163
/// @notice Verify caller is the pool's voter
158164
function _requireVoter(
159165
address stakePool
@@ -173,6 +179,40 @@ contract Governance is IGovernance, Ownable2Step {
173179
}
174180
}
175181

182+
/// @notice Return true if either side has reached the proposal's early resolution threshold.
183+
function _canBeResolvedEarly(
184+
Proposal storage p
185+
) internal view returns (bool) {
186+
uint128 threshold = p.earlyResolutionVoteThreshold;
187+
return threshold > 0 && (p.yesVotes >= threshold || p.noVotes >= threshold);
188+
}
189+
190+
/// @notice Return true when no more votes are needed for state evaluation.
191+
function _isVotingClosed(
192+
Proposal storage p
193+
) internal view returns (bool) {
194+
return _now() >= p.expirationTime || _canBeResolvedEarly(p);
195+
}
196+
197+
/// @notice Return true if the current vote totals satisfy the passing condition.
198+
function _hasSucceeded(
199+
Proposal storage p
200+
) internal view returns (bool) {
201+
return p.yesVotes > p.noVotes && p.yesVotes + p.noVotes >= p.minVoteThreshold;
202+
}
203+
204+
/// @notice Compute Aptos-style default early resolution threshold.
205+
/// @dev Uses current active validator total voting power as the voting-power supply.
206+
/// The threshold is 50% + 1, matching Aptos Governance.
207+
function _defaultEarlyResolutionVoteThreshold() internal view returns (uint128) {
208+
uint256 totalVotingPower = _validatorManager().getTotalVotingPower();
209+
uint256 threshold = totalVotingPower / 2 + 1;
210+
if (threshold > type(uint128).max) {
211+
threshold = type(uint128).max;
212+
}
213+
return uint128(threshold);
214+
}
215+
176216
// ========================================================================
177217
// VIEW FUNCTIONS
178218
// ========================================================================
@@ -203,21 +243,17 @@ contract Governance is IGovernance, Ownable2Step {
203243

204244
// Check if resolved
205245
if (p.isResolved) {
206-
// Determine if it passed
207-
if (p.yesVotes > p.noVotes && p.yesVotes + p.noVotes >= p.minVoteThreshold) {
246+
if (_hasSucceeded(p)) {
208247
return ProposalState.SUCCEEDED;
209248
}
210249
return ProposalState.FAILED;
211250
}
212251

213-
// Not resolved yet
214-
uint64 now_ = _now();
215-
if (now_ < p.expirationTime) {
252+
if (!_isVotingClosed(p)) {
216253
return ProposalState.PENDING;
217254
}
218255

219-
// Voting ended but not resolved - determine outcome
220-
if (p.yesVotes > p.noVotes && p.yesVotes + p.noVotes >= p.minVoteThreshold) {
256+
if (_hasSucceeded(p)) {
221257
return ProposalState.SUCCEEDED;
222258
}
223259
return ProposalState.FAILED;
@@ -254,16 +290,14 @@ contract Governance is IGovernance, Ownable2Step {
254290
return false;
255291
}
256292

257-
uint64 now_ = _now();
258-
259-
// Can resolve if voting period ended
260-
if (now_ < p.expirationTime) {
293+
if (!_isVotingClosed(p)) {
261294
return false;
262295
}
263296

264297
// Atomicity guard: resolution must happen strictly after the last vote
265298
// This prevents flash loan attacks where someone borrows tokens, votes, and resolves in the same tx
266299
uint64 lastVote = lastVoteTime[proposalId];
300+
uint64 now_ = _now();
267301
if (lastVote > 0 && now_ <= lastVote) {
268302
return false;
269303
}
@@ -317,6 +351,17 @@ contract Governance is IGovernance, Ownable2Step {
317351
return lastVoteTime[proposalId];
318352
}
319353

354+
/// @inheritdoc IGovernance
355+
function getEarlyResolutionVoteThreshold(
356+
uint64 proposalId
357+
) external view returns (uint128) {
358+
Proposal storage p = _proposals[proposalId];
359+
if (p.id == 0) {
360+
revert Errors.ProposalNotFound(proposalId);
361+
}
362+
return p.earlyResolutionVoteThreshold;
363+
}
364+
320365
// ========================================================================
321366
// PROPOSAL MANAGEMENT
322367
// ========================================================================
@@ -368,18 +413,25 @@ contract Governance is IGovernance, Ownable2Step {
368413
// Create proposal
369414
proposalId = nextProposalId++;
370415

416+
uint128 minVoteThreshold = _config().minVotingThreshold();
417+
uint128 earlyResolutionVoteThreshold = _defaultEarlyResolutionVoteThreshold();
418+
if (earlyResolutionVoteThreshold < minVoteThreshold) {
419+
revert Errors.InvalidEarlyResolutionVoteThreshold(minVoteThreshold, earlyResolutionVoteThreshold);
420+
}
421+
371422
_proposals[proposalId] = Proposal({
372423
id: proposalId,
373424
proposer: msg.sender,
374425
executionHash: executionHash,
375426
metadataUri: metadataUri,
376427
creationTime: now_,
377428
expirationTime: expirationTime,
378-
minVoteThreshold: _config().minVotingThreshold(),
429+
minVoteThreshold: minVoteThreshold,
379430
yesVotes: 0,
380431
noVotes: 0,
381432
isResolved: false,
382-
resolutionTime: 0
433+
resolutionTime: 0,
434+
earlyResolutionVoteThreshold: earlyResolutionVoteThreshold
383435
});
384436

385437
emit ProposalCreated(proposalId, msg.sender, stakePool, executionHash, metadataUri);
@@ -506,8 +558,7 @@ contract Governance is IGovernance, Ownable2Step {
506558

507559
uint64 now_ = _now();
508560

509-
// Check voting period has ended
510-
if (now_ < p.expirationTime) {
561+
if (!_isVotingClosed(p)) {
511562
revert Errors.VotingPeriodNotEnded(p.expirationTime);
512563
}
513564

@@ -625,4 +676,3 @@ contract Governance is IGovernance, Ownable2Step {
625676
}
626677
}
627678
}
628-

src/governance/IGovernance.sol

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ interface IGovernance {
124124
uint64 proposalId
125125
) external view returns (uint64);
126126

127+
/// @notice Get the early resolution vote threshold for a proposal
128+
/// @dev Returns 0 if early resolution is disabled for the proposal
129+
/// @param proposalId ID of the proposal
130+
/// @return Early resolution vote threshold
131+
function getEarlyResolutionVoteThreshold(
132+
uint64 proposalId
133+
) external view returns (uint128);
134+
127135
/// @notice Get all authorized executors
128136
/// @return Array of executor addresses
129137
function getExecutors() external view returns (address[] memory);
@@ -241,4 +249,3 @@ interface IGovernance {
241249
address executor
242250
) external;
243251
}
244-

test/unit/foundation/Types.t.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ contract TypesTest is Test {
171171
creationTime: creationTime,
172172
expirationTime: expirationTime,
173173
minVoteThreshold: 1000 ether,
174+
earlyResolutionVoteThreshold: 1000 ether,
174175
yesVotes: 0,
175176
noVotes: 0,
176177
isResolved: false,
@@ -184,6 +185,7 @@ contract TypesTest is Test {
184185
assertEq(prop.creationTime, creationTime);
185186
assertEq(prop.expirationTime, expirationTime);
186187
assertEq(prop.minVoteThreshold, 1000 ether);
188+
assertEq(prop.earlyResolutionVoteThreshold, 1000 ether);
187189
assertEq(prop.yesVotes, 0);
188190
assertEq(prop.noVotes, 0);
189191
assertEq(prop.isResolved, false);
@@ -264,11 +266,11 @@ contract TypesTest is Test {
264266
creationTime: TIMESTAMP_NOV_2023,
265267
expirationTime: TIMESTAMP_NOV_2023 + ONE_DAY_MICROS,
266268
minVoteThreshold: 1000 ether,
269+
earlyResolutionVoteThreshold: 1000 ether,
267270
yesVotes: 0,
268271
noVotes: 0,
269272
isResolved: false,
270273
resolutionTime: 0
271274
});
272275
}
273276
}
274-

test/unit/governance/Governance.t.sol

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { Ownable } from "@openzeppelin/access/Ownable.sol";
1717

1818
/// @notice Mock ValidatorManagement for testing - all pools are non-validators
1919
contract MockValidatorManagement {
20+
uint256 public totalVotingPower = 300 ether;
21+
2022
function isValidator(
2123
address
2224
) external pure returns (bool) {
@@ -28,6 +30,16 @@ contract MockValidatorManagement {
2830
) external pure returns (ValidatorStatus) {
2931
return ValidatorStatus.INACTIVE;
3032
}
33+
34+
function getTotalVotingPower() external view returns (uint256) {
35+
return totalVotingPower;
36+
}
37+
38+
function setTotalVotingPower(
39+
uint256 newTotalVotingPower
40+
) external {
41+
totalVotingPower = newTotalVotingPower;
42+
}
3143
}
3244

3345
/// @notice Mock Reconfiguration for testing - always returns not in progress
@@ -93,6 +105,7 @@ contract GovernanceTest is Test {
93105

94106
// Deploy mock ValidatorManagement - returns false for isValidator()
95107
vm.etch(SystemAddresses.VALIDATOR_MANAGER, address(new MockValidatorManagement()).code);
108+
MockValidatorManagement(SystemAddresses.VALIDATOR_MANAGER).setTotalVotingPower(300 ether);
96109

97110
// Deploy mock Reconfiguration - returns false for isTransitionInProgress()
98111
// Staking.createPool now checks reconfiguration status
@@ -219,6 +232,8 @@ contract GovernanceTest is Test {
219232
assertEq(proposal.id, proposalId);
220233
assertEq(proposal.proposer, alice);
221234
assertEq(proposal.executionHash, executionHash);
235+
assertEq(proposal.earlyResolutionVoteThreshold, 150 ether + 1);
236+
assertEq(governance.getEarlyResolutionVoteThreshold(proposalId), 150 ether + 1);
222237
assertEq(proposal.yesVotes, 0);
223238
assertEq(proposal.noVotes, 0);
224239
assertEq(proposal.isResolved, false);
@@ -234,6 +249,22 @@ contract GovernanceTest is Test {
234249
governance.createProposal(invalidPool, targets, datas, "ipfs://test");
235250
}
236251

252+
function test_RevertWhen_CreateProposalEarlyResolutionThresholdBelowMinThreshold() public {
253+
address pool = _createStakePool(alice, 100 ether);
254+
MockValidatorManagement(SystemAddresses.VALIDATOR_MANAGER).setTotalVotingPower(100 ether);
255+
256+
(address[] memory targets, bytes[] memory datas) = _toArrays(address(mockTarget), "");
257+
uint128 earlyResolutionVoteThreshold = 50 ether + 1;
258+
259+
vm.prank(alice);
260+
vm.expectRevert(
261+
abi.encodeWithSelector(
262+
Errors.InvalidEarlyResolutionVoteThreshold.selector, MIN_VOTING_THRESHOLD, earlyResolutionVoteThreshold
263+
)
264+
);
265+
governance.createProposal(pool, targets, datas, "ipfs://test");
266+
}
267+
237268
function test_RevertWhen_CreateProposalNotVoter() public {
238269
// Create pool with alice as owner
239270
address pool = _createStakePool(alice, 100 ether);
@@ -539,6 +570,36 @@ contract GovernanceTest is Test {
539570
governance.resolve(proposalId);
540571
}
541572

573+
function test_EarlyResolveRequiresValidatorMajorityThreshold() public {
574+
address pool = _createStakePool(alice, 200 ether);
575+
576+
(address[] memory targets, bytes[] memory datas) = _toArrays(address(mockTarget), "");
577+
578+
vm.prank(alice);
579+
uint64 proposalId = governance.createProposal(pool, targets, datas, "ipfs://test");
580+
581+
assertEq(governance.getEarlyResolutionVoteThreshold(proposalId), 150 ether + 1);
582+
583+
vm.prank(alice);
584+
governance.vote(pool, proposalId, MIN_VOTING_THRESHOLD, true);
585+
586+
assertEq(uint8(governance.getProposalState(proposalId)), uint8(ProposalState.PENDING));
587+
assertFalse(governance.canResolve(proposalId));
588+
589+
vm.prank(alice);
590+
governance.vote(pool, proposalId, 50 ether + 1, true);
591+
592+
assertEq(uint8(governance.getProposalState(proposalId)), uint8(ProposalState.SUCCEEDED));
593+
594+
_advanceTime(1);
595+
assertTrue(governance.canResolve(proposalId));
596+
597+
governance.resolve(proposalId);
598+
Proposal memory proposal = governance.getProposal(proposalId);
599+
assertTrue(proposal.isResolved);
600+
assertEq(uint8(governance.getProposalState(proposalId)), uint8(ProposalState.SUCCEEDED));
601+
}
602+
542603
// ========================================================================
543604
// EXECUTE TESTS
544605
// ========================================================================
@@ -1689,4 +1750,3 @@ contract MockTarget {
16891750
revert("MockTarget: always reverts");
16901751
}
16911752
}
1692-

0 commit comments

Comments
 (0)