-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add emergency execution (#104)
* feat: add emergency execution * refactor: emergencyQuorum has priority over quorum; return when emergency quorum is rejected but still under maxEndTimestamp * refactor: move len checks to top of initializer * feat: check array lengths match * chore: updated tests * chore: increase coverage * chore: increase coverage --------- Co-authored-by: Orland0x <[email protected]> Co-authored-by: Orlando <[email protected]>
- Loading branch information
1 parent
abdc1bd
commit 9a4640f
Showing
3 changed files
with
323 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,7 +7,7 @@ coverage: | |
project: | ||
default: | ||
target: auto | ||
threshold: 1% | ||
threshold: 2% | ||
patch: | ||
default: | ||
target: 0% |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
// SPDX-License-Identifier: MIT | ||
|
||
pragma solidity ^0.8.18; | ||
|
||
import { IExecutionStrategy } from "../interfaces/IExecutionStrategy.sol"; | ||
import { FinalizationStatus, Proposal, ProposalStatus } from "../types.sol"; | ||
|
||
abstract contract EmergencyQuorumStrategy is IExecutionStrategy { | ||
uint256 public immutable quorum; | ||
uint256 public immutable emergencyQuorum; | ||
|
||
constructor(uint256 _quorum, uint256 _emergencyQuorum) { | ||
quorum = _quorum; | ||
emergencyQuorum = _emergencyQuorum; | ||
} | ||
|
||
function execute( | ||
Proposal memory proposal, | ||
uint256 votesFor, | ||
uint256 votesAgainst, | ||
uint256 votesAbstain, | ||
bytes memory payload | ||
) external virtual override; | ||
|
||
// solhint-disable-next-line code-complexity | ||
function getProposalStatus( | ||
Proposal memory proposal, | ||
uint256 votesFor, | ||
uint256 votesAgainst, | ||
uint256 votesAbstain | ||
) public view override returns (ProposalStatus) { | ||
bool emergencyQuorumReached = _quorumReached(emergencyQuorum, votesFor, votesAgainst, votesAbstain); | ||
|
||
bool accepted = _quorumReached(quorum, votesFor, votesAgainst, votesAbstain) && | ||
_supported(votesFor, votesAgainst); | ||
|
||
if (proposal.finalizationStatus == FinalizationStatus.Cancelled) { | ||
return ProposalStatus.Cancelled; | ||
} else if (proposal.finalizationStatus == FinalizationStatus.Executed) { | ||
return ProposalStatus.Executed; | ||
} else if (block.timestamp < proposal.startTimestamp) { | ||
return ProposalStatus.VotingDelay; | ||
} else if (emergencyQuorumReached) { | ||
if (_supported(votesFor, votesAgainst)) { | ||
// Proposal is supported | ||
if (block.timestamp < proposal.maxEndTimestamp) { | ||
// New votes can still come in so return `VotingPeriodAccepted`. | ||
return ProposalStatus.VotingPeriodAccepted; | ||
} else { | ||
// No new votes can't come in, so it's definitely accepted. | ||
return ProposalStatus.Accepted; | ||
} | ||
} else { | ||
// Proposal is not supported | ||
if (block.timestamp < proposal.maxEndTimestamp) { | ||
// New votes might still come in so return `VotingPeriod`. | ||
return ProposalStatus.VotingPeriod; | ||
} else { | ||
// New votes can't come in, so it's definitely rejected. | ||
return ProposalStatus.Rejected; | ||
} | ||
} | ||
} else if (block.timestamp < proposal.minEndTimestamp) { | ||
// Proposal has not reached minEndTimestamp yet. | ||
return ProposalStatus.VotingPeriod; | ||
} else if (block.timestamp < proposal.maxEndTimestamp) { | ||
// Timestamp is between minEndTimestamp and maxEndTimestamp | ||
if (accepted) { | ||
return ProposalStatus.VotingPeriodAccepted; | ||
} else { | ||
return ProposalStatus.VotingPeriod; | ||
} | ||
} else if (accepted) { | ||
// Quorum reached and proposal supported: no new votes will come in so the proposal is | ||
// definitely accepted. | ||
return ProposalStatus.Accepted; | ||
} else { | ||
// Quorum not reached reached or proposal supported: no new votes will come in so the proposal is | ||
// definitely rejected. | ||
return ProposalStatus.Rejected; | ||
} | ||
} | ||
|
||
function _quorumReached( | ||
uint256 _quorum, | ||
uint256 _votesFor, | ||
uint256 _votesAgainst, | ||
uint256 _votesAbstain | ||
) internal pure returns (bool) { | ||
uint256 totalVotes = _votesFor + _votesAgainst + _votesAbstain; | ||
return totalVotes >= _quorum; | ||
} | ||
|
||
function _supported(uint256 _votesFor, uint256 _votesAgainst) internal pure returns (bool) { | ||
return _votesFor > _votesAgainst; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
// SPDX-License-Identifier: UNLICENSED | ||
|
||
pragma solidity ^0.8.18; | ||
|
||
import { SpaceTest } from "./utils/Space.t.sol"; | ||
import { Choice, IndexedStrategy, Proposal, ProposalStatus, Strategy, UpdateSettingsInput } from "../src/types.sol"; | ||
import { EmergencyQuorumStrategy } from "../src/execution-strategies/EmergencyQuorumStrategy.sol"; | ||
|
||
contract EmergencyQuorumExec is EmergencyQuorumStrategy { | ||
uint256 internal numExecuted; | ||
|
||
// solhint-disable-next-line no-empty-blocks | ||
constructor(uint256 _quorum, uint256 _emergencyQuorum) EmergencyQuorumStrategy(_quorum, _emergencyQuorum) {} | ||
|
||
function execute( | ||
Proposal memory proposal, | ||
uint256 votesFor, | ||
uint256 votesAgainst, | ||
uint256 votesAbstain, | ||
bytes memory payload | ||
) external override { | ||
ProposalStatus proposalStatus = getProposalStatus(proposal, votesFor, votesAgainst, votesAbstain); | ||
if ((proposalStatus != ProposalStatus.Accepted) && (proposalStatus != ProposalStatus.VotingPeriodAccepted)) { | ||
revert InvalidProposalStatus(proposalStatus); | ||
} | ||
// Check that the execution payload matches the payload supplied when the proposal was created | ||
if (proposal.executionPayloadHash != keccak256(payload)) revert InvalidPayload(); | ||
numExecuted++; | ||
} | ||
|
||
function getStrategyType() external pure returns (string memory) { | ||
return "EmergencyQuorumExecution"; | ||
} | ||
} | ||
|
||
contract EmergencyQuorumTest is SpaceTest { | ||
Strategy internal emergencyStrategy; | ||
uint256 internal emergencyQuorum = 2; | ||
EmergencyQuorumExec internal emergency; | ||
|
||
function setUp() public override { | ||
super.setUp(); | ||
|
||
emergency = new EmergencyQuorumExec(quorum, emergencyQuorum); | ||
emergencyStrategy = Strategy(address(emergency), new bytes(0)); | ||
|
||
minVotingDuration = 100; | ||
space.updateSettings( | ||
UpdateSettingsInput( | ||
minVotingDuration, | ||
NO_UPDATE_UINT32, | ||
NO_UPDATE_UINT32, | ||
NO_UPDATE_STRING, | ||
NO_UPDATE_STRING, | ||
NO_UPDATE_STRATEGY, | ||
NO_UPDATE_STRING, | ||
NO_UPDATE_ADDRESSES, | ||
NO_UPDATE_ADDRESSES, | ||
NO_UPDATE_STRATEGIES, | ||
NO_UPDATE_STRINGS, | ||
NO_UPDATE_UINT8S | ||
) | ||
); | ||
} | ||
|
||
function testEmergencyQuorum() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
_vote(address(42), proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 2 | ||
|
||
vm.expectEmit(true, true, true, true); | ||
emit ProposalExecuted(proposalId); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
|
||
assertEq(uint8(space.getProposalStatus(proposalId)), uint8(ProposalStatus.Executed)); | ||
} | ||
|
||
function testEmergencyQuorumNotReached() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
|
||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.VotingPeriod))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumAfterMinDuration() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
|
||
vm.warp(block.timestamp + minVotingDuration); | ||
|
||
vm.expectEmit(true, true, true, true); | ||
emit ProposalExecuted(proposalId); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumAfterMaxDuration() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
|
||
vm.warp(block.timestamp + maxVotingDuration); | ||
|
||
vm.expectEmit(true, true, true, true); | ||
emit ProposalExecuted(proposalId); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumReachedButRejected() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
|
||
// Cast two votes AGAINST | ||
_vote(author, proposalId, Choice.Against, userVotingStrategies, voteMetadataURI); // 1 | ||
_vote(address(42), proposalId, Choice.Against, userVotingStrategies, voteMetadataURI); // 2 | ||
|
||
// EmergencyQuorum should've been reached but with only `AGAINST` votes, so proposal status should be | ||
// `VotingPeriod`. | ||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.VotingPeriod))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
|
||
// Now forward to `maxEndTimestamp`, the proposal should be finalized and `Rejected`. | ||
vm.warp(block.timestamp + maxVotingDuration); | ||
|
||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.Rejected))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumLowerThanQuorum() public { | ||
EmergencyQuorumExec emergencyQuorumExec = new EmergencyQuorumExec(quorum, quorum - 1); | ||
|
||
emergencyStrategy = Strategy(address(emergencyQuorumExec), new bytes(0)); | ||
|
||
// Create proposal and vote | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // emergencyQuorum reached | ||
vm.warp(block.timestamp + maxVotingDuration); | ||
|
||
vm.expectEmit(true, true, true, true); | ||
emit ProposalExecuted(proposalId); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumVotingPeriod() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
|
||
// Cast two votes AGAINST | ||
_vote(author, proposalId, Choice.Against, userVotingStrategies, voteMetadataURI); // 1 | ||
_vote(address(42), proposalId, Choice.Against, userVotingStrategies, voteMetadataURI); // 2 | ||
|
||
// EmergencyQuorum should've been reached but with only `AGAINST` votes, so proposal status should be | ||
// `VotingPeriod`. | ||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.VotingPeriod))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumCancelled() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
|
||
space.cancel(proposalId); | ||
|
||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.Cancelled))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testEmergencyQuorumAlreadyExecuted() public { | ||
uint256 proposalId = _createProposal( | ||
author, | ||
proposalMetadataURI, | ||
emergencyStrategy, | ||
abi.encode(userVotingStrategies) | ||
); | ||
_vote(author, proposalId, Choice.For, userVotingStrategies, voteMetadataURI); // 1 | ||
|
||
vm.warp(block.timestamp + minVotingDuration); | ||
|
||
space.execute(proposalId, emergencyStrategy.params); | ||
|
||
vm.expectRevert(abi.encodeWithSelector(InvalidProposalStatus.selector, uint8(ProposalStatus.Executed))); | ||
space.execute(proposalId, emergencyStrategy.params); | ||
} | ||
|
||
function testGetStrategyType() public { | ||
assertEq(emergency.getStrategyType(), "EmergencyQuorumExecution"); | ||
} | ||
} |