Governance

Decentralized protocols are in constant evolution from the moment they are publicly released. Often, the initial team retains control of this evolution in the first stages, but eventually delegates it to a community of stakeholders. The process by which this community makes decisions is called on-chain governance, and it has become a central component of decentralized protocols, fueling varied decisions such as parameter tweaking, smart contract upgrades, integrations with other protocols, treasury management, grants, etc.

The Contracts for Cairo library aims to build a modular system of governance components for users to easily integrate and customize in their contracts.

Timelock Controller

The Timelock Controller provides a means of enforcing time delays on the execution of transactions. This is considered good practice regarding governance systems because it allows users the opportunity to exit the system if they disagree with a decision before it is executed.

The Timelock contract itself executes transactions, not the user. The Timelock should, therefore, hold associated funds, ownership, and access control roles.

Operation lifecycle

Timelocked operations are identified by a unique id (their hash) and follow a specific OperationState lifecycle:

UnsetWaitingReadyDone

Timelock flow

Schedule

When a proposer calls schedule, the OperationState moves from Unset to Waiting. This starts a timer that must be greater than or equal to the minimum delay. The timer expires at a timestamp accessible through get_timestamp. Once the timer expires, the OperationState automatically moves to the Ready state. At this point, it can be executed.

Execute

By calling execute, an executor triggers the operation’s underlying transactions and moves it to the Done state. If the operation has a predecessor, the predecessor’s operation must be in the Done state for this transaction to succeed.

Cancel

The cancel function allows cancellers to cancel any pending operations. This resets the operation to the Unset state. It is therefore possible for a proposer to re-schedule an operation that has been cancelled. In this case, the timer restarts when the operation is re-scheduled.

Roles

TimelockControllerComponent leverages an AccessControlComponent setup that we need to understand in order to set up roles.

  • PROPOSER_ROLE - in charge of queueing operations.

  • CANCELLER_ROLE - may cancel scheduled operations. During initialization, accounts granted with PROPOSER_ROLE will also be granted CANCELLER_ROLE. Therefore, the initial proposers may also cancel operations after they are scheduled.

  • EXECUTOR_ROLE - in charge of executing already available operations.

  • DEFAULT_ADMIN_ROLE - can grant and revoke the three previous roles.

The DEFAULT_ADMIN_ROLE is a sensitive role that will be granted automatically to the timelock itself and optionally to a second account. The latter case may be required to ease a contract’s initial configuration; however, this role should promptly be renounced.

Furthermore, the timelock component supports the concept of open roles for the EXECUTOR_ROLE. This allows anyone to execute an operation once it’s in the Ready OperationState. To enable the EXECUTOR_ROLE to be open, grant the zero address with the EXECUTOR_ROLE.

Be very careful with enabling open roles as anyone can call the function.

Minimum delay

The minimum delay of the timelock acts as a buffer from when a proposer schedules an operation to the earliest point at which an executor may execute that operation. The idea is for users, should they disagree with a scheduled proposal, to have options such as exiting the system or making their case for cancellers to cancel the operation.

After initialization, the only way to change the timelock’s minimum delay is to schedule it and execute it with the same flow as any other operation.

The minimum delay of a contract is accessible through get_min_delay.

Usage

Integrating the timelock into a contract requires integrating TimelockControllerComponent as well as SRC5Component and AccessControlComponent as dependencies. The contract’s constructor should initialize the timelock which consists of setting the:

  • Proposers and executors.

  • Minimum delay between scheduling and executing an operation.

  • Optional admin if additional configuration is required.

The optional admin should renounce their role once configuration is complete.

Here’s an example of a simple timelock contract:

#[starknet::contract]
mod TimelockControllerContract {
    use openzeppelin_access::accesscontrol::AccessControlComponent;
    use openzeppelin_governance::timelock::TimelockControllerComponent;
    use openzeppelin_introspection::src5::SRC5Component;
    use starknet::ContractAddress;

    component!(path: AccessControlComponent, storage: access_control, event: AccessControlEvent);
    component!(path: TimelockControllerComponent, storage: timelock, event: TimelockEvent);
    component!(path: SRC5Component, storage: src5, event: SRC5Event);

    // Timelock Mixin
    #[abi(embed_v0)]
    impl TimelockMixinImpl =
        TimelockControllerComponent::TimelockMixinImpl<ContractState>;
    impl TimelockInternalImpl = TimelockControllerComponent::InternalImpl<ContractState>;

    #[storage]
    struct Storage {
        #[substorage(v0)]
        access_control: AccessControlComponent::Storage,
        #[substorage(v0)]
        timelock: TimelockControllerComponent::Storage,
        #[substorage(v0)]
        src5: SRC5Component::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        AccessControlEvent: AccessControlComponent::Event,
        #[flat]
        TimelockEvent: TimelockControllerComponent::Event,
        #[flat]
        SRC5Event: SRC5Component::Event
    }

    #[constructor]
    fn constructor(
        ref self: ContractState,
        min_delay: u64,
        proposers: Span<ContractAddress>,
        executors: Span<ContractAddress>,
        admin: ContractAddress
    ) {
        self.timelock.initializer(min_delay, proposers, executors, admin);
    }
}

Interface

This is the full interface of the TimelockMixinImpl implementation:

#[starknet::interface]
pub trait TimelockABI<TState> {
    // ITimelock
    fn is_operation(self: @TState, id: felt252) -> bool;
    fn is_operation_pending(self: @TState, id: felt252) -> bool;
    fn is_operation_ready(self: @TState, id: felt252) -> bool;
    fn is_operation_done(self: @TState, id: felt252) -> bool;
    fn get_timestamp(self: @TState, id: felt252) -> u64;
    fn get_operation_state(self: @TState, id: felt252) -> OperationState;
    fn get_min_delay(self: @TState) -> u64;
    fn hash_operation(self: @TState, call: Call, predecessor: felt252, salt: felt252) -> felt252;
    fn hash_operation_batch(
        self: @TState, calls: Span<Call>, predecessor: felt252, salt: felt252
    ) -> felt252;
    fn schedule(ref self: TState, call: Call, predecessor: felt252, salt: felt252, delay: u64);
    fn schedule_batch(
        ref self: TState, calls: Span<Call>, predecessor: felt252, salt: felt252, delay: u64
    );
    fn cancel(ref self: TState, id: felt252);
    fn execute(ref self: TState, call: Call, predecessor: felt252, salt: felt252);
    fn execute_batch(ref self: TState, calls: Span<Call>, predecessor: felt252, salt: felt252);
    fn update_delay(ref self: TState, new_delay: u64);

    // ISRC5
    fn supports_interface(self: @TState, interface_id: felt252) -> bool;

    // IAccessControl
    fn has_role(self: @TState, role: felt252, account: ContractAddress) -> bool;
    fn get_role_admin(self: @TState, role: felt252) -> felt252;
    fn grant_role(ref self: TState, role: felt252, account: ContractAddress);
    fn revoke_role(ref self: TState, role: felt252, account: ContractAddress);
    fn renounce_role(ref self: TState, role: felt252, account: ContractAddress);

    // IAccessControlCamel
    fn hasRole(self: @TState, role: felt252, account: ContractAddress) -> bool;
    fn getRoleAdmin(self: @TState, role: felt252) -> felt252;
    fn grantRole(ref self: TState, role: felt252, account: ContractAddress);
    fn revokeRole(ref self: TState, role: felt252, account: ContractAddress);
    fn renounceRole(ref self: TState, role: felt252, account: ContractAddress);
}

Votes

The VotesComponent provides a flexible system for tracking and delegating voting power. This system allows users to delegate their voting power to other addresses, enabling more active participation in governance.

By default, token balance does not account for voting power. This makes transfers cheaper. The downside is that it requires users to delegate to themselves in order to activate checkpoints and have their voting power tracked.
The transferring of voting units must be handled by the implementing contract. In the case of ERC20 and ERC721 this is usually done via the hooks. You can check the usage section for examples of how to implement this.

Key Features

  1. Delegation: Users can delegate their voting power to any address, including themselves. Vote power can be delegated either by calling the delegate function directly, or by providing a signature to be used with delegate_by_sig.

  2. Historical lookups: The system keeps track of historical snapshots for each account, which allows the voting power of an account to be queried at a specific timestamp.
    This can be used for example to determine the voting power of an account when a proposal was created, rather than using the current balance.

Usage

When integrating the VotesComponent, the VotingUnitsTrait must be implemented to get the voting units for a given account as a function of the implementing contract.
For simplicity, this module already provides two implementations for ERC20 and ERC721 tokens, which will work out of the box if the respective components are integrated.
Additionally, you must implement the NoncesComponent and the SNIP12Metadata trait to enable delegation by signatures.

Here’s an example of how to structure a simple ERC20Votes contract:

#[starknet::contract]
mod ERC20VotesContract {
    use openzeppelin_governance::votes::VotesComponent;
    use openzeppelin_token::erc20::ERC20Component;
    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: NoncesComponent, storage: nonces, event: NoncesEvent);

    // Votes
    #[abi(embed_v0)]
    impl VotesImpl = VotesComponent::VotesImpl<ContractState>;
    impl VotesInternalImpl = VotesComponent::InternalImpl<ContractState>;

    // ERC20
    #[abi(embed_v0)]
    impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl<ContractState>;
    impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;

    // Nonces
    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;

    #[storage]
    pub struct Storage {
        #[substorage(v0)]
        pub erc20_votes: VotesComponent::Storage,
        #[substorage(v0)]
        pub erc20: ERC20Component::Storage,
        #[substorage(v0)]
        pub nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC20VotesEvent: VotesComponent::Event,
        #[flat]
        ERC20Event: ERC20Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }

    // Required for hash computation.
    pub impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'DAPP_NAME'
        }
        fn version() -> felt252 {
            'DAPP_VERSION'
        }
    }

    // We need to call the `transfer_voting_units` function after
    // every mint, burn and transfer.
    // For this, we use the `after_update` hook of the `ERC20Component::ERC20HooksTrait`.
    impl ERC20VotesHooksImpl of ERC20Component::ERC20HooksTrait<ContractState> {
        fn after_update(
            ref self: ERC20Component::ComponentState<ContractState>,
            from: ContractAddress,
            recipient: ContractAddress,
            amount: u256
        ) {
            let mut contract_state = ERC20Component::HasComponent::get_contract_mut(ref self);
            contract_state.erc20_votes.transfer_voting_units(from, recipient, amount);
        }
    }

    #[constructor]
    fn constructor(ref self: ContractState) {
        self.erc20.initializer("MyToken", "MTK");
    }
}

And here’s an example of how to structure a simple ERC721Votes contract:

#[starknet::contract]
pub mod ERC721VotesContract {
    use openzeppelin_governance::votes::VotesComponent;
    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: 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
    #[abi(embed_v0)]
    impl VotesImpl = VotesComponent::VotesImpl<ContractState>;
    impl VotesInternalImpl = VotesComponent::InternalImpl<ContractState>;

    // ERC721
    #[abi(embed_v0)]
    impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;
    impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;

    // Nonces
    #[abi(embed_v0)]
    impl NoncesImpl = NoncesComponent::NoncesImpl<ContractState>;

    #[storage]
    pub struct Storage {
        #[substorage(v0)]
        pub erc721_votes: VotesComponent::Storage,
        #[substorage(v0)]
        pub erc721: ERC721Component::Storage,
        #[substorage(v0)]
        pub src5: SRC5Component::Storage,
        #[substorage(v0)]
        pub nonces: NoncesComponent::Storage
    }

    #[event]
    #[derive(Drop, starknet::Event)]
    enum Event {
        #[flat]
        ERC721VotesEvent: VotesComponent::Event,
        #[flat]
        ERC721Event: ERC721Component::Event,
        #[flat]
        SRC5Event: SRC5Component::Event,
        #[flat]
        NoncesEvent: NoncesComponent::Event
    }

    /// Required for hash computation.
    pub impl SNIP12MetadataImpl of SNIP12Metadata {
        fn name() -> felt252 {
            'DAPP_NAME'
        }
        fn version() -> felt252 {
            'DAPP_VERSION'
        }
    }

    // We need to call the `transfer_voting_units` function after
    // every mint, burn and transfer.
    // For this, we use the `before_update` hook of the
    //`ERC721Component::ERC721HooksTrait`.
    // This hook is called before the transfer is executed.
    // This  gives us access to the previous owner.
    impl ERC721VotesHooksImpl of ERC721Component::ERC721HooksTrait<ContractState> {
        fn before_update(
            ref self: ERC721Component::ComponentState<ContractState>,
            to: ContractAddress,
            token_id: u256,
            auth: ContractAddress
        ) {
            let mut contract_state = ERC721Component::HasComponent::get_contract_mut(ref self);

            // 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.erc721.initializer("MyToken", "MTK", "");
    }
}

Interface

This is the full interface of the VotesImpl implementation:

#[starknet::interface]
pub trait VotesABI<TState> {
    // IVotes
    fn get_votes(self: @TState, account: ContractAddress) -> u256;
    fn get_past_votes(self: @TState, account: ContractAddress, timepoint: u64) -> u256;
    fn get_past_total_supply(self: @TState, timepoint: u64) -> u256;
    fn delegates(self: @TState, account: ContractAddress) -> ContractAddress;
    fn delegate(ref self: TState, delegatee: ContractAddress);
    fn delegate_by_sig(ref self: TState, delegator: ContractAddress, delegatee: ContractAddress, nonce: felt252, expiry: u64, signature: Span<felt252>);

    // INonces
    fn nonces(self: @TState, owner: ContractAddress) -> felt252;
}