Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
316 changes: 316 additions & 0 deletions content/stellar-contracts/governance/timelock-controller.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
---
title: Timelock Controller
---

[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/governance/src/timelock)

## 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.

```rust
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. 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_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:

```rust
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`.

## Usage Example

We are providing below a complete timelock controller implementation with role-based access control.

### Roles

The timelock controller leverages an [Access Control](/stellar-contracts/access/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. After the delay passes, call the admin function directly (not via `execute_op`)
3. The `CustomAccountInterface` implementation validates the operation is ready and marks it as executed

**Why not use `execute_op` for self-administration?** Soroban does not allow re-entrancy: a contract cannot call its own public functions during execution. For example, `execute_op` 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:
- 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

```rust
use soroban_sdk::{
auth::{Context, ContractContext, CustomAccountInterface},
contract, 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, get_min_delay as timelock_get_min_delay,
get_operation_state, hash_operation as timelock_hash_operation,
is_operation_done, is_operation_pending, is_operation_ready, operation_exists,
schedule_operation, set_execute_operation, set_min_delay as timelock_set_min_delay,
Operation, OperationState, TimelockError,
};
use stellar_macros::{only_admin, only_role};

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> {
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);
}

#[only_role(proposer, "proposer")]
pub fn schedule_op(
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)
}

pub fn execute_op(
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")]
pub fn cancel_op(e: &Env, operation_id: BytesN<32>, canceller: Address) {
cancel_operation(e, &operation_id);
}

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

pub fn get_min_delay(e: &Env) -> u32 {
timelock_get_min_delay(e)
}

pub fn hash_operation(
e: &Env,
target: Address,
function: Symbol,
args: Vec<Val>,
predecessor: BytesN<32>,
salt: BytesN<32>,
) -> BytesN<32> {
let operation = Operation { target, function, args, predecessor, salt };
timelock_hash_operation(e, &operation)
}

pub fn get_operation_state(e: &Env, operation_id: BytesN<32>) -> OperationState {
get_operation_state(e, &operation_id)
}

pub fn is_operation_pending(e: &Env, operation_id: BytesN<32>) -> bool {
is_operation_pending(e, &operation_id)
}

pub fn is_operation_ready(e: &Env, operation_id: BytesN<32>) -> bool {
is_operation_ready(e, &operation_id)
}

pub fn is_operation_done(e: &Env, operation_id: BytesN<32>) -> bool {
is_operation_done(e, &operation_id)
}
}

#[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.

## See Also

* [Access Control](/stellar-contracts/access/access-control)
5 changes: 5 additions & 0 deletions content/stellar-contracts/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ for access control and contract management.
* **[Ownable](/stellar-contracts/access/ownable)**: A simple mechanism with a single account authorized for all privileged actions.
* **[Role-Based Access Control](/stellar-contracts/access/access-control)**: A flexible mechanism with distinct roles for each privileged action.

## Governance

* **[Timelock Controller](/stellar-contracts/governance/timelock-controller)**: Enforce time delays on transaction execution, allowing users to exit the system if they disagree with a decision before it is executed.

## Utilities

* **[Pausable](/stellar-contracts/utils/pausable)**: Pause and unpause contract functions, useful for emergency response.
Expand Down Expand Up @@ -60,6 +64,7 @@ Similarly, utilities and other modules have their own error codes:
* Ownable: `21XX`
* Role Transfer (internal common module for 2-step role transfer): `22XX`
* Accounts: `3XXX`
* Governance: `4XXX`
* Fee Abstraction: `5XXX`

## Important Notes
Expand Down
11 changes: 11 additions & 0 deletions src/navigation/stellar.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,17 @@
}
]
},
{
"type": "folder",
"name": "Governance",
"children": [
{
"type": "page",
"name": "Timelock Controller",
"url": "/stellar-contracts/governance/timelock-controller"
}
]
},
{
"type": "folder",
"name": "Utilities",
Expand Down