Before you continue, please read and agree to the Terms of Service and Optimism Community Agreement.

Before you continue, please read and agree to the Terms of Service and Optimism Community Agreement.

Alberto Cuesta Cañada

Alberto Cuesta Cañada

Alberto Cuesta Cañada

February 19, 2026

February 19, 2026

Timelock Guard

A timelock is an essential tool in blockchain governance. It takes transactions and delays their execution while making them visible to everyone.

Timelocks have traditionally been used to reassure users that protocol owners cannot do anything undesired without giving users time to react and remove their assets. But that is not all you can do with a timelock.

Relatively recent hacks, like the one suffered by Bybit, could likely have been prevented with a timelock. In a scenario where a hacker manages to obtain signatures to execute a malicious transaction, a timelock would reveal that plan and offer an opportunity to counter it.

Unfortunately, the most popular timelocks in use, such as those from OpenZeppelin and Compound, suffer from several drawbacks that make them not great for protecting protocols against external attacks:

  • They require the timelock to have privileged permissions over the assets or protocol.

  • They add several steps to the governance process.

  • They do not make scheduled transactions easily visible, since only a hash identifier is stored.

  • They do not make scheduled transactions easy to cancel: cancellation requires the same permissions as scheduling, or a separate privileged actor.

Given these limitations, we decided at Optimism to design a timelock that would be simple, robust, easy to plug into any protocol governance structure, and that would allow us to easily detect and remove malicious transactions.

The Solution

Most governance actions originate in a Gnosis Safe Multisig, and one of the tools available in such multisigs are Gnosis Safe Guards.

A Gnosis Safe Guard is a smart contract plugin for Gnosis Safe Multisigs that allows the multisig owners to dictate some conditions that are required for any action executed from the multisig.

At heart, any timelock is a condition for execution. A transaction needs to have been revealed to the timelock a certain time before execution. A Gnosis Safe Guard is a perfect fit and allows a timelock function to be implemented as a plugin to a Gnosis Safe Multisig.

Like other timelocks, we implemented three functions: schedule, execute, and cancel. We complemented those with a very visible transaction queue, a dynamic cancellation threshold, and a mechanism to clear the timelock queue.

There are other things that we didn’t build. The Safe already does all the work to store and execute a transaction as part of its core function of enabling collective control of an account. The Safe also includes logic for batching transactions. By not needing to code this ourselves, we greatly reduce the complexity and burden of the timelock.

Let’s discuss the implementation in detail.

Schedule

To schedule a transaction in our Timelock, the user needs to have already collected the signatures for its execution in the Safe, and we reuse the Safe code to check the signatures and derive an identifier hash. The only new code is storing all the transaction data, along with a minimum execution time.

// Get the encoded transaction data as defined in the Safe
// The format of the string returned is: "0x1901{domainSeparator}{safeTxHash}"
bytes memory txHashData = _safe.encodeTransactionData(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Get the transaction hash and data as defined in the Safe
// This value is identical to keccak256(txHashData), but we prefer to use the
// Safe's own internal logic as it is more future-proof in case future versions
// of the Safe change the transaction hash derivation.
bytes32 txHash = _safe.getTransactionHash(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Check if the transaction exists
// A transaction can only be scheduled once, regardless of whether it has been
// cancelled or not, as otherwise an observer could reuse the same signatures to
// either:
// 1. Reschedule a transaction after it has been cancelled
// 2. Reschedule a pending transaction, which would update the execution time
// thus
//    extending the delay for the original transaction.
if (_currentSafeState(_safe).scheduledTransactions[txHash].executionTime != 0) {
  revert TimelockGuard_TransactionAlreadyScheduled();
}

// Verify signatures using the Safe's signature checking logic
// This function call reverts if the signatures are invalid.
_safe.checkSignatures(txHash, txHashData, _signatures);
// Get the encoded transaction data as defined in the Safe
// The format of the string returned is: "0x1901{domainSeparator}{safeTxHash}"
bytes memory txHashData = _safe.encodeTransactionData(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Get the transaction hash and data as defined in the Safe
// This value is identical to keccak256(txHashData), but we prefer to use the
// Safe's own internal logic as it is more future-proof in case future versions
// of the Safe change the transaction hash derivation.
bytes32 txHash = _safe.getTransactionHash(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Check if the transaction exists
// A transaction can only be scheduled once, regardless of whether it has been
// cancelled or not, as otherwise an observer could reuse the same signatures to
// either:
// 1. Reschedule a transaction after it has been cancelled
// 2. Reschedule a pending transaction, which would update the execution time
// thus
//    extending the delay for the original transaction.
if (_currentSafeState(_safe).scheduledTransactions[txHash].executionTime != 0) {
  revert TimelockGuard_TransactionAlreadyScheduled();
}

// Verify signatures using the Safe's signature checking logic
// This function call reverts if the signatures are invalid.
_safe.checkSignatures(txHash, txHashData, _signatures);
// Get the encoded transaction data as defined in the Safe
// The format of the string returned is: "0x1901{domainSeparator}{safeTxHash}"
bytes memory txHashData = _safe.encodeTransactionData(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Get the transaction hash and data as defined in the Safe
// This value is identical to keccak256(txHashData), but we prefer to use the
// Safe's own internal logic as it is more future-proof in case future versions
// of the Safe change the transaction hash derivation.
bytes32 txHash = _safe.getTransactionHash(
    _params.to, _params.value, _params.data, _params.operation,
    _params.safeTxGas, _params.baseGas, _params.gasPrice, _params.gasToken,
    _params.refundReceiver, _nonce);

// Check if the transaction exists
// A transaction can only be scheduled once, regardless of whether it has been
// cancelled or not, as otherwise an observer could reuse the same signatures to
// either:
// 1. Reschedule a transaction after it has been cancelled
// 2. Reschedule a pending transaction, which would update the execution time
// thus
//    extending the delay for the original transaction.
if (_currentSafeState(_safe).scheduledTransactions[txHash].executionTime != 0) {
  revert TimelockGuard_TransactionAlreadyScheduled();
}

// Verify signatures using the Safe's signature checking logic
// This function call reverts if the signatures are invalid.
_safe.checkSignatures(txHash, txHashData, _signatures);

The safe.checkSignatures logic does all the work: it verifies that the signatures are valid and that there are enough of them to reach quorum. By reusing this code from the Safe, our timelock doesn’t need to have its own access control mechanism.

Execute

There is no standalone execute function. Instead, as a Safe Guard, the Timelock is consulted by the Safe before executing a transaction in the checkTransaction function. The main goal of this function is to revert if the transaction was not scheduled at least a defined time before execution.

/// @notice Implementation of Guard interface.Called by the Safe before executing a transaction
/// @dev This function is used to check that the transaction has been scheduled and is ready to
/// execute. It only reads the state of the contract, and potentially reverts in order to
/// protect against execution of unscheduled, early or cancelled transactions.
function checkTransaction(
    address _to,
    uint256 _value,
    bytes memory _data,
    Enum.Operation _operation,
    uint256 _safeTxGas,
    uint256 _baseGas,
    uint256 _gasPrice,
    address _gasToken,
    address payable _refundReceiver,
    bytes memory, /* signatures */
    address _msgSender
)
/// @notice Implementation of Guard interface.Called by the Safe before executing a transaction
/// @dev This function is used to check that the transaction has been scheduled and is ready to
/// execute. It only reads the state of the contract, and potentially reverts in order to
/// protect against execution of unscheduled, early or cancelled transactions.
function checkTransaction(
    address _to,
    uint256 _value,
    bytes memory _data,
    Enum.Operation _operation,
    uint256 _safeTxGas,
    uint256 _baseGas,
    uint256 _gasPrice,
    address _gasToken,
    address payable _refundReceiver,
    bytes memory, /* signatures */
    address _msgSender
)
/// @notice Implementation of Guard interface.Called by the Safe before executing a transaction
/// @dev This function is used to check that the transaction has been scheduled and is ready to
/// execute. It only reads the state of the contract, and potentially reverts in order to
/// protect against execution of unscheduled, early or cancelled transactions.
function checkTransaction(
    address _to,
    uint256 _value,
    bytes memory _data,
    Enum.Operation _operation,
    uint256 _safeTxGas,
    uint256 _baseGas,
    uint256 _gasPrice,
    address _gasToken,
    address payable _refundReceiver,
    bytes memory, /* signatures */
    address _msgSender
)

There is no execute function, instead the timelock uses the execution hook of the guard

By inserting itself in the Safe execution, our timelock doesn’t require any additional work by signers after the scheduling and the wait. It works behind the scenes.

Cancel

In other timelocks, cancellation is often an afterthought, and ends up being implemented in a way that adds complexity and risk.

In our timelock, we wanted to make transaction cancellation easy for the Safe owners. In a crisis situation, it might be difficult to assemble a quorum of signers to cancel a malicious transaction. At the same time, we didn't want to create a parallel governance structure to manage cancellations.

We solved this by creating a no-op signCancellation function. This function does nothing, but Safe owners can create off-chain signatures allowing the Safe to execute it, the same as they would sign any other transaction from the multisig.

The multisig is not going to execute signCancellation, because it does nothing. Instead, we implemented a cancelTransaction function that uses the signatures for signCancellation to assess if enough signers wanted to cancel a transaction. This sounds complicated, but the implementation is very simple as it it built entirely from features already present in the Safe multisig code.

// Generate the cancellation transaction data
bytes memory txData = abi.encodeCall(this.signCancellation, (_txHash));
// Any nonce can be used here, as long as all of the signatures are for the same
// nonce. In practice we expect the nonce to be the same as the nonce of the transaction
// being cancelled, as this most closely mimics the behaviour of the Safe UI's transaction
// replacement feature. However we do not enforce that here, to allow for flexibility,
// and to avoid the need for logic to retrieve the nonce from the transaction being
// cancelled.
bytes memory cancellationTxData = _safe.encodeTransactionData(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);
bytes32 cancellationTxHash = _safe.getTransactionHash(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);

// Verify signatures using the Safe's signature checking logic, with the cancellation
// threshold as the number of signatures required.
_safe.checkNSignatures(
    cancellationTxHash, cancellationTxData, _signatures, _currentSafeState(_safe).cancellationThreshold
);
// Generate the cancellation transaction data
bytes memory txData = abi.encodeCall(this.signCancellation, (_txHash));
// Any nonce can be used here, as long as all of the signatures are for the same
// nonce. In practice we expect the nonce to be the same as the nonce of the transaction
// being cancelled, as this most closely mimics the behaviour of the Safe UI's transaction
// replacement feature. However we do not enforce that here, to allow for flexibility,
// and to avoid the need for logic to retrieve the nonce from the transaction being
// cancelled.
bytes memory cancellationTxData = _safe.encodeTransactionData(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);
bytes32 cancellationTxHash = _safe.getTransactionHash(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);

// Verify signatures using the Safe's signature checking logic, with the cancellation
// threshold as the number of signatures required.
_safe.checkNSignatures(
    cancellationTxHash, cancellationTxData, _signatures, _currentSafeState(_safe).cancellationThreshold
);
// Generate the cancellation transaction data
bytes memory txData = abi.encodeCall(this.signCancellation, (_txHash));
// Any nonce can be used here, as long as all of the signatures are for the same
// nonce. In practice we expect the nonce to be the same as the nonce of the transaction
// being cancelled, as this most closely mimics the behaviour of the Safe UI's transaction
// replacement feature. However we do not enforce that here, to allow for flexibility,
// and to avoid the need for logic to retrieve the nonce from the transaction being
// cancelled.
bytes memory cancellationTxData = _safe.encodeTransactionData(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);
bytes32 cancellationTxHash = _safe.getTransactionHash(
    address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce
);

// Verify signatures using the Safe's signature checking logic, with the cancellation
// threshold as the number of signatures required.
_safe.checkNSignatures(
    cancellationTxHash, cancellationTxData, _signatures, _currentSafeState(_safe).cancellationThreshold
);

The cancelTransaction function was coded mainly as calls to Safe code.

Cancellation Threshold

In describing the cancelTransaction function, we skipped the question of how many signers need to assemble to cancel a transaction.

At the suggestion of some very sharp folks working on the same problem (samczsun, dvf), we decided to implement a dynamic cancellation threshold, which is by far the most complex part of the timelock. We thought it was worth it, though.

The number of signatures needed to cancel a transaction starts at 1, giving a crisis team the best possible chance of stopping an attack.

If the cancellation threshold were always 1, it would be too easy for an attacker to compromise a single key and permanently DoS the Safe. To avoid that, the cancellation threshold increases by one with each consecutive cancellation, and resets back to 1 with a successful execution.

////////////////////////////////////////////////////////////////
//              Internal State-Changing Functions             //
////////////////////////////////////////////////////////////////

/// @notice Increase the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling cancel
/// @param _safe The Safe address to increase the cancellation threshold for.
function _increaseCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);

  if (safeState.cancellationThreshold < maxCancellationThreshold(_safe)) {
    uint256 oldThreshold = safeState.cancellationThreshold;
    safeState.cancellationThreshold++;
    emit CancellationThresholdUpdated(_safe, oldThreshold,
                                      safeState.cancellationThreshold);
  }
}

/// @notice Reset the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling
/// checkAfterExecution
/// @param _safe The Safe address to reset the cancellation threshold for.
function _resetCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);
  uint256 oldThreshold = safeState.cancellationThreshold;
  safeState.cancellationThreshold = 1;
  emit CancellationThresholdUpdated(_safe, oldThreshold, 1);
}
////////////////////////////////////////////////////////////////
//              Internal State-Changing Functions             //
////////////////////////////////////////////////////////////////

/// @notice Increase the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling cancel
/// @param _safe The Safe address to increase the cancellation threshold for.
function _increaseCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);

  if (safeState.cancellationThreshold < maxCancellationThreshold(_safe)) {
    uint256 oldThreshold = safeState.cancellationThreshold;
    safeState.cancellationThreshold++;
    emit CancellationThresholdUpdated(_safe, oldThreshold,
                                      safeState.cancellationThreshold);
  }
}

/// @notice Reset the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling
/// checkAfterExecution
/// @param _safe The Safe address to reset the cancellation threshold for.
function _resetCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);
  uint256 oldThreshold = safeState.cancellationThreshold;
  safeState.cancellationThreshold = 1;
  emit CancellationThresholdUpdated(_safe, oldThreshold, 1);
}
////////////////////////////////////////////////////////////////
//              Internal State-Changing Functions             //
////////////////////////////////////////////////////////////////

/// @notice Increase the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling cancel
/// @param _safe The Safe address to increase the cancellation threshold for.
function _increaseCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);

  if (safeState.cancellationThreshold < maxCancellationThreshold(_safe)) {
    uint256 oldThreshold = safeState.cancellationThreshold;
    safeState.cancellationThreshold++;
    emit CancellationThresholdUpdated(_safe, oldThreshold,
                                      safeState.cancellationThreshold);
  }
}

/// @notice Reset the cancellation threshold for a safe
/// @dev This function must be called only once and only when calling
/// checkAfterExecution
/// @param _safe The Safe address to reset the cancellation threshold for.
function _resetCancellationThreshold(Safe _safe) internal {
  SafeState storage safeState = _currentSafeState(_safe);
  uint256 oldThreshold = safeState.cancellationThreshold;
  safeState.cancellationThreshold = 1;
  emit CancellationThresholdUpdated(_safe, oldThreshold, 1);
}

Cancellation threshold management functions

A determined DoS attack would need a high number of leaked keys to be successful. We capped the cancellation threshold at min(quorum, blocking_threshold), so it never exceeds the quorum, and never exceeds the number of signatures required to permanently block executions from the Safe.

We decided that this should be the limit because if attackers hold a blocking_threshold of keys or a quorum of keys, we already have a more serious problem than them being able to abuse the timelock to DoS the Safe, and it is best to deal with those problems separately.

Pending Transactions

We wanted scheduled transactions to be clearly visible. Other timelocks just emit an event when a transaction is scheduled, but our experience is that monitoring often misses events or can be fooled to miss events.

For this reason, we store the hash identifiers of all pending transactions in an enumerable set. When queried, we return all data fields of each scheduled transaction, to make analysis as easy as possible.

/// @notice Returns the list of all scheduled but not cancelled or executed
/// transactions for
///         for a given safe
/// @dev WARNING: This operation will copy the entire set of pending
/// transactions to memory,
///      which can be quite expensive. This is designed only to be used by view
///      accessors that are queried without any gas fees. Developers should keep
///      in mind that this function has an unbounded cost, and using it as part
///      of a state-changing function may render the function uncallable if the
///      set grows to a point where copying to memory consumes too much gas to
///      fit in a block.
/// @return List of pending transaction hashes
function pendingTransactions(Safe _safe) external view
    returns(ScheduledTransaction[] memory) {
  SafeState storage safeState = _currentSafeState(_safe);

  // Get the list of pending transaction hashes
  bytes32[] memory hashes = safeState.pendingTxHashes.values();

  // We want to provide the caller with the full parameters of each pending
  // transaction, but mappings are not iterable, so we use the enumerable set of
  // pending transaction hashes to retrieve the ScheduledTransaction struct for
  // each hash, and then return an array of the ScheduledTransaction structs.
  ScheduledTransaction[] memory scheduled =
      new ScheduledTransaction[](hashes.length);
  for (uint256 i = 0; i < hashes.length; i++) {
    scheduled[i] = safeState.scheduledTransactions[hashes[i]];
  }
  return scheduled;
}
/// @notice Returns the list of all scheduled but not cancelled or executed
/// transactions for
///         for a given safe
/// @dev WARNING: This operation will copy the entire set of pending
/// transactions to memory,
///      which can be quite expensive. This is designed only to be used by view
///      accessors that are queried without any gas fees. Developers should keep
///      in mind that this function has an unbounded cost, and using it as part
///      of a state-changing function may render the function uncallable if the
///      set grows to a point where copying to memory consumes too much gas to
///      fit in a block.
/// @return List of pending transaction hashes
function pendingTransactions(Safe _safe) external view
    returns(ScheduledTransaction[] memory) {
  SafeState storage safeState = _currentSafeState(_safe);

  // Get the list of pending transaction hashes
  bytes32[] memory hashes = safeState.pendingTxHashes.values();

  // We want to provide the caller with the full parameters of each pending
  // transaction, but mappings are not iterable, so we use the enumerable set of
  // pending transaction hashes to retrieve the ScheduledTransaction struct for
  // each hash, and then return an array of the ScheduledTransaction structs.
  ScheduledTransaction[] memory scheduled =
      new ScheduledTransaction[](hashes.length);
  for (uint256 i = 0; i < hashes.length; i++) {
    scheduled[i] = safeState.scheduledTransactions[hashes[i]];
  }
  return scheduled;
}
/// @notice Returns the list of all scheduled but not cancelled or executed
/// transactions for
///         for a given safe
/// @dev WARNING: This operation will copy the entire set of pending
/// transactions to memory,
///      which can be quite expensive. This is designed only to be used by view
///      accessors that are queried without any gas fees. Developers should keep
///      in mind that this function has an unbounded cost, and using it as part
///      of a state-changing function may render the function uncallable if the
///      set grows to a point where copying to memory consumes too much gas to
///      fit in a block.
/// @return List of pending transaction hashes
function pendingTransactions(Safe _safe) external view
    returns(ScheduledTransaction[] memory) {
  SafeState storage safeState = _currentSafeState(_safe);

  // Get the list of pending transaction hashes
  bytes32[] memory hashes = safeState.pendingTxHashes.values();

  // We want to provide the caller with the full parameters of each pending
  // transaction, but mappings are not iterable, so we use the enumerable set of
  // pending transaction hashes to retrieve the ScheduledTransaction struct for
  // each hash, and then return an array of the ScheduledTransaction structs.
  ScheduledTransaction[] memory scheduled =
      new ScheduledTransaction[](hashes.length);
  for (uint256 i = 0; i < hashes.length; i++) {
    scheduled[i] = safeState.scheduledTransactions[hashes[i]];
  }
  return scheduled;
}

Querying the timelock is very convenient for all monitoring purposes

We considered the added complexity reasonable because the pending transactions set is never consulted on-chain by the timelock itself. Instead, it is maintained as a completely parallel structure for the benefit of monitoring tools.

Resetting the Timelock

Even with all the precautions above, there are some scenarios that could overwhelm the timelock with unwanted transactions, such as those we discussed when working on the cancellation threshold.

In those scenarios, we would expect the protocol to skip the timelock and execute a pause. Then, the crisis management team would resolve the situation before allowing normal operations to resume.

To make a restart safer, we included a very simple switch to remove all pending transactions in a single call, by pointing the Timelock to a new configuration set.

/// @notice Clears the timelock guard configuration for a Safe.
/// @dev Note: Clearing the configuration also cancels all pending transactions.
///      This function is intended for use when a Safe wants to permanently
///      remove the TimelockGuard configuration. Typical usage pattern:
///      1. Safe disables the guard via GuardManager.setGuard(address(0)).
///      2. Safe calls this clearTimelockGuard() function to remove stored
///      configuration.
///      3. If Safe later re-enables the guard, it must call
///      configureTimelockGuard() again. Warning: Clearing the configuration
///      allows all transactions previously scheduled to be scheduled again,
///      including cancelled transactions. It is strongly recommended to
///      manually increment the Safe's nonce when a scheduled transaction is
///      cancelled.
function clearTimelockGuard() external {
  Safe callingSafe = Safe(payable(msg.sender));

  // Check that this guard is NOT enabled on the calling Safe
  // This prevents clearing configuration while guard is still enabled
  if (_isGuardEnabled(callingSafe)) {
    revert TimelockGuard_GuardStillEnabled();
  }

  // Clear the configuration by bumping the nonce, all config and pending
  // transactions will be effectively wiped.
  _safeConfigNonces[callingSafe]++;
}
/// @notice Clears the timelock guard configuration for a Safe.
/// @dev Note: Clearing the configuration also cancels all pending transactions.
///      This function is intended for use when a Safe wants to permanently
///      remove the TimelockGuard configuration. Typical usage pattern:
///      1. Safe disables the guard via GuardManager.setGuard(address(0)).
///      2. Safe calls this clearTimelockGuard() function to remove stored
///      configuration.
///      3. If Safe later re-enables the guard, it must call
///      configureTimelockGuard() again. Warning: Clearing the configuration
///      allows all transactions previously scheduled to be scheduled again,
///      including cancelled transactions. It is strongly recommended to
///      manually increment the Safe's nonce when a scheduled transaction is
///      cancelled.
function clearTimelockGuard() external {
  Safe callingSafe = Safe(payable(msg.sender));

  // Check that this guard is NOT enabled on the calling Safe
  // This prevents clearing configuration while guard is still enabled
  if (_isGuardEnabled(callingSafe)) {
    revert TimelockGuard_GuardStillEnabled();
  }

  // Clear the configuration by bumping the nonce, all config and pending
  // transactions will be effectively wiped.
  _safeConfigNonces[callingSafe]++;
}
/// @notice Clears the timelock guard configuration for a Safe.
/// @dev Note: Clearing the configuration also cancels all pending transactions.
///      This function is intended for use when a Safe wants to permanently
///      remove the TimelockGuard configuration. Typical usage pattern:
///      1. Safe disables the guard via GuardManager.setGuard(address(0)).
///      2. Safe calls this clearTimelockGuard() function to remove stored
///      configuration.
///      3. If Safe later re-enables the guard, it must call
///      configureTimelockGuard() again. Warning: Clearing the configuration
///      allows all transactions previously scheduled to be scheduled again,
///      including cancelled transactions. It is strongly recommended to
///      manually increment the Safe's nonce when a scheduled transaction is
///      cancelled.
function clearTimelockGuard() external {
  Safe callingSafe = Safe(payable(msg.sender));

  // Check that this guard is NOT enabled on the calling Safe
  // This prevents clearing configuration while guard is still enabled
  if (_isGuardEnabled(callingSafe)) {
    revert TimelockGuard_GuardStillEnabled();
  }

  // Clear the configuration by bumping the nonce, all config and pending
  // transactions will be effectively wiped.
  _safeConfigNonces[callingSafe]++;
}

We can reset the timelock with a single call

By making the configuration and pending transaction queue depend on a sequential identifier, we just need to increase it to immediately switch to a default configuration and empty transaction queue, without resorting to heavy cleaning loops.

Conclusion

The dominant timelock implementations are a good fit for protecting users from harmful governance actions, but fall short when protecting protocols against external attackers. Furthermore, they are a burden on already complex governance processes.

At Optimism, we implemented a timelock that integrates closely with Gnosis Safes and demands the minimal possible variation on existing governance processes. By reusing code from the Safe that it integrates with, the timelock becomes much simpler and more robust.

We also paid special attention to how the timelock would be used during a crisis situation, so that the timelock owners have the best possible chance to detect and stop a malicious actor.

Our timelock is public goods and was audited by Spearbit. You can use it in any way that would suit you, including forking it and modifying it. We would love to hear your feedback!

This module was a joint effort by several people at Optimism. The vision came from Kelvin Fichter, and John Mardlin put the code together. Josep Bové, Ethnical, Matt Solomon, and I helped in other capacities.

At Optimism, we are always looking for talented individuals that will help us on our mission to scale Ethereum and build the Superchain, pushing the edge on what is possible and finding smart solutions for complex problems. If that is something that interests you, we are hiring.

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.