From f0edfaae62da3f800f6ba7691fb3044ff2825b96 Mon Sep 17 00:00:00 2001 From: Eric Nordelo Date: Wed, 20 Nov 2024 13:52:29 +0100 Subject: [PATCH] Add Governor component (#1180) * feat: add features * feat: remove access dual dispatchers (#1154) * feat: bump scarb * feat: update CHANGELOG * feat: add features * feat: remove account dual dispatchers (#1168) * feat: move mocks to test_common * Remove token dual dispatchers (#1175) * feat: remove modules * fix: mock * fix: linter * fix: tests * fix: mock * feat: apply review suggestions * feat: update docs * feat: update CHANGELOG * fix: typo * fix: mod * feat: remove unused imports * fix: README * feat: move mocks into test_common * feat: remove mocks from release target * fix: mock * fix: imports * feat: bumo scarb and remove assert_macros from manifest * feat: re-add assert_macros * feat: add test target names * docs: update index * feat: add interface * feat: add double ended queue struct * feat: add proposal core * + * feat: add extension traits * feat: add hash_proposal * feat: add state internal function * feat: add bytearray to utils * fix: bytearray trait types * feat: apply stash * feat: add propose mechanism * feat: add queue mechanism * feat: add execute and cancel * feat: add main trait * feat: finish first extension * feat: add votes quorum fractional extension main logic * feat: add settings extension * feat: add governor votes extension * feat: add core execution extension * feat: add timelock execution extension * refactor: dependencies * feat: add mock * fix: linter * fix: comment * feat: add some tests * fix: linter * refactor: comments * fix: import path * fix: test * feat: add relay mechanism * fix: conditions * feat: add more tests * fix: linter * feat: add more tests * feat: add more tests * Update packages/governance/src/governor/interface.cairo Co-authored-by: Andrew Fleming * Update packages/governance/src/governor/interface.cairo Co-authored-by: Andrew Fleming * Update packages/governance/src/governor/governor.cairo Co-authored-by: Andrew Fleming * Update packages/governance/src/governor/governor.cairo Co-authored-by: Andrew Fleming * Update packages/governance/src/governor/governor.cairo Co-authored-by: Andrew Fleming * Update packages/governance/src/governor/proposal_core.cairo Co-authored-by: Andrew Fleming * feat: apply review updates * fix: linter * fix: bytearray * feat: add more tests * feat: add more tests * Update packages/utils/src/bytearray.cairo Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com> * feat: apply review updates * feat: add cast vote by sig * feat: add more tests * feat: add more tests * fix: remove import * feat: add yet more tests * feat: update CHANGELOG * feat: apply review updates * feat: add tests for governor core execution * feat: add more tests * feat: add more tests * feat: add more tests * fix: linter * feat: add more tests * feat: add more tests * feat: add more tests * fix: warnings * feat: add more tests * feat: add more tests * fix: linter --------- Co-authored-by: Andrew Fleming Co-authored-by: immrsd <103599616+immrsd@users.noreply.github.com> --- CHANGELOG.md | 8 + packages/governance/Scarb.toml | 2 + packages/governance/src/governor.cairo | 8 + .../governance/src/governor/extensions.cairo | 14 + .../extensions/governor_core_execution.cairo | 89 + .../extensions/governor_counting_simple.cairo | 162 ++ .../extensions/governor_settings.cairo | 251 ++ .../governor_timelock_execution.cairo | 306 +++ .../governor/extensions/governor_votes.cairo | 105 + .../governor_votes_quorum_fraction.cairo | 211 ++ .../src/governor/extensions/interface.cairo | 49 + .../governance/src/governor/governor.cairo | 1044 ++++++++ .../governance/src/governor/interface.cairo | 217 ++ .../src/governor/proposal_core.cairo | 117 + packages/governance/src/governor/vote.cairo | 97 + packages/governance/src/lib.cairo | 1 + packages/governance/src/tests.cairo | 1 + packages/governance/src/tests/governor.cairo | 8 + .../src/tests/governor/common.cairo | 246 ++ .../src/tests/governor/test_governor.cairo | 2113 +++++++++++++++++ .../test_governor_core_execution.cairo | 283 +++ .../test_governor_counting_simple.cairo | 313 +++ .../governor/test_governor_settings.cairo | 461 ++++ .../test_governor_timelock_execution.cairo | 745 ++++++ .../tests/governor/test_governor_votes.cairo | 78 + .../test_governor_votes_quorum_fraction.cairo | 232 ++ .../governance/src/utils/call_impls.cairo | 5 +- packages/test_common/src/mocks.cairo | 2 +- packages/test_common/src/mocks/governor.cairo | 408 ++++ packages/test_common/src/mocks/votes.cairo | 103 +- packages/testing/src/constants.cairo | 8 + packages/utils/src/bytearray.cairo | 94 + packages/utils/src/cryptography/snip12.cairo | 15 +- packages/utils/src/lib.cairo | 4 +- packages/utils/src/structs.cairo | 2 + packages/utils/src/structs/checkpoint.cairo | 22 +- sncast_scripts/Scarb.lock | 20 +- 37 files changed, 7761 insertions(+), 83 deletions(-) create mode 100644 packages/governance/src/governor.cairo create mode 100644 packages/governance/src/governor/extensions.cairo create mode 100644 packages/governance/src/governor/extensions/governor_core_execution.cairo create mode 100644 packages/governance/src/governor/extensions/governor_counting_simple.cairo create mode 100644 packages/governance/src/governor/extensions/governor_settings.cairo create mode 100644 packages/governance/src/governor/extensions/governor_timelock_execution.cairo create mode 100644 packages/governance/src/governor/extensions/governor_votes.cairo create mode 100644 packages/governance/src/governor/extensions/governor_votes_quorum_fraction.cairo create mode 100644 packages/governance/src/governor/extensions/interface.cairo create mode 100644 packages/governance/src/governor/governor.cairo create mode 100644 packages/governance/src/governor/interface.cairo create mode 100644 packages/governance/src/governor/proposal_core.cairo create mode 100644 packages/governance/src/governor/vote.cairo create mode 100644 packages/governance/src/tests/governor.cairo create mode 100644 packages/governance/src/tests/governor/common.cairo create mode 100644 packages/governance/src/tests/governor/test_governor.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_core_execution.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_counting_simple.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_settings.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_timelock_execution.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_votes.cairo create mode 100644 packages/governance/src/tests/governor/test_governor_votes_quorum_fraction.cairo create mode 100644 packages/test_common/src/mocks/governor.cairo create mode 100644 packages/utils/src/bytearray.cairo diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c8efd650..1c9cf83c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - SRC9 (Outside Execution) integration to account presets (#1201) +- `SNIP12HashSpanImpl` to `openzeppelin_utils::cryptography::snip12` (#1180) +- GovernorComponent with the following extensions: (#1180) + - GovernorCoreExecutionComponent + - GovernorCountingSimpleComponent + - GovernorSettingsComponent + - GovernorTimelockExecutionComponent + - GovernorVotesQuorumFractionComponent + - GovernorVotesComponent - `is_tx_version_valid` utility function to `openzeppelin_account::utils` (#1224) ### Changed diff --git a/packages/governance/Scarb.toml b/packages/governance/Scarb.toml index d32d87a15..d9afb52cf 100644 --- a/packages/governance/Scarb.toml +++ b/packages/governance/Scarb.toml @@ -46,6 +46,8 @@ casm = false name = "openzeppelin_governance_unittest" build-external-contracts = [ "openzeppelin_test_common::mocks::account::SnakeAccountMock", + "openzeppelin_test_common::mocks::governor::GovernorMock", + "openzeppelin_test_common::mocks::governor::GovernorTimelockedMock", "openzeppelin_test_common::mocks::timelock::TimelockControllerMock", "openzeppelin_test_common::mocks::timelock::MockContract", "openzeppelin_test_common::mocks::timelock::TimelockAttackerMock", diff --git a/packages/governance/src/governor.cairo b/packages/governance/src/governor.cairo new file mode 100644 index 000000000..4d2442162 --- /dev/null +++ b/packages/governance/src/governor.cairo @@ -0,0 +1,8 @@ +pub mod extensions; +pub mod governor; +pub mod interface; +pub mod proposal_core; +pub mod vote; + +pub use governor::{GovernorComponent, DefaultConfig}; +pub use proposal_core::ProposalCore; diff --git a/packages/governance/src/governor/extensions.cairo b/packages/governance/src/governor/extensions.cairo new file mode 100644 index 000000000..c3364c162 --- /dev/null +++ b/packages/governance/src/governor/extensions.cairo @@ -0,0 +1,14 @@ +pub mod governor_core_execution; +pub mod governor_counting_simple; +pub mod governor_settings; +pub mod governor_timelock_execution; +pub mod governor_votes; +pub mod governor_votes_quorum_fraction; +pub mod interface; + +pub use governor_core_execution::GovernorCoreExecutionComponent; +pub use governor_counting_simple::GovernorCountingSimpleComponent; +pub use governor_settings::GovernorSettingsComponent; +pub use governor_timelock_execution::GovernorTimelockExecutionComponent; +pub use governor_votes::GovernorVotesComponent; +pub use governor_votes_quorum_fraction::GovernorVotesQuorumFractionComponent; diff --git a/packages/governance/src/governor/extensions/governor_core_execution.cairo b/packages/governance/src/governor/extensions/governor_core_execution.cairo new file mode 100644 index 000000000..98aeaeeae --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_core_execution.cairo @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_core_execution.cairo) + +/// # GovernorCoreExecution Component +/// +/// Extension of GovernorComponent providing an execution mechanism directly through +/// the Governor itself. For a timelocked execution mechanism, see +/// GovernorTimelockExecutionComponent. +#[starknet::component] +pub mod GovernorCoreExecutionComponent { + use crate::governor::GovernorComponent::{ + InternalExtendedTrait, ComponentState as GovernorComponentState + }; + use crate::governor::GovernorComponent; + use crate::governor::interface::ProposalState; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::account::Call; + use starknet::{ContractAddress, SyscallResultTrait}; + + #[storage] + pub struct Storage {} + + // + // Extensions + // + + pub impl GovernorExecution< + TContractState, + +GovernorComponent::HasComponent, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorSettingsTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + impl GovernorCoreExecution: HasComponent, + +Drop + > of GovernorComponent::GovernorExecutionTrait { + /// See `GovernorComponent::GovernorExecutionTrait::state`. + fn state( + self: @GovernorComponentState, proposal_id: felt252 + ) -> ProposalState { + self._state(proposal_id) + } + + /// See `GovernorComponent::GovernorExecutionTrait::executor`. + fn executor(self: @GovernorComponentState) -> ContractAddress { + starknet::get_contract_address() + } + + /// See `GovernorComponent::GovernorExecutionTrait::execute_operations`. + fn execute_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ) { + for call in calls { + let Call { to, selector, calldata } = *call; + starknet::syscalls::call_contract_syscall(to, selector, calldata).unwrap_syscall(); + }; + } + + /// See `GovernorComponent::GovernorExecutionTrait::queue_operations`. + fn queue_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ) -> u64 { + 0 + } + + /// See `GovernorComponent::GovernorExecutionTrait::proposal_needs_queuing`. + fn proposal_needs_queuing( + self: @GovernorComponentState, proposal_id: felt252 + ) -> bool { + false + } + + /// See `GovernorComponent::GovernorExecutionTrait::cancel_operations`. + fn cancel_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + description_hash: felt252 + ) { + self._cancel(proposal_id, description_hash); + } + } +} diff --git a/packages/governance/src/governor/extensions/governor_counting_simple.cairo b/packages/governance/src/governor/extensions/governor_counting_simple.cairo new file mode 100644 index 000000000..c69745cdd --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_counting_simple.cairo @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_counting_simple.cairo) + +/// # GovernorCountingSimple Component +/// +/// Extension of GovernorComponent for simple vote counting with three options. +#[starknet::component] +pub mod GovernorCountingSimpleComponent { + use crate::governor::GovernorComponent::{ + InternalTrait, ComponentState as GovernorComponentState + }; + use crate::governor::GovernorComponent; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::ContractAddress; + use starknet::storage::{Map, StoragePathEntry, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + type ProposalId = felt252; + + #[storage] + pub struct Storage { + pub Governor_proposals_votes: Map, + } + + /// Supported vote types. + #[derive(Drop, PartialEq, Debug)] + pub enum VoteType { + Against, + For, + Abstain + } + + impl U8TryIntoVoteType of TryInto { + fn try_into(self: u8) -> Option { + match self { + 0 => Option::Some(VoteType::Against), + 1 => Option::Some(VoteType::For), + 2 => Option::Some(VoteType::Abstain), + _ => Option::None, + } + } + } + + impl VoteTypeIntoU8 of Into { + fn into(self: VoteType) -> u8 { + match self { + VoteType::Against => 0, + VoteType::For => 1, + VoteType::Abstain => 2 + } + } + } + + #[starknet::storage_node] + pub struct ProposalVote { + pub against_votes: u256, + pub for_votes: u256, + pub abstain_votes: u256, + pub has_voted: Map + } + + pub mod Errors { + pub const ALREADY_CAST_VOTE: felt252 = 'Already cast vote'; + pub const INVALID_VOTE_TYPE: felt252 = 'Invalid vote type'; + } + + // + // Extensions + // + + pub impl GovernorCounting< + TContractState, + +GovernorComponent::HasComponent, + +GovernorComponent::GovernorQuorumTrait, + +SRC5Component::HasComponent, + impl GovernorCountingSimple: HasComponent, + +Drop + > of GovernorComponent::GovernorCountingTrait { + /// See `GovernorComponent::GovernorCountingTrait::counting_mode`. + fn counting_mode(self: @GovernorComponentState) -> ByteArray { + return "support=bravo&quorum=for,abstain"; + } + + /// See `GovernorComponent::GovernorCountingTrait::count_vote`. + /// + /// In this module, the support follows the `VoteType` enum (from Governor Bravo). + fn count_vote( + ref self: GovernorComponentState, + proposal_id: felt252, + account: ContractAddress, + support: u8, + total_weight: u256, + params: Span + ) -> u256 { + let mut contract = self.get_contract_mut(); + let mut this_component = GovernorCountingSimple::get_component_mut(ref contract); + + let proposal_votes = this_component.Governor_proposals_votes.entry(proposal_id); + assert(!proposal_votes.has_voted.read(account), Errors::ALREADY_CAST_VOTE); + + proposal_votes.has_voted.write(account, true); + + let support: VoteType = support.try_into().expect(Errors::INVALID_VOTE_TYPE); + match support { + VoteType::Against => { + let current_votes = proposal_votes.against_votes.read(); + proposal_votes.against_votes.write(current_votes + total_weight); + }, + VoteType::For => { + let current_votes = proposal_votes.for_votes.read(); + proposal_votes.for_votes.write(current_votes + total_weight); + }, + VoteType::Abstain => { + let current_votes = proposal_votes.abstain_votes.read(); + proposal_votes.abstain_votes.write(current_votes + total_weight); + } + } + total_weight + } + + /// See `GovernorComponent::GovernorCountingTrait::has_voted`. + fn has_voted( + self: @GovernorComponentState, + proposal_id: felt252, + account: ContractAddress + ) -> bool { + let contract = self.get_contract(); + let this_component = GovernorCountingSimple::get_component(contract); + let proposal_votes = this_component.Governor_proposals_votes.entry(proposal_id); + + proposal_votes.has_voted.read(account) + } + + /// See `GovernorComponent::GovernorCountingTrait::quorum_reached`. + fn quorum_reached( + self: @GovernorComponentState, proposal_id: felt252 + ) -> bool { + let contract = self.get_contract(); + let this_component = GovernorCountingSimple::get_component(contract); + + let proposal_votes = this_component.Governor_proposals_votes.entry(proposal_id); + let snapshot = self._proposal_snapshot(proposal_id); + + self.quorum(snapshot) <= proposal_votes.for_votes.read() + + proposal_votes.abstain_votes.read() + } + + /// See `GovernorComponent::GovernorCountingTrait::vote_succeeded`. + /// + /// In this module, the `for_votes` must be strictly over the `against_votes`. + fn vote_succeeded( + self: @GovernorComponentState, proposal_id: felt252 + ) -> bool { + let contract = self.get_contract(); + let this_component = GovernorCountingSimple::get_component(contract); + let proposal_votes = this_component.Governor_proposals_votes.entry(proposal_id); + + proposal_votes.for_votes.read() > proposal_votes.against_votes.read() + } + } +} diff --git a/packages/governance/src/governor/extensions/governor_settings.cairo b/packages/governance/src/governor/extensions/governor_settings.cairo new file mode 100644 index 000000000..9ad903326 --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_settings.cairo @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_settings.cairo) + +/// # GovernorSettings Component +/// +/// Extension of GovernorComponent for settings updatable through governance. +#[starknet::component] +pub mod GovernorSettingsComponent { + use crate::governor::GovernorComponent::{ + InternalExtendedTrait, ComponentState as GovernorComponentState + }; + use crate::governor::GovernorComponent; + use crate::governor::extensions::interface::IGovernorSettingsAdmin; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + #[storage] + pub struct Storage { + pub Governor_voting_delay: u64, + pub Governor_voting_period: u64, + pub Governor_proposal_threshold: u256, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + VotingDelayUpdated: VotingDelayUpdated, + VotingPeriodUpdated: VotingPeriodUpdated, + ProposalThresholdUpdated: ProposalThresholdUpdated + } + + /// Emitted when `Governor_voting_delay` is updated. + #[derive(Drop, starknet::Event)] + pub struct VotingDelayUpdated { + pub old_voting_delay: u64, + pub new_voting_delay: u64 + } + + /// Emitted when `Governor_voting_period` is updated. + #[derive(Drop, starknet::Event)] + pub struct VotingPeriodUpdated { + pub old_voting_period: u64, + pub new_voting_period: u64 + } + + /// Emitted when `Governor_proposal_threshold` is updated. + #[derive(Drop, starknet::Event)] + pub struct ProposalThresholdUpdated { + pub old_proposal_threshold: u256, + pub new_proposal_threshold: u256 + } + + pub mod Errors { + pub const INVALID_VOTING_PERIOD: felt252 = 'Invalid voting period'; + } + + // + // Extensions + // + + pub impl GovernorSettings< + TContractState, + +GovernorComponent::HasComponent, + +SRC5Component::HasComponent, + impl GovernorSettings: HasComponent, + +Drop + > of GovernorComponent::GovernorSettingsTrait { + /// See `GovernorComponent::GovernorSettingsTrait::voting_delay`. + fn voting_delay(self: @GovernorComponentState) -> u64 { + let contract = self.get_contract(); + let this_component = GovernorSettings::get_component(contract); + + this_component.Governor_voting_delay.read() + } + + /// See `GovernorComponent::GovernorSettingsTrait::voting_period`. + fn voting_period(self: @GovernorComponentState) -> u64 { + let contract = self.get_contract(); + let this_component = GovernorSettings::get_component(contract); + + this_component.Governor_voting_period.read() + } + + /// See `GovernorComponent::GovernorSettingsTrait::proposal_threshold`. + fn proposal_threshold(self: @GovernorComponentState) -> u256 { + let contract = self.get_contract(); + let this_component = GovernorSettings::get_component(contract); + + this_component.Governor_proposal_threshold.read() + } + } + + // + // External + // + + #[embeddable_as(GovernorSettingsAdminImpl)] + impl GovernorSettingsAdmin< + TContractState, + +HasComponent, + +GovernorComponent::HasComponent, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorExecutionTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + +Drop + > of IGovernorSettingsAdmin> { + /// Sets the voting delay. + /// + /// Requirements: + /// + /// - Caller must be the governance executor. + /// + /// NOTE: This function does not emit an event if the new voting delay is the same as the + /// old one. + /// + /// May emit a `VotingDelayUpdated` event. + fn set_voting_delay(ref self: ComponentState, new_voting_delay: u64) { + self.assert_only_governance(); + self._set_voting_delay(new_voting_delay); + } + + /// Sets the voting period. + /// + /// NOTE: This function does not emit an event if the new voting period is the same as the + /// old one. + /// + /// Requirements: + /// + /// - Caller must be the governance executor. + /// - `new_voting_period` must be greater than 0. + /// + /// May emit a `VotingPeriodUpdated` event. + fn set_voting_period(ref self: ComponentState, new_voting_period: u64) { + self.assert_only_governance(); + self._set_voting_period(new_voting_period); + } + + /// Sets the proposal threshold. + /// + /// NOTE: This function does not emit an event if the new proposal threshold is the same as + /// the old one. + /// + /// Requirements: + /// + /// - Caller must be the governance executor. + /// + /// May emit a `ProposalThresholdUpdated` event. + fn set_proposal_threshold( + ref self: ComponentState, new_proposal_threshold: u256 + ) { + self.assert_only_governance(); + self._set_proposal_threshold(new_proposal_threshold); + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorExecutionTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + impl Governor: GovernorComponent::HasComponent, + +Drop + > of InternalTrait { + /// Initializes the component by setting the default values. + /// + /// Requirements: + /// + /// - `new_voting_period` must be greater than 0. + /// + /// Emits a `VotingDelayUpdated`, `VotingPeriodUpdated`, and `ProposalThresholdUpdated` + /// event. + fn initializer( + ref self: ComponentState, + new_voting_delay: u64, + new_voting_period: u64, + new_proposal_threshold: u256 + ) { + self._set_voting_delay(new_voting_delay); + self._set_voting_period(new_voting_period); + self._set_proposal_threshold(new_proposal_threshold); + } + + /// Wrapper for `Governor::assert_only_governance`. + fn assert_only_governance(self: @ComponentState) { + let governor_component = get_dep_component!(self, Governor); + governor_component.assert_only_governance(); + } + + /// Internal function to update the voting delay. + /// + /// NOTE: This function does not emit an event if the new voting delay is the same as the + /// old one. + /// + /// May emit a `VotingDelayUpdated` event. + fn _set_voting_delay(ref self: ComponentState, new_voting_delay: u64) { + let old_voting_delay = self.Governor_voting_delay.read(); + if old_voting_delay != new_voting_delay { + self.emit(VotingDelayUpdated { old_voting_delay, new_voting_delay }); + self.Governor_voting_delay.write(new_voting_delay); + } + } + + /// Internal function to update the voting period. + /// + /// Requirements: + /// + /// - `new_voting_period` must be greater than 0. + /// + /// NOTE: This function does not emit an event if the new voting period is the same as the + /// old one. + /// + /// May emit a `VotingPeriodUpdated` event. + fn _set_voting_period(ref self: ComponentState, new_voting_period: u64) { + assert(new_voting_period > 0, Errors::INVALID_VOTING_PERIOD); + + let old_voting_period = self.Governor_voting_period.read(); + if old_voting_period != new_voting_period { + self.emit(VotingPeriodUpdated { old_voting_period, new_voting_period }); + self.Governor_voting_period.write(new_voting_period); + } + } + + /// Internal function to update the proposal threshold. + /// + /// NOTE: This function does not emit an event if the new proposal threshold is the same as + /// the old one. + /// + /// May emit a `ProposalThresholdUpdated` event. + fn _set_proposal_threshold( + ref self: ComponentState, new_proposal_threshold: u256 + ) { + let old_proposal_threshold = self.Governor_proposal_threshold.read(); + if old_proposal_threshold != new_proposal_threshold { + let event = ProposalThresholdUpdated { + old_proposal_threshold, new_proposal_threshold + }; + self.emit(event); + self.Governor_proposal_threshold.write(new_proposal_threshold); + } + } + } +} diff --git a/packages/governance/src/governor/extensions/governor_timelock_execution.cairo b/packages/governance/src/governor/extensions/governor_timelock_execution.cairo new file mode 100644 index 000000000..435733e0a --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_timelock_execution.cairo @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_core_execution.cairo) + +/// # GovernorTimelockExecution Component +/// +/// Extension of GovernorComponent that binds the execution process to an instance of a contract +/// implementing TimelockControllerComponent. This adds a delay, enforced by the TimelockController +/// to all successful proposal (in addition to the voting duration). +/// +/// NOTE: The Governor needs the PROPOSER, EXECUTOR, and CANCELLER roles to work properly. +/// +/// Using this model means the proposal will be operated by the TimelockController and not by the +/// Governor. Thus, the assets and permissions must be attached to the TimelockController. Any asset +/// sent to the Governor will be inaccessible from a proposal, unless executed via +/// `Governor::relay`. +/// +/// WARNING: Setting up the TimelockController to have additional proposers or cancellers besides +/// the governor is very risky, as it grants them the ability to: 1) execute operations as the +/// timelock, and thus possibly performing operations or accessing funds that are expected to only +/// be accessible through a vote, and 2) block governance proposals that have been approved by the +/// voters, effectively executing a Denial of Service attack. +#[starknet::component] +pub mod GovernorTimelockExecutionComponent { + use core::num::traits::Zero; + use crate::governor::GovernorComponent::{ + InternalExtendedTrait, ComponentState as GovernorComponentState + }; + use crate::governor::GovernorComponent; + use crate::governor::extensions::interface::ITimelocked; + use crate::governor::interface::ProposalState; + use crate::timelock::interface::{OperationState, ITimelockDispatcher, ITimelockDispatcherTrait}; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::ContractAddress; + use starknet::account::Call; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + type ProposalId = felt252; + type TimelockProposalId = felt252; + + #[storage] + pub struct Storage { + pub Governor_timelock_controller: ContractAddress, + pub Governor_timelock_ids: Map + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + TimelockUpdated: TimelockUpdated, + } + + /// Emitted when the timelock controller used for proposal execution is modified. + #[derive(Drop, starknet::Event)] + pub struct TimelockUpdated { + pub old_timelock: ContractAddress, + pub new_timelock: ContractAddress + } + + pub mod Errors { + pub const INVALID_TIMELOCK_CONTROLLER: felt252 = 'Invalid timelock controller'; + } + + // + // Extensions + // + + /// NOTE: Some of these function can reenter through the external calls to the timelock, but we + /// assume the timelock is trusted and well behaved (according to TimelockController) and this + /// will not happen. + pub impl GovernorExecution< + TContractState, + +GovernorComponent::HasComponent, + +GovernorComponent::GovernorSettingsTrait, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + impl GovernorCoreExecution: HasComponent, + +Drop + > of GovernorComponent::GovernorExecutionTrait { + /// See `GovernorComponent::GovernorExecutionTrait::state`. + fn state( + self: @GovernorComponentState, proposal_id: felt252 + ) -> ProposalState { + let current_state = self._state(proposal_id); + + if current_state != ProposalState::Queued { + return current_state; + } + + let contract = self.get_contract(); + let this_component = GovernorCoreExecution::get_component(contract); + + let queue_id = this_component.Governor_timelock_ids.read(proposal_id); + let timelock_dispatcher = this_component.get_timelock_dispatcher(); + let operation_state = timelock_dispatcher.get_operation_state(queue_id); + + let is_timelock_operation_pending = operation_state == OperationState::Waiting + || operation_state == OperationState::Ready; + let is_timelock_operation_done = operation_state == OperationState::Done; + + if is_timelock_operation_pending { + ProposalState::Queued + } else if is_timelock_operation_done { + // This can happen if the proposal is executed directly on the timelock. + ProposalState::Executed + } else { + // This can happen if the proposal is canceled directly on the timelock. + ProposalState::Canceled + } + } + + /// See `GovernorComponent::GovernorExecutionTrait::executor`. + /// + /// In this module, the executor is the timelock controller. + fn executor(self: @GovernorComponentState) -> ContractAddress { + let contract = self.get_contract(); + let this_component = GovernorCoreExecution::get_component(contract); + + this_component.timelock() + } + + /// See `GovernorComponent::GovernorExecutionTrait::execute_operations`. + /// + /// Runs the already queued proposal through the timelock. + fn execute_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ) { + let mut contract = self.get_contract_mut(); + let mut this_component = GovernorCoreExecution::get_component_mut(ref contract); + + let timelock_dispatcher = this_component.get_timelock_dispatcher(); + + timelock_dispatcher + .execute_batch(calls, 0, this_component.timelock_salt(description_hash)); + + // Cleanup + this_component.Governor_timelock_ids.write(proposal_id, 0); + } + + /// See `GovernorComponent::GovernorExecutionTrait::queue_operations`. + /// + /// Queue a proposal to the timelock. + fn queue_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ) -> u64 { + let mut contract = self.get_contract_mut(); + let mut this_component = GovernorCoreExecution::get_component_mut(ref contract); + + let timelock_dispatcher = this_component.get_timelock_dispatcher(); + + let delay = timelock_dispatcher.get_min_delay(); + let salt = this_component.timelock_salt(description_hash); + + let queue_id = timelock_dispatcher.hash_operation_batch(calls, 0, salt); + this_component.Governor_timelock_ids.write(proposal_id, queue_id); + + timelock_dispatcher.schedule_batch(calls, 0, salt, delay); + + starknet::get_block_timestamp() + delay + } + + /// See `GovernorComponent::GovernorExecutionTrait::proposal_needs_queuing`. + fn proposal_needs_queuing( + self: @GovernorComponentState, proposal_id: felt252 + ) -> bool { + true + } + + /// See `GovernorComponent::GovernorExecutionTrait::cancel_operations`. + /// + /// Cancels the timelocked proposal if it has already been queued. + fn cancel_operations( + ref self: GovernorComponentState, + proposal_id: felt252, + description_hash: felt252 + ) { + self._cancel(proposal_id, description_hash); + + let mut contract = self.get_contract_mut(); + let mut this_component = GovernorCoreExecution::get_component_mut(ref contract); + + let timelock_id = this_component.Governor_timelock_ids.read(proposal_id); + if timelock_id.is_non_zero() { + let timelock_dispatcher = this_component.get_timelock_dispatcher(); + + timelock_dispatcher.cancel(timelock_id); + this_component.Governor_timelock_ids.write(proposal_id, 0); + } + } + } + + // + // External + // + + #[embeddable_as(TimelockedImpl)] + impl Timelocked< + TContractState, + +HasComponent, + +GovernorComponent::GovernorSettingsTrait, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + +GovernorComponent::HasComponent, + +Drop + > of ITimelocked> { + /// Returns the token that voting power is sourced from. + fn timelock(self: @ComponentState) -> ContractAddress { + self.Governor_timelock_controller.read() + } + + /// Returns the timelock proposal id for a given proposal id. + fn get_timelock_id( + self: @ComponentState, proposal_id: felt252 + ) -> TimelockProposalId { + self.Governor_timelock_ids.read(proposal_id) + } + + /// Updates the associated timelock. + /// + /// Requirements: + /// + /// - Caller must be the governance. + /// + /// Emits a `TimelockUpdated` event. + fn update_timelock( + ref self: ComponentState, new_timelock: ContractAddress + ) { + self.assert_only_governance(); + self._update_timelock(new_timelock); + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +GovernorComponent::GovernorSettingsTrait, + +GovernorComponent::GovernorCountingTrait, + +GovernorComponent::GovernorVotesTrait, + +SRC5Component::HasComponent, + impl Governor: GovernorComponent::HasComponent, + +Drop + > of InternalTrait { + /// Initializes the component by setting the timelock contract address. + /// + /// Requirements: + /// + /// - `timelock_controller` must not be zero. + fn initializer( + ref self: ComponentState, timelock_controller: ContractAddress + ) { + assert(timelock_controller.is_non_zero(), Errors::INVALID_TIMELOCK_CONTROLLER); + self._update_timelock(timelock_controller); + } + + /// Wrapper for `Governor::assert_only_governance`. + fn assert_only_governance(self: @ComponentState) { + let governor_component = get_dep_component!(self, Governor); + governor_component.assert_only_governance(); + } + + /// Computes the `TimelockController` operation salt. + /// + /// It is computed with the governor address itself to avoid collisions across + /// governor instances using the same timelock. + fn timelock_salt( + self: @ComponentState, description_hash: felt252 + ) -> felt252 { + let description_hash: u256 = description_hash.into(); + let this: felt252 = starknet::get_contract_address().into(); + + // Unwrap is safe since the u256 value came from a felt252. + (this.into() ^ description_hash).try_into().unwrap() + } + + /// Returns the timelock contract address wrapped in a ITimelockDispatcher. + fn get_timelock_dispatcher(self: @ComponentState) -> ITimelockDispatcher { + let timelock_controller = self.timelock(); + ITimelockDispatcher { contract_address: timelock_controller } + } + + /// Updates the timelock contract address. + /// + /// Emits a `TimelockUpdated` event. + fn _update_timelock( + ref self: ComponentState, new_timelock: ContractAddress + ) { + let old_timelock = self.Governor_timelock_controller.read(); + self.emit(TimelockUpdated { old_timelock, new_timelock }); + self.Governor_timelock_controller.write(new_timelock); + } + } +} diff --git a/packages/governance/src/governor/extensions/governor_votes.cairo b/packages/governance/src/governor/extensions/governor_votes.cairo new file mode 100644 index 000000000..d997bda35 --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_votes.cairo @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_votes.cairo) + +/// # GovernorVotes Component +/// +/// Extension of GovernorComponent for voting weight extraction from a token with the Votes +/// extension. +#[starknet::component] +pub mod GovernorVotesComponent { + use core::num::traits::Zero; + use crate::governor::GovernorComponent::ComponentState as GovernorComponentState; + use crate::governor::GovernorComponent; + use crate::governor::extensions::interface::IVotesToken; + use crate::votes::interface::{IVotesDispatcher, IVotesDispatcherTrait}; + use openzeppelin_introspection::src5::SRC5Component; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + #[storage] + pub struct Storage { + pub Governor_token: ContractAddress + } + + pub mod Errors { + pub const INVALID_TOKEN: felt252 = 'Invalid votes token'; + } + + // + // Extensions + // + + pub impl GovernorVotes< + TContractState, + +GovernorComponent::HasComponent, + +SRC5Component::HasComponent, + impl GovernorVotes: HasComponent, + +Drop + > of GovernorComponent::GovernorVotesTrait { + /// See `GovernorComponent::GovernorVotesTrait::clock`. + fn clock(self: @GovernorComponentState) -> u64 { + // VotesComponent uses the block timestamp for tracking checkpoints. + // That must be updated in order to allow for more flexible clock modes. + starknet::get_block_timestamp() + } + + /// See `GovernorComponent::GovernorVotesTrait::CLOCK_MODE`. + fn clock_mode(self: @GovernorComponentState) -> ByteArray { + "mode=timestamp&from=starknet::SN_MAIN" + } + + /// See `GovernorComponent::GovernorVotesTrait::get_votes`. + fn get_votes( + self: @GovernorComponentState, + account: ContractAddress, + timepoint: u64, + params: Span + ) -> u256 { + let contract = self.get_contract(); + let this_component = GovernorVotes::get_component(contract); + + let token = this_component.Governor_token.read(); + let votes_dispatcher = IVotesDispatcher { contract_address: token }; + + votes_dispatcher.get_past_votes(account, timepoint) + } + } + + // + // External + // + + #[embeddable_as(VotesTokenImpl)] + impl VotesToken< + TContractState, +HasComponent, +Drop + > of IVotesToken> { + /// Returns the token that voting power is sourced from. + fn token(self: @ComponentState) -> ContractAddress { + self.Governor_token.read() + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +GovernorComponent::HasComponent, + +GovernorComponent::GovernorVotesTrait, + +Drop + > of InternalTrait { + /// Initializes the component by setting the votes token. + /// + /// Requirements: + /// + /// - `votes_token` must not be zero. + fn initializer(ref self: ComponentState, votes_token: ContractAddress) { + assert(votes_token.is_non_zero(), Errors::INVALID_TOKEN); + self.Governor_token.write(votes_token); + } + } +} diff --git a/packages/governance/src/governor/extensions/governor_votes_quorum_fraction.cairo b/packages/governance/src/governor/extensions/governor_votes_quorum_fraction.cairo new file mode 100644 index 000000000..2c91ecdcb --- /dev/null +++ b/packages/governance/src/governor/extensions/governor_votes_quorum_fraction.cairo @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 +// (governance/governor/extensions/governor_votes_quorum_fraction.cairo) + +/// # GovernorVotesQuorumFraction Component +/// +/// Extension of GovernorComponent for voting weight extraction from a token with the Votes +/// extension and a quorum expressed as a fraction of the total supply. +#[starknet::component] +pub mod GovernorVotesQuorumFractionComponent { + use core::num::traits::Zero; + use crate::governor::GovernorComponent::ComponentState as GovernorComponentState; + use crate::governor::GovernorComponent; + use crate::governor::extensions::interface::IQuorumFraction; + use crate::votes::interface::{IVotesDispatcher, IVotesDispatcherTrait}; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_utils::structs::checkpoint::{Trace, TraceTrait}; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + #[storage] + pub struct Storage { + pub Governor_token: ContractAddress, + pub Governor_quorum_numerator_history: Trace, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + QuorumNumeratorUpdated: QuorumNumeratorUpdated + } + + /// Emitted when the quorum numerator is updated. + #[derive(Drop, starknet::Event)] + pub struct QuorumNumeratorUpdated { + pub old_quorum_numerator: u256, + pub new_quorum_numerator: u256 + } + + pub mod Errors { + pub const INVALID_QUORUM_FRACTION: felt252 = 'Invalid quorum fraction'; + pub const INVALID_TOKEN: felt252 = 'Invalid votes token'; + } + + // + // Extensions + // + + pub impl GovernorQuorum< + TContractState, + +GovernorComponent::HasComponent, + +SRC5Component::HasComponent, + impl GovernorVotesQuorumFraction: HasComponent, + +Drop + > of GovernorComponent::GovernorQuorumTrait { + /// See `GovernorComponent::GovernorQuorumTrait::quorum`. + fn quorum(self: @GovernorComponentState, timepoint: u64) -> u256 { + let contract = self.get_contract(); + let this_component = GovernorVotesQuorumFraction::get_component(contract); + + let token = this_component.Governor_token.read(); + let votes_dispatcher = IVotesDispatcher { contract_address: token }; + + let past_total_supply = votes_dispatcher.get_past_total_supply(timepoint); + let quorum_numerator = this_component.quorum_numerator(timepoint); + let quorum_denominator = this_component.quorum_denominator(); + + past_total_supply * quorum_numerator / quorum_denominator + } + } + + pub impl GovernorVotes< + TContractState, + +GovernorComponent::HasComponent, + +SRC5Component::HasComponent, + impl GovernorVotesQuorumFraction: HasComponent, + +Drop + > of GovernorComponent::GovernorVotesTrait { + /// See `GovernorComponent::GovernorVotesTrait::clock`. + fn clock(self: @GovernorComponentState) -> u64 { + // VotesComponent uses the block timestamp for tracking checkpoints. + // That must be updated in order to allow for more flexible clock modes. + starknet::get_block_timestamp() + } + + /// See `GovernorComponent::GovernorVotesTrait::CLOCK_MODE`. + fn clock_mode(self: @GovernorComponentState) -> ByteArray { + "mode=timestamp&from=starknet::SN_MAIN" + } + + /// See `GovernorComponent::GovernorVotesTrait::get_votes`. + fn get_votes( + self: @GovernorComponentState, + account: ContractAddress, + timepoint: u64, + params: Span + ) -> u256 { + let contract = self.get_contract(); + let this_component = GovernorVotesQuorumFraction::get_component(contract); + + let token = this_component.Governor_token.read(); + let votes_dispatcher = IVotesDispatcher { contract_address: token }; + + votes_dispatcher.get_past_votes(account, timepoint) + } + } + + // + // External + // + + #[embeddable_as(QuorumFractionImpl)] + impl QuorumFraction< + TContractState, +HasComponent, +Drop + > of IQuorumFraction> { + /// Returns the token that voting power is sourced from. + fn token(self: @ComponentState) -> ContractAddress { + self.Governor_token.read() + } + + /// Returns the current quorum numerator. + fn current_quorum_numerator(self: @ComponentState) -> u256 { + self.Governor_quorum_numerator_history.deref().latest() + } + + /// Returns the quorum numerator at a specific timepoint. + fn quorum_numerator(self: @ComponentState, timepoint: u64) -> u256 { + // Optimistic search: check the latest checkpoint. + // The initializer call ensures that there is at least one checkpoint in the history. + // + // NOTE: This optimization is especially helpful when the supply is not updated often. + let history = self.Governor_quorum_numerator_history.deref(); + let (_, key, value) = history.latest_checkpoint(); + + if key <= timepoint { + return value; + } + + // Fallback to the binary search + history.upper_lookup(timepoint) + } + + /// Returns the quorum denominator. + fn quorum_denominator(self: @ComponentState) -> u256 { + 1000 + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + +GovernorComponent::GovernorVotesTrait, + impl Governor: GovernorComponent::HasComponent, + +Drop + > of InternalTrait { + /// Initializes the component by setting the votes token and the + /// initial quorum numerator value. + /// + /// Requirements: + /// + /// - `votes_token` must not be zero. + /// - `quorum_numerator` must be less than `quorum_denominator`. + /// + /// Emits a `QuorumNumeratorUpdated` event. + fn initializer( + ref self: ComponentState, + votes_token: ContractAddress, + quorum_numerator: u256 + ) { + assert(votes_token.is_non_zero(), Errors::INVALID_TOKEN); + + self.Governor_token.write(votes_token); + self.update_quorum_numerator(quorum_numerator); + } + + /// Updates the quorum numerator. + /// + /// NOTE: This function does not emit an event if the new quorum numerator is the same as + /// the old one. + /// + /// Requirements: + /// + /// - `new_quorum_numerator` must be less than `quorum_denominator`. + /// + /// May emit a `QuorumNumeratorUpdated` event. + fn update_quorum_numerator( + ref self: ComponentState, new_quorum_numerator: u256 + ) { + let denominator = self.quorum_denominator(); + + assert(new_quorum_numerator <= denominator, Errors::INVALID_QUORUM_FRACTION); + + let old_quorum_numerator = self.current_quorum_numerator(); + + if old_quorum_numerator != new_quorum_numerator { + let governor_component = get_dep_component!(@self, Governor); + + let clock = governor_component.clock(); + + self.Governor_quorum_numerator_history.deref().push(clock, new_quorum_numerator); + + self.emit(QuorumNumeratorUpdated { old_quorum_numerator, new_quorum_numerator }); + } + } + } +} diff --git a/packages/governance/src/governor/extensions/interface.cairo b/packages/governance/src/governor/extensions/interface.cairo new file mode 100644 index 000000000..a8343a81e --- /dev/null +++ b/packages/governance/src/governor/extensions/interface.cairo @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (governance/governor/extensions/interface.cairo) + +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IQuorumFraction { + /// Returns the token that voting power is sourced from. + fn token(self: @TState) -> ContractAddress; + + /// Returns the current quorum numerator. + fn current_quorum_numerator(self: @TState) -> u256; + + /// Returns the quorum numerator at a specific timepoint. + fn quorum_numerator(self: @TState, timepoint: u64) -> u256; + + /// Returns the quorum denominator. + fn quorum_denominator(self: @TState) -> u256; +} + +#[starknet::interface] +pub trait IVotesToken { + /// Returns the token that voting power is sourced from. + fn token(self: @TState) -> ContractAddress; +} + +#[starknet::interface] +pub trait ITimelocked { + /// Returns address of the associated timelock. + fn timelock(self: @TState) -> ContractAddress; + + /// Returns the timelock proposal id for a given proposal id. + fn get_timelock_id(self: @TState, proposal_id: felt252) -> felt252; + + /// Updates the associated timelock. + fn update_timelock(ref self: TState, new_timelock: ContractAddress); +} + +#[starknet::interface] +pub trait IGovernorSettingsAdmin { + /// Sets the voting delay. + fn set_voting_delay(ref self: TState, new_voting_delay: u64); + + /// Sets the voting period. + fn set_voting_period(ref self: TState, new_voting_period: u64); + + /// Sets the proposal threshold. + fn set_proposal_threshold(ref self: TState, new_proposal_threshold: u256); +} diff --git a/packages/governance/src/governor/governor.cairo b/packages/governance/src/governor/governor.cairo new file mode 100644 index 000000000..9a750bbc2 --- /dev/null +++ b/packages/governance/src/governor/governor.cairo @@ -0,0 +1,1044 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (governance/governor/governor.cairo) + +/// # Governor Component +/// +/// Core of the governance system. +#[starknet::component] +pub mod GovernorComponent { + use core::hash::{HashStateTrait, HashStateExTrait}; + use core::num::traits::Zero; + use core::pedersen::PedersenTrait; + use crate::governor::ProposalCore; + use crate::governor::interface::{ProposalState, IGovernor, IGOVERNOR_ID}; + use crate::governor::vote::{Vote, VoteWithReasonAndParams}; + use crate::utils::call_impls::{HashCallImpl, HashCallsImpl}; + use openzeppelin_account::interface::{ISRC6Dispatcher, ISRC6DispatcherTrait}; + use openzeppelin_introspection::src5::SRC5Component::InternalImpl as SRC5InternalImpl; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_utils::bytearray::ByteArrayExtTrait; + use openzeppelin_utils::cryptography::snip12::{OffchainMessageHash, SNIP12Metadata}; + use starknet::account::Call; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + use starknet::{ContractAddress, SyscallResultTrait}; + + type ProposalId = felt252; + + #[storage] + pub struct Storage { + pub Governor_proposals: Map, + pub Governor_nonces: Map + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + ProposalCreated: ProposalCreated, + ProposalQueued: ProposalQueued, + ProposalExecuted: ProposalExecuted, + ProposalCanceled: ProposalCanceled, + VoteCast: VoteCast, + VoteCastWithParams: VoteCastWithParams + } + + /// Emitted when `call` is scheduled as part of operation `id`. + #[derive(Drop, starknet::Event)] + pub struct ProposalCreated { + #[key] + pub proposal_id: felt252, + #[key] + pub proposer: ContractAddress, + pub calls: Span, + pub signatures: Span>, + pub vote_start: u64, + pub vote_end: u64, + pub description: ByteArray + } + + /// Emitted when a proposal is queued. + #[derive(Drop, starknet::Event)] + pub struct ProposalQueued { + #[key] + pub proposal_id: felt252, + pub eta_seconds: u64 + } + + /// Emitted when a proposal is executed. + #[derive(Drop, starknet::Event)] + pub struct ProposalExecuted { + #[key] + pub proposal_id: felt252 + } + + /// Emitted when a proposal is canceled. + #[derive(Drop, starknet::Event)] + pub struct ProposalCanceled { + #[key] + pub proposal_id: felt252 + } + + /// Emitted when a vote is cast without params. + #[derive(Drop, starknet::Event)] + pub struct VoteCast { + #[key] + pub voter: ContractAddress, + pub proposal_id: felt252, + pub support: u8, + pub weight: u256, + pub reason: ByteArray + } + + /// Emitted when a vote is cast with params. + /// + /// NOTE: `support` values should be seen as buckets. Their interpretation depends on the voting + /// module used. `params` are additional encoded parameters. Their interpretation also + /// depends on the voting module used. + #[derive(Drop, starknet::Event)] + pub struct VoteCastWithParams { + #[key] + pub voter: ContractAddress, + pub proposal_id: felt252, + pub support: u8, + pub weight: u256, + pub reason: ByteArray, + pub params: Span + } + + pub mod Errors { + pub const EXECUTOR_ONLY: felt252 = 'Executor only'; + pub const PROPOSER_ONLY: felt252 = 'Proposer only'; + pub const NONEXISTENT_PROPOSAL: felt252 = 'Nonexistent proposal'; + pub const EXISTENT_PROPOSAL: felt252 = 'Existent proposal'; + pub const RESTRICTED_PROPOSER: felt252 = 'Restricted proposer'; + pub const INSUFFICIENT_PROPOSER_VOTES: felt252 = 'Insufficient votes'; + pub const UNEXPECTED_PROPOSAL_STATE: felt252 = 'Unexpected proposal state'; + pub const QUEUE_NOT_IMPLEMENTED: felt252 = 'Queue not implemented'; + pub const INVALID_SIGNATURE: felt252 = 'Invalid signature'; + } + + /// Constants expected to be defined at the contract level used to configure the component + /// behaviour. + /// + /// - `DEFAULT_PARAMS`: Default additional encoded parameters used by cast_vote + /// methods that don't include them. + pub trait ImmutableConfig { + /// Defined as a function since constant Span is not supported. + fn DEFAULT_PARAMS() -> Span; + } + + // + // Extensions traits + // + + pub trait GovernorSettingsTrait { + /// See `interface::IGovernor::voting_delay`. + fn voting_delay(self: @ComponentState) -> u64; + + /// See `interface::IGovernor::voting_period`. + fn voting_period(self: @ComponentState) -> u64; + + /// See `interface::IGovernor::proposal_threshold`. + fn proposal_threshold(self: @ComponentState) -> u256; + } + + pub trait GovernorQuorumTrait { + /// See `interface::IGovernor::quorum`. + fn quorum(self: @ComponentState, timepoint: u64) -> u256; + } + + pub trait GovernorCountingTrait { + /// See `interface::IGovernor::COUNTING_MODE`. + fn counting_mode(self: @ComponentState) -> ByteArray; + + /// Register a vote for `proposal_id` by `account` with a given `support`, + /// voting `weight` and voting `params`. + /// + /// NOTE: Support is generic and can represent various things depending on the voting system + /// used. + fn count_vote( + ref self: ComponentState, + proposal_id: felt252, + account: ContractAddress, + support: u8, + total_weight: u256, + params: Span + ) -> u256; + + /// See `interface::IGovernor::has_voted`. + fn has_voted( + self: @ComponentState, proposal_id: felt252, account: ContractAddress + ) -> bool; + + /// Returns whether amount of votes already cast passes the threshold limit. + fn quorum_reached(self: @ComponentState, proposal_id: felt252) -> bool; + + /// Returns whether the proposal is successful or not. + fn vote_succeeded(self: @ComponentState, proposal_id: felt252) -> bool; + } + + pub trait GovernorVotesTrait { + /// See `interface::IERC6372::clock`. + fn clock(self: @ComponentState) -> u64; + + /// See `interface::IERC6372::CLOCK_MODE`. + fn clock_mode(self: @ComponentState) -> ByteArray; + + /// See `interface::IGovernor::get_votes`. + fn get_votes( + self: @ComponentState, + account: ContractAddress, + timepoint: u64, + params: Span + ) -> u256; + } + + pub trait GovernorExecutionTrait { + /// See `interface::IGovernor::state`. + fn state(self: @ComponentState, proposal_id: felt252) -> ProposalState; + + /// Address through which the governor executes action. + /// Should be used to specify whether the module execute actions through another contract + /// such as a timelock. + /// + /// NOTE: MUST be the governor itself, or an instance of TimelockController with the + /// governor as the only proposer, canceller, and executor. + /// + /// WARNING: When the executor is not the governor itself (i.e. a timelock), it can call + /// functions that are restricted with the `assert_only_governance` guard, and also + /// potentially execute transactions on behalf of the governor. Because of this, this module + /// is designed to work with the TimelockController as the unique potential external + /// executor. + fn executor(self: @ComponentState) -> ContractAddress; + + /// Execution mechanism. Can be used to modify the way execution is + /// performed (for example adding a vault/timelock). + fn execute_operations( + ref self: ComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ); + + /// Queuing mechanism. Can be used to modify the way queuing is + /// performed (for example adding a vault/timelock). + /// + /// Requirements: + /// + /// - Must return a timestamp that describes the expected ETA for execution. If the returned + /// value is 0, the core will consider queueing did not succeed, and the public `queue` + /// function will revert. + fn queue_operations( + ref self: ComponentState, + proposal_id: felt252, + calls: Span, + description_hash: felt252 + ) -> u64; + + /// See `interface::IGovernor::proposal_needs_queuing`. + fn proposal_needs_queuing( + self: @ComponentState, proposal_id: felt252 + ) -> bool; + + /// Cancel mechanism. Can be used to modify the way canceling is + /// performed (for example adding a vault/timelock). + fn cancel_operations( + ref self: ComponentState, + proposal_id: felt252, + description_hash: felt252 + ); + } + + // + // External + // + + #[embeddable_as(GovernorImpl)] + impl Governor< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +GovernorVotesTrait, + impl GovernorQuorum: GovernorQuorumTrait, + impl GovernorCounting: GovernorCountingTrait, + impl GovernorExecution: GovernorExecutionTrait, + impl GovernorSettings: GovernorSettingsTrait, + impl Metadata: SNIP12Metadata, + impl Immutable: ImmutableConfig, + +Drop, + > of IGovernor> { + /// Name of the governor instance (used in building the SNIP-12 domain separator). + fn name(self: @ComponentState) -> felt252 { + Metadata::name() + } + + /// Version of the governor instance (used in building SNIP-12 domain separator). + fn version(self: @ComponentState) -> felt252 { + Metadata::version() + } + + /// A description of the possible `support` values for `cast_vote` and the way these votes + /// are counted, meant to be consumed by UIs to show correct vote options and interpret the + /// results. + /// The string is a URL-encoded sequence of key-value pairs that each describe one aspect, + /// for example `support=bravo&quorum=for,abstain`. + /// + /// There are 2 standard keys: `support` and `quorum`. + /// + /// - `support=bravo` refers to the vote options 0 = Against, 1 = For, 2 = Abstain, as in + /// `GovernorBravo`. + /// - `quorum=bravo` means that only For votes are counted towards quorum. + /// - `quorum=for,abstain` means that both For and Abstain votes are counted towards quorum. + /// + /// If a counting module makes use of encoded `params`, it should include this under a + /// `params` + /// key with a unique name that describes the behavior. For example: + /// + /// - `params=fractional` might refer to a scheme where votes are divided fractionally + /// between for/against/abstain. + /// - `params=erc721` might refer to a scheme where specific NFTs are delegated to vote. + /// + /// NOTE: The string can be decoded by the standard + /// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams[`URLSearchParams`] + /// JavaScript class. + fn COUNTING_MODE(self: @ComponentState) -> ByteArray { + self.counting_mode() + } + + /// Hashing function used to (re)build the proposal id from the proposal details. + fn hash_proposal( + self: @ComponentState, calls: Span, description_hash: felt252 + ) -> felt252 { + self._hash_proposal(calls, description_hash) + } + + /// Returns the state of a proposal, given its id. + fn state(self: @ComponentState, proposal_id: felt252) -> ProposalState { + GovernorExecution::state(self, proposal_id) + } + + /// The number of votes required in order for a voter to become a proposer. + fn proposal_threshold(self: @ComponentState) -> u256 { + self._proposal_threshold() + } + + /// Timepoint used to retrieve user's votes and quorum. If using block number, the snapshot + /// is performed at the end of this block. Hence, voting for this proposal starts at the + /// beginning of the following block. + fn proposal_snapshot(self: @ComponentState, proposal_id: felt252) -> u64 { + self._proposal_snapshot(proposal_id) + } + + /// Timepoint at which votes close. If using block number, votes close at the end of this + /// block, so it is possible to cast a vote during this block. + fn proposal_deadline(self: @ComponentState, proposal_id: felt252) -> u64 { + self._proposal_deadline(proposal_id) + } + + /// The account that created a proposal. + fn proposal_proposer( + self: @ComponentState, proposal_id: felt252 + ) -> ContractAddress { + self._proposal_proposer(proposal_id) + } + + /// The time when a queued proposal becomes executable ("ETA"). Unlike `proposal_snapshot` + /// and `proposal_deadline`, this doesn't use the governor clock, and instead relies on the + /// executor's clock which may be different. In most cases this will be a timestamp. + fn proposal_eta(self: @ComponentState, proposal_id: felt252) -> u64 { + self._proposal_eta(proposal_id) + } + + /// Whether a proposal needs to be queued before execution. + fn proposal_needs_queuing( + self: @ComponentState, proposal_id: felt252 + ) -> bool { + GovernorExecution::proposal_needs_queuing(self, proposal_id) + } + + /// Delay between the proposal is created and the vote starts. The unit this duration is + /// expressed in depends on the clock (see ERC-6372) this contract uses. + /// + /// This can be increased to leave time for users to buy voting power, or delegate it, + /// before the voting of a proposal starts. + fn voting_delay(self: @ComponentState) -> u64 { + GovernorSettings::voting_delay(self) + } + + /// Delay between the vote start and vote end. The unit this duration is expressed in + /// depends on the clock (see ERC-6372) this contract uses. + /// + /// NOTE: The `voting_delay` can delay the start of the vote. This must be considered when + /// setting the voting duration compared to the voting delay. + /// + /// NOTE: This value is stored when the proposal is submitted so that possible changes to + /// the value do not affect proposals that have already been submitted. + fn voting_period(self: @ComponentState) -> u64 { + GovernorSettings::voting_period(self) + } + + /// Minimum number of casted votes required for a proposal to be successful. + /// + /// NOTE: The `timepoint` parameter corresponds to the snapshot used for counting votes. + /// This allows the quorum to scale depending on values such as the total supply of a token + /// at this timepoint. + fn quorum(self: @ComponentState, timepoint: u64) -> u256 { + GovernorQuorum::quorum(self, timepoint) + } + + /// Voting power of an `account` at a specific `timepoint`. + /// + /// NOTE: this can be implemented in a number of ways, for example by reading the delegated + /// balance from one (or multiple) `ERC20Votes` tokens. + fn get_votes( + self: @ComponentState, account: ContractAddress, timepoint: u64 + ) -> u256 { + self._get_votes(account, timepoint, Immutable::DEFAULT_PARAMS()) + } + + /// Voting power of an `account` at a specific `timepoint` given additional encoded + /// parameters. + fn get_votes_with_params( + self: @ComponentState, + account: ContractAddress, + timepoint: u64, + params: Span + ) -> u256 { + self._get_votes(account, timepoint, params) + } + + /// Returns whether `account` has cast a vote on `proposal_id`. + fn has_voted( + self: @ComponentState, proposal_id: felt252, account: ContractAddress + ) -> bool { + GovernorCounting::has_voted(self, proposal_id, account) + } + + /// Creates a new proposal. Voting starts after the delay specified by `voting_delay` and + /// lasts for a duration specified by `voting_period`. Returns the id of the proposal. + /// + /// This function has opt-in frontrunning protection, described in + /// `is_valid_description_for_proposer`. + /// + /// NOTE: The state of the Governor and targets may change between the proposal creation + /// and its execution. This may be the result of third party actions on the targeted + /// contracts, or other governor proposals. For example, the balance of this contract could + /// be updated or its access control permissions may be modified, possibly compromising the + /// proposal's ability to execute successfully (e.g. the governor doesn't have enough value + /// to cover a proposal with multiple transfers). + /// + /// Requirements: + /// + /// - The proposer must be authorized to submit the proposal. + /// - The proposer must have enough votes to submit the proposal if `proposal_threshold` is + /// greater than zero. + /// - The proposal must not already exist. + /// + /// Emits a `ProposalCreated` event. + fn propose( + ref self: ComponentState, calls: Span, description: ByteArray + ) -> felt252 { + let proposer = starknet::get_caller_address(); + + // Check description for restricted proposer + assert( + self.is_valid_description_for_proposer(proposer, @description), + Errors::RESTRICTED_PROPOSER + ); + + // Check proposal threshold + let vote_threshold = self._proposal_threshold(); + if vote_threshold > 0 { + let votes = self + ._get_votes(proposer, self.clock() - 1, Immutable::DEFAULT_PARAMS()); + assert(votes >= vote_threshold, Errors::INSUFFICIENT_PROPOSER_VOTES); + } + + self._propose(calls, @description, proposer) + } + + /// Queues a proposal. Some governors require this step to be performed before execution can + /// happen. If queuing is not necessary, this function may revert. + /// Queuing a proposal requires the quorum to be reached, the vote to be successful, and the + /// deadline to be reached. + /// + /// Returns the id of the proposal. + /// + /// Requirements: + /// + /// - The proposal must be in the `Succeeded` state. + /// - The queue operation must return a non-zero ETA. + /// + /// Emits a `ProposalQueued` event. + fn queue( + ref self: ComponentState, calls: Span, description_hash: felt252 + ) -> felt252 { + let proposal_id = self._hash_proposal(calls, description_hash); + self.validate_state(proposal_id, array![ProposalState::Succeeded].span()); + + let eta_seconds = self.queue_operations(proposal_id, calls, description_hash); + assert(eta_seconds > 0, Errors::QUEUE_NOT_IMPLEMENTED); + + let mut proposal = self.Governor_proposals.read(proposal_id); + proposal.eta_seconds = eta_seconds; + self.Governor_proposals.write(proposal_id, proposal); + + self.emit(ProposalQueued { proposal_id, eta_seconds }); + + proposal_id + } + + /// Executes a successful proposal. This requires the quorum to be reached, the vote to be + /// successful, and the deadline to be reached. Depending on the governor it might also be + /// required that the proposal was queued and that some delay passed. + /// + /// NOTE: Some modules can modify the requirements for execution, for example by adding an + /// additional timelock (See `timelock_controller`). + /// + /// Returns the id of the proposal. + /// + /// Requirements: + /// + /// - The proposal must be in the `Succeeded` or `Queued` state. + /// + /// Emits a `ProposalExecuted` event. + fn execute( + ref self: ComponentState, calls: Span, description_hash: felt252 + ) -> felt252 { + let proposal_id = self._hash_proposal(calls, description_hash); + self + .validate_state( + proposal_id, array![ProposalState::Succeeded, ProposalState::Queued].span() + ); + + // Mark proposal as executed to avoid reentrancy + let mut proposal = self.Governor_proposals.read(proposal_id); + proposal.executed = true; + self.Governor_proposals.write(proposal_id, proposal); + + self.execute_operations(proposal_id, calls, description_hash); + + self.emit(ProposalExecuted { proposal_id }); + + proposal_id + } + + /// Cancels a proposal. A proposal is cancellable by the proposer, but only while it is + /// Pending state, i.e. before the vote starts. + /// + /// Returns the id of the proposal. + /// + /// Requirements: + /// + /// - The proposal must be in the `Pending` state. + /// + /// Emits a `ProposalCanceled` event. + fn cancel( + ref self: ComponentState, calls: Span, description_hash: felt252 + ) -> felt252 { + let proposal_id = self._hash_proposal(calls, description_hash); + self.validate_state(proposal_id, array![ProposalState::Pending].span()); + + assert( + starknet::get_caller_address() == self.proposal_proposer(proposal_id), + Errors::PROPOSER_ONLY + ); + self.cancel_operations(proposal_id, description_hash); + + self.emit(ProposalCanceled { proposal_id }); + + proposal_id + } + + /// Cast a vote. + /// + /// Requirements: + /// + /// - The proposal must be active. + /// + /// Emits a `VoteCast` event. + fn cast_vote( + ref self: ComponentState, proposal_id: felt252, support: u8 + ) -> u256 { + let voter = starknet::get_caller_address(); + self._cast_vote(proposal_id, voter, support, "", Immutable::DEFAULT_PARAMS()) + } + + /// Cast a vote with a `reason`. + /// + /// Requirements: + /// + /// - The proposal must be active. + /// + /// Emits a `VoteCast` event. + fn cast_vote_with_reason( + ref self: ComponentState, + proposal_id: felt252, + support: u8, + reason: ByteArray + ) -> u256 { + let voter = starknet::get_caller_address(); + self._cast_vote(proposal_id, voter, support, reason, Immutable::DEFAULT_PARAMS()) + } + + /// Cast a vote with a `reason` and additional serialized `params`. + /// + /// Requirements: + /// + /// - The proposal must be active. + /// + /// Emits either: + /// - `VoteCast` event if no params are provided. + /// - `VoteCastWithParams` event otherwise. + fn cast_vote_with_reason_and_params( + ref self: ComponentState, + proposal_id: felt252, + support: u8, + reason: ByteArray, + params: Span + ) -> u256 { + let voter = starknet::get_caller_address(); + self._cast_vote(proposal_id, voter, support, reason, params) + } + + /// Cast a vote using the `voter`'s signature. + /// + /// Requirements: + /// + /// - The proposal must be active. + /// - The nonce in the signed message must match the account's current nonce. + /// - `voter` must implement `SRC6::is_valid_signature`. + /// - `signature` should be valid for the message hash. + /// + /// Emits a `VoteCast` event. + fn cast_vote_by_sig( + ref self: ComponentState, + proposal_id: felt252, + support: u8, + voter: ContractAddress, + signature: Span + ) -> u256 { + // 1. Get and increase current nonce + let nonce = self.use_nonce(voter); + + // 2. Build hash for calling `is_valid_signature` + let verifying_contract = starknet::get_contract_address(); + let vote = Vote { verifying_contract, nonce, proposal_id, support, voter }; + let hash = vote.get_message_hash(voter); + + let is_valid_signature_felt = ISRC6Dispatcher { contract_address: voter } + .is_valid_signature(hash, signature.into()); + + // 3. Check either 'VALID' or true for backwards compatibility + let is_valid_signature = is_valid_signature_felt == starknet::VALIDATED + || is_valid_signature_felt == 1; + + assert(is_valid_signature, Errors::INVALID_SIGNATURE); + + // 4. Cast vote + self._cast_vote(proposal_id, voter, support, "", Immutable::DEFAULT_PARAMS()) + } + + /// Cast a vote with a `reason` and additional serialized `params` using the `voter`'s + /// signature. + /// + /// Requirements: + /// + /// - The proposal must be active. + /// - The nonce in the signed message must match the account's current nonce. + /// - `voter` must implement `SRC6::is_valid_signature`. + /// - `signature` should be valid for the message hash. + /// + /// Emits either: + /// - `VoteCast` event if no params are provided. + /// - `VoteCastWithParams` event otherwise. + fn cast_vote_with_reason_and_params_by_sig( + ref self: ComponentState, + proposal_id: felt252, + support: u8, + voter: ContractAddress, + reason: ByteArray, + params: Span, + signature: Span + ) -> u256 { + // 1. Get and increase current nonce + let nonce = self.use_nonce(voter); + + // 2. Build hash for calling `is_valid_signature` + let verifying_contract = starknet::get_contract_address(); + let reason_hash = reason.hash(); + let vote = VoteWithReasonAndParams { + verifying_contract, nonce, proposal_id, support, voter, reason_hash, params + }; + let hash = vote.get_message_hash(voter); + + let is_valid_signature_felt = ISRC6Dispatcher { contract_address: voter } + .is_valid_signature(hash, signature.into()); + + // 3. Check either 'VALID' or true for backwards compatibility + let is_valid_signature = is_valid_signature_felt == starknet::VALIDATED + || is_valid_signature_felt == 1; + + assert(is_valid_signature, Errors::INVALID_SIGNATURE); + + // 4. Cast vote + self._cast_vote(proposal_id, voter, support, reason, params) + } + + /// Returns the next unused nonce for an address. + fn nonces(self: @ComponentState, voter: ContractAddress) -> felt252 { + self.Governor_nonces.read(voter) + } + + /// Relays a transaction or function call to an arbitrary target. + /// + /// In cases where the governance executor is some contract other than the governor itself, + /// like when using a timelock, this function can be invoked in a governance proposal to + /// recover tokens that was sent to the governor contract by mistake. + /// + /// NOTE: If the executor is simply the governor itself, use of `relay` is redundant. + fn relay(ref self: ComponentState, call: Call) { + self.assert_only_governance(); + + let Call { to, selector, calldata } = call; + starknet::syscalls::call_contract_syscall(to, selector, calldata).unwrap_syscall(); + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + +Drop + > of InternalTrait { + /// Initializes the contract by registering the supported interface id. + fn initializer(ref self: ComponentState) { + let mut src5_component = get_dep_component_mut!(ref self, SRC5); + src5_component.register_interface(IGOVERNOR_ID); + } + + /// Returns the proposal object given its id. + fn get_proposal( + self: @ComponentState, proposal_id: felt252 + ) -> ProposalCore { + self.Governor_proposals.read(proposal_id) + } + + /// Checks if the proposer is authorized to submit a proposal with the given description. + /// + /// If the proposal description ends with `#proposer=0x???`, where `0x???` is an address + /// written as a hex string (case insensitive), then the submission of this proposal will + /// only be authorized to said address. + /// + /// This is used for frontrunning protection. By adding this pattern at the end of their + /// proposal, one can ensure that no other address can submit the same proposal. An attacker + /// would have to either remove or change that part, which would result in a different + /// proposal id. + /// + /// NOTE: In Starknet, the Sequencer ensures the order of transactions, but frontrunning + /// can still be achieved by nodes, and potentially other actors in the future with + /// sequencer decentralization. + /// + /// If the description does not match this pattern, it is unrestricted and anyone can submit + /// it. This includes: + /// - If the `0x???` part is not a valid hex string. + /// - If the `0x???` part is a valid hex string, but does not contain exactly 64 hex digits. + /// - If it ends with the expected suffix followed by newlines or other whitespace. + /// - If it ends with some other similar suffix, e.g. `#other=abc`. + /// - If it does not end with any such suffix. + fn is_valid_description_for_proposer( + self: @ComponentState, + proposer: ContractAddress, + description: @ByteArray + ) -> bool { + let length = description.len(); + + // Length is too short to contain a valid proposer suffix + if description.len() < 76 { + return true; + } + + // Extract what would be the `#proposer=` marker beginning the suffix + let marker = description.read_n_bytes(length - 76, 10); + + // If the marker is not found, there is no proposer suffix to check + if marker != "#proposer=" { + return true; + } + + let expected_address = description.read_n_bytes(length - 64, 64); + + proposer.to_byte_array(16, 64) == expected_address + } + + /// Returns a hash of the proposal using the Pedersen hashing algorithm. + fn _hash_proposal( + self: @ComponentState, calls: Span, description_hash: felt252 + ) -> felt252 { + PedersenTrait::new(0).update_with(calls).update_with(description_hash).finalize() + } + + /// Timepoint used to retrieve user's votes and quorum. If using block number, the snapshot + /// is performed at the end of this block. Hence, voting for this proposal starts at the + /// beginning of the following block. + fn _proposal_snapshot(self: @ComponentState, proposal_id: felt252) -> u64 { + self.Governor_proposals.read(proposal_id).vote_start + } + + /// Timepoint at which votes close. If using block number, votes close at the end of this + /// block, so it is possible to cast a vote during this block. + fn _proposal_deadline(self: @ComponentState, proposal_id: felt252) -> u64 { + let proposal = self.Governor_proposals.read(proposal_id); + proposal.vote_start + proposal.vote_duration + } + + /// The account that created a proposal. + fn _proposal_proposer( + self: @ComponentState, proposal_id: felt252 + ) -> ContractAddress { + self.Governor_proposals.read(proposal_id).proposer + } + + /// The time when a queued proposal becomes executable ("ETA"). Unlike `proposal_snapshot` + /// and `proposal_deadline`, this doesn't use the governor clock, and instead relies on the + /// executor's clock which may be different. In most cases this will be a timestamp. + fn _proposal_eta(self: @ComponentState, proposal_id: felt252) -> u64 { + self.Governor_proposals.read(proposal_id).eta_seconds + } + } + + #[generate_trait] + pub impl InternalExtendedImpl< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +GovernorCountingTrait, + +GovernorExecutionTrait, + impl GovernorSettings: GovernorSettingsTrait, + impl GovernorVotes: GovernorVotesTrait, + +Drop + > of InternalExtendedTrait { + /// Asserts that the caller is the governance executor. + /// + /// WARNING: When the executor is not the governor itself (i.e. a timelock), it can call + /// functions that are restricted with this modifier, and also potentially execute + /// transactions on behalf of the governor. Because of this, this module is designed to work + /// with the TimelockController as the unique potential external executor. The timelock + /// MUST have the governor as the only proposer, canceller, and executor. + fn assert_only_governance(self: @ComponentState) { + let executor = self.executor(); + assert(executor == starknet::get_caller_address(), Errors::EXECUTOR_ONLY); + } + + /// Validates that the proposal is in one of the expected states. + fn validate_state( + self: @ComponentState, + proposal_id: felt252, + allowed_states: Span + ) { + let current_state = self.state(proposal_id); + let mut found = false; + for state in allowed_states { + if current_state == *state { + found = true; + break; + } + }; + assert(found, Errors::UNEXPECTED_PROPOSAL_STATE); + } + + /// Consumes a nonce, returns the current value, and increments nonce. + fn use_nonce(ref self: ComponentState, voter: ContractAddress) -> felt252 { + // For each account, the nonce has an initial value of 0, can only be incremented by + // one, and cannot be decremented or reset. This guarantees that the nonce never + // overflows. + let nonce = self.Governor_nonces.read(voter); + self.Governor_nonces.write(voter, nonce + 1); + nonce + } + + /// Internal wrapper for `GovernorVotesTrait::get_votes`. + fn _get_votes( + self: @ComponentState, + account: ContractAddress, + timepoint: u64, + params: Span + ) -> u256 { + GovernorVotes::get_votes(self, account, timepoint, params) + } + + /// Internal wrapper for `GovernorProposeTrait::proposal_threshold`. + fn _proposal_threshold(self: @ComponentState) -> u256 { + GovernorSettings::proposal_threshold(self) + } + + /// Returns the state of a proposal, given its id. + fn _state(self: @ComponentState, proposal_id: felt252) -> ProposalState { + let proposal = self.get_proposal(proposal_id); + + if proposal.executed { + return ProposalState::Executed; + } + if proposal.canceled { + return ProposalState::Canceled; + } + + let snapshot = self._proposal_snapshot(proposal_id); + assert(snapshot.is_non_zero(), Errors::NONEXISTENT_PROPOSAL); + + let current_timepoint = self.clock(); + if current_timepoint < snapshot { + return ProposalState::Pending; + } + + let deadline = self._proposal_deadline(proposal_id); + + if current_timepoint <= deadline { + return ProposalState::Active; + } else if !self.quorum_reached(proposal_id) || !self.vote_succeeded(proposal_id) { + return ProposalState::Defeated; + } else if self._proposal_eta(proposal_id).is_zero() { + return ProposalState::Succeeded; + } else { + return ProposalState::Queued; + } + } + + /// Internal propose mechanism. Returns the proposal id. + /// + /// Requirements: + /// + /// - The proposal must not already exist. + /// + /// Emits a `ProposalCreated` event. + fn _propose( + ref self: ComponentState, + calls: Span, + description: @ByteArray, + proposer: ContractAddress + ) -> felt252 { + let proposal_id = self._hash_proposal(calls, description.hash()); + + assert( + self.Governor_proposals.read(proposal_id).vote_start == 0, Errors::EXISTENT_PROPOSAL + ); + + let snapshot = self.clock() + self.voting_delay(); + let duration = self.voting_period(); + + let proposal = ProposalCore { + proposer, + vote_start: snapshot, + vote_duration: duration, + executed: false, + canceled: false, + eta_seconds: 0 + }; + + self.Governor_proposals.write(proposal_id, proposal); + self + .emit( + ProposalCreated { + proposal_id, + proposer, + calls, + signatures: array![].span(), + vote_start: snapshot, + vote_end: snapshot + duration, + description: description.clone() + } + ); + + proposal_id + } + + /// Internal cancel mechanism with minimal restrictions. + /// A proposal can be cancelled in any state other than Canceled or Executed. + /// + /// NOTE: Once cancelled a proposal can't be re-submitted. + fn _cancel( + ref self: ComponentState, + proposal_id: felt252, + description_hash: felt252 + ) { + let valid_states = array![ + ProposalState::Pending, + ProposalState::Active, + ProposalState::Defeated, + ProposalState::Succeeded, + ProposalState::Queued + ]; + self.validate_state(proposal_id, valid_states.span()); + + let mut proposal = self.Governor_proposals.read(proposal_id); + proposal.canceled = true; + self.Governor_proposals.write(proposal_id, proposal); + } + + /// Internal wrapper for `GovernorCountingTrait::count_vote`. + fn _count_vote( + ref self: ComponentState, + proposal_id: felt252, + account: ContractAddress, + support: u8, + total_weight: u256, + params: Span + ) -> u256 { + self.count_vote(proposal_id, account, support, total_weight, params) + } + + /// Internal vote casting mechanism. + /// + /// Checks that the vote is pending, that it has not been cast yet, retrieve + /// voting weight using `get_votes` and call the `_count_vote` internal function. + /// + /// Emits either: + /// - `VoteCast` event if no params are provided. + /// - `VoteCastWithParams` event otherwise. + fn _cast_vote( + ref self: ComponentState, + proposal_id: felt252, + voter: ContractAddress, + support: u8, + reason: ByteArray, + params: Span + ) -> u256 { + self.validate_state(proposal_id, array![ProposalState::Active].span()); + + let snapshot = self._proposal_snapshot(proposal_id); + let total_weight = self._get_votes(voter, snapshot, params); + let voted_weight = self._count_vote(proposal_id, voter, support, total_weight, params); + + if params.len().is_zero() { + self.emit(VoteCast { voter, proposal_id, support, weight: voted_weight, reason }); + } else { + self + .emit( + VoteCastWithParams { + voter, proposal_id, support, weight: voted_weight, reason, params + } + ); + } + + // TODO: add tally hook when the PreventLateQuorum extension gets added + + voted_weight + } + } +} + +/// Implementation of the default Governor ImmutableConfig. +/// +/// See +/// https://github.com/starknet-io/SNIPs/blob/963848f0752bde75c7087c2446d83b7da8118b25/SNIPS/snip-107.md#defaultconfig-implementation +/// +/// The `DEFAULT_PARAMS` is set to an empty span of felts. +pub impl DefaultConfig of GovernorComponent::ImmutableConfig { + fn DEFAULT_PARAMS() -> Span { + array![].span() + } +} diff --git a/packages/governance/src/governor/interface.cairo b/packages/governance/src/governor/interface.cairo new file mode 100644 index 000000000..317309876 --- /dev/null +++ b/packages/governance/src/governor/interface.cairo @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (governance/governor/interface.cairo) + +use starknet::ContractAddress; +use starknet::account::Call; + +pub const IGOVERNOR_ID: felt252 = 0x1f; // TODO: Update this value. + +/// Interface for a contract that implements the ERC-6372 standard. +#[starknet::interface] +pub trait IERC6372 { + /// Clock used for flagging checkpoints. + /// Can be overridden to implement timestamp based checkpoints (and voting). + fn clock(self: @TState) -> u64; + + /// Description of the clock. + /// See https://eips.ethereum.org/EIPS/eip-6372#clock_mode + fn CLOCK_MODE(self: @TState) -> ByteArray; +} + +#[derive(Copy, PartialEq, Drop, Serde, Debug)] +pub enum ProposalState { + Pending, + Active, + Canceled, + Defeated, + Succeeded, + Queued, + Executed +} + +#[starknet::interface] +pub trait IGovernor { + /// Name of the governor instance (used in building the SNIP-12 domain separator). + fn name(self: @TState) -> felt252; + + /// Version of the governor instance (used in building SNIP-12 domain separator). + fn version(self: @TState) -> felt252; + + /// A description of the possible `support` values for `cast_vote` and the way these votes are + /// counted, meant to be consumed by UIs to show correct vote options and interpret the results. + /// The string is a URL-encoded sequence of key-value pairs that each describe one aspect, for + /// example `support=bravo&quorum=for,abstain`. + /// + /// There are 2 standard keys: `support` and `quorum`. + /// + /// - `support=bravo` refers to the vote options 0 = Against, 1 = For, 2 = Abstain, as in + /// `GovernorBravo`. + /// - `quorum=bravo` means that only For votes are counted towards quorum. + /// - `quorum=for,abstain` means that both For and Abstain votes are counted towards quorum. + /// + /// If a counting module makes use of encoded `params`, it should include this under a `params` + /// key with a unique name that describes the behavior. For example: + /// + /// - `params=fractional` might refer to a scheme where votes are divided fractionally between + /// for/against/abstain. + /// - `params=erc721` might refer to a scheme where specific NFTs are delegated to vote. + /// + /// NOTE: The string can be decoded by the standard + /// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams[`URLSearchParams`] + /// JavaScript class. + fn COUNTING_MODE(self: @TState) -> ByteArray; + + /// Hashing function used to (re)build the proposal id from the proposal details. + fn hash_proposal(self: @TState, calls: Span, description_hash: felt252) -> felt252; + + /// Returns the state of a proposal, given its id. + fn state(self: @TState, proposal_id: felt252) -> ProposalState; + + /// The number of votes required in order for a voter to become a proposer. + fn proposal_threshold(self: @TState) -> u256; + + /// Timepoint used to retrieve user's votes and quorum. If using block number, the snapshot is + /// performed at the end of this block. Hence, voting for this proposal starts at the beginning + /// of the following block. + fn proposal_snapshot(self: @TState, proposal_id: felt252) -> u64; + + /// Timepoint at which votes close. If using block number, votes close at the end of this block, + /// so it is possible to cast a vote during this block. + fn proposal_deadline(self: @TState, proposal_id: felt252) -> u64; + + /// The account that created a proposal. + fn proposal_proposer(self: @TState, proposal_id: felt252) -> ContractAddress; + + /// The time when a queued proposal becomes executable ("ETA"). Unlike `proposal_snapshot` and + /// `proposal_deadline`, this doesn't use the governor clock, and instead relies on the + /// executor's clock which may be different. In most cases this will be a timestamp. + fn proposal_eta(self: @TState, proposal_id: felt252) -> u64; + + /// Whether a proposal needs to be queued before execution. + fn proposal_needs_queuing(self: @TState, proposal_id: felt252) -> bool; + + /// Delay between the proposal is created and the vote starts. The unit this duration is + /// expressed in depends on the clock (see ERC-6372) this contract uses. + /// + /// This can be increased to leave time for users to buy voting power, or delegate it, before + /// the voting of a proposal starts. + fn voting_delay(self: @TState) -> u64; + + /// Delay between the vote start and vote end. The unit this duration is expressed in depends on + /// the clock (see ERC-6372) this contract uses. + /// + /// NOTE: The `voting_delay` can delay the start of the vote. This must be considered when + /// setting the voting duration compared to the voting delay. + /// + /// NOTE: This value is stored when the proposal is submitted so that possible changes to the + /// value do not affect proposals that have already been submitted. + fn voting_period(self: @TState) -> u64; + + /// Minimum number of cast voted required for a proposal to be successful. + /// + /// NOTE: The `timepoint` parameter corresponds to the snapshot used for counting vote. This + /// allows the quorum to scale depending on values such as the total supply of a token at this + /// timepoint. + fn quorum(self: @TState, timepoint: u64) -> u256; + + /// Voting power of an `account` at a specific `timepoint`. + /// + /// NOTE: this can be implemented in a number of ways, for example by reading the delegated + /// balance from one (or multiple) `ERC20Votes` tokens. + fn get_votes(self: @TState, account: ContractAddress, timepoint: u64) -> u256; + + /// Voting power of an `account` at a specific `timepoint` given additional encoded parameters. + fn get_votes_with_params( + self: @TState, account: ContractAddress, timepoint: u64, params: Span + ) -> u256; + + /// Returns whether `account` has cast a vote on `proposal_id`. + fn has_voted(self: @TState, proposal_id: felt252, account: ContractAddress) -> bool; + + /// Creates a new proposal. Vote start after a delay specified by `voting_delay` and + /// lasts for a duration specified by `voting_period`. + /// + /// NOTE: The state of the Governor and targets may change between the proposal creation and + /// its execution. This may be the result of third party actions on the targeted contracts, or + /// other governor proposals. For example, the balance of this contract could be updated or its + /// access control permissions may be modified, possibly compromising the proposal's ability to + /// execute successfully (e.g. the governor doesn't have enough value to cover a proposal with + /// multiple transfers). + /// + /// Returns the id of the proposal. + fn propose(ref self: TState, calls: Span, description: ByteArray) -> felt252; + + /// Queue a proposal. Some governors require this step to be performed before execution can + /// happen. If queuing is not necessary, this function may revert. + /// Queuing a proposal requires the quorum to be reached, the vote to be successful, and the + /// deadline to be reached. + /// + /// Returns the id of the proposal. + fn queue(ref self: TState, calls: Span, description_hash: felt252) -> felt252; + + /// Execute a successful proposal. This requires the quorum to be reached, the vote to be + /// successful, and the deadline to be reached. Depending on the governor it might also be + /// required that the proposal was queued and that some delay passed. + /// + /// NOTE: Some modules can modify the requirements for execution, for example by adding an + /// additional timelock (See `timelock_controller`). + /// + /// Returns the id of the proposal. + fn execute(ref self: TState, calls: Span, description_hash: felt252) -> felt252; + + /// Cancel a proposal. A proposal is cancellable by the proposer, but only while it is Pending + /// state, i.e. before the vote starts. + /// + /// Returns the id of the proposal. + fn cancel(ref self: TState, calls: Span, description_hash: felt252) -> felt252; + + /// Cast a vote. + fn cast_vote(ref self: TState, proposal_id: felt252, support: u8) -> u256; + + /// Cast a vote with a `reason`. + fn cast_vote_with_reason( + ref self: TState, proposal_id: felt252, support: u8, reason: ByteArray + ) -> u256; + + /// Cast a vote with a `reason` and additional serialized `params`. + fn cast_vote_with_reason_and_params( + ref self: TState, + proposal_id: felt252, + support: u8, + reason: ByteArray, + params: Span + ) -> u256; + + /// Cast a vote using the `voter`'s signature. + fn cast_vote_by_sig( + ref self: TState, + proposal_id: felt252, + support: u8, + voter: ContractAddress, + signature: Span + ) -> u256; + + /// Cast a vote with a `reason` and additional serialized `params` using the `voter`'s + /// signature. + fn cast_vote_with_reason_and_params_by_sig( + ref self: TState, + proposal_id: felt252, + support: u8, + voter: ContractAddress, + reason: ByteArray, + params: Span, + signature: Span + ) -> u256; + + /// Returns the next unused nonce for an address. + fn nonces(self: @TState, voter: ContractAddress) -> felt252; + + /// Relays a transaction or function call to an arbitrary target. + /// + /// In cases where the governance executor is some contract other than the governor itself, like + /// when using a timelock, this function can be invoked in a governance proposal to recover + /// tokens that were sent to the governor contract by mistake. + /// + /// NOTE: If the executor is simply the governor itself, use of `relay` is redundant. + fn relay(ref self: TState, call: Call); +} diff --git a/packages/governance/src/governor/proposal_core.cairo b/packages/governance/src/governor/proposal_core.cairo new file mode 100644 index 000000000..748ce0e91 --- /dev/null +++ b/packages/governance/src/governor/proposal_core.cairo @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (governance/governor/proposal_core.cairo) + +use core::traits::DivRem; +use starknet::ContractAddress; +use starknet::storage_access::StorePacking; + +/// Proposal state. +#[derive(Copy, Drop, Serde, PartialEq, Debug)] +pub struct ProposalCore { + pub proposer: ContractAddress, + pub vote_start: u64, + pub vote_duration: u64, + pub executed: bool, + pub canceled: bool, + pub eta_seconds: u64 +} + +const _2_POW_184: felt252 = 0x10000000000000000000000000000000000000000000000; +const _2_POW_120: felt252 = 0x1000000000000000000000000000000; +const _2_POW_56: felt252 = 0x100000000000000; +const _2_POW_55: felt252 = 0x80000000000000; +const _2_POW_54: felt252 = 0x40000000000000; + +/// Packs a ProposalCore into a (felt252, felt252). +/// +/// The packing is done as follows: +/// +/// 1. The first felt of the tuple contains `proposer` serialized. +/// 2. The second felt of the tuple contains `vote_start`, `vote_duration`, `eta_seconds`, +/// `executed`, and `canceled` organized as: +/// - `vote_start` is stored at range [4,67] bits (0-indexed), taking the most significant usable +/// bits. +/// - `vote_duration` is stored at range [68, 131], following `vote_start`. +/// - `eta_seconds` is stored at range [132, 195], following `vote_duration`. +/// - `executed` is stored at range [133, 133], following `eta_seconds`. +/// - `canceled` is stored at range [134, 134], following `executed`. +/// +/// NOTE: In the second felt252, the first four bits are skipped to avoid representation errors due +/// to `felt252` max value being a bit less than a 252 bits number max value +/// (https://docs.starknet.io/documentation/architecture_and_concepts/Cryptography/p-value/). +impl ProposalCoreStorePacking of StorePacking { + fn pack(value: ProposalCore) -> (felt252, felt252) { + let proposal = value; + + // shift-left to reach the corresponding positions + let vote_start = proposal.vote_start.into() * _2_POW_184; + let vote_duration = proposal.vote_duration.into() * _2_POW_120; + let eta_seconds = proposal.eta_seconds.into() * _2_POW_56; + let executed = proposal.executed.into() * _2_POW_55; + let canceled = proposal.canceled.into() * _2_POW_54; + + let second_felt = vote_start + vote_duration + eta_seconds + executed + canceled; + + (proposal.proposer.into(), second_felt) + } + + fn unpack(value: (felt252, felt252)) -> ProposalCore { + let (proposer, second_felt) = value; + let _2_POW_64: NonZero = 0x10000000000000000; + + // shift-right to extract the corresponding values + let val: u256 = second_felt.into() / _2_POW_54.into(); + + let (val, canceled) = DivRem::div_rem(val, 2); + let (val, executed) = DivRem::div_rem(val, 2); + let (val, eta_seconds) = DivRem::div_rem(val, _2_POW_64); + let (val, vote_duration) = DivRem::div_rem(val, _2_POW_64); + let (_, vote_start) = DivRem::div_rem(val, _2_POW_64); + + ProposalCore { + proposer: proposer.try_into().unwrap(), + vote_start: vote_start.try_into().unwrap(), + vote_duration: vote_duration.try_into().unwrap(), + executed: executed > 0, + canceled: canceled > 0, + eta_seconds: eta_seconds.try_into().unwrap() + } + } +} + +#[cfg(test)] +mod tests { + use core::num::traits::Bounded; + use openzeppelin_testing::constants::ALICE; + use super::{ProposalCore, ProposalCoreStorePacking}; + + #[test] + fn test_pack_and_unpack() { + let proposal = ProposalCore { + proposer: ALICE(), + vote_start: 100, + vote_duration: 200, + executed: false, + canceled: true, + eta_seconds: 300 + }; + let packed = ProposalCoreStorePacking::pack(proposal); + let unpacked = ProposalCoreStorePacking::unpack(packed); + assert_eq!(proposal, unpacked); + } + + #[test] + fn test_pack_and_unpack_big_values() { + let proposal = ProposalCore { + proposer: ALICE(), + vote_start: Bounded::MAX, + vote_duration: Bounded::MAX, + executed: true, + canceled: true, + eta_seconds: Bounded::MAX + }; + let packed = ProposalCoreStorePacking::pack(proposal); + let unpacked = ProposalCoreStorePacking::unpack(packed); + assert_eq!(proposal, unpacked); + } +} diff --git a/packages/governance/src/governor/vote.cairo b/packages/governance/src/governor/vote.cairo new file mode 100644 index 000000000..75e98b4b4 --- /dev/null +++ b/packages/governance/src/governor/vote.cairo @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (governance/governor/vote.cairo) + +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::poseidon::PoseidonTrait; +use openzeppelin_utils::cryptography::snip12::{StructHash, SNIP12HashSpanImpl}; +use starknet::ContractAddress; + +// sn_keccak( +// "\"Vote\"(\"verifying_contract\":\"ContractAddress\", +// \"nonce\":\"felt\", +// \"proposal_id\":\"felt\", +// \"support\":\"u128\", +// \"voter\":\"ContractAddress\")" +// ) +// +// Since there's no u8 type in SNIP-12, we use u128 for `support` in the type hash generation. +pub const VOTE_TYPE_HASH: felt252 = + 0x19a625949c5c367200d9ca91e845fe9c5f3c7f04735d97d91f3a6cb4cb30b81; + +#[derive(Copy, Drop, Hash)] +pub struct Vote { + pub verifying_contract: ContractAddress, + pub nonce: felt252, + pub proposal_id: felt252, + pub support: u8, + pub voter: ContractAddress, +} + +impl VoteStructHashImpl of StructHash { + fn hash_struct(self: @Vote) -> felt252 { + let hash_state = PoseidonTrait::new(); + hash_state.update_with(VOTE_TYPE_HASH).update_with(*self).finalize() + } +} + +// sn_keccak( +// "\"VoteWithReasonAndParams\"(\"verifying_contract\":\"ContractAddress\", +// \"nonce\":\"felt\", +// \"proposal_id\":\"felt\", +// \"support\":\"u128\", +// \"voter\":\"ContractAddress\", +// \"reason_hash\":\"felt\", +// \"params\":\"felt*\")" +// ) +// +// Since there's no u8 type in SNIP-12, we use u128 for `support` in the type hash generation. +pub const VOTE_WITH_REASON_AND_PARAMS_TYPE_HASH: felt252 = + 0x1f4ccab7220d6a3c0c1cbc1008bfcb3f6fbdb361dd14fd017fabd229e0cf94b; + +#[derive(Copy, Drop)] +pub struct VoteWithReasonAndParams { + pub verifying_contract: ContractAddress, + pub nonce: felt252, + pub proposal_id: felt252, + pub support: u8, + pub voter: ContractAddress, + pub reason_hash: felt252, + pub params: Span, +} + +impl VoteWithReasonAndParamsStructHashImpl of StructHash { + fn hash_struct(self: @VoteWithReasonAndParams) -> felt252 { + let hash_state = PoseidonTrait::new(); + hash_state + .update_with(VOTE_WITH_REASON_AND_PARAMS_TYPE_HASH) + .update_with(*self.verifying_contract) + .update_with(*self.nonce) + .update_with(*self.proposal_id) + .update_with(*self.support) + .update_with(*self.voter) + .update_with(*self.reason_hash) + .update_with(*self.params) + .finalize() + } +} + +#[cfg(test)] +mod tests { + use super::{VOTE_TYPE_HASH, VOTE_WITH_REASON_AND_PARAMS_TYPE_HASH}; + + #[test] + fn test_vote_type_hash() { + let expected = selector!( + "\"Vote\"(\"verifying_contract\":\"ContractAddress\",\"nonce\":\"felt\",\"proposal_id\":\"felt\",\"support\":\"u128\",\"voter\":\"ContractAddress\")" + ); + assert_eq!(VOTE_TYPE_HASH, expected); + } + + #[test] + fn test_vote_with_reason_and_params_type_hash() { + let expected = selector!( + "\"VoteWithReasonAndParams\"(\"verifying_contract\":\"ContractAddress\",\"nonce\":\"felt\",\"proposal_id\":\"felt\",\"support\":\"u128\",\"voter\":\"ContractAddress\",\"reason_hash\":\"felt\",\"params\":\"felt*\")" + ); + assert_eq!(VOTE_WITH_REASON_AND_PARAMS_TYPE_HASH, expected); + } +} diff --git a/packages/governance/src/lib.cairo b/packages/governance/src/lib.cairo index 041e1b05e..a30d8952a 100644 --- a/packages/governance/src/lib.cairo +++ b/packages/governance/src/lib.cairo @@ -1,3 +1,4 @@ +pub mod governor; pub mod multisig; #[cfg(test)] diff --git a/packages/governance/src/tests.cairo b/packages/governance/src/tests.cairo index 6a51994ef..b9ca1c3dc 100644 --- a/packages/governance/src/tests.cairo +++ b/packages/governance/src/tests.cairo @@ -1,3 +1,4 @@ +mod governor; mod test_multisig; mod test_timelock; mod test_utils; diff --git a/packages/governance/src/tests/governor.cairo b/packages/governance/src/tests/governor.cairo new file mode 100644 index 000000000..981591110 --- /dev/null +++ b/packages/governance/src/tests/governor.cairo @@ -0,0 +1,8 @@ +mod common; +mod test_governor; +mod test_governor_core_execution; +mod test_governor_counting_simple; +mod test_governor_settings; +mod test_governor_timelock_execution; +mod test_governor_votes; +mod test_governor_votes_quorum_fraction; diff --git a/packages/governance/src/tests/governor/common.cairo b/packages/governance/src/tests/governor/common.cairo new file mode 100644 index 000000000..4ebb68ef0 --- /dev/null +++ b/packages/governance/src/tests/governor/common.cairo @@ -0,0 +1,246 @@ +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::pedersen::PedersenTrait; +use crate::governor::GovernorComponent::{InternalImpl, InternalExtendedImpl}; +use crate::governor::interface::{IGovernor, ProposalState}; +use crate::governor::{DefaultConfig, GovernorComponent, ProposalCore}; +use crate::utils::call_impls::{HashCallImpl, HashCallsImpl}; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_test_common::mocks::governor::{GovernorMock, GovernorTimelockedMock}; +use openzeppelin_testing::constants::{ADMIN, OTHER}; +use openzeppelin_utils::bytearray::ByteArrayExtTrait; +use snforge_std::{start_cheat_block_timestamp_global, start_mock_call}; +use starknet::ContractAddress; +use starknet::account::Call; +use starknet::storage::{StoragePathEntry, StoragePointerWriteAccess, StorageMapWriteAccess}; + +pub type ComponentState = GovernorComponent::ComponentState; +pub type ComponentStateTimelocked = + GovernorComponent::ComponentState; + +pub fn CONTRACT_STATE() -> GovernorMock::ContractState { + GovernorMock::contract_state_for_testing() +} + +pub fn COMPONENT_STATE() -> ComponentState { + GovernorComponent::component_state_for_testing() +} + +pub fn CONTRACT_STATE_TIMELOCKED() -> GovernorTimelockedMock::ContractState { + GovernorTimelockedMock::contract_state_for_testing() +} + +pub fn COMPONENT_STATE_TIMELOCKED() -> ComponentStateTimelocked { + GovernorComponent::component_state_for_testing() +} + +// +// Helpers +// + +pub fn hash_proposal(calls: Span, description_hash: felt252) -> felt252 { + PedersenTrait::new(0).update_with(calls).update_with(description_hash).finalize() +} + +pub fn get_proposal_info() -> (felt252, ProposalCore) { + let calls = get_calls(OTHER(), false); + get_proposal_with_id(calls, @"proposal description") +} + +pub fn get_proposal_with_id(calls: Span, description: @ByteArray) -> (felt252, ProposalCore) { + let timestamp = starknet::get_block_timestamp(); + let vote_start = timestamp + GovernorMock::VOTING_DELAY; + let vote_duration = GovernorMock::VOTING_PERIOD; + + let proposal_id = hash_proposal(calls, description.hash()); + let proposal = ProposalCore { + proposer: ADMIN(), + vote_start, + vote_duration, + executed: false, + canceled: false, + eta_seconds: 0 + }; + + (proposal_id, proposal) +} + +pub fn get_calls(to: ContractAddress, mock_syscalls: bool) -> Span { + let call1 = Call { to, selector: selector!("test1"), calldata: array![].span() }; + let call2 = Call { to, selector: selector!("test2"), calldata: array![].span() }; + + if mock_syscalls { + start_mock_call(to, selector!("test1"), 'test1'); + start_mock_call(to, selector!("test2"), 'test2'); + } + + array![call1, call2].span() +} + +pub fn get_state( + state: @ComponentState, id: felt252, external_state_version: bool +) -> ProposalState { + if external_state_version { + state.state(id) + } else { + state._state(id) + } +} + +pub fn get_mock_state( + mock_state: @GovernorMock::ContractState, id: felt252, external_state_version: bool +) -> ProposalState { + if external_state_version { + mock_state.governor.state(id) + } else { + mock_state.governor._state(id) + } +} + +pub fn set_executor( + ref mock_state: GovernorTimelockedMock::ContractState, executor: ContractAddress +) { + mock_state.governor_timelock_execution.Governor_timelock_controller.write(executor); +} + +// +// Setup proposals +// + +pub fn setup_pending_proposal( + ref state: ComponentState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let current_state = get_state(@state, id, external_state_version); + let expected = ProposalState::Pending; + + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_active_proposal( + ref state: ComponentState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Active; + + // Is active before deadline + start_cheat_block_timestamp_global(deadline - 1); + let current_state = get_state(@state, id, external_state_version); + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_queued_proposal( + ref mock_state: GovernorMock::ContractState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, mut proposal) = get_proposal_info(); + + proposal.eta_seconds = 1; + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + + // Quorum reached + start_cheat_block_timestamp_global(deadline + 1); + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote succeeded + proposal_votes.against_votes.write(quorum); + + let expected = ProposalState::Queued; + let current_state = get_mock_state(@mock_state, id, external_state_version); + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_canceled_proposal( + ref state: ComponentState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + state._cancel(id, 0); + + let expected = ProposalState::Canceled; + let current_state = get_state(@state, id, external_state_version); + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_defeated_proposal( + ref mock_state: GovernorMock::ContractState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + + // Quorum not reached + start_cheat_block_timestamp_global(deadline + 1); + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum - 1); + + let expected = ProposalState::Defeated; + let current_state = get_mock_state(@mock_state, id, external_state_version); + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_succeeded_proposal( + ref mock_state: GovernorMock::ContractState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Succeeded; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum reached + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote succeeded + proposal_votes.against_votes.write(quorum); + + let current_state = get_mock_state(@mock_state, id, external_state_version); + assert_eq!(current_state, expected); + + (id, proposal) +} + +pub fn setup_executed_proposal( + ref state: ComponentState, external_state_version: bool +) -> (felt252, ProposalCore) { + let (id, mut proposal) = get_proposal_info(); + + proposal.executed = true; + state.Governor_proposals.write(id, proposal); + + let current_state = get_state(@state, id, external_state_version); + let expected = ProposalState::Executed; + + assert_eq!(current_state, expected); + + (id, proposal) +} diff --git a/packages/governance/src/tests/governor/test_governor.cairo b/packages/governance/src/tests/governor/test_governor.cairo new file mode 100644 index 000000000..2cdbfa40a --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor.cairo @@ -0,0 +1,2113 @@ +use core::num::traits::{Bounded, Zero}; +use crate::governor::GovernorComponent::{InternalImpl, InternalExtendedImpl}; +use crate::governor::interface::{IGovernor, IGOVERNOR_ID, ProposalState}; +use crate::governor::interface::{IGovernorDispatcher, IGovernorDispatcherTrait}; +use crate::governor::vote::{Vote, VoteWithReasonAndParams}; +use crate::governor::{DefaultConfig, GovernorComponent, ProposalCore}; +use crate::tests::governor::common::{ + hash_proposal, get_state, get_mock_state, get_proposal_info, get_calls +}; +use crate::tests::governor::common::{ + setup_pending_proposal, setup_active_proposal, setup_queued_proposal, setup_canceled_proposal, + setup_defeated_proposal, setup_succeeded_proposal, setup_executed_proposal +}; +use crate::tests::governor::common::{COMPONENT_STATE, CONTRACT_STATE}; +use openzeppelin_introspection::src5::SRC5Component::SRC5Impl; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_test_common::mocks::governor::GovernorMock; +use openzeppelin_test_common::mocks::timelock::{ + IMockContractDispatcher, IMockContractDispatcherTrait +}; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants::{ADMIN, OTHER, ZERO, VOTES_TOKEN}; +use openzeppelin_testing::events::EventSpyExt; +use openzeppelin_utils::bytearray::ByteArrayExtTrait; +use openzeppelin_utils::cryptography::snip12::OffchainMessageHash; +use snforge_std::signature::stark_curve::{StarkCurveKeyPairImpl, StarkCurveSignerImpl}; +use snforge_std::{ + start_cheat_caller_address, start_cheat_block_timestamp_global, start_cheat_chain_id_global, + start_mock_call +}; +use snforge_std::{EventSpy, spy_events, test_address}; +use starknet::account::Call; +use starknet::storage::{StoragePathEntry, StoragePointerWriteAccess, StorageMapWriteAccess}; +use starknet::{ContractAddress, contract_address_const}; + +// +// Dispatchers +// + +fn deploy_governor() -> IGovernorDispatcher { + let mut calldata = array![VOTES_TOKEN().into()]; + + let address = utils::declare_and_deploy("GovernorMock", calldata); + IGovernorDispatcher { contract_address: address } +} + +fn deploy_mock_target() -> IMockContractDispatcher { + let mut calldata = array![]; + + let address = utils::declare_and_deploy("MockContract", calldata); + IMockContractDispatcher { contract_address: address } +} + +fn setup_dispatchers() -> (IGovernorDispatcher, IMockContractDispatcher) { + let governor = deploy_governor(); + let target = deploy_mock_target(); + + (governor, target) +} + +fn setup_account(public_key: felt252) -> ContractAddress { + let mut calldata = array![public_key]; + utils::declare_and_deploy("SnakeAccountMock", calldata) +} + +// +// External +// + +#[test] +fn test_name() { + let state = @COMPONENT_STATE(); + let name = state.name(); + assert_eq!(name, 'DAPP_NAME'); +} + +#[test] +fn test_version() { + let state = COMPONENT_STATE(); + let version = state.version(); + assert_eq!(version, 'DAPP_VERSION'); +} + +#[test] +fn test_counting_mode() { + let state = COMPONENT_STATE(); + let counting_mode = state.COUNTING_MODE(); + assert_eq!(counting_mode, "support=bravo&quorum=for,abstain"); +} + +#[test] +fn test_hash_proposal() { + let state = COMPONENT_STATE(); + let calls = get_calls(ZERO(), false); + let description = @"proposal description"; + let description_hash = description.hash(); + + let expected_hash = hash_proposal(calls, description_hash); + let hash = state.hash_proposal(calls, description_hash); + + assert_eq!(hash, expected_hash); +} + +// +// state +// + +#[test] +fn test_state_executed() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_executed_proposal(ref state, true); +} + +#[test] +fn test_state_canceled() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_canceled_proposal(ref state, true); +} + +#[test] +#[should_panic(expected: 'Nonexistent proposal')] +fn test_state_non_existent() { + let state = COMPONENT_STATE(); + + state._state(1); +} + +#[test] +fn test_state_pending() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_pending_proposal(ref state, true); +} + +fn test_state_active_external_version(external_state_version: bool) { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Active; + + // Is active before deadline + start_cheat_block_timestamp_global(deadline - 1); + let current_state = get_state(@state, id, external_state_version); + assert_eq!(current_state, expected); + + // Is active in deadline + start_cheat_block_timestamp_global(deadline); + let current_state = get_state(@state, id, external_state_version); + assert_eq!(current_state, expected); +} + +#[test] +fn test_state_active() { + test_state_active_external_version(true); +} + +fn test_state_defeated_quorum_not_reached_external_version(external_state_version: bool) { + let mut mock_state = CONTRACT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum not reached + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum - 1); + + let current_state = get_mock_state(@mock_state, id, external_state_version); + assert_eq!(current_state, expected); +} + +#[test] +fn test_state_defeated_quorum_not_reached() { + test_state_defeated_quorum_not_reached_external_version(true); +} + +fn test_state_defeated_vote_not_succeeded_external_version(external_state_version: bool) { + let mut mock_state = CONTRACT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum reached + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote not succeeded + proposal_votes.against_votes.write(quorum + 1); + + let current_state = get_mock_state(@mock_state, id, external_state_version); + assert_eq!(current_state, expected); +} + +#[test] +fn test_state_defeated_vote_not_succeeded() { + test_state_defeated_vote_not_succeeded_external_version(true); +} + +#[test] +fn test_state_queued() { + let mut mock_state = CONTRACT_STATE(); + + // The function already asserts the state + setup_queued_proposal(ref mock_state, true); +} + +#[test] +fn test_state_succeeded() { + let mut mock_state = CONTRACT_STATE(); + + // The function already asserts the state + setup_succeeded_proposal(ref mock_state, true); +} + +// +// Proposal info +// + +#[test] +fn test_proposal_threshold() { + let state = COMPONENT_STATE(); + + let threshold = state.proposal_threshold(); + let expected = GovernorMock::PROPOSAL_THRESHOLD; + assert_eq!(threshold, expected); +} + +#[test] +fn test_proposal_snapshot() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let snapshot = state.proposal_snapshot(id); + let expected = proposal.vote_start; + assert_eq!(snapshot, expected); +} + +#[test] +fn test_proposal_deadline() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let deadline = state.proposal_deadline(id); + let expected = proposal.vote_start + proposal.vote_duration; + assert_eq!(deadline, expected); +} + +#[test] +fn test_proposal_proposer() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let proposer = state.proposal_proposer(id); + let expected = proposal.proposer; + assert_eq!(proposer, expected); +} + +#[test] +fn test_proposal_eta() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let eta = state.proposal_eta(id); + let expected = proposal.eta_seconds; + assert_eq!(eta, expected); +} + +#[test] +fn test_proposal_needs_queuing() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let needs_queuing = state.proposal_needs_queuing(id); + assert_eq!(needs_queuing, false); +} + +#[test] +fn test_voting_delay() { + let state = COMPONENT_STATE(); + + let threshold = state.voting_delay(); + let expected = GovernorMock::VOTING_DELAY; + assert_eq!(threshold, expected); +} + +#[test] +fn test_voting_period() { + let state = COMPONENT_STATE(); + + let threshold = state.voting_period(); + let expected = GovernorMock::VOTING_PERIOD; + assert_eq!(threshold, expected); +} + +#[test] +fn test_quorum(timepoint: u64) { + let state = COMPONENT_STATE(); + + let threshold = state.quorum(timepoint); + let expected = if timepoint == Bounded::MAX { + Bounded::MAX + } else { + GovernorMock::QUORUM + }; + assert_eq!(threshold, expected); +} + +// +// get_votes +// + +#[test] +fn test_get_votes() { + let state = COMPONENT_STATE(); + let timepoint = 0; + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + let votes = state.get_votes(OTHER(), timepoint); + assert_eq!(votes, expected_weight); +} + +#[test] +fn test_get_votes_with_params() { + let state = COMPONENT_STATE(); + let timepoint = 0; + let expected_weight = 100; + let params = array!['param'].span(); + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + let votes = state.get_votes_with_params(OTHER(), timepoint, params); + assert_eq!(votes, expected_weight); +} + +// +// has_voted +// + +#[test] +fn test_has_voted() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + + let reason = "reason"; + let params = array![].span(); + + // 1. Assert has not voted + let has_not_voted = !state.has_voted(id, OTHER()); + assert!(has_not_voted); + + // 2. Cast vote + start_mock_call(Zero::zero(), selector!("get_past_votes"), 100_u256); + state._cast_vote(id, OTHER(), 0, reason, params); + + // 3. Assert has voted + let has_voted = state.has_voted(id, OTHER()); + assert!(has_voted); +} + +// +// propose +// + +fn test_propose_external_version(external_state_version: bool) { + let mut state = COMPONENT_STATE(); + let mut spy = spy_events(); + let contract_address = test_address(); + + let calls = get_calls(OTHER(), false); + let proposer = ADMIN(); + let address = ADMIN().to_byte_array(16, 64); + let mut description: ByteArray = "proposal description#proposer=0x"; + description.append(@address); + let description_snap = @description; + let vote_start = starknet::get_block_timestamp() + GovernorMock::VOTING_DELAY; + let vote_end = vote_start + GovernorMock::VOTING_PERIOD; + + // 1. Check id + let id = if external_state_version { + state.propose(calls, description) + } else { + state._propose(calls, description_snap, proposer) + }; + let expected_id = hash_proposal(calls, description_snap.hash()); + assert_eq!(id, expected_id); + + // 2. Check event + spy + .assert_only_event_proposal_created( + contract_address, + expected_id, + proposer, + calls, + array![].span(), + vote_start, + vote_end, + description_snap + ); + + // 3. Check proposal + let proposal = state.get_proposal(id); + let expected = ProposalCore { + proposer: ADMIN(), + vote_start: starknet::get_block_timestamp() + GovernorMock::VOTING_DELAY, + vote_duration: GovernorMock::VOTING_PERIOD, + executed: false, + canceled: false, + eta_seconds: 0 + }; + + assert_eq!(proposal, expected); +} + +#[test] +fn test_propose() { + let votes = GovernorMock::PROPOSAL_THRESHOLD + 1; + + start_cheat_block_timestamp_global(10); + start_mock_call(Zero::zero(), selector!("get_past_votes"), votes); + start_cheat_caller_address(test_address(), ADMIN()); + + test_propose_external_version(true); +} + +#[test] +#[should_panic(expected: 'Existent proposal')] +fn test_propose_existent_proposal() { + let mut state = COMPONENT_STATE(); + let calls = get_calls(OTHER(), false); + let description = "proposal description"; + let votes = GovernorMock::PROPOSAL_THRESHOLD + 1; + + start_cheat_block_timestamp_global(10); + start_mock_call(Zero::zero(), selector!("get_past_votes"), votes); + start_cheat_caller_address(test_address(), ADMIN()); + + state.propose(calls, description.clone()); + + // Propose again + state.propose(calls, description); +} + +#[test] +#[should_panic(expected: 'Insufficient votes')] +fn test_propose_insufficient_proposer_votes() { + let mut state = COMPONENT_STATE(); + let votes = GovernorMock::PROPOSAL_THRESHOLD - 1; + + start_cheat_block_timestamp_global(10); + start_mock_call(Zero::zero(), selector!("get_past_votes"), votes); + + let calls = get_calls(ZERO(), false); + let description = "proposal description"; + + state.propose(calls, description); +} + +#[test] +#[should_panic(expected: 'Restricted proposer')] +fn test_propose_restricted_proposer() { + let mut state = COMPONENT_STATE(); + + let calls = get_calls(ZERO(), false); + let description = + "#proposer=0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; + + state.propose(calls, description); +} + +// +// execute +// + +#[test] +fn test_execute() { + let (mut governor, target) = setup_dispatchers(); + let new_number = 125; + + let call = Call { + to: target.contract_address, + selector: selector!("set_number"), + calldata: array![new_number].span() + }; + let calls = array![call].span(); + let description = "proposal description"; + + let number = target.get_number(); + assert_eq!(number, 0); + + // Mock the get_past_votes call + let quorum = GovernorMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 1. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let id = governor.propose(calls, description.clone()); + + // 2. Cast vote + + // Fast forward the vote delay + current_time += GovernorMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // Cast vote + governor.cast_vote(id, 1); + + // 3. Execute + + // Fast forward the vote duration + current_time += (GovernorMock::VOTING_PERIOD + 1); + start_cheat_block_timestamp_global(current_time); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Succeeded); + + let mut spy = spy_events(); + governor.execute(calls, (@description).hash()); + + // 4. Assertions + let number = target.get_number(); + assert_eq!(number, new_number); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Executed); + + spy.assert_only_event_proposal_executed(governor.contract_address, id); +} + +#[test] +#[should_panic(expected: 'Expected failure')] +fn test_execute_panics() { + let (mut governor, target) = setup_dispatchers(); + + let call = Call { + to: target.contract_address, + selector: selector!("failing_function"), + calldata: array![].span() + }; + let calls = array![call].span(); + let description = "proposal description"; + + // Mock the get_past_votes call + let quorum = GovernorMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 1. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let id = governor.propose(calls, description.clone()); + + // 2. Cast vote + + // Fast forward the vote delay + current_time += GovernorMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // Cast vote + governor.cast_vote(id, 1); + + // 3. Execute + + // Fast forward the vote duration + current_time += (GovernorMock::VOTING_PERIOD + 1); + start_cheat_block_timestamp_global(current_time); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Succeeded); + + governor.execute(calls, (@description).hash()); +} + +#[test] +fn test_execute_correct_id() { + let mut mock_state = CONTRACT_STATE(); + setup_succeeded_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), true); + let description = @"proposal description"; + + let id = mock_state.governor.execute(calls, description.hash()); + let expected_id = hash_proposal(calls, description.hash()); + assert_eq!(id, expected_id); +} + +#[test] +fn test_execute_succeeded_passes() { + let mut mock_state = CONTRACT_STATE(); + setup_succeeded_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), true); + let description = @"proposal description"; + + mock_state.governor.execute(calls, description.hash()); +} + +#[test] +fn test_execute_queued_passes() { + let mut mock_state = CONTRACT_STATE(); + setup_queued_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), true); + let description = @"proposal description"; + + mock_state.governor.execute(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_execute_pending() { + let mut state = COMPONENT_STATE(); + setup_pending_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.execute(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_execute_active() { + let mut state = COMPONENT_STATE(); + setup_active_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.execute(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_execute_defeated() { + let mut mock_state = CONTRACT_STATE(); + setup_defeated_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + mock_state.governor.execute(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_execute_canceled() { + let mut state = COMPONENT_STATE(); + setup_canceled_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.execute(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_execute_executed() { + let mut state = COMPONENT_STATE(); + setup_executed_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.execute(calls, description.hash()); +} + +// +// cancel +// + +#[test] +fn test_cancel() { + let mut state = COMPONENT_STATE(); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + let proposer = ADMIN(); + + // 1. Propose + let id = state._propose(calls, description, proposer); + + let proposal_state = state.state(id); + assert_eq!(proposal_state, ProposalState::Pending); + + // 2. Cancel + let mut spy = spy_events(); + + // Proposer must be the caller + start_cheat_caller_address(test_address(), ADMIN()); + + state.cancel(calls, description.hash()); + + // 3. Assertions + let proposal_state = state.state(id); + assert_eq!(proposal_state, ProposalState::Canceled); + + spy.assert_only_event_proposal_canceled(test_address(), id); +} + +#[test] +fn test_cancel_correct_id() { + let mut state = COMPONENT_STATE(); + setup_pending_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + // Proposer must be the caller + start_cheat_caller_address(test_address(), ADMIN()); + + let id = state.cancel(calls, description.hash()); + let expected_id = hash_proposal(calls, description.hash()); + assert_eq!(id, expected_id); +} + +#[test] +#[should_panic(expected: 'Proposer only')] +fn test_cancel_invalid_caller() { + let mut state = COMPONENT_STATE(); + setup_pending_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + // Proposer must be the caller + start_cheat_caller_address(test_address(), OTHER()); + + state.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_succeeded() { + let mut mock_state = CONTRACT_STATE(); + setup_succeeded_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), true); + let description = @"proposal description"; + + mock_state.governor.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_queued() { + let mut mock_state = CONTRACT_STATE(); + setup_queued_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), true); + let description = @"proposal description"; + + mock_state.governor.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_active() { + let mut state = COMPONENT_STATE(); + setup_active_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_defeated() { + let mut mock_state = CONTRACT_STATE(); + setup_defeated_proposal(ref mock_state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + mock_state.governor.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_canceled() { + let mut state = COMPONENT_STATE(); + setup_canceled_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.cancel(calls, description.hash()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_executed() { + let mut state = COMPONENT_STATE(); + setup_executed_proposal(ref state, false); + + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + + state.cancel(calls, description.hash()); +} + +// +// cast_vote +// + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_pending() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref state, false); + + state.cast_vote(id, 0); +} + +#[test] +fn test_cast_vote_active() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + start_cheat_caller_address(contract_address, OTHER()); + let weight = state.cast_vote(id, 0); + assert_eq!(weight, expected_weight); + + spy.assert_only_event_vote_cast(contract_address, OTHER(), id, 0, expected_weight, @""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_defeated() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + + mock_state.governor.cast_vote(id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + mock_state.governor.cast_vote(id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_queued() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + mock_state.governor.cast_vote(id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + + state.cast_vote(id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + + state.cast_vote(id, 0); +} + +// +// cast_vote_with_reason +// + +#[test] +fn test_cast_vote_with_reason_active() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let reason = "proposal reason"; + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + start_cheat_caller_address(contract_address, OTHER()); + let weight = state.cast_vote_with_reason(id, 0, reason.clone()); + assert_eq!(weight, expected_weight); + + spy.assert_only_event_vote_cast(contract_address, OTHER(), id, 0, expected_weight, @reason); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_pending() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref state, false); + + state.cast_vote_with_reason(id, 0, ""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_defeated() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason(id, 0, ""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason(id, 0, ""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_queued() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason(id, 0, ""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + + state.cast_vote_with_reason(id, 0, ""); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + + state.cast_vote_with_reason(id, 0, ""); +} + +// +// cast_vote_with_reason_and_params +// + +#[test] +fn test_cast_vote_with_reason_and_params_active() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let params = array!['param1', 'param2'].span(); + let reason = "proposal reason"; + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + start_cheat_caller_address(contract_address, OTHER()); + let weight = state.cast_vote_with_reason_and_params(id, 0, reason.clone(), params); + assert_eq!(weight, expected_weight); + + spy + .assert_only_event_vote_cast_with_params( + contract_address, OTHER(), id, 0, expected_weight, @reason, params + ); +} + +#[test] +fn test_cast_vote_with_reason_and_params_active_no_params() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let params = array![].span(); + let reason = "proposal reason"; + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + start_cheat_caller_address(contract_address, OTHER()); + let weight = state.cast_vote_with_reason_and_params(id, 0, reason.clone(), params); + assert_eq!(weight, expected_weight); + + spy.assert_only_event_vote_cast(contract_address, OTHER(), id, 0, expected_weight, @reason); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_and_params_defeated() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason_and_params(id, 0, "", array![].span()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_and_params_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason_and_params(id, 0, "", array![].span()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_and_params_queued() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + mock_state.governor.cast_vote_with_reason_and_params(id, 0, "", array![].span()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_and_params_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + + state.cast_vote_with_reason_and_params(id, 0, "", array![].span()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cast_vote_with_reason_and_params_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + + state.cast_vote_with_reason_and_params(id, 0, "", array![].span()); +} + +// +// cast_vote_by_sig +// + +fn prepare_governor_and_signature( + nonce: felt252 +) -> (IGovernorDispatcher, felt252, felt252, felt252, u8, ContractAddress, u256) { + let mut governor = deploy_governor(); + let calls = get_calls(OTHER(), false); + let description = "proposal description"; + + // Mock the get_past_votes call + let quorum = GovernorMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 1. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let proposal_id = governor.propose(calls, description.clone()); + + // 2. Fast forward the vote delay + current_time += GovernorMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // 3. Generate a key pair and set up an account + let key_pair = StarkCurveKeyPairImpl::generate(); + let voter = setup_account(key_pair.public_key); + + // 4. Set up signature parameters + let support = 1; + let verifying_contract = governor.contract_address; + + // 5. Create and sign the vote message + let vote = Vote { verifying_contract, nonce, proposal_id, support, voter }; + let msg_hash = vote.get_message_hash(voter); + let (r, s) = key_pair.sign(msg_hash).unwrap(); + + (governor, r, s, proposal_id, support, voter, quorum) +} + +#[test] +fn test_cast_vote_by_sig() { + let (governor, r, s, proposal_id, support, voter, quorum) = prepare_governor_and_signature(0); + + // Set up event spy and cast vote + let mut spy = spy_events(); + governor.cast_vote_by_sig(proposal_id, support, voter, array![r, s].span()); + + spy + .assert_only_event_vote_cast( + governor.contract_address, voter, proposal_id, support, quorum, @"" + ); +} + +#[test] +#[should_panic(expected: 'Invalid signature')] +fn test_cast_vote_by_sig_invalid_signature() { + let (governor, r, s, proposal_id, support, voter, _) = prepare_governor_and_signature(0); + + // Cast vote with invalid signature + governor.cast_vote_by_sig(proposal_id, support, voter, array![r + 1, s].span()); +} + +#[test] +#[should_panic(expected: 'Invalid signature')] +fn test_cast_vote_by_sig_invalid_msg_hash() { + // Use invalid nonce (not the account's current nonce) + let (governor, r, s, proposal_id, support, voter, _) = prepare_governor_and_signature(1); + + // Cast vote with invalid msg hash + governor.cast_vote_by_sig(proposal_id, support, voter, array![r, s].span()); +} + +#[test] +fn test_cast_vote_by_sig_hash_generation() { + start_cheat_chain_id_global('SN_TEST'); + + let verifying_contract = contract_address_const::<'VERIFIER'>(); + let nonce = 0; + let proposal_id = 1; + let support = 1; + let voter = contract_address_const::<'VOTER'>(); + + let vote = Vote { verifying_contract, nonce, proposal_id, support, voter }; + let hash = vote.get_message_hash(voter); + + // This hash was computed using starknet js sdk from the following values: + // - name: 'DAPP_NAME' + // - version: 'DAPP_VERSION' + // - chainId: 'SN_TEST' + // - account: 'VOTER' + // - nonce: 0 + // - verifying_contract: 'VERIFIER' + // - proposal_id: 1 + // - support: 1 + // - voter: 'VOTER' + // - revision: '1' + let expected_hash = 0x6541a00fa95d4796bded177fca3cee29d9697174edadebfa4b0b9784379f636; + assert_eq!(hash, expected_hash); +} + +// +// cast_vote_with_reason_and_params_by_sig +// + +fn prepare_governor_and_signature_with_reason_and_params( + reason: @ByteArray, params: Span, nonce: felt252 +) -> (IGovernorDispatcher, felt252, felt252, felt252, u8, ContractAddress, u256) { + let mut governor = deploy_governor(); + let calls = get_calls(OTHER(), false); + let description = "proposal description"; + + // Mock the get_past_votes call + let quorum = GovernorMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 1. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let proposal_id = governor.propose(calls, description.clone()); + + // 2. Fast forward the vote delay + current_time += GovernorMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // 3. Generate a key pair and set up an account + let key_pair = StarkCurveKeyPairImpl::generate(); + let voter = setup_account(key_pair.public_key); + + // 4. Set up signature parameters + let support = 1; + let verifying_contract = governor.contract_address; + let reason_hash = reason.hash(); + + // 5. Create and sign the vote message + let vote = VoteWithReasonAndParams { + verifying_contract, nonce, proposal_id, support, voter, reason_hash, params + }; + let msg_hash = vote.get_message_hash(voter); + let (r, s) = key_pair.sign(msg_hash).unwrap(); + + (governor, r, s, proposal_id, support, voter, quorum) +} + +#[test] +fn test_cast_vote_with_reason_and_params_by_sig() { + let reason = "proposal reason"; + let params = array!['param'].span(); + + let (governor, r, s, proposal_id, support, voter, quorum) = + prepare_governor_and_signature_with_reason_and_params( + @reason, params, 0 + ); + + // Set up event spy and cast vote + let mut spy = spy_events(); + governor + .cast_vote_with_reason_and_params_by_sig( + proposal_id, support, voter, reason.clone(), params, array![r, s].span() + ); + + spy + .assert_only_event_vote_cast_with_params( + governor.contract_address, voter, proposal_id, support, quorum, @reason, params + ); +} + +#[test] +fn test_cast_vote_with_reason_and_params_by_sig_empty_params() { + let reason = "proposal reason"; + let params = array![].span(); + + let (governor, r, s, proposal_id, support, voter, quorum) = + prepare_governor_and_signature_with_reason_and_params( + @reason, params, 0 + ); + + // Set up event spy and cast vote + let mut spy = spy_events(); + governor + .cast_vote_with_reason_and_params_by_sig( + proposal_id, support, voter, reason.clone(), params, array![r, s].span() + ); + + spy + .assert_only_event_vote_cast( + governor.contract_address, voter, proposal_id, support, quorum, @reason + ); +} + +#[test] +#[should_panic(expected: 'Invalid signature')] +fn test_cast_vote_with_reason_and_params_by_sig_invalid_signature() { + let reason = "proposal reason"; + let params = array!['param'].span(); + + let (governor, r, s, proposal_id, support, voter, _) = + prepare_governor_and_signature_with_reason_and_params( + @reason, params, 0 + ); + + // Cast vote with invalid signature + governor + .cast_vote_with_reason_and_params_by_sig( + proposal_id, support, voter, reason.clone(), params, array![r + 1, s].span() + ); +} + +#[test] +#[should_panic(expected: 'Invalid signature')] +fn test_cast_vote_with_reason_and_params_by_sig_invalid_msg_hash() { + let reason = "proposal reason"; + let params = array!['param'].span(); + + // Use invalid nonce (not the account's current nonce) + let invalid_nonce = 1; + let (governor, r, s, proposal_id, support, voter, _) = + prepare_governor_and_signature_with_reason_and_params( + @reason, params, invalid_nonce + ); + + // Cast vote with invalid msg hash + governor + .cast_vote_with_reason_and_params_by_sig( + proposal_id, support, voter, reason.clone(), params, array![r, s].span() + ); +} + +#[test] +fn test_cast_vote_with_reason_and_params_by_sig_hash_generation() { + start_cheat_chain_id_global('SN_TEST'); + + let verifying_contract = contract_address_const::<'VERIFIER'>(); + let nonce = 0; + let proposal_id = 1; + let support = 1; + let voter = contract_address_const::<'VOTER'>(); + let reason_hash = 'hash'; + let params = array!['param'].span(); + let vote = VoteWithReasonAndParams { + verifying_contract, nonce, proposal_id, support, voter, reason_hash, params + }; + let hash = vote.get_message_hash(voter); + + // This hash was computed using starknet js sdk from the following values: + // - name: 'DAPP_NAME' + // - version: 'DAPP_VERSION' + // - chainId: 'SN_TEST' + // - account: 'VOTER' + // - nonce: 0 + // - verifying_contract: 'VERIFIER' + // - proposal_id: 1 + // - support: 1 + // - voter: 'VOTER' + // - reason_hash: 'hash' + // - params: ['param'] + // - revision: '1' + let expected_hash = 0x729b7bd36fcddae615f7e2d7c78270e7f820f0dec9faf7842e0187670d3e84a; + assert_eq!(hash, expected_hash); +} + +// +// nonces +// + +#[test] +fn test_nonces() { + let mut state = COMPONENT_STATE(); + let nonce = state.nonces(OTHER()); + assert_eq!(nonce, 0); + + state.Governor_nonces.write(OTHER(), 1); + let nonce = state.nonces(OTHER()); + assert_eq!(nonce, 1); +} + +// +// relay +// + +#[test] +fn test_relay() { + let (mut governor, target) = setup_dispatchers(); + let new_number = 1; + let contract_address = governor.contract_address; + + let call = Call { + to: target.contract_address, + selector: selector!("set_number"), + calldata: array![new_number].span() + }; + + let number = target.get_number(); + assert_eq!(number, 0); + + start_cheat_caller_address(contract_address, contract_address); + governor.relay(call); + + let number = target.get_number(); + assert_eq!(number, new_number); +} + +#[test] +#[should_panic(expected: 'Expected failure')] +fn test_relay_panics() { + let (mut governor, target) = setup_dispatchers(); + let contract_address = governor.contract_address; + + let call = Call { + to: target.contract_address, + selector: selector!("failing_function"), + calldata: array![].span() + }; + + start_cheat_caller_address(contract_address, contract_address); + governor.relay(call); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_relay_invalid_caller() { + let mut state = COMPONENT_STATE(); + let call = Call { to: ADMIN(), selector: selector!("foo"), calldata: array![].span() }; + + start_cheat_caller_address(test_address(), OTHER()); + state.relay(call); +} + +// +// Internal +// + +#[test] +fn test_initializer() { + let mut state = COMPONENT_STATE(); + let contract_state = CONTRACT_STATE(); + + state.initializer(); + + assert!(contract_state.supports_interface(IGOVERNOR_ID)); +} + +// +// get_proposal +// + +#[test] +fn test_get_empty_proposal() { + let mut state = COMPONENT_STATE(); + + let proposal = state.get_proposal(0); + + assert_eq!(proposal.proposer, ZERO()); + assert_eq!(proposal.vote_start, 0); + assert_eq!(proposal.vote_duration, 0); + assert_eq!(proposal.executed, false); + assert_eq!(proposal.canceled, false); + assert_eq!(proposal.eta_seconds, 0); +} + +#[test] +fn test_get_proposal() { + let mut state = COMPONENT_STATE(); + let (_, expected_proposal) = get_proposal_info(); + + state.Governor_proposals.write(1, expected_proposal); + + let proposal = state.get_proposal(1); + assert_eq!(proposal, expected_proposal); +} + +// +// is_valid_description_for_proposer +// + +#[test] +fn test_is_valid_description_too_short() { + let state = COMPONENT_STATE(); + let short_description: ByteArray = + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + assert_eq!(short_description.len(), 75); + + let is_valid = state.is_valid_description_for_proposer(ADMIN(), @short_description); + assert!(is_valid); +} + +#[test] +fn test_is_valid_description_wrong_suffix() { + let state = COMPONENT_STATE(); + let description = + "?proposer=0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; + + let is_valid = state.is_valid_description_for_proposer(ADMIN(), @description); + assert!(is_valid); +} + +#[test] +fn test_is_valid_description_wrong_proposer() { + let state = COMPONENT_STATE(); + let description = + "#proposer=0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d"; + + let is_valid = state.is_valid_description_for_proposer(ADMIN(), @description); + assert!(!is_valid); +} + +#[test] +fn test_is_valid_description_valid_proposer() { + let state = COMPONENT_STATE(); + let address = ADMIN().to_byte_array(16, 64); + let mut description: ByteArray = "#proposer=0x"; + + description.append(@address); + + let is_valid = state.is_valid_description_for_proposer(ADMIN(), @description); + assert!(is_valid); +} + +// +// _hash_proposal +// + +#[test] +fn test__hash_proposal() { + let state = COMPONENT_STATE(); + let calls = get_calls(ZERO(), false); + let description = @"proposal description"; + let description_hash = description.hash(); + + let expected_hash = hash_proposal(calls, description_hash); + let hash = state._hash_proposal(calls, description_hash); + + assert_eq!(hash, expected_hash); +} + +// +// Proposal info +// + +#[test] +fn test__proposal_threshold() { + let mut state = COMPONENT_STATE(); + + let threshold = state._proposal_threshold(); + let expected = GovernorMock::PROPOSAL_THRESHOLD; + assert_eq!(threshold, expected); +} + +#[test] +fn test__proposal_snapshot() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let snapshot = state._proposal_snapshot(id); + let expected = proposal.vote_start; + assert_eq!(snapshot, expected); +} + +#[test] +fn test__proposal_deadline() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let deadline = state._proposal_deadline(id); + let expected = proposal.vote_start + proposal.vote_duration; + assert_eq!(deadline, expected); +} + +#[test] +fn test__proposal_proposer() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let proposer = state._proposal_proposer(id); + let expected = proposal.proposer; + assert_eq!(proposer, expected); +} + +#[test] +fn test__proposal_eta() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let eta = state._proposal_eta(id); + let expected = proposal.eta_seconds; + assert_eq!(eta, expected); +} + +// +// assert_only_governance +// + +#[test] +fn test_assert_only_governance() { + let mut state = COMPONENT_STATE(); + let contract_address = test_address(); + + start_cheat_caller_address(contract_address, contract_address); + + state.assert_only_governance(); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_assert_only_governance_not_executor() { + let mut state = COMPONENT_STATE(); + let contract_address = test_address(); + + start_cheat_caller_address(contract_address, OTHER()); + + state.assert_only_governance(); +} + +// +// validate_state +// + +#[test] +fn test_validate_state() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + // Current should be Pending + let current_state = state._state(id); + assert_eq!(current_state, ProposalState::Pending); + + let valid_states = array![ProposalState::Pending]; + state.validate_state(id, valid_states.span()); + + let valid_states = array![ProposalState::Pending, ProposalState::Active]; + state.validate_state(id, valid_states.span()); + + let valid_states = array![ + ProposalState::Executed, ProposalState::Active, ProposalState::Pending + ]; + state.validate_state(id, valid_states.span()); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_validate_state_invalid() { + let mut state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + // Current should be Pending + let current_state = state._state(id); + assert_eq!(current_state, ProposalState::Pending); + + let valid_states = array![ProposalState::Active].span(); + state.validate_state(id, valid_states); +} + +// +// _get_votes +// + +#[test] +fn test__get_votes() { + let mut state = COMPONENT_STATE(); + let timepoint = 0; + let expected_weight = 100; + let params = array!['param'].span(); + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + let votes = state._get_votes(OTHER(), timepoint, params); + assert_eq!(votes, expected_weight); +} + +// +// _state +// + +#[test] +fn test__state_executed() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_executed_proposal(ref state, false); +} + +#[test] +fn test__state_canceled() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_canceled_proposal(ref state, false); +} + +#[test] +#[should_panic(expected: 'Nonexistent proposal')] +fn test__state_non_existent() { + let state = COMPONENT_STATE(); + + state._state(1); +} + +#[test] +fn test__state_pending() { + let mut state = COMPONENT_STATE(); + + // The function already asserts the state + setup_pending_proposal(ref state, false); +} + +#[test] +fn test__state_active() { + test_state_active_external_version(false); +} + +#[test] +fn test__state_defeated_quorum_not_reached() { + test_state_defeated_quorum_not_reached_external_version(false); +} + +#[test] +fn test__state_defeated_vote_not_succeeded() { + test_state_defeated_vote_not_succeeded_external_version(false); +} + +#[test] +fn test__state_queued() { + let mut mock_state = CONTRACT_STATE(); + + // The function already asserts the state + setup_queued_proposal(ref mock_state, false); +} + +#[test] +fn test__state_succeeded() { + let mut mock_state = CONTRACT_STATE(); + + // The function already asserts the state + setup_succeeded_proposal(ref mock_state, false); +} + +// +// _propose +// + +#[test] +fn test__propose() { + test_propose_external_version(false); +} + +#[test] +#[should_panic(expected: 'Existent proposal')] +fn test__propose_existent_proposal() { + let mut state = COMPONENT_STATE(); + let calls = get_calls(OTHER(), false); + let description = @"proposal description"; + let proposer = ADMIN(); + + let id = state._propose(calls, description, proposer); + let expected_id = hash_proposal(calls, description.hash()); + assert_eq!(id, expected_id); + + // Propose again + state._propose(calls, description, proposer); +} + +// +// _cancel +// + +#[test] +fn test__cancel_pending() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref state, false); + + state._cancel(id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test__cancel_active() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + + state._cancel(id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test__cancel_defeated() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + + mock_state.governor._cancel(id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test__cancel_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + mock_state.governor._cancel(id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test__cancel_queued() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + mock_state.governor._cancel(id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cancel_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + + // Cancel again + state._cancel(id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cancel_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + + state._cancel(id, 0); +} + +// +// _cast_vote +// + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_pending() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref state, false); + let params = array![].span(); + + state._cast_vote(id, OTHER(), 0, "", params); +} + +#[test] +fn test__cast_vote_active_no_params() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let reason = "reason"; + let params = array![].span(); + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + let weight = state._cast_vote(id, OTHER(), 0, reason, params); + assert_eq!(weight, expected_weight); + + spy.assert_only_event_vote_cast(contract_address, OTHER(), id, 0, expected_weight, @"reason"); +} + +#[test] +fn test__cast_vote_active_with_params() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + let mut spy = spy_events(); + let contract_address = test_address(); + + let reason = "reason"; + let params = array!['param'].span(); + let expected_weight = 100; + + // Mock the get_past_votes call + start_mock_call(Zero::zero(), selector!("get_past_votes"), expected_weight); + + let weight = state._cast_vote(id, OTHER(), 0, reason, params); + assert_eq!(weight, expected_weight); + + spy + .assert_event_vote_cast_with_params( + contract_address, OTHER(), id, 0, expected_weight, @"reason", params + ); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_defeated() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + let params = array![].span(); + + mock_state.governor._cast_vote(id, OTHER(), 0, "", params); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + let params = array![].span(); + + mock_state.governor._cast_vote(id, OTHER(), 0, "", params); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_queued() { + let mut mock_state = CONTRACT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + let params = array![].span(); + + mock_state.governor._cast_vote(id, OTHER(), 0, "", params); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + let params = array![].span(); + + state._cast_vote(id, OTHER(), 0, "", params); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test__cast_vote_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + let params = array![].span(); + + state._cast_vote(id, OTHER(), 0, "", params); +} + +// +// Event helpers +// + +#[generate_trait] +pub(crate) impl GovernorSpyHelpersImpl of GovernorSpyHelpers { + fn assert_event_proposal_created( + ref self: EventSpy, + contract: ContractAddress, + proposal_id: felt252, + proposer: ContractAddress, + calls: Span, + signatures: Span>, + vote_start: u64, + vote_end: u64, + description: @ByteArray + ) { + let expected = GovernorComponent::Event::ProposalCreated( + GovernorComponent::ProposalCreated { + proposal_id, + proposer, + calls, + signatures, + vote_start, + vote_end, + description: description.clone() + } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_proposal_created( + ref self: EventSpy, + contract: ContractAddress, + proposal_id: felt252, + proposer: ContractAddress, + calls: Span, + signatures: Span>, + vote_start: u64, + vote_end: u64, + description: @ByteArray + ) { + self + .assert_event_proposal_created( + contract, + proposal_id, + proposer, + calls, + signatures, + vote_start, + vote_end, + description + ); + self.assert_no_events_left_from(contract); + } + + fn assert_event_vote_cast( + ref self: EventSpy, + contract: ContractAddress, + voter: ContractAddress, + proposal_id: felt252, + support: u8, + weight: u256, + reason: @ByteArray + ) { + let expected = GovernorComponent::Event::VoteCast( + GovernorComponent::VoteCast { + voter, proposal_id, support, weight, reason: reason.clone() + } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_vote_cast( + ref self: EventSpy, + contract: ContractAddress, + voter: ContractAddress, + proposal_id: felt252, + support: u8, + weight: u256, + reason: @ByteArray + ) { + self.assert_event_vote_cast(contract, voter, proposal_id, support, weight, reason); + self.assert_no_events_left_from(contract); + } + + fn assert_event_vote_cast_with_params( + ref self: EventSpy, + contract: ContractAddress, + voter: ContractAddress, + proposal_id: felt252, + support: u8, + weight: u256, + reason: @ByteArray, + params: Span + ) { + let expected = GovernorComponent::Event::VoteCastWithParams( + GovernorComponent::VoteCastWithParams { + voter, proposal_id, support, weight, reason: reason.clone(), params + } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_vote_cast_with_params( + ref self: EventSpy, + contract: ContractAddress, + voter: ContractAddress, + proposal_id: felt252, + support: u8, + weight: u256, + reason: @ByteArray, + params: Span + ) { + self + .assert_event_vote_cast_with_params( + contract, voter, proposal_id, support, weight, reason, params + ); + self.assert_no_events_left_from(contract); + } + + fn assert_event_proposal_queued( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252, eta_seconds: u64 + ) { + let expected = GovernorComponent::Event::ProposalQueued( + GovernorComponent::ProposalQueued { proposal_id, eta_seconds } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_proposal_queued( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252, eta_seconds: u64 + ) { + self.assert_event_proposal_queued(contract, proposal_id, eta_seconds); + self.assert_no_events_left_from(contract); + } + + fn assert_event_proposal_executed( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252 + ) { + let expected = GovernorComponent::Event::ProposalExecuted( + GovernorComponent::ProposalExecuted { proposal_id } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_proposal_executed( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252 + ) { + self.assert_event_proposal_executed(contract, proposal_id); + self.assert_no_events_left_from(contract); + } + + fn assert_event_proposal_canceled( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252 + ) { + let expected = GovernorComponent::Event::ProposalCanceled( + GovernorComponent::ProposalCanceled { proposal_id } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_proposal_canceled( + ref self: EventSpy, contract: ContractAddress, proposal_id: felt252 + ) { + self.assert_event_proposal_canceled(contract, proposal_id); + self.assert_no_events_left_from(contract); + } +} diff --git a/packages/governance/src/tests/governor/test_governor_core_execution.cairo b/packages/governance/src/tests/governor/test_governor_core_execution.cairo new file mode 100644 index 000000000..6b305dc8c --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_core_execution.cairo @@ -0,0 +1,283 @@ +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::InternalImpl; +use crate::governor::extensions::GovernorCoreExecutionComponent::GovernorExecution; +use crate::governor::interface::{IGovernor, ProposalState}; +use crate::tests::governor::common::{ + setup_pending_proposal, setup_active_proposal, setup_defeated_proposal, setup_queued_proposal, + setup_canceled_proposal, setup_succeeded_proposal, setup_executed_proposal +}; +use crate::tests::governor::common::{get_proposal_info, get_calls, COMPONENT_STATE, CONTRACT_STATE}; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_testing::constants::OTHER; +use snforge_std::start_cheat_block_timestamp_global; +use starknet::storage::{StoragePathEntry, StoragePointerWriteAccess, StorageMapWriteAccess}; + +// +// state +// + +#[test] +fn test_state_executed() { + let mut component_state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref component_state, false); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Executed); +} + +#[test] +fn test_state_canceled() { + let mut component_state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref component_state, false); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Canceled); +} + +#[test] +#[should_panic(expected: 'Nonexistent proposal')] +fn test_state_non_existent() { + let component_state = COMPONENT_STATE(); + + GovernorExecution::state(@component_state, 1); +} + +#[test] +fn test_state_pending() { + let mut component_state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref component_state, false); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Pending); +} + +#[test] +fn test_state_active() { + let mut component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + component_state.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Active; + + // Is active before deadline + start_cheat_block_timestamp_global(deadline - 1); + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); + + // Is active in deadline + start_cheat_block_timestamp_global(deadline); + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_defeated_quorum_not_reached() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum not reached + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum - 1); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_defeated_vote_not_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum reached + let quorum = mock_state.governor.quorum(0); + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote not succeeded + proposal_votes.against_votes.write(quorum + 1); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_queued() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Queued); +} + +#[test] +fn test_state_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Succeeded); +} + +// +// executor +// + +#[test] +fn test_executor() { + let component_state = COMPONENT_STATE(); + let expected = starknet::get_contract_address(); + + assert_eq!(GovernorExecution::executor(@component_state), expected); +} + +// +// execute_operations +// + +#[test] +fn test_execute_operations(id: felt252) { + let mut component_state = COMPONENT_STATE(); + let calls = get_calls(OTHER(), true); + let description_hash = 'hash'; + + GovernorExecution::execute_operations(ref component_state, id, calls, description_hash); +} + +#[test] +#[should_panic(expected: "Contract not deployed at address: 0x4f54484552")] +fn test_execute_operations_panics() { + let mut component_state = COMPONENT_STATE(); + let id = 0; + let calls = get_calls(OTHER(), false); + let description_hash = 'hash'; + + GovernorExecution::execute_operations(ref component_state, id, calls, description_hash); +} + +// +// queue_operations +// + +#[test] +fn test_queue_operations(id: felt252) { + let mut component_state = COMPONENT_STATE(); + let calls = array![].span(); + let description_hash = 'hash'; + + let eta = GovernorExecution::queue_operations(ref component_state, id, calls, description_hash); + + assert_eq!(eta, 0); +} + +// +// proposal_needs_queuing +// + +#[test] +fn test_proposal_needs_queuing(id: felt252) { + let component_state = COMPONENT_STATE(); + + assert_eq!(GovernorExecution::proposal_needs_queuing(@component_state, id), false); +} + +// +// cancel_operations +// + +#[test] +fn test_cancel_operations_pending() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_pending_proposal(ref state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_active() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_active_proposal(ref state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_defeated() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + let (id, _) = setup_defeated_proposal(ref mock_state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + let (id, _) = setup_succeeded_proposal(ref mock_state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_queued() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + let (id, _) = setup_queued_proposal(ref mock_state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_operations_canceled() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_canceled_proposal(ref state, false); + + // Cancel again + GovernorExecution::cancel_operations(ref state, id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_operations_executed() { + let mut state = COMPONENT_STATE(); + let (id, _) = setup_executed_proposal(ref state, false); + + GovernorExecution::cancel_operations(ref state, id, 0); +} diff --git a/packages/governance/src/tests/governor/test_governor_counting_simple.cairo b/packages/governance/src/tests/governor/test_governor_counting_simple.cairo new file mode 100644 index 000000000..13a12a7dd --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_counting_simple.cairo @@ -0,0 +1,313 @@ +use core::num::traits::Bounded; +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::InternalImpl; +use crate::governor::extensions::GovernorCountingSimpleComponent::{VoteType, GovernorCounting}; +use crate::governor::interface::IGovernor; +use crate::tests::governor::common::{COMPONENT_STATE, CONTRACT_STATE}; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_testing::constants::OTHER; +use starknet::storage::{StorageMapReadAccess, StorageMapWriteAccess}; +use starknet::storage::{StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess}; + +// +// try_into +// + +#[test] +fn test_try_into_u8_VoteType() { + let number = 0_u8; + let vote_type: VoteType = number.try_into().unwrap(); + assert_eq!(vote_type, VoteType::Against); + + let number = 1_u8; + let vote_type: VoteType = number.try_into().unwrap(); + assert_eq!(vote_type, VoteType::For); + + let number = 2_u8; + let vote_type: VoteType = number.try_into().unwrap(); + assert_eq!(vote_type, VoteType::Abstain); + + let number = 3_u8; + let result: Option = number.try_into(); + assert_eq!(result, Option::None); +} + +// +// into +// + +#[test] +fn test_into_VoteType_u8() { + let vote_type = VoteType::Against; + let number: u8 = vote_type.into(); + assert_eq!(number, 0); + + let vote_type = VoteType::For; + let number: u8 = vote_type.into(); + assert_eq!(number, 1); + + let vote_type = VoteType::Abstain; + let number: u8 = vote_type.into(); + assert_eq!(number, 2); +} + +// +// counting_mode +// + +#[test] +fn test_counting_mode() { + let state = COMPONENT_STATE(); + assert_eq!(GovernorCounting::counting_mode(@state), "support=bravo&quorum=for,abstain"); +} + +// +// count_vote +// + +#[test] +fn test_count_vote_against() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + let support = 0; + let total_weight = 100; + let params = array![].span(); + + let proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + assert_eq!(proposal_votes.has_voted.read(account), false); + assert_eq!(proposal_votes.against_votes.read(), 0); + + let weight = GovernorCounting::count_vote( + ref state, proposal_id, account, support, total_weight, params + ); + assert_eq!(weight, total_weight); + + assert_eq!(proposal_votes.has_voted.read(account), true); + assert_eq!(proposal_votes.against_votes.read(), total_weight); +} + +#[test] +fn test_count_vote_for() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + let support = 1; + let total_weight = 100; + let params = array![].span(); + + let proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + assert_eq!(proposal_votes.has_voted.read(account), false); + assert_eq!(proposal_votes.for_votes.read(), 0); + + let weight = GovernorCounting::count_vote( + ref state, proposal_id, account, support, total_weight, params + ); + assert_eq!(weight, total_weight); + + assert_eq!(proposal_votes.has_voted.read(account), true); + assert_eq!(proposal_votes.for_votes.read(), total_weight); +} + +#[test] +fn test_count_vote_abstain() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + let support = 2; + let total_weight = 100; + let params = array![].span(); + + let proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + assert_eq!(proposal_votes.has_voted.read(account), false); + assert_eq!(proposal_votes.abstain_votes.read(), 0); + + let weight = GovernorCounting::count_vote( + ref state, proposal_id, account, support, total_weight, params + ); + assert_eq!(weight, total_weight); + + assert_eq!(proposal_votes.has_voted.read(account), true); + assert_eq!(proposal_votes.abstain_votes.read(), total_weight); +} + +#[test] +#[should_panic(expected: 'Already cast vote')] +fn test_count_vote_already_voted() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + let support = 2; + let total_weight = 100; + let params = array![].span(); + + let proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + proposal_votes.has_voted.write(account, true); + + GovernorCounting::count_vote(ref state, proposal_id, account, support, total_weight, params); +} + +#[test] +#[should_panic(expected: 'Invalid vote type')] +fn test_count_vote_invalid_vote_type() { + let mut state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + let support = 3; + let total_weight = 100; + let params = array![].span(); + + GovernorCounting::count_vote(ref state, proposal_id, account, support, total_weight, params); +} + +// +// has_voted +// + +#[test] +fn test_has_voted() { + let mut mock_state = CONTRACT_STATE(); + let state = COMPONENT_STATE(); + + let proposal_id = 0; + let account = OTHER(); + + assert_eq!(GovernorCounting::has_voted(@state, proposal_id, account), false); + + let proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + proposal_votes.has_voted.write(account, true); + + assert_eq!(GovernorCounting::has_voted(@state, proposal_id, account), true); +} + +// +// quorum_reached +// + +#[test] +fn test_quorum_reached() { + let mut mock_state = CONTRACT_STATE(); + let state = COMPONENT_STATE(); + + let timepoint = 0; + let proposal_id = 0; + + let quorum = state.quorum(timepoint); + let for_votes = quorum - 10; + let abstain_votes = 10; + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); + + let mut proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + + // 1. Set for_votes and abstain_votes sum equal to quorum + proposal_votes.for_votes.write(for_votes); + proposal_votes.abstain_votes.write(abstain_votes); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), true); + + // 2. Set for_votes and abstain_votes sum greater than quorum + proposal_votes.for_votes.write(for_votes + 1); + proposal_votes.abstain_votes.write(abstain_votes + 1); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), true); + + // 3. Set for_votes and abstain_votes sum less than quorum + proposal_votes.for_votes.write(for_votes - 1); + proposal_votes.abstain_votes.write(abstain_votes - 1); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); +} + +#[test] +fn test_quorum_reached_snapshot_used() { + let mut mock_state = CONTRACT_STATE(); + let state = COMPONENT_STATE(); + + let timepoint = 0; + let proposal_id = 0; + + let quorum = state.quorum(timepoint); + let for_votes = quorum + 10; + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); + + let mut proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + + // 1. Set for_votes greater than quorum + proposal_votes.for_votes.write(for_votes); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), true); + + // 2. Set proposal snapshot to a special timepoint + let mut proposal = mock_state.governor.Governor_proposals.read(proposal_id); + proposal.vote_start = Bounded::MAX; + mock_state.governor.Governor_proposals.write(proposal_id, proposal); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); +} + +// +// vote_succeeded +// + +#[test] +fn test_vote_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let state = COMPONENT_STATE(); + + let proposal_id = 0; + + let mut proposal_votes = mock_state + .governor_counting_simple + .Governor_proposals_votes + .entry(proposal_id); + + // 1. Set for_votes greater than against_votes + proposal_votes.for_votes.write(500); + proposal_votes.against_votes.write(499); + + assert_eq!(GovernorCounting::vote_succeeded(@state, proposal_id), true); + + // 2. Set for_votes less than against_votes + proposal_votes.for_votes.write(499); + proposal_votes.against_votes.write(500); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); + + // 3. Set for_votes equal to against_votes + proposal_votes.for_votes.write(500); + proposal_votes.abstain_votes.write(500); + + assert_eq!(GovernorCounting::quorum_reached(@state, proposal_id), false); +} diff --git a/packages/governance/src/tests/governor/test_governor_settings.cairo b/packages/governance/src/tests/governor/test_governor_settings.cairo new file mode 100644 index 000000000..309f8ec13 --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_settings.cairo @@ -0,0 +1,461 @@ +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::InternalImpl; +use crate::governor::extensions::GovernorSettingsComponent::InternalImpl as GovernorSettingsInternalImpl; +use crate::governor::extensions::GovernorSettingsComponent::{ + GovernorSettings, GovernorSettingsAdminImpl +}; +use crate::governor::extensions::GovernorSettingsComponent; +use crate::tests::governor::common::set_executor; +use crate::tests::governor::common::{ + COMPONENT_STATE_TIMELOCKED as COMPONENT_STATE, CONTRACT_STATE_TIMELOCKED as CONTRACT_STATE +}; +use openzeppelin_test_common::mocks::governor::GovernorTimelockedMock::SNIP12MetadataImpl; +use openzeppelin_testing::constants::OTHER; +use openzeppelin_testing::events::EventSpyExt; +use snforge_std::start_cheat_caller_address; +use snforge_std::{EventSpy, spy_events, test_address}; +use starknet::ContractAddress; +use starknet::storage::StoragePointerWriteAccess; + +// +// Extensions +// + +#[test] +fn test_voting_delay() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let expected = 15; + + assert_eq!(GovernorSettings::voting_delay(component_state), 0); + mock_state.governor_settings.Governor_voting_delay.write(expected); + assert_eq!(GovernorSettings::voting_delay(component_state), expected); +} + +#[test] +fn test_voting_period() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let expected = 45; + + assert_eq!(GovernorSettings::voting_period(component_state), 0); + mock_state.governor_settings.Governor_voting_period.write(expected); + assert_eq!(GovernorSettings::voting_period(component_state), expected); +} + +#[test] +fn test_proposal_threshold() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let expected = 100; + + assert_eq!(GovernorSettings::proposal_threshold(component_state), 0); + mock_state.governor_settings.Governor_proposal_threshold.write(expected); + assert_eq!(GovernorSettings::proposal_threshold(component_state), expected); +} + +// +// External +// + +// +// set_voting_delay +// + +#[test] +fn test_set_voting_delay() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::voting_delay(component_state), 0); + mock_state.governor_settings.set_voting_delay(expected); + assert_eq!(GovernorSettings::voting_delay(component_state), expected); + + spy.assert_only_event_voting_delay_updated(contract_address, 0, expected); +} + +#[test] +fn test_set_voting_delay_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_voting_delay.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings.set_voting_delay(expected); + assert_eq!(GovernorSettings::voting_delay(component_state), expected); + + spy.assert_no_events_left(); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_set_voting_delay_only_governance() { + let mut mock_state = CONTRACT_STATE(); + let expected = 15; + + set_executor(ref mock_state, OTHER()); + + mock_state.governor_settings.set_voting_delay(expected); +} + +// +// set_voting_period +// + +#[test] +fn test_set_voting_period() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::voting_period(component_state), 0); + mock_state.governor_settings.set_voting_period(expected); + assert_eq!(GovernorSettings::voting_period(component_state), expected); + + spy.assert_only_event_voting_period_updated(contract_address, 0, expected); +} + +#[test] +fn test_set_voting_period_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_voting_period.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings.set_voting_period(expected); + assert_eq!(GovernorSettings::voting_period(component_state), expected); + + spy.assert_no_events_left(); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_set_voting_period_only_governance() { + let mut mock_state = CONTRACT_STATE(); + let expected = 15; + + set_executor(ref mock_state, OTHER()); + + mock_state.governor_settings.set_voting_period(expected); +} + +// +// set_proposal_threshold +// + +#[test] +fn test_set_proposal_threshold() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::proposal_threshold(component_state), 0); + mock_state.governor_settings.set_proposal_threshold(expected); + assert_eq!(GovernorSettings::proposal_threshold(component_state), expected); + + spy.assert_only_event_proposal_threshold_updated(contract_address, 0, expected); +} + +#[test] +fn test_set_proposal_threshold_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_proposal_threshold.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings.set_proposal_threshold(expected); + assert_eq!(GovernorSettings::proposal_threshold(component_state), expected); + + spy.assert_no_events_left(); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_set_proposal_threshold_only_governance() { + let mut mock_state = CONTRACT_STATE(); + let expected = 15; + + set_executor(ref mock_state, OTHER()); + + mock_state.governor_settings.set_proposal_threshold(expected); +} + +// +// Internal +// + +// +// initializer +// + +#[test] +fn test_initializer() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + mock_state.governor_settings.initializer(15, 45, 100); + + assert_eq!(GovernorSettings::voting_delay(component_state), 15); + spy.assert_event_voting_delay_updated(test_address(), 0, 15); + + assert_eq!(GovernorSettings::voting_period(component_state), 45); + spy.assert_event_voting_period_updated(test_address(), 0, 45); + + assert_eq!(GovernorSettings::proposal_threshold(component_state), 100); + spy.assert_only_event_proposal_threshold_updated(test_address(), 0, 100); +} + +// +// assert_only_governance +// + +#[test] +fn test_assert_only_governance() { + let mut mock_state = CONTRACT_STATE(); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings.assert_only_governance(); +} + +#[test] +#[should_panic(expected: 'Executor only')] +fn test_assert_only_governance_not_executor() { + let mut mock_state = CONTRACT_STATE(); + + set_executor(ref mock_state, OTHER()); + + mock_state.governor_settings.assert_only_governance(); +} + + +// +// _set_voting_delay +// + +#[test] +fn test__set_voting_delay() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::voting_delay(component_state), 0); + mock_state.governor_settings._set_voting_delay(expected); + assert_eq!(GovernorSettings::voting_delay(component_state), expected); + + spy.assert_only_event_voting_delay_updated(contract_address, 0, expected); +} + +#[test] +fn test__set_voting_delay_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_voting_delay.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings._set_voting_delay(expected); + assert_eq!(GovernorSettings::voting_delay(component_state), expected); + + spy.assert_no_events_left(); +} + +// +// _set_voting_period +// + +#[test] +fn test__set_voting_period() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::voting_period(component_state), 0); + mock_state.governor_settings._set_voting_period(expected); + assert_eq!(GovernorSettings::voting_period(component_state), expected); + + spy.assert_only_event_voting_period_updated(contract_address, 0, expected); +} + +#[test] +fn test__set_voting_period_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_voting_period.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings._set_voting_period(expected); + assert_eq!(GovernorSettings::voting_period(component_state), expected); + + spy.assert_no_events_left(); +} + +// +// _set_proposal_threshold +// + +#[test] +fn test__set_proposal_threshold() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let contract_address = test_address(); + let mut spy = spy_events(); + + let expected = 15; + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(contract_address, OTHER()); + + assert_eq!(GovernorSettings::proposal_threshold(component_state), 0); + mock_state.governor_settings._set_proposal_threshold(expected); + assert_eq!(GovernorSettings::proposal_threshold(component_state), expected); + + spy.assert_only_event_proposal_threshold_updated(contract_address, 0, expected); +} + +#[test] +fn test__set_proposal_threshold_no_change() { + let mut mock_state = CONTRACT_STATE(); + let component_state = @COMPONENT_STATE(); + let mut spy = spy_events(); + + let expected = 15; + mock_state.governor_settings.Governor_proposal_threshold.write(expected); + + set_executor(ref mock_state, OTHER()); + start_cheat_caller_address(test_address(), OTHER()); + + mock_state.governor_settings._set_proposal_threshold(expected); + assert_eq!(GovernorSettings::proposal_threshold(component_state), expected); + + spy.assert_no_events_left(); +} + +// +// Event helpers +// + +#[generate_trait] +pub(crate) impl GovernorSettingsSpyHelpersImpl of GovernorSettingsSpyHelpers { + fn assert_event_voting_delay_updated( + ref self: EventSpy, contract: ContractAddress, old_voting_delay: u64, new_voting_delay: u64 + ) { + let expected = GovernorSettingsComponent::Event::VotingDelayUpdated( + GovernorSettingsComponent::VotingDelayUpdated { old_voting_delay, new_voting_delay } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_voting_delay_updated( + ref self: EventSpy, contract: ContractAddress, old_voting_delay: u64, new_voting_delay: u64 + ) { + self.assert_event_voting_delay_updated(contract, old_voting_delay, new_voting_delay); + self.assert_no_events_left_from(contract); + } + + fn assert_event_voting_period_updated( + ref self: EventSpy, + contract: ContractAddress, + old_voting_period: u64, + new_voting_period: u64 + ) { + let expected = GovernorSettingsComponent::Event::VotingPeriodUpdated( + GovernorSettingsComponent::VotingPeriodUpdated { old_voting_period, new_voting_period } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_voting_period_updated( + ref self: EventSpy, + contract: ContractAddress, + old_voting_period: u64, + new_voting_period: u64 + ) { + self.assert_event_voting_period_updated(contract, old_voting_period, new_voting_period); + self.assert_no_events_left_from(contract); + } + + fn assert_event_proposal_threshold_updated( + ref self: EventSpy, + contract: ContractAddress, + old_proposal_threshold: u256, + new_proposal_threshold: u256 + ) { + let expected = GovernorSettingsComponent::Event::ProposalThresholdUpdated( + GovernorSettingsComponent::ProposalThresholdUpdated { + old_proposal_threshold, new_proposal_threshold + } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_proposal_threshold_updated( + ref self: EventSpy, + contract: ContractAddress, + old_proposal_threshold: u256, + new_proposal_threshold: u256 + ) { + self + .assert_event_proposal_threshold_updated( + contract, old_proposal_threshold, new_proposal_threshold + ); + self.assert_no_events_left_from(contract); + } +} diff --git a/packages/governance/src/tests/governor/test_governor_timelock_execution.cairo b/packages/governance/src/tests/governor/test_governor_timelock_execution.cairo new file mode 100644 index 000000000..b954e5e3c --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_timelock_execution.cairo @@ -0,0 +1,745 @@ +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::{InternalImpl, InternalExtendedImpl}; +use crate::governor::extensions::GovernorTimelockExecutionComponent::GovernorExecution; +use crate::governor::extensions::GovernorTimelockExecutionComponent; +use crate::governor::extensions::interface::{ITimelockedDispatcher, ITimelockedDispatcherTrait}; +use crate::governor::interface::ProposalState; +use crate::governor::interface::{IGovernorDispatcher, IGovernorDispatcherTrait}; +use crate::tests::governor::common::{ + COMPONENT_STATE_TIMELOCKED as COMPONENT_STATE, CONTRACT_STATE_TIMELOCKED as CONTRACT_STATE +}; +use crate::tests::governor::common::{set_executor, get_proposal_info, ComponentStateTimelocked}; +use crate::tests::governor::test_governor::GovernorSpyHelpersImpl; +use crate::tests::test_timelock::TimelockSpyHelpersImpl; +use crate::timelock::interface::{OperationState, ITimelockDispatcher}; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_test_common::mocks::governor::{ + GovernorTimelockedMock, CancelOperationsDispatcher, CancelOperationsDispatcherTrait +}; +use openzeppelin_test_common::mocks::timelock::{ + IMockContractDispatcher, IMockContractDispatcherTrait +}; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants::{TIMELOCK, VOTES_TOKEN, OTHER}; +use openzeppelin_testing::events::EventSpyExt; +use openzeppelin_utils::bytearray::ByteArrayExtTrait; +use openzeppelin_utils::serde::SerializedAppend; +use snforge_std::{start_cheat_block_timestamp_global, start_cheat_caller_address}; +use snforge_std::{start_mock_call, spy_events, store, EventSpy}; +use starknet::ContractAddress; +use starknet::account::Call; +use starknet::storage::{StoragePathEntry, StoragePointerWriteAccess, StorageMapWriteAccess}; + +const MIN_DELAY: u64 = 100; + +// +// Dispatchers +// + +fn deploy_governor(timelock: ContractAddress) -> IGovernorDispatcher { + let mut calldata = array![]; + calldata.append_serde(VOTES_TOKEN()); + calldata.append_serde(timelock); + + let address = utils::declare_and_deploy("GovernorTimelockedMock", calldata); + IGovernorDispatcher { contract_address: address } +} + +fn deploy_timelock(admin: ContractAddress) -> ITimelockDispatcher { + let proposers = array![admin].span(); + let executors = array![admin].span(); + + let mut calldata = array![]; + calldata.append_serde(MIN_DELAY); + calldata.append_serde(proposers); + calldata.append_serde(executors); + calldata.append_serde(admin); + + let address = utils::declare_and_deploy("TimelockControllerMock", calldata); + ITimelockDispatcher { contract_address: address } +} + +fn deploy_mock_target() -> IMockContractDispatcher { + let mut calldata = array![]; + + let address = utils::declare_and_deploy("MockContract", calldata); + IMockContractDispatcher { contract_address: address } +} + +fn setup_dispatchers() -> (IGovernorDispatcher, ITimelockDispatcher, IMockContractDispatcher) { + let governor = deploy_governor(TIMELOCK()); + let timelock = deploy_timelock(governor.contract_address); + let target = deploy_mock_target(); + + // Set the timelock controller + store( + governor.contract_address, + selector!("Governor_timelock_controller"), + array![timelock.contract_address.into()].span() + ); + + (governor, timelock, target) +} + +// +// state +// + +#[test] +fn test_state_executed() { + let mut component_state = COMPONENT_STATE(); + let id = setup_executed_proposal(ref component_state); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Executed); +} + +#[test] +fn test_state_canceled() { + let mut component_state = COMPONENT_STATE(); + let id = setup_canceled_proposal(ref component_state); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Canceled); +} + +#[test] +#[should_panic(expected: 'Nonexistent proposal')] +fn test_state_non_existent() { + let component_state = COMPONENT_STATE(); + + GovernorExecution::state(@component_state, 1); +} + +#[test] +fn test_state_pending() { + let mut component_state = COMPONENT_STATE(); + let id = setup_pending_proposal(ref component_state); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Pending); +} + +#[test] +fn test_state_active() { + let mut component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + component_state.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Active; + + // Is active before deadline + start_cheat_block_timestamp_global(deadline - 1); + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); + + // Is active in deadline + start_cheat_block_timestamp_global(deadline); + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_defeated_quorum_not_reached() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum not reached + let quorum = GovernorTimelockedMock::QUORUM; + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum - 1); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_defeated_vote_not_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Defeated; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum reached + let quorum = GovernorTimelockedMock::QUORUM; + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote not succeeded + proposal_votes.against_votes.write(quorum + 1); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, expected); +} + +#[test] +fn test_state_queued_timelock_waiting() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let id = setup_queued_proposal(ref mock_state); + + // 1. Mock the timelock to return pending + set_executor(ref mock_state, TIMELOCK()); + start_mock_call(TIMELOCK(), selector!("get_operation_state"), OperationState::Waiting); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Queued); +} + +#[test] +fn test_state_queued_timelock_ready() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let id = setup_queued_proposal(ref mock_state); + + // 1. Mock the timelock to return pending + set_executor(ref mock_state, TIMELOCK()); + start_mock_call(TIMELOCK(), selector!("get_operation_state"), OperationState::Ready); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Queued); +} + +#[test] +fn test_state_queued_timelock_done() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let id = setup_queued_proposal(ref mock_state); + + // 1. Mock the timelock to return pending + set_executor(ref mock_state, TIMELOCK()); + start_mock_call(TIMELOCK(), selector!("get_operation_state"), OperationState::Done); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Executed); +} + +#[test] +fn test_state_queued_timelock_canceled() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let id = setup_queued_proposal(ref mock_state); + + // 1. Mock the timelock to return pending + set_executor(ref mock_state, TIMELOCK()); + start_mock_call(TIMELOCK(), selector!("get_operation_state"), OperationState::Unset); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Canceled); +} + +#[test] +fn test_state_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let id = setup_succeeded_proposal(ref mock_state); + + let state = GovernorExecution::state(@component_state, id); + assert_eq!(state, ProposalState::Succeeded); +} + +// +// executor +// + +#[test] +fn test_executor() { + let mut mock_state = CONTRACT_STATE(); + let component_state = COMPONENT_STATE(); + let expected = TIMELOCK(); + + set_executor(ref mock_state, expected); + + assert_eq!(GovernorExecution::executor(@component_state), expected); +} + +// +// execute_operations +// + +#[test] +fn test_execute_operations() { + let (mut governor, timelock, target) = setup_dispatchers(); + let timelocked = ITimelockedDispatcher { contract_address: governor.contract_address }; + + let new_number = 125; + + let call = Call { + to: target.contract_address, + selector: selector!("set_number"), + calldata: array![new_number].span() + }; + let calls = array![call].span(); + let description = "proposal description"; + + let number = target.get_number(); + assert_eq!(number, 0); + + // 1. Mock the get_past_votes call + let quorum = GovernorTimelockedMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 2. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let id = governor.propose(calls, description.clone()); + + // 3. Cast vote + + // Fast forward the vote delay + current_time += GovernorTimelockedMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // Cast vote + governor.cast_vote(id, 1); + + // 4. Queue + + // Fast forward the vote duration + current_time += (GovernorTimelockedMock::VOTING_PERIOD + 1); + start_cheat_block_timestamp_global(current_time); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Succeeded); + + governor.queue(calls, (@description).hash()); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Queued); + + let target_id = timelocked.get_timelock_id(id); + + // 5. Execute + // Fast forward the timelock delay + current_time += MIN_DELAY; + start_cheat_block_timestamp_global(current_time); + + let mut spy = spy_events(); + governor.execute(calls, (@description).hash()); + + // 6. Assertions + let number = target.get_number(); + assert_eq!(number, new_number); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Executed); + + spy.assert_events_call_executed_batch(timelock.contract_address, target_id, calls); + + let target_id = timelocked.get_timelock_id(id); + assert_eq!(target_id, 0); +} + +// +// queue_operations +// + +#[test] +fn test_queue_operations() { + let (mut governor, timelock, target) = setup_dispatchers(); + let timelocked = ITimelockedDispatcher { contract_address: governor.contract_address }; + + let new_number = 125; + + let call = Call { + to: target.contract_address, + selector: selector!("set_number"), + calldata: array![new_number].span() + }; + let calls = array![call].span(); + let description = "proposal description"; + + let number = target.get_number(); + assert_eq!(number, 0); + + // 1. Mock the get_past_votes call + let quorum = GovernorTimelockedMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 2. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let id = governor.propose(calls, description.clone()); + + // 3. Cast vote + + // Fast forward the vote delay + current_time += GovernorTimelockedMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // Cast vote + governor.cast_vote(id, 1); + + // 4. Queue + + // Fast forward the vote duration + current_time += (GovernorTimelockedMock::VOTING_PERIOD + 1); + start_cheat_block_timestamp_global(current_time); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Succeeded); + + let mut spy = spy_events(); + + governor.queue(calls, (@description).hash()); + + let target_id = timelocked.get_timelock_id(id); + let salt = timelock_salt(governor.contract_address, (@description).hash()); + spy.assert_event_call_scheduled(timelock.contract_address, target_id, 0, call, 0, MIN_DELAY); + spy.assert_event_call_salt(timelock.contract_address, target_id, salt); + spy.assert_only_event_proposal_queued(governor.contract_address, id, current_time + MIN_DELAY); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Queued); +} + +// +// proposal_needs_queuing +// + +#[test] +fn test_proposal_needs_queuing(id: felt252) { + let component_state = COMPONENT_STATE(); + + assert_eq!(GovernorExecution::proposal_needs_queuing(@component_state, id), true); +} + +// +// cancel_operations +// + +#[test] +fn test_cancel_operations_queued() { + let (mut governor, timelock, target) = setup_dispatchers(); + let timelocked = ITimelockedDispatcher { contract_address: governor.contract_address }; + + let new_number = 125; + + let call = Call { + to: target.contract_address, + selector: selector!("set_number"), + calldata: array![new_number].span() + }; + let calls = array![call].span(); + let description = "proposal description"; + + let number = target.get_number(); + assert_eq!(number, 0); + + // 1. Mock the get_past_votes call + let quorum = GovernorTimelockedMock::QUORUM; + start_mock_call(VOTES_TOKEN(), selector!("get_past_votes"), quorum); + + // 2. Propose + let mut current_time = 10; + start_cheat_block_timestamp_global(current_time); + let id = governor.propose(calls, description.clone()); + + // 3. Cast vote + + // Fast forward the vote delay + current_time += GovernorTimelockedMock::VOTING_DELAY; + start_cheat_block_timestamp_global(current_time); + + // Cast vote + governor.cast_vote(id, 1); + + // 4. Queue + + // Fast forward the vote duration + current_time += (GovernorTimelockedMock::VOTING_PERIOD + 1); + start_cheat_block_timestamp_global(current_time); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Succeeded); + + governor.queue(calls, (@description).hash()); + + let state = governor.state(id); + assert_eq!(state, ProposalState::Queued); + + let target_id = timelocked.get_timelock_id(id); + + // 5. Cancel + // Fast forward the timelock delay + current_time += MIN_DELAY; + start_cheat_block_timestamp_global(current_time); + + let mut spy = spy_events(); + let dispatcher = CancelOperationsDispatcher { contract_address: governor.contract_address }; + dispatcher.cancel_operations(id, (@description).hash()); + + spy.assert_event_call_cancelled(timelock.contract_address, target_id); + + let target_id = timelocked.get_timelock_id(id); + assert_eq!(target_id, 0); +} + +#[test] +fn test_cancel_operations_pending() { + let mut state = COMPONENT_STATE(); + let id = setup_pending_proposal(ref state); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_active() { + let mut state = COMPONENT_STATE(); + let id = setup_active_proposal(ref state); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = state.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_defeated() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + let id = setup_defeated_proposal(ref mock_state); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +fn test_cancel_operations_succeeded() { + let mut mock_state = CONTRACT_STATE(); + let mut state = COMPONENT_STATE(); + let id = setup_succeeded_proposal(ref mock_state); + + GovernorExecution::cancel_operations(ref state, id, 0); + + let canceled_proposal = mock_state.governor.get_proposal(id); + assert_eq!(canceled_proposal.canceled, true); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_operations_canceled() { + let mut state = COMPONENT_STATE(); + let id = setup_canceled_proposal(ref state); + + // Cancel again + GovernorExecution::cancel_operations(ref state, id, 0); +} + +#[test] +#[should_panic(expected: 'Unexpected proposal state')] +fn test_cancel_operations_executed() { + let mut state = COMPONENT_STATE(); + let id = setup_executed_proposal(ref state); + + GovernorExecution::cancel_operations(ref state, id, 0); +} + +// +// update_timelock +// + +#[test] +fn test_update_timelock() { + let mut governor = deploy_governor(TIMELOCK()); + let timelocked = ITimelockedDispatcher { contract_address: governor.contract_address }; + + start_cheat_caller_address(governor.contract_address, TIMELOCK()); + + let mut spy = spy_events(); + timelocked.update_timelock(OTHER()); + + assert_eq!(timelocked.timelock(), OTHER()); + + spy.assert_only_event_timelock_updated(governor.contract_address, TIMELOCK(), OTHER()); +} + +// +// Helpers +// + +fn timelock_salt(contract_address: ContractAddress, description_hash: felt252) -> felt252 { + let description_hash: u256 = description_hash.into(); + let contract_address: felt252 = contract_address.into(); + + (contract_address.into() ^ description_hash).try_into().unwrap() +} + +// +// Setup proposals +// + +pub fn setup_pending_proposal(ref state: ComponentStateTimelocked) -> felt252 { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let current_state = state._state(id); + let expected = ProposalState::Pending; + + assert_eq!(current_state, expected); + + id +} + +pub fn setup_active_proposal(ref state: ComponentStateTimelocked) -> felt252 { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Active; + + // Is active before deadline + start_cheat_block_timestamp_global(deadline - 1); + let current_state = state._state(id); + assert_eq!(current_state, expected); + + id +} + +pub fn setup_queued_proposal(ref mock_state: GovernorTimelockedMock::ContractState) -> felt252 { + let (id, mut proposal) = get_proposal_info(); + + proposal.eta_seconds = 1; + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + + // Quorum reached + start_cheat_block_timestamp_global(deadline + 1); + let quorum = GovernorTimelockedMock::QUORUM; + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote succeeded + proposal_votes.against_votes.write(quorum); + + let expected = ProposalState::Queued; + let current_state = mock_state.governor._state(id); + assert_eq!(current_state, expected); + + id +} + +pub fn setup_canceled_proposal(ref state: ComponentStateTimelocked) -> felt252 { + let (id, proposal) = get_proposal_info(); + + state.Governor_proposals.write(id, proposal); + + state._cancel(id, 0); + + let expected = ProposalState::Canceled; + let current_state = state._state(id); + assert_eq!(current_state, expected); + + id +} + +pub fn setup_defeated_proposal(ref mock_state: GovernorTimelockedMock::ContractState) -> felt252 { + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + + // Quorum not reached + start_cheat_block_timestamp_global(deadline + 1); + let quorum = GovernorTimelockedMock::QUORUM; + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum - 1); + + let expected = ProposalState::Defeated; + let current_state = mock_state.governor._state(id); + assert_eq!(current_state, expected); + + id +} + +pub fn setup_succeeded_proposal(ref mock_state: GovernorTimelockedMock::ContractState) -> felt252 { + let (id, proposal) = get_proposal_info(); + + mock_state.governor.Governor_proposals.write(id, proposal); + + let deadline = proposal.vote_start + proposal.vote_duration; + let expected = ProposalState::Succeeded; + + start_cheat_block_timestamp_global(deadline + 1); + + // Quorum reached + let quorum = GovernorTimelockedMock::QUORUM; + let proposal_votes = mock_state.governor_counting_simple.Governor_proposals_votes.entry(id); + proposal_votes.for_votes.write(quorum + 1); + + // Vote succeeded + proposal_votes.against_votes.write(quorum); + + let current_state = mock_state.governor._state(id); + assert_eq!(current_state, expected); + + id +} + +pub fn setup_executed_proposal(ref state: ComponentStateTimelocked) -> felt252 { + let (id, mut proposal) = get_proposal_info(); + + proposal.executed = true; + state.Governor_proposals.write(id, proposal); + + let current_state = state._state(id); + let expected = ProposalState::Executed; + + assert_eq!(current_state, expected); + + id +} + +// +// Event helpers +// + +#[generate_trait] +pub(crate) impl GovernorTimelockExecutionSpyHelpersImpl of GovernorTimelockExecutionSpyHelpers { + fn assert_event_timelock_updated( + ref self: EventSpy, + contract: ContractAddress, + old_timelock: ContractAddress, + new_timelock: ContractAddress + ) { + let expected = GovernorTimelockExecutionComponent::Event::TimelockUpdated( + GovernorTimelockExecutionComponent::TimelockUpdated { old_timelock, new_timelock } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_timelock_updated( + ref self: EventSpy, + contract: ContractAddress, + old_timelock: ContractAddress, + new_timelock: ContractAddress + ) { + self.assert_event_timelock_updated(contract, old_timelock, new_timelock); + self.assert_no_events_left_from(contract); + } +} diff --git a/packages/governance/src/tests/governor/test_governor_votes.cairo b/packages/governance/src/tests/governor/test_governor_votes.cairo new file mode 100644 index 000000000..5746793e0 --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_votes.cairo @@ -0,0 +1,78 @@ +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::InternalImpl; +use crate::governor::extensions::GovernorVotesComponent::{ + GovernorVotes, VotesTokenImpl, InternalTrait +}; +use crate::tests::governor::common::{COMPONENT_STATE, CONTRACT_STATE}; +use openzeppelin_test_common::mocks::governor::GovernorMock::SNIP12MetadataImpl; +use openzeppelin_testing::constants::{VOTES_TOKEN, OTHER, ZERO}; +use snforge_std::{start_mock_call, start_cheat_block_timestamp_global}; +use snforge_std::{store, test_address}; + +// +// GovernorVotes +// + +#[test] +fn test_clock() { + let component_state = COMPONENT_STATE(); + let timestamp = 10; + + start_cheat_block_timestamp_global(timestamp); + let clock = GovernorVotes::clock(@component_state); + assert_eq!(clock, timestamp); +} + +#[test] +fn test_clock_mode() { + let component_state = COMPONENT_STATE(); + let mode = GovernorVotes::clock_mode(@component_state); + assert_eq!(mode, "mode=timestamp&from=starknet::SN_MAIN"); +} + +#[test] +fn test_get_votes() { + let mut component_state = COMPONENT_STATE(); + let timepoint = 0; + let expected_weight = 100; + let params = array!['param'].span(); + + start_mock_call(ZERO(), selector!("get_past_votes"), expected_weight); + + let votes = GovernorVotes::get_votes(@component_state, OTHER(), timepoint, params); + assert_eq!(votes, expected_weight); +} + +// +// External +// + +#[test] +fn test_token() { + let mock_state = CONTRACT_STATE(); + + store(test_address(), selector!("Governor_token"), array![VOTES_TOKEN().into()].span()); + let token = mock_state.governor_votes.token(); + assert_eq!(token, VOTES_TOKEN()); +} + +// +// Internal +// + +#[test] +fn test_initializer() { + let mut mock_state = CONTRACT_STATE(); + + mock_state.governor_votes.initializer(VOTES_TOKEN()); + + let token = mock_state.governor_votes.token(); + assert_eq!(token, VOTES_TOKEN()); +} + +#[test] +#[should_panic(expected: 'Invalid votes token')] +fn test_initializer_with_zero_token() { + let mut mock_state = CONTRACT_STATE(); + mock_state.governor_votes.initializer(ZERO()); +} diff --git a/packages/governance/src/tests/governor/test_governor_votes_quorum_fraction.cairo b/packages/governance/src/tests/governor/test_governor_votes_quorum_fraction.cairo new file mode 100644 index 000000000..8b3189a69 --- /dev/null +++ b/packages/governance/src/tests/governor/test_governor_votes_quorum_fraction.cairo @@ -0,0 +1,232 @@ +use crate::governor::DefaultConfig; +use crate::governor::GovernorComponent::InternalImpl; +use crate::governor::GovernorComponent; +use crate::governor::extensions::GovernorVotesQuorumFractionComponent::{ + GovernorQuorum, GovernorVotes, QuorumFractionImpl, InternalTrait +}; +use crate::governor::extensions::GovernorVotesQuorumFractionComponent; +use openzeppelin_test_common::mocks::governor::GovernorQuorumFractionMock::SNIP12MetadataImpl; +use openzeppelin_test_common::mocks::governor::GovernorQuorumFractionMock; +use openzeppelin_testing::constants::{VOTES_TOKEN, OTHER, ZERO}; +use openzeppelin_testing::events::EventSpyExt; +use snforge_std::{EventSpy, store, test_address, spy_events}; +use snforge_std::{start_mock_call, start_cheat_block_timestamp_global}; +use starknet::ContractAddress; + +pub type ComponentState = + GovernorComponent::ComponentState; + +pub fn CONTRACT_STATE() -> GovernorQuorumFractionMock::ContractState { + GovernorQuorumFractionMock::contract_state_for_testing() +} + +pub fn COMPONENT_STATE() -> ComponentState { + GovernorComponent::component_state_for_testing() +} + +// +// GovernorQuorum +// + +#[test] +fn test_quorum() { + let component_state = COMPONENT_STATE(); + let mock_state = CONTRACT_STATE(); + let past_total_supply = 100; + let timepoint = 123; + + start_mock_call(ZERO(), selector!("get_past_total_supply"), past_total_supply); + + let quorum = GovernorQuorum::quorum(@component_state, timepoint); + let quorum_numerator = mock_state.governor_votes.quorum_numerator(timepoint); + let quorum_denominator = mock_state.governor_votes.quorum_denominator(); + + assert_eq!(quorum, quorum_numerator * past_total_supply / quorum_denominator); +} + +// +// GovernorVotes +// + +#[test] +fn test_clock() { + let component_state = COMPONENT_STATE(); + let timestamp = 10; + + start_cheat_block_timestamp_global(timestamp); + let clock = GovernorVotes::clock(@component_state); + assert_eq!(clock, timestamp); +} + +#[test] +fn test_clock_mode() { + let component_state = COMPONENT_STATE(); + let mode = GovernorVotes::clock_mode(@component_state); + assert_eq!(mode, "mode=timestamp&from=starknet::SN_MAIN"); +} + +#[test] +fn test_get_votes() { + let mut component_state = COMPONENT_STATE(); + let timepoint = 0; + let expected_weight = 100; + let params = array!['param'].span(); + + start_mock_call(ZERO(), selector!("get_past_votes"), expected_weight); + + let votes = GovernorVotes::get_votes(@component_state, OTHER(), timepoint, params); + assert_eq!(votes, expected_weight); +} + +// +// External +// + +#[test] +fn test_token() { + let mock_state = CONTRACT_STATE(); + + store(test_address(), selector!("Governor_token"), array![VOTES_TOKEN().into()].span()); + let token = mock_state.governor_votes.token(); + assert_eq!(token, VOTES_TOKEN()); +} + +#[test] +fn test_quorum_denominator() { + let mock_state = CONTRACT_STATE(); + let quorum_denominator = mock_state.governor_votes.quorum_denominator(); + assert_eq!(quorum_denominator, 1000); +} + +// +// Internal +// + +#[test] +fn test_initializer() { + let mut mock_state = CONTRACT_STATE(); + let now = starknet::get_block_timestamp(); + + mock_state.governor_votes.initializer(VOTES_TOKEN(), 600); + + let quorum_numerator = mock_state.governor_votes.quorum_numerator(now); + assert_eq!(quorum_numerator, 600); + + let token = mock_state.governor_votes.token(); + assert_eq!(token, VOTES_TOKEN()); +} + +#[test] +#[should_panic(expected: 'Invalid votes token')] +fn test_initializer_with_zero_token() { + let mut mock_state = CONTRACT_STATE(); + mock_state.governor_votes.initializer(ZERO(), 600); +} + + +#[test] +#[should_panic(expected: 'Invalid quorum fraction')] +fn test_initializer_with_invalid_numerator() { + let mut mock_state = CONTRACT_STATE(); + mock_state.governor_votes.initializer(VOTES_TOKEN(), 1001); +} + +// +// update_quorum_numerator +// + +#[test] +#[should_panic(expected: 'Invalid quorum fraction')] +fn test_update_quorum_numerator_invalid_numerator() { + let mut mock_state = CONTRACT_STATE(); + mock_state.governor_votes.update_quorum_numerator(1001); +} + +#[test] +fn test_update_quorum_numerator() { + let mut mock_state = CONTRACT_STATE(); + let ts1 = '10'; + let ts2 = '20'; + let ts3 = '30'; + let ts4 = '15'; + let ts5 = '35'; + let new_quorum_numerator_1 = 700; + let new_quorum_numerator_2 = 800; + let new_quorum_numerator_3 = 900; + + let mut spy = spy_events(); + let contract_address = test_address(); + + // 1. Update the numerators + start_cheat_block_timestamp_global(ts1); + mock_state.governor_votes.update_quorum_numerator(new_quorum_numerator_1); + spy.assert_only_event_quorum_numerator_updated(contract_address, 0, new_quorum_numerator_1); + + start_cheat_block_timestamp_global(ts2); + mock_state.governor_votes.update_quorum_numerator(new_quorum_numerator_2); + spy + .assert_only_event_quorum_numerator_updated( + contract_address, new_quorum_numerator_1, new_quorum_numerator_2 + ); + + start_cheat_block_timestamp_global(ts3); + mock_state.governor_votes.update_quorum_numerator(new_quorum_numerator_3); + spy + .assert_only_event_quorum_numerator_updated( + contract_address, new_quorum_numerator_2, new_quorum_numerator_3 + ); + + // 2. Check the current quorum numerator + let current_quorum_numerator = mock_state.governor_votes.current_quorum_numerator(); + assert_eq!(current_quorum_numerator, new_quorum_numerator_3); + + // 3. Check the history + let history = mock_state.governor_votes.quorum_numerator(ts1); + assert_eq!(history, new_quorum_numerator_1); + + let history = mock_state.governor_votes.quorum_numerator(ts2); + assert_eq!(history, new_quorum_numerator_2); + + let history = mock_state.governor_votes.quorum_numerator(ts3); + assert_eq!(history, new_quorum_numerator_3); + + let history = mock_state.governor_votes.quorum_numerator(ts4); + assert_eq!(history, new_quorum_numerator_1); + + let history = mock_state.governor_votes.quorum_numerator(ts5); + assert_eq!(history, new_quorum_numerator_3); +} + +// +// Event helpers +// + +#[generate_trait] +pub(crate) impl GovernorSettingsSpyHelpersImpl of GovernorSettingsSpyHelpers { + fn assert_event_quorum_numerator_updated( + ref self: EventSpy, + contract: ContractAddress, + old_quorum_numerator: u256, + new_quorum_numerator: u256 + ) { + let expected = GovernorVotesQuorumFractionComponent::Event::QuorumNumeratorUpdated( + GovernorVotesQuorumFractionComponent::QuorumNumeratorUpdated { + old_quorum_numerator, new_quorum_numerator + } + ); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_quorum_numerator_updated( + ref self: EventSpy, + contract: ContractAddress, + old_quorum_numerator: u256, + new_quorum_numerator: u256 + ) { + self + .assert_event_quorum_numerator_updated( + contract, old_quorum_numerator, new_quorum_numerator + ); + self.assert_no_events_left_from(contract); + } +} diff --git a/packages/governance/src/utils/call_impls.cairo b/packages/governance/src/utils/call_impls.cairo index 36ba4df14..4079ffe06 100644 --- a/packages/governance/src/utils/call_impls.cairo +++ b/packages/governance/src/utils/call_impls.cairo @@ -19,10 +19,9 @@ pub impl HashCallImpl, +Drop> of Hash { pub impl HashCallsImpl, +Drop> of Hash, S> { fn update_state(mut state: S, value: Span) -> S { state = state.update_with(value.len()); - for call in value { - state = state.update_with(*call); + for elem in value { + state = state.update_with(*elem); }; - state } } diff --git a/packages/test_common/src/mocks.cairo b/packages/test_common/src/mocks.cairo index 2e6901947..e19179acc 100644 --- a/packages/test_common/src/mocks.cairo +++ b/packages/test_common/src/mocks.cairo @@ -5,6 +5,7 @@ pub mod erc1155; pub mod erc20; pub mod erc2981; pub mod erc721; +pub mod governor; pub mod multisig; pub mod non_implementing; pub mod nonces; @@ -16,4 +17,3 @@ pub mod timelock; pub mod upgrades; pub mod vesting; pub mod votes; - diff --git a/packages/test_common/src/mocks/governor.cairo b/packages/test_common/src/mocks/governor.cairo new file mode 100644 index 000000000..e87743a70 --- /dev/null +++ b/packages/test_common/src/mocks/governor.cairo @@ -0,0 +1,408 @@ +#[starknet::contract] +pub mod GovernorMock { + use core::num::traits::Bounded; + use openzeppelin_governance::governor::GovernorComponent::InternalTrait as GovernorInternalTrait; + use openzeppelin_governance::governor::extensions::GovernorVotesComponent::InternalTrait; + use openzeppelin_governance::governor::extensions::{ + GovernorVotesComponent, GovernorCountingSimpleComponent, GovernorCoreExecutionComponent + }; + use openzeppelin_governance::governor::{GovernorComponent, DefaultConfig}; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; + use starknet::ContractAddress; + + pub const VOTING_DELAY: u64 = 86400; // 1 day + pub const VOTING_PERIOD: u64 = 432_000; // 1 week + pub const PROPOSAL_THRESHOLD: u256 = 10; + pub const QUORUM: u256 = 100_000_000; + + component!(path: GovernorComponent, storage: governor, event: GovernorEvent); + component!(path: GovernorVotesComponent, storage: governor_votes, event: GovernorVotesEvent); + component!( + path: GovernorCountingSimpleComponent, + storage: governor_counting_simple, + event: GovernorCountingSimpleEvent + ); + component!( + path: GovernorCoreExecutionComponent, + storage: governor_core_execution, + event: GovernorCoreExecutionEvent + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + // Governor + #[abi(embed_v0)] + impl GovernorImpl = GovernorComponent::GovernorImpl; + + // Extensions external + #[abi(embed_v0)] + impl VotesTokenImpl = GovernorVotesComponent::VotesTokenImpl; + + // Extensions internal + impl GovernorVotesImpl = GovernorVotesComponent::GovernorVotes; + impl GovernorCountingSimpleImpl = + GovernorCountingSimpleComponent::GovernorCounting; + impl GovernorCoreExecutionImpl = + GovernorCoreExecutionComponent::GovernorExecution; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + #[substorage(v0)] + pub governor: GovernorComponent::Storage, + #[substorage(v0)] + pub governor_votes: GovernorVotesComponent::Storage, + #[substorage(v0)] + pub governor_counting_simple: GovernorCountingSimpleComponent::Storage, + #[substorage(v0)] + pub governor_core_execution: GovernorCoreExecutionComponent::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + GovernorEvent: GovernorComponent::Event, + #[flat] + GovernorVotesEvent: GovernorVotesComponent::Event, + #[flat] + GovernorCountingSimpleEvent: GovernorCountingSimpleComponent::Event, + #[flat] + GovernorCoreExecutionEvent: GovernorCoreExecutionComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, votes_token: ContractAddress) { + self.governor.initializer(); + self.governor_votes.initializer(votes_token); + } + + // + // SNIP12 Metadata + // + + pub impl SNIP12MetadataImpl of SNIP12Metadata { + fn name() -> felt252 { + 'DAPP_NAME' + } + + fn version() -> felt252 { + 'DAPP_VERSION' + } + } + + // + // Locally implemented extensions + // + + impl GovernorQuorum of GovernorComponent::GovernorQuorumTrait { + /// See `GovernorComponent::GovernorQuorumTrait::quorum`. + fn quorum(self: @GovernorComponent::ComponentState, timepoint: u64) -> u256 { + if timepoint == Bounded::MAX { + Bounded::MAX + } else { + QUORUM + } + } + } + + pub impl GovernorSettings of GovernorComponent::GovernorSettingsTrait { + /// See `GovernorComponent::GovernorSettingsTrait::voting_delay`. + fn voting_delay(self: @GovernorComponent::ComponentState) -> u64 { + VOTING_DELAY + } + + /// See `GovernorComponent::GovernorSettingsTrait::voting_period`. + fn voting_period(self: @GovernorComponent::ComponentState) -> u64 { + VOTING_PERIOD + } + + /// See `GovernorComponent::GovernorSettingsTrait::proposal_threshold`. + fn proposal_threshold(self: @GovernorComponent::ComponentState) -> u256 { + PROPOSAL_THRESHOLD + } + } +} + +#[starknet::contract] +pub mod GovernorQuorumFractionMock { + use openzeppelin_governance::governor::GovernorComponent::InternalTrait as GovernorInternalTrait; + use openzeppelin_governance::governor::extensions::GovernorVotesQuorumFractionComponent::InternalTrait; + use openzeppelin_governance::governor::extensions::{ + GovernorVotesQuorumFractionComponent, GovernorCountingSimpleComponent, + GovernorCoreExecutionComponent, + }; + use openzeppelin_governance::governor::{GovernorComponent, DefaultConfig}; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; + use starknet::ContractAddress; + + pub const VOTING_DELAY: u64 = 86400; // 1 day + pub const VOTING_PERIOD: u64 = 432_000; // 1 week + pub const PROPOSAL_THRESHOLD: u256 = 10; + pub const QUORUM_NUMERATOR: u256 = 600; // 60% + + component!(path: GovernorComponent, storage: governor, event: GovernorEvent); + component!( + path: GovernorVotesQuorumFractionComponent, + storage: governor_votes, + event: GovernorVotesEvent + ); + component!( + path: GovernorCountingSimpleComponent, + storage: governor_counting_simple, + event: GovernorCountingSimpleEvent + ); + component!( + path: GovernorCoreExecutionComponent, + storage: governor_core_execution, + event: GovernorCoreExecutionEvent + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + // Governor + #[abi(embed_v0)] + impl GovernorImpl = GovernorComponent::GovernorImpl; + + // Extensions external + #[abi(embed_v0)] + impl QuorumFractionImpl = + GovernorVotesQuorumFractionComponent::QuorumFractionImpl; + + // Extensions internal + impl GovernorQuorumImpl = GovernorVotesQuorumFractionComponent::GovernorQuorum; + impl GovernorVotesImpl = GovernorVotesQuorumFractionComponent::GovernorVotes; + impl GovernorCountingSimpleImpl = + GovernorCountingSimpleComponent::GovernorCounting; + impl GovernorCoreExecutionImpl = + GovernorCoreExecutionComponent::GovernorExecution; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + #[substorage(v0)] + pub governor: GovernorComponent::Storage, + #[substorage(v0)] + pub governor_votes: GovernorVotesQuorumFractionComponent::Storage, + #[substorage(v0)] + pub governor_counting_simple: GovernorCountingSimpleComponent::Storage, + #[substorage(v0)] + pub governor_core_execution: GovernorCoreExecutionComponent::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + GovernorEvent: GovernorComponent::Event, + #[flat] + GovernorVotesEvent: GovernorVotesQuorumFractionComponent::Event, + #[flat] + GovernorCountingSimpleEvent: GovernorCountingSimpleComponent::Event, + #[flat] + GovernorCoreExecutionEvent: GovernorCoreExecutionComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, votes_token: ContractAddress) { + self.governor.initializer(); + self.governor_votes.initializer(votes_token, QUORUM_NUMERATOR); + } + + // + // SNIP12 Metadata + // + + pub impl SNIP12MetadataImpl of SNIP12Metadata { + fn name() -> felt252 { + 'DAPP_NAME' + } + + fn version() -> felt252 { + 'DAPP_VERSION' + } + } + + // + // Locally implemented extensions + // + + pub impl GovernorSettings of GovernorComponent::GovernorSettingsTrait { + /// See `GovernorComponent::GovernorSettingsTrait::voting_delay`. + fn voting_delay(self: @GovernorComponent::ComponentState) -> u64 { + VOTING_DELAY + } + + /// See `GovernorComponent::GovernorSettingsTrait::voting_period`. + fn voting_period(self: @GovernorComponent::ComponentState) -> u64 { + VOTING_PERIOD + } + + /// See `GovernorComponent::GovernorSettingsTrait::proposal_threshold`. + fn proposal_threshold(self: @GovernorComponent::ComponentState) -> u256 { + PROPOSAL_THRESHOLD + } + } +} + +#[starknet::contract] +pub mod GovernorTimelockedMock { + use openzeppelin_governance::governor::GovernorComponent::InternalTrait as GovernorInternalTrait; + use openzeppelin_governance::governor::extensions::GovernorSettingsComponent::InternalTrait as GovernorSettingsInternalTrait; + use openzeppelin_governance::governor::extensions::GovernorTimelockExecutionComponent::InternalTrait as GovernorTimelockExecutionInternalTrait; + use openzeppelin_governance::governor::extensions::GovernorVotesComponent::InternalTrait as GovernorVotesInternalTrait; + use openzeppelin_governance::governor::extensions::{ + GovernorVotesComponent, GovernorSettingsComponent, GovernorCountingSimpleComponent, + GovernorTimelockExecutionComponent + }; + use openzeppelin_governance::governor::{GovernorComponent, DefaultConfig}; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; + use starknet::ContractAddress; + + pub const VOTING_DELAY: u64 = 86400; // 1 day + pub const VOTING_PERIOD: u64 = 432_000; // 1 week + pub const PROPOSAL_THRESHOLD: u256 = 10; + pub const QUORUM: u256 = 100_000_000; + + component!(path: GovernorComponent, storage: governor, event: GovernorEvent); + component!(path: GovernorVotesComponent, storage: governor_votes, event: GovernorVotesEvent); + component!( + path: GovernorSettingsComponent, storage: governor_settings, event: GovernorSettingsEvent + ); + component!( + path: GovernorCountingSimpleComponent, + storage: governor_counting_simple, + event: GovernorCountingSimpleEvent + ); + component!( + path: GovernorTimelockExecutionComponent, + storage: governor_timelock_execution, + event: GovernorTimelockExecutionEvent + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + // Governor + #[abi(embed_v0)] + impl GovernorImpl = GovernorComponent::GovernorImpl; + + // Extensions external + #[abi(embed_v0)] + impl VotesTokenImpl = GovernorVotesComponent::VotesTokenImpl; + #[abi(embed_v0)] + impl GovernorSettingsAdminImpl = + GovernorSettingsComponent::GovernorSettingsAdminImpl; + #[abi(embed_v0)] + impl TimelockedImpl = + GovernorTimelockExecutionComponent::TimelockedImpl; + + // Extensions internal + impl GovernorVotesImpl = GovernorVotesComponent::GovernorVotes; + impl GovernorSettingsImpl = GovernorSettingsComponent::GovernorSettings; + impl GovernorCountingSimpleImpl = + GovernorCountingSimpleComponent::GovernorCounting; + impl GovernorTimelockExecutionImpl = + GovernorTimelockExecutionComponent::GovernorExecution; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + #[substorage(v0)] + pub governor: GovernorComponent::Storage, + #[substorage(v0)] + pub governor_votes: GovernorVotesComponent::Storage, + #[substorage(v0)] + pub governor_settings: GovernorSettingsComponent::Storage, + #[substorage(v0)] + pub governor_counting_simple: GovernorCountingSimpleComponent::Storage, + #[substorage(v0)] + pub governor_timelock_execution: GovernorTimelockExecutionComponent::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + GovernorEvent: GovernorComponent::Event, + #[flat] + GovernorVotesEvent: GovernorVotesComponent::Event, + #[flat] + GovernorSettingsEvent: GovernorSettingsComponent::Event, + #[flat] + GovernorCountingSimpleEvent: GovernorCountingSimpleComponent::Event, + #[flat] + GovernorTimelockExecutionEvent: GovernorTimelockExecutionComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor( + ref self: ContractState, votes_token: ContractAddress, timelock_controller: ContractAddress + ) { + self.governor.initializer(); + self.governor_votes.initializer(votes_token); + self.governor_settings.initializer(VOTING_DELAY, VOTING_PERIOD, PROPOSAL_THRESHOLD); + self.governor_timelock_execution.initializer(timelock_controller); + } + + // + // SNIP12 Metadata + // + + pub impl SNIP12MetadataImpl of SNIP12Metadata { + fn name() -> felt252 { + 'DAPP_NAME' + } + + fn version() -> felt252 { + 'DAPP_VERSION' + } + } + + // + // Locally implemented extensions + // + + impl GovernorQuorum of GovernorComponent::GovernorQuorumTrait { + /// See `GovernorComponent::GovernorQuorumTrait::quorum`. + fn quorum(self: @GovernorComponent::ComponentState, timepoint: u64) -> u256 { + QUORUM + } + } + + #[abi(per_item)] + #[generate_trait] + impl ExternalImpl of ExternalTrait { + #[external(v0)] + fn cancel_operations( + ref self: ContractState, proposal_id: felt252, description_hash: felt252 + ) { + self.governor.cancel_operations(proposal_id, description_hash); + } + } +} + +#[starknet::interface] +pub trait CancelOperations { + fn cancel_operations(ref self: TContractState, proposal_id: felt252, description_hash: felt252); +} diff --git a/packages/test_common/src/mocks/votes.cairo b/packages/test_common/src/mocks/votes.cairo index fa4cb0261..eefdce4df 100644 --- a/packages/test_common/src/mocks/votes.cairo +++ b/packages/test_common/src/mocks/votes.cairo @@ -1,15 +1,13 @@ #[starknet::contract] -pub mod ERC721VotesMock { +pub mod ERC20VotesMock { use openzeppelin_governance::votes::VotesComponent; - use openzeppelin_introspection::src5::SRC5Component; - use openzeppelin_token::erc721::ERC721Component; + use openzeppelin_token::erc20::ERC20Component; use openzeppelin_utils::cryptography::nonces::NoncesComponent; use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; use starknet::ContractAddress; - component!(path: VotesComponent, storage: erc721_votes, event: ERC721VotesEvent); - component!(path: ERC721Component, storage: erc721, event: ERC721Event); - component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: VotesComponent, storage: erc20_votes, event: ERC20VotesEvent); + component!(path: ERC20Component, storage: erc20, event: ERC20Event); component!(path: NoncesComponent, storage: nonces, event: NoncesEvent); // Votes @@ -17,10 +15,10 @@ pub mod ERC721VotesMock { impl VotesImpl = VotesComponent::VotesImpl; impl VotesInternalImpl = VotesComponent::InternalImpl; - // ERC721 + // ERC20 #[abi(embed_v0)] - impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl; - impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; + impl ERC20InternalImpl = ERC20Component::InternalImpl; // Nonces #[abi(embed_v0)] @@ -29,11 +27,9 @@ pub mod ERC721VotesMock { #[storage] pub struct Storage { #[substorage(v0)] - pub erc721_votes: VotesComponent::Storage, - #[substorage(v0)] - pub erc721: ERC721Component::Storage, + pub erc20_votes: VotesComponent::Storage, #[substorage(v0)] - pub src5: SRC5Component::Storage, + pub erc20: ERC20Component::Storage, #[substorage(v0)] pub nonces: NoncesComponent::Storage } @@ -42,11 +38,9 @@ pub mod ERC721VotesMock { #[derive(Drop, starknet::Event)] enum Event { #[flat] - ERC721VotesEvent: VotesComponent::Event, - #[flat] - ERC721Event: ERC721Component::Event, + ERC20VotesEvent: VotesComponent::Event, #[flat] - SRC5Event: SRC5Component::Event, + ERC20Event: ERC20Component::Event, #[flat] NoncesEvent: NoncesComponent::Event } @@ -61,51 +55,47 @@ pub mod ERC721VotesMock { } } - impl ERC721VotesHooksImpl of ERC721Component::ERC721HooksTrait { - // We need to use the `before_update` hook to check the previous owner - // before the transfer is executed. - fn before_update( - ref self: ERC721Component::ComponentState, - to: ContractAddress, - token_id: u256, - auth: ContractAddress + impl ERC20VotesHooksImpl of ERC20Component::ERC20HooksTrait { + fn after_update( + ref self: ERC20Component::ComponentState, + from: ContractAddress, + recipient: ContractAddress, + amount: u256 ) { let mut contract_state = self.get_contract_mut(); - - // We use the internal function here since it does not check if the token id exists - // which is necessary for mints - let previous_owner = self._owner_of(token_id); - contract_state.erc721_votes.transfer_voting_units(previous_owner, to, 1); + contract_state.erc20_votes.transfer_voting_units(from, recipient, amount); } } #[constructor] fn constructor(ref self: ContractState) { - self.erc721.initializer("MyToken", "MTK", ""); + self.erc20.initializer("MyToken", "MTK"); } } #[starknet::contract] -pub mod ERC20VotesMock { +pub mod ERC721VotesMock { use openzeppelin_governance::votes::VotesComponent; - use openzeppelin_token::erc20::ERC20Component; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_token::erc721::ERC721Component; use openzeppelin_utils::cryptography::nonces::NoncesComponent; use openzeppelin_utils::cryptography::snip12::SNIP12Metadata; use starknet::ContractAddress; - component!(path: VotesComponent, storage: erc20_votes, event: ERC20VotesEvent); - component!(path: ERC20Component, storage: erc20, event: ERC20Event); + component!(path: VotesComponent, storage: erc721_votes, event: ERC721VotesEvent); + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); component!(path: NoncesComponent, storage: nonces, event: NoncesEvent); - // Votes and ERC20Votes + // Votes #[abi(embed_v0)] impl VotesImpl = VotesComponent::VotesImpl; impl VotesInternalImpl = VotesComponent::InternalImpl; - // ERC20 + // ERC721 #[abi(embed_v0)] - impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl; - impl ERC20InternalImpl = ERC20Component::InternalImpl; + impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl; + impl ERC721InternalImpl = ERC721Component::InternalImpl; // Nonces #[abi(embed_v0)] @@ -114,9 +104,11 @@ pub mod ERC20VotesMock { #[storage] pub struct Storage { #[substorage(v0)] - pub erc20_votes: VotesComponent::Storage, + pub erc721_votes: VotesComponent::Storage, #[substorage(v0)] - pub erc20: ERC20Component::Storage, + pub erc721: ERC721Component::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, #[substorage(v0)] pub nonces: NoncesComponent::Storage } @@ -125,9 +117,11 @@ pub mod ERC20VotesMock { #[derive(Drop, starknet::Event)] enum Event { #[flat] - ERC20VotesEvent: VotesComponent::Event, + ERC721VotesEvent: VotesComponent::Event, #[flat] - ERC20Event: ERC20Component::Event, + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, #[flat] NoncesEvent: NoncesComponent::Event } @@ -142,21 +136,26 @@ pub mod ERC20VotesMock { } } - impl ERC20VotesHooksImpl of ERC20Component::ERC20HooksTrait { - fn after_update( - ref self: ERC20Component::ComponentState, - from: ContractAddress, - recipient: ContractAddress, - amount: u256 + impl ERC721VotesHooksImpl of ERC721Component::ERC721HooksTrait { + // We need to use the `before_update` hook to check the previous owner + // before the transfer is executed. + fn before_update( + ref self: ERC721Component::ComponentState, + to: ContractAddress, + token_id: u256, + auth: ContractAddress ) { let mut contract_state = self.get_contract_mut(); - contract_state.erc20_votes.transfer_voting_units(from, recipient, amount); + + // We use the internal function here since it does not check if the token id exists + // which is necessary for mints + let previous_owner = self._owner_of(token_id); + contract_state.erc721_votes.transfer_voting_units(previous_owner, to, 1); } } #[constructor] fn constructor(ref self: ContractState) { - self.erc20.initializer("MyToken", "MTK"); + self.erc721.initializer("MyToken", "MTK", ""); } } - diff --git a/packages/testing/src/constants.cairo b/packages/testing/src/constants.cairo index d3cd64fdc..07752c82e 100644 --- a/packages/testing/src/constants.cairo +++ b/packages/testing/src/constants.cairo @@ -102,6 +102,14 @@ pub fn DELEGATEE() -> ContractAddress { contract_address_const::<'DELEGATEE'>() } +pub fn TIMELOCK() -> ContractAddress { + contract_address_const::<'TIMELOCK'>() +} + +pub fn VOTES_TOKEN() -> ContractAddress { + contract_address_const::<'VOTES_TOKEN'>() +} + pub fn ALICE() -> ContractAddress { contract_address_const::<'ALICE'>() } diff --git a/packages/utils/src/bytearray.cairo b/packages/utils/src/bytearray.cairo new file mode 100644 index 000000000..f2f383943 --- /dev/null +++ b/packages/utils/src/bytearray.cairo @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v0.19.0 (utils/bytearray.cairo) + +use core::byte_array::ByteArrayTrait; +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::pedersen::PedersenTrait; +use core::to_byte_array::FormatAsByteArray; + +/// Reads n bytes from a byte array starting from a given index. +/// +/// Requirements: +/// +/// - `start + n` must be less than or equal to the length of the byte array. +pub fn read_n_bytes(data: @ByteArray, start: u32, n: u32) -> ByteArray { + let end_index = start + n; + assert(end_index <= data.len(), 'ByteArray: out of bounds'); + + let mut result: ByteArray = Default::default(); + for i in start..end_index { + result.append_byte(data[i]); + }; + result +} + +/// Converts a value to a byte array with a given base and padding. +/// +/// Requirements: +/// +/// - `base` cannot be zero. +pub fn to_byte_array, +Copy>( + value: @T, base: u8, padding: u16 +) -> ByteArray { + let value: felt252 = (*value).into(); + let base: felt252 = base.into(); + let mut byte_array = value + .format_as_byte_array(base.try_into().expect('ByteArray: base cannot be 0')); + + if padding.into() > byte_array.len() { + let mut padding = padding.into() - byte_array.len(); + while padding > 0 { + byte_array = "0" + byte_array; + padding -= 1; + }; + }; + byte_array +} + +/// Returns a unique hash given a ByteArray. +/// +/// The hash is computed by serializing the data into a span of felts, and +/// then hashing the span using the Pedersen hash algorithm. +pub fn hash_byte_array(data: @ByteArray) -> felt252 { + let mut serialized = array![]; + + data.serialize(ref serialized); + let len = serialized.len(); + + let mut state = PedersenTrait::new(0); + for elem in serialized { + state = state.update_with(elem); + }; + state = state.update_with(len); + state.finalize() +} + +/// ByteArray extension trait. +#[generate_trait] +pub impl ByteArrayExtImpl of ByteArrayExtTrait { + // Reads n bytes from a byte array starting from a given index. + /// + /// Requirements: + /// + /// - `start + n` must be less than or equal to the length of the byte array. + fn read_n_bytes(self: @ByteArray, start: u32, n: u32) -> ByteArray { + read_n_bytes(self, start, n) + } + + /// Converts a value to a byte array with a given base and padding. + /// + /// Requirements: + /// + /// - `base` cannot be zero. + fn to_byte_array, +Copy>( + self: @T, base: u8, padding: u16 + ) -> ByteArray { + to_byte_array(self, base, padding) + } + + /// Hashes a byte array using the Pedersen hash algorithm. + /// Encodes the byte array as a ´Span´ by serializing ´data´. + fn hash(self: @ByteArray) -> felt252 { + hash_byte_array(self) + } +} diff --git a/packages/utils/src/cryptography/snip12.cairo b/packages/utils/src/cryptography/snip12.cairo index f2f1df794..9135333f7 100644 --- a/packages/utils/src/cryptography/snip12.cairo +++ b/packages/utils/src/cryptography/snip12.cairo @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // OpenZeppelin Contracts for Cairo v0.19.0 (utils/cryptography/snip12.cairo) -use core::hash::{HashStateTrait, HashStateExTrait}; -use core::poseidon::PoseidonTrait; +use core::hash::{HashStateTrait, HashStateExTrait, Hash}; +use core::poseidon::{PoseidonTrait, HashState}; use starknet::{ContractAddress, get_tx_info}; // selector!( @@ -77,3 +77,14 @@ pub(crate) impl OffchainMessageHashImpl< state.finalize() } } + +/// Hash trait implementation for a span of elements according to SNIP-12. +pub impl SNIP12HashSpanImpl, +Hash> of Hash, HashState> { + fn update_state(mut state: HashState, value: Span) -> HashState { + let mut inner_state = PoseidonTrait::new(); + for elem in value { + inner_state = inner_state.update_with(*elem); + }; + state.update_with(inner_state.finalize()) + } +} diff --git a/packages/utils/src/lib.cairo b/packages/utils/src/lib.cairo index e1d443c29..02dcf735f 100644 --- a/packages/utils/src/lib.cairo +++ b/packages/utils/src/lib.cairo @@ -1,6 +1,4 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Contracts for Cairo v0.19.0 (utils/lib.cairo) - +pub mod bytearray; pub mod cryptography; pub mod deployments; pub mod interfaces; diff --git a/packages/utils/src/structs.cairo b/packages/utils/src/structs.cairo index 4c0eada6d..a42c00014 100644 --- a/packages/utils/src/structs.cairo +++ b/packages/utils/src/structs.cairo @@ -1 +1,3 @@ pub mod checkpoint; + +pub use checkpoint::{Trace, Checkpoint}; diff --git a/packages/utils/src/structs/checkpoint.cairo b/packages/utils/src/structs/checkpoint.cairo index c677f805f..46f2d39ae 100644 --- a/packages/utils/src/structs/checkpoint.cairo +++ b/packages/utils/src/structs/checkpoint.cairo @@ -7,8 +7,6 @@ use starknet::storage::{StoragePath, StorageAsPath, Vec, VecTrait, Mutable, Muta use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; use starknet::storage_access::StorePacking; -const _2_POW_184: felt252 = 0x10000000000000000000000000000000000000000000000; - /// `Trace` struct, for checkpointing values as they change at different points in /// time, and later looking up past values by block timestamp. #[starknet::storage_node] @@ -161,20 +159,26 @@ impl CheckpointImpl of CheckpointTrait { } } +const _2_POW_184: felt252 = 0x10000000000000000000000000000000000000000000000; +const _128_BITS_MASK: u256 = 0xffffffffffffffffffffffffffffffff; + /// Packs a Checkpoint into a (felt252, felt252). /// /// The packing is done as follows: +/// /// - The first felt of the tuple contains `key` and `value.low`. -/// - In this first felt, the first four bits are skipped to avoid representation errors due -/// to `felt252` max value being a bit less than a 252 bits number max value -/// (https://docs.starknet.io/documentation/architecture_and_concepts/Cryptography/p-value/). /// - `key` is stored at range [4,67] bits (0-indexed), taking the most significant usable bits. /// - `value.low` is stored at range [124, 251], taking the less significant bits (at the end). /// - `value.high` is stored as the second tuple element. +/// +/// NOTE: In this first felt, the first four bits are skipped to avoid representation errors due +/// to `felt252` max value being a bit less than a 252 bits number max value +/// (https://docs.starknet.io/documentation/architecture_and_concepts/Cryptography/p-value/). impl CheckpointStorePacking of StorePacking { fn pack(value: Checkpoint) -> (felt252, felt252) { let checkpoint = value; - // shift-left by 184 bits + + // shift-left to reach the corresponding position let key = checkpoint.key.into() * _2_POW_184; let key_and_low = key + checkpoint.value.low.into(); @@ -183,11 +187,11 @@ impl CheckpointStorePacking of StorePacking { fn unpack(value: (felt252, felt252)) -> Checkpoint { let (key_and_low, high) = value; - let key_and_low: u256 = key_and_low.into(); - // shift-right by 184 bits + + // shift-right and mask to extract the corresponding values let key: u256 = key_and_low / _2_POW_184.into(); - let low = key_and_low & 0xffffffffffffffffffffffffffffffff; + let low = key_and_low & _128_BITS_MASK; Checkpoint { key: key.try_into().unwrap(), diff --git a/sncast_scripts/Scarb.lock b/sncast_scripts/Scarb.lock index fc192541d..58357c836 100644 --- a/sncast_scripts/Scarb.lock +++ b/sncast_scripts/Scarb.lock @@ -25,14 +25,6 @@ dependencies = [ "openzeppelin_token", ] -[[package]] -name = "openzeppelin_governance" -version = "0.19.0" -dependencies = [ - "openzeppelin_access", - "openzeppelin_introspection", -] - [[package]] name = "openzeppelin_introspection" version = "0.19.0" @@ -47,6 +39,7 @@ dependencies = [ "openzeppelin_introspection", "openzeppelin_token", "openzeppelin_upgrades", + "openzeppelin_utils", ] [[package]] @@ -60,9 +53,10 @@ dependencies = [ name = "openzeppelin_token" version = "0.19.0" dependencies = [ + "openzeppelin_access", "openzeppelin_account", - "openzeppelin_governance", "openzeppelin_introspection", + "openzeppelin_utils", ] [[package]] @@ -90,15 +84,15 @@ checksum = "sha256:cfd7c73a6f9984880249babfa8664b69c5f7209c737b1081156a284061ccd [[package]] name = "snforge_scarb_plugin" -version = "0.2.0" +version = "0.32.0" source = "registry+https://scarbs.xyz/" -checksum = "sha256:2e4ce3ebe3f49548bd26908391b5d78537a765d827df0d96c32aeb88941d0d67" +checksum = "sha256:e5a0e80294b1f5f00955c614ee3fc94c843ff0d27935693c3598d0ac8d79250a" [[package]] name = "snforge_std" -version = "0.30.0" +version = "0.32.0" source = "registry+https://scarbs.xyz/" -checksum = "sha256:2f3c4846881813ac0f5d1460981249c9f5e2a6831e752beedf9b70975495b4ec" +checksum = "sha256:0e3cb45c6276334fd142a77212f0592d55744f1c022b7a63f20bcd79d0ce3927" dependencies = [ "snforge_scarb_plugin", ]