diff --git a/.semgrep/rules/sol-rules.yaml b/.semgrep/rules/sol-rules.yaml index bea5f9b6b4e70..63ddba50bddd3 100644 --- a/.semgrep/rules/sol-rules.yaml +++ b/.semgrep/rules/sol-rules.yaml @@ -331,6 +331,7 @@ rules: - packages/contracts-bedrock/src/dispute/SuperFaultDisputeGame.sol - packages/contracts-bedrock/src/dispute/SuperPermissionedDisputeGame.sol - packages/contracts-bedrock/src/governance/MintManager.sol + - packages/contracts-bedrock/src/governance/ProposalValidator.sol - packages/contracts-bedrock/src/periphery/TransferOnion.sol - packages/contracts-bedrock/src/periphery/faucet/Faucet.sol - packages/contracts-bedrock/src/periphery/faucet/authmodules/AdminFaucetAuthModule.sol diff --git a/packages/contracts-bedrock/interfaces/governance/IApprovalVotingModule.sol b/packages/contracts-bedrock/interfaces/governance/IApprovalVotingModule.sol new file mode 100644 index 0000000000000..f17217e873095 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/governance/IApprovalVotingModule.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title IApprovalVotingModule +/// @notice Interface for the Approval Voting Module containing only the essential types +/// needed by the ProposalValidator contract. +interface IApprovalVotingModule { + struct ProposalOption { + uint256 budgetTokensSpent; + address[] targets; + uint256[] values; + bytes[] calldatas; + string description; + } + + struct ProposalSettings { + uint8 maxApprovals; + uint8 criteria; + address budgetToken; + uint128 criteriaValue; + uint128 budgetAmount; + } + + enum PassingCriteria { + Threshold, + TopChoices + } +} diff --git a/packages/contracts-bedrock/interfaces/governance/IOptimismGovernor.sol b/packages/contracts-bedrock/interfaces/governance/IOptimismGovernor.sol new file mode 100644 index 0000000000000..dfd1af85fede5 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/governance/IOptimismGovernor.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IVotesUpgradeable} from "@openzeppelin/contracts-upgradeable/governance/utils/IVotesUpgradeable.sol"; + +interface IOptimismGovernor { + function propose( + address[] memory targets, + uint256[] memory values, + bytes[] memory calldatas, + string memory description, + uint8 proposalType + ) external returns (uint256 proposalId); + + function proposeWithModule( + address module, + bytes memory proposalData, + string memory description, + uint8 proposalType + ) external returns (uint256 proposalId); + + function timelock() external view returns (address); + + function PROPOSAL_TYPES_CONFIGURATOR() external view returns (address); + + function token() external view returns (IVotesUpgradeable); + + function getProposalType(uint256 proposalId) external view returns (uint8); + + function proposalVotes(uint256 proposalId) + external + view + returns (uint256 againstVotes, uint256 forVotes, uint256 abstainVotes); + + /// @notice Returns the snapshot block number for a proposal, 0 if proposal doesn't exist + /// @param proposalId The ID of the proposal + /// @return The snapshot block number, or 0 if proposal doesn't exist + function proposalSnapshot(uint256 proposalId) external view returns (uint256); +} \ No newline at end of file diff --git a/packages/contracts-bedrock/interfaces/governance/IOptimisticModule.sol b/packages/contracts-bedrock/interfaces/governance/IOptimisticModule.sol new file mode 100644 index 0000000000000..8b03a9986dfc8 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/governance/IOptimisticModule.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title IOptimisticModule +/// @notice Interface for the Optimistic Module containing only the essential types +/// needed by the ProposalValidator contract. +interface IOptimisticModule { + struct ProposalSettings { + uint248 againstThreshold; + bool isRelativeToVotableSupply; + } +} \ No newline at end of file diff --git a/packages/contracts-bedrock/interfaces/governance/IProposalTypesConfigurator.sol b/packages/contracts-bedrock/interfaces/governance/IProposalTypesConfigurator.sol new file mode 100644 index 0000000000000..17e92cd5d5bc9 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/governance/IProposalTypesConfigurator.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IProposalTypesConfigurator { + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error InvalidQuorum(); + error InvalidApprovalThreshold(); + error NotManagerOrTimelock(); + error AlreadyInit(); + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event ProposalTypeSet( + uint8 indexed proposalTypeId, uint16 quorum, uint16 approvalThreshold, string name, string description + ); + + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + struct ProposalType { + uint16 quorum; + uint16 approvalThreshold; + string name; + string description; + address module; + } + + /*////////////////////////////////////////////////////////////// + FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function initialize(address _governor, ProposalType[] calldata _proposalTypes) external; + + function proposalTypes(uint8 proposalTypeId) external view returns (ProposalType memory); + + function setProposalType( + uint8 proposalTypeId, + uint16 quorum, + uint16 approvalThreshold, + string memory name, + string memory description, + address module + ) external; +} diff --git a/packages/contracts-bedrock/interfaces/governance/IProposalValidator.sol b/packages/contracts-bedrock/interfaces/governance/IProposalValidator.sol new file mode 100644 index 0000000000000..48a26114c51fc --- /dev/null +++ b/packages/contracts-bedrock/interfaces/governance/IProposalValidator.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// Interfaces +import { IOptimismGovernor } from "./IOptimismGovernor.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; + +/// @title IProposalValidator +/// @notice Interface for the ProposalValidator contract. +interface IProposalValidator is ISemver { + error ProposalValidator_InsufficientApprovals(); + error ProposalValidator_ProposalAlreadyApproved(); + error ProposalValidator_ProposalAlreadySubmitted(); + error ProposalValidator_ProposalAlreadyMovedToVote(); + error ProposalValidator_InvalidAttestation(); + error ProposalValidator_VotingCycleAlreadySet(); + error ProposalValidator_ProposalDoesNotExist(); + error ProposalValidator_ProposalTypesDataLengthMismatch(); + error ProposalValidator_InvalidFundingProposalType(); + error ProposalValidator_ExceedsDistributionThreshold(); + error ProposalValidator_InvalidOptionsLength(); + error ProposalValidator_AttestationRevoked(); + error ProposalValidator_AttestationExpired(); + error ProposalValidator_InvalidAttestationSchema(); + error ProposalValidator_InvalidCriteriaValue(); + error ProposalValidator_InvalidAgainstThreshold(); + error ProposalValidator_InvalidUpgradeProposalType(); + error ProposalValidator_InvalidVotingCycle(); + error ProposalValidator_ProposalIdMismatch(); + error ProposalValidator_InvalidProposer(); + error ProposalValidator_InvalidProposal(); + error ProposalValidator_InvalidVotingModule(); + error ProposalValidator_InvalidTotalBudget(); + error ProposalValidator_AttestationCreatedAfterLastVotingCycle(); + error ProposalValidator_PreviousVotingCycleNotStarted(); + + event ProposalSubmitted( + uint256 indexed proposalId, + address indexed proposer, + string description, + ProposalType proposalType + ); + + event ProposalApproved( + uint256 indexed proposalId, + address indexed approver + ); + + event ProposalMovedToVote( + uint256 indexed proposalId, + address indexed executor + ); + + event VotingCycleDataSet( + uint256 cycleNumber, + uint256 startingTimestamp, + uint256 duration, + uint256 votingCycleDistributionLimit + ); + + event ProposalDistributionThresholdSet(uint256 newProposalDistributionThreshold); + + event ProposalTypeDataSet( + ProposalType proposalType, + uint256 requiredApprovals, + uint8 idInConfigurator + ); + + event ProposalVotingModuleData( + uint256 indexed proposalId, + bytes encodedVotingModuleData + ); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + struct ProposalData { + address proposer; + ProposalType proposalType; + bool movedToVote; + mapping(address => bool) delegateApprovals; + uint256 approvalCount; + uint256 votingCycle; + } + + struct ProposalTypeData { + uint256 requiredApprovals; + uint8 idInConfigurator; + } + + struct VotingCycleData { + uint256 startingTimestamp; + uint256 duration; + uint256 votingCycleDistributionLimit; + uint256 movedToVoteTokenCount; + } + + enum ProposalType { + ProtocolOrGovernorUpgrade, + MaintenanceUpgrade, + CouncilMemberElections, + GovernanceFund, + CouncilBudget + } + + function submitUpgradeProposal( + uint248 _againstThreshold, + string memory _proposalDescription, + bytes32 _attestationUid, + ProposalType _proposalType, + uint256 _latestVotingCycle + ) external returns (uint256 proposalId_); + + function submitCouncilMemberElectionsProposal( + uint128 _criteriaValue, + string[] memory _optionDescriptions, + string memory _proposalDescription, + bytes32 _attestationUid, + uint256 _votingCycle + ) external returns (uint256 proposalId_); + + function submitFundingProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + address[] memory _optionsRecipients, + uint256[] memory _optionsAmounts, + string memory _description, + ProposalType _proposalType, + uint256 _votingCycle + ) external returns (uint256 proposalId_); + + function approveProposal(uint256 _proposalId, bytes32 _attestationUid) external; + + function moveToVoteProtocolOrGovernorUpgradeProposal( + uint248 _againstThreshold, + string memory _proposalDescription + ) external returns (uint256 proposalId_); + + function moveToVoteCouncilMemberElectionsProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + string memory _proposalDescription + ) external returns (uint256 proposalId_); + + function moveToVoteFundingProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + address[] memory _optionsRecipients, + uint256[] memory _optionsAmounts, + string memory _description, + ProposalType _proposalType + ) external returns (uint256 proposalId_); + + function setVotingCycleData( + uint256 _cycleNumber, + uint256 _startingTimestamp, + uint256 _duration, + uint256 _votingCycleDistributionLimit + ) external; + + function setProposalDistributionThreshold(uint256 _proposalDistributionThreshold) external; + + function setProposalTypeData( + ProposalType _proposalType, + ProposalTypeData memory _proposalTypeData + ) external; + + function renounceOwnership() external; + + function transferOwnership(address newOwner) external; + + function proposalDistributionThreshold() external view returns (uint256); + + function GOVERNOR() external view returns (IOptimismGovernor); + + function owner() external view returns (address); + + + function APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID() external view returns (bytes32); + + function TOP_DELEGATES_ATTESTATION_SCHEMA_UID() external view returns (bytes32); + + function OPTIMISTIC_MODULE_PERCENT_DIVISOR() external view returns (uint256); + + function proposalTypesData(ProposalType) external view returns (uint256 requiredApprovals, uint8 idInConfigurator); + + function votingCycles(uint256) external view returns ( + uint256 startingTimestamp, + uint256 duration, + uint256 votingCycleDistributionLimit, + uint256 movedToVoteTokenCount + ); + + function __constructor__( + address _owner, + IOptimismGovernor _governor, + bytes32 _approvedProposerAttestationSchemaUid, + bytes32 _topDelegatesAttestationSchemaUid + ) external; +} diff --git a/packages/contracts-bedrock/snapshots/abi/ProposalValidator.json b/packages/contracts-bedrock/snapshots/abi/ProposalValidator.json new file mode 100644 index 0000000000000..0dc60b6f83933 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/ProposalValidator.json @@ -0,0 +1,811 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_owner", + "type": "address" + }, + { + "internalType": "contract IOptimismGovernor", + "name": "_governor", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_approvedProposerAttestationSchemaUid", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "_topDelegatesAttestationSchemaUid", + "type": "bytes32" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "GOVERNOR", + "outputs": [ + { + "internalType": "contract IOptimismGovernor", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "OPTIMISTIC_MODULE_PERCENT_DIVISOR", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "TOP_DELEGATES_ATTESTATION_SCHEMA_UID", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_proposalId", + "type": "uint256" + }, + { + "internalType": "bytes32", + "name": "_attestationUid", + "type": "bytes32" + } + ], + "name": "approveProposal", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint128", + "name": "_criteriaValue", + "type": "uint128" + }, + { + "internalType": "string[]", + "name": "_optionsDescriptions", + "type": "string[]" + }, + { + "internalType": "string", + "name": "_proposalDescription", + "type": "string" + } + ], + "name": "moveToVoteCouncilMemberElectionsProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint128", + "name": "_criteriaValue", + "type": "uint128" + }, + { + "internalType": "string[]", + "name": "_optionsDescriptions", + "type": "string[]" + }, + { + "internalType": "address[]", + "name": "_optionsRecipients", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_optionsAmounts", + "type": "uint256[]" + }, + { + "internalType": "string", + "name": "_description", + "type": "string" + }, + { + "internalType": "enum ProposalValidator.ProposalType", + "name": "_proposalType", + "type": "uint8" + } + ], + "name": "moveToVoteFundingProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint248", + "name": "_againstThreshold", + "type": "uint248" + }, + { + "internalType": "string", + "name": "_proposalDescription", + "type": "string" + } + ], + "name": "moveToVoteProtocolOrGovernorUpgradeProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proposalDistributionThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "enum ProposalValidator.ProposalType", + "name": "", + "type": "uint8" + } + ], + "name": "proposalTypesData", + "outputs": [ + { + "internalType": "uint256", + "name": "requiredApprovals", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "idInConfigurator", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_proposalDistributionThreshold", + "type": "uint256" + } + ], + "name": "setProposalDistributionThreshold", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "enum ProposalValidator.ProposalType", + "name": "_proposalType", + "type": "uint8" + }, + { + "components": [ + { + "internalType": "uint256", + "name": "requiredApprovals", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "idInConfigurator", + "type": "uint8" + } + ], + "internalType": "struct ProposalValidator.ProposalTypeData", + "name": "_proposalTypeData", + "type": "tuple" + } + ], + "name": "setProposalTypeData", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_cycleNumber", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_startingTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_duration", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_votingCycleDistributionLimit", + "type": "uint256" + } + ], + "name": "setVotingCycleData", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint128", + "name": "_criteriaValue", + "type": "uint128" + }, + { + "internalType": "string[]", + "name": "_optionDescriptions", + "type": "string[]" + }, + { + "internalType": "string", + "name": "_proposalDescription", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "_attestationUid", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_votingCycle", + "type": "uint256" + } + ], + "name": "submitCouncilMemberElectionsProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint128", + "name": "_criteriaValue", + "type": "uint128" + }, + { + "internalType": "string[]", + "name": "_optionsDescriptions", + "type": "string[]" + }, + { + "internalType": "address[]", + "name": "_optionsRecipients", + "type": "address[]" + }, + { + "internalType": "uint256[]", + "name": "_optionsAmounts", + "type": "uint256[]" + }, + { + "internalType": "string", + "name": "_description", + "type": "string" + }, + { + "internalType": "enum ProposalValidator.ProposalType", + "name": "_proposalType", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_votingCycle", + "type": "uint256" + } + ], + "name": "submitFundingProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint248", + "name": "_againstThreshold", + "type": "uint248" + }, + { + "internalType": "string", + "name": "_proposalDescription", + "type": "string" + }, + { + "internalType": "bytes32", + "name": "_attestationUid", + "type": "bytes32" + }, + { + "internalType": "enum ProposalValidator.ProposalType", + "name": "_proposalType", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_latestVotingCycle", + "type": "uint256" + } + ], + "name": "submitUpgradeProposal", + "outputs": [ + { + "internalType": "uint256", + "name": "proposalId_", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "votingCycles", + "outputs": [ + { + "internalType": "uint256", + "name": "startingTimestamp", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "duration", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "votingCycleDistributionLimit", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "movedToVoteTokenCount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ProposalApproved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "newProposalDistributionThreshold", + "type": "uint256" + } + ], + "name": "ProposalDistributionThresholdSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "executor", + "type": "address" + } + ], + "name": "ProposalMovedToVote", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "proposer", + "type": "address" + }, + { + "indexed": false, + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "indexed": false, + "internalType": "enum ProposalValidator.ProposalType", + "name": "proposalType", + "type": "uint8" + } + ], + "name": "ProposalSubmitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "enum ProposalValidator.ProposalType", + "name": "proposalType", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "requiredApprovals", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "idInConfigurator", + "type": "uint8" + } + ], + "name": "ProposalTypeDataSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "proposalId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes", + "name": "encodedVotingModuleData", + "type": "bytes" + } + ], + "name": "ProposalVotingModuleData", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint256", + "name": "cycleNumber", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startingTimestamp", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "duration", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "votingCycleDistributionLimit", + "type": "uint256" + } + ], + "name": "VotingCycleDataSet", + "type": "event" + }, + { + "inputs": [], + "name": "ProposalValidator_AttestationCreatedAfterLastVotingCycle", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_AttestationExpired", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_AttestationRevoked", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ExceedsDistributionThreshold", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InsufficientApprovals", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidAgainstThreshold", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidAttestation", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidAttestationSchema", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidCriteriaValue", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidFundingProposalType", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidOptionsLength", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidProposal", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidProposer", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidTotalBudget", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidUpgradeProposalType", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidVotingCycle", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_InvalidVotingModule", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_PreviousVotingCycleNotStarted", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalAlreadyApproved", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalAlreadyMovedToVote", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalAlreadySubmitted", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalDoesNotExist", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalIdMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_ProposalTypesDataLengthMismatch", + "type": "error" + }, + { + "inputs": [], + "name": "ProposalValidator_VotingCycleAlreadySet", + "type": "error" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index e7faca31901e0..a75dbb27efdae 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -171,6 +171,10 @@ "initCodeHash": "0x9c954076097eb80f70333a387f12ba190eb9374aebb923ce30ecfe1d17030cc0", "sourceCodeHash": "0x9baa0f9e744cc0ecc61d0fade8bffc18321b228833ea0904dc645f3975be9ed1" }, + "src/governance/ProposalValidator.sol:ProposalValidator": { + "initCodeHash": "0x3d2dc05bda4ddd8412b16da644ef8d2cbffb0b93c9ba326639ae73c7eedd890d", + "sourceCodeHash": "0x6f62e5285eb2d328f053d168dd7312d16b1557f9e9a08025c18f1916367c4b26" + }, "src/legacy/DeployerWhitelist.sol:DeployerWhitelist": { "initCodeHash": "0x53099379ed48b87f027d55712dbdd1da7d7099925426eb0531da9c0012e02c29", "sourceCodeHash": "0xf22c94ed20c32a8ed2705a22d12c6969c3c3bad409c4efe2f95b0db74f210e10" diff --git a/packages/contracts-bedrock/snapshots/storageLayout/ProposalValidator.json b/packages/contracts-bedrock/snapshots/storageLayout/ProposalValidator.json new file mode 100644 index 0000000000000..683eee9d9b1a8 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/ProposalValidator.json @@ -0,0 +1,37 @@ +[ + { + "bytes": "20", + "label": "_owner", + "offset": 0, + "slot": "0", + "type": "address" + }, + { + "bytes": "32", + "label": "proposalDistributionThreshold", + "offset": 0, + "slot": "1", + "type": "uint256" + }, + { + "bytes": "32", + "label": "votingCycles", + "offset": 0, + "slot": "2", + "type": "mapping(uint256 => struct ProposalValidator.VotingCycleData)" + }, + { + "bytes": "32", + "label": "proposalTypesData", + "offset": 0, + "slot": "3", + "type": "mapping(enum ProposalValidator.ProposalType => struct ProposalValidator.ProposalTypeData)" + }, + { + "bytes": "32", + "label": "_proposals", + "offset": 0, + "slot": "4", + "type": "mapping(uint256 => struct ProposalValidator.ProposalData)" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/governance/ProposalValidator.sol b/packages/contracts-bedrock/src/governance/ProposalValidator.sol new file mode 100644 index 0000000000000..30adec8219a66 --- /dev/null +++ b/packages/contracts-bedrock/src/governance/ProposalValidator.sol @@ -0,0 +1,1099 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Contracts +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// Libraries +import { Predeploys } from "src/libraries/Predeploys.sol"; + +// Interfaces +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { IOptimismGovernor } from "interfaces/governance/IOptimismGovernor.sol"; +import { IProposalTypesConfigurator } from "interfaces/governance/IProposalTypesConfigurator.sol"; +import { IEAS, Attestation } from "src/vendor/eas/IEAS.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IApprovalVotingModule } from "interfaces/governance/IApprovalVotingModule.sol"; +import { IOptimisticModule } from "interfaces/governance/IOptimisticModule.sol"; + +/// @title ProposalValidator +/// @notice The ProposalValidator contract is responsible for validating proposals and moving +/// them to the vote phase on the Optimism Governor. +contract ProposalValidator is Ownable, ISemver { + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + /// @notice Thrown when a proposal doesn't have enough delegate approvals to move to vote. + error ProposalValidator_InsufficientApprovals(); + + /// @notice Thrown when a delegate attempts to approve a proposal they've already approved. + error ProposalValidator_ProposalAlreadyApproved(); + + /// @notice Thrown when attempting to move a proposal to vote that is already in voting. + error ProposalValidator_ProposalAlreadySubmitted(); + + /// @notice Thrown when attempting to move a proposal to vote that is already in voting. + error ProposalValidator_ProposalAlreadyMovedToVote(); + + /// @notice Thrown when an invalid attestation is provided for a proposal. + error ProposalValidator_InvalidAttestation(); + + /// @notice Thrown when a voting cycle is already set. + error ProposalValidator_VotingCycleAlreadySet(); + + /// @notice Thrown when a proposal does not exist. + error ProposalValidator_ProposalDoesNotExist(); + + /// @notice Thrown when the length of the proposal types and proposal types data arrays do not match. + error ProposalValidator_ProposalTypesDataLengthMismatch(); + + /// @notice Thrown when the proposal type is not valid for funding proposals. + error ProposalValidator_InvalidFundingProposalType(); + + /// @notice Thrown when the requested amount exceeds the distribution threshold. + error ProposalValidator_ExceedsDistributionThreshold(); + + /// @notice Thrown when the options length is invalid (zero or exceeds uint8 max). + error ProposalValidator_InvalidOptionsLength(); + + /// @notice Thrown when an attestation is revoked. + error ProposalValidator_AttestationRevoked(); + + /// @notice Thrown when the attestation is expired. + error ProposalValidator_AttestationExpired(); + + /// @notice Thrown when an attestation schema is invalid. + error ProposalValidator_InvalidAttestationSchema(); + + /// @notice Thrown when the criteria value is invalid for council elections (must not exceed options length). + error ProposalValidator_InvalidCriteriaValue(); + + /// @notice Thrown when the against threshold is invalid (must be > 0 and <= 10000 basis points). + error ProposalValidator_InvalidAgainstThreshold(); + + /// @notice Thrown when an invalid proposal type is provided for upgrade proposals. + error ProposalValidator_InvalidUpgradeProposalType(); + + /// @notice Thrown when the trying to move a proposal to vote outside of the accepted voting cycle. + error ProposalValidator_InvalidVotingCycle(); + + /// @notice Thrown when the proposalId returned by the Governor does not match the expected proposalId. + error ProposalValidator_ProposalIdMismatch(); + + /// @notice Thrown when the caller is not the proposer. + error ProposalValidator_InvalidProposer(); + + /// @notice Thrown when the proposal is invalid trying to move to vote. + error ProposalValidator_InvalidProposal(); + + /// @notice Thrown when the voting module address is invalid. + error ProposalValidator_InvalidVotingModule(); + + /// @notice Thrown when the total budget is invalid (must be > 0 and <= uint128 max). + error ProposalValidator_InvalidTotalBudget(); + + /// @notice Thrown when the attestation was created after the last voting cycle. + error ProposalValidator_AttestationCreatedAfterLastVotingCycle(); + + /// @notice Thrown when trying to approve and the previous voting cycle has not started. + error ProposalValidator_PreviousVotingCycleNotStarted(); + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when a new proposal is submitted. + /// @param proposalId The ID of the submitted proposal. + /// @param proposer The address that submitted the proposal. + /// @param description Description of the proposal. + /// @param proposalType Type of the proposal. + event ProposalSubmitted( + uint256 indexed proposalId, address indexed proposer, string description, ProposalType proposalType + ); + + /// @notice Emitted when a delegate approves a proposal. + /// @param proposalId The ID of the approved proposal. + /// @param approver The address of the delegate who approved the proposal. + event ProposalApproved(uint256 indexed proposalId, address indexed approver); + + /// @notice Emitted when a proposal is moved to the voting phase in the governor contract. + /// @param proposalId The ID of the proposal moved to vote. + /// @param executor The address that executed the move to vote. + event ProposalMovedToVote(uint256 indexed proposalId, address indexed executor); + + /// @notice Emitted when the voting cycle data is set. + /// @param cycleNumber The number of the voting cycle. + /// @param startingTimestamp The starting timestamp of the voting cycle. + /// @param duration The duration of the voting cycle. + /// @param votingCycleDistributionLimit The max amount of tokens that can be distributed during the voting cycle. + event VotingCycleDataSet( + uint256 cycleNumber, uint256 startingTimestamp, uint256 duration, uint256 votingCycleDistributionLimit + ); + + /// @notice Emitted when the proposal distribution limit is set. + /// @param newProposalDistributionThreshold The new proposal distribution threshold. + event ProposalDistributionThresholdSet(uint256 newProposalDistributionThreshold); + + /// @notice Emitted when the proposal type data is set. + /// @param proposalType The type of proposal. + /// @param requiredApprovals The required number of approvals. + /// @param idInConfigurator The proposal type ID in the ProposalTypesConfigurator contract. + event ProposalTypeDataSet(ProposalType proposalType, uint256 requiredApprovals, uint8 idInConfigurator); + + /// @notice Emitted with ProposalSubmitted event. + /// @param proposalId The ID of the submitted proposal. + /// @param encodedVotingModuleData The encoded voting module data. + event ProposalVotingModuleData(uint256 indexed proposalId, bytes encodedVotingModuleData); + + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Struct for storing proposal information. + /// @param proposer The address that submitted the proposal. + /// @param proposalType Type of the proposal from the ProposalType enum. + /// @param movedToVote Whether the proposal has been proposed to the Governor for voting. + /// @param delegateApprovals Mapping of delegate addresses to their approval status. + /// @param approvalCount Number of approvals received so far. + /// @param votingCycle The voting cycle number the proposal is targetted for. + struct ProposalData { + address proposer; + ProposalType proposalType; + bool movedToVote; + mapping(address => bool) delegateApprovals; + uint256 approvalCount; + uint256 votingCycle; + } + + /// @notice Struct for storing explicit data for each proposal type. + /// @param requiredApprovals The number of approvals each proposal type requires in order to be able to move for + /// voting. + /// @param idInConfigurator The proposal type ID used to get the voting module from the configurator. + /// @dev Based on the spec document, funding and council member elections proposals are + /// configured for the ApprovalVotingModule, while the upgrade proposals are configured for the + /// OptimisticVotingModule. + /// Any change on the module used for proposals would require the Validator to be upgraded. + struct ProposalTypeData { + uint256 requiredApprovals; + uint8 idInConfigurator; + } + + /// @notice Struct for storing voting cycle data. + /// @param startingTimestamp The starting timestamp of the voting cycle. + /// @param duration The duration of the voting cycle. Should be 1 day which is the end of Week 2 and start of Week 3 + /// of the voting cycle. + /// @param votingCycleDistributionLimit The max amount of tokens that can be distributed in a proposal. + /// @param movedToVoteTokenCount The total amount of tokens to possibly be distributed in the voting cycle. + struct VotingCycleData { + uint256 startingTimestamp; + uint256 duration; + uint256 votingCycleDistributionLimit; + uint256 movedToVoteTokenCount; + } + + /*////////////////////////////////////////////////////////////// + ENUMS + //////////////////////////////////////////////////////////////*/ + + /// @notice Types of proposals that can be submitted. + /// @param ProtocolOrGovernorUpgrade Proposals for upgrading the protocol or governor. + /// @param MaintenanceUpgrade Proposals for maintenance upgrades. + /// @param CouncilMemberElections Proposals for council member elections. + /// @param GovernanceFund Proposals related to the governance fund. + /// @param CouncilBudget Proposals related to the council budget. + enum ProposalType { + ProtocolOrGovernorUpgrade, + MaintenanceUpgrade, + CouncilMemberElections, + GovernanceFund, + CouncilBudget + } + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice The divisor used for percentage calculations in optimistic voting modules. + /// @dev Represents 100% in basis points (10,000 = 100%). + uint256 public constant OPTIMISTIC_MODULE_PERCENT_DIVISOR = 10_000; + + /*////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////*/ + + /// @notice The Optimism Governor contract that will handle the voting phase. + IOptimismGovernor public immutable GOVERNOR; + + /// @notice The schema UID for attestations in the Ethereum Attestation Service for checking if the caller + /// is an approved proposer. + /// @dev Schema format: { proposalType: uint8, date: string } + bytes32 public immutable APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID; + + /// @notice The schema UID for attestations in the Ethereum Attestation Service for checking if the caller + /// is part of the top100 delegates. + bytes32 public immutable TOP_DELEGATES_ATTESTATION_SCHEMA_UID; + + /// @notice The max amount of tokens that can be distributed in a single proposal. + uint256 public proposalDistributionThreshold; + + /// @notice Mapping of voting cycle numbers to their corresponding data. + mapping(uint256 => VotingCycleData) public votingCycles; + + /// @notice Mapping of proposal types to their corresponding data. + mapping(ProposalType => ProposalTypeData) public proposalTypesData; + + /// @notice Mapping of proposal ID to their corresponding proposal data. + mapping(uint256 => ProposalData) internal _proposals; + + /// @notice Constructs the ProposalValidator contract. + /// @param _owner The address that will own the contract, should be the OP Foundation. + /// @param _governor The Optimism Governor contract address. + /// @param _approvedProposerAttestationSchemaUid The schema UID for attestations in the Ethereum Attestation Service + /// for checking if the caller + /// is an approved proposer. + /// @param _topDelegatesAttestationSchemaUid The schema UID for attestations in the Ethereum Attestation Service for + /// checking if the caller + /// is part of the top100 delegates. + constructor( + address _owner, + IOptimismGovernor _governor, + bytes32 _approvedProposerAttestationSchemaUid, + bytes32 _topDelegatesAttestationSchemaUid + ) { + _transferOwnership(_owner); + GOVERNOR = _governor; + APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID = _approvedProposerAttestationSchemaUid; + TOP_DELEGATES_ATTESTATION_SCHEMA_UID = _topDelegatesAttestationSchemaUid; + } + + /// @notice Submits a Protocol/Governor Upgrade or Maintenance Upgrade proposal. + /// @param _againstThreshold The percentage that will be used to calculate the fraction of the votable supply + /// that the proposal will need in votes against it to fail. + /// @param _proposalDescription Description of the proposal. + /// @param _attestationUid The UID of the attestation for the approved proposer. + /// @param _proposalType The type of proposal (ProtocolOrGovernorUpgrade or MaintenanceUpgrade). + /// @param _latestVotingCycle The latest voting cycle number. Even though the upgrade proposal can be submitted + /// outside of a voting cycle, we still need the latest voting cycle number to validate top delegates attestations. + /// @return proposalId_ The ID of the submitted proposal. + function submitUpgradeProposal( + uint248 _againstThreshold, + string memory _proposalDescription, + bytes32 _attestationUid, + ProposalType _proposalType, + uint256 _latestVotingCycle + ) + external + returns (uint256 proposalId_) + { + // Validate proposal type is valid for upgrade proposals + if (_proposalType != ProposalType.ProtocolOrGovernorUpgrade && _proposalType != ProposalType.MaintenanceUpgrade) + { + revert ProposalValidator_InvalidUpgradeProposalType(); + } + + // Validate voting cycle exists + if (votingCycles[_latestVotingCycle].startingTimestamp == 0) { + revert ProposalValidator_InvalidVotingCycle(); + } + + // Validate EAS attestation - must be called by owner-approved address + _validateApprovedProposerAttestation(_attestationUid, _proposalType); + + // Validate againstThreshold is non-zero and within bounds for percentage-based thresholds + if (_againstThreshold == 0 || _againstThreshold > OPTIMISTIC_MODULE_PERCENT_DIVISOR) { + revert ProposalValidator_InvalidAgainstThreshold(); + } + + // Optimistic proposals are signal-only, no execution targets/calldatas needed + bytes memory proposalVotingModuleData = abi.encode( + IOptimisticModule.ProposalSettings({ + againstThreshold: _againstThreshold, + isRelativeToVotableSupply: true // MUST always be true + }) + ); + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[_proposalType].idInConfigurator; + + // Get the optimistic module address from configurator + address votingModule; + { + IProposalTypesConfigurator.ProposalType memory proposalTypeConfig = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator); + + // Validate voting module exists + if (bytes(proposalTypeConfig.name).length == 0) { + revert ProposalValidator_InvalidVotingModule(); + } + + votingModule = proposalTypeConfig.module; + } + + // Generate unique proposal ID + proposalId_ = + _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_proposalDescription))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Prevent duplicate proposals + if (proposal.proposer != address(0)) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Check if proposal already exists in OptimismGovernor + if (GOVERNOR.proposalSnapshot(proposalId_) != 0) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Store proposal metadata + proposal.proposer = _msgSender(); + proposal.proposalType = _proposalType; + proposal.votingCycle = _latestVotingCycle; + + emit ProposalSubmitted(proposalId_, _msgSender(), _proposalDescription, _proposalType); + emit ProposalVotingModuleData(proposalId_, proposalVotingModuleData); + + // MaintenanceUpgrade proposals move directly to voting (atomic operation) + if (_proposalType == ProposalType.MaintenanceUpgrade) { + proposal.movedToVote = true; + _proposeToGovernor( + votingModule, proposalVotingModuleData, _proposalDescription, idInConfigurator, proposalId_ + ); + } + } + + /// @notice Submits a Council Member Elections proposal for approval and voting. + /// @param _criteriaValue Since the passing criteria type is "TopChoices" this number represents the amount + /// of top choices that can pass the voting. + /// @param _optionDescriptions The strings of the different options that can be voted. + /// @param _proposalDescription Description of the proposal. + /// @param _attestationUid The UID of the attestation for the approved proposer. + /// @param _votingCycle The voting cycle number the proposal is targetted for. + /// @return proposalId_ The ID of the submitted proposal. + function submitCouncilMemberElectionsProposal( + uint128 _criteriaValue, + string[] memory _optionDescriptions, + string memory _proposalDescription, + bytes32 _attestationUid, + uint256 _votingCycle + ) + external + returns (uint256 proposalId_) + { + // Validate voting cycle exists and is not in the past + VotingCycleData memory votingCycleData = votingCycles[_votingCycle]; + if (votingCycleData.startingTimestamp == 0 || votingCycleData.startingTimestamp < block.timestamp) { + revert ProposalValidator_InvalidVotingCycle(); + } + + // Validate EAS attestation - must be called by owner-approved address + _validateApprovedProposerAttestation(_attestationUid, ProposalType.CouncilMemberElections); + + // Validate criteria value doesn't exceed options length for TopChoices + if (_criteriaValue > _optionDescriptions.length) { + revert ProposalValidator_InvalidCriteriaValue(); + } + + // Build proposal options (elections don't execute operations) + (IApprovalVotingModule.ProposalOption[] memory options,) = + _buildApprovalModuleOptions(_optionDescriptions, new address[](0), new uint256[](0)); + + // Configure approval voting settings with TopChoices criteria + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(_optionDescriptions.length), + criteria: uint8(IApprovalVotingModule.PassingCriteria.TopChoices), + budgetToken: address(0), // No budget token for elections + criteriaValue: _criteriaValue, + budgetAmount: 0 // No budget amount for elections + }); + + bytes memory proposalVotingModuleData = abi.encode(options, approvalSettings); + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[ProposalType.CouncilMemberElections].idInConfigurator; + + // Get the module address from the configurator + IProposalTypesConfigurator.ProposalType memory proposalTypeConfig = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator); + + // Validate voting module exists + if (bytes(proposalTypeConfig.name).length == 0) { + revert ProposalValidator_InvalidVotingModule(); + } + + address votingModule = proposalTypeConfig.module; + + // Generate unique proposal ID + proposalId_ = + _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_proposalDescription))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Prevent duplicate proposals with same ID + if (proposal.proposer != address(0)) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Check if proposal already exists in OptimismGovernor + if (GOVERNOR.proposalSnapshot(proposalId_) != 0) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Store proposal metadata + proposal.proposer = _msgSender(); + proposal.proposalType = ProposalType.CouncilMemberElections; + proposal.votingCycle = _votingCycle; + + emit ProposalSubmitted(proposalId_, _msgSender(), _proposalDescription, ProposalType.CouncilMemberElections); + emit ProposalVotingModuleData(proposalId_, proposalVotingModuleData); + } + + /// @notice Submits a GovernanceFund or CouncilBudget proposal type that transfers OP tokens for approval and + /// voting. + /// @dev For UI integration: Frontend interfaces should present this as a percentage input to users (e.g., "25%"), + /// then convert to the absolute vote count by calculating: (percentage / 100) * total_votable_supply. + /// Direct contract callers must provide the absolute number of votes required for passage. + /// @param _criteriaValue The absolute number of votes required for the proposal to pass. This represents the + /// threshold that must be met or exceeded for any option to be considered successful. + /// @param _optionsDescriptions The strings of the different options that can be voted. + /// @param _optionsRecipients An address for each option to transfer funds to in case the option passes the voting. + /// @param _optionsAmounts The amount to transfer for each option in case the option passes the voting. + /// @param _description Description of the proposal. + /// @param _proposalType The type of proposal (must be GovernanceFund or CouncilBudget). + /// @param _votingCycle The voting cycle number the proposal is targetted for. + /// @return proposalId_ The ID of the submitted proposal. + function submitFundingProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + address[] memory _optionsRecipients, + uint256[] memory _optionsAmounts, + string memory _description, + ProposalType _proposalType, + uint256 _votingCycle + ) + external + returns (uint256 proposalId_) + { + // Only funding proposal types can use this function + if (_proposalType != ProposalType.GovernanceFund && _proposalType != ProposalType.CouncilBudget) { + revert ProposalValidator_InvalidFundingProposalType(); + } + + // Validate voting cycle exists and is not in the past + VotingCycleData memory votingCycleData = votingCycles[_votingCycle]; + if (votingCycleData.startingTimestamp == 0 || votingCycleData.startingTimestamp < block.timestamp) { + revert ProposalValidator_InvalidVotingCycle(); + } + + // Validate input arrays have matching lengths + uint256 optionsLength = _optionsDescriptions.length; + if (optionsLength != _optionsRecipients.length || optionsLength != _optionsAmounts.length) { + revert ProposalValidator_ProposalTypesDataLengthMismatch(); + } + + // Build proposal options with funding execution data + (IApprovalVotingModule.ProposalOption[] memory options, uint256 totalBudget) = + _buildApprovalModuleOptions(_optionsDescriptions, _optionsRecipients, _optionsAmounts); + + // Configure approval voting settings + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(optionsLength), + criteria: uint8(IApprovalVotingModule.PassingCriteria.Threshold), + budgetToken: Predeploys.GOVERNANCE_TOKEN, + criteriaValue: _criteriaValue, + budgetAmount: uint128(totalBudget) + }); + + bytes memory proposalVotingModuleData = abi.encode(options, approvalSettings); + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[_proposalType].idInConfigurator; + + // Get the module address from the configurator + address votingModule; + { + IProposalTypesConfigurator.ProposalType memory proposalTypeConfig = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator); + + // Validate voting module exists + if (bytes(proposalTypeConfig.name).length == 0) { + revert ProposalValidator_InvalidVotingModule(); + } + + votingModule = proposalTypeConfig.module; + } + + // Generate unique proposal ID + proposalId_ = _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_description))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Prevent duplicate proposals with same ID + if (proposal.proposer != address(0)) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Check if proposal already exists in OptimismGovernor + if (GOVERNOR.proposalSnapshot(proposalId_) != 0) { + revert ProposalValidator_ProposalAlreadySubmitted(); + } + + // Store proposal metadata + proposal.proposer = _msgSender(); + proposal.proposalType = _proposalType; + proposal.votingCycle = _votingCycle; + + emit ProposalSubmitted(proposalId_, _msgSender(), _description, _proposalType); + emit ProposalVotingModuleData(proposalId_, proposalVotingModuleData); + } + + /// @notice Approves a proposal before being moved for voting. + /// @dev This function should only be called by the top delegates. + /// @param _proposalId The ID of the proposal to approve + /// @param _attestationUid The UID of the attestation for the delegate to approve the proposal + function approveProposal(uint256 _proposalId, bytes32 _attestationUid) external { + address _delegate = _msgSender(); + ProposalData storage proposal = _proposals[_proposalId]; + // check if the proposal exists + // proposal.votingCycle should never be 0, voting cycles already exist before the ProposalValidator is deployed + // and should be set by the OP Foundation + if (proposal.proposer == address(0) || proposal.votingCycle == 0) { + revert ProposalValidator_ProposalDoesNotExist(); + } + + // check if the caller has already approved the proposal + if (proposal.delegateApprovals[_delegate]) { + revert ProposalValidator_ProposalAlreadyApproved(); + } + + // check if proposal has already moved to vote + if (proposal.movedToVote) { + revert ProposalValidator_ProposalAlreadyMovedToVote(); + } + + // The previous voting cycle of a proposal should be the one before the + // proposal's targetted voting cycle. + uint256 previousVotingCycle = proposal.votingCycle - 1; + // Proposal or Governor Upgrade proposals are submitted with the latest voting cycle number, + // because they can be submitted outside of a voting cycle. + if (proposal.proposalType == ProposalType.ProtocolOrGovernorUpgrade) { + previousVotingCycle = proposal.votingCycle; + } + + // revert if the previous voting cycle has not started, we should only allow delegates + // to approve relative close to the proposals voting cycle + if (votingCycles[previousVotingCycle].startingTimestamp > block.timestamp) { + revert ProposalValidator_PreviousVotingCycleNotStarted(); + } + + // validate the attestation + _validateTopDelegateAttestation(_attestationUid, previousVotingCycle); + + // store the approval + proposal.delegateApprovals[_delegate] = true; + proposal.approvalCount++; + + emit ProposalApproved(_proposalId, _delegate); + } + + /// @notice Moves a Protocol or Governor Upgrade proposal to vote by proposing it on the Governor. + /// @param _againstThreshold The threshold for the proposal to be against the total supply. + /// @param _proposalDescription Description of the proposal. + /// @return proposalId_ The ID of the submitted proposal. + function moveToVoteProtocolOrGovernorUpgradeProposal( + uint248 _againstThreshold, + string memory _proposalDescription + ) + external + returns (uint256 proposalId_) + { + // Configure optimistic proposal settings + IOptimisticModule.ProposalSettings memory optimisticSettings = + IOptimisticModule.ProposalSettings({ againstThreshold: _againstThreshold, isRelativeToVotableSupply: true }); + + bytes memory proposalVotingModuleData = abi.encode(optimisticSettings); + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[ProposalType.ProtocolOrGovernorUpgrade].idInConfigurator; + + // Get the module address from the configurator + ProposalType proposalType = ProposalType.ProtocolOrGovernorUpgrade; + address votingModule = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator).module; + + // Generate unique proposal ID + proposalId_ = + _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_proposalDescription))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Proposal must exist and be valid + if (proposal.proposer == address(0) || proposal.proposalType != proposalType) { + revert ProposalValidator_InvalidProposal(); + } + + // Check if the caller is the proposer + if (proposal.proposer != _msgSender()) { + revert ProposalValidator_InvalidProposer(); + } + + // Check if proposal has enough approvals + if (proposal.approvalCount < proposalTypesData[proposalType].requiredApprovals) { + revert ProposalValidator_InsufficientApprovals(); + } + + // Check if proposal is already in voting + if (proposal.movedToVote) { + revert ProposalValidator_ProposalAlreadyMovedToVote(); + } + + proposal.movedToVote = true; + + // Propose with module on the Governor + _proposeToGovernor(votingModule, proposalVotingModuleData, _proposalDescription, idInConfigurator, proposalId_); + } + + /// @notice Moves a council member elections proposal to vote by proposing it on the Governor. + /// @param _criteriaValue The number of top choices that can pass the voting. + /// @param _optionsDescriptions The strings of the different options that can be voted. + /// @param _proposalDescription Description of the proposal. + /// @return proposalId_ The ID of the submitted proposal. + function moveToVoteCouncilMemberElectionsProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + string memory _proposalDescription + ) + external + returns (uint256 proposalId_) + { + // Configure approval module options + (IApprovalVotingModule.ProposalOption[] memory options,) = + _buildApprovalModuleOptions(_optionsDescriptions, new address[](0), new uint256[](0)); + + // Configure approval module settings + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(_optionsDescriptions.length), + criteria: uint8(IApprovalVotingModule.PassingCriteria.TopChoices), + budgetToken: address(0), + criteriaValue: _criteriaValue, + budgetAmount: 0 + }); + + bytes memory proposalVotingModuleData = abi.encode(options, approvalSettings); + + ProposalType _proposalType = ProposalType.CouncilMemberElections; + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[_proposalType].idInConfigurator; + + // Get the module address from the configurator + address votingModule = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator).module; + + // Generate unique proposal ID + proposalId_ = + _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_proposalDescription))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Proposal must exist and be valid + if (proposal.proposer == address(0) || proposal.proposalType != _proposalType) { + revert ProposalValidator_InvalidProposal(); + } + + // Check if the caller is the proposer + if (proposal.proposer != _msgSender()) { + revert ProposalValidator_InvalidProposer(); + } + + // Check if proposal has enough approvals + if (proposal.approvalCount < proposalTypesData[_proposalType].requiredApprovals) { + revert ProposalValidator_InsufficientApprovals(); + } + + // Check if proposal is already in voting + if (proposal.movedToVote) { + revert ProposalValidator_ProposalAlreadyMovedToVote(); + } + + // Check if the voting cycle is valid + VotingCycleData memory votingCycleData = votingCycles[proposal.votingCycle]; + if ( + votingCycleData.startingTimestamp > block.timestamp + || votingCycleData.startingTimestamp + votingCycleData.duration < block.timestamp + ) { + revert ProposalValidator_InvalidVotingCycle(); + } + + proposal.movedToVote = true; + + // Propose with module on the Governor + _proposeToGovernor(votingModule, proposalVotingModuleData, _proposalDescription, idInConfigurator, proposalId_); + } + + /// @notice Moves a funding proposal to vote by proposing it on the Governor. + /// @dev For UI integration: Frontend interfaces should present this as a percentage input to users (e.g., "25%"), + /// then convert to the absolute vote count by calculating: (percentage / 100) * total_votable_supply. + /// Direct contract callers must provide the absolute number of votes required for passage. + /// @param _criteriaValue The absolute number of votes required for the proposal to pass. This represents the + /// threshold that must be met or exceeded for any option to be considered successful. + /// @param _optionsDescriptions The strings of the different options that can be voted. + /// @param _optionsRecipients An address for each option to transfer funds to in case the option passes the voting. + /// @param _optionsAmounts The amount to transfer for each option in case the option passes the voting. + /// @param _description Description of the proposal. + /// @param _proposalType The type of proposal (must be GovernanceFund or CouncilBudget). + /// @return proposalId_ The ID of the submitted proposal. + function moveToVoteFundingProposal( + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + address[] memory _optionsRecipients, + uint256[] memory _optionsAmounts, + string memory _description, + ProposalType _proposalType + ) + external + returns (uint256 proposalId_) + { + // Only funding proposal types can use this function + if (_proposalType != ProposalType.GovernanceFund && _proposalType != ProposalType.CouncilBudget) { + revert ProposalValidator_InvalidFundingProposalType(); + } + + // Configure approval module options + (IApprovalVotingModule.ProposalOption[] memory options, uint256 totalBudget) = + _buildApprovalModuleOptions(_optionsDescriptions, _optionsRecipients, _optionsAmounts); + + // Configure approval module settings + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(_optionsDescriptions.length), + criteria: uint8(IApprovalVotingModule.PassingCriteria.Threshold), + budgetToken: Predeploys.GOVERNANCE_TOKEN, + criteriaValue: _criteriaValue, + budgetAmount: uint128(totalBudget) + }); + + bytes memory proposalVotingModuleData = abi.encode(options, approvalSettings); + + // Retrieve the ID to use in the proposal type configurator + uint8 idInConfigurator = proposalTypesData[_proposalType].idInConfigurator; + + // Get the module address from the configurator + address votingModule = + IProposalTypesConfigurator(GOVERNOR.PROPOSAL_TYPES_CONFIGURATOR()).proposalTypes(idInConfigurator).module; + + // Generate unique proposal ID + proposalId_ = _hashProposalWithModule(votingModule, proposalVotingModuleData, keccak256(bytes(_description))); + + ProposalData storage proposal = _proposals[proposalId_]; + + // Proposal must exist + if (proposal.proposer == address(0) || proposal.proposalType != _proposalType) { + revert ProposalValidator_InvalidProposal(); + } + + // Check if proposal has enough approvals + if (proposal.approvalCount < proposalTypesData[_proposalType].requiredApprovals) { + revert ProposalValidator_InsufficientApprovals(); + } + + // Check if proposal is already in voting + if (proposal.movedToVote) { + revert ProposalValidator_ProposalAlreadyMovedToVote(); + } + + { + // Check if proposal can be moved to vote + VotingCycleData memory votingCycleData = votingCycles[proposal.votingCycle]; + if ( + votingCycleData.startingTimestamp > block.timestamp + || votingCycleData.startingTimestamp + votingCycleData.duration < block.timestamp + ) { + revert ProposalValidator_InvalidVotingCycle(); + } + + // Check if total budget is within the voting cycle distribution limit + if (votingCycleData.movedToVoteTokenCount + totalBudget > votingCycleData.votingCycleDistributionLimit) { + revert ProposalValidator_ExceedsDistributionThreshold(); + } + } + + // Move proposal to vote + proposal.movedToVote = true; + votingCycles[proposal.votingCycle].movedToVoteTokenCount += totalBudget; + + // Propose with module on the Governor + _proposeToGovernor(votingModule, proposalVotingModuleData, _description, idInConfigurator, proposalId_); + } + + /// @notice Sets the data of a voting cycle. + /// @param _cycleNumber The number of the voting cycle to set. + /// @param _startingTimestamp The starting timestamp of the voting cycle. + /// @param _duration The duration of the voting cycle. Should be 1 day which is the end of Week 2 and start of Week + /// 3 + /// of the voting cycle. + /// @param _votingCycleDistributionLimit The max amount of tokens that can be distributed during the voting cycle. + function setVotingCycleData( + uint256 _cycleNumber, + uint256 _startingTimestamp, + uint256 _duration, + uint256 _votingCycleDistributionLimit + ) + external + onlyOwner + { + _setVotingCycleData(_cycleNumber, _startingTimestamp, _duration, _votingCycleDistributionLimit); + } + + /// @notice Sets the max amount of tokens that can be distributed in a proposal. + /// @param _proposalDistributionThreshold The new proposal distribution threshold. + function setProposalDistributionThreshold(uint256 _proposalDistributionThreshold) external onlyOwner { + _setProposalDistributionThreshold(_proposalDistributionThreshold); + } + + /// @notice Sets the data for a proposal type. + /// @param _proposalType The type of proposal to set the data for. + /// @param _proposalTypeData The data for the proposal type. + function setProposalTypeData( + ProposalType _proposalType, + ProposalTypeData memory _proposalTypeData + ) + external + onlyOwner + { + _setProposalTypeData(_proposalType, _proposalTypeData); + } + + /// @notice Validates the attestation data for a proposal. + /// @dev Checks that the attester is the owner, the schema is correct, + /// the sender is the approved delegate, and that the proposal type is correct. + /// Reverts with ProposalValidator_InvalidAttestation if validation fails. + /// @param _attestationUid The UID of the attestation to validate. + /// @param _expectedProposalType The expected proposal type from the attestation. + function _validateApprovedProposerAttestation( + bytes32 _attestationUid, + ProposalType _expectedProposalType + ) + internal + view + { + Attestation memory attestation = IEAS(Predeploys.EAS).getAttestation(_attestationUid); + + // Check if attestation exists, equivalent to calling EAS.isAttestationValid(_attestationUid) + if (attestation.uid == bytes32(0)) { + revert ProposalValidator_InvalidAttestation(); + } + + // check if the attestation is revoked + if (attestation.revocationTime != 0) { + revert ProposalValidator_AttestationRevoked(); + } + + // check if the attestation is expired + if (attestation.expirationTime != 0 && attestation.expirationTime < block.timestamp) { + revert ProposalValidator_AttestationExpired(); + } + + (uint8 proposalType,) = abi.decode(attestation.data, (uint8, string)); + + if ( + attestation.attester != owner() || attestation.schema != APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID + || attestation.recipient != _msgSender() || proposalType != uint8(_expectedProposalType) + ) { + revert ProposalValidator_InvalidAttestation(); + } + } + + /// @notice Validates the attestation data for a delegate that tries to approve a proposal. + /// @dev Only accepts attestations that do NOT include partial delegation. + /// @param _attestationUid The UID of the attestation to validate. + /// @param _lastVotingCycle The last voting cycle to validate against. + function _validateTopDelegateAttestation(bytes32 _attestationUid, uint256 _lastVotingCycle) internal view { + Attestation memory attestation = IEAS(Predeploys.EAS).getAttestation(_attestationUid); + VotingCycleData memory previousVotingCycleData = votingCycles[_lastVotingCycle]; + if (previousVotingCycleData.startingTimestamp == 0) { + revert ProposalValidator_InvalidVotingCycle(); + } + + // Check if attestation exists, equivalent to calling EAS.isAttestationValid(_attestationUid) + if (attestation.uid == bytes32(0)) { + revert ProposalValidator_InvalidAttestation(); + } + + // check if the schema is correct + if (attestation.schema != TOP_DELEGATES_ATTESTATION_SCHEMA_UID) { + revert ProposalValidator_InvalidAttestationSchema(); + } + + // check if the attestation is revoked + if (attestation.revocationTime != 0) { + revert ProposalValidator_AttestationRevoked(); + } + + // since the attestations are updated daily we should only allow attestations + // created before the last voting cycle of the proposal + if (attestation.time > previousVotingCycleData.startingTimestamp) { + revert ProposalValidator_AttestationCreatedAfterLastVotingCycle(); + } + + (, bool _includePartialDelegation,) = abi.decode(attestation.data, (string, bool, string)); + + // check if the attestation includes partial delegation or the recipient is not the caller + if (_includePartialDelegation || attestation.recipient != _msgSender()) { + revert ProposalValidator_InvalidAttestation(); + } + } + + /// @notice Internal function to build proposal options with optional execution data. + /// @param _optionDescriptions The strings of the different options that can be voted. + /// @param _recipients An address for each option to transfer funds to (empty for non-funding proposals). + /// @param _amounts The amount to transfer for each option (empty for non-funding proposals). + /// @return options_ The built proposal options. + /// @return totalBudget_ The total budget amount (sum of all amounts, 0 for non-funding proposals). + function _buildApprovalModuleOptions( + string[] memory _optionDescriptions, + address[] memory _recipients, + uint256[] memory _amounts + ) + internal + view + returns (IApprovalVotingModule.ProposalOption[] memory options_, uint256 totalBudget_) + { + uint256 optionsLength = _optionDescriptions.length; + // Validate options length bounds + if (optionsLength == 0 || optionsLength > type(uint8).max) { + revert ProposalValidator_InvalidOptionsLength(); + } + options_ = new IApprovalVotingModule.ProposalOption[](optionsLength); + + for (uint256 i = 0; i < optionsLength; i++) { + address[] memory targets; + uint256[] memory values; + bytes[] memory calldatas; + uint256 budgetTokensSpent; + + // Check if this is a funding proposal (has recipients and amounts) + if (_recipients.length > 0 && _amounts.length > 0) { + // Validate amount doesn't exceed distribution threshold + if (_amounts[i] > proposalDistributionThreshold) { + revert ProposalValidator_ExceedsDistributionThreshold(); + } + targets = new address[](1); + values = new uint256[](1); + calldatas = new bytes[](1); + + targets[0] = Predeploys.GOVERNANCE_TOKEN; + calldatas[0] = abi.encodeCall(IERC20.transfer, (_recipients[i], _amounts[i])); + budgetTokensSpent = _amounts[i]; + totalBudget_ += _amounts[i]; + } else { + // Non-funding proposals have no execution data + targets = new address[](0); + values = new uint256[](0); + calldatas = new bytes[](0); + budgetTokensSpent = 0; + } + + options_[i] = IApprovalVotingModule.ProposalOption({ + budgetTokensSpent: budgetTokensSpent, + targets: targets, + values: values, + calldatas: calldatas, + description: _optionDescriptions[i] + }); + } + + if (totalBudget_ > type(uint128).max) { + revert ProposalValidator_InvalidTotalBudget(); + } + } + + /// @notice Calculate `proposalId` based on `module`, `proposalData` and `descriptionHash`. + /// @param _module The address of the voting module to use for this proposal. + /// @param _proposalData The proposal data to pass to the voting module. + /// @param _descriptionHash The hash of the proposal description. + /// @return The proposal ID as uint256. + function _hashProposalWithModule( + address _module, + bytes memory _proposalData, + bytes32 _descriptionHash + ) + internal + view + returns (uint256) + { + return uint256(keccak256(abi.encode(address(GOVERNOR), _module, _proposalData, _descriptionHash))); + } + + /// @notice Private function to set the voting cycle data and emit event. + /// @param _cycleNumber The number of the voting cycle to set. + /// @param _startingTimestamp The starting timestamp of the voting cycle. + /// @param _duration The duration of the voting cycle. Should be 1 day which is the end of Week 2 and start of Week + /// 3 + /// of the voting cycle. + /// @param _votingCycleDistributionLimit The max amount of tokens that can be distributed during the voting cycle. + function _setVotingCycleData( + uint256 _cycleNumber, + uint256 _startingTimestamp, + uint256 _duration, + uint256 _votingCycleDistributionLimit + ) + private + { + if (votingCycles[_cycleNumber].startingTimestamp != 0) { + revert ProposalValidator_VotingCycleAlreadySet(); + } + + votingCycles[_cycleNumber] = VotingCycleData({ + startingTimestamp: _startingTimestamp, + duration: _duration, + votingCycleDistributionLimit: _votingCycleDistributionLimit, + movedToVoteTokenCount: 0 + }); + emit VotingCycleDataSet(_cycleNumber, _startingTimestamp, _duration, _votingCycleDistributionLimit); + } + + /// @notice Private function to set the proposal distribution threshold and emit event. + /// @param _proposalDistributionThreshold The new proposal distribution threshold. + function _setProposalDistributionThreshold(uint256 _proposalDistributionThreshold) private { + proposalDistributionThreshold = _proposalDistributionThreshold; + emit ProposalDistributionThresholdSet(_proposalDistributionThreshold); + } + + /// @notice Private function to set a proposal's type data. + /// @param _proposalType The type of proposal to set the data for. + /// @param _proposalTypeData The data for the proposal type. + function _setProposalTypeData(ProposalType _proposalType, ProposalTypeData memory _proposalTypeData) private { + proposalTypesData[_proposalType] = _proposalTypeData; + emit ProposalTypeDataSet(_proposalType, _proposalTypeData.requiredApprovals, _proposalTypeData.idInConfigurator); + } + + /// @notice Private function to propose to the governor when a proposal is ready to be moved to vote. + /// @param _votingModule The address of the voting module to use for this proposal. + /// @param _proposalData The proposal data to pass to the voting module. + /// @param _description The description of the proposal. + /// @param _idInConfigurator The ID of the proposal type in the proposal types configurator. + /// @param _expectedProposalId The proposalId should be the same as the one returned by the governor. + function _proposeToGovernor( + address _votingModule, + bytes memory _proposalData, + string memory _description, + uint8 _idInConfigurator, + uint256 _expectedProposalId + ) + private + { + uint256 proposalId = GOVERNOR.proposeWithModule(_votingModule, _proposalData, _description, _idInConfigurator); + + // Make sure the proposalId matches + if (proposalId != _expectedProposalId) { + revert ProposalValidator_ProposalIdMismatch(); + } + + emit ProposalMovedToVote(_expectedProposalId, _msgSender()); + } +} diff --git a/packages/contracts-bedrock/test/governance/ProposalValidator.t.sol b/packages/contracts-bedrock/test/governance/ProposalValidator.t.sol new file mode 100644 index 0000000000000..c39acdd671278 --- /dev/null +++ b/packages/contracts-bedrock/test/governance/ProposalValidator.t.sol @@ -0,0 +1,2949 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Interfaces +import { IProposalValidator } from "interfaces/governance/IProposalValidator.sol"; +import { IOptimismGovernor } from "interfaces/governance/IOptimismGovernor.sol"; +import { IProposalTypesConfigurator } from "interfaces/governance/IProposalTypesConfigurator.sol"; +import { + IEAS, + AttestationRequest, + AttestationRequestData, + RevocationRequest, + RevocationRequestData +} from "src/vendor/eas/IEAS.sol"; +import { ISchemaRegistry, ISchemaResolver } from "src/vendor/eas/ISchemaRegistry.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IApprovalVotingModule } from "interfaces/governance/IApprovalVotingModule.sol"; +import { IOptimisticModule } from "interfaces/governance/IOptimisticModule.sol"; + +// Testing utilities +import { stdStorage, StdStorage } from "forge-std/Test.sol"; + +// Contracts +import { ProposalValidator } from "src/governance/ProposalValidator.sol"; + +// Mocks +import { ProposalValidatorForTest } from "test/mocks/ProposalValidatorForTest.sol"; + +// Libraries +import { Predeploys } from "src/libraries/Predeploys.sol"; + +// Testing utilities +import { stdStorage, StdStorage } from "forge-std/Test.sol"; +import { CommonTest } from "test/setup/CommonTest.sol"; + +/// @title ProposalValidator_TestInit +/// @notice Setup contract for ProposalValidator tests +contract ProposalValidator_TestInit is CommonTest { + using stdStorage for StdStorage; + + // voting cycle constants + uint256 public constant CYCLE_NUMBER = 1; + uint256 public constant START_TIMESTAMP = 1000000; + uint256 public constant DURATION = 1 days; + uint256 public constant VOTING_CYCLE_DISTRIBUTION_LIMIT = 20000 ether; + + // proposal data constants + uint256 public constant PROPOSAL_DISTRIBUTION_THRESHOLD = 10000 ether; + uint256 public constant PROPOSAL_REQUIRED_APPROVALS = 1; + uint256 public constant OPTIMISTIC_MODULE_PERCENT_DIVISOR = 10_000; + uint8 public constant APPROVAL_VOTING_MODULE_ID = 1; + uint8 public constant OPTIMISTIC_VOTING_MODULE_ID = 2; + uint64 public constant ATT_EXPIRATION_TIME = 10 days; + uint248 public constant AGAINST_THRESHOLD = 5000; // 50% + string public constant PROPOSAL_DESCRIPTION = "Test proposal"; + + // attestation constants + bytes32 public APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID; + bytes32 public TOP_DELEGATES_ATTESTATION_SCHEMA_UID; + + address owner; + address user; + address topDelegate_A = makeAddr("topDelegate_A"); + bytes32 topDelegateAttestation_A; + address approvedProposer = makeAddr("approvedProposer"); + address approvalVotingModule; + address optimisticVotingModule; + + ProposalValidatorForTest public validator; + IOptimismGovernor public governor; + IProposalTypesConfigurator public proposalTypesConfigurator; + + event ProposalSubmitted( + uint256 indexed proposalId, + address indexed proposer, + string description, + ProposalValidator.ProposalType proposalType + ); + event ProposalApproved(uint256 indexed proposalId, address indexed approver); + event ProposalMovedToVote(uint256 indexed proposalId, address indexed executor); + event MinimumVotingPowerSet(uint256 newMinimumVotingPower); + event VotingCycleDataSet( + uint256 cycleNumber, uint256 startingTimestamp, uint256 duration, uint256 votingCycleDistributionLimit + ); + event ProposalDistributionThresholdSet(uint256 newProposalDistributionThreshold); + event ProposalTypeDataSet( + ProposalValidator.ProposalType proposalType, uint256 requiredApprovals, uint8 idInConfigurator + ); + event ProposalVotingModuleData(uint256 indexed proposalId, bytes encodedVotingModuleData); + + /// @notice Helper function to setup a mock and expect a call to it. + function _mockAndExpect(address _receiver, bytes memory _calldata, bytes memory _returned) internal { + vm.mockCall(_receiver, _calldata, _returned); + vm.expectCall(_receiver, _calldata); + } + + /// @notice Helper function to set proposal type data using StdStorage. + function _setProposalTypeData( + ProposalValidator.ProposalType _proposalType, + ProposalValidator.ProposalTypeData memory _data + ) + internal + { + // Set requiredApprovals (depth 0) + stdstore.target(address(validator)).sig("proposalTypesData(uint8)").with_key(uint256(_proposalType)).depth(0) + .checked_write(_data.requiredApprovals); + + // Set idInConfigurator (depth 1) + stdstore.target(address(validator)).sig("proposalTypesData(uint8)").with_key(uint256(_proposalType)).depth(1) + .checked_write(_data.idInConfigurator); + } + + /// @notice Helper function to set CouncilMemberElections proposal type data. + function _setCouncilMemberElectionsProposalType() internal { + _setProposalTypeData( + ProposalValidator.ProposalType.CouncilMemberElections, + ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }) + ); + } + + /// @notice Helper function to set GovernanceFund proposal type data. + function _setGovernanceFundProposalType() internal { + _setProposalTypeData( + ProposalValidator.ProposalType.GovernanceFund, + ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }) + ); + } + + /// @notice Helper function to set CouncilBudget proposal type data. + function _setCouncilBudgetProposalType() internal { + _setProposalTypeData( + ProposalValidator.ProposalType.CouncilBudget, + ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }) + ); + } + + /// @notice Helper function to set ProtocolOrGovernorUpgrade proposal type data. + function _setProtocolOrGovernorUpgradeProposalType() internal { + _setProposalTypeData( + ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade, + ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: OPTIMISTIC_VOTING_MODULE_ID + }) + ); + } + + /// @notice Helper function to set MaintenanceUpgrade proposal type data. + function _setMaintenanceUpgradeProposalType() internal { + _setProposalTypeData( + ProposalValidator.ProposalType.MaintenanceUpgrade, + ProposalValidator.ProposalTypeData({ + requiredApprovals: 0, // MaintenanceUpgrade moves directly to voting + idInConfigurator: OPTIMISTIC_VOTING_MODULE_ID + }) + ); + } + + /// @notice Helper to create minimal valid arrays for funding proposal error tests + + function _createMinimalFundingArrays(uint256 _length) + internal + returns (string[] memory descriptions_, address[] memory recipients_, uint256[] memory amounts_) + { + descriptions_ = new string[](_length); + recipients_ = new address[](_length); + amounts_ = new uint256[](_length); + for (uint256 i = 0; i < _length; i++) { + descriptions_[i] = string.concat("Option ", vm.toString(i + 1)); + recipients_[i] = makeAddr(string.concat("recipient", vm.toString(i + 1))); + amounts_[i] = 100 ether * (i + 1); + } + } + + function _getProposalTypesAndData() + internal + pure + returns (ProposalValidator.ProposalType[] memory, ProposalValidator.ProposalTypeData[] memory) + { + ProposalValidator.ProposalType[] memory proposalTypes = new ProposalValidator.ProposalType[](5); + proposalTypes[0] = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + proposalTypes[1] = ProposalValidator.ProposalType.MaintenanceUpgrade; + proposalTypes[2] = ProposalValidator.ProposalType.CouncilMemberElections; + proposalTypes[3] = ProposalValidator.ProposalType.GovernanceFund; + proposalTypes[4] = ProposalValidator.ProposalType.CouncilBudget; + + ProposalValidator.ProposalTypeData[] memory proposalTypesData = new ProposalValidator.ProposalTypeData[](5); + // ProtocolOrGovernorUpgrade + proposalTypesData[0] = ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: OPTIMISTIC_VOTING_MODULE_ID + }); + // MaintenanceUpgrade + proposalTypesData[1] = + ProposalValidator.ProposalTypeData({ requiredApprovals: 0, idInConfigurator: OPTIMISTIC_VOTING_MODULE_ID }); + // CouncilMemberElections + proposalTypesData[2] = ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }); + // GovernanceFund + proposalTypesData[3] = ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }); + // CouncilBudget + proposalTypesData[4] = ProposalValidator.ProposalTypeData({ + requiredApprovals: PROPOSAL_REQUIRED_APPROVALS, + idInConfigurator: APPROVAL_VOTING_MODULE_ID + }); + + return (proposalTypes, proposalTypesData); + } + + function _constructFundingVotingModuleData( + string[] memory _descriptions, + address[] memory _recipients, + uint256[] memory _amounts, + uint128 _criteriaValue + ) + internal + pure + returns (bytes memory) + { + // Construct ProposalOption array + IApprovalVotingModule.ProposalOption[] memory options = + new IApprovalVotingModule.ProposalOption[](_descriptions.length); + + for (uint256 i = 0; i < _descriptions.length; i++) { + address[] memory targets = new address[](1); + uint256[] memory values = new uint256[](1); + bytes[] memory calldatas = new bytes[](1); + + targets[0] = Predeploys.GOVERNANCE_TOKEN; + calldatas[0] = abi.encodeCall(IERC20.transfer, (_recipients[i], _amounts[i])); + + options[i] = IApprovalVotingModule.ProposalOption({ + budgetTokensSpent: _amounts[i], + targets: targets, + values: values, + calldatas: calldatas, + description: _descriptions[i] + }); + } + + // Calculate total budget + uint256 totalBudget = 0; + for (uint256 i = 0; i < _amounts.length; i++) { + totalBudget += _amounts[i]; + } + + // Construct ProposalSettings + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(_descriptions.length), + criteria: uint8(IApprovalVotingModule.PassingCriteria.Threshold), + budgetToken: Predeploys.GOVERNANCE_TOKEN, + criteriaValue: _criteriaValue, + budgetAmount: uint128(totalBudget) + }); + + return abi.encode(options, approvalSettings); + } + + /// @notice Helper function to construct voting module data for council elections + function _constructCouncilElectionVotingModuleData( + string[] memory _descriptions, + uint128 _criteriaValue + ) + internal + pure + returns (bytes memory) + { + // Construct ProposalOption array for elections (no execution calls) + IApprovalVotingModule.ProposalOption[] memory options = + new IApprovalVotingModule.ProposalOption[](_descriptions.length); + + for (uint256 i = 0; i < _descriptions.length; i++) { + address[] memory targets = new address[](0); + uint256[] memory values = new uint256[](0); + bytes[] memory calldatas = new bytes[](0); + + options[i] = IApprovalVotingModule.ProposalOption({ + budgetTokensSpent: 0, + targets: targets, + values: values, + calldatas: calldatas, + description: _descriptions[i] + }); + } + + // Construct ProposalSettings with TopChoices criteria + IApprovalVotingModule.ProposalSettings memory approvalSettings = IApprovalVotingModule.ProposalSettings({ + maxApprovals: uint8(_descriptions.length), + criteria: uint8(IApprovalVotingModule.PassingCriteria.TopChoices), + budgetToken: address(0), + criteriaValue: _criteriaValue, + budgetAmount: 0 + }); + + return abi.encode(options, approvalSettings); + } + + /// @notice Helper function to construct voting module data for upgrade proposals + function _constructOptimisticVotingModuleData(uint248 _againstThreshold) internal pure returns (bytes memory) { + IOptimisticModule.ProposalSettings memory optimisticSettings = + IOptimisticModule.ProposalSettings({ againstThreshold: _againstThreshold, isRelativeToVotableSupply: true }); + + return abi.encode(optimisticSettings); + } + + /// @notice Helper function to create a proposal for move to vote + function _createUpgradeProposalForMoveToVote( + address _proposer, + uint248 _againstThreshold, + string memory _proposalDescription + ) + internal + returns (uint256 proposalId_, bytes memory votingModuleData_) + { + // Calculate expected proposal ID + votingModuleData_ = _constructOptimisticVotingModuleData(_againstThreshold); + proposalId_ = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData_, keccak256(bytes(_proposalDescription)) + ); + + // 1 vote as default for being able to move to vote + validator.setProposalData( + proposalId_, + _proposer, + ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade, + false, + PROPOSAL_REQUIRED_APPROVALS, + CYCLE_NUMBER + ); + } + + /// @notice Helper function to create a proposal for move to vote for council elections + function _createCouncilElectionProposalForMoveToVote( + address _proposer, + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + string memory _proposalDescription + ) + internal + returns (uint256 proposalId_, bytes memory votingModuleData_) + { + votingModuleData_ = _constructCouncilElectionVotingModuleData(_optionsDescriptions, _criteriaValue); + proposalId_ = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData_, keccak256(bytes(_proposalDescription)) + ); + + validator.setProposalData( + proposalId_, + _proposer, + ProposalValidator.ProposalType.CouncilMemberElections, + false, + PROPOSAL_REQUIRED_APPROVALS, + CYCLE_NUMBER + ); + } + + /// @notice Helper function to create a proposal for move to vote for a funding proposal type + function _createFundingProposalForMoveToVote( + address _proposer, + uint128 _criteriaValue, + string[] memory _optionsDescriptions, + address[] memory _optionsRecipients, + uint256[] memory _optionsAmounts, + string memory _proposalDescription, + ProposalValidator.ProposalType _proposalType + ) + internal + returns (uint256 proposalId_, bytes memory votingModuleData_) + { + votingModuleData_ = + _constructFundingVotingModuleData(_optionsDescriptions, _optionsRecipients, _optionsAmounts, _criteriaValue); + proposalId_ = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData_, keccak256(bytes(_proposalDescription)) + ); + + validator.setProposalData( + proposalId_, _proposer, _proposalType, false, PROPOSAL_REQUIRED_APPROVALS, CYCLE_NUMBER + ); + } + + /// @notice Helper function to setup proposal types configurator mocks + function _mockProposalTypesConfiguratorCall(uint8 _votingModuleId) internal { + address moduleAddress; + if (_votingModuleId == APPROVAL_VOTING_MODULE_ID) { + moduleAddress = approvalVotingModule; + } else if (_votingModuleId == OPTIMISTIC_VOTING_MODULE_ID) { + moduleAddress = optimisticVotingModule; + } + + _mockAndExpect( + address(governor), + abi.encodeCall(IOptimismGovernor.PROPOSAL_TYPES_CONFIGURATOR, ()), + abi.encode(proposalTypesConfigurator) + ); + + _mockAndExpect( + address(proposalTypesConfigurator), + abi.encodeCall(IProposalTypesConfigurator.proposalTypes, (_votingModuleId)), + abi.encode( + IProposalTypesConfigurator.ProposalType({ + quorum: 100, + approvalThreshold: 100, + name: "Test Proposal Type", + description: "Test Description", + module: moduleAddress + }) + ) + ); + } + + /// @notice Helper function to mock proposal types configurator call with changed module + function _mockProposalTypesConfiguratorCallWithUninitializedModule(uint8 _votingModuleId) internal { + address moduleAddress; + if (_votingModuleId == APPROVAL_VOTING_MODULE_ID) { + moduleAddress = approvalVotingModule; + } else if (_votingModuleId == OPTIMISTIC_VOTING_MODULE_ID) { + moduleAddress = optimisticVotingModule; + } + + _mockAndExpect( + address(governor), + abi.encodeCall(IOptimismGovernor.PROPOSAL_TYPES_CONFIGURATOR, ()), + abi.encode(proposalTypesConfigurator) + ); + + _mockAndExpect( + address(proposalTypesConfigurator), + abi.encodeCall(IProposalTypesConfigurator.proposalTypes, (_votingModuleId)), + abi.encode( + IProposalTypesConfigurator.ProposalType({ + quorum: 0, + approvalThreshold: 0, + name: "", + description: "", + module: address(0) + }) + ) + ); + } + + /// @notice Initializes the validator + function _initializeValidator() internal virtual { + ( + ProposalValidator.ProposalType[] memory proposalTypes, + ProposalValidator.ProposalTypeData[] memory proposalTypesData + ) = _getProposalTypesAndData(); + + // Create mock addresses + proposalTypesConfigurator = IProposalTypesConfigurator(makeAddr("proposalTypesConfigurator")); + + validator = new ProposalValidatorForTest( + owner, governor, APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, TOP_DELEGATES_ATTESTATION_SCHEMA_UID + ); + + vm.startPrank(owner); + validator.setProposalDistributionThreshold(PROPOSAL_DISTRIBUTION_THRESHOLD); + for (uint256 i = 0; i < proposalTypes.length; i++) { + validator.setProposalTypeData(proposalTypes[i], proposalTypesData[i]); + } + validator.setVotingCycleData(CYCLE_NUMBER, START_TIMESTAMP, DURATION, PROPOSAL_DISTRIBUTION_THRESHOLD); + vm.stopPrank(); + } + + /// @dev Sets up the test suite. + function setUp() public virtual override { + super.setUp(); + owner = governanceToken.owner(); + user = makeAddr("user"); + governor = IOptimismGovernor(makeAddr("governor")); + approvalVotingModule = makeAddr("approvalVotingModule"); + optimisticVotingModule = makeAddr("optimisticVotingModule"); + + // Create schemas + vm.prank(owner); + APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID = ISchemaRegistry(Predeploys.SCHEMA_REGISTRY).register( + "uint8 proposalType,string date", ISchemaResolver(address(0)), true + ); + + vm.prank(owner); + TOP_DELEGATES_ATTESTATION_SCHEMA_UID = ISchemaRegistry(Predeploys.SCHEMA_REGISTRY).register( + "string top100,bool includePartialDelegation,string date", ISchemaResolver(address(0)), true + ); + + _initializeValidator(); + + // Create attestations for top delegates + topDelegateAttestation_A = _createTopDelegateAttestation(topDelegate_A); + } + + /// @notice Helper to create a valid attestation for an approved proposer + function _createApprovedProposerAttestation( + address _delegate, + ProposalValidator.ProposalType _proposalType + ) + internal + returns (bytes32) + { + vm.prank(owner); + return IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, + data: AttestationRequestData({ + recipient: _delegate, + expirationTime: uint64(block.timestamp + ATT_EXPIRATION_TIME), + revocable: true, + refUID: bytes32(0), + data: abi.encode(_proposalType, "2000-01-01"), + value: 0 + }) + }) + ); + } + + /// @notice Helper to create a valid attestation for a top delegate + function _createTopDelegateAttestation(address _delegate) internal returns (bytes32) { + vm.prank(owner); + return IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: TOP_DELEGATES_ATTESTATION_SCHEMA_UID, + data: AttestationRequestData({ + recipient: _delegate, + expirationTime: 0, + revocable: true, + refUID: bytes32(0), + data: abi.encode("top100", false, "2000-01-01"), + value: 0 + }) + }) + ); + } +} + +/// @title ProposalValidator_SubmitUpgradeProposal_Test +/// @notice Happy path tests for submitUpgradeProposal function +contract ProposalValidator_SubmitUpgradeProposal_Test is ProposalValidator_TestInit { + function setUp() public override { + super.setUp(); + + _setProtocolOrGovernorUpgradeProposalType(); + _setMaintenanceUpgradeProposalType(); + } + + function testFuzz_submitUpgradeProposal_maintenanceUpgrade_succeeds( + uint248 fuzzedAgainstThreshold, + address fuzzedProposer + ) + public + { + // Assume proposer is not zero address + vm.assume(fuzzedProposer != address(0)); + + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.MaintenanceUpgrade; + + // Bound fuzzedAgainstThreshold to valid range (1 to 10000 basis points) + fuzzedAgainstThreshold = uint248(bound(fuzzedAgainstThreshold, 1, OPTIMISTIC_MODULE_PERCENT_DIVISOR)); + + // Create attestation for the proposal + bytes32 fuzzedAttestationUid = _createApprovedProposerAttestation(fuzzedProposer, proposalType); + + // Calculate expected proposal ID + bytes memory votingModuleData = _constructOptimisticVotingModuleData(fuzzedAgainstThreshold); + uint256 expectedId = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 (proposal doesn't exist in governor) + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (optimisticVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, OPTIMISTIC_VOTING_MODULE_ID) + ), + abi.encode(expectedId) + ); + + // For MaintenanceUpgrade, events are: ProposalSubmitted, ProposalVotingModuleData, ProposalMovedToVote + vm.expectEmit(address(validator)); + emit ProposalSubmitted(expectedId, fuzzedProposer, PROPOSAL_DESCRIPTION, proposalType); + + vm.expectEmit(address(validator)); + emit ProposalVotingModuleData(expectedId, votingModuleData); + + vm.expectEmit(address(validator)); + emit ProposalMovedToVote(expectedId, fuzzedProposer); + + vm.prank(fuzzedProposer); + uint256 proposalId = validator.submitUpgradeProposal( + fuzzedAgainstThreshold, PROPOSAL_DESCRIPTION, fuzzedAttestationUid, proposalType, CYCLE_NUMBER + ); + + assertEq(proposalId, expectedId); + + // Verify proposal data was stored correctly + ( + address storedProposer, + ProposalValidator.ProposalType storedProposalType, + bool movedToVote, + uint256 approvalCount, + uint256 votingCycle + ) = validator.getProposalData(proposalId); + + assertEq(storedProposer, fuzzedProposer, "Proposer should match input"); + assertEq(uint8(storedProposalType), uint8(proposalType), "Proposal type should match input"); + assertTrue(movedToVote, "MaintenanceUpgrade should be in voting immediately"); + assertEq(approvalCount, 0, "Approval count should be 0"); + assertEq(votingCycle, CYCLE_NUMBER, "Voting cycle should match input"); + } + + function testFuzz_submitUpgradeProposal_protocolOrGovernorUpgrade_succeeds( + uint248 fuzzedAgainstThreshold, + address fuzzedProposer + ) + public + { + // Assume proposer is not zero address + vm.assume(fuzzedProposer != address(0)); + + // Bound fuzzedAgainstThreshold to valid range (1 to 10000 basis points) + fuzzedAgainstThreshold = uint248(bound(fuzzedAgainstThreshold, 1, OPTIMISTIC_MODULE_PERCENT_DIVISOR)); + + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + + // Create attestation for the proposal + bytes32 attestationUid = _createApprovedProposerAttestation(fuzzedProposer, proposalType); + + // Calculate expected proposal ID + bytes memory votingModuleData = _constructOptimisticVotingModuleData(fuzzedAgainstThreshold); + uint256 expectedId = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 (proposal doesn't exist in governor) + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // For ProtocolOrGovernorUpgrade, only ProposalSubmitted and ProposalVotingModuleData events + vm.expectEmit(address(validator)); + emit ProposalSubmitted(expectedId, fuzzedProposer, PROPOSAL_DESCRIPTION, proposalType); + + vm.expectEmit(address(validator)); + emit ProposalVotingModuleData(expectedId, votingModuleData); + + vm.prank(fuzzedProposer); + uint256 proposalId = validator.submitUpgradeProposal( + fuzzedAgainstThreshold, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + + assertEq(proposalId, expectedId); + + // Verify proposal data was stored correctly + ( + address storedProposer, + ProposalValidator.ProposalType storedProposalType, + bool movedToVote, + uint256 approvalCount, + uint256 votingCycle + ) = validator.getProposalData(proposalId); + + assertEq(storedProposer, fuzzedProposer, "Proposer should match input"); + assertEq(uint8(storedProposalType), uint8(proposalType), "Proposal type should match input"); + assertFalse(movedToVote, "ProtocolOrGovernorUpgrade should not be in voting yet"); + assertEq(approvalCount, 0, "Approval count should be 0"); + assertEq(votingCycle, CYCLE_NUMBER, "Voting cycle should match input"); + } + + function testFuzz_submitUpgradeProposal_invalidProposalType_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid upgrade proposal types are ProtocolOrGovernorUpgrade (0) and MaintenanceUpgrade (1) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 2, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidUpgradeProposalType.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_invalidVotingCycle_reverts( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedVotingCycle + ) + public + { + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 1)); + vm.assume(fuzzedVotingCycle != CYCLE_NUMBER); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, fuzzedVotingCycle + ); + } + + function testFuzz_submitUpgradeProposal_invalidAttestation_reverts(bytes32 fuzzedAttestationUid) public { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes32 validAttestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + vm.assume(fuzzedAttestationUid != validAttestationUid); // Ensure it's different from valid attestation + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, fuzzedAttestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_unattestedProposer_reverts(address fuzzedProposer) public { + vm.assume(fuzzedProposer != topDelegate_A); // Ensure it's different from attested proposer + + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Try to submit with different address than attested + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(fuzzedProposer); // Different from attested topDelegate_A + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_attestationExpired_reverts(uint8 fuzzedProposalTypeValue) public { + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 1)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // warp the time to after the attestation expiration time + vm.warp(block.timestamp + ATT_EXPIRATION_TIME + 1); + vm.expectRevert(ProposalValidator.ProposalValidator_AttestationExpired.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function test_submitUpgradeProposal_zeroAgainstThreshold_reverts() public { + uint248 zeroThreshold = 0; + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAgainstThreshold.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal(zeroThreshold, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER); + } + + function testFuzz_submitUpgradeProposal_exceedsMaxAgainstThreshold_reverts(uint248 fuzzedExcessiveThreshold) + public + { + // Bound excessive threshold to be greater than OPTIMISTIC_MODULE_PERCENT_DIVISOR + fuzzedExcessiveThreshold = + uint248(bound(fuzzedExcessiveThreshold, OPTIMISTIC_MODULE_PERCENT_DIVISOR + 1, type(uint248).max)); + + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAgainstThreshold.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + fuzzedExcessiveThreshold, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function test_submitUpgradeProposal_invalidVotingModule_reverts() public { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Mock configurator to return uninitialized module + _mockProposalTypesConfiguratorCallWithUninitializedModule(OPTIMISTIC_VOTING_MODULE_ID); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingModule.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_duplicateProposal_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only upgrade proposals (0 = ProtocolOrGovernorUpgrade, 1 = MaintenanceUpgrade) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 1)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Calculate expected proposal ID + bytes memory votingModuleData = _constructOptimisticVotingModuleData(AGAINST_THRESHOLD); + uint256 expectedId = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 for first submission + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // For MaintenanceUpgrade, mock the governor.proposeWithModule call + if (proposalType == ProposalValidator.ProposalType.MaintenanceUpgrade) { + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (optimisticVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, OPTIMISTIC_VOTING_MODULE_ID) + ), + abi.encode(expectedId) + ); + } + + // Submit first proposal + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + + // Attempt to submit identical proposal should revert + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_proposalExistsInGovernor_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only upgrade proposals (0 = ProtocolOrGovernorUpgrade, 1 = MaintenanceUpgrade) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 1)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Calculate expected proposal ID + bytes memory votingModuleData = _constructOptimisticVotingModuleData(AGAINST_THRESHOLD); + uint256 expectedId = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return non-zero (proposal already exists in governor) + _mockAndExpect( + address(governor), + abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), + abi.encode(1000) // Non-zero indicates proposal exists + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_attestationNotFromOwner_reverts(address fuzzedAttester) public { + vm.assume(fuzzedAttester != owner); // Ensure it's not the approved owner + + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + + // Create attestation but don't use proper owner as attester + vm.prank(fuzzedAttester); // Not the owner + bytes32 invalidAttestation = IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, + data: AttestationRequestData({ + recipient: topDelegate_A, + expirationTime: uint64(block.timestamp + ATT_EXPIRATION_TIME), + revocable: false, + refUID: bytes32(0), + data: abi.encode(proposalType, "2000-01-01"), + value: 0 + }) + }) + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, invalidAttestation, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitUpgradeProposal_attestationRevoked_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only upgrade proposals (0 = ProtocolOrGovernorUpgrade, 1 = MaintenanceUpgrade) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 1)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Create valid attestation first (make it revocable) + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Revoke the attestation + vm.prank(owner); + IEAS(Predeploys.EAS).revoke( + RevocationRequest({ + schema: APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, + data: RevocationRequestData({ uid: attestationUid, value: 0 }) + }) + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_AttestationRevoked.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } + + function test_submitUpgradeProposal_proposalIdMismatch_reverts(uint256 fuzzedProposalId) public { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.MaintenanceUpgrade; + bytes32 attestationUid = _createApprovedProposerAttestation(topDelegate_A, proposalType); + + // Calculate expected proposal ID + bytes memory votingModuleData = _constructOptimisticVotingModuleData(AGAINST_THRESHOLD); + uint256 expectedId = validator.hashProposalWithModule( + optimisticVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + vm.assume(fuzzedProposalId != expectedId); // Ensure proposalId is different from expectedId + + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + // Mock the proposeWithModule call to return a different proposalId + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (optimisticVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, OPTIMISTIC_VOTING_MODULE_ID) + ), + abi.encode(fuzzedProposalId) + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalIdMismatch.selector); + vm.prank(topDelegate_A); + validator.submitUpgradeProposal( + AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION, attestationUid, proposalType, CYCLE_NUMBER + ); + } +} + +/// @title ProposalValidator_SubmitCouncilMemberElectionsProposal_Test +/// @notice Happy path tests for submitCouncilMemberElectionsProposal function +contract ProposalValidator_SubmitCouncilMemberElectionsProposal_Test is ProposalValidator_TestInit { + uint128 criteriaValue; + string[] optionDescriptions; + bytes32 approvedProposerAttestationUid; + + function setUp() public override { + super.setUp(); + + _setCouncilMemberElectionsProposalType(); + + criteriaValue = 2; + optionDescriptions = new string[](3); + optionDescriptions[0] = "Candidate A"; + optionDescriptions[1] = "Candidate B"; + optionDescriptions[2] = "Candidate C"; + + approvedProposerAttestationUid = + _createApprovedProposerAttestation(topDelegate_A, ProposalValidator.ProposalType.CouncilMemberElections); + } + + function testFuzz_submitCouncilMemberElectionsProposal_succeeds( + uint8 fuzzedOptionCount, + uint128 fuzzedCriteriaValue + ) + public + { + fuzzedOptionCount = uint8(bound(fuzzedOptionCount, 2, 5)); // Minimum 2 options to have valid criteria < + // optionCount + fuzzedCriteriaValue = uint128(bound(fuzzedCriteriaValue, 1, fuzzedOptionCount - 1)); // Must be less than + // optionCount + + // Create dynamic array of option descriptions based on option count + string[] memory fuzzedOptionDescriptions = new string[](fuzzedOptionCount); + for (uint256 i = 0; i < fuzzedOptionCount; i++) { + fuzzedOptionDescriptions[i] = string(abi.encodePacked("Candidate ", vm.toString(i))); + } + + // Calculate expected proposal ID + bytes memory votingModuleData = + _constructCouncilElectionVotingModuleData(fuzzedOptionDescriptions, fuzzedCriteriaValue); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 (proposal doesn't exist in governor) + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + // Expect ProposalSubmitted event + vm.expectEmit(address(validator)); + emit ProposalSubmitted( + expectedId, topDelegate_A, PROPOSAL_DESCRIPTION, ProposalValidator.ProposalType.CouncilMemberElections + ); + + // Expect ProposalVotingModuleData event + vm.expectEmit(address(validator)); + emit ProposalVotingModuleData(expectedId, votingModuleData); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(topDelegate_A); + uint256 proposalId = validator.submitCouncilMemberElectionsProposal( + fuzzedCriteriaValue, + fuzzedOptionDescriptions, + PROPOSAL_DESCRIPTION, + approvedProposerAttestationUid, + CYCLE_NUMBER + ); + + assertEq(proposalId, expectedId); + + // Verify proposal data was stored correctly + ( + address proposer, + ProposalValidator.ProposalType proposalType, + bool movedToVote, + uint256 approvalCount, + uint256 votingCycle + ) = validator.getProposalData(proposalId); + + assertEq(proposer, topDelegate_A, "Proposer should be topDelegate_A"); + assertEq( + uint8(proposalType), + uint8(ProposalValidator.ProposalType.CouncilMemberElections), + "Proposal type should be CouncilMemberElections" + ); + assertFalse(movedToVote, "Proposal should not be in voting yet"); + assertEq(approvalCount, 0, "Approval count should be 0"); + assertEq(votingCycle, CYCLE_NUMBER, "Voting cycle should match input"); + } + + function testFuzz_submitCouncilMemberElectionsProposal_invalidVotingCycle_reverts(uint256 fuzzedVotingCycle) + public + { + vm.assume(fuzzedVotingCycle != CYCLE_NUMBER); + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, fuzzedVotingCycle + ); + } + + function testFuzz_submitCouncilMemberElectionsProposal_invalidAttestation_reverts(bytes32 fuzzedAttestationUid) + public + { + vm.assume(fuzzedAttestationUid != approvedProposerAttestationUid); // Ensure it's different from valid + // attestation + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, fuzzedAttestationUid, CYCLE_NUMBER + ); + } + + function testFuzz_submitCouncilMemberElectionsProposal_unattestedProposer_reverts(address fuzzedProposer) public { + vm.assume(fuzzedProposer != topDelegate_A); // Ensure it's different from attested proposer + + // Try to submit with different address than attested + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(fuzzedProposer); // Different from attested topDelegate_A + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function testFuzz_submitCouncilMemberElectionsProposal_attestationExpired_reverts() public { + // warp the time to after the attestation expiration time + vm.warp(block.timestamp + ATT_EXPIRATION_TIME + 1); + vm.expectRevert(ProposalValidator.ProposalValidator_AttestationExpired.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function test_submitCouncilMemberElectionsProposal_zeroOptions_reverts() public { + string[] memory emptyOptions = new string[](0); + uint128 zeroCriteriaValue = 0; // 0 so it doesnt exceed the options length + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + zeroCriteriaValue, emptyOptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function test_submitCouncilMemberElectionsProposal_duplicateProposal_reverts() public { + // Calculate expected proposal ID + bytes memory votingModuleData = _constructCouncilElectionVotingModuleData(optionDescriptions, criteriaValue); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 for first submission + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Submit first proposal + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + + // Attempt to submit identical proposal should revert + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function test_submitCouncilMemberElectionsProposal_proposalExistsInGovernor_reverts() public { + // Calculate expected proposal ID + bytes memory votingModuleData = _constructCouncilElectionVotingModuleData(optionDescriptions, criteriaValue); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return non-zero (proposal already exists in governor) + _mockAndExpect( + address(governor), + abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), + abi.encode(1000) // Non-zero indicates proposal exists + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function testFuzz_submitCouncilMemberElectionsProposal_attestationNotFromOwner_reverts(address fuzzedAttester) + public + { + vm.assume(fuzzedAttester != owner); // Ensure it's not the approved owner + + // Create attestation but don't use proper owner as attester + vm.prank(fuzzedAttester); // Not the owner + bytes32 invalidAttestation = IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, + data: AttestationRequestData({ + recipient: topDelegate_A, + expirationTime: uint64(block.timestamp + ATT_EXPIRATION_TIME), + revocable: false, + refUID: bytes32(0), + data: abi.encode(ProposalValidator.ProposalType.CouncilMemberElections, "2000-01-01"), + value: 0 + }) + }) + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, invalidAttestation, CYCLE_NUMBER + ); + } + + function testFuzz_submitCouncilMemberElectionsProposal_criteriaValueExceedsOptionsLength_reverts( + uint128 fuzzedCriteriaValue + ) + public + { + // Bound fuzzedCriteriaValue to be greater than options length + fuzzedCriteriaValue = uint128(bound(fuzzedCriteriaValue, optionDescriptions.length + 1, type(uint128).max)); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidCriteriaValue.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + fuzzedCriteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } + + function test_submitCouncilMemberElectionsProposal_attestationRevoked_reverts() public { + // Create valid attestation first (make it revocable) + bytes32 revocableAttestationUid = + _createApprovedProposerAttestation(topDelegate_A, ProposalValidator.ProposalType.CouncilMemberElections); + + // Revoke the attestation + vm.prank(owner); + IEAS(Predeploys.EAS).revoke( + RevocationRequest({ + schema: APPROVED_PROPOSER_ATTESTATION_SCHEMA_UID, + data: RevocationRequestData({ uid: revocableAttestationUid, value: 0 }) + }) + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_AttestationRevoked.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, revocableAttestationUid, CYCLE_NUMBER + ); + } + + function test_submitCouncilMemberElectionsProposal_invalidVotingModule_reverts() public { + // Mock configurator to return uninitialized module + _mockProposalTypesConfiguratorCallWithUninitializedModule(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingModule.selector); + vm.prank(topDelegate_A); + validator.submitCouncilMemberElectionsProposal( + criteriaValue, optionDescriptions, PROPOSAL_DESCRIPTION, approvedProposerAttestationUid, CYCLE_NUMBER + ); + } +} + +/// @title ProposalValidator_SubmitFundingProposal_Test +/// @notice Happy path tests for submitFundingProposal function +contract ProposalValidator_SubmitFundingProposal_Test is ProposalValidator_TestInit { + uint128 constant FUNDING_CRITERIA_VALUE = 1000; + + function setUp() public override { + super.setUp(); + + _setGovernanceFundProposalType(); + _setCouncilBudgetProposalType(); + } + + function testFuzz_submitFundingProposal_succeeds( + uint8 fuzzedProposalTypeValue, + uint8 fuzzedOptionCount, + uint256 fuzzedAmount, + address fuzzedProposer + ) + public + { + // Assume proposer is not zero address + vm.assume(fuzzedProposer != address(0)); + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Bound option count between 1 and 5 for reasonable test execution + fuzzedOptionCount = uint8(bound(fuzzedOptionCount, 1, 5)); + + // Bound amount from 0 to PROPOSAL_DISTRIBUTION_THRESHOLD (inclusive) + fuzzedAmount = bound(fuzzedAmount, 0, PROPOSAL_DISTRIBUTION_THRESHOLD); + + // Start with minimal arrays and extend based on option count + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(fuzzedOptionCount); + + // fuzz the amounts + for (uint256 i = 0; i < fuzzedOptionCount; i++) { + amounts[i] = fuzzedAmount; + } + + // Calculate expected proposal ID + bytes memory votingModuleData = + _constructFundingVotingModuleData(descriptions, recipients, amounts, FUNDING_CRITERIA_VALUE); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 (proposal doesn't exist in governor) + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + // Expect ProposalSubmitted event + vm.expectEmit(address(validator)); + emit ProposalSubmitted(expectedId, fuzzedProposer, PROPOSAL_DESCRIPTION, proposalType); + + // Expect ProposalVotingModuleData event + vm.expectEmit(address(validator)); + emit ProposalVotingModuleData(expectedId, votingModuleData); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(fuzzedProposer); + uint256 proposalId = validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + + assertEq(proposalId, expectedId); + + // Verify proposal data was stored correctly + ( + address storedProposer, + ProposalValidator.ProposalType storedProposalType, + bool movedToVote, + uint256 approvalCount, + uint256 votingCycle + ) = validator.getProposalData(proposalId); + + assertEq(storedProposer, fuzzedProposer, "Proposer should match input"); + assertEq(uint8(storedProposalType), uint8(proposalType), "Proposal type should match input"); + assertFalse(movedToVote, "Proposal should not be in voting yet"); + assertEq(approvalCount, 0, "Approval count should be 0"); + assertEq(votingCycle, CYCLE_NUMBER, "Voting cycle should match input"); + } + + function testFuzz_submitFundingProposal_invalidProposalType_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 2)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidFundingProposalType.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_invalidVotingCycle_reverts( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedVotingCycle + ) + public + { + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + vm.assume(fuzzedVotingCycle != CYCLE_NUMBER); + + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + descriptions, + recipients, + amounts, + PROPOSAL_DESCRIPTION, + proposalType, + fuzzedVotingCycle + ); + } + + function testFuzz_submitFundingProposal_mismatchedDescriptionsLength_reverts( + uint8 matchingLength, + uint8 mismatchedLength, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound lengths to reasonable values (1-50) and ensure they're different + matchingLength = uint8(bound(matchingLength, 1, 50)); + mismatchedLength = uint8(bound(mismatchedLength, 1, 50)); + vm.assume(matchingLength != mismatchedLength); + + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Create arrays - recipients and amounts match, descriptions are different + string[] memory mismatchedDescriptions = new string[](mismatchedLength); + address[] memory matchingRecipients = new address[](matchingLength); + uint256[] memory matchingAmounts = new uint256[](matchingLength); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalTypesDataLengthMismatch.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + mismatchedDescriptions, + matchingRecipients, + matchingAmounts, + PROPOSAL_DESCRIPTION, + proposalType, + CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_mismatchedRecipientsLength_reverts( + uint8 matchingLength, + uint8 mismatchedLength, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound lengths to reasonable values (1-50) and ensure they're different + matchingLength = uint8(bound(matchingLength, 1, 50)); + mismatchedLength = uint8(bound(mismatchedLength, 1, 50)); + vm.assume(matchingLength != mismatchedLength); + + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Create arrays - descriptions and amounts match, recipients are different + string[] memory matchingDescriptions = new string[](matchingLength); + address[] memory mismatchedRecipients = new address[](mismatchedLength); + uint256[] memory matchingAmounts = new uint256[](matchingLength); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalTypesDataLengthMismatch.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + matchingDescriptions, + mismatchedRecipients, + matchingAmounts, + PROPOSAL_DESCRIPTION, + proposalType, + CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_mismatchedAmountsLength_reverts( + uint8 matchingLength, + uint8 mismatchedLength, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound lengths to reasonable values (1-50) and ensure they're different + matchingLength = uint8(bound(matchingLength, 1, 50)); + mismatchedLength = uint8(bound(mismatchedLength, 1, 50)); + vm.assume(matchingLength != mismatchedLength); + + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Create arrays - descriptions and recipients match, amounts are different + string[] memory matchingDescriptions = new string[](matchingLength); + address[] memory matchingRecipients = new address[](matchingLength); + uint256[] memory mismatchedAmounts = new uint256[](mismatchedLength); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalTypesDataLengthMismatch.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + matchingDescriptions, + matchingRecipients, + mismatchedAmounts, + PROPOSAL_DESCRIPTION, + proposalType, + CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_exceedsProposalDistributionThreshold_reverts( + uint256 fuzzedExcessAmount, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound excess amount to be greater than PROPOSAL_DISTRIBUTION_THRESHOLD + fuzzedExcessAmount = bound(fuzzedExcessAmount, PROPOSAL_DISTRIBUTION_THRESHOLD + 1, type(uint128).max); + + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Create arrays with excessive amount + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + amounts[0] = fuzzedExcessAmount; + + vm.expectRevert(ProposalValidator.ProposalValidator_ExceedsDistributionThreshold.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_duplicateProposal_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + // Calculate expected proposal ID + bytes memory votingModuleData = + _constructFundingVotingModuleData(descriptions, recipients, amounts, FUNDING_CRITERIA_VALUE); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return 0 for first submission + _mockAndExpect( + address(governor), abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), abi.encode(0) + ); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Submit first proposal + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + + // Attempt to submit identical proposal + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_proposalExistsInGovernor_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + // Calculate expected proposal ID + bytes memory votingModuleData = + _constructFundingVotingModuleData(descriptions, recipients, amounts, FUNDING_CRITERIA_VALUE); + uint256 expectedId = validator.hashProposalWithModule( + approvalVotingModule, votingModuleData, keccak256(bytes(PROPOSAL_DESCRIPTION)) + ); + + // Mock proposalSnapshot to return non-zero (proposal already exists in governor) + _mockAndExpect( + address(governor), + abi.encodeCall(IOptimismGovernor.proposalSnapshot, (expectedId)), + abi.encode(1000) // Non-zero indicates proposal exists + ); + + vm.expectRevert(ProposalValidator.ProposalValidator_ProposalAlreadySubmitted.selector); + + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_zeroOptionsLength_reverts(uint8 fuzzedProposalTypeValue) public { + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + string[] memory emptyDescriptions = new string[](0); + address[] memory emptyRecipients = new address[](0); + uint256[] memory emptyAmounts = new uint256[](0); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + emptyDescriptions, + emptyRecipients, + emptyAmounts, + PROPOSAL_DESCRIPTION, + proposalType, + CYCLE_NUMBER + ); + } + + function testFuzz_submitFundingProposal_exceedsMaxOptionsLength_reverts( + uint256 fuzzedTooManyOptions, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + // Create arrays with more than 255 options (exceeds allowed uint8 max) + fuzzedTooManyOptions = uint256(bound(fuzzedTooManyOptions, 256, 512)); + string[] memory tooManyDescriptions = new string[](fuzzedTooManyOptions); + address[] memory tooManyRecipients = new address[](fuzzedTooManyOptions); + uint256[] memory tooManyAmounts = new uint256[](fuzzedTooManyOptions); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, + tooManyDescriptions, + tooManyRecipients, + tooManyAmounts, + PROPOSAL_DESCRIPTION, + proposalType, + CYCLE_NUMBER + ); + } + + function test_submitFundingProposal_invalidVotingModule_reverts() public { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.GovernanceFund; + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + // Mock configurator to return uninitialized module + _mockProposalTypesConfiguratorCallWithUninitializedModule(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidVotingModule.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } + + function test_submitFundingProposal_invalidTotalBudget_reverts( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedAmount + ) + public + { + fuzzedAmount = bound(fuzzedAmount, type(uint136).max, type(uint192).max); + // Bound proposal type to only GovernanceFund (3) or CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + (string[] memory descriptions, address[] memory recipients, uint256[] memory amounts) = + _createMinimalFundingArrays(1); + + vm.prank(owner); + validator.setProposalDistributionThreshold(type(uint256).max); + + amounts[0] = fuzzedAmount; + vm.expectRevert(ProposalValidator.ProposalValidator_InvalidTotalBudget.selector); + vm.prank(user); + validator.submitFundingProposal( + FUNDING_CRITERIA_VALUE, descriptions, recipients, amounts, PROPOSAL_DESCRIPTION, proposalType, CYCLE_NUMBER + ); + } +} + +/// @title ProposalValidator_ApproveProposal_Test +/// @notice Happy path tests for approveProposal function +contract ProposalValidator_ApproveProposal_Test is ProposalValidator_TestInit { + function setUp() public override { + super.setUp(); + + // create the previous voting cycle + // cycle number decreased by 1 and start time CYCLE_DURATION before the current cycle + vm.prank(owner); + validator.setVotingCycleData( + CYCLE_NUMBER - 1, START_TIMESTAMP - DURATION, DURATION, VOTING_CYCLE_DISTRIBUTION_LIMIT + ); + // warp to the start of the previous cycle + vm.warp(START_TIMESTAMP - DURATION); + } + + function test_approveProposal_succeeds(uint256 fuzzedProposalId, uint8 fuzzedProposalTypeValue) public { + // Ensure the proposal ID is not 0 + vm.assume(fuzzedProposalId != 0); + + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Set mock proposal data of a random proposal in the validator contract + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + + // Expect event to be emitted when approving + vm.expectEmit(address(validator)); + emit ProposalApproved(fuzzedProposalId, topDelegate_A); + + // warp to the start of current cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + // Approve the proposal, use the attestation of the top delegate that was created in setUp + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + + // Check that the proposal data has been updated + assertTrue(validator.hasDelegateApproved(fuzzedProposalId, topDelegate_A)); + + (,,, uint256 approvalCount,) = validator.getProposalData(fuzzedProposalId); + assertEq(approvalCount, 1); + } + + function test_approveProposal_proposalDoesNotExist_reverts(uint256 fuzzedProposalId) public { + // There is no stored proposal data so this will revert + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalDoesNotExist.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_proposalAlreadyApproved_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + + // Mock the proposal as already approved by the top delegate + validator.mockApproveProposal(fuzzedProposalId, topDelegate_A); + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalAlreadyApproved.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_proposalAlreadyMovedToVote_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + // set proposal data so that the proposal exists and set movedToVote to true + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, true, 0, CYCLE_NUMBER); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalAlreadyMovedToVote.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_previousVotingCycleNotStarted_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + + // warp before the start of the previous cycle so that it reverts + vm.warp(START_TIMESTAMP - DURATION - 1); + + vm.expectRevert(IProposalValidator.ProposalValidator_PreviousVotingCycleNotStarted.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_invalidVotingCycle_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue, + uint256 fuzzedVotingCycle + ) + public + { + vm.assume(fuzzedVotingCycle != CYCLE_NUMBER && fuzzedVotingCycle != 0); + vm.assume(fuzzedVotingCycle != CYCLE_NUMBER + 1); // Avoid existing cycle + + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, fuzzedVotingCycle); + + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_invalidSchema_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // create a new schema + vm.prank(topDelegate_A); + bytes32 _invalidSchemaUid = ISchemaRegistry(Predeploys.SCHEMA_REGISTRY).register( + "string top100, string date", ISchemaResolver(address(0)), true + ); + + // create an attestation with the new schema + vm.prank(topDelegate_A); + bytes32 _invalidAttestationUid = IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: _invalidSchemaUid, + data: AttestationRequestData({ + recipient: topDelegate_A, + expirationTime: 0, + revocable: true, + refUID: bytes32(0), + data: abi.encode("top100", false, "2000-01-01"), + value: 0 + }) + }) + ); + + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidAttestationSchema.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, _invalidAttestationUid); + } + + function test_approveProposal_attestationRevoked_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + // revoke the attestation + vm.prank(owner); + IEAS(Predeploys.EAS).revoke( + RevocationRequest({ + schema: TOP_DELEGATES_ATTESTATION_SCHEMA_UID, + data: RevocationRequestData({ uid: topDelegateAttestation_A, value: 0 }) + }) + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_AttestationRevoked.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_attestationCreatedAfterPreviousVotingCycle_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // create a new delegate and attestation + address _delegate = makeAddr("delegate"); + bytes32 _attestationUid; + + // create the attestation based on the proposal type + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + // warp to after the start of the current voting cycle if the proposal is ProtocolOrGovernorUpgrade + // because this proposal can be submitted and approved outside of a voting cycle + vm.warp(START_TIMESTAMP + 1); + _attestationUid = _createTopDelegateAttestation(_delegate); + } else { + // warp to after the start of the previous cycle for an other proposal type + vm.warp(START_TIMESTAMP - DURATION + 1); + _attestationUid = _createTopDelegateAttestation(_delegate); + } + + // set proposal data so that the proposal exists + validator.setProposalData(fuzzedProposalId, _delegate, proposalType, false, 0, CYCLE_NUMBER); + + // warp to after the start of the current voting cycle + vm.warp(START_TIMESTAMP + 2); + vm.expectRevert(IProposalValidator.ProposalValidator_AttestationCreatedAfterLastVotingCycle.selector); + vm.prank(_delegate); + validator.approveProposal(fuzzedProposalId, _attestationUid); + } + + function test_approveProposal_invalidAttestationCaller_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue, + address fuzzedCaller + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Ensure the caller is not a top delegate + vm.assume(fuzzedCaller != topDelegate_A); + + // Set mock proposal data of a random proposal in the validator contract + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + // Expect the invalid attestation error to be reverted + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(fuzzedCaller); + validator.approveProposal(fuzzedProposalId, topDelegateAttestation_A); + } + + function test_approveProposal_invalidAttestationPartialDelegation_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Set mock proposal data of a random proposal in the validator contract + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + // create an attestation with partial delegation + vm.prank(owner); + bytes32 _attestationUidWithPartialDelegation = IEAS(Predeploys.EAS).attest( + AttestationRequest({ + schema: TOP_DELEGATES_ATTESTATION_SCHEMA_UID, + data: AttestationRequestData({ + recipient: topDelegate_A, + expirationTime: 0, + revocable: true, + refUID: bytes32(0), + data: abi.encode("top100", true, "2000-01-01"), + value: 0 + }) + }) + ); + + // Expect the invalid attestation error to be reverted + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, _attestationUidWithPartialDelegation); + } + + function test_approveProposal_nonExistentAttestation_reverts( + uint256 fuzzedProposalId, + uint8 fuzzedProposalTypeValue, + bytes32 fuzzedNonExistentAttestationUid + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Ensure the attestation uid is not one of the valid ones + vm.assume(fuzzedNonExistentAttestationUid != topDelegateAttestation_A); + + // Set mock proposal data of a random proposal in the validator contract + validator.setProposalData(fuzzedProposalId, topDelegate_A, proposalType, false, 0, CYCLE_NUMBER); + // warp to start of current voting cycle if the proposal is ProtocolOrGovernorUpgrade + if (proposalType == ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade) { + vm.warp(START_TIMESTAMP); + } + + // Expect the invalid attestation error to be reverted when attestation doesn't exist + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidAttestation.selector); + vm.prank(topDelegate_A); + validator.approveProposal(fuzzedProposalId, fuzzedNonExistentAttestationUid); + } +} + +/// @title ProposalValidator_MoveToVoteProtocolOrGovernorUpgradeProposal_Test +/// @notice Happy path tests for moveToVoteProtocolOrGovernorUpgradeProposal function +contract ProposalValidator_MoveToVoteProtocolOrGovernorUpgradeProposal_Test is ProposalValidator_TestInit { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade; + bytes votingModuleData; + uint256 expectedId; + + function setUp() public override { + super.setUp(); + + (expectedId, votingModuleData) = + _createUpgradeProposalForMoveToVote(approvedProposer, AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_succeeds() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (optimisticVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, OPTIMISTIC_VOTING_MODULE_ID) + ), + abi.encode(expectedId) + ); + + // Expect the ProposalMovedToVote event to be emitted + vm.expectEmit(address(validator)); + emit ProposalMovedToVote(expectedId, approvedProposer); + + // Move to vote + vm.prank(approvedProposer); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + + // Check that the proposal is in voting + (,, bool movedToVote,,) = validator.getProposalData(expectedId); + assertTrue(movedToVote, "Proposal should be in voting"); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_invalidProposer_reverts(address fuzzedCaller) public { + vm.assume(fuzzedCaller != approvedProposer); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposer.selector); + vm.prank(fuzzedCaller); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_invalidProposal_reverts(uint248 fuzzedAgainstThreshold) + public + { + // This will generate a different proposal ID which will make the proposal type wrong + vm.assume(fuzzedAgainstThreshold != AGAINST_THRESHOLD); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposal.selector); + vm.prank(approvedProposer); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(fuzzedAgainstThreshold, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_insufficientApprovals_reverts() public { + // Set proposal data approved count to 0 since it is 1 by the approval on the setUp + validator.setProposalData(expectedId, approvedProposer, proposalType, false, 0, CYCLE_NUMBER); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InsufficientApprovals.selector); + vm.prank(approvedProposer); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_proposalAlreadyMovedToVote_reverts() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // Set proposal data movedToVote to true + validator.setProposalData(expectedId, approvedProposer, proposalType, true, 1, CYCLE_NUMBER); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalAlreadyMovedToVote.selector); + vm.prank(approvedProposer); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteProtocolOrGovernorUpgradeProposal_proposalIdMismatch_reverts(uint256 fuzzedProposalId) + public + { + vm.assume(fuzzedProposalId != expectedId); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(OPTIMISTIC_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (optimisticVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, OPTIMISTIC_VOTING_MODULE_ID) + ), + abi.encode(uint256(fuzzedProposalId)) + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalIdMismatch.selector); + vm.prank(approvedProposer); + validator.moveToVoteProtocolOrGovernorUpgradeProposal(AGAINST_THRESHOLD, PROPOSAL_DESCRIPTION); + } +} + +contract ProposalValidator_MoveToVoteCouncilMemberElectionsProposal_Test is ProposalValidator_TestInit { + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType.CouncilMemberElections; + uint128 criteriaValue = 1; + uint256 expectedId; + bytes votingModuleData; + string[] optionsDescriptions = new string[](2); + + function setUp() public override { + super.setUp(); + + // Create a proposal for move to vote with 1 top choice and 2 options + optionsDescriptions[0] = "Option 1"; + optionsDescriptions[1] = "Option 2"; + (expectedId, votingModuleData) = _createCouncilElectionProposalForMoveToVote( + approvedProposer, criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION + ); + } + + function test_moveToVoteCouncilMemberElectionsProposal_succeeds() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (approvalVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, APPROVAL_VOTING_MODULE_ID) + ), + abi.encode(expectedId) + ); + + // Expect the ProposalMovedToVote event to be emitted + vm.expectEmit(address(validator)); + emit ProposalMovedToVote(expectedId, approvedProposer); + + // Move to vote + vm.warp(START_TIMESTAMP + 1); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + + // Check that the proposal is in voting + (,, bool movedToVote,,) = validator.getProposalData(expectedId); + assertTrue(movedToVote, "Proposal should be in voting"); + } + + function test_moveToVoteCouncilMemberElectionsProposal_invalidProposer_reverts(address fuzzedCaller) public { + vm.assume(fuzzedCaller != approvedProposer); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposer.selector); + vm.prank(fuzzedCaller); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_invalidProposal_reverts() public { + // This will generate a different proposal ID which will make the proposal type wrong + uint128 _criteriaValue = 2; // we use 2 since it is the max based on the created proposal in setUp + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposal.selector); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(_criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_invalidOptionsLength_reverts() public { + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, new string[](0), PROPOSAL_DESCRIPTION); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, new string[](256), PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_insufficientApprovals_reverts() public { + // Set proposal data approved count to 0 since it is 1 by the approval on the setUp + validator.setProposalData(expectedId, approvedProposer, proposalType, false, 0, CYCLE_NUMBER); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InsufficientApprovals.selector); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_proposalAlreadyMovedToVote_reverts() public { + // Set proposal data movedToVote to true + validator.setProposalData(expectedId, approvedProposer, proposalType, true, 2, CYCLE_NUMBER); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalAlreadyMovedToVote.selector); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_invalidVotingCycle_reverts() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.warp(START_TIMESTAMP + DURATION + 1); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } + + function test_moveToVoteCouncilMemberElectionsProposal_proposalIdMismatch_reverts(uint256 fuzzedProposalId) + public + { + vm.assume(fuzzedProposalId != expectedId); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (approvalVotingModule, votingModuleData, PROPOSAL_DESCRIPTION, APPROVAL_VOTING_MODULE_ID) + ), + abi.encode(uint256(fuzzedProposalId)) + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalIdMismatch.selector); + vm.warp(START_TIMESTAMP + 1); + vm.prank(approvedProposer); + validator.moveToVoteCouncilMemberElectionsProposal(criteriaValue, optionsDescriptions, PROPOSAL_DESCRIPTION); + } +} + +contract ProposalValidator_MoveToVoteFundingProposal_Test is ProposalValidator_TestInit { + ProposalValidator.ProposalType governanceFundProposalType = ProposalValidator.ProposalType.GovernanceFund; + ProposalValidator.ProposalType councilBudgetProposalType = ProposalValidator.ProposalType.CouncilBudget; + uint128 criteriaValue = 1; + string governanceFundProposalDescription = "Test governance fund proposal"; + string councilBudgetProposalDescription = "Test council budget proposal"; + string[] optionsDescriptions = new string[](2); + address[] optionsRecipients = new address[](2); + uint256[] optionsAmounts = new uint256[](2); + uint256 expectedGovernanceFundId; + uint256 expectedCouncilBudgetId; + bytes governanceFundVotingModuleData; + bytes councilBudgetVotingModuleData; + + function setUp() public override { + super.setUp(); + + // Create option descriptions for the proposals + optionsDescriptions[0] = "Option 1"; + optionsDescriptions[1] = "Option 2"; + + // Create option recipients for the proposals + optionsRecipients[0] = makeAddr("optionRecipient1"); + optionsRecipients[1] = makeAddr("optionRecipient2"); + + // Create option amounts for the proposals + optionsAmounts[0] = 100 ether; + optionsAmounts[1] = 200 ether; + + // Create one proposal for each type + (expectedGovernanceFundId, governanceFundVotingModuleData) = _createFundingProposalForMoveToVote( + approvedProposer, + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + governanceFundProposalDescription, + governanceFundProposalType + ); + (expectedCouncilBudgetId, councilBudgetVotingModuleData) = _createFundingProposalForMoveToVote( + approvedProposer, + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + councilBudgetProposalDescription, + councilBudgetProposalType + ); + } + + function test_moveToVoteFundingProposal_governanceFund_succeeds() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + ( + approvalVotingModule, + governanceFundVotingModuleData, + governanceFundProposalDescription, + APPROVAL_VOTING_MODULE_ID + ) + ), + abi.encode(uint256(expectedGovernanceFundId)) + ); + + // Expect the ProposalMovedToVote event to be emitted + vm.expectEmit(address(validator)); + emit ProposalMovedToVote(expectedGovernanceFundId, approvedProposer); + + // Move to vote + vm.warp(START_TIMESTAMP + 1); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + governanceFundProposalDescription, + governanceFundProposalType + ); + + // Check that the proposal is in voting + (,, bool movedToVote,,) = validator.getProposalData(expectedGovernanceFundId); + assertTrue(movedToVote, "Proposal should be in voting"); + } + + function test_moveToVoteFundingProposal_councilBudget_succeeds() public { + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + ( + approvalVotingModule, + councilBudgetVotingModuleData, + councilBudgetProposalDescription, + APPROVAL_VOTING_MODULE_ID + ) + ), + abi.encode(uint256(expectedCouncilBudgetId)) + ); + + // Expect the ProposalMovedToVote event to be emitted + vm.expectEmit(address(validator)); + emit ProposalMovedToVote(expectedCouncilBudgetId, approvedProposer); + + // Move to vote + vm.warp(START_TIMESTAMP + 1); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + councilBudgetProposalDescription, + councilBudgetProposalType + ); + } + + function test_moveToVoteFundingProposal_invalidFundingProposalType_reverts( + uint8 fuzzedProposalTypeValue, + string memory fuzzedProposalDescription + ) + public + { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 2)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidFundingProposalType.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fuzzedProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_invalidProposal_reverts( + uint8 fuzzedProposalTypeValue, + uint128 fuzzedCriteriaValue + ) + public + { + // Ensure the criteria value is not the same as the one in setUp so when calculating the proposal ID it will + // not find the proposal + vm.assume(fuzzedCriteriaValue != criteriaValue); + + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposal.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + fuzzedCriteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_invalidProposalWrongProposalType_reverts( + uint8 fuzzedWrongProposalTypeValue, + uint8 fuzzedValidProposalTypeValue + ) + public + { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedWrongProposalTypeValue = uint8(bound(fuzzedWrongProposalTypeValue, 0, 2)); + ProposalValidator.ProposalType wrongProposalType = ProposalValidator.ProposalType(fuzzedWrongProposalTypeValue); + + fuzzedValidProposalTypeValue = uint8(bound(fuzzedValidProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType validProposalType = ProposalValidator.ProposalType(fuzzedValidProposalTypeValue); + + string memory fundingProposalDescription; + if (validProposalType == governanceFundProposalType) { + // Set proposal data proposal type to a different value + validator.setProposalData( + expectedGovernanceFundId, approvedProposer, wrongProposalType, false, 0, CYCLE_NUMBER + ); + fundingProposalDescription = governanceFundProposalDescription; + } else { + // Set proposal data proposal type to a different value + validator.setProposalData( + expectedCouncilBudgetId, approvedProposer, wrongProposalType, false, 0, CYCLE_NUMBER + ); + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidProposal.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + validProposalType + ); + } + + function test_moveToVoteFundingProposal_invalidOptionsLength_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, new string[](0), optionsRecipients, optionsAmounts, fundingProposalDescription, proposalType + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidOptionsLength.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + new string[](256), + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_invalidTotalBudget_reverts( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedAmount + ) + public + { + fuzzedAmount = bound(fuzzedAmount, type(uint136).max, type(uint192).max); + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + vm.prank(owner); + validator.setProposalDistributionThreshold(type(uint256).max); + + optionsAmounts[0] = fuzzedAmount; + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidTotalBudget.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_insufficientApprovals_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + // Set proposal data approved count to 0 since it is 1 by the approval on the setUp + validator.setProposalData(expectedGovernanceFundId, approvedProposer, proposalType, false, 0, CYCLE_NUMBER); + fundingProposalDescription = governanceFundProposalDescription; + } else { + // Set proposal data approved count to 0 since it is 1 by the approval on the setUp + validator.setProposalData(expectedCouncilBudgetId, approvedProposer, proposalType, false, 0, CYCLE_NUMBER); + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InsufficientApprovals.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_proposalAlreadyMovedToVote_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + // Set proposal data movedToVote to true + validator.setProposalData(expectedGovernanceFundId, approvedProposer, proposalType, true, 1, CYCLE_NUMBER); + fundingProposalDescription = governanceFundProposalDescription; + } else { + // Set proposal data movedToVote to true + validator.setProposalData(expectedCouncilBudgetId, approvedProposer, proposalType, true, 1, CYCLE_NUMBER); + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalAlreadyMovedToVote.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_invalidVotingCycle_reverts(uint8 fuzzedProposalTypeValue) public { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + vm.expectRevert(IProposalValidator.ProposalValidator_InvalidVotingCycle.selector); + vm.warp(START_TIMESTAMP + DURATION + 1); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_buildApprovalModuleOptionsExceedsProposalDistributionThreshold_reverts( + uint8 fuzzedProposalTypeValue + ) + public + { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Set the first option amount to exceed the distribution threshold + optionsAmounts[0] = PROPOSAL_DISTRIBUTION_THRESHOLD + 1; + + vm.expectRevert(IProposalValidator.ProposalValidator_ExceedsDistributionThreshold.selector); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_exceedsDistributionThreshold_reverts(uint8 fuzzedProposalTypeValue) + public + { + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + fundingProposalDescription = governanceFundProposalDescription; + } else { + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + string[] memory _optionsDescriptions = new string[](3); + address[] memory _optionsRecipients = new address[](3); + uint256[] memory _optionsAmounts = new uint256[](3); + + _optionsDescriptions[0] = "Option 1"; + _optionsDescriptions[1] = "Option 2"; + _optionsDescriptions[2] = "Option 3"; + + _optionsRecipients[0] = makeAddr("optionRecipient1"); + _optionsRecipients[1] = makeAddr("optionRecipient2"); + _optionsRecipients[2] = makeAddr("optionRecipient3"); + + _optionsAmounts[0] = PROPOSAL_DISTRIBUTION_THRESHOLD - 1; + _optionsAmounts[1] = PROPOSAL_DISTRIBUTION_THRESHOLD - 1; + _optionsAmounts[2] = PROPOSAL_DISTRIBUTION_THRESHOLD - 1; + + _createFundingProposalForMoveToVote( + approvedProposer, + criteriaValue, + _optionsDescriptions, + _optionsRecipients, + _optionsAmounts, + fundingProposalDescription, + proposalType + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_ExceedsDistributionThreshold.selector); + vm.prank(approvedProposer); + vm.warp(START_TIMESTAMP + 1); + validator.moveToVoteFundingProposal( + criteriaValue, + _optionsDescriptions, + _optionsRecipients, + _optionsAmounts, + fundingProposalDescription, + proposalType + ); + } + + function test_moveToVoteFundingProposal_proposalIdMismatch_reverts( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedProposalId + ) + public + { + vm.assume(fuzzedProposalId != expectedGovernanceFundId && fuzzedProposalId != expectedCouncilBudgetId); + + // Valid funding proposal types are GovernanceFund (3) and CouncilBudget (4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 3, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + // Mock the proposal types configurator call + _mockProposalTypesConfiguratorCall(APPROVAL_VOTING_MODULE_ID); + + bytes memory votingModuleData; + string memory fundingProposalDescription; + if (proposalType == governanceFundProposalType) { + votingModuleData = governanceFundVotingModuleData; + fundingProposalDescription = governanceFundProposalDescription; + } else { + votingModuleData = councilBudgetVotingModuleData; + fundingProposalDescription = councilBudgetProposalDescription; + } + + // Mock the governor.proposeWithModule call + _mockAndExpect( + address(governor), + abi.encodeCall( + IOptimismGovernor.proposeWithModule, + (approvalVotingModule, votingModuleData, fundingProposalDescription, APPROVAL_VOTING_MODULE_ID) + ), + abi.encode(uint256(fuzzedProposalId)) + ); + + vm.expectRevert(IProposalValidator.ProposalValidator_ProposalIdMismatch.selector); + vm.warp(START_TIMESTAMP + 1); + vm.prank(approvedProposer); + validator.moveToVoteFundingProposal( + criteriaValue, + optionsDescriptions, + optionsRecipients, + optionsAmounts, + fundingProposalDescription, + proposalType + ); + } +} + +/// @title ProposalValidator_Setters_Test +/// @notice Tests for setter functions +contract ProposalValidator_SetVotingCycleData_Test is ProposalValidator_TestInit { + function testFuzz_setVotingCycleData_succeeds( + uint256 fuzzedCycleNumber, + uint256 fuzzedStartingTimestamp, + uint256 fuzzedDuration, + uint256 fuzzedDistributionLimit + ) + public + { + vm.assume(fuzzedCycleNumber != CYCLE_NUMBER); // Avoid existing cycle + + // Expect the VotingCycleDataSet event to be emitted + vm.expectEmit(address(validator)); + emit VotingCycleDataSet(fuzzedCycleNumber, fuzzedStartingTimestamp, fuzzedDuration, fuzzedDistributionLimit); + + vm.prank(owner); + validator.setVotingCycleData( + fuzzedCycleNumber, fuzzedStartingTimestamp, fuzzedDuration, fuzzedDistributionLimit + ); + + ( + uint256 actualStartingTimestamp, + uint256 actualDuration, + uint256 actualDistributionLimit, + uint256 actualMovedToVoteTokenCount + ) = validator.votingCycles(fuzzedCycleNumber); + + assertEq(actualStartingTimestamp, fuzzedStartingTimestamp); + assertEq(actualDuration, fuzzedDuration); + assertEq(actualDistributionLimit, fuzzedDistributionLimit); + assertEq(actualMovedToVoteTokenCount, 0); + } + + function testFuzz_setVotingCycleData_notOwner_reverts( + address fuzzedCaller, + uint256 fuzzedCycleNumber, + uint256 fuzzedStartingTimestamp, + uint256 fuzzedDuration, + uint256 fuzzedDistributionLimit + ) + public + { + vm.assume(fuzzedCaller != owner); + + vm.prank(fuzzedCaller); + vm.expectRevert("Ownable: caller is not the owner"); + validator.setVotingCycleData( + fuzzedCycleNumber, fuzzedStartingTimestamp, fuzzedDuration, fuzzedDistributionLimit + ); + } + + function testFuzz_setVotingCycleData_votingCycleAlreadySet_reverts( + uint256 fuzzedStartingTimestamp, + uint256 fuzzedDuration, + uint256 fuzzedDistributionLimit + ) + public + { + vm.expectRevert(ProposalValidator.ProposalValidator_VotingCycleAlreadySet.selector); + vm.prank(owner); + validator.setVotingCycleData(CYCLE_NUMBER, fuzzedStartingTimestamp, fuzzedDuration, fuzzedDistributionLimit); + } +} + +/// @title ProposalValidator_SetProposalDistributionThreshold_Test +/// @notice Tests for the setProposalDistributionThreshold function +contract ProposalValidator_SetProposalDistributionThreshold_Test is ProposalValidator_TestInit { + function testFuzz_setProposalDistributionThreshold_succeeds(uint256 fuzzedNewProposalDistributionThreshold) + public + { + // Expect the ProposalDistributionThresholdSet event to be emitted + vm.expectEmit(address(validator)); + emit ProposalDistributionThresholdSet(fuzzedNewProposalDistributionThreshold); + + vm.prank(owner); + validator.setProposalDistributionThreshold(fuzzedNewProposalDistributionThreshold); + + assertEq(validator.proposalDistributionThreshold(), fuzzedNewProposalDistributionThreshold); + } + + function testFuzz_setProposalDistributionThreshold_notOwner_reverts( + address fuzzedCaller, + uint256 fuzzedThreshold + ) + public + { + vm.assume(fuzzedCaller != owner); + + vm.prank(fuzzedCaller); + vm.expectRevert("Ownable: caller is not the owner"); + validator.setProposalDistributionThreshold(fuzzedThreshold); + } +} + +/// @title ProposalValidator_SetProposalTypeData_Test +/// @notice Tests for the setProposalTypeData function +contract ProposalValidator_SetProposalTypeData_Test is ProposalValidator_TestInit { + function testFuzz_setProposalTypeData_succeeds( + uint8 fuzzedProposalTypeValue, + uint256 fuzzedNewRequiredApprovals, + uint8 fuzzedNewProposalTypeId + ) + public + { + // Bound the proposal type to valid enum values (0-4) + fuzzedProposalTypeValue = uint8(bound(fuzzedProposalTypeValue, 0, 4)); + ProposalValidator.ProposalType proposalType = ProposalValidator.ProposalType(fuzzedProposalTypeValue); + + ProposalValidator.ProposalTypeData memory newData = ProposalValidator.ProposalTypeData({ + requiredApprovals: fuzzedNewRequiredApprovals, + idInConfigurator: fuzzedNewProposalTypeId + }); + + // Expect the ProposalTypeDataSet event to be emitted + vm.expectEmit(address(validator)); + emit ProposalTypeDataSet(proposalType, fuzzedNewRequiredApprovals, fuzzedNewProposalTypeId); + + vm.prank(owner); + validator.setProposalTypeData(proposalType, newData); + + (uint256 requiredApprovals, uint8 idInConfigurator) = validator.proposalTypesData(proposalType); + assertEq(requiredApprovals, fuzzedNewRequiredApprovals); + assertEq(idInConfigurator, fuzzedNewProposalTypeId); + } + + function testFuzz_setProposalTypeData_notOwner_reverts(address fuzzedCaller) public { + vm.assume(fuzzedCaller != owner); + + ProposalValidator.ProposalTypeData memory newData = + ProposalValidator.ProposalTypeData({ requiredApprovals: 4, idInConfigurator: 0 }); + + vm.prank(fuzzedCaller); + vm.expectRevert("Ownable: caller is not the owner"); + validator.setProposalTypeData(ProposalValidator.ProposalType.ProtocolOrGovernorUpgrade, newData); + } +} + +/// @title ProposalValidator_Uncategorized_Test +/// @notice Tests for the `_hashProposalWithModule` function that is not part of the public interface +/// @dev This internal function is only exposed through the ProposalValidatorForTest contract +contract ProposalValidator_Uncategorized_Test is ProposalValidator_TestInit { + function testFuzz_hashProposalWithModule_succeeds( + address fuzzedModule, + bytes memory fuzzedProposalData, + bytes32 fuzzedDescriptionHash + ) + public + view + { + uint256 id = validator.hashProposalWithModule(fuzzedModule, fuzzedProposalData, fuzzedDescriptionHash); + uint256 expectedId = uint256( + keccak256( + abi.encode(address(validator.GOVERNOR()), fuzzedModule, fuzzedProposalData, fuzzedDescriptionHash) + ) + ); + + assertEq(id, expectedId); + } + + function test_hashProposalWithModule_differentInputs_succeeds() public { + address module1 = makeAddr("module1"); + address module2 = makeAddr("module2"); + bytes memory data = abi.encode("data"); + bytes32 descHash = keccak256("desc"); + + uint256 id1 = validator.hashProposalWithModule(module1, data, descHash); + uint256 id2 = validator.hashProposalWithModule(module2, data, descHash); + + assertTrue(id1 != id2); + } +} diff --git a/packages/contracts-bedrock/test/mocks/ProposalValidatorForTest.sol b/packages/contracts-bedrock/test/mocks/ProposalValidatorForTest.sol new file mode 100644 index 0000000000000..16d4f49eb6ad4 --- /dev/null +++ b/packages/contracts-bedrock/test/mocks/ProposalValidatorForTest.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Interfaces +import { IOptimismGovernor } from "interfaces/governance/IOptimismGovernor.sol"; + +// Contracts +import { ProposalValidator } from "src/governance/ProposalValidator.sol"; + +/// @title ProposalValidatorForTest +/// @notice A test contract that exposes the private _hashProposalWithModule function +contract ProposalValidatorForTest is ProposalValidator { + constructor( + address _owner, + IOptimismGovernor _governor, + bytes32 _approvedProposerAttestationSchemaUid, + bytes32 _topDelegatesAttestationSchemaUid + ) + ProposalValidator(_owner, _governor, _approvedProposerAttestationSchemaUid, _topDelegatesAttestationSchemaUid) + { } + + function hashProposalWithModule( + address _module, + bytes memory _proposalData, + bytes32 _descriptionHash + ) + public + view + returns (uint256) + { + return _hashProposalWithModule(_module, _proposalData, _descriptionHash); + } + + /// @notice Exposes proposal data for testing + function getProposalData(uint256 _proposalId) + public + view + returns ( + address proposer_, + ProposalType proposalType_, + bool movedToVote_, + uint256 approvalCount_, + uint256 votingCycle_ + ) + { + ProposalData storage proposal = _proposals[_proposalId]; + return ( + proposal.proposer, proposal.proposalType, proposal.movedToVote, proposal.approvalCount, proposal.votingCycle + ); + } + + function setProposalData( + uint256 _proposalId, + address _proposer, + ProposalType _proposalType, + bool _movedToVote, + uint256 _approvalCount, + uint256 _votingCycle + ) + public + { + _proposals[_proposalId].proposer = _proposer; + _proposals[_proposalId].proposalType = _proposalType; + _proposals[_proposalId].movedToVote = _movedToVote; + _proposals[_proposalId].approvalCount = _approvalCount; + _proposals[_proposalId].votingCycle = _votingCycle; + } + + function mockApproveProposal(uint256 _proposalId, address _delegate) public { + _proposals[_proposalId].delegateApprovals[_delegate] = true; + } + + /// @notice Check if a delegate has approved a proposal + function hasDelegateApproved(uint256 _proposalId, address _delegate) public view returns (bool hasApproved_) { + return _proposals[_proposalId].delegateApprovals[_delegate]; + } +}