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
The state of an operation is represented by the OperationState
enum and can be retrieved
by calling the get_operation_state
function with the operation’s identifier.
The identifier of an operation is a felt252
value, computed as the Pedersen hash of the
operation’s call or calls, its predecessor, and salt. It can be computed by invoking the
implementing contract’s hash_operation
function for single-call operations or
hash_operation_batch
for multi-call operations. Submitting an operation with identical calls,
predecessor, and the same salt value a second time will fail, as operation identifiers must be
unique. To resolve this, use a different salt value to generate a unique identifier.
Timelocked operations follow a specific lifecycle:
Unset
→ Waiting
→ Ready
→ Done
-
Unset
: the operation has not been scheduled or has been canceled. -
Waiting
: the operation has been scheduled and is pending the scheduled delay. -
Ready
: the timer has expired, and the operation is eligible for execution. -
Done
: the operation has been executed.
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 withPROPOSER_ROLE
will also be grantedCANCELLER_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);
}