Governance

Timelock Controller

Source Code

Overview

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 benefits of a timelock are best seen in an ownership-based system. Typically, the owner is a G-account or another contract. To delay the execution of privileged functions, one can designate a Timelock Controller as the owner of the system.

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.

pub enum OperationState {
    /// Operation has not been scheduled
    Unset,
    /// Operation is scheduled but the delay period has not passed
    Waiting,
    /// Operation is ready to be executed (delay has passed)
    Ready,
    /// Operation has been executed
    Done,
}

The identifier of an operation is a BytesN<32> value, computed as the Keccak256 hash of the operation's target, function, arguments, predecessor, and salt. It can be computed by invoking the hash_operation function. Submitting an operation with identical parameters 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

Timelock Flow

Schedule

When a proposer calls schedule_operation, the OperationState moves from Unset to Waiting. This starts a timer that must be greater than or equal to the minimum delay (specified in ledger sequence numbers). The timer expires at a ledger sequence accessible through get_operation_ledger. Once the current ledger reaches that point (current_ledger >= ready_ledger), the OperationState automatically moves to the Ready state. At this point, it can be executed.

Execute

By calling execute_operation, an executor triggers the operation's underlying transaction 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_operation 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.

Operation Structure

An operation encapsulates all the information needed to invoke a function on a target contract after the timelock delay has passed:

pub struct Operation {
    /// The contract address to call
    pub target: Address,
    /// The function name to invoke on the target contract
    pub function: Symbol,
    /// The serialized arguments to pass to the function
    pub args: Vec<Val>,
    /// Hash of a predecessor operation that must be executed first.
    /// Use BytesN::<32>::from_array(&[0u8; 32]) for no predecessor.
    pub predecessor: BytesN<32>,
    /// A salt value for operation uniqueness.
    /// Allows scheduling the same operation multiple times with different IDs.
    pub salt: BytesN<32>,
}

Predecessor Operations

The predecessor field allows you to create dependencies between operations. An operation with a predecessor can only be executed after the predecessor operation has been completed (in Done state). Use BytesN::<32>::from_array(&[0u8; 32]) to indicate no predecessor dependency.

Salt for Uniqueness

The salt field ensures operation uniqueness. If you need to schedule the same operation (same target, function, args, and predecessor) multiple times, use different salt values to generate unique operation IDs.

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.

The Timelock Trait

The Timelock trait defines the standard interface of a timelock controller contract. Implement it with #[contractimpl(contracttrait)] so that its default methods are exported too. The trait also generates a TimelockClient that other contracts use for type-safe cross-contract calls: for example, a Governor with queuing enabled schedules and cancels proposals on the timelock through it (see the fungible-governor-timelock example).

// Default implementations
fn get_min_delay(e: &Env) -> u32;
fn hash_operation(e: &Env, target: Address, function: Symbol, args: Vec<Val>,
    predecessor: BytesN<32>, salt: BytesN<32>) -> BytesN<32>;
fn get_operation_ledger(e: &Env, operation_id: BytesN<32>) -> u32;
fn get_operation_state(e: &Env, operation_id: BytesN<32>) -> OperationState;

// No default — must implement
fn schedule(e: &Env, target: Address, function: Symbol, args: Vec<Val>,
    predecessor: BytesN<32>, salt: BytesN<32>, delay: u32, proposer: Address) -> BytesN<32>;
fn execute(e: &Env, target: Address, function: Symbol, args: Vec<Val>,
    predecessor: BytesN<32>, salt: BytesN<32>, executor: Option<Address>) -> Val;
fn cancel(e: &Env, operation_id: BytesN<32>, canceller: Address);
fn update_delay(e: &Env, new_delay: u32, operator: Address);

schedule, execute, cancel and update_delay have no default implementation because their access control varies per deployment. An implementation performs its authorization checks and then delegates to the library functions schedule_operation, execute_operation, cancel_operation and set_min_delay, which perform the state transitions and emit the events.

Usage Example

Below is a timelock controller that implements the Timelock trait with role-based access control.

Roles

The timelock controller leverages an Access Control setup with the following roles:

  • PROPOSER_ROLE: Can schedule operations. Proposers are automatically granted the Canceller role during initialization.
  • CANCELLER_ROLE: Can cancel pending operations.
  • EXECUTOR_ROLE: Can execute operations that are ready. If no executors are configured, anyone can execute ready operations.
  • Admin: Can manage all roles and update the minimum delay. By default, the contract itself is the admin, meaning admin operations must go through the timelock process.

An optional external admin can be provided during deployment to aid with initial configuration of roles without being subject to delay. However, this role should be subsequently renounced in favor of administration through timelocked proposals to ensure all administrative actions have proper oversight and transparency.

Self-Administration

When the contract is deployed with admin set to None, the contract address itself becomes the admin (self-administration). This ensures that administrative changes like updating the minimum delay or managing roles must go through the timelock process.

For self-administration operations (e.g., updating the minimum delay, granting and revoking roles), the proposal lifecycle is:

  1. Proposer schedules the operation targeting the timelock contract itself
  2. Once the operation is ready, call the admin function directly (not via execute)
  3. The CustomAccountInterface implementation validates the operation is ready and marks it as executed

Why not use execute for self-administration? Soroban does not allow re-entrancy: a contract cannot call its own public functions during execution. For example, execute cannot internally call update_delay on the same contract. To work around this, the CustomAccountInterface implementation validates and marks operations as executed without performing the cross-contract call, allowing admin functions to be called directly.

The custom __check_auth implementation validates that:

  • Every authorization context has its own OperationMeta entry
  • The operation targets the timelock contract itself
  • The operation was properly scheduled and is ready for execution
  • The predecessor and salt match the scheduled operation
  • The executor (if any) has the required role and has authorized the invocation

The length check at the top of __check_auth is essential. zip stops at the shorter of the two vectors, so without it a signature with fewer OperationMeta entries than there are contexts (or an empty one) would leave the remaining contexts unvalidated and return Ok. Anyone could then authorize arbitrary calls as the timelock, including privileged calls on the contracts it owns, bypassing the delay entirely.

use soroban_sdk::{
    auth::{Context, ContractContext, CustomAccountInterface},
    contract, contracterror, contractimpl, contracttype,
    crypto::Hash,
    panic_with_error, symbol_short, Address, BytesN, Env, IntoVal, Symbol, Val, Vec,
};
use stellar_access::access_control::{
    ensure_role, get_role_member_count, grant_role_no_auth, set_admin, AccessControl,
};
use stellar_governance::timelock::{
    cancel_operation, execute_operation, schedule_operation, set_execute_operation,
    set_min_delay as timelock_set_min_delay, Operation, OperationState, Timelock, TimelockError,
};
use stellar_macros::{only_admin, only_role};

#[contracterror]
#[repr(u32)]
enum TimelockControllerError {
    Mismatch = 0,
}

const PROPOSER_ROLE: Symbol = symbol_short!("proposer");
const EXECUTOR_ROLE: Symbol = symbol_short!("executor");
const CANCELLER_ROLE: Symbol = symbol_short!("canceller");

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct OperationMeta {
    pub predecessor: BytesN<32>,
    pub salt: BytesN<32>,
    pub executor: Option<Address>,
}

#[contract]
pub struct TimelockController;

#[contractimpl]
impl CustomAccountInterface for TimelockController {
    type Error = TimelockError;
    type Signature = Vec<OperationMeta>;

    fn __check_auth(
        e: Env,
        _signature_payload: Hash<32>,
        context_meta: Vec<OperationMeta>,
        auth_contexts: Vec<Context>,
    ) -> Result<(), Self::Error> {
        // `zip` stops at the shorter vector: without this check, extra
        // contexts would be authorized without any validation.
        if auth_contexts.len() != context_meta.len() {
            panic_with_error!(&e, TimelockControllerError::Mismatch);
        }
        for (context, meta) in auth_contexts.iter().zip(context_meta) {
            match context.clone() {
                Context::Contract(ContractContext { contract, fn_name, args }) => {
                    if contract != e.current_contract_address() {
                        panic_with_error!(&e, TimelockError::Unauthorized)
                    }

                    if get_role_member_count(&e, &EXECUTOR_ROLE) != 0 {
                        let args_for_auth = (
                            Symbol::new(&e, "execute_op"),
                            contract.clone(),
                            fn_name.clone(),
                            args.clone(),
                            meta.predecessor.clone(),
                            meta.salt.clone(),
                        )
                            .into_val(&e);

                        let executor = meta.executor.expect("Executor must be present");
                        ensure_role(&e, &EXECUTOR_ROLE, &executor);
                        executor.require_auth_for_args(args_for_auth);
                    }

                    let op = Operation {
                        target: contract,
                        function: fn_name,
                        args,
                        predecessor: meta.predecessor,
                        salt: meta.salt,
                    };
                    set_execute_operation(&e, &op);
                }
                _ => panic_with_error!(&e, TimelockError::Unauthorized),
            }
        }
        Ok(())
    }
}

#[contractimpl]
impl TimelockController {
    pub fn __constructor(
        e: &Env,
        min_delay: u32,
        proposers: Vec<Address>,
        executors: Vec<Address>,
        admin: Option<Address>,
    ) {
        let admin_addr = match admin {
            Some(admin_addr) => admin_addr,
            _ => e.current_contract_address(),
        };
        set_admin(e, &admin_addr);

        for proposer in proposers.iter() {
            grant_role_no_auth(e, &proposer, &PROPOSER_ROLE, &admin_addr);
            grant_role_no_auth(e, &proposer, &CANCELLER_ROLE, &admin_addr);
        }

        for executor in executors.iter() {
            grant_role_no_auth(e, &executor, &EXECUTOR_ROLE, &admin_addr);
        }

        timelock_set_min_delay(e, min_delay);
    }
}

// The getters (`get_min_delay`, `hash_operation`, `get_operation_ledger`,
// `get_operation_state`) come from the trait's default implementations.
#[contractimpl(contracttrait)]
impl Timelock for TimelockController {
    #[only_role(proposer, "proposer")]
    fn schedule(
        e: &Env,
        target: Address,
        function: Symbol,
        args: Vec<Val>,
        predecessor: BytesN<32>,
        salt: BytesN<32>,
        delay: u32,
        proposer: Address,
    ) -> BytesN<32> {
        let operation = Operation { target, function, args, predecessor, salt };
        schedule_operation(e, &operation, delay)
    }

    fn execute(
        e: &Env,
        target: Address,
        function: Symbol,
        args: Vec<Val>,
        predecessor: BytesN<32>,
        salt: BytesN<32>,
        executor: Option<Address>,
    ) -> Val {
        if get_role_member_count(e, &EXECUTOR_ROLE) != 0 {
            let executor = executor.expect("Executor must be present");
            ensure_role(e, &EXECUTOR_ROLE, &executor);
            executor.require_auth();
        }

        let operation = Operation { target, function, args, predecessor, salt };
        execute_operation(e, &operation)
    }

    #[only_role(canceller, "canceller")]
    fn cancel(e: &Env, operation_id: BytesN<32>, canceller: Address) {
        cancel_operation(e, &operation_id);
    }

    #[only_admin]
    fn update_delay(e: &Env, new_delay: u32, _operator: Address) {
        timelock_set_min_delay(e, new_delay);
    }
}

#[contractimpl(contracttrait)]
impl AccessControl for TimelockController {}

The OperationMeta struct is used as the signature type for CustomAccountInterface. When calling a self-administration function, the caller must provide the predecessor and salt values that were used when scheduling the operation, allowing __check_auth to validate and mark the operation as executed.

update_delay ignores operator: #[only_admin] requires the admin's authorization, which with self-administration is granted by __check_auth once the scheduled operation is ready. The full example, with tests, is available in examples/timelock-controller.

See Also