diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol new file mode 100644 index 00000000000..3316a191dcb --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +library Enum { + type Operation is uint8; +} + +library TimelockGuard { + struct GuardConfig { + uint256 timelockDelay; + uint256 safetyDelay; + } + + struct ScheduledTransaction { + uint256 executionTime; + bool cancelled; + bool executed; + } +} + +interface Interface { + struct ExecTransactionParams { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; + } + + error TimelockGuard_GuardNotConfigured(); + error TimelockGuard_GuardNotEnabled(); + error TimelockGuard_GuardStillEnabled(); + error TimelockGuard_InvalidTimelockDelay(); + error TimelockGuard_InvalidSafetyDelay(); + error TimelockGuard_TransactionAlreadyCancelled(); + error TimelockGuard_TransactionAlreadyScheduled(); + error TimelockGuard_TransactionNotScheduled(); + + event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); + event GuardConfigured(address indexed safe, uint256 timelockDelay, uint256 safetyDelay); + event TransactionCancelled(address indexed safe, bytes32 indexed txId); + event TransactionScheduled(address indexed safe, bytes32 indexed txId, uint256 when); + + function blockingThresholdForSafe(address) external pure returns (uint256); + function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; + function cancellationThresholdForSafe(address _safe) external view returns (uint256); + function checkAfterExecution(bytes32, bool) external; + function pendingTransactionsForSafe(address) external pure returns (bytes32[] memory); + function checkTransaction( + address, + uint256 _value, + bytes memory, + Enum.Operation, + uint256, + uint256, + uint256, + address, + address payable, + bytes memory, + address + ) external; + function configureTimelockGuard(uint256 _timelockDelay, uint256 _safetyDelay) external; + function scheduledTransactionForSafe(address _safe, bytes32 _txHash) + external + view + returns (TimelockGuard.ScheduledTransaction memory); + function safeConfigs(address) external view returns (uint256 timelockDelay, uint256 safetyDelay); + function scheduleTransaction( + address _safe, + uint256 _nonce, + ExecTransactionParams memory _params, + bytes memory _signatures + ) external; + function version() external view returns (string memory); + function timelockConfigurationForSafe(address _safe) external view returns (TimelockGuard.GuardConfig memory); +} diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json new file mode 100644 index 00000000000..838896bda84 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -0,0 +1,538 @@ +[ + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "", + "type": "address" + } + ], + "name": "blockingThresholdForSafe", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_signatures", + "type": "bytes" + } + ], + "name": "cancelTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "cancellationThresholdForSafe", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + }, + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "name": "checkAfterExecution", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "pendingTransactionsForSafe", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "_operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "_safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "_gasToken", + "type": "address" + }, + { + "internalType": "address payable", + "name": "_refundReceiver", + "type": "address" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + }, + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "checkTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "clearTimelockGuard", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_timelockDelay", + "type": "uint256" + } + ], + "name": "configureTimelockGuard", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" + } + ], + "name": "scheduledTransactionForSafe", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "executionTime", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "cancelled", + "type": "bool" + }, + { + "internalType": "bool", + "name": "executed", + "type": "bool" + } + ], + "internalType": "struct TimelockGuard.ScheduledTransaction", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "", + "type": "address" + } + ], + "name": "safeConfigs", + "outputs": [ + { + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "configured", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "components": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "operation", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "safeTxGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "baseGas", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "gasPrice", + "type": "uint256" + }, + { + "internalType": "address", + "name": "gasToken", + "type": "address" + }, + { + "internalType": "address payable", + "name": "refundReceiver", + "type": "address" + } + ], + "internalType": "struct ExecTransactionParams", + "name": "_params", + "type": "tuple" + }, + { + "internalType": "bytes", + "name": "_signatures", + "type": "bytes" + } + ], + "name": "scheduleTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "timelockConfigurationForSafe", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "configured", + "type": "bool" + } + ], + "internalType": "struct TimelockGuard.GuardConfig", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldThreshold", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newThreshold", + "type": "uint256" + } + ], + "name": "CancellationThresholdUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + } + ], + "name": "GuardCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + } + ], + "name": "GuardConfigured", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + } + ], + "name": "TransactionCancelled", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "nonce", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "bytes32", + "name": "txHash", + "type": "bytes32" + } + ], + "name": "TransactionExecuted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract GnosisSafe", + "name": "safe", + "type": "address" + }, + { + "indexed": true, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "when", + "type": "uint256" + } + ], + "name": "TransactionScheduled", + "type": "event" + }, + { + "inputs": [], + "name": "TimelockGuard_GuardNotConfigured", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_GuardNotEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_GuardStillEnabled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_InvalidTimelockDelay", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionAlreadyCancelled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionAlreadyScheduled", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionNotReady", + "type": "error" + }, + { + "inputs": [], + "name": "TimelockGuard_TransactionNotScheduled", + "type": "error" + } +] diff --git a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json new file mode 100644 index 00000000000..3b67ff1ebd3 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json @@ -0,0 +1,23 @@ +[ + { + "bytes": "32", + "label": "safeConfigs", + "offset": 0, + "slot": "0", + "type": "mapping(contract GnosisSafe => struct TimelockGuard.GuardConfig)" + }, + { + "bytes": "32", + "label": "scheduledTransactions", + "offset": 0, + "slot": "1", + "type": "mapping(contract GnosisSafe => mapping(bytes32 => struct TimelockGuard.ScheduledTransaction))" + }, + { + "bytes": "32", + "label": "safeCancellationThreshold", + "offset": 0, + "slot": "2", + "type": "mapping(contract GnosisSafe => uint256)" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol new file mode 100644 index 00000000000..7bb8e437f66 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Safe +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { ExecTransactionParams } from "src/safe/Types.sol"; + +// Libraries +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +// Interfaces +import { ISemver } from "interfaces/universal/ISemver.sol"; + +/// @title TimelockGuard +/// @notice This guard provides timelock functionality for Safe transactions +/// @dev This is a singleton contract. To use it: +/// 1. The Safe must first enable this guard using GuardManager.setGuard() +/// 2. The Safe must then configure the guard by calling configureTimelockGuard() +contract TimelockGuard is IGuard, ISemver { + using EnumerableSet for EnumerableSet.Bytes32Set; + + /// @notice Configuration for a Safe's timelock guard + struct GuardConfig { + uint256 timelockDelay; + uint256 safetyDelay; + bool safetyDelayEnabled; + } + + /// @notice Scheduled transaction + struct ScheduledTransaction { + uint256 executionTime; + bool cancelled; + bool executed; + ExecTransactionParams params; + } + + /// @notice Mapping from Safe address to its guard configuration + mapping(Safe => GuardConfig) internal _timelockSafeConfiguration; + + /// @notice Mapping from Safe and tx id to scheduled transaction. + mapping(Safe => mapping(bytes32 => ScheduledTransaction)) internal _scheduledTransactions; + + /// @notice Mapping from a Safe to an enumerable set of tx hashes used to store the list of tx + /// hashes which have been scheduled, but not yet exeuted or cancelled. + mapping(Safe => EnumerableSet.Bytes32Set) internal _safePendingTxHashes; + + /// @notice Mapping from Safe to cancellation threshold. + mapping(Safe => uint256) internal _safeCancellationThreshold; + + /// @notice Error for when guard is not enabled for the Safe + error TimelockGuard_GuardNotEnabled(); + + /// @notice Error for when Safe is not configured for this guard + error TimelockGuard_GuardNotConfigured(); + + /// @notice Error for invalid timelock delay + error TimelockGuard_InvalidTimelockDelay(); + + /// @notice Error for invalid safety delay + error TimelockGuard_InvalidSafetyDelay(); + + /// @notice Error for when a transaction is already scheduled + error TimelockGuard_TransactionAlreadyScheduled(); + + /// @notice Error for when a transaction is already cancelled + error TimelockGuard_TransactionAlreadyCancelled(); + + /// @notice Error for when a transaction is not scheduled + error TimelockGuard_TransactionNotScheduled(); + + /// @notice Error for when a transaction is not ready to execute (timelock delay not passed) + error TimelockGuard_TransactionNotReady(); + + /// @notice Error for when a transaction has already been executed + error TimelockGuard_TransactionAlreadyExecuted(); + + /// @notice Emitted when a Safe configures the guard + event GuardConfigured(Safe indexed safe, uint256 timelockDelay, uint256 safetyDelay); + + /// @notice Emitted when a transaction is scheduled for a Safe. + /// @param safe The Safe whose transaction is scheduled. + /// @param txId The identifier of the scheduled transaction (nonce-independent). + /// @param when The timestamp when execution becomes valid. + event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); + + /// @notice Emitted when a transaction is cancelled for a Safe. + /// @param safe The Safe whose transaction is cancelled. + /// @param txId The identifier of the cancelled transaction (nonce-independent). + event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); + + /// @notice Emitted when the cancellation threshold is updated + event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); + + /// @notice Emitted when a transaction is executed for a Safe. + /// @param safe The Safe whose transaction is executed. + /// @param nonce The nonce of the Safe for the transaction being executed. + /// @param txHash The identifier of the executed transaction (nonce-independent). + event TransactionExecuted(Safe indexed safe, uint256 indexed nonce, bytes32 txHash); + + /// @notice Emitted when the safety delay is toggled for a Safe + /// @param safe The Safe whose safety delay is toggled. + /// @param enabled Whether the safety delay is enabled. + event SafetyDelayToggled(Safe indexed safe, bool enabled); + + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice Returns the timelock delay for a given Safe + /// @param _safe The Safe address to query + /// @return The timelock delay in seconds + function timelockConfigurationForSafe(Safe _safe) public view returns (GuardConfig memory) { + return _timelockSafeConfiguration[_safe]; + } + + /// @notice Returns the scheduled transaction for a given Safe and tx hash + /// @dev This function is necessary to properly expose the scheduledTransactions mapping, as + /// simply making the mapping public will return a tuple instead of a struct. + function scheduledTransactionForSafe( + Safe _safe, + bytes32 _txHash + ) + public + view + returns (ScheduledTransaction memory) + { + return _scheduledTransactions[_safe][_txHash]; + } + + /// @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 pendingTransactionsForSafe(Safe _safe) external view returns (ScheduledTransaction[] memory) { + bytes32[] memory hashes = _safePendingTxHashes[_safe].values(); + ScheduledTransaction[] memory scheduled = new ScheduledTransaction[](hashes.length); + for (uint256 i = 0; i < hashes.length; i++) { + scheduled[i] = _scheduledTransactions[_safe][hashes[i]]; + } + return scheduled; + } + + /// @notice Configure the contract as a timelock guard by setting the timelock delay + /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) + function configureTimelockGuard(uint256 _timelockDelay, uint256 _safetyDelay) external { + Safe callingSafe = Safe(payable(msg.sender)); + + // Check that this guard is enabled on the calling Safe + if (!_isGuardEnabled(callingSafe)) { + revert TimelockGuard_GuardNotEnabled(); + } + + // Check that the timelock delay is not longer than 1 year + if (_timelockDelay > 365 days) { + revert TimelockGuard_InvalidTimelockDelay(); + } + + // Check that the safety delay is not longer than 1 year + if (_safetyDelay > 365 days) { + revert TimelockGuard_InvalidSafetyDelay(); + } + + GuardConfig storage config = _timelockSafeConfiguration[callingSafe]; + config.timelockDelay = _timelockDelay; + config.safetyDelay = _safetyDelay; + + _safeCancellationThreshold[callingSafe] = 1; + + emit GuardConfigured(callingSafe, _timelockDelay, _safetyDelay); + } + + /// @notice Returns the blocking threshold threshold for a given safe + /// @return The current blocking threshold + function _blockingThreshold(Safe _safe) internal view returns (uint256) { + // The blocking threshold is the number of owners who can coordinate to block a transaction + // from being executed by refusing to sign. + return _safe.getOwners().length - _safe.getThreshold() + 1; + } + + /// @notice Returns the cancellation threshold for a given safe + /// @param _safe The Safe address to query + /// @return The current cancellation threshold + function cancellationThresholdForSafe(Safe _safe) public view returns (uint256) { + // Return 0 if guard is not enabled + if (!_isGuardEnabled(_safe)) { + return 0; + } + + return _safeCancellationThreshold[_safe]; + } + + /// @notice Returns the maximum cancellation threshold for a given safe + /// @return The maximum cancellation threshold + function maxCancellationThreshold(Safe _safe) public view returns (uint256) { + uint256 blockingThreshold = _blockingThreshold(_safe); + uint256 quorum = _safe.getThreshold(); + // Return the minimum of the blocking threshold and the quorum + return (blockingThreshold < quorum ? blockingThreshold : quorum) - 1; + } + + /// @notice Internal helper to get the guard address from a Safe + /// @param _safe The Safe address + /// @return The current guard address + function _isGuardEnabled(Safe _safe) internal view returns (bool) { + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(_safe.getStorageAt(uint256(guardSlot), 1), (address)); + return guard == address(this); + } + + /// @notice Schedule a transaction for execution after the timelock delay. + function scheduleTransaction( + Safe _safe, + uint256 _nonce, + ExecTransactionParams memory _params, + bytes memory _signatures + ) + external + { + // Check that this guard is enabled on the calling Safe + if (!_isGuardEnabled(_safe)) { + revert TimelockGuard_GuardNotEnabled(); + } + + // Check that the guard has been configured for the Safe + if (_timelockSafeConfiguration[_safe].timelockDelay == 0) { + revert TimelockGuard_GuardNotConfigured(); + } + + // 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. + if (_scheduledTransactions[_safe][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); + + // Calculate the execution time + uint256 executionTime = block.timestamp + _timelockSafeConfiguration[_safe].timelockDelay; + + // Schedule the transaction + _scheduledTransactions[_safe][txHash] = + ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); + _safePendingTxHashes[_safe].add(txHash); + + emit TransactionScheduled(_safe, txHash, executionTime); + } + + /// @notice Cancel a scheduled transaction if cancellation threshold is met + /// @dev This function aims to mimic the approach which would be used by a quorum of signers to + /// cancel a partially signed transaction, by signing and executing an empty + /// transaction at the same nonce. + /// This enables us to define a standard "cancellation transaction" format using the Safe address, nonce, + /// and hash of the transaction being cancelled. This is necessary to ensure that the cancellation transaction + /// is unique and cannot be used to cancel another transaction at the same nonce. + /// + /// Signature verificiation uses the Safe's checkNSignatures function, so that the number of signatures + /// required + /// can be set by the Safe's current cancellation threshold. Another benefit of checkNSignatures is that owners + /// can use any method to sign the cancellation transaction inputs, including signing with a private key, + /// calling the Safe's approveHash function, or EIP1271 contract signatures. + function cancelTransaction(Safe _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external { + if (_scheduledTransactions[_safe][_txHash].cancelled) { + revert TimelockGuard_TransactionAlreadyCancelled(); + } + if (_scheduledTransactions[_safe][_txHash].executed) { + revert TimelockGuard_TransactionAlreadyExecuted(); + } + if (_scheduledTransactions[_safe][_txHash].executionTime == 0) { + revert TimelockGuard_TransactionNotScheduled(); + } + + // Generate the cancellation transaction data + bytes memory data = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); + bytes memory cancellationTxData = _safe.encodeTransactionData( + address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce + ); + bytes32 cancellationTxHash = _safe.getTransactionHash( + address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce + ); + + // Verify signatures using the Safe's signature checking logic + // This function call reverts if the signatures are invalid. + _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, _safeCancellationThreshold[_safe]); + + _scheduledTransactions[_safe][_txHash].cancelled = true; + _safePendingTxHashes[_safe].remove(_txHash); + _increaseCancellationThreshold(_safe); + + emit TransactionCancelled(_safe, _txHash); + } + + /// @notice Toggle the safety delay for a Safe + /// @dev This function is used to add or remove a safety delay to all transactions executed by a Safe. Similar to + /// cancelTransaction, it uses a custom format for the transaction data and uses the Safe's + /// signature checking logic to verify the signatures. The current nonce of the Safe is used to + /// ensure that the safety delay transaction is unique and cannot be replayed in the future. + /// @param _safe The Safe address + /// @param _signatures The signatures of the owners + function toggleSafetyDelay(Safe _safe, bool _enableSafetyDelay, bytes memory _signatures) external { + // Get the current Safe nonce + uint256 nonce = _safe.nonce(); + + // Generate the safety delay transaction data + bytes memory data = abi.encodeWithSignature("toggleSafetyDelay(bool)", _enableSafetyDelay); + bytes memory safetyDelayTxData = _safe.encodeTransactionData( + address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), nonce + ); + bytes32 safetyDelayTxHash = _safe.getTransactionHash( + address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), nonce + ); + + // Verify signatures using the Safe's signature checking logic + // This function call reverts if the signatures are invalid. + _safe.checkSignatures(safetyDelayTxHash, safetyDelayTxData, _signatures); + + _timelockSafeConfiguration[_safe].safetyDelayEnabled = _enableSafetyDelay; + + emit SafetyDelayToggled(_safe, _enableSafetyDelay); + } + + /// @notice Increase the cancellation threshold for a safe + /// @dev This function must be called only once and only when calling cancel + function _increaseCancellationThreshold(Safe _safe) internal { + if (_safeCancellationThreshold[_safe] < maxCancellationThreshold(_safe)) { + uint256 oldThreshold = _safeCancellationThreshold[_safe]; + _safeCancellationThreshold[_safe]++; + emit CancellationThresholdUpdated(_safe, oldThreshold, _safeCancellationThreshold[_safe]); + } + } + + /// @notice Reset the cancellation threshold for a safe + /// @dev This function must be called only once and only when calling checkAfterExecution + function _resetCancellationThreshold(Safe _safe) internal { + uint256 oldThreshold = _safeCancellationThreshold[_safe]; + _safeCancellationThreshold[_safe] = 1; + emit CancellationThresholdUpdated(_safe, oldThreshold, 1); + } + + /// @notice Called by the Safe before executing a transaction + /// @dev Implementation of IGuard interface + 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, + address + ) + external + override + { + Safe callingSafe = Safe(payable(msg.sender)); + + if (_timelockSafeConfiguration[callingSafe].timelockDelay == 0) { + // We return immediately. This is important in order to allow a Safe which has the + // guard set, but not configured to complete the setup process. + // It is also just a reasonable thing to do, since an unconfigured Safe must have a + // delay of zero. + return; + } + + // Get the nonce of the Safe for the transaction being executed, + // since the Safe's nonce is incremented before the transaction is executed, + // we must subtract 1. + uint256 nonce = callingSafe.nonce() - 1; + + // Get the transaction hash from the Safe's getTransactionHash function + bytes32 txHash = callingSafe.getTransactionHash( + _to, _value, _data, _operation, _safeTxGas, _baseGas, _gasPrice, _gasToken, _refundReceiver, nonce + ); + + // Get the scheduled transaction + ScheduledTransaction storage scheduledTx = _scheduledTransactions[callingSafe][txHash]; + + // Check if the transaction was cancelled + if (scheduledTx.cancelled) { + revert TimelockGuard_TransactionAlreadyCancelled(); + } + + // Check if the transaction has been scheduled + if (scheduledTx.executionTime == 0) { + revert TimelockGuard_TransactionNotScheduled(); + } + + // Calculate the execution time + uint256 executionTime = scheduledTx.executionTime; + if (_timelockSafeConfiguration[callingSafe].safetyDelayEnabled) { + // Add the safety delay to the execution time + executionTime += _timelockSafeConfiguration[callingSafe].safetyDelay; + } + + // Check if the timelock delay has passed + if (executionTime > block.timestamp) { + revert TimelockGuard_TransactionNotReady(); + } + + // Check if the transaction has already been executed + // Note: this is of course enforced by the Safe itself, but we check it here for + // completeness + if (scheduledTx.executed) { + revert TimelockGuard_TransactionAlreadyExecuted(); + } + + // Set the transaction as executed + scheduledTx.executed = true; + _safePendingTxHashes[callingSafe].remove(txHash); + + // Reset the cancellation threshold + _resetCancellationThreshold(callingSafe); + + emit TransactionExecuted(callingSafe, nonce, txHash); + } + + /// @notice Called by the Safe after executing a transaction + /// @dev Implementation of IGuard interface + function checkAfterExecution(bytes32, bool) external override { + // Do nothing + // In order to follow the Checks-Effects-Interactions pattern, + // all logic should be done in the checkTransaction function. + } +} diff --git a/packages/contracts-bedrock/src/safe/Types.sol b/packages/contracts-bedrock/src/safe/Types.sol new file mode 100644 index 00000000000..1d999d04a4d --- /dev/null +++ b/packages/contracts-bedrock/src/safe/Types.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { Enum } from "safe-contracts/common/Enum.sol"; + +/// @notice Parameters for the Safe's execTransaction function +struct ExecTransactionParams { + address to; + uint256 value; + bytes data; + Enum.Operation operation; + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address payable refundReceiver; +} diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol new file mode 100644 index 00000000000..294c51be6f4 --- /dev/null +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -0,0 +1,958 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "forge-std/Test.sol"; +import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { ExecTransactionParams } from "src/safe/Types.sol"; +import "test/safe-tools/SafeTestTools.sol"; + +import { TimelockGuard } from "src/safe/TimelockGuard.sol"; + +using TransactionBuilder for TransactionBuilder.Transaction; + +library TransactionBuilder { + // A struct type used to construct a transaction for scheduling and execution + struct Transaction { + SafeInstance safeInstance; + ExecTransactionParams params; + uint256 nonce; + bytes32 hash; + bytes signatures; + } + + address internal constant VM_ADDR = 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D; + + /// @notice Sets a nonce value on the provided transaction struct. + function setNonce(Transaction memory _tx, uint256 _nonce) internal pure { + _tx.nonce = _nonce; + } + + /// @notice Computes and stores the Safe transaction hash for the struct. + function setHash(Transaction memory _tx) internal view { + _tx.hash = _tx.safeInstance.safe.getTransactionHash({ + to: _tx.params.to, + value: _tx.params.value, + data: _tx.params.data, + operation: _tx.params.operation, + safeTxGas: _tx.params.safeTxGas, + baseGas: _tx.params.baseGas, + gasPrice: _tx.params.gasPrice, + gasToken: _tx.params.gasToken, + refundReceiver: _tx.params.refundReceiver, + _nonce: _tx.nonce + }); + } + + /// @notice Collects signatures from the first `_num` owners for the transaction. + function setSignatures(Transaction memory _tx, uint256 _num) internal pure { + bytes memory signatures = new bytes(0); + for (uint256 i; i < _num; ++i) { + (uint8 v, bytes32 r, bytes32 s) = Vm(VM_ADDR).sign(_tx.safeInstance.ownerPKs[i], _tx.hash); + + // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} + signatures = bytes.concat(signatures, abi.encodePacked(r, s, v)); + } + _tx.signatures = signatures; + } + + /// @notice Collects enough signatures to meet the Safe threshold. + function setSignatures(Transaction memory _tx) internal view { + uint256 num = _tx.safeInstance.safe.getThreshold(); + setSignatures(_tx, num); + } + + /// @notice Updates the hash and signatures for a specific approval count. + function updateTransaction(Transaction memory _tx, uint256 _num) internal view { + _tx.setHash(); + _tx.setSignatures(_num); + } + + /// @notice Updates the hash and threshold-based signatures on the transaction. + function updateTransaction(Transaction memory _tx) internal view { + _tx.setHash(); + _tx.setSignatures(); + } + + /// @notice Schedules the transaction with the supplied TimelockGuard instance. + function scheduleTransaction(Transaction memory _tx, TimelockGuard _timelockGuard) internal { + _timelockGuard.scheduleTransaction(_tx.safeInstance.safe, _tx.nonce, _tx.params, _tx.signatures); + } + + /// @notice Executes the transaction via the underlying Safe contract. + function executeTransaction(Transaction memory _tx) internal { + _tx.safeInstance.safe.execTransaction( + _tx.params.to, + _tx.params.value, + _tx.params.data, + _tx.params.operation, + _tx.params.safeTxGas, + _tx.params.baseGas, + _tx.params.gasPrice, + _tx.params.gasToken, + _tx.params.refundReceiver, + _tx.signatures + ); + } + + /// @notice Returns a fresh transaction struct copy with identical fields. + function deepCopy(Transaction memory _tx) internal pure returns (Transaction memory) { + return Transaction({ + safeInstance: _tx.safeInstance, + nonce: _tx.nonce, + params: _tx.params, + signatures: _tx.signatures, + hash: _tx.hash + }); + } + + /// @notice Builds the corresponding cancellation transaction for the provided data. + function makeCancellationTransaction( + Transaction memory _tx, + TimelockGuard _timelockGuard + ) + internal + view + returns (Transaction memory) + { + // Deep copy the transaction + Transaction memory cancellation = Transaction({ + safeInstance: _tx.safeInstance, + nonce: _tx.nonce, + params: _tx.params, + signatures: _tx.signatures, + hash: _tx.hash + }); + + // Empty out the params, then set based on the cancellation transaction format + delete cancellation.params; + cancellation.params.to = address(_tx.safeInstance.safe); + cancellation.params.data = abi.encodeWithSignature("cancelTransaction(bytes32)", _tx.hash); + + // Get only the number of signatures required for the cancellation transaction + uint256 cancellationThreshold = _timelockGuard.cancellationThresholdForSafe(_tx.safeInstance.safe); + + cancellation.updateTransaction(cancellationThreshold); + return cancellation; + } +} + +/// @title TimelockGuard_TestInit +/// @notice Reusable test initialization for `TimelockGuard` tests. +contract TimelockGuard_TestInit is Test, SafeTestTools { + // Events + event GuardConfigured(Safe indexed safe, uint256 timelockDelay, uint256 safetyDelay); + event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); + event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); + event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); + event TransactionExecuted(Safe indexed safe, uint256 indexed nonce, bytes32 txHash); + event SafetyDelayToggled(Safe indexed safe, bool enabled); + + uint256 constant INIT_TIME = 10; + uint256 constant TIMELOCK_DELAY = 7 days; + uint256 constant SAFETY_DELAY = 5 days; + uint256 constant NUM_OWNERS = 5; + uint256 constant THRESHOLD = 3; + uint256 constant ONE_YEAR = 365 days; + + TimelockGuard timelockGuard; + + // The Safe address will be the same as SafeInstance.safe, but it has the Safe type. + // This is useful for testing functions that take a Safe as an argument. + Safe safe; + SafeInstance safeInstance; + + SafeInstance unguardedSafe; + + /// @notice Deploys test fixtures and configures default Safe instances. + function setUp() public virtual { + vm.warp(INIT_TIME); + + // Deploy the singleton TimelockGuard + timelockGuard = new TimelockGuard(); + + // Create Safe owners + (, uint256[] memory keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); + + // Set up Safe with owners + safeInstance = _setupSafe(keys, THRESHOLD); + safe = Safe(payable(safeInstance.safe)); + + // Safe without guard enabled + // Reduce the threshold just to prevent a CREATE2 collision when deploying this safe. + unguardedSafe = _setupSafe(keys, THRESHOLD - 1); + + // Enable the guard on the Safe + _enableGuard(safeInstance); + } + + /// @notice Builds an empty transaction wrapper for a Safe instance. + function _createEmptyTransaction(SafeInstance memory _safeInstance) + internal + view + returns (TransactionBuilder.Transaction memory) + { + TransactionBuilder.Transaction memory transaction; + // transaction.params will have null values + transaction.safeInstance = _safeInstance; + transaction.nonce = _safeInstance.safe.nonce(); + transaction.updateTransaction(); + return transaction; + } + + /// @notice Creates a dummy transaction populated with placeholder call data. + function _createDummyTransaction(SafeInstance memory _safeInstance) + internal + view + returns (TransactionBuilder.Transaction memory) + { + TransactionBuilder.Transaction memory transaction = _createEmptyTransaction(_safeInstance); + transaction.params.to = address(0xabba); + transaction.params.data = abi.encodeWithSignature("doSomething()"); + transaction.updateTransaction(); + return transaction; + } + + /// @notice Helper to configure the TimelockGuard for a Safe + function _configureGuard(SafeInstance memory _safe, uint256 _timelockDelay, uint256 _safetyDelay) internal { + SafeTestLib.execTransaction( + _safe, + address(timelockGuard), + 0, + abi.encodeCall(TimelockGuard.configureTimelockGuard, (_timelockDelay, _safetyDelay)) + ); + } + + /// @notice Helper to enable guard on a Safe + function _enableGuard(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))) + ); + } +} + +/// @title TimelockGuard_ViewTimelockGuardConfiguration_Test +/// @notice Tests for viewTimelockGuardConfiguration function +contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { + /// @notice Ensures an unconfigured Safe reports a zero timelock delay. + function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + assertEq(config.timelockDelay, 0); + assertEq(config.safetyDelay, 0); + // configured is now determined by timelockDelay == 0 + assertEq(config.timelockDelay == 0, true); + } + + /// @notice Validates the configuration view reflects the stored timelock delay. + function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + assertEq(config.timelockDelay, TIMELOCK_DELAY); + assertEq(config.safetyDelay, SAFETY_DELAY); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); + } +} + +/// @title TimelockGuard_ConfigureTimelockGuard_Test +/// @notice Tests for configureTimelockGuard function +contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { + /// @notice Verifies the guard can be configured with a standard delay. + function test_configureTimelockGuard_succeeds() external { + vm.expectEmit(true, true, true, true); + emit GuardConfigured(safe, TIMELOCK_DELAY, SAFETY_DELAY); + + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(config.timelockDelay, TIMELOCK_DELAY); + assertEq(config.safetyDelay, SAFETY_DELAY); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); + } + + /// @notice Checks configuration reverts when the guard is not enabled. + function test_configureTimelockGuard_revertsIfGuardNotEnabled_reverts() external { + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); + vm.prank(address(unguardedSafe.safe)); + timelockGuard.configureTimelockGuard(TIMELOCK_DELAY, SAFETY_DELAY); + } + + /// @notice Confirms delays above the maximum revert during configuration. + function test_configureTimelockGuard_revertsIfDelayTooLong_reverts() external { + uint256 tooLongDelay = ONE_YEAR + 1; + + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(tooLongDelay, SAFETY_DELAY); + } + + function test_configureTimelockGuard_revertsIfSafetyDelayTooLong_reverts() external { + uint256 tooLongDelay = ONE_YEAR + 1; + + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidSafetyDelay.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(TIMELOCK_DELAY, tooLongDelay); + } + + /// @notice Asserts the maximum valid delay configures successfully. + function test_configureTimelockGuard_acceptsMaxValidDelay_succeeds() external { + vm.expectEmit(true, true, true, true); + emit GuardConfigured(safe, ONE_YEAR, SAFETY_DELAY); + + _configureGuard(safeInstance, ONE_YEAR, SAFETY_DELAY); + + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(config.timelockDelay, ONE_YEAR); + assertEq(config.safetyDelay, SAFETY_DELAY); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); + } + + /// @notice Demonstrates the guard can be reconfigured to a new delay. + function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { + // Initial configuration + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + TimelockGuard.GuardConfig memory initialConfig = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(initialConfig.timelockDelay, TIMELOCK_DELAY); + assertEq(initialConfig.safetyDelay, SAFETY_DELAY); + + uint256 newDelay = TIMELOCK_DELAY + 1; + + // Setup and schedule the reconfiguration transaction + TransactionBuilder.Transaction memory reconfigureGuardTx = _createEmptyTransaction(safeInstance); + reconfigureGuardTx.params.to = address(timelockGuard); + reconfigureGuardTx.params.data = abi.encodeCall(TimelockGuard.configureTimelockGuard, (newDelay, SAFETY_DELAY)); + reconfigureGuardTx.updateTransaction(); + reconfigureGuardTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + + // Reconfigure with different delay + vm.expectEmit(true, true, true, true); + emit GuardConfigured(safe, newDelay, SAFETY_DELAY); + + _configureGuard(safeInstance, newDelay, SAFETY_DELAY); + TimelockGuard.GuardConfig memory updatedConfig = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(updatedConfig.timelockDelay, newDelay); + assertEq(updatedConfig.safetyDelay, SAFETY_DELAY); + } + + /// @notice Ensures setting delay to zero clears the configuration. + function test_configureTimelockGuard_clearConfiguration_succeeds() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + TimelockGuard.GuardConfig memory configured = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(configured.timelockDelay, TIMELOCK_DELAY); + assertEq(configured.safetyDelay, SAFETY_DELAY); + + // Configure timelock delay to 0 should succeed and emit event + vm.expectEmit(true, true, true, true); + emit GuardConfigured(safe, 0, 0); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0, 0); + + // Timelock & safety delays should be reset to 0 + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(config.timelockDelay, 0); + assertEq(config.safetyDelay, 0); + // Cancellation threshold should reset to 1 per spec + assertEq(timelockGuard.cancellationThresholdForSafe(safe), 1); + } + + /// @notice Checks clearing succeeds even if the guard was never configured. + function test_configureTimelockGuard_notConfigured_succeeds() external { + // Try to clear - should succeed even if not yet configured + vm.expectEmit(true, true, true, true); + emit GuardConfigured(safe, 0, 0); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0, 0); + } +} + +/// @title TimelockGuard_CancellationThresholdForSafe_Test +/// @notice Tests for cancellationThresholdForSafe function +contract TimelockGuard_CancellationThresholdForSafe_Test is TimelockGuard_TestInit { + /// @notice Validates cancellation threshold is zero when the guard is disabled. + function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external view { + uint256 threshold = timelockGuard.cancellationThresholdForSafe(Safe(payable(unguardedSafe.safe))); + assertEq(threshold, 0); + } + + /// @notice Ensures an enabled but unconfigured guard yields a zero threshold. + function test_cancellationThreshold_returnsZeroIfGuardNotConfigured_succeeds() external view { + // Safe with guard enabled but not configured should return 0 + uint256 threshold = timelockGuard.cancellationThresholdForSafe(safe); + assertEq(threshold, 0); + } + + /// @notice Confirms the default threshold becomes one after configuration. + function test_cancellationThreshold_returnsOneAfterConfiguration_succeeds() external { + // Configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + + // Should default to 1 after configuration + uint256 threshold = timelockGuard.cancellationThresholdForSafe(safe); + assertEq(threshold, 1); + } + + // Note: Testing increment/decrement behavior will require scheduleTransaction, + // cancelTransaction and execution functions to be implemented first +} + +/// @title TimelockGuard_ScheduleTransaction_Test +/// @notice Tests for scheduleTransaction function +contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { + /// @notice Configures the guard before each scheduleTransaction test. + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + /// @notice Ensures scheduling emits the expected event and stores state. + function test_scheduleTransaction_succeeds() public { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + vm.expectEmit(true, true, true, true); + emit TransactionScheduled(safe, dummyTx.hash, INIT_TIME + TIMELOCK_DELAY); + dummyTx.scheduleTransaction(timelockGuard); + } + + // A test which demonstrates that if the guard is enabled but not explicitly configured, + // the timelock delay is set to 0. + /// @notice Checks scheduling reverts if the guard lacks configuration. + function test_scheduleTransaction_guardNotConfigured_reverts() external { + // Enable the guard on the unguarded Safe, but don't configure it + _enableGuard(unguardedSafe); + assertEq(timelockGuard.timelockConfigurationForSafe(unguardedSafe.safe).timelockDelay, 0); + + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); + dummyTx.scheduleTransaction(timelockGuard); + } + + /// @notice Verifies rescheduling an identical pending transaction reverts. + function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + timelockGuard.scheduleTransaction(safeInstance.safe, dummyTx.nonce, dummyTx.params, dummyTx.signatures); + + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); + timelockGuard.scheduleTransaction(dummyTx.safeInstance.safe, dummyTx.nonce, dummyTx.params, dummyTx.signatures); + } + + /// @notice Confirms scheduling fails when the guard has not been enabled. + function test_scheduleTransaction_guardNotEnabled_reverts() external { + // Attempt to schedule a transaction with a Safe that has enabled the guard but + // has not configured it. + _enableGuard(unguardedSafe); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); + + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); + dummyTx.scheduleTransaction(timelockGuard); + } + + /// @notice Demonstrates identical payloads can be scheduled with distinct nonces. + function test_scheduleTransaction_canScheduleIdenticalWithDifferentNonce_succeeds() external { + // Schedule a transaction with a specific nonce + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Schedule an identical transaction with a different nonce (salt) + TransactionBuilder.Transaction memory newTx = dummyTx.deepCopy(); + newTx.nonce = dummyTx.nonce + 1; + newTx.updateTransaction(); + + vm.expectEmit(true, true, true, true); + emit TransactionScheduled(safe, newTx.hash, INIT_TIME + TIMELOCK_DELAY); + timelockGuard.scheduleTransaction(safeInstance.safe, newTx.nonce, newTx.params, newTx.signatures); + } +} + +/// @title TimelockGuard_ScheduledTransactionForSafe_Test +/// @notice Tests for scheduledTransactionForSafe function +contract TimelockGuard_ScheduledTransactionForSafe_Test is TimelockGuard_TestInit { + /// @notice Configures the guard before each scheduleTransaction test. + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + function test_scheduledTransactionForSafe_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.scheduledTransactionForSafe(safe, dummyTx.hash); + assertEq(scheduledTransaction.executionTime, INIT_TIME + TIMELOCK_DELAY); + assertEq(scheduledTransaction.cancelled, false); + assertEq(scheduledTransaction.executed, false); + assertEq(keccak256(abi.encode(scheduledTransaction.params)), keccak256(abi.encode(dummyTx.params))); + } +} + +/// @title TimelockGuard_PendingTransactionsForSafe_Test +/// @notice Tests for pendingTransactionsForSafe function +contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + function test_pendingTransactionsForSafe_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + assertEq(pendingTransactions.length, 1); + // ensure the hash of the transaction params are the same + assertEq(pendingTransactions[0].params.to, dummyTx.params.to); + assertEq(keccak256(abi.encode(pendingTransactions[0].params)), keccak256(abi.encode(dummyTx.params))); + } + + function test_pendingTransactionsForSafe_removeTransactionAfterCancellation_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // cancel the transaction + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + + // get the pending transactions + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + assertEq(pendingTransactions.length, 0); + } + + function test_pendingTransactionsForSafe_removeTransactionAfterExecution_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + + // execute the transaction + dummyTx.executeTransaction(); + + // get the pending transactions + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + assertEq(pendingTransactions.length, 0); + } +} + +/// @title TimelockGuard_CancelTransaction_Test +/// @notice Tests for cancelTransaction function +contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { + /// @notice Prepares a configured guard before cancellation tests run. + function setUp() public override { + super.setUp(); + + // Configure the guard and schedule a transaction + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + /// @notice Ensures cancellations succeed using owner signatures. + function test_cancelTransaction_withPrivKeySignature_succeeds() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Get the cancellation transaction + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); + + // Cancel the transaction + vm.expectEmit(true, true, true, true); + emit CancellationThresholdUpdated(safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(safeInstance.safe, dummyTx.hash); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + + assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).cancelled, true); + } + + /// @notice Confirms pre-approved hashes can authorise cancellations. + function test_cancelTransaction_withApproveHash_succeeds() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Get the cancellation transaction hash + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + + // Get the owner + address owner = dummyTx.safeInstance.safe.getOwners()[0]; + + // Approve the cancellation transaction hash + vm.prank(owner); + safeInstance.safe.approveHash(cancellationTx.hash); + + // Encode the prevalidated cancellation signature + bytes memory cancellationSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); + + // Get the cancellation threshold + uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); + + // Cancel the transaction + vm.expectEmit(true, true, true, true); + emit CancellationThresholdUpdated(dummyTx.safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(dummyTx.safeInstance.safe, dummyTx.hash); + timelockGuard.cancelTransaction(dummyTx.safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationSignatures); + + // Confirm that the transaction is cancelled + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.scheduledTransactionForSafe(dummyTx.safeInstance.safe, dummyTx.hash); + assertEq(scheduledTransaction.cancelled, true); + } + + /// @notice Verifies cancelling an unscheduled transaction reverts. + function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + + // Attempt to cancel the transaction + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + } +} + +/// @title TimelockGuard_CheckTransaction_Test +/// @notice Tests for checkTransaction function +contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { + using stdStorage for StdStorage; + + /// @notice Establishes the configured guard before checkTransaction tests. + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + /// @notice Test that scheduled transactions can execute after the delay period + function test_checkTransaction_scheduledTransactionAfterDelay_succeeds() external { + // Schedule a transaction + uint256 nonce = safeInstance.safe.nonce(); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Fast forward past the timelock delay + vm.warp(block.timestamp + TIMELOCK_DELAY); + // Increment the nonce, as would normally happen when the transaction is executed + vm.store(address(safeInstance.safe), bytes32(uint256(5)), bytes32(uint256(nonce + 1))); + + // increment the cancellation threshold so that we can test that it is reset + uint256 slot = stdstore.target(address(timelockGuard)).sig("cancellationThresholdForSafe(address)").with_key( + address(safeInstance.safe) + ).find(); + vm.store( + address(timelockGuard), + bytes32(slot), + bytes32(uint256(timelockGuard.cancellationThresholdForSafe(safeInstance.safe) + 1)) + ); + + vm.prank(address(safeInstance.safe)); + vm.expectEmit(true, true, true, true); + emit TransactionExecuted(safeInstance.safe, nonce, dummyTx.hash); + timelockGuard.checkTransaction( + dummyTx.params.to, + dummyTx.params.value, + dummyTx.params.data, + dummyTx.params.operation, + dummyTx.params.safeTxGas, + dummyTx.params.baseGas, + dummyTx.params.gasPrice, + dummyTx.params.gasToken, + dummyTx.params.refundReceiver, + "", + address(0) + ); + + // Confirm that the transaction is executed + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash); + assertEq(scheduledTransaction.executed, true); + + // Confirm that the cancellation threshold is reset + assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), 1); + } + + /// @notice Test that checkTransaction reverts when scheduled transaction delay hasn't passed + function test_checkTransaction_scheduledTransactionNotReady_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + // Schedule the transaction but do not advance time past the timelock delay + dummyTx.scheduleTransaction(timelockGuard); + + // Increment the nonce, as would normally happen when the transaction is executed + vm.store(address(safeInstance.safe), bytes32(uint256(5)), bytes32(uint256(safeInstance.safe.nonce() + 1))); + + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotReady.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.checkTransaction( + dummyTx.params.to, + dummyTx.params.value, + dummyTx.params.data, + dummyTx.params.operation, + dummyTx.params.safeTxGas, + dummyTx.params.baseGas, + dummyTx.params.gasPrice, + dummyTx.params.gasToken, + dummyTx.params.refundReceiver, + "", + address(0) + ); + } + + /// @notice Test that checkTransaction reverts when scheduled transaction was cancelled + function test_checkTransaction_scheduledTransactionCancelled_reverts() external { + // Schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Cancel the transaction + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + + // Fast forward past the timelock delay + vm.warp(block.timestamp + TIMELOCK_DELAY); + // Increment the nonce, as would normally happen when the transaction is executed + vm.store(address(safeInstance.safe), bytes32(uint256(5)), bytes32(uint256(safeInstance.safe.nonce() + 1))); + + // Should revert because transaction was cancelled + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyCancelled.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.checkTransaction( + dummyTx.params.to, + dummyTx.params.value, + dummyTx.params.data, + dummyTx.params.operation, + dummyTx.params.safeTxGas, + dummyTx.params.baseGas, + dummyTx.params.gasPrice, + dummyTx.params.gasToken, + dummyTx.params.refundReceiver, + "", + address(0) + ); + } + + /// @notice Test that checkTransaction reverts when a transaction has not been scheduled + function test_checkTransaction_transactionNotScheduled_reverts() external { + // Get transaction parameters but don't schedule the transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + // Should revert because transaction was not scheduled + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.checkTransaction( + dummyTx.params.to, + dummyTx.params.value, + dummyTx.params.data, + dummyTx.params.operation, + dummyTx.params.safeTxGas, + dummyTx.params.baseGas, + dummyTx.params.gasPrice, + dummyTx.params.gasToken, + dummyTx.params.refundReceiver, + "", + address(0) + ); + } +} + +/// @title TimelockGuard_ToggleSafetyDelay_Test +/// @notice Tests for toggleSafetyDelay function +contract TimelockGuard_ToggleSafetyDelay_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + function test_toggleSafetyDelayTrue_succeeds() public { + TransactionBuilder.Transaction memory toggleOnTx = _createEmptyTransaction(safeInstance); + toggleOnTx.params.data = abi.encodeWithSignature("toggleSafetyDelay(bool)", true); + toggleOnTx.params.to = address(safeInstance.safe); + toggleOnTx.nonce = safeInstance.safe.nonce(); + toggleOnTx.updateTransaction(); + + vm.expectEmit(true, true, true, true); + emit SafetyDelayToggled(safeInstance.safe, true); + timelockGuard.toggleSafetyDelay(safeInstance.safe, true, toggleOnTx.signatures); + + assertEq(timelockGuard.timelockConfigurationForSafe(safeInstance.safe).safetyDelayEnabled, true); + } + + function test_toggleSafetyDelayFalse_succeeds() external { + // first toggle safety delay to true + test_toggleSafetyDelayTrue_succeeds(); + + TransactionBuilder.Transaction memory toggleOffTx = _createEmptyTransaction(safeInstance); + toggleOffTx.params.data = abi.encodeWithSignature("toggleSafetyDelay(bool)", false); + toggleOffTx.params.to = address(safeInstance.safe); + toggleOffTx.nonce = safeInstance.safe.nonce(); + toggleOffTx.updateTransaction(); + + vm.expectEmit(true, true, true, true); + emit SafetyDelayToggled(safeInstance.safe, false); + timelockGuard.toggleSafetyDelay(safeInstance.safe, false, toggleOffTx.signatures); + + assertEq(timelockGuard.timelockConfigurationForSafe(safeInstance.safe).safetyDelayEnabled, false); + } +} + +/// @title TimelockGuard_Integration_test +/// @notice Tests for integration between TimelockGuard and Safe +contract TimelockGuard_Integration_test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY, SAFETY_DELAY); + } + + /// @notice Test that scheduling a transaction and then executing it succeeds + function test_integration_scheduleThenExecute_succeeds() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + dummyTx.executeTransaction(); + + assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).executed, true); + } + + /// @notice Test that scheduling a transaction and then executing it twice reverts + function test_integration_scheduleThenExecuteTwice_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + dummyTx.executeTransaction(); + + vm.expectRevert("GS026"); + dummyTx.executeTransaction(); + } + + function test_integration_scheduleThenExecuteThenCancel_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + dummyTx.executeTransaction(); + + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyExecuted.selector); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + } + + /// @notice Test that rescheduling an identical previously cancelled transaction reverts + function test_integration_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); + dummyTx.scheduleTransaction(timelockGuard); + } + + /// @notice Test that the guard can be disabled while still configured, and then can be + /// deconfigured + function test_integration_disableThenResetGuard_succeeds() external { + TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); + disableGuardTx.params.to = address(disableGuardTx.safeInstance.safe); + disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); + disableGuardTx.updateTransaction(); + disableGuardTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + disableGuardTx.executeTransaction(); + + // TODO: this test fails because the guard config cannot be modified while the guard is + // disabled. IMO a guard should be able to manage its own configuration while it is disabled. + vm.skip(true); + + TransactionBuilder.Transaction memory resetGuardConfigTx = _createEmptyTransaction(safeInstance); + resetGuardConfigTx.params.to = address(timelockGuard); + resetGuardConfigTx.params.data = abi.encodeCall(TimelockGuard.configureTimelockGuard, (0, 0)); + resetGuardConfigTx.updateTransaction(); + resetGuardConfigTx.scheduleTransaction(timelockGuard); + + // vm.warp(block.timestamp + TIMELOCK_DELAY); + resetGuardConfigTx.executeTransaction(); + } + + /// @notice Test that the guard can be reset while still enabled, and then can be disabled + function test_integration_resetThenDisableGuard_succeeds() external { + TransactionBuilder.Transaction memory resetGuardTx = _createEmptyTransaction(safeInstance); + resetGuardTx.params.to = address(timelockGuard); + resetGuardTx.params.data = abi.encodeCall(TimelockGuard.configureTimelockGuard, (0, 0)); + resetGuardTx.updateTransaction(); + resetGuardTx.scheduleTransaction(timelockGuard); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + resetGuardTx.executeTransaction(); + + TransactionBuilder.Transaction memory disableGuardTx = _createEmptyTransaction(safeInstance); + disableGuardTx.params.to = address(safeInstance.safe); + disableGuardTx.params.data = abi.encodeCall(GuardManager.setGuard, (address(0))); + disableGuardTx.updateTransaction(); + + vm.warp(block.timestamp + TIMELOCK_DELAY); + disableGuardTx.executeTransaction(); + } + + /// @notice Test that the max cancellation threshold is not exceeded + function test_integration_maxCancellationThresholdNotExceeded_succeeds() external { + uint256 maxThreshold = timelockGuard.maxCancellationThreshold(safeInstance.safe); + + // Schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + + // schedule and cancel the transaction maxThreshold + 1 times + for (uint256 i = 0; i < maxThreshold + 1; i++) { + // modify the calldata slightly to make the txHash different + dummyTx.params.data = bytes.concat(dummyTx.params.data, abi.encodePacked(i)); + dummyTx.updateTransaction(); + dummyTx.scheduleTransaction(timelockGuard); + + // Cancel the transaction + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); + } + + assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), maxThreshold); + } + + /// @notice Test that a transaction scheduled with safety delay enabled but not executed within the safety delay reverts + function test_integration_executeTransactionWithSafetyDelay_works() external { + // Schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Toggle the safety delay on + TransactionBuilder.Transaction memory toggleOnTx = _createEmptyTransaction(safeInstance); + toggleOnTx.params.data = abi.encodeWithSignature("toggleSafetyDelay(bool)", true); + toggleOnTx.params.to = address(safeInstance.safe); + toggleOnTx.nonce = safeInstance.safe.nonce(); + toggleOnTx.updateTransaction(); + timelockGuard.toggleSafetyDelay(safeInstance.safe, true, toggleOnTx.signatures); + + // Fast forward past the timelock delay but not the safety delay + vm.warp(block.timestamp + TIMELOCK_DELAY); + + // Attempt to execute the transaction, it should revert + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotReady.selector); + dummyTx.executeTransaction(); + + // Fast forward past the timelock delay AND the safety delay + vm.warp(block.timestamp + TIMELOCK_DELAY + SAFETY_DELAY); + + // Execute the transaction, it should succeed + vm.expectEmit(true, true, true, true); + emit TransactionExecuted(safeInstance.safe, dummyTx.nonce, dummyTx.hash); + dummyTx.executeTransaction(); + + assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).executed, true); + } +}