From cf4270b2c3b32b08b7ec0baf1d1a5e67575778d3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 8 Sep 2025 16:22:58 -0400 Subject: [PATCH 001/102] feat: implement configureTimelockGuard function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configureTimelockGuard function to allow Safes to set timelock delays - Validate timelock delay between 1 second and 1 year - Check that guard is properly enabled on calling Safe using getStorageAt() - Store configuration per Safe with GuardConfigured event emission - Add comprehensive test suite covering all spec requirements - Implement IGuard interface for Safe compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/safe/TimelockGuard.sol | 102 +++++++++++++ .../test/safe/TimelockGuard.t.sol | 137 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 packages/contracts-bedrock/src/safe/TimelockGuard.sol create mode 100644 packages/contracts-bedrock/test/safe/TimelockGuard.t.sol diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol new file mode 100644 index 0000000000000..8bed57465e1a5 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -0,0 +1,102 @@ +// 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 { GuardManager, Guard as IGuard } from "safe-contracts/base/GuardManager.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 { + /// @notice Configuration for a Safe's timelock guard + struct GuardConfig { + uint256 timelockDelay; + } + + /// @notice Mapping from Safe address to its guard configuration + mapping(address => GuardConfig) public safeConfigs; + + /// @notice Error for when guard is not enabled for the Safe + error TimelockGuard_GuardNotEnabled(); + + /// @notice Error for invalid timelock delay + error TimelockGuard_InvalidTimelockDelay(); + + /// @notice Emitted when a Safe configures the guard + event GuardConfigured(address indexed safe, uint256 timelockDelay); + + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + /// @notice Returns the timelock delay for a given Safe + /// @dev MUST never revert + /// @param _safe The Safe address to query + /// @return The timelock delay in seconds + function viewTimelockGuardConfiguration(address _safe) public view returns (uint256) { + return safeConfigs[_safe].timelockDelay; + } + + /// @notice Configure the contract as a timelock guard by setting the timelock delay + /// @dev MUST allow an arbitrary number of Safe contracts to use the contract as a guard + /// @dev The contract MUST be enabled as a guard for the Safe + /// @dev MUST revert if timelock_delay is longer than 1 year + /// @dev MUST set the caller as a Safe + /// @dev MUST take timelock_delay as a parameter and store it as related to the Safe + /// @dev MUST emit a GuardConfigured event with at least timelock_delay as a parameter + /// @param _timelockDelay The timelock delay in seconds + function configureTimelockGuard(uint256 _timelockDelay) external { + // Validate timelock delay - must be non-zero and not longer than 1 year + if (_timelockDelay == 0 || _timelockDelay > 365 days) { + revert TimelockGuard_InvalidTimelockDelay(); + } + + // Check that this guard is enabled on the calling Safe + Safe safe = Safe(payable(msg.sender)); + // The Safe contract does not expose a getGuard function, so we need to provide the + // the storage slot to the getStorageAt function. + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); + + if (guard != address(this)) { + revert TimelockGuard_GuardNotEnabled(); + } + + // Store the configuration for this safe + safeConfigs[msg.sender].timelockDelay = _timelockDelay; + + emit GuardConfigured(msg.sender, _timelockDelay); + } + + /// @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 signatures, + address msgSender + ) external override { + // Empty implementation for now - will be filled in when implementing checkTransaction + } + + /// @notice Called by the Safe after executing a transaction + /// @dev Implementation of IGuard interface + function checkAfterExecution(bytes32 txHash, bool success) external override { + // Empty implementation for now + } +} 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 0000000000000..8c72b1edfb625 --- /dev/null +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +import { Test } from "forge-std/Test.sol"; +import { Enum } from "safe-contracts/common/Enum.sol"; +import { GuardManager } from "safe-contracts/base/GuardManager.sol"; +import { StorageAccessible } from "safe-contracts/common/StorageAccessible.sol"; +import "test/safe-tools/SafeTestTools.sol"; + +import { TimelockGuard } from "src/safe/TimelockGuard.sol"; + +/// @title TimelockGuard_TestInit +/// @notice Reusable test initialization for `TimelockGuard` tests. +contract TimelockGuard_TestInit is Test, SafeTestTools { + using SafeTestLib for SafeInstance; + + // Events + event GuardConfigured(address indexed safe, uint256 timelockDelay); + + uint256 constant INIT_TIME = 10; + uint256 constant TIMELOCK_DELAY = 7 days; + uint256 constant NUM_OWNERS = 5; + uint256 constant THRESHOLD = 3; + uint256 constant ONE_YEAR = 365 days; + + TimelockGuard timelockGuard; + SafeInstance safeInstance; + SafeInstance safeInstance2; + address[] owners; + uint256[] ownerPKs; + + function setUp() public virtual { + vm.warp(INIT_TIME); + + // Deploy the singleton TimelockGuard + timelockGuard = new TimelockGuard(); + + // Create Safe owners + (address[] memory _owners, uint256[] memory _keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); + owners = _owners; + ownerPKs = _keys; + + // Set up Safe with owners + safeInstance = _setupSafe(ownerPKs, THRESHOLD); + + // Enable the guard on the Safe + SafeTestLib.execTransaction( + safeInstance, + address(safeInstance.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))), + Enum.Operation.Call + ); + } + + /// @notice Helper to configure the TimelockGuard for a Safe + function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { + SafeTestLib.execTransaction( + _safe, + address(timelockGuard), + 0, + abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)), + Enum.Operation.Call + ); + } +} + +/// @title TimelockGuard_ViewTimelockGuardConfiguration_Test +/// @notice Tests for viewTimelockGuardConfiguration function +contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { + function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe() external view { + uint256 delay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + assertEq(delay, 0); + } +} + +/// @title TimelockGuard_ConfigureTimelockGuard_Test +/// @notice Tests for configureTimelockGuard function +contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { + function test_configureTimelockGuard_succeeds() external { + vm.expectEmit(true, true, true, true); + emit GuardConfigured(address(safeInstance.safe), TIMELOCK_DELAY); + + _configureGuard(safeInstance, TIMELOCK_DELAY); + + uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + assertEq(storedDelay, TIMELOCK_DELAY); + } + + function test_configureTimelockGuard_revertsIfGuardNotEnabled() external { + // Create a safe without enabling the guard + // Reduce the threshold just to prevent a CREATE2 collision when deploying this safe. + SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD-1); + + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); + vm.prank(address(unguardedSafe.safe)); + timelockGuard.configureTimelockGuard(TIMELOCK_DELAY); + } + + function test_configureTimelockGuard_revertsIfDelayTooLong() external { + uint256 tooLongDelay = ONE_YEAR + 1; + + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(tooLongDelay); + } + + function test_configureTimelockGuard_revertsIfDelayZero() external { + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0); + } + + function test_configureTimelockGuard_acceptsMaxValidDelay() external { + vm.expectEmit(true, true, true, true); + emit GuardConfigured(address(safeInstance.safe), ONE_YEAR); + + _configureGuard(safeInstance, ONE_YEAR); + + uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + assertEq(storedDelay, ONE_YEAR); + } + + function test_configureTimelockGuard_allowsReconfiguration() external { + // Initial configuration + _configureGuard(safeInstance, TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), TIMELOCK_DELAY); + + // Reconfigure with different delay + uint256 newDelay = 14 days; + vm.expectEmit(true, true, true, true); + emit GuardConfigured(address(safeInstance.safe), newDelay); + + _configureGuard(safeInstance, newDelay); + assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), newDelay); + } +} From 290ec17f204f22c751cc1c3cb6a101643cb13df2 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 8 Sep 2025 16:52:16 -0400 Subject: [PATCH 002/102] feat: implement clearTimelockGuard function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add clearTimelockGuard function to allow Safes to clear timelock configuration - Validate that guard is disabled before allowing configuration clearing - Check that Safe was previously configured before clearing - Delete configuration data and emit GuardCleared event - Add comprehensive test suite covering all spec requirements - Add new errors: TimelockGuard_GuardNotConfigured, TimelockGuard_GuardStillEnabled 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/safe/TimelockGuard.sol | 35 +++++++++++ .../test/safe/TimelockGuard.t.sol | 63 +++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 8bed57465e1a5..e91121b2f88a3 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -26,12 +26,21 @@ contract TimelockGuard is IGuard, ISemver { /// @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 when attempt to clear guard while it is still enabled for the Safe + error TimelockGuard_GuardStillEnabled(); + /// @notice Error for invalid timelock delay error TimelockGuard_InvalidTimelockDelay(); /// @notice Emitted when a Safe configures the guard event GuardConfigured(address indexed safe, uint256 timelockDelay); + /// @notice Emitted when a Safe clears the guard configuration + event GuardCleared(address indexed safe); + /// @notice Semantic version. /// @custom:semver 1.0.0 string public constant version = "1.0.0"; @@ -76,6 +85,32 @@ contract TimelockGuard is IGuard, ISemver { emit GuardConfigured(msg.sender, _timelockDelay); } + /// @notice Remove the timelock guard configuration by a previously enabled Safe + /// @dev The contract MUST NOT be enabled as a guard for the Safe + /// @dev MUST erase the existing timelock_delay data related to the calling Safe + /// @dev MUST emit a GuardCleared event + function clearTimelockGuard() external { + // Check if the calling safe has configuration set + if (safeConfigs[msg.sender].timelockDelay == 0) { + revert TimelockGuard_GuardNotConfigured(); + } + + // Check that this guard is NOT enabled on the calling Safe + Safe safe = Safe(payable(msg.sender)); + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + address guard = abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); + + if (guard == address(this)) { + revert TimelockGuard_GuardStillEnabled(); + } + + // Erase the configuration data for this safe + delete safeConfigs[msg.sender]; + + emit GuardCleared(msg.sender); + } + /// @notice Called by the Safe before executing a transaction /// @dev Implementation of IGuard interface function checkTransaction( diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 8c72b1edfb625..c40bad4ff3a23 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -16,6 +16,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // Events event GuardConfigured(address indexed safe, uint256 timelockDelay); + event GuardCleared(address indexed safe); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -63,6 +64,28 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { Enum.Operation.Call ); } + + /// @notice Helper to disable guard on a Safe + function _disableGuard(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, + address(_safe.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (address(0))), + Enum.Operation.Call + ); + } + + /// @notice Helper to clear the TimelockGuard configuration for a Safe + function _clearGuard(SafeInstance memory _safe) internal { + SafeTestLib.execTransaction( + _safe, + address(timelockGuard), + 0, + abi.encodeCall(TimelockGuard.clearTimelockGuard, ()), + Enum.Operation.Call + ); + } } /// @title TimelockGuard_ViewTimelockGuardConfiguration_Test @@ -135,3 +158,43 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), newDelay); } } + +/// @title TimelockGuard_ClearTimelockGuard_Test +/// @notice Tests for clearTimelockGuard function +contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { + function test_clearTimelockGuard_succeeds() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), TIMELOCK_DELAY); + + // Disable the guard first + _disableGuard(safeInstance); + + // Clear should succeed and emit event + vm.expectEmit(true, true, true, true); + emit GuardCleared(address(safeInstance.safe)); + + _clearGuard(safeInstance); + + // Configuration should be cleared + assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), 0); + // TODO: Check that any active challenge is cancelled + } + + function test_clearTimelockGuard_revertsIfGuardStillEnabled() external { + // First configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + + // Try to clear without disabling guard first - should revert + vm.expectRevert(TimelockGuard.TimelockGuard_GuardStillEnabled.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.clearTimelockGuard(); + } + + function test_clearTimelockGuard_revertsIfNotConfigured() external { + // Try to clear - should revert because not configured + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.clearTimelockGuard(); + } +} From ef1ccbd23fbfdc87efa4998a80cc9c477fbfaef4 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 8 Sep 2025 17:01:53 -0400 Subject: [PATCH 003/102] refactor: extract guard checking logic to internal helper function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal _getGuard() helper to centralize guard address retrieval - Update configureTimelockGuard and clearTimelockGuard to use helper - Reduces code duplication and improves maintainability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/safe/TimelockGuard.sol | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index e91121b2f88a3..f8ced0110b5c9 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -68,14 +68,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that this guard is enabled on the calling Safe - Safe safe = Safe(payable(msg.sender)); - // The Safe contract does not expose a getGuard function, so we need to provide the - // the storage slot to the getStorageAt function. - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); - - if (guard != address(this)) { + if (_getGuard(msg.sender) != address(this)) { revert TimelockGuard_GuardNotEnabled(); } @@ -96,12 +89,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that this guard is NOT enabled on the calling Safe - Safe safe = Safe(payable(msg.sender)); - // keccak256("guard_manager.guard.address") from GuardManager - bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - address guard = abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); - - if (guard == address(this)) { + if (_getGuard(msg.sender) == address(this)) { revert TimelockGuard_GuardStillEnabled(); } @@ -111,6 +99,16 @@ contract TimelockGuard is IGuard, ISemver { emit GuardCleared(msg.sender); } + /// @notice Internal helper to get the guard address from a Safe + /// @param _safe The Safe address + /// @return The current guard address + function _getGuard(address _safe) internal view returns (address) { + // keccak256("guard_manager.guard.address") from GuardManager + bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; + Safe safe = Safe(payable(_safe)); + return abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); + } + /// @notice Called by the Safe before executing a transaction /// @dev Implementation of IGuard interface function checkTransaction( From a95ed01dcd9bfdd70e7422a16be430aa32673014 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 11:36:31 -0400 Subject: [PATCH 004/102] feat: implement cancellationThreshold function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cancellationThreshold function to return current threshold for a Safe - Return 0 if guard not enabled or not configured - Initialize to 1 when Safe configures guard - Clear threshold when Safe clears guard configuration - Add comprehensive test suite covering all spec requirements - Function never reverts as per spec requirements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/safe/TimelockGuard.sol | 40 +++++++++++++++- .../test/safe/TimelockGuard.t.sol | 47 ++++++++++++++----- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index f8ced0110b5c9..9e7761a0ea4f3 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -23,6 +23,9 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Mapping from Safe address to its guard configuration mapping(address => GuardConfig) public safeConfigs; + /// @notice Mapping from Safe address to its current cancellation threshold + mapping(address => uint256) public safeCancellationThreshold; + /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -75,6 +78,9 @@ contract TimelockGuard is IGuard, ISemver { // Store the configuration for this safe safeConfigs[msg.sender].timelockDelay = _timelockDelay; + // Initialize cancellation threshold to 1 + safeCancellationThreshold[msg.sender] = 1; + emit GuardConfigured(msg.sender, _timelockDelay); } @@ -95,10 +101,37 @@ contract TimelockGuard is IGuard, ISemver { // Erase the configuration data for this safe delete safeConfigs[msg.sender]; + delete safeCancellationThreshold[msg.sender]; emit GuardCleared(msg.sender); } + /// @notice Returns the cancellation threshold for a given safe + /// @dev MUST NOT revert + /// @dev MUST return 0 if the contract is not enabled as a guard for the safe + /// @param _safe The Safe address to query + /// @return The current cancellation threshold + function cancellationThreshold(address _safe) public view returns (uint256) { + // Return 0 if guard is not enabled + if (_getGuard(_safe) != address(this)) { + return 0; + } + + // Return 0 if not configured + if (safeConfigs[_safe].timelockDelay == 0) { + return 0; + } + + uint256 threshold = safeCancellationThreshold[_safe]; + if (threshold == 0) { + // NOTE: not sure if this is the right thing to do. + // defaulting to one is good to prevent us from forgetting to set it to one elsewhere. + // Default to 1 if not set + return 1; + } + return threshold; + } + /// @notice Internal helper to get the guard address from a Safe /// @param _safe The Safe address /// @return The current guard address @@ -106,7 +139,7 @@ contract TimelockGuard is IGuard, ISemver { // keccak256("guard_manager.guard.address") from GuardManager bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; Safe safe = Safe(payable(_safe)); - return abi.decode(safe.getStorageAt({offset: uint256(guardSlot), length: 1}), (address)); + return abi.decode(safe.getStorageAt({ offset: uint256(guardSlot), length: 1 }), (address)); } /// @notice Called by the Safe before executing a transaction @@ -123,7 +156,10 @@ contract TimelockGuard is IGuard, ISemver { address payable refundReceiver, bytes memory signatures, address msgSender - ) external override { + ) + external + override + { // Empty implementation for now - will be filled in when implementing checkTransaction } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index c40bad4ff3a23..c57635e52abc2 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -68,22 +68,14 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Helper to disable guard on a Safe function _disableGuard(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, - address(_safe.safe), - 0, - abi.encodeCall(GuardManager.setGuard, (address(0))), - Enum.Operation.Call + _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(0))), Enum.Operation.Call ); } /// @notice Helper to clear the TimelockGuard configuration for a Safe function _clearGuard(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, - address(timelockGuard), - 0, - abi.encodeCall(TimelockGuard.clearTimelockGuard, ()), - Enum.Operation.Call + _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.clearTimelockGuard, ()), Enum.Operation.Call ); } } @@ -113,7 +105,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_revertsIfGuardNotEnabled() external { // Create a safe without enabling the guard // Reduce the threshold just to prevent a CREATE2 collision when deploying this safe. - SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD-1); + SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); vm.prank(address(unguardedSafe.safe)); @@ -178,6 +170,9 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { // Configuration should be cleared assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), 0); + // Ensure cancellation threshold is reset to 0 + assertEq(timelockGuard.cancellationThreshold(address(safeInstance.safe)), 0); + // TODO: Check that any active challenge is cancelled } @@ -198,3 +193,33 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.clearTimelockGuard(); } } + +/// @title TimelockGuard_CancellationThreshold_Test +/// @notice Tests for cancellationThreshold function +contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { + function test_cancellationThreshold_returnsZeroIfGuardNotEnabled() external { + // Safe without guard enabled should return 0 + SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); + + uint256 threshold = timelockGuard.cancellationThreshold(address(unguardedSafe.safe)); + assertEq(threshold, 0); + } + + function test_cancellationThreshold_returnsZeroIfGuardNotConfigured() external { + // Safe with guard enabled but not configured should return 0 + uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + assertEq(threshold, 0); + } + + function test_cancellationThreshold_returnsOneAfterConfiguration() external { + // Configure the guard + _configureGuard(safeInstance, TIMELOCK_DELAY); + + // Should default to 1 after configuration + uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + assertEq(threshold, 1); + } + + // Note: Testing increment/decrement behavior will require scheduleTransaction, + // cancelTransaction and execution functions to be implemented first +} From a5054e611596ac0f81348eda91c4d589d3f8d8cb Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 13:29:16 -0400 Subject: [PATCH 005/102] feat: add placeholder functions for remaining TimelockGuard functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scheduleTransaction placeholder (Function 4) - Add checkPendingTransactions placeholder (Function 6) - Add rejectTransaction placeholder (Function 7) - Add rejectTransactionWithSignature placeholder (Function 8) - Add cancelTransaction placeholder (Function 9) - Update checkTransaction placeholder (Function 5) - All placeholders have proper signatures and documentation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/safe/TimelockGuard.sol | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 9e7761a0ea4f3..3c3c84403ac75 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -142,6 +142,67 @@ contract TimelockGuard is IGuard, ISemver { return abi.decode(safe.getStorageAt({ offset: uint256(guardSlot), length: 1 }), (address)); } + /// @notice Schedule a transaction for execution after the timelock delay + /// @dev Called by anyone using signatures from Safe owners + /// @param safe The Safe address + /// @param to Transaction target address + /// @param value Transaction value + /// @param data Transaction data + /// @param operation Transaction operation type + /// @param safeTxGas Safe transaction gas + /// @param baseGas Base gas for transaction + /// @param gasPrice Gas price + /// @param gasToken Gas token address + /// @param refundReceiver Refund receiver address + /// @param signatures Transaction signatures + function scheduleTransaction( + address safe, + address to, + uint256 value, + bytes memory data, + Enum.Operation operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) + external + { + revert("Not implemented yet"); + } + + /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe + /// @dev MUST NOT revert + /// @param _safe The Safe address to query + /// @return List of pending transaction hashes + function checkPendingTransactions(address _safe) external view returns (bytes32[] memory) { + return new bytes32[](0); + } + + /// @notice Signal rejection of a scheduled transaction by a Safe owner + /// @param safe The Safe address that scheduled the transaction + /// @param txHash The transaction hash to reject + function rejectTransaction(address safe, bytes32 txHash) external { + revert("Not implemented yet"); + } + + /// @notice Signal rejection of a scheduled transaction using signatures + /// @param safe The Safe address that scheduled the transaction + /// @param txHash The transaction hash to reject + /// @param signatures Owner signatures rejecting the transaction + function rejectTransactionWithSignature(address safe, bytes32 txHash, bytes memory signatures) external { + revert("Not implemented yet"); + } + + /// @notice Cancel a scheduled transaction if cancellation threshold is met + /// @param safe The Safe address that scheduled the transaction + /// @param txHash The transaction hash to cancel + function cancelTransaction(address safe, bytes32 txHash) external { + revert("Not implemented yet"); + } + /// @notice Called by the Safe before executing a transaction /// @dev Implementation of IGuard interface function checkTransaction( @@ -160,7 +221,7 @@ contract TimelockGuard is IGuard, ISemver { external override { - // Empty implementation for now - will be filled in when implementing checkTransaction + // Empty implementation for now } /// @notice Called by the Safe after executing a transaction From 6084abe00f08da1efb1b24f947dddcc18fb8202c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 15:14:01 -0400 Subject: [PATCH 006/102] Self review fixes --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 3c3c84403ac75..a09a7c65787b2 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -58,7 +58,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Configure the contract as a timelock guard by setting the timelock delay /// @dev MUST allow an arbitrary number of Safe contracts to use the contract as a guard - /// @dev The contract MUST be enabled as a guard for the Safe + /// @dev MUST revert if the contract is not enabled as a guard for the Safe /// @dev MUST revert if timelock_delay is longer than 1 year /// @dev MUST set the caller as a Safe /// @dev MUST take timelock_delay as a parameter and store it as related to the Safe @@ -85,7 +85,7 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Remove the timelock guard configuration by a previously enabled Safe - /// @dev The contract MUST NOT be enabled as a guard for the Safe + /// @dev MUST revert if the contract is not enabled as a guard for the Safe /// @dev MUST erase the existing timelock_delay data related to the calling Safe /// @dev MUST emit a GuardCleared event function clearTimelockGuard() external { @@ -124,8 +124,6 @@ contract TimelockGuard is IGuard, ISemver { uint256 threshold = safeCancellationThreshold[_safe]; if (threshold == 0) { - // NOTE: not sure if this is the right thing to do. - // defaulting to one is good to prevent us from forgetting to set it to one elsewhere. // Default to 1 if not set return 1; } @@ -139,7 +137,7 @@ contract TimelockGuard is IGuard, ISemver { // keccak256("guard_manager.guard.address") from GuardManager bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; Safe safe = Safe(payable(_safe)); - return abi.decode(safe.getStorageAt({ offset: uint256(guardSlot), length: 1 }), (address)); + return abi.decode(safe.getStorageAt(uint256(guardSlot), 1), (address)); } /// @notice Schedule a transaction for execution after the timelock delay From e3121cac633d1f365bad2f35642a648a0633ae57 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 15:24:22 -0400 Subject: [PATCH 007/102] Fix warnings on unimplemented functions --- .../src/safe/TimelockGuard.sol | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a09a7c65787b2..fa6d1e95ba03d 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -141,63 +141,48 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Schedule a transaction for execution after the timelock delay - /// @dev Called by anyone using signatures from Safe owners - /// @param safe The Safe address - /// @param to Transaction target address - /// @param value Transaction value - /// @param data Transaction data - /// @param operation Transaction operation type - /// @param safeTxGas Safe transaction gas - /// @param baseGas Base gas for transaction - /// @param gasPrice Gas price - /// @param gasToken Gas token address - /// @param refundReceiver Refund receiver address - /// @param signatures Transaction signatures + /// @dev Called by anyone using signatures from Safe owners - NOT IMPLEMENTED YET function scheduleTransaction( - address safe, - 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, /* safe */ + address, /* to */ + uint256, /* value */ + bytes memory, /* data */ + Enum.Operation, /* operation */ + uint256, /* safeTxGas */ + uint256, /* baseGas */ + uint256, /* gasPrice */ + address, /* gasToken */ + address payable, /* refundReceiver */ + bytes memory /* signatures */ ) external + pure { revert("Not implemented yet"); } /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe - /// @dev MUST NOT revert - /// @param _safe The Safe address to query + /// @dev MUST NOT revert - NOT IMPLEMENTED YET /// @return List of pending transaction hashes - function checkPendingTransactions(address _safe) external view returns (bytes32[] memory) { + function checkPendingTransactions(address /* _safe */) external pure returns (bytes32[] memory) { return new bytes32[](0); } /// @notice Signal rejection of a scheduled transaction by a Safe owner - /// @param safe The Safe address that scheduled the transaction - /// @param txHash The transaction hash to reject - function rejectTransaction(address safe, bytes32 txHash) external { + /// @dev NOT IMPLEMENTED YET + function rejectTransaction(address /* safe */, bytes32 /* txHash */) external pure { revert("Not implemented yet"); } /// @notice Signal rejection of a scheduled transaction using signatures - /// @param safe The Safe address that scheduled the transaction - /// @param txHash The transaction hash to reject - /// @param signatures Owner signatures rejecting the transaction - function rejectTransactionWithSignature(address safe, bytes32 txHash, bytes memory signatures) external { + /// @dev NOT IMPLEMENTED YET + function rejectTransactionWithSignature(address /* safe */, bytes32 /* txHash */, bytes memory /* signatures */) external pure { revert("Not implemented yet"); } /// @notice Cancel a scheduled transaction if cancellation threshold is met - /// @param safe The Safe address that scheduled the transaction - /// @param txHash The transaction hash to cancel - function cancelTransaction(address safe, bytes32 txHash) external { + /// @dev NOT IMPLEMENTED YET + function cancelTransaction(address /* safe */, bytes32 /* txHash */) external pure { revert("Not implemented yet"); } From d117aa791c54b5d97fb1f0056651b8f85270db8d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 15:50:08 -0400 Subject: [PATCH 008/102] Fix names of test functions --- .../test/safe/TimelockGuard.t.sol | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index c57635e52abc2..3e047ca5a595d 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -83,7 +83,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @title TimelockGuard_ViewTimelockGuardConfiguration_Test /// @notice Tests for viewTimelockGuardConfiguration function contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { - function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe() external view { + function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { uint256 delay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); assertEq(delay, 0); } @@ -102,7 +102,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(storedDelay, TIMELOCK_DELAY); } - function test_configureTimelockGuard_revertsIfGuardNotEnabled() external { + function test_configureTimelockGuard_revertsIfGuardNotEnabled_reverts() external { // Create a safe without enabling the guard // Reduce the threshold just to prevent a CREATE2 collision when deploying this safe. SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); @@ -112,7 +112,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(TIMELOCK_DELAY); } - function test_configureTimelockGuard_revertsIfDelayTooLong() external { + function test_configureTimelockGuard_revertsIfDelayTooLong_reverts() external { uint256 tooLongDelay = ONE_YEAR + 1; vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); @@ -120,13 +120,13 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(tooLongDelay); } - function test_configureTimelockGuard_revertsIfDelayZero() external { + function test_configureTimelockGuard_revertsIfDelayZero_reverts() external { vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); vm.prank(address(safeInstance.safe)); timelockGuard.configureTimelockGuard(0); } - function test_configureTimelockGuard_acceptsMaxValidDelay() external { + function test_configureTimelockGuard_acceptsMaxValidDelay_succeeds() external { vm.expectEmit(true, true, true, true); emit GuardConfigured(address(safeInstance.safe), ONE_YEAR); @@ -136,7 +136,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(storedDelay, ONE_YEAR); } - function test_configureTimelockGuard_allowsReconfiguration() external { + function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), TIMELOCK_DELAY); @@ -176,7 +176,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { // TODO: Check that any active challenge is cancelled } - function test_clearTimelockGuard_revertsIfGuardStillEnabled() external { + function test_clearTimelockGuard_revertsIfGuardStillEnabled_reverts() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); @@ -186,7 +186,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.clearTimelockGuard(); } - function test_clearTimelockGuard_revertsIfNotConfigured() external { + function test_clearTimelockGuard_revertsIfNotConfigured_reverts() external { // Try to clear - should revert because not configured vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); vm.prank(address(safeInstance.safe)); @@ -197,7 +197,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { /// @title TimelockGuard_CancellationThreshold_Test /// @notice Tests for cancellationThreshold function contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { - function test_cancellationThreshold_returnsZeroIfGuardNotEnabled() external { + function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external { // Safe without guard enabled should return 0 SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); @@ -205,13 +205,13 @@ contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { assertEq(threshold, 0); } - function test_cancellationThreshold_returnsZeroIfGuardNotConfigured() external { + function test_cancellationThreshold_returnsZeroIfGuardNotConfigured_succeeds() external { // Safe with guard enabled but not configured should return 0 uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); assertEq(threshold, 0); } - function test_cancellationThreshold_returnsOneAfterConfiguration() external { + function test_cancellationThreshold_returnsOneAfterConfiguration_succeeds() external { // Configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); From 56e366d5e03673725c9ec1223e2f0ecaee330a7f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 16:24:21 -0400 Subject: [PATCH 009/102] Satisfy semgrep by removing revert with string --- .../src/safe/TimelockGuard.sol | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index fa6d1e95ba03d..63b8ed43dfeab 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -158,7 +158,7 @@ contract TimelockGuard is IGuard, ISemver { external pure { - revert("Not implemented yet"); + // TODO: Implement } /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe @@ -171,45 +171,45 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Signal rejection of a scheduled transaction by a Safe owner /// @dev NOT IMPLEMENTED YET function rejectTransaction(address /* safe */, bytes32 /* txHash */) external pure { - revert("Not implemented yet"); + // TODO: Implement } /// @notice Signal rejection of a scheduled transaction using signatures /// @dev NOT IMPLEMENTED YET function rejectTransactionWithSignature(address /* safe */, bytes32 /* txHash */, bytes memory /* signatures */) external pure { - revert("Not implemented yet"); + // TODO: Implement } /// @notice Cancel a scheduled transaction if cancellation threshold is met /// @dev NOT IMPLEMENTED YET function cancelTransaction(address /* safe */, bytes32 /* txHash */) external pure { - revert("Not implemented yet"); + // TODO: Implement } /// @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 signatures, - address msgSender + 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 */ ) external override { - // Empty implementation for now + // TODO: Implement } /// @notice Called by the Safe after executing a transaction /// @dev Implementation of IGuard interface - function checkAfterExecution(bytes32 txHash, bool success) external override { - // Empty implementation for now + function checkAfterExecution(bytes32 /* _txHash */, bool /* _success */) external override { + // TODO: Implement } } From 4c790440be627f64479cafeed59b25a34232a92b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 17:08:12 -0400 Subject: [PATCH 010/102] Remove arg names from unimplemented functions --- .../src/safe/TimelockGuard.sol | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 63b8ed43dfeab..9a9e59a73ce50 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -143,17 +143,17 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Schedule a transaction for execution after the timelock delay /// @dev Called by anyone using signatures from Safe owners - NOT IMPLEMENTED YET function scheduleTransaction( - address, /* safe */ - 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, + address, + uint256, + bytes memory, + Enum.Operation, + uint256, + uint256, + uint256, + address, + address payable, + bytes memory ) external pure @@ -164,42 +164,42 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe /// @dev MUST NOT revert - NOT IMPLEMENTED YET /// @return List of pending transaction hashes - function checkPendingTransactions(address /* _safe */) external pure returns (bytes32[] memory) { + function checkPendingTransactions(address) external pure returns (bytes32[] memory) { return new bytes32[](0); } /// @notice Signal rejection of a scheduled transaction by a Safe owner /// @dev NOT IMPLEMENTED YET - function rejectTransaction(address /* safe */, bytes32 /* txHash */) external pure { + function rejectTransaction(address, bytes32) external pure { // TODO: Implement } /// @notice Signal rejection of a scheduled transaction using signatures /// @dev NOT IMPLEMENTED YET - function rejectTransactionWithSignature(address /* safe */, bytes32 /* txHash */, bytes memory /* signatures */) external pure { + function rejectTransactionWithSignature(address, bytes32, bytes memory) external pure { // TODO: Implement } /// @notice Cancel a scheduled transaction if cancellation threshold is met /// @dev NOT IMPLEMENTED YET - function cancelTransaction(address /* safe */, bytes32 /* txHash */) external pure { + function cancelTransaction(address, bytes32) external pure { // TODO: Implement } /// @notice Called by the Safe before executing a transaction /// @dev Implementation of IGuard interface function checkTransaction( - address /* _to */, + address, uint256 _value, - bytes memory /* _data */, - Enum.Operation /* _operation */, - uint256 /* _safeTxGas */, - uint256 /* _baseGas */, - uint256 /* _gasPrice */, - address /* _gasToken */, - address payable /* _refundReceiver */, - bytes memory /* _signatures */, - address /* _msgSender */ + bytes memory, + Enum.Operation, + uint256, + uint256, + uint256, + address, + address payable, + bytes memory, + address ) external override @@ -209,7 +209,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Called by the Safe after executing a transaction /// @dev Implementation of IGuard interface - function checkAfterExecution(bytes32 /* _txHash */, bool /* _success */) external override { + function checkAfterExecution(bytes32, bool) external override { // TODO: Implement } } From 3de308edbc1d12bf53d92fba29249736e3cadac9 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 17:08:42 -0400 Subject: [PATCH 011/102] Snapshots --- .../snapshots/abi/TimelockGuard.json | 385 ++++++++++++++++++ .../storageLayout/TimelockGuard.json | 16 + 2 files changed, 401 insertions(+) create mode 100644 packages/contracts-bedrock/snapshots/abi/TimelockGuard.json create mode 100644 packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json new file mode 100644 index 0000000000000..745fdf6ec1383 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -0,0 +1,385 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "cancelTransaction", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "cancellationThreshold", + "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": "checkPendingTransactions", + "outputs": [ + { + "internalType": "bytes32[]", + "name": "", + "type": "bytes32[]" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_value", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address payable", + "name": "", + "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": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "rejectTransaction", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "rejectTransactionWithSignature", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "safeCancellationThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "safeConfigs", + "outputs": [ + { + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + }, + { + "internalType": "enum Enum.Operation", + "name": "", + "type": "uint8" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "", + "type": "uint256" + }, + { + "internalType": "address", + "name": "", + "type": "address" + }, + { + "internalType": "address payable", + "name": "", + "type": "address" + }, + { + "internalType": "bytes", + "name": "", + "type": "bytes" + } + ], + "name": "scheduleTransaction", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_safe", + "type": "address" + } + ], + "name": "viewTimelockGuardConfiguration", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + } + ], + "name": "GuardCleared", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + } + ], + "name": "GuardConfigured", + "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" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json new file mode 100644 index 0000000000000..a8c3b0cb554d6 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json @@ -0,0 +1,16 @@ +[ + { + "bytes": "32", + "label": "safeConfigs", + "offset": 0, + "slot": "0", + "type": "mapping(address => struct TimelockGuard.GuardConfig)" + }, + { + "bytes": "32", + "label": "safeCancellationThreshold", + "offset": 0, + "slot": "1", + "type": "mapping(address => uint256)" + } +] \ No newline at end of file From 052e4e609814f421e70f9719b5e458ef7dd89fd6 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 9 Sep 2025 17:08:48 -0400 Subject: [PATCH 012/102] Add interface --- .../interfaces/safe/ITimelockGuard.sol | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol new file mode 100644 index 0000000000000..92bbabbda9703 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { Enum } from "safe-contracts/common/Enum.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; + + +/// @title ITimelockGuard +/// @notice Interface for the TimelockGuard Safe guard. +interface ITimelockGuard is IGuard, ISemver { + // Errors + error TimelockGuard_GuardNotConfigured(); + error TimelockGuard_GuardNotEnabled(); + error TimelockGuard_GuardStillEnabled(); + error TimelockGuard_InvalidTimelockDelay(); + + // Events + event GuardCleared(address indexed safe); + event GuardConfigured(address indexed safe, uint256 timelockDelay); + + // Views + function version() external view returns (string memory); + + function viewTimelockGuardConfiguration(address _safe) external view returns (uint256); + + function cancellationThreshold(address _safe) external view returns (uint256 cancellationThreshold_); + + function safeCancellationThreshold(address) external view returns (uint256); + + function safeConfigs(address) + external + view + returns (uint256 timelockDelay); + + // Admin + function configureTimelockGuard(uint256 _timelockDelay) external; + + function clearTimelockGuard() external; + + // Scheduling API (placeholders until fully implemented in the guard) + function scheduleTransaction( + address _safe, + address _to, + uint256 _value, + bytes memory _data, + Enum.Operation _operation, + uint256 _safeTxGas, + uint256 _baseGas, + uint256 _gasPrice, + address _gasToken, + address payable _refundReceiver, + bytes memory _signatures + ) + external + pure; + + function checkPendingTransactions(address _safe) external pure returns (bytes32[] memory pendingTxs_); + + function rejectTransaction(address _safe, bytes32 _txHash) external pure; + + function rejectTransactionWithSignature(address _safe, bytes32 _txHash, bytes memory _signatures) external pure; + + function cancelTransaction(address _safe, bytes32 _txHash) external pure; +} + From 54d5e926d853ef166163f09cd4157a9907290c68 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 10 Sep 2025 15:36:54 -0400 Subject: [PATCH 013/102] Simplify cancellationThreshold() getter --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 9a9e59a73ce50..a0e1d495e7032 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -117,17 +117,7 @@ contract TimelockGuard is IGuard, ISemver { return 0; } - // Return 0 if not configured - if (safeConfigs[_safe].timelockDelay == 0) { - return 0; - } - - uint256 threshold = safeCancellationThreshold[_safe]; - if (threshold == 0) { - // Default to 1 if not set - return 1; - } - return threshold; + return safeCancellationThreshold[_safe]; } /// @notice Internal helper to get the guard address from a Safe From afdbaf178224e596c5a086ec024fc447f86e3222 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 10 Sep 2025 15:44:14 -0400 Subject: [PATCH 014/102] Replace _getGuard with isGuardEnabled --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a0e1d495e7032..fa6e701e5c08c 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -66,12 +66,12 @@ contract TimelockGuard is IGuard, ISemver { /// @param _timelockDelay The timelock delay in seconds function configureTimelockGuard(uint256 _timelockDelay) external { // Validate timelock delay - must be non-zero and not longer than 1 year - if (_timelockDelay == 0 || _timelockDelay > 365 days) { + if (_timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); } // Check that this guard is enabled on the calling Safe - if (_getGuard(msg.sender) != address(this)) { + if (!_isGuardEnabled(msg.sender)) { revert TimelockGuard_GuardNotEnabled(); } @@ -95,7 +95,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that this guard is NOT enabled on the calling Safe - if (_getGuard(msg.sender) == address(this)) { + if (_isGuardEnabled(msg.sender)) { revert TimelockGuard_GuardStillEnabled(); } @@ -113,7 +113,7 @@ contract TimelockGuard is IGuard, ISemver { /// @return The current cancellation threshold function cancellationThreshold(address _safe) public view returns (uint256) { // Return 0 if guard is not enabled - if (_getGuard(_safe) != address(this)) { + if (!_isGuardEnabled(_safe)) { return 0; } @@ -123,11 +123,12 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Internal helper to get the guard address from a Safe /// @param _safe The Safe address /// @return The current guard address - function _getGuard(address _safe) internal view returns (address) { + function _isGuardEnabled(address _safe) internal view returns (bool) { // keccak256("guard_manager.guard.address") from GuardManager bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; Safe safe = Safe(payable(_safe)); - return abi.decode(safe.getStorageAt(uint256(guardSlot), 1), (address)); + address guard = abi.decode(safe.getStorageAt(uint256(guardSlot), 1), (address)); + return guard == address(this); } /// @notice Schedule a transaction for execution after the timelock delay From 3f82d246347f4845dcf29e70f7b1338c98d6e2ef Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 10 Sep 2025 15:47:58 -0400 Subject: [PATCH 015/102] Allow a timelock delay of zero --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index fa6e701e5c08c..f87ba843dbcb7 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -66,7 +66,7 @@ contract TimelockGuard is IGuard, ISemver { /// @param _timelockDelay The timelock delay in seconds function configureTimelockGuard(uint256 _timelockDelay) external { // Validate timelock delay - must be non-zero and not longer than 1 year - if (_timelockDelay > 365 days) { + if (_timelockDelay == 0 || _timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 3e047ca5a595d..8be77e3b7e390 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -120,12 +120,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(tooLongDelay); } - function test_configureTimelockGuard_revertsIfDelayZero_reverts() external { - vm.expectRevert(TimelockGuard.TimelockGuard_InvalidTimelockDelay.selector); - vm.prank(address(safeInstance.safe)); - timelockGuard.configureTimelockGuard(0); - } - function test_configureTimelockGuard_acceptsMaxValidDelay_succeeds() external { vm.expectEmit(true, true, true, true); emit GuardConfigured(address(safeInstance.safe), ONE_YEAR); From cea893eb07f4fa63b7b2f5e90adda8708adbc08b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 11 Sep 2025 12:30:43 -0400 Subject: [PATCH 016/102] TimelockGuard: Add scheduleTransaction() --- .../src/safe/TimelockGuard.sol | 97 +++++++++++++--- packages/contracts-bedrock/src/safe/Types.sol | 19 +++ .../test/safe-tools/SafeTestTools.sol | 2 + .../test/safe/TimelockGuard.t.sol | 108 +++++++++++++++--- 4 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 packages/contracts-bedrock/src/safe/Types.sol diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index f87ba843dbcb7..7654943921a66 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -5,6 +5,7 @@ pragma solidity 0.8.15; import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; import { GuardManager, Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { ExecTransactionParams } from "src/safe/Types.sol"; // Interfaces import { ISemver } from "interfaces/universal/ISemver.sol"; @@ -20,12 +21,21 @@ contract TimelockGuard is IGuard, ISemver { uint256 timelockDelay; } + /// @notice Scheduled transaction + struct ScheduledTransaction { + uint256 executionTime; + bool cancelled; + } + /// @notice Mapping from Safe address to its guard configuration mapping(address => GuardConfig) public safeConfigs; /// @notice Mapping from Safe address to its current cancellation threshold mapping(address => uint256) public safeCancellationThreshold; + /// @notice Mapping from Safe and tx id to scheduled transaction. + mapping(Safe => mapping(bytes32 => ScheduledTransaction)) public scheduledTransactions; + /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -38,12 +48,21 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Error for invalid timelock delay error TimelockGuard_InvalidTimelockDelay(); + /// @notice Error for when a transaction is already scheduled + error TimelockGuard_TransactionAlreadyScheduled(); + /// @notice Emitted when a Safe configures the guard event GuardConfigured(address indexed safe, uint256 timelockDelay); /// @notice Emitted when a Safe clears the guard configuration event GuardCleared(address indexed safe); + /// @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 Semantic version. /// @custom:semver 1.0.0 string public constant version = "1.0.0"; @@ -131,25 +150,65 @@ contract TimelockGuard is IGuard, ISemver { return guard == address(this); } - /// @notice Schedule a transaction for execution after the timelock delay - /// @dev Called by anyone using signatures from Safe owners - NOT IMPLEMENTED YET - function scheduleTransaction( - address, - address, - uint256, - bytes memory, - Enum.Operation, - uint256, - uint256, - uint256, - address, - address payable, - bytes memory - ) - external - pure - { - // TODO: Implement + /// @notice Schedule a transaction for execution after the timelock delay. + /// @dev Minimal implementation: checks enabled+configured, uniqueness, cancellation, stores execution time and + /// emits. + /// @dev The txId is computed independent of Safe nonce using all exec params (with keccak(data)). + function scheduleTransaction(Safe _safe, uint256 _nonce, ExecTransactionParams memory _params) external { + // Check that this guard is enabled on the calling Safe + if (!_isGuardEnabled(address(_safe))) { + revert TimelockGuard_GuardNotEnabled(); + } + + // 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 + ); + + // Verify signatures using the Safe's signature checking logic + // This function call reverts if the signatures are invalid. + _safe.checkSignatures(txHash, txHashData, _params.signatures); + + // 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(); + } + + // Calculate the execution time + uint256 executionTime = block.timestamp + safeConfigs[address(_safe)].timelockDelay; + + // Schedule the transaction + scheduledTransactions[_safe][txHash] = ScheduledTransaction({ executionTime: executionTime, cancelled: false }); + + emit TransactionScheduled(_safe, txHash, executionTime); } /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe diff --git a/packages/contracts-bedrock/src/safe/Types.sol b/packages/contracts-bedrock/src/safe/Types.sol new file mode 100644 index 0000000000000..d628d5d293e79 --- /dev/null +++ b/packages/contracts-bedrock/src/safe/Types.sol @@ -0,0 +1,19 @@ +// 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; + // TODO: Life might be easier if this was left out of the struct + bytes signatures; +} diff --git a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol index fa86111dd7973..332039b5cf63b 100644 --- a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol +++ b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol @@ -191,6 +191,8 @@ library SafeTestLib { refundReceiver: refundReceiver, _nonce: _nonce }); + console.log('txDataHash:'); + console.logBytes32(txDataHash); } (v, r, s) = Vm(VM_ADDR).sign(pk, txDataHash); diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 8be77e3b7e390..863fc0f9df14d 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -2,11 +2,15 @@ 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 { StorageAccessible } from "safe-contracts/common/StorageAccessible.sol"; +import { ExecTransactionParams } from "src/safe/Types.sol"; import "test/safe-tools/SafeTestTools.sol"; +import { console2 as console } from "forge-std/console2.sol"; + import { TimelockGuard } from "src/safe/TimelockGuard.sol"; /// @title TimelockGuard_TestInit @@ -17,6 +21,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // Events event GuardConfigured(address indexed safe, uint256 timelockDelay); event GuardCleared(address indexed safe); + event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -26,9 +31,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { TimelockGuard timelockGuard; SafeInstance safeInstance; - SafeInstance safeInstance2; - address[] owners; - uint256[] ownerPKs; + SafeInstance unguardedSafe; function setUp() public virtual { vm.warp(INIT_TIME); @@ -37,12 +40,14 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { timelockGuard = new TimelockGuard(); // Create Safe owners - (address[] memory _owners, uint256[] memory _keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); - owners = _owners; - ownerPKs = _keys; + (, uint256[] memory keys) = SafeTestLib.makeAddrsAndKeys("owners", NUM_OWNERS); // Set up Safe with owners - safeInstance = _setupSafe(ownerPKs, THRESHOLD); + safeInstance = _setupSafe(keys, THRESHOLD); + + // 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 SafeTestLib.execTransaction( @@ -54,6 +59,53 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { ); } + /// @notice Helper to create a dummy transaction with signatures and a tx hash + function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes32) { + // Get the nonce of the safe to sign + uint256 nonce = safeInstance.safe.nonce(); + + // Declare the dummy transaction params with an empty signature + ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ + to: address(0xabba), + value: 0, + data: hex"acdc", + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)), + signatures: new bytes(0) + }); + + // Get the tx hash + bytes32 txHash; + { + txHash = safeInstance.safe.getTransactionHash({ + to: dummyTxParams.to, + value: dummyTxParams.value, + data: dummyTxParams.data, + operation: dummyTxParams.operation, + safeTxGas: dummyTxParams.safeTxGas, + baseGas: dummyTxParams.baseGas, + gasPrice: dummyTxParams.gasPrice, + gasToken: dummyTxParams.gasToken, + refundReceiver: dummyTxParams.refundReceiver, + _nonce: nonce + }); + } + + // Sign the tx hash with the owners' private keys + for (uint256 i; i < THRESHOLD; ++i) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeInstance.ownerPKs[i], txHash); + + // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} + dummyTxParams.signatures = bytes.concat(dummyTxParams.signatures, abi.encodePacked(r, s, v)); + } + + return (dummyTxParams, txHash); + } + /// @notice Helper to configure the TimelockGuard for a Safe function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { SafeTestLib.execTransaction( @@ -103,10 +155,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { } function test_configureTimelockGuard_revertsIfGuardNotEnabled_reverts() external { - // Create a safe without enabling the guard - // Reduce the threshold just to prevent a CREATE2 collision when deploying this safe. - SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); - vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); vm.prank(address(unguardedSafe.safe)); timelockGuard.configureTimelockGuard(TIMELOCK_DELAY); @@ -192,14 +240,11 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { /// @notice Tests for cancellationThreshold function contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external { - // Safe without guard enabled should return 0 - SafeInstance memory unguardedSafe = _setupSafe(ownerPKs, THRESHOLD - 1); - uint256 threshold = timelockGuard.cancellationThreshold(address(unguardedSafe.safe)); assertEq(threshold, 0); } - function test_cancellationThreshold_returnsZeroIfGuardNotConfigured_succeeds() external { + function test_cancellationThreshold_returnsZeroIfGuardNotConfigured_succeeds() external view { // Safe with guard enabled but not configured should return 0 uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); assertEq(threshold, 0); @@ -217,3 +262,36 @@ contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { // 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 { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY); + } + + function test_scheduleTransaction_succeeds() public { + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + + vm.expectEmit(true, true, true, true); + emit TransactionScheduled(safeInstance.safe, txHash, INIT_TIME + TIMELOCK_DELAY); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + } + + function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { + (ExecTransactionParams memory dummyTxParams,) = _getDummyTx(); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + } + + function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { } + + function test_scheduleTransaction_guardNotEnabled_reverts() external { } + + function test_scheduleTransaction_guardNotConfigured_reverts() external { } + + function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { } +} From c90de489993e6e3666c857302d63335b78cf26ae Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 11 Sep 2025 15:48:50 -0400 Subject: [PATCH 017/102] Add todo note --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 863fc0f9df14d..d289b000359a5 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -60,6 +60,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to create a dummy transaction with signatures and a tx hash + // TODO: separate into two functions: one for the params+hash, one for the signatures function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes32) { // Get the nonce of the safe to sign uint256 nonce = safeInstance.safe.nonce(); From c17a7826ee5c6c470dabb583aa7ba4ae6117e544 Mon Sep 17 00:00:00 2001 From: alcueca Date: Mon, 15 Sep 2025 09:35:04 +0100 Subject: [PATCH 018/102] Pseudocode draft of a non-nested timelock Simpler code Add cancelTransaction function relying on the Safe's internal logic undo type change Add note explaining getScheduledTransactions --- .../src/safe/TimelockGuard.sol | 93 +++++++++++++++---- .../test/safe-tools/SafeTestTools.sol | 2 +- .../test/safe/TimelockGuard.t.sol | 50 +++++++--- 3 files changed, 113 insertions(+), 32 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 7654943921a66..a7e9db8394edd 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -25,16 +25,17 @@ contract TimelockGuard is IGuard, ISemver { struct ScheduledTransaction { uint256 executionTime; bool cancelled; + bool executed; } /// @notice Mapping from Safe address to its guard configuration mapping(address => GuardConfig) public safeConfigs; - /// @notice Mapping from Safe address to its current cancellation threshold - mapping(address => uint256) public safeCancellationThreshold; - /// @notice Mapping from Safe and tx id to scheduled transaction. - mapping(Safe => mapping(bytes32 => ScheduledTransaction)) public scheduledTransactions; + mapping(Safe => mapping(bytes32 => ScheduledTransaction)) internal scheduledTransactions; + + /// @notice Mapping from Safe to cancellation threshold. + mapping(address => uint256) internal safeCancellationThreshold; /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -51,6 +52,9 @@ contract TimelockGuard is IGuard, ISemver { /// @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 Emitted when a Safe configures the guard event GuardConfigured(address indexed safe, uint256 timelockDelay); @@ -75,6 +79,13 @@ contract TimelockGuard is IGuard, ISemver { return safeConfigs[_safe].timelockDelay; } + /// @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 getScheduledTransaction(Safe _safe, bytes32 _txHash) public view returns (ScheduledTransaction memory) { + return scheduledTransactions[_safe][_txHash]; + } + /// @notice Configure the contract as a timelock guard by setting the timelock delay /// @dev MUST allow an arbitrary number of Safe contracts to use the contract as a guard /// @dev MUST revert if the contract is not enabled as a guard for the Safe @@ -120,7 +131,6 @@ contract TimelockGuard is IGuard, ISemver { // Erase the configuration data for this safe delete safeConfigs[msg.sender]; - delete safeCancellationThreshold[msg.sender]; emit GuardCleared(msg.sender); } @@ -139,6 +149,15 @@ contract TimelockGuard is IGuard, ISemver { return safeCancellationThreshold[_safe]; } + /// @notice Returns the blocking threshold threshold for a given safe + /// @dev MUST NOT revert + /// @param _safe The Safe address to query + /// @return The current blocking threshold + function blockingThreshold(address _safe) public view returns (uint256) { + return 0; + // return min(quorum, total_owners - quorum + 1) for _safe; + } + /// @notice Internal helper to get the guard address from a Safe /// @param _safe The Safe address /// @return The current guard address @@ -206,7 +225,8 @@ contract TimelockGuard is IGuard, ISemver { uint256 executionTime = block.timestamp + safeConfigs[address(_safe)].timelockDelay; // Schedule the transaction - scheduledTransactions[_safe][txHash] = ScheduledTransaction({ executionTime: executionTime, cancelled: false }); + scheduledTransactions[_safe][txHash] = + ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false }); emit TransactionScheduled(_safe, txHash, executionTime); } @@ -218,22 +238,58 @@ contract TimelockGuard is IGuard, ISemver { return new bytes32[](0); } - /// @notice Signal rejection of a scheduled transaction by a Safe owner + /// @notice Cancel a scheduled transaction if cancellation threshold is met /// @dev NOT IMPLEMENTED YET - function rejectTransaction(address, bytes32) external pure { - // TODO: Implement + /// @dev This function aims to mimic the approach which would be used by a quorum of signers to + /// cancel a partially signed transaction, which would be to sign and execute an empty + /// transaction at the same nonce. This enables us to deterministically generate the + /// transaction inputs for a cancellation transaction from the transaction being cancelled. + /// Thus in order for an owners to sign their cancellation transaction, they must first sign + /// an empty transaction at the same nonce, or call approveHash on the Safe for that + /// transaction hash. + function cancelTransaction(Safe _safe, ExecTransactionParams memory _params, uint256 _nonce) external { + // Calculate the transaction hash + bytes32 txHash = _safe.getTransactionHash( + _params.to, + _params.value, + _params.data, + _params.operation, + _params.safeTxGas, + _params.baseGas, + _params.gasPrice, + _params.gasToken, + _params.refundReceiver, + _nonce + ); + if (scheduledTransactions[_safe][txHash].cancelled) { + revert TimelockGuard_TransactionAlreadyCancelled(); + } + + // Generate the cancellation transaction data + bytes memory cancellationTxData = + _safe.encodeTransactionData(address(0), 0, "", Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); + bytes32 cancellationTxHash = + _safe.getTransactionHash(address(0), 0, "", 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, _params.signatures, safeCancellationThreshold[address(_safe)] + ); + + scheduledTransactions[_safe][txHash].cancelled = true; } - /// @notice Signal rejection of a scheduled transaction using signatures - /// @dev NOT IMPLEMENTED YET - function rejectTransactionWithSignature(address, bytes32, bytes memory) external pure { - // TODO: Implement + /// @notice Increase the cancellation threshold for a safe + /// @dev This function must be caled only once and only when calling cancel + function increaseCancellationThresholds(address _safe, bytes32 txHash) internal pure { + // if safeCancellationThreshold[_safe] < blockingThreshold(_safe) + // safeCancellationThreshold[_safe]++ } - /// @notice Cancel a scheduled transaction if cancellation threshold is met - /// @dev NOT IMPLEMENTED YET - function cancelTransaction(address, bytes32) external pure { - // TODO: Implement + /// @notice Reset the cancellation threshold for a safe + /// @dev This function must be called only once and only when calling checkAfterExecution + function resetCancellationThreshold(address _safe, bytes32 txHash) external pure { + // safeCancellationThreshold[_safe] = 1 } /// @notice Called by the Safe before executing a transaction @@ -261,5 +317,8 @@ contract TimelockGuard is IGuard, ISemver { /// @dev Implementation of IGuard interface function checkAfterExecution(bytes32, bool) external override { // TODO: Implement + // extract txHash + // resetCancellationThreshold(_safe, txHash) + // scheduledTransactions[_safe][txHash].executed = true } } diff --git a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol index 332039b5cf63b..f31ea2d8fb498 100644 --- a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol +++ b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol @@ -191,7 +191,7 @@ library SafeTestLib { refundReceiver: refundReceiver, _nonce: _nonce }); - console.log('txDataHash:'); + console.log("txDataHash:"); console.logBytes32(txDataHash); } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index d289b000359a5..515f027804156 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -81,20 +81,20 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // Get the tx hash bytes32 txHash; - { - txHash = safeInstance.safe.getTransactionHash({ - to: dummyTxParams.to, - value: dummyTxParams.value, - data: dummyTxParams.data, - operation: dummyTxParams.operation, - safeTxGas: dummyTxParams.safeTxGas, - baseGas: dummyTxParams.baseGas, - gasPrice: dummyTxParams.gasPrice, - gasToken: dummyTxParams.gasToken, - refundReceiver: dummyTxParams.refundReceiver, - _nonce: nonce - }); - } + { + txHash = safeInstance.safe.getTransactionHash({ + to: dummyTxParams.to, + value: dummyTxParams.value, + data: dummyTxParams.data, + operation: dummyTxParams.operation, + safeTxGas: dummyTxParams.safeTxGas, + baseGas: dummyTxParams.baseGas, + gasPrice: dummyTxParams.gasPrice, + gasToken: dummyTxParams.gasToken, + refundReceiver: dummyTxParams.refundReceiver, + _nonce: nonce + }); + } // Sign the tx hash with the owners' private keys for (uint256 i; i < THRESHOLD; ++i) { @@ -296,3 +296,25 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { } } + +/// @title TimelockGuard_CancelTransaction_Test +/// @notice Tests for cancelTransaction function +contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + + // Configure the guard and schedule a transaction + _configureGuard(safeInstance, TIMELOCK_DELAY); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + + // verify that the transaction is scheduled + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); + assertEq(scheduledTransaction.executionTime, block.timestamp + TIMELOCK_DELAY); + assertEq(scheduledTransaction.cancelled, false); + assertEq(scheduledTransaction.executed, false); + } + + function test_cancelTransaction_succeeds() external { } +} From 0d31d51d79fe0816bb0dc41a477410a6fc4cb7cc Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 11:30:01 -0400 Subject: [PATCH 019/102] Remove signatures field from ExecTransactionParams --- .../src/safe/TimelockGuard.sol | 8 +++--- packages/contracts-bedrock/src/safe/Types.sol | 2 -- .../test/safe/TimelockGuard.t.sol | 27 +++++++++---------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a7e9db8394edd..97aef4260fe93 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -173,7 +173,7 @@ contract TimelockGuard is IGuard, ISemver { /// @dev Minimal implementation: checks enabled+configured, uniqueness, cancellation, stores execution time and /// emits. /// @dev The txId is computed independent of Safe nonce using all exec params (with keccak(data)). - function scheduleTransaction(Safe _safe, uint256 _nonce, ExecTransactionParams memory _params) external { + 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(address(_safe))) { revert TimelockGuard_GuardNotEnabled(); @@ -213,7 +213,7 @@ contract TimelockGuard is IGuard, ISemver { // Verify signatures using the Safe's signature checking logic // This function call reverts if the signatures are invalid. - _safe.checkSignatures(txHash, txHashData, _params.signatures); + _safe.checkSignatures(txHash, txHashData, _signatures); // Check if the transaction exists // A transaction can only be scheduled once, regardless of whether it has been cancelled or not. @@ -247,7 +247,7 @@ contract TimelockGuard is IGuard, ISemver { /// Thus in order for an owners to sign their cancellation transaction, they must first sign /// an empty transaction at the same nonce, or call approveHash on the Safe for that /// transaction hash. - function cancelTransaction(Safe _safe, ExecTransactionParams memory _params, uint256 _nonce) external { + function cancelTransaction(Safe _safe, ExecTransactionParams memory _params, uint256 _nonce, bytes memory _signatures) external { // Calculate the transaction hash bytes32 txHash = _safe.getTransactionHash( _params.to, @@ -273,7 +273,7 @@ contract TimelockGuard is IGuard, ISemver { // Verify signatures using the Safe's signature checking logic // This function call reverts if the signatures are invalid. _safe.checkNSignatures( - cancellationTxHash, cancellationTxData, _params.signatures, safeCancellationThreshold[address(_safe)] + cancellationTxHash, cancellationTxData, _signatures, safeCancellationThreshold[address(_safe)] ); scheduledTransactions[_safe][txHash].cancelled = true; diff --git a/packages/contracts-bedrock/src/safe/Types.sol b/packages/contracts-bedrock/src/safe/Types.sol index d628d5d293e79..1d999d04a4db6 100644 --- a/packages/contracts-bedrock/src/safe/Types.sol +++ b/packages/contracts-bedrock/src/safe/Types.sol @@ -14,6 +14,4 @@ struct ExecTransactionParams { uint256 gasPrice; address gasToken; address payable refundReceiver; - // TODO: Life might be easier if this was left out of the struct - bytes signatures; } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 515f027804156..f2b315c6babae 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -60,12 +60,11 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to create a dummy transaction with signatures and a tx hash - // TODO: separate into two functions: one for the params+hash, one for the signatures - function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes32) { + function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes memory, bytes32) { // Get the nonce of the safe to sign uint256 nonce = safeInstance.safe.nonce(); - // Declare the dummy transaction params with an empty signature + // Declare the dummy transaction params ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ to: address(0xabba), value: 0, @@ -75,8 +74,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { baseGas: 0, gasPrice: 0, gasToken: address(0), - refundReceiver: payable(address(0)), - signatures: new bytes(0) + refundReceiver: payable(address(0)) }); // Get the tx hash @@ -97,14 +95,15 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } // Sign the tx hash with the owners' private keys + bytes memory signatures = new bytes(0); for (uint256 i; i < THRESHOLD; ++i) { (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeInstance.ownerPKs[i], txHash); // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} - dummyTxParams.signatures = bytes.concat(dummyTxParams.signatures, abi.encodePacked(r, s, v)); + signatures = bytes.concat(signatures, abi.encodePacked(r, s, v)); } - return (dummyTxParams, txHash); + return (dummyTxParams, signatures, txHash); } /// @notice Helper to configure the TimelockGuard for a Safe @@ -273,19 +272,19 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } function test_scheduleTransaction_succeeds() public { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + (ExecTransactionParams memory dummyTxParams, bytes memory signatures, bytes32 txHash) = _getDummyTx(); vm.expectEmit(true, true, true, true); emit TransactionScheduled(safeInstance.safe, txHash, INIT_TIME + TIMELOCK_DELAY); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); } function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { - (ExecTransactionParams memory dummyTxParams,) = _getDummyTx(); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + (ExecTransactionParams memory dummyTxParams, bytes memory signatures,) = _getDummyTx(); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); } function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { } @@ -305,8 +304,8 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Configure the guard and schedule a transaction _configureGuard(safeInstance, TIMELOCK_DELAY); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams); + (ExecTransactionParams memory dummyTxParams, bytes memory signatures, bytes32 txHash) = _getDummyTx(); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); // verify that the transaction is scheduled TimelockGuard.ScheduledTransaction memory scheduledTransaction = From 056248201f684ea4344303311dfc05d366cd8918 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 12:31:05 -0400 Subject: [PATCH 020/102] Refactor tests with improve utils (_getDummyTx, _getSignaturesForTx) --- .../test/safe/TimelockGuard.t.sol | 106 ++++++++++++------ 1 file changed, 71 insertions(+), 35 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index f2b315c6babae..fdba84a94ffbf 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -59,13 +59,9 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { ); } - /// @notice Helper to create a dummy transaction with signatures and a tx hash - function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes memory, bytes32) { - // Get the nonce of the safe to sign - uint256 nonce = safeInstance.safe.nonce(); - - // Declare the dummy transaction params - ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ + /// @notice Helper to generate a dummy transaction + function _getDummyTxParams() internal pure returns (ExecTransactionParams memory) { + return ExecTransactionParams({ to: address(0xabba), value: 0, data: hex"acdc", @@ -76,34 +72,46 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { gasToken: address(0), refundReceiver: payable(address(0)) }); + } - // Get the tx hash - bytes32 txHash; - { - txHash = safeInstance.safe.getTransactionHash({ - to: dummyTxParams.to, - value: dummyTxParams.value, - data: dummyTxParams.data, - operation: dummyTxParams.operation, - safeTxGas: dummyTxParams.safeTxGas, - baseGas: dummyTxParams.baseGas, - gasPrice: dummyTxParams.gasPrice, - gasToken: dummyTxParams.gasToken, - refundReceiver: dummyTxParams.refundReceiver, - _nonce: nonce - }); - } - - // Sign the tx hash with the owners' private keys + /// @notice Helper to generate signatures for an arbitrary transaction + /// @param _txHash The transaction hash to sign + /// @param _numSignatures The number of signatures to generate + /// @return signatures The packed signatures for the transaction + function _getSignaturesForTx(bytes32 _txHash, uint256 _numSignatures) internal view returns (bytes memory) { bytes memory signatures = new bytes(0); - for (uint256 i; i < THRESHOLD; ++i) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeInstance.ownerPKs[i], txHash); + for (uint256 i; i < _numSignatures; ++i) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeInstance.ownerPKs[i], _txHash); // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} signatures = bytes.concat(signatures, abi.encodePacked(r, s, v)); } + return signatures; + } + + /// @notice Helper to create a dummy transaction with signatures and a tx hash + function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes32) { + // Get the nonce of the safe to sign + uint256 nonce = safeInstance.safe.nonce(); + + // Get the dummy transaction params + ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); - return (dummyTxParams, signatures, txHash); + // Get the tx hash + bytes32 txHash = safeInstance.safe.getTransactionHash({ + to: dummyTxParams.to, + value: dummyTxParams.value, + data: dummyTxParams.data, + operation: dummyTxParams.operation, + safeTxGas: dummyTxParams.safeTxGas, + baseGas: dummyTxParams.baseGas, + gasPrice: dummyTxParams.gasPrice, + gasToken: dummyTxParams.gasToken, + refundReceiver: dummyTxParams.refundReceiver, + _nonce: nonce + }); + + return (dummyTxParams, txHash); } /// @notice Helper to configure the TimelockGuard for a Safe @@ -239,7 +247,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { /// @title TimelockGuard_CancellationThreshold_Test /// @notice Tests for cancellationThreshold function contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { - function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external { + function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external view { uint256 threshold = timelockGuard.cancellationThreshold(address(unguardedSafe.safe)); assertEq(threshold, 0); } @@ -272,7 +280,8 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } function test_scheduleTransaction_succeeds() public { - (ExecTransactionParams memory dummyTxParams, bytes memory signatures, bytes32 txHash) = _getDummyTx(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); vm.expectEmit(true, true, true, true); emit TransactionScheduled(safeInstance.safe, txHash, INIT_TIME + TIMELOCK_DELAY); @@ -280,11 +289,14 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { - (ExecTransactionParams memory dummyTxParams, bytes memory signatures,) = _getDummyTx(); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + uint256 nonce = safeInstance.safe.nonce(); + + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); } function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { } @@ -305,9 +317,14 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Configure the guard and schedule a transaction _configureGuard(safeInstance, TIMELOCK_DELAY); (ExecTransactionParams memory dummyTxParams, bytes memory signatures, bytes32 txHash) = _getDummyTx(); + + function _scheduleTransaction() internal { + // Schedule a transaction + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); - // verify that the transaction is scheduled + // Confirm that the transaction is scheduled TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); assertEq(scheduledTransaction.executionTime, block.timestamp + TIMELOCK_DELAY); @@ -315,5 +332,24 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { assertEq(scheduledTransaction.executed, false); } - function test_cancelTransaction_succeeds() external { } + function test_cancelTransaction_succeeds() external { + _scheduleTransaction(); + + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + uint256 numSignatures = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + bytes memory signatures = _getSignaturesForTx(txHash, numSignatures); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), signatures); + + // Confirm that the transaction is cancelled + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); + assertEq(scheduledTransaction.cancelled, true); + } + + function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { + (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + uint256 nonce = safeInstance.safe.nonce(); + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, nonce, new bytes(0)); + } } From 227f9eb7c915dd5e54a3dbc612028a8051e4aeca Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 12:33:08 -0400 Subject: [PATCH 021/102] Test for TransactionCancelled event --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 6 ++++++ packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 97aef4260fe93..8ef93ea7ab336 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -55,6 +55,9 @@ contract TimelockGuard is IGuard, ISemver { /// @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 Emitted when a Safe configures the guard event GuardConfigured(address indexed safe, uint256 timelockDelay); @@ -264,6 +267,9 @@ contract TimelockGuard is IGuard, ISemver { if (scheduledTransactions[_safe][txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } + if (scheduledTransactions[_safe][txHash].executionTime == 0) { + revert TimelockGuard_TransactionNotScheduled(); + } // Generate the cancellation transaction data bytes memory cancellationTxData = diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index fdba84a94ffbf..292302ed6534b 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -22,6 +22,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { event GuardConfigured(address indexed safe, uint256 timelockDelay); event GuardCleared(address indexed safe); event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); + event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -299,7 +300,9 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); } - function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { } + function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { + // TODO: Implement once cancelTransaction is implemented and tested + } function test_scheduleTransaction_guardNotEnabled_reverts() external { } @@ -316,7 +319,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Configure the guard and schedule a transaction _configureGuard(safeInstance, TIMELOCK_DELAY); - (ExecTransactionParams memory dummyTxParams, bytes memory signatures, bytes32 txHash) = _getDummyTx(); + } function _scheduleTransaction() internal { // Schedule a transaction From e7f90ce9adf9e020bea90c329624a304b7d24e2c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 12:54:54 -0400 Subject: [PATCH 022/102] Further improve util functions --- .../test/safe/TimelockGuard.t.sol | 93 +++++++++---------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 292302ed6534b..5660b308dc2b7 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -60,25 +60,23 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { ); } - /// @notice Helper to generate a dummy transaction - function _getDummyTxParams() internal pure returns (ExecTransactionParams memory) { - return ExecTransactionParams({ - to: address(0xabba), - value: 0, - data: hex"acdc", - operation: Enum.Operation.Call, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)) + /// @notice Helper to generate the transaction hash for a given transaction params and nonce + function _getTxHash(ExecTransactionParams memory _params, uint256 _nonce) internal view returns (bytes32) { + return safeInstance.safe.getTransactionHash({ + to: _params.to, + value: _params.value, + data: _params.data, + operation: _params.operation, + safeTxGas: _params.safeTxGas, + baseGas: _params.baseGas, + gasPrice: _params.gasPrice, + gasToken: _params.gasToken, + refundReceiver: _params.refundReceiver, + _nonce: _nonce }); } /// @notice Helper to generate signatures for an arbitrary transaction - /// @param _txHash The transaction hash to sign - /// @param _numSignatures The number of signatures to generate - /// @return signatures The packed signatures for the transaction function _getSignaturesForTx(bytes32 _txHash, uint256 _numSignatures) internal view returns (bytes memory) { bytes memory signatures = new bytes(0); for (uint256 i; i < _numSignatures; ++i) { @@ -90,29 +88,23 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return signatures; } - /// @notice Helper to create a dummy transaction with signatures and a tx hash - function _getDummyTx() internal view returns (ExecTransactionParams memory, bytes32) { - // Get the nonce of the safe to sign + /// @notice Helper to generate everything needed to schedule a transaction for the current nonce + function _getDummyTxWithSignaturesAndHash() internal view returns (ExecTransactionParams memory, bytes32, bytes memory) { uint256 nonce = safeInstance.safe.nonce(); - - // Get the dummy transaction params - ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); - - // Get the tx hash - bytes32 txHash = safeInstance.safe.getTransactionHash({ - to: dummyTxParams.to, - value: dummyTxParams.value, - data: dummyTxParams.data, - operation: dummyTxParams.operation, - safeTxGas: dummyTxParams.safeTxGas, - baseGas: dummyTxParams.baseGas, - gasPrice: dummyTxParams.gasPrice, - gasToken: dummyTxParams.gasToken, - refundReceiver: dummyTxParams.refundReceiver, - _nonce: nonce + ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ + to: address(0xabba), + value: 0, + data: hex"acdc", + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)) }); - - return (dummyTxParams, txHash); + bytes32 txHash = _getTxHash(dummyTxParams, nonce); + bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + return (dummyTxParams, txHash, signatures); } /// @notice Helper to configure the TimelockGuard for a Safe @@ -281,8 +273,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } function test_scheduleTransaction_succeeds() public { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); - bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); vm.expectEmit(true, true, true, true); emit TransactionScheduled(safeInstance.safe, txHash, INIT_TIME + TIMELOCK_DELAY); @@ -292,8 +283,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { uint256 nonce = safeInstance.safe.nonce(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); - bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); @@ -302,7 +292,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { // TODO: Implement once cancelTransaction is implemented and tested - } + } function test_scheduleTransaction_guardNotEnabled_reverts() external { } @@ -322,9 +312,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function _scheduleTransaction() internal { - // Schedule a transaction - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); - bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); // Confirm that the transaction is scheduled @@ -335,13 +323,20 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { assertEq(scheduledTransaction.executed, false); } - function test_cancelTransaction_succeeds() external { + function test_cancelTransaction_withPrivKeySignature_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); uint256 numSignatures = timelockGuard.cancellationThreshold(address(safeInstance.safe)); - bytes memory signatures = _getSignaturesForTx(txHash, numSignatures); - timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), signatures); + bytes memory cancelSignatures = _getSignaturesForTx(txHash, numSignatures); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); + + // Confirm that the transaction is cancelled + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); + assertEq(scheduledTransaction.cancelled, true); + } + // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = @@ -350,7 +345,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash) = _getDummyTx(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); uint256 nonce = safeInstance.safe.nonce(); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, nonce, new bytes(0)); From 5b1cfccdb020366f169720418ba6d5bd21d9fb7f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 12:55:08 -0400 Subject: [PATCH 023/102] Add approve hash test case --- .../contracts-bedrock/test/safe/TimelockGuard.t.sol | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 5660b308dc2b7..ea02cedf7fd93 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -337,6 +337,18 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { assertEq(scheduledTransaction.cancelled, true); } + function test_cancelTransaction_withApproveHash_succeeds() external { + _scheduleTransaction(); + + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + address owner = safeInstance.safe.getOwners()[0]; + + vm.prank(owner); + safeInstance.safe.approveHash(txHash); + + // Generate a prevalidated signature + bytes memory cancelSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = From 756cdff3388b23591432be709543475961006059 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 12:56:43 -0400 Subject: [PATCH 024/102] fix warnings --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 3 +-- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 8ef93ea7ab336..6d9837b80a75d 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -154,9 +154,8 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Returns the blocking threshold threshold for a given safe /// @dev MUST NOT revert - /// @param _safe The Safe address to query /// @return The current blocking threshold - function blockingThreshold(address _safe) public view returns (uint256) { + function blockingThreshold(address /* _safe */) public pure returns (uint256) { return 0; // return min(quorum, total_owners - quorum + 1) for _safe; } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index ea02cedf7fd93..ddafcabc5fcde 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -283,7 +283,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { uint256 nonce = safeInstance.safe.nonce(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); @@ -326,7 +326,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withPrivKeySignature_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(); uint256 numSignatures = timelockGuard.cancellationThreshold(address(safeInstance.safe)); bytes memory cancelSignatures = _getSignaturesForTx(txHash, numSignatures); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); @@ -340,7 +340,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withApproveHash_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(); address owner = safeInstance.safe.getOwners()[0]; vm.prank(owner); @@ -357,7 +357,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams,,) = _getDummyTxWithSignaturesAndHash(); uint256 nonce = safeInstance.safe.nonce(); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, nonce, new bytes(0)); From bac2f8a60ba58d198a03a758bc5f6e4a242968bb Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 13:14:46 -0400 Subject: [PATCH 025/102] Use correct typing for Safe addresses This is applied to function inputs/outputs as well as mappings and events --- .../src/safe/TimelockGuard.sol | 67 +++++++++++-------- .../test/safe/TimelockGuard.t.sol | 56 ++++++++++------ 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 6d9837b80a75d..ed652d544d0e3 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -29,13 +29,13 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Mapping from Safe address to its guard configuration - mapping(address => GuardConfig) public safeConfigs; + mapping(Safe => GuardConfig) public safeConfigs; /// @notice Mapping from Safe and tx id to scheduled transaction. mapping(Safe => mapping(bytes32 => ScheduledTransaction)) internal scheduledTransactions; /// @notice Mapping from Safe to cancellation threshold. - mapping(address => uint256) internal safeCancellationThreshold; + mapping(Safe => uint256) internal safeCancellationThreshold; /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -59,10 +59,10 @@ contract TimelockGuard is IGuard, ISemver { error TimelockGuard_TransactionNotScheduled(); /// @notice Emitted when a Safe configures the guard - event GuardConfigured(address indexed safe, uint256 timelockDelay); + event GuardConfigured(Safe indexed safe, uint256 timelockDelay); /// @notice Emitted when a Safe clears the guard configuration - event GuardCleared(address indexed safe); + event GuardCleared(Safe indexed safe); /// @notice Emitted when a transaction is scheduled for a Safe. /// @param safe The Safe whose transaction is scheduled. @@ -78,7 +78,7 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The timelock delay in seconds - function viewTimelockGuardConfiguration(address _safe) public view returns (uint256) { + function viewTimelockGuardConfiguration(Safe _safe) public view returns (uint256) { return safeConfigs[_safe].timelockDelay; } @@ -98,23 +98,24 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST emit a GuardConfigured event with at least timelock_delay as a parameter /// @param _timelockDelay The timelock delay in seconds function configureTimelockGuard(uint256 _timelockDelay) external { + Safe callingSafe = Safe(payable(msg.sender)); // Validate timelock delay - must be non-zero and not longer than 1 year if (_timelockDelay == 0 || _timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); } // Check that this guard is enabled on the calling Safe - if (!_isGuardEnabled(msg.sender)) { + if (!_isGuardEnabled(callingSafe)) { revert TimelockGuard_GuardNotEnabled(); } // Store the configuration for this safe - safeConfigs[msg.sender].timelockDelay = _timelockDelay; + safeConfigs[callingSafe].timelockDelay = _timelockDelay; // Initialize cancellation threshold to 1 - safeCancellationThreshold[msg.sender] = 1; + safeCancellationThreshold[callingSafe] = 1; - emit GuardConfigured(msg.sender, _timelockDelay); + emit GuardConfigured(callingSafe, _timelockDelay); } /// @notice Remove the timelock guard configuration by a previously enabled Safe @@ -122,20 +123,21 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST erase the existing timelock_delay data related to the calling Safe /// @dev MUST emit a GuardCleared event function clearTimelockGuard() external { + Safe callingSafe = Safe(payable(msg.sender)); // Check if the calling safe has configuration set - if (safeConfigs[msg.sender].timelockDelay == 0) { + if (safeConfigs[callingSafe].timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } // Check that this guard is NOT enabled on the calling Safe - if (_isGuardEnabled(msg.sender)) { + if (_isGuardEnabled(callingSafe)) { revert TimelockGuard_GuardStillEnabled(); } // Erase the configuration data for this safe - delete safeConfigs[msg.sender]; + delete safeConfigs[callingSafe]; - emit GuardCleared(msg.sender); + emit GuardCleared(callingSafe); } /// @notice Returns the cancellation threshold for a given safe @@ -143,7 +145,7 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST return 0 if the contract is not enabled as a guard for the safe /// @param _safe The Safe address to query /// @return The current cancellation threshold - function cancellationThreshold(address _safe) public view returns (uint256) { + function cancellationThreshold(Safe _safe) public view returns (uint256) { // Return 0 if guard is not enabled if (!_isGuardEnabled(_safe)) { return 0; @@ -155,7 +157,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Returns the blocking threshold threshold for a given safe /// @dev MUST NOT revert /// @return The current blocking threshold - function blockingThreshold(address /* _safe */) public pure returns (uint256) { + function blockingThreshold(address /* _safe */ ) public pure returns (uint256) { return 0; // return min(quorum, total_owners - quorum + 1) for _safe; } @@ -163,11 +165,10 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Internal helper to get the guard address from a Safe /// @param _safe The Safe address /// @return The current guard address - function _isGuardEnabled(address _safe) internal view returns (bool) { + function _isGuardEnabled(Safe _safe) internal view returns (bool) { // keccak256("guard_manager.guard.address") from GuardManager bytes32 guardSlot = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; - Safe safe = Safe(payable(_safe)); - address guard = abi.decode(safe.getStorageAt(uint256(guardSlot), 1), (address)); + address guard = abi.decode(_safe.getStorageAt(uint256(guardSlot), 1), (address)); return guard == address(this); } @@ -175,9 +176,16 @@ contract TimelockGuard is IGuard, ISemver { /// @dev Minimal implementation: checks enabled+configured, uniqueness, cancellation, stores execution time and /// emits. /// @dev The txId is computed independent of Safe nonce using all exec params (with keccak(data)). - function scheduleTransaction(Safe _safe, uint256 _nonce, ExecTransactionParams memory _params, bytes memory _signatures) external { + 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(address(_safe))) { + if (!_isGuardEnabled(_safe)) { revert TimelockGuard_GuardNotEnabled(); } @@ -224,7 +232,7 @@ contract TimelockGuard is IGuard, ISemver { } // Calculate the execution time - uint256 executionTime = block.timestamp + safeConfigs[address(_safe)].timelockDelay; + uint256 executionTime = block.timestamp + safeConfigs[_safe].timelockDelay; // Schedule the transaction scheduledTransactions[_safe][txHash] = @@ -249,7 +257,14 @@ contract TimelockGuard is IGuard, ISemver { /// Thus in order for an owners to sign their cancellation transaction, they must first sign /// an empty transaction at the same nonce, or call approveHash on the Safe for that /// transaction hash. - function cancelTransaction(Safe _safe, ExecTransactionParams memory _params, uint256 _nonce, bytes memory _signatures) external { + function cancelTransaction( + Safe _safe, + ExecTransactionParams memory _params, + uint256 _nonce, + bytes memory _signatures + ) + external + { // Calculate the transaction hash bytes32 txHash = _safe.getTransactionHash( _params.to, @@ -277,23 +292,21 @@ contract TimelockGuard is IGuard, ISemver { _safe.getTransactionHash(address(0), 0, "", 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[address(_safe)] - ); + _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, safeCancellationThreshold[_safe]); scheduledTransactions[_safe][txHash].cancelled = true; } /// @notice Increase the cancellation threshold for a safe /// @dev This function must be caled only once and only when calling cancel - function increaseCancellationThresholds(address _safe, bytes32 txHash) internal pure { + function increaseCancellationThresholds(Safe _safe, bytes32 txHash) internal pure { // if safeCancellationThreshold[_safe] < blockingThreshold(_safe) // 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(address _safe, bytes32 txHash) external pure { + function resetCancellationThreshold(Safe _safe, bytes32 txHash) external pure { // safeCancellationThreshold[_safe] = 1 } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index ddafcabc5fcde..eb378283e22a9 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -19,8 +19,8 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { using SafeTestLib for SafeInstance; // Events - event GuardConfigured(address indexed safe, uint256 timelockDelay); - event GuardCleared(address indexed safe); + event GuardConfigured(Safe indexed safe, uint256 timelockDelay); + event GuardCleared(Safe indexed safe); event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); @@ -31,7 +31,12 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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; function setUp() public virtual { @@ -45,6 +50,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // 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. @@ -89,7 +95,11 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate everything needed to schedule a transaction for the current nonce - function _getDummyTxWithSignaturesAndHash() internal view returns (ExecTransactionParams memory, bytes32, bytes memory) { + function _getDummyTxWithSignaturesAndHash() + internal + view + returns (ExecTransactionParams memory, bytes32, bytes memory) + { uint256 nonce = safeInstance.safe.nonce(); ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ to: address(0xabba), @@ -137,7 +147,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Tests for viewTimelockGuardConfiguration function contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { - uint256 delay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + uint256 delay = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); assertEq(delay, 0); } } @@ -147,11 +157,11 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_succeeds() external { vm.expectEmit(true, true, true, true); - emit GuardConfigured(address(safeInstance.safe), TIMELOCK_DELAY); + emit GuardConfigured(safe, TIMELOCK_DELAY); _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(safe); assertEq(storedDelay, TIMELOCK_DELAY); } @@ -171,26 +181,26 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_acceptsMaxValidDelay_succeeds() external { vm.expectEmit(true, true, true, true); - emit GuardConfigured(address(safeInstance.safe), ONE_YEAR); + emit GuardConfigured(safe, ONE_YEAR); _configureGuard(safeInstance, ONE_YEAR); - uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)); + uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(safe); assertEq(storedDelay, ONE_YEAR); } function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), TIMELOCK_DELAY); // Reconfigure with different delay uint256 newDelay = 14 days; vm.expectEmit(true, true, true, true); - emit GuardConfigured(address(safeInstance.safe), newDelay); + emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), newDelay); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), newDelay); } } @@ -200,21 +210,21 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { function test_clearTimelockGuard_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), TIMELOCK_DELAY); // Disable the guard first _disableGuard(safeInstance); // Clear should succeed and emit event vm.expectEmit(true, true, true, true); - emit GuardCleared(address(safeInstance.safe)); + emit GuardCleared(safe); _clearGuard(safeInstance); // Configuration should be cleared - assertEq(timelockGuard.viewTimelockGuardConfiguration(address(safeInstance.safe)), 0); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), 0); // Ensure cancellation threshold is reset to 0 - assertEq(timelockGuard.cancellationThreshold(address(safeInstance.safe)), 0); + assertEq(timelockGuard.cancellationThreshold(safe), 0); // TODO: Check that any active challenge is cancelled } @@ -241,13 +251,13 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { /// @notice Tests for cancellationThreshold function contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { function test_cancellationThreshold_returnsZeroIfGuardNotEnabled_succeeds() external view { - uint256 threshold = timelockGuard.cancellationThreshold(address(unguardedSafe.safe)); + uint256 threshold = timelockGuard.cancellationThreshold(Safe(payable(unguardedSafe.safe))); assertEq(threshold, 0); } function test_cancellationThreshold_returnsZeroIfGuardNotConfigured_succeeds() external view { // Safe with guard enabled but not configured should return 0 - uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + uint256 threshold = timelockGuard.cancellationThreshold(safe); assertEq(threshold, 0); } @@ -256,7 +266,7 @@ contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); // Should default to 1 after configuration - uint256 threshold = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + uint256 threshold = timelockGuard.cancellationThreshold(safe); assertEq(threshold, 1); } @@ -273,10 +283,11 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } function test_scheduleTransaction_succeeds() public { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(); vm.expectEmit(true, true, true, true); - emit TransactionScheduled(safeInstance.safe, txHash, INIT_TIME + TIMELOCK_DELAY); + emit TransactionScheduled(safe, txHash, INIT_TIME + TIMELOCK_DELAY); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); } @@ -312,7 +323,8 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function _scheduleTransaction() internal { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); // Confirm that the transaction is scheduled @@ -327,7 +339,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { _scheduleTransaction(); (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(); - uint256 numSignatures = timelockGuard.cancellationThreshold(address(safeInstance.safe)); + uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); bytes memory cancelSignatures = _getSignaturesForTx(txHash, numSignatures); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); From e92ed3f211660384167daedc60e1912c947bea7e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 15:33:43 -0400 Subject: [PATCH 026/102] Add additional scheduleTransaction tests --- .../test/safe/TimelockGuard.t.sol | 105 ++++++++++++++---- 1 file changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index eb378283e22a9..50013fb8413fd 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -67,8 +67,8 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate the transaction hash for a given transaction params and nonce - function _getTxHash(ExecTransactionParams memory _params, uint256 _nonce) internal view returns (bytes32) { - return safeInstance.safe.getTransactionHash({ + function _getTxHash(SafeInstance memory _safeInstance, ExecTransactionParams memory _params, uint256 _nonce) internal view returns (bytes32) { + return _safeInstance.safe.getTransactionHash({ to: _params.to, value: _params.value, data: _params.data, @@ -83,10 +83,10 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate signatures for an arbitrary transaction - function _getSignaturesForTx(bytes32 _txHash, uint256 _numSignatures) internal view returns (bytes memory) { + function _getSignaturesForTx(SafeInstance memory _safeInstance, bytes32 _txHash, uint256 _numSignatures) internal pure returns (bytes memory) { bytes memory signatures = new bytes(0); for (uint256 i; i < _numSignatures; ++i) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeInstance.ownerPKs[i], _txHash); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_safeInstance.ownerPKs[i], _txHash); // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} signatures = bytes.concat(signatures, abi.encodePacked(r, s, v)); @@ -94,14 +94,9 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return signatures; } - /// @notice Helper to generate everything needed to schedule a transaction for the current nonce - function _getDummyTxWithSignaturesAndHash() - internal - view - returns (ExecTransactionParams memory, bytes32, bytes memory) - { - uint256 nonce = safeInstance.safe.nonce(); - ExecTransactionParams memory dummyTxParams = ExecTransactionParams({ + /// @notice Helper to generate dummy transaction parameters + function _getDummyTxParams() internal pure returns (ExecTransactionParams memory) { + return ExecTransactionParams({ to: address(0xabba), value: 0, data: hex"acdc", @@ -112,8 +107,18 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { gasToken: address(0), refundReceiver: payable(address(0)) }); - bytes32 txHash = _getTxHash(dummyTxParams, nonce); - bytes memory signatures = _getSignaturesForTx(txHash, THRESHOLD); + } + + /// @notice Helper to generate everything needed to schedule a transaction for the current nonce + function _getDummyTxWithSignaturesAndHash(SafeInstance memory _safe) + internal + view + returns (ExecTransactionParams memory, bytes32, bytes memory) + { + uint256 nonce = _safe.safe.nonce(); + ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); + bytes32 txHash = _getTxHash(_safe, dummyTxParams, nonce); + bytes memory signatures = _getSignaturesForTx(_safe, txHash, THRESHOLD); return (dummyTxParams, txHash, signatures); } @@ -135,6 +140,13 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { ); } + /// @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))), Enum.Operation.Call + ); + } + /// @notice Helper to clear the TimelockGuard configuration for a Safe function _clearGuard(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( @@ -284,17 +296,35 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_succeeds() public { (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(); + _getDummyTxWithSignaturesAndHash(safeInstance); vm.expectEmit(true, true, true, true); emit TransactionScheduled(safe, txHash, INIT_TIME + TIMELOCK_DELAY); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); } + // A test which demonstrates that if the guard is enabled but not explicitly configured, + // the timelock delay is set to 0. + function test_scheduleTransaction_guardNotConfigured_succeeds() external { + // Enable the guard on the unguarded Safe, but don't configure it + _enableGuard(unguardedSafe); + assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe), 0); + + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, ) = + _getDummyTxWithSignaturesAndHash(unguardedSafe); + + bytes memory signatures = _getSignaturesForTx(unguardedSafe, txHash, THRESHOLD - 1); + + uint256 nonce = unguardedSafe.safe.nonce(); + vm.expectEmit(true, true, true, true); + emit TransactionScheduled(unguardedSafe.safe, txHash, INIT_TIME + 0); + timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, dummyTxParams, signatures); + } + function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { uint256 nonce = safeInstance.safe.nonce(); - (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(safeInstance); timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); @@ -305,11 +335,38 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { // TODO: Implement once cancelTransaction is implemented and tested } - function test_scheduleTransaction_guardNotEnabled_reverts() external { } + function test_scheduleTransaction_guardNotEnabled_reverts() external { + // Attempt to schedule a transaction with a Safe that has not enabled the guard + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); + timelockGuard.scheduleTransaction(unguardedSafe.safe, unguardedSafe.safe.nonce(), _getDummyTxParams(), ""); + } - function test_scheduleTransaction_guardNotConfigured_reverts() external { } + function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { + // Schedule a transaction with a specific nonce + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(safeInstance); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); - function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { } + // Schedule an identical transaction with a different nonce (salt) + uint256 newNonce = safeInstance.safe.nonce() + 1; + bytes32 newTxHash = safeInstance.safe.getTransactionHash({ + to: dummyTxParams.to, + value: dummyTxParams.value, + data: dummyTxParams.data, + operation: dummyTxParams.operation, + safeTxGas: dummyTxParams.safeTxGas, + baseGas: dummyTxParams.baseGas, + gasPrice: dummyTxParams.gasPrice, + gasToken: dummyTxParams.gasToken, + refundReceiver: dummyTxParams.refundReceiver, + _nonce: newNonce + }); + bytes memory newSignatures = _getSignaturesForTx(safeInstance, newTxHash, THRESHOLD); + + vm.expectEmit(true, true, true, true); + emit TransactionScheduled(safe, newTxHash, INIT_TIME + TIMELOCK_DELAY); + timelockGuard.scheduleTransaction(safeInstance.safe, newNonce, dummyTxParams, newSignatures); + } } /// @title TimelockGuard_CancelTransaction_Test @@ -324,7 +381,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function _scheduleTransaction() internal { (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(); + _getDummyTxWithSignaturesAndHash(safeInstance); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); // Confirm that the transaction is scheduled @@ -338,9 +395,9 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withPrivKeySignature_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - bytes memory cancelSignatures = _getSignaturesForTx(txHash, numSignatures); + bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, txHash, numSignatures); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); // Confirm that the transaction is cancelled @@ -352,7 +409,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withApproveHash_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); address owner = safeInstance.safe.getOwners()[0]; vm.prank(owner); @@ -369,7 +426,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { - (ExecTransactionParams memory dummyTxParams,,) = _getDummyTxWithSignaturesAndHash(); + (ExecTransactionParams memory dummyTxParams,,) = _getDummyTxWithSignaturesAndHash(safeInstance); uint256 nonce = safeInstance.safe.nonce(); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, nonce, new bytes(0)); From 18f54e6a08f99ab4ad118bfaeb0ce8bc2ae5f808 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 15:34:06 -0400 Subject: [PATCH 027/102] Enable specifying which safeInstance in utility functions --- .../test/safe/TimelockGuard.t.sol | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 50013fb8413fd..1f25cd40d34fa 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -67,7 +67,15 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate the transaction hash for a given transaction params and nonce - function _getTxHash(SafeInstance memory _safeInstance, ExecTransactionParams memory _params, uint256 _nonce) internal view returns (bytes32) { + function _getTxHash( + SafeInstance memory _safeInstance, + ExecTransactionParams memory _params, + uint256 _nonce + ) + internal + view + returns (bytes32) + { return _safeInstance.safe.getTransactionHash({ to: _params.to, value: _params.value, @@ -83,7 +91,15 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate signatures for an arbitrary transaction - function _getSignaturesForTx(SafeInstance memory _safeInstance, bytes32 _txHash, uint256 _numSignatures) internal pure returns (bytes memory) { + function _getSignaturesForTx( + SafeInstance memory _safeInstance, + bytes32 _txHash, + uint256 _numSignatures + ) + internal + pure + returns (bytes memory) + { bytes memory signatures = new bytes(0); for (uint256 i; i < _numSignatures; ++i) { (uint8 v, bytes32 r, bytes32 s) = vm.sign(_safeInstance.ownerPKs[i], _txHash); @@ -143,7 +159,11 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @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))), Enum.Operation.Call + _safe, + address(_safe.safe), + 0, + abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))), + Enum.Operation.Call ); } @@ -310,8 +330,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { _enableGuard(unguardedSafe); assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe), 0); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, ) = - _getDummyTxWithSignaturesAndHash(unguardedSafe); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(unguardedSafe); bytes memory signatures = _getSignaturesForTx(unguardedSafe, txHash, THRESHOLD - 1); @@ -324,7 +343,8 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { uint256 nonce = safeInstance.safe.nonce(); - (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(safeInstance); + (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(safeInstance); timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); @@ -343,7 +363,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { // Schedule a transaction with a specific nonce - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(safeInstance); timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); From 6782b1b8cea9dd9327b8989392bbd125e459b95f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 16:29:18 -0400 Subject: [PATCH 028/102] Change cancelTransaction to accept a tx hash --- .../src/safe/TimelockGuard.sol | 22 +----- .../test/safe/TimelockGuard.t.sol | 78 +++++++++++++------ 2 files changed, 58 insertions(+), 42 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ed652d544d0e3..7b1c2060b0fbf 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -249,7 +249,6 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Cancel a scheduled transaction if cancellation threshold is met - /// @dev NOT IMPLEMENTED YET /// @dev This function aims to mimic the approach which would be used by a quorum of signers to /// cancel a partially signed transaction, which would be to sign and execute an empty /// transaction at the same nonce. This enables us to deterministically generate the @@ -259,29 +258,16 @@ contract TimelockGuard is IGuard, ISemver { /// transaction hash. function cancelTransaction( Safe _safe, - ExecTransactionParams memory _params, + bytes32 _txHash, uint256 _nonce, bytes memory _signatures ) external { - // Calculate the transaction hash - bytes32 txHash = _safe.getTransactionHash( - _params.to, - _params.value, - _params.data, - _params.operation, - _params.safeTxGas, - _params.baseGas, - _params.gasPrice, - _params.gasToken, - _params.refundReceiver, - _nonce - ); - if (scheduledTransactions[_safe][txHash].cancelled) { + if (scheduledTransactions[_safe][_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } - if (scheduledTransactions[_safe][txHash].executionTime == 0) { + if (scheduledTransactions[_safe][_txHash].executionTime == 0) { revert TimelockGuard_TransactionNotScheduled(); } @@ -294,7 +280,7 @@ contract TimelockGuard is IGuard, ISemver { // This function call reverts if the signatures are invalid. _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, safeCancellationThreshold[_safe]); - scheduledTransactions[_safe][txHash].cancelled = true; + scheduledTransactions[_safe][_txHash].cancelled = true; } /// @notice Increase the cancellation threshold for a safe diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 1f25cd40d34fa..f110587124d15 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -138,6 +138,18 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return (dummyTxParams, txHash, signatures); } + function _getCancellationTx(SafeInstance memory _safe) + internal + pure + returns (ExecTransactionParams memory) + { + ExecTransactionParams memory cancellationTxParams = ExecTransactionParams( + address(0), 0, hex"", Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)) + ); + + return cancellationTxParams; + } + /// @notice Helper to configure the TimelockGuard for a Safe function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { SafeTestLib.execTransaction( @@ -357,11 +369,12 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_guardNotEnabled_reverts() external { // Attempt to schedule a transaction with a Safe that has not enabled the guard + uint256 nonce = unguardedSafe.safe.nonce(); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); - timelockGuard.scheduleTransaction(unguardedSafe.safe, unguardedSafe.safe.nonce(), _getDummyTxParams(), ""); + timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, _getDummyTxParams(), ""); } - function test_scheduleTransaction_canScheduleIdenticalWithSalt_succeeds() external { + function test_scheduleTransaction_canScheduleIdenticalWithDifferentNonce_succeeds() external { // Schedule a transaction with a specific nonce (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(safeInstance); @@ -369,18 +382,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { // Schedule an identical transaction with a different nonce (salt) uint256 newNonce = safeInstance.safe.nonce() + 1; - bytes32 newTxHash = safeInstance.safe.getTransactionHash({ - to: dummyTxParams.to, - value: dummyTxParams.value, - data: dummyTxParams.data, - operation: dummyTxParams.operation, - safeTxGas: dummyTxParams.safeTxGas, - baseGas: dummyTxParams.baseGas, - gasPrice: dummyTxParams.gasPrice, - gasToken: dummyTxParams.gasToken, - refundReceiver: dummyTxParams.refundReceiver, - _nonce: newNonce - }); + bytes32 newTxHash = _getTxHash(safeInstance, dummyTxParams, newNonce); bytes memory newSignatures = _getSignaturesForTx(safeInstance, newTxHash, THRESHOLD); vm.expectEmit(true, true, true, true); @@ -415,10 +417,20 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withPrivKeySignature_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); + // Get the transaction hash + (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); + + // Get the nonce + uint256 nonce = safeInstance.safe.nonce(); + + // Get the cancellation signatures uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, txHash, numSignatures); - timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(safeInstance); + bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); + bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); + + // Cancel the transaction + timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = @@ -429,15 +441,28 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { function test_cancelTransaction_withApproveHash_succeeds() external { _scheduleTransaction(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); + // Get the transaction hash + (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); + + // Get the nonce + uint256 nonce = safeInstance.safe.nonce(); + + // Get the cancellation transaction hash + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(safeInstance); + bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); + + // Get the owner address owner = safeInstance.safe.getOwners()[0]; + // Approve the cancellation transaction hash vm.prank(owner); - safeInstance.safe.approveHash(txHash); + safeInstance.safe.approveHash(cancellationTxHash); + + // Encode the prevalidated cancellation signature + bytes memory signatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); - // Generate a prevalidated signature - bytes memory cancelSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); - timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, safeInstance.safe.nonce(), cancelSignatures); + // Cancel the transaction + timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, signatures); // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = @@ -446,9 +471,14 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { } function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { - (ExecTransactionParams memory dummyTxParams,,) = _getDummyTxWithSignaturesAndHash(safeInstance); + // Get the transaction hash + (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); + + // Get the cancellation signatures uint256 nonce = safeInstance.safe.nonce(); + + // Attempt to cancel the transaction vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); - timelockGuard.cancelTransaction(safeInstance.safe, dummyTxParams, nonce, new bytes(0)); + timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, new bytes(0)); } } From 4fccd48d597e651d6cc81351b2f8214a1c16a080 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 16:49:45 -0400 Subject: [PATCH 029/102] Add increaseCancellationThreshold to cancelTransaction --- .../src/safe/TimelockGuard.sol | 23 ++++++++++++++----- .../test/safe/TimelockGuard.t.sol | 11 ++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 7b1c2060b0fbf..a913a6970778f 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -70,6 +70,11 @@ contract TimelockGuard is IGuard, ISemver { /// @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, uint256 newCancellationThreshold); + /// @notice Semantic version. /// @custom:semver 1.0.0 string public constant version = "1.0.0"; @@ -157,8 +162,9 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Returns the blocking threshold threshold for a given safe /// @dev MUST NOT revert /// @return The current blocking threshold - function blockingThreshold(address /* _safe */ ) public pure returns (uint256) { - return 0; + function blockingThreshold(Safe /* _safe */ ) public pure returns (uint256) { + // TODO: Implement this + return 10; // return min(quorum, total_owners - quorum + 1) for _safe; } @@ -276,23 +282,28 @@ contract TimelockGuard is IGuard, ISemver { _safe.encodeTransactionData(address(0), 0, "", Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); bytes32 cancellationTxHash = _safe.getTransactionHash(address(0), 0, "", 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; + increaseCancellationThreshold(_safe, _txHash); + + emit TransactionCancelled(_safe, _txHash, safeCancellationThreshold[_safe]); } /// @notice Increase the cancellation threshold for a safe /// @dev This function must be caled only once and only when calling cancel - function increaseCancellationThresholds(Safe _safe, bytes32 txHash) internal pure { - // if safeCancellationThreshold[_safe] < blockingThreshold(_safe) - // safeCancellationThreshold[_safe]++ + function increaseCancellationThreshold(Safe _safe, bytes32 _txHash) internal { + if (safeCancellationThreshold[_safe] < blockingThreshold(_safe)) { + 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, bytes32 txHash) external pure { + function resetCancellationThreshold(Safe _safe, bytes32 _txHash) external pure { // safeCancellationThreshold[_safe] = 1 } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index f110587124d15..ca683282a41f2 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -22,7 +22,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { event GuardConfigured(Safe indexed safe, uint256 timelockDelay); event GuardCleared(Safe indexed safe); event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); - event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); + event TransactionCancelled(Safe indexed safe, bytes32 indexed txId, uint256 newCancellationThreshold); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -401,6 +401,8 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); } + /// @notice Helper to schedule a transaction in order to test cancelTransaction. + /// Will always schedule the dummy transaction using the Safe's current nonce. function _scheduleTransaction() internal { (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = _getDummyTxWithSignaturesAndHash(safeInstance); @@ -430,6 +432,8 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); // Cancel the transaction + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(safeInstance.safe, txHash, numSignatures + 1); timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); // Confirm that the transaction is cancelled @@ -461,7 +465,12 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Encode the prevalidated cancellation signature bytes memory signatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); + // Get the cancellation threshold + uint256 cancellationThreshold = timelockGuard.cancellationThreshold(safeInstance.safe); + // Cancel the transaction + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(safeInstance.safe, txHash, cancellationThreshold + 1); timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, signatures); // Confirm that the transaction is cancelled From b792e042b1cb5fb2d35b8933b11231ca814b849b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 15 Sep 2025 17:00:51 -0400 Subject: [PATCH 030/102] Add configured boolean to guard config --- .../src/safe/TimelockGuard.sol | 6 ++- .../test/safe/TimelockGuard.t.sol | 39 ++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a913a6970778f..00db04e13e897 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -19,6 +19,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Configuration for a Safe's timelock guard struct GuardConfig { uint256 timelockDelay; + bool configured; } /// @notice Scheduled transaction @@ -83,8 +84,8 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The timelock delay in seconds - function viewTimelockGuardConfiguration(Safe _safe) public view returns (uint256) { - return safeConfigs[_safe].timelockDelay; + function viewTimelockGuardConfiguration(Safe _safe) public view returns (GuardConfig memory) { + return safeConfigs[_safe]; } /// @notice Returns the scheduled transaction for a given Safe and tx hash @@ -116,6 +117,7 @@ contract TimelockGuard is IGuard, ISemver { // Store the configuration for this safe safeConfigs[callingSafe].timelockDelay = _timelockDelay; + safeConfigs[callingSafe].configured = true; // Initialize cancellation threshold to 1 safeCancellationThreshold[callingSafe] = 1; diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index ca683282a41f2..1679877bbc96e 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -138,7 +138,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return (dummyTxParams, txHash, signatures); } - function _getCancellationTx(SafeInstance memory _safe) + function _getCancellationTx() internal pure returns (ExecTransactionParams memory) @@ -191,8 +191,16 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Tests for viewTimelockGuardConfiguration function contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { - uint256 delay = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); - assertEq(delay, 0); + TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); + assertEq(config.timelockDelay, 0); + assertEq(config.configured, false); + } + + function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { + _configureGuard(safeInstance, TIMELOCK_DELAY); + TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); + assertEq(config.timelockDelay, TIMELOCK_DELAY); + assertEq(config.configured, true); } } @@ -205,8 +213,9 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(safe); - assertEq(storedDelay, TIMELOCK_DELAY); + TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); + assertEq(config.timelockDelay, TIMELOCK_DELAY); + assertEq(config.configured, true); } function test_configureTimelockGuard_revertsIfGuardNotEnabled_reverts() external { @@ -229,14 +238,15 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - uint256 storedDelay = timelockGuard.viewTimelockGuardConfiguration(safe); - assertEq(storedDelay, ONE_YEAR); + TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); + assertEq(config.timelockDelay, ONE_YEAR); + assertEq(config.configured, true); } function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); // Reconfigure with different delay uint256 newDelay = 14 days; @@ -244,7 +254,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), newDelay); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, newDelay); } } @@ -254,7 +264,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { function test_clearTimelockGuard_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); // Disable the guard first _disableGuard(safeInstance); @@ -266,7 +276,8 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { _clearGuard(safeInstance); // Configuration should be cleared - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe), 0); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, 0); + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).configured, false); // Ensure cancellation threshold is reset to 0 assertEq(timelockGuard.cancellationThreshold(safe), 0); @@ -340,7 +351,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_guardNotConfigured_succeeds() external { // Enable the guard on the unguarded Safe, but don't configure it _enableGuard(unguardedSafe); - assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe), 0); + assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe).timelockDelay, 0); (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(unguardedSafe); @@ -427,7 +438,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Get the cancellation signatures uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(safeInstance); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(); bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); @@ -452,7 +463,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { uint256 nonce = safeInstance.safe.nonce(); // Get the cancellation transaction hash - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(safeInstance); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(); bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); // Get the owner From 3f407e1825543e2362ea771a7ba6730efaa173a3 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 16 Sep 2025 16:52:29 -0400 Subject: [PATCH 031/102] Fix signature reuse vulnerability in cancelTransaction Include transaction hash in cancellation signature data to prevent signatures from being reused across different transactions with the same nonce. Updates both contract implementation and tests. --- .../src/safe/TimelockGuard.sol | 23 +++++++++++++------ .../test/safe/TimelockGuard.t.sol | 9 ++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 00db04e13e897..217d4121f46c2 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -259,11 +259,19 @@ contract TimelockGuard is IGuard, ISemver { /// @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, which would be to sign and execute an empty - /// transaction at the same nonce. This enables us to deterministically generate the - /// transaction inputs for a cancellation transaction from the transaction being cancelled. - /// Thus in order for an owners to sign their cancellation transaction, they must first sign - /// an empty transaction at the same nonce, or call approveHash on the Safe for that - /// transaction hash. + /// transaction at the same nonce. + /// This enables us to deterministically generate the transaction inputs for a cancellation + /// transaction from the transaction being cancelled. + /// In this case however we cannot use a completely empty transaction (with all inputs other than the nonce being null), + /// as that would allow for the signatures used to cancel one transaction at nonce X to + /// be used to cancel all transactions at nonce X. + /// + /// Therefore we define a custom set of inputs for a cancellation transaction, based on the + /// Safe's address as well as the nonce and hash of the transaction being cancelled. + /// + /// Since the Safe's checkNSignatures function is used, the owner 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, @@ -280,10 +288,11 @@ contract TimelockGuard is IGuard, ISemver { } // Generate the cancellation transaction data + bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); bytes memory cancellationTxData = - _safe.encodeTransactionData(address(0), 0, "", Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); + _safe.encodeTransactionData(address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); bytes32 cancellationTxHash = - _safe.getTransactionHash(address(0), 0, "", Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); + _safe.getTransactionHash(address(_safe), 0, txData, 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. diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 1679877bbc96e..1561768fc8055 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -138,13 +138,14 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return (dummyTxParams, txHash, signatures); } - function _getCancellationTx() + function _getCancellationTx(address _safe, bytes32 _txHash) internal pure returns (ExecTransactionParams memory) { + bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); ExecTransactionParams memory cancellationTxParams = ExecTransactionParams( - address(0), 0, hex"", Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)) + _safe, 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)) ); return cancellationTxParams; @@ -438,7 +439,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Get the cancellation signatures uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); @@ -463,7 +464,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { uint256 nonce = safeInstance.safe.nonce(); // Get the cancellation transaction hash - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); // Get the owner From 37da3d55db616db09ca6dc3f5df8b6bc69315dfb Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 16 Sep 2025 16:57:22 -0400 Subject: [PATCH 032/102] Move signature verification before existence check in scheduleTransaction --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 217d4121f46c2..ce8b2452a07d1 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -229,16 +229,16 @@ contract TimelockGuard is IGuard, ISemver { _nonce ); - // Verify signatures using the Safe's signature checking logic - // This function call reverts if the signatures are invalid. - _safe.checkSignatures(txHash, txHashData, _signatures); - // 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 + safeConfigs[_safe].timelockDelay; From 43d7871bbb63ea0d834e196136dc5a3f824e5357 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 16 Sep 2025 16:58:22 -0400 Subject: [PATCH 033/102] Remove unused console.logs --- packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol index f31ea2d8fb498..fa86111dd7973 100644 --- a/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol +++ b/packages/contracts-bedrock/test/safe-tools/SafeTestTools.sol @@ -191,8 +191,6 @@ library SafeTestLib { refundReceiver: refundReceiver, _nonce: _nonce }); - console.log("txDataHash:"); - console.logBytes32(txDataHash); } (v, r, s) = Vm(VM_ADDR).sign(pk, txDataHash); From 8c9fbf1f3f094119c792432dd24cb595204fe60a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 16 Sep 2025 17:03:09 -0400 Subject: [PATCH 034/102] Fix increaseCancellationThreshold inputs --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ce8b2452a07d1..44df1bc850c87 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -299,14 +299,14 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, safeCancellationThreshold[_safe]); scheduledTransactions[_safe][_txHash].cancelled = true; - increaseCancellationThreshold(_safe, _txHash); + increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash, safeCancellationThreshold[_safe]); } /// @notice Increase the cancellation threshold for a safe /// @dev This function must be caled only once and only when calling cancel - function increaseCancellationThreshold(Safe _safe, bytes32 _txHash) internal { + function increaseCancellationThreshold(Safe _safe) internal { if (safeCancellationThreshold[_safe] < blockingThreshold(_safe)) { safeCancellationThreshold[_safe]++; } From b28d65372d88bb747b4cd6e1ff72ea56318e147c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 09:05:36 -0400 Subject: [PATCH 035/102] Separate cancellation threshold events from transaction cancellation Add CancellationThresholdUpdated event and emit it from threshold modification functions. Remove threshold parameter from TransactionCancelled event for cleaner separation of concerns. --- .../interfaces/safe/ITimelockGuard.sol | 13 +++---- .../src/safe/TimelockGuard.sol | 37 ++++++++++--------- .../test/safe/TimelockGuard.t.sol | 22 +++++------ 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 92bbabbda9703..d816c780a1207 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -19,15 +19,18 @@ interface ITimelockGuard is IGuard, ISemver { event GuardCleared(address indexed safe); event GuardConfigured(address indexed safe, uint256 timelockDelay); + struct GuardConfig { + uint256 timelockDelay; + bool configured; + } + // Views function version() external view returns (string memory); - function viewTimelockGuardConfiguration(address _safe) external view returns (uint256); + function viewTimelockGuardConfiguration(address _safe) external view returns (GuardConfig memory); function cancellationThreshold(address _safe) external view returns (uint256 cancellationThreshold_); - function safeCancellationThreshold(address) external view returns (uint256); - function safeConfigs(address) external view @@ -57,10 +60,6 @@ interface ITimelockGuard is IGuard, ISemver { function checkPendingTransactions(address _safe) external pure returns (bytes32[] memory pendingTxs_); - function rejectTransaction(address _safe, bytes32 _txHash) external pure; - - function rejectTransactionWithSignature(address _safe, bytes32 _txHash, bytes memory _signatures) external pure; - function cancelTransaction(address _safe, bytes32 _txHash) external pure; } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 44df1bc850c87..5821562fb98ab 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -74,7 +74,10 @@ contract TimelockGuard is IGuard, ISemver { /// @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, uint256 newCancellationThreshold); + 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 Semantic version. /// @custom:semver 1.0.0 @@ -262,7 +265,8 @@ contract TimelockGuard is IGuard, ISemver { /// transaction at the same nonce. /// This enables us to deterministically generate the transaction inputs for a cancellation /// transaction from the transaction being cancelled. - /// In this case however we cannot use a completely empty transaction (with all inputs other than the nonce being null), + /// In this case however we cannot use a completely empty transaction (with all inputs other than the nonce + /// being null), /// as that would allow for the signatures used to cancel one transaction at nonce X to /// be used to cancel all transactions at nonce X. /// @@ -272,14 +276,7 @@ contract TimelockGuard is IGuard, ISemver { /// Since the Safe's checkNSignatures function is used, the owner 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 - { + function cancelTransaction(Safe _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external { if (scheduledTransactions[_safe][_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } @@ -289,10 +286,12 @@ contract TimelockGuard is IGuard, ISemver { // Generate the cancellation transaction data bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); - bytes memory cancellationTxData = - _safe.encodeTransactionData(address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); - bytes32 cancellationTxHash = - _safe.getTransactionHash(address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce); + bytes memory cancellationTxData = _safe.encodeTransactionData( + address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce + ); + bytes32 cancellationTxHash = _safe.getTransactionHash( + address(_safe), 0, txData, 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. @@ -301,21 +300,25 @@ contract TimelockGuard is IGuard, ISemver { scheduledTransactions[_safe][_txHash].cancelled = true; increaseCancellationThreshold(_safe); - emit TransactionCancelled(_safe, _txHash, safeCancellationThreshold[_safe]); + emit TransactionCancelled(_safe, _txHash); } /// @notice Increase the cancellation threshold for a safe /// @dev This function must be caled only once and only when calling cancel function increaseCancellationThreshold(Safe _safe) internal { if (safeCancellationThreshold[_safe] < blockingThreshold(_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, bytes32 _txHash) external pure { - // safeCancellationThreshold[_safe] = 1 + function resetCancellationThreshold(Safe _safe, bytes32 _txHash) internal { + uint256 oldThreshold = safeCancellationThreshold[_safe]; + safeCancellationThreshold[_safe] = 1; + emit CancellationThresholdUpdated(_safe, oldThreshold, 1); } /// @notice Called by the Safe before executing a transaction diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 1561768fc8055..659324baa79d0 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -22,7 +22,8 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { event GuardConfigured(Safe indexed safe, uint256 timelockDelay); event GuardCleared(Safe indexed safe); event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); - event TransactionCancelled(Safe indexed safe, bytes32 indexed txId, uint256 newCancellationThreshold); + event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); + event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -138,15 +139,10 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { return (dummyTxParams, txHash, signatures); } - function _getCancellationTx(address _safe, bytes32 _txHash) - internal - pure - returns (ExecTransactionParams memory) - { + function _getCancellationTx(address _safe, bytes32 _txHash) internal pure returns (ExecTransactionParams memory) { bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); - ExecTransactionParams memory cancellationTxParams = ExecTransactionParams( - _safe, 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0)) - ); + ExecTransactionParams memory cancellationTxParams = + ExecTransactionParams(_safe, 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0))); return cancellationTxParams; } @@ -445,7 +441,9 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Cancel the transaction vm.expectEmit(true, true, true, true); - emit TransactionCancelled(safeInstance.safe, txHash, numSignatures + 1); + emit CancellationThresholdUpdated(safeInstance.safe, numSignatures, numSignatures + 1); + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(safeInstance.safe, txHash); timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); // Confirm that the transaction is cancelled @@ -482,7 +480,9 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Cancel the transaction vm.expectEmit(true, true, true, true); - emit TransactionCancelled(safeInstance.safe, txHash, cancellationThreshold + 1); + emit CancellationThresholdUpdated(safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); + vm.expectEmit(true, true, true, true); + emit TransactionCancelled(safeInstance.safe, txHash); timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, signatures); // Confirm that the transaction is cancelled From 4fff3d175f5c9c7d96a47d4f6a23cc75722ee66a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 09:22:46 -0400 Subject: [PATCH 036/102] Remove unused _txHash argument from resetCancellation function --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5821562fb98ab..10d2884f5eb65 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -315,7 +315,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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, bytes32 _txHash) internal { + function resetCancellationThreshold(Safe _safe) internal { uint256 oldThreshold = safeCancellationThreshold[_safe]; safeCancellationThreshold[_safe] = 1; emit CancellationThresholdUpdated(_safe, oldThreshold, 1); From 56238e83aad43bb88005807e30c108dce427883d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 09:29:40 -0400 Subject: [PATCH 037/102] Update ITimelockGuard to match implementation --- .../interfaces/safe/ITimelockGuard.sol | 112 ++++++++++-------- 1 file changed, 64 insertions(+), 48 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index d816c780a1207..17e46901626c7 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -1,65 +1,81 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.0; +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; -import { Enum } from "safe-contracts/common/Enum.sol"; -import { ISemver } from "interfaces/universal/ISemver.sol"; -import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +library Enum { + type Operation is uint8; +} +library TimelockGuard { + struct GuardConfig { + uint256 timelockDelay; + bool configured; + } + + 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; + } -/// @title ITimelockGuard -/// @notice Interface for the TimelockGuard Safe guard. -interface ITimelockGuard is IGuard, ISemver { - // Errors error TimelockGuard_GuardNotConfigured(); error TimelockGuard_GuardNotEnabled(); error TimelockGuard_GuardStillEnabled(); error TimelockGuard_InvalidTimelockDelay(); + error TimelockGuard_TransactionAlreadyCancelled(); + error TimelockGuard_TransactionAlreadyScheduled(); + error TimelockGuard_TransactionNotScheduled(); - // Events + event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); event GuardCleared(address indexed safe); event GuardConfigured(address indexed safe, uint256 timelockDelay); + event TransactionCancelled(address indexed safe, bytes32 indexed txId); + event TransactionScheduled(address indexed safe, bytes32 indexed txId, uint256 when); - struct GuardConfig { - uint256 timelockDelay; - bool configured; - } - - // Views - function version() external view returns (string memory); - - function viewTimelockGuardConfiguration(address _safe) external view returns (GuardConfig memory); - - function cancellationThreshold(address _safe) external view returns (uint256 cancellationThreshold_); - - function safeConfigs(address) + function blockingThreshold(address) external pure returns (uint256); + function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; + function cancellationThreshold(address _safe) external view returns (uint256); + function checkAfterExecution(bytes32, bool) external; + function checkPendingTransactions(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 clearTimelockGuard() external; + function configureTimelockGuard(uint256 _timelockDelay) external; + function getScheduledTransaction(address _safe, bytes32 _txHash) external view - returns (uint256 timelockDelay); - - // Admin - function configureTimelockGuard(uint256 _timelockDelay) external; - - function clearTimelockGuard() external; - - // Scheduling API (placeholders until fully implemented in the guard) + returns (TimelockGuard.ScheduledTransaction memory); + function safeConfigs(address) external view returns (uint256 timelockDelay, bool configured); function scheduleTransaction( address _safe, - address _to, - uint256 _value, - bytes memory _data, - Enum.Operation _operation, - uint256 _safeTxGas, - uint256 _baseGas, - uint256 _gasPrice, - address _gasToken, - address payable _refundReceiver, + uint256 _nonce, + ExecTransactionParams memory _params, bytes memory _signatures - ) - external - pure; - - function checkPendingTransactions(address _safe) external pure returns (bytes32[] memory pendingTxs_); - - function cancelTransaction(address _safe, bytes32 _txHash) external pure; + ) external; + function version() external view returns (string memory); + function viewTimelockGuardConfiguration(address _safe) external view returns (TimelockGuard.GuardConfig memory); } - From 1856abcbe9bce646a150866b6165d31fcb48cbfa Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 09:35:51 -0400 Subject: [PATCH 038/102] Use configured flag instead of timelockDelay check in clearTimelockGuard --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 10d2884f5eb65..1b1446adb70ba 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -135,7 +135,7 @@ contract TimelockGuard is IGuard, ISemver { function clearTimelockGuard() external { Safe callingSafe = Safe(payable(msg.sender)); // Check if the calling safe has configuration set - if (safeConfigs[callingSafe].timelockDelay == 0) { + if (safeConfigs[callingSafe].configured == false) { revert TimelockGuard_GuardNotConfigured(); } From 4a9f2e24408d810cf0cd5f818f5fb03eca4819be Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 09:46:27 -0400 Subject: [PATCH 039/102] Add configuration check to scheduleTransaction and fix test names --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 5 +++++ .../contracts-bedrock/test/safe/TimelockGuard.t.sol | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 1b1446adb70ba..ad518e2d2d0af 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -200,6 +200,11 @@ contract TimelockGuard is IGuard, ISemver { revert TimelockGuard_GuardNotEnabled(); } + // Check that the guard has been configured for the Safe + if (!safeConfigs[_safe].configured) { + 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( diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 659324baa79d0..308c3f67e5d5f 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -358,6 +358,8 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { vm.expectEmit(true, true, true, true); emit TransactionScheduled(unguardedSafe.safe, txHash, INIT_TIME + 0); timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, dummyTxParams, signatures); + + // TODO: show that an unscheduled tx will be executed immediately. } function test_scheduleTransaction_reschedulingIdenticalTransaction_reverts() external { @@ -376,6 +378,15 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } 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); + uint256 nonce = unguardedSafe.safe.nonce(); + vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); + timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, _getDummyTxParams(), ""); + } + + function test_scheduleTransaction_guardNotConfigured_reverts() external { // Attempt to schedule a transaction with a Safe that has not enabled the guard uint256 nonce = unguardedSafe.safe.nonce(); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); From 486ab059fd9dfd862623dfdf8346f4beac64bc29 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 20:39:21 -0400 Subject: [PATCH 040/102] Implement checkTransaction --- .../src/safe/TimelockGuard.sol | 71 +++++- .../test/safe/TimelockGuard.t.sol | 203 +++++++++++++++++- 2 files changed, 260 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ad518e2d2d0af..b6717adc94482 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -59,6 +59,9 @@ contract TimelockGuard is IGuard, ISemver { /// @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 Emitted when a Safe configures the guard event GuardConfigured(Safe indexed safe, uint256 timelockDelay); @@ -79,6 +82,12 @@ contract TimelockGuard is IGuard, ISemver { /// @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 Semantic version. /// @custom:semver 1.0.0 string public constant version = "1.0.0"; @@ -329,22 +338,66 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Called by the Safe before executing a transaction /// @dev Implementation of IGuard interface function checkTransaction( - address, + address _to, uint256 _value, - bytes memory, - Enum.Operation, - uint256, - uint256, - uint256, - address, - address payable, + bytes memory _data, + Enum.Operation _operation, + uint256 _safeTxGas, + uint256 _baseGas, + uint256 _gasPrice, + address _gasToken, + address payable _refundReceiver, bytes memory, address ) external override { - // TODO: Implement + Safe callingSafe = Safe(payable(msg.sender)); + + if (safeConfigs[callingSafe].configured == false) { + // 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 has been scheduled + if (scheduledTx.executionTime == 0) { + revert TimelockGuard_TransactionNotScheduled(); + } + + // Check if the transaction was cancelled + if (scheduledTx.cancelled) { + revert TimelockGuard_TransactionAlreadyCancelled(); + } + + // Check if the timelock delay has passed + if (scheduledTx.executionTime > block.timestamp) { + revert TimelockGuard_TransactionNotReady(); + } + + // Set the transaction as executed + scheduledTx.executed = true; + + // Reset the cancellation threshold + resetCancellationThreshold(callingSafe); + + emit TransactionExecuted(callingSafe, nonce, txHash); } /// @notice Called by the Safe after executing a transaction diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 308c3f67e5d5f..6143c5853d227 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -24,6 +24,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -132,13 +133,24 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { view returns (ExecTransactionParams memory, bytes32, bytes memory) { - uint256 nonce = _safe.safe.nonce(); ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); - bytes32 txHash = _getTxHash(_safe, dummyTxParams, nonce); - bytes memory signatures = _getSignaturesForTx(_safe, txHash, THRESHOLD); + (bytes32 txHash, bytes memory signatures) = _getTxWithSignaturesAndHash(_safe, dummyTxParams); + return (dummyTxParams, txHash, signatures); } + /// @notice Helper to generate everything needed to schedule a transaction for the current nonce + function _getTxWithSignaturesAndHash(SafeInstance memory _safe, ExecTransactionParams memory _params) + internal + view + returns (bytes32, bytes memory) + { + uint256 nonce = _safe.safe.nonce(); + bytes32 txHash = _getTxHash(_safe, _params, nonce); + bytes memory signatures = _getSignaturesForTx(_safe, txHash, THRESHOLD); + return (txHash, signatures); + } + function _getCancellationTx(address _safe, bytes32 _txHash) internal pure returns (ExecTransactionParams memory) { bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); ExecTransactionParams memory cancellationTxParams = @@ -160,6 +172,21 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Helper to disable guard on a Safe function _disableGuard(SafeInstance memory _safe) internal { + // Schedule the disable guard transaction + ExecTransactionParams memory disableGuardTxParams = ExecTransactionParams({ + to: address(_safe.safe), + value: 0, + data: abi.encodeCall(GuardManager.setGuard, (address(0))), + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)) + }); + (, bytes memory signatures) = _getTxWithSignaturesAndHash(_safe, disableGuardTxParams); + timelockGuard.scheduleTransaction(_safe.safe, _safe.safe.nonce(), disableGuardTxParams, signatures); + vm.warp(block.timestamp + TIMELOCK_DELAY); SafeTestLib.execTransaction( _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(0))), Enum.Operation.Call ); @@ -245,8 +272,24 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); + uint256 newDelay = TIMELOCK_DELAY + 1; + // Schedule the reconfiguration transaction + ExecTransactionParams memory reconfigureGuardTxParams = ExecTransactionParams({ + to: address(timelockGuard), + value: 0, + data: abi.encodeCall(TimelockGuard.configureTimelockGuard, (newDelay)), + operation: Enum.Operation.Call, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: payable(address(0)) + }); + (, bytes memory signatures) = _getTxWithSignaturesAndHash(safeInstance, reconfigureGuardTxParams); + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), reconfigureGuardTxParams, signatures); + vm.warp(block.timestamp + TIMELOCK_DELAY); + // Reconfigure with different delay - uint256 newDelay = 14 days; vm.expectEmit(true, true, true, true); emit GuardConfigured(safe, newDelay); @@ -263,7 +306,7 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); - // Disable the guard first + // Disable the guard _disableGuard(safeInstance); // Clear should succeed and emit event @@ -346,6 +389,9 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { // A test which demonstrates that if the guard is enabled but not explicitly configured, // the timelock delay is set to 0. function test_scheduleTransaction_guardNotConfigured_succeeds() external { + // TODO: Determine what the actual behavior should be here, ie. if unconfigured, + // should scheduleTransaction revert, or should it schedule and allow immediate execution? + vm.skip(true); // Enable the guard on the unguarded Safe, but don't configure it _enableGuard(unguardedSafe); assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe).timelockDelay, 0); @@ -355,6 +401,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { bytes memory signatures = _getSignaturesForTx(unguardedSafe, txHash, THRESHOLD - 1); uint256 nonce = unguardedSafe.safe.nonce(); + vm.expectEmit(true, true, true, true); emit TransactionScheduled(unguardedSafe.safe, txHash, INIT_TIME + 0); timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, dummyTxParams, signatures); @@ -514,3 +561,149 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, new bytes(0)); } } + +/// @title TimelockGuard_CheckTransaction_Test +/// @notice Tests for checkTransaction function +contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { + using stdStorage for StdStorage; + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_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(); + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(safeInstance); + timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, 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(nonce+1))); + + // increment the cancellation threshold so that we can test that it is reset + uint256 slot = stdstore.target(address(timelockGuard)).sig("cancellationThreshold(address)").with_key( + address(safeInstance.safe) + ).find(); + vm.store(address(timelockGuard), bytes32(slot), bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe)+1))); + + vm.prank(address(safeInstance.safe)); + vm.expectEmit(true, true, true, true); + emit TransactionExecuted(safeInstance.safe, nonce, txHash); + timelockGuard.checkTransaction( + dummyTxParams.to, + dummyTxParams.value, + dummyTxParams.data, + dummyTxParams.operation, + dummyTxParams.safeTxGas, + dummyTxParams.baseGas, + dummyTxParams.gasPrice, + dummyTxParams.gasToken, + dummyTxParams.refundReceiver, + "", + address(0) + ); + + // Confirm that the transaction is executed + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); + assertEq(scheduledTransaction.executed, true); + + // Confirm that the cancellation threshold is reset + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); + } + + /// @notice Test that checkTransaction reverts when scheduled transaction delay hasn't passed + function test_checkTransaction_scheduledTransactionNotReady_reverts() external { + (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(safeInstance); + + // Schedule the transaction but do not advance time past the timelock delay + timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + + // 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( + dummyTxParams.to, + dummyTxParams.value, + dummyTxParams.data, + dummyTxParams.operation, + dummyTxParams.safeTxGas, + dummyTxParams.baseGas, + dummyTxParams.gasPrice, + dummyTxParams.gasToken, + dummyTxParams.refundReceiver, + "", + address(0) + ); + } + + /// @notice Test that checkTransaction reverts when scheduled transaction was cancelled + function test_checkTransaction_scheduledTransactionCancelled_reverts() external { + // Schedule a transaction + (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = + _getDummyTxWithSignaturesAndHash(safeInstance); + uint256 nonce = safeInstance.safe.nonce(); + timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); + + // new scope for the compiler error which must not be named + { + // Cancel the transaction + uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); + ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); + bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); + bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); + timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); + } + // 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( + dummyTxParams.to, + dummyTxParams.value, + dummyTxParams.data, + dummyTxParams.operation, + dummyTxParams.safeTxGas, + dummyTxParams.baseGas, + dummyTxParams.gasPrice, + dummyTxParams.gasToken, + dummyTxParams.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 + ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); + + // Should revert because transaction was not scheduled + vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotScheduled.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.checkTransaction( + dummyTxParams.to, + dummyTxParams.value, + dummyTxParams.data, + dummyTxParams.operation, + dummyTxParams.safeTxGas, + dummyTxParams.baseGas, + dummyTxParams.gasPrice, + dummyTxParams.gasToken, + dummyTxParams.refundReceiver, + "", + address(0) + ); + } +} From bf20466620c0acdedfc0f19a89bf0a9ebd78e353 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 21:34:33 -0400 Subject: [PATCH 041/102] Add itest placeholder contract --- .../contracts-bedrock/test/safe/TimelockGuard.t.sol | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 6143c5853d227..375c84c64306a 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -707,3 +707,14 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { ); } } + +/// @title TimelockGuard_Integration_Test +/// @notice Integration tests for TimelockGuard with full Safe execution flow +contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { +// TODO: Add end-to-end tests that verify the complete flow: +// - Schedule a transaction +// - Wait for delay to pass +// - Execute transaction through Safe.execTransaction() +// - Verify that the target contract was actually called +// - Test various failure scenarios in the full execution context +} From ca166ef18893061a61859b122b7eb698a4d09189 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 21:34:50 -0400 Subject: [PATCH 042/102] Add comment to checkAfterExecution body --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index b6717adc94482..546db879ef108 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -403,9 +403,8 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Called by the Safe after executing a transaction /// @dev Implementation of IGuard interface function checkAfterExecution(bytes32, bool) external override { - // TODO: Implement - // extract txHash - // resetCancellationThreshold(_safe, txHash) - // scheduledTransactions[_safe][txHash].executed = true + // Do nothing + // In order to follow the Checks-Effects-Interactions pattern, + // all checks and effects should be done in the checkTransaction function. } } From 8b621c4406c2b65f9bd9c90cb21adedeabdb9c4c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 17 Sep 2025 22:03:38 -0400 Subject: [PATCH 043/102] pre-pr checks --- .../snapshots/abi/TimelockGuard.json | 363 +++++++++++++----- .../storageLayout/TimelockGuard.json | 13 +- .../src/safe/TimelockGuard.sol | 2 +- .../test/safe/TimelockGuard.t.sol | 36 +- 4 files changed, 285 insertions(+), 129 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json index 745fdf6ec1383..8c529698e9514 100644 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -2,25 +2,54 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "", "type": "address" + } + ], + "name": "blockingThreshold", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" }, { "internalType": "bytes32", - "name": "", + "name": "_txHash", "type": "bytes32" + }, + { + "internalType": "uint256", + "name": "_nonce", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "_signatures", + "type": "bytes" } ], "name": "cancelTransaction", "outputs": [], - "stateMutability": "pure", + "stateMutability": "nonpayable", "type": "function" }, { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" } @@ -77,7 +106,7 @@ "inputs": [ { "internalType": "address", - "name": "", + "name": "_to", "type": "address" }, { @@ -87,37 +116,37 @@ }, { "internalType": "bytes", - "name": "", + "name": "_data", "type": "bytes" }, { "internalType": "enum Enum.Operation", - "name": "", + "name": "_operation", "type": "uint8" }, { "internalType": "uint256", - "name": "", + "name": "_safeTxGas", "type": "uint256" }, { "internalType": "uint256", - "name": "", + "name": "_baseGas", "type": "uint256" }, { "internalType": "uint256", - "name": "", + "name": "_gasPrice", "type": "uint256" }, { "internalType": "address", - "name": "", + "name": "_gasToken", "type": "address" }, { "internalType": "address payable", - "name": "", + "name": "_refundReceiver", "type": "address" }, { @@ -159,58 +188,39 @@ { "inputs": [ { - "internalType": "address", - "name": "", + "internalType": "contract GnosisSafe", + "name": "_safe", "type": "address" }, { "internalType": "bytes32", - "name": "", + "name": "_txHash", "type": "bytes32" } ], - "name": "rejectTransaction", - "outputs": [], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "bytes32", - "name": "", - "type": "bytes32" - }, - { - "internalType": "bytes", - "name": "", - "type": "bytes" - } - ], - "name": "rejectTransactionWithSignature", - "outputs": [], - "stateMutability": "pure", - "type": "function" - }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "safeCancellationThreshold", + "name": "getScheduledTransaction", "outputs": [ { - "internalType": "uint256", - "name": "", - "type": "uint256" + "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", @@ -219,7 +229,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "", "type": "address" } @@ -230,6 +240,11 @@ "internalType": "uint256", "name": "timelockDelay", "type": "uint256" + }, + { + "internalType": "bool", + "name": "configured", + "type": "bool" } ], "stateMutability": "view", @@ -238,64 +253,76 @@ { "inputs": [ { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "address", - "name": "", + "internalType": "contract GnosisSafe", + "name": "_safe", "type": "address" }, { "internalType": "uint256", - "name": "", - "type": "uint256" - }, - { - "internalType": "bytes", - "name": "", - "type": "bytes" - }, - { - "internalType": "enum Enum.Operation", - "name": "", - "type": "uint8" - }, - { - "internalType": "uint256", - "name": "", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "", - "type": "uint256" - }, - { - "internalType": "uint256", - "name": "", + "name": "_nonce", "type": "uint256" }, { - "internalType": "address", - "name": "", - "type": "address" - }, - { - "internalType": "address payable", - "name": "", - "type": "address" + "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": "", + "name": "_signatures", "type": "bytes" } ], "name": "scheduleTransaction", "outputs": [], - "stateMutability": "pure", + "stateMutability": "nonpayable", "type": "function" }, { @@ -314,7 +341,7 @@ { "inputs": [ { - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" } @@ -322,9 +349,21 @@ "name": "viewTimelockGuardConfiguration", "outputs": [ { - "internalType": "uint256", - "name": "", - "type": "uint256" + "components": [ + { + "internalType": "uint256", + "name": "timelockDelay", + "type": "uint256" + }, + { + "internalType": "bool", + "name": "configured", + "type": "bool" + } + ], + "internalType": "struct TimelockGuard.GuardConfig", + "name": "", + "type": "tuple" } ], "stateMutability": "view", @@ -335,7 +374,32 @@ "inputs": [ { "indexed": true, - "internalType": "address", + "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" } @@ -348,7 +412,7 @@ "inputs": [ { "indexed": true, - "internalType": "address", + "internalType": "contract GnosisSafe", "name": "safe", "type": "address" }, @@ -362,6 +426,75 @@ "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", @@ -381,5 +514,25 @@ "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" } ] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json index a8c3b0cb554d6..3b67ff1ebd391 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json @@ -4,13 +4,20 @@ "label": "safeConfigs", "offset": 0, "slot": "0", - "type": "mapping(address => struct TimelockGuard.GuardConfig)" + "type": "mapping(contract GnosisSafe => struct TimelockGuard.GuardConfig)" }, { "bytes": "32", - "label": "safeCancellationThreshold", + "label": "scheduledTransactions", "offset": 0, "slot": "1", - "type": "mapping(address => uint256)" + "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 index 546db879ef108..2b7e3ae8cb52c 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.15; // Safe import { GnosisSafe as Safe } from "safe-contracts/GnosisSafe.sol"; import { Enum } from "safe-contracts/common/Enum.sol"; -import { GuardManager, Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; +import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; import { ExecTransactionParams } from "src/safe/Types.sol"; // Interfaces diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 375c84c64306a..8a00b2e1e9337 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -5,12 +5,9 @@ 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 { StorageAccessible } from "safe-contracts/common/StorageAccessible.sol"; import { ExecTransactionParams } from "src/safe/Types.sol"; import "test/safe-tools/SafeTestTools.sol"; -import { console2 as console } from "forge-std/console2.sol"; - import { TimelockGuard } from "src/safe/TimelockGuard.sol"; /// @title TimelockGuard_TestInit @@ -140,7 +137,10 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to generate everything needed to schedule a transaction for the current nonce - function _getTxWithSignaturesAndHash(SafeInstance memory _safe, ExecTransactionParams memory _params) + function _getTxWithSignaturesAndHash( + SafeInstance memory _safe, + ExecTransactionParams memory _params + ) internal view returns (bytes32, bytes memory) @@ -286,7 +286,9 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { refundReceiver: payable(address(0)) }); (, bytes memory signatures) = _getTxWithSignaturesAndHash(safeInstance, reconfigureGuardTxParams); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), reconfigureGuardTxParams, signatures); + timelockGuard.scheduleTransaction( + safeInstance.safe, safeInstance.safe.nonce(), reconfigureGuardTxParams, signatures + ); vm.warp(block.timestamp + TIMELOCK_DELAY); // Reconfigure with different delay @@ -566,6 +568,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { /// @notice Tests for checkTransaction function contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { using stdStorage for StdStorage; + function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); @@ -582,13 +585,17 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { // 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))); + 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("cancellationThreshold(address)").with_key( address(safeInstance.safe) ).find(); - vm.store(address(timelockGuard), bytes32(slot), bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe)+1))); + vm.store( + address(timelockGuard), + bytes32(slot), + bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe) + 1)) + ); vm.prank(address(safeInstance.safe)); vm.expectEmit(true, true, true, true); @@ -625,7 +632,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); // 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.store(address(safeInstance.safe), bytes32(uint256(5)), bytes32(uint256(safeInstance.safe.nonce() + 1))); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotReady.selector); vm.prank(address(safeInstance.safe)); @@ -664,7 +671,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { // 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))); + 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); @@ -707,14 +714,3 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { ); } } - -/// @title TimelockGuard_Integration_Test -/// @notice Integration tests for TimelockGuard with full Safe execution flow -contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { -// TODO: Add end-to-end tests that verify the complete flow: -// - Schedule a transaction -// - Wait for delay to pass -// - Execute transaction through Safe.execTransaction() -// - Verify that the target contract was actually called -// - Test various failure scenarios in the full execution context -} From 04ad47d1b8015bd9517235a4bfa87b6be3d9689c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 18 Sep 2025 09:43:06 -0400 Subject: [PATCH 044/102] Remove GuardConfig.configured boolean field We can simply use `timelock > 0` as an indicator of configuration --- .../interfaces/safe/ITimelockGuard.sol | 3 +-- .../contracts-bedrock/src/safe/TimelockGuard.sol | 8 +++----- .../test/safe/TimelockGuard.t.sol | 15 ++++++++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 17e46901626c7..b3835b9e404a6 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -8,7 +8,6 @@ library Enum { library TimelockGuard { struct GuardConfig { uint256 timelockDelay; - bool configured; } struct ScheduledTransaction { @@ -69,7 +68,7 @@ interface Interface { external view returns (TimelockGuard.ScheduledTransaction memory); - function safeConfigs(address) external view returns (uint256 timelockDelay, bool configured); + function safeConfigs(address) external view returns (uint256 timelockDelay); function scheduleTransaction( address _safe, uint256 _nonce, diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 2b7e3ae8cb52c..c51b65547edbb 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -19,7 +19,6 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Configuration for a Safe's timelock guard struct GuardConfig { uint256 timelockDelay; - bool configured; } /// @notice Scheduled transaction @@ -129,7 +128,6 @@ contract TimelockGuard is IGuard, ISemver { // Store the configuration for this safe safeConfigs[callingSafe].timelockDelay = _timelockDelay; - safeConfigs[callingSafe].configured = true; // Initialize cancellation threshold to 1 safeCancellationThreshold[callingSafe] = 1; @@ -144,7 +142,7 @@ contract TimelockGuard is IGuard, ISemver { function clearTimelockGuard() external { Safe callingSafe = Safe(payable(msg.sender)); // Check if the calling safe has configuration set - if (safeConfigs[callingSafe].configured == false) { + if (safeConfigs[callingSafe].timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -210,7 +208,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that the guard has been configured for the Safe - if (!safeConfigs[_safe].configured) { + if (safeConfigs[_safe].timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -355,7 +353,7 @@ contract TimelockGuard is IGuard, ISemver { { Safe callingSafe = Safe(payable(msg.sender)); - if (safeConfigs[callingSafe].configured == false) { + if (safeConfigs[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 diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 8a00b2e1e9337..a5191af495a78 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -217,14 +217,16 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); assertEq(config.timelockDelay, 0); - assertEq(config.configured, false); + // configured is now determined by timelockDelay == 0 + assertEq(config.timelockDelay == 0, true); } function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); - assertEq(config.configured, true); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); } } @@ -239,7 +241,8 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); - assertEq(config.configured, true); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); } function test_configureTimelockGuard_revertsIfGuardNotEnabled_reverts() external { @@ -264,7 +267,8 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); assertEq(config.timelockDelay, ONE_YEAR); - assertEq(config.configured, true); + // configured is now determined by timelockDelay != 0 + assertEq(config.timelockDelay != 0, true); } function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { @@ -319,7 +323,8 @@ contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { // Configuration should be cleared assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, 0); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).configured, false); + // configured is now determined by timelockDelay == 0 + assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay == 0, true); // Ensure cancellation threshold is reset to 0 assertEq(timelockGuard.cancellationThreshold(safe), 0); From b9776663a6ab564d857e6db79e3f3b7c9589ef5c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 18 Sep 2025 14:36:16 -0400 Subject: [PATCH 045/102] Remove clearTimelockGuard The right way to do this is now just to set timelockDelay to zero. --- .../interfaces/safe/ITimelockGuard.sol | 2 - .../src/safe/TimelockGuard.sol | 47 +++++-------------- .../test/safe/TimelockGuard.t.sol | 47 +++++-------------- 3 files changed, 26 insertions(+), 70 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index b3835b9e404a6..8c18257efd423 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -39,7 +39,6 @@ interface Interface { error TimelockGuard_TransactionNotScheduled(); event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); - event GuardCleared(address indexed safe); event GuardConfigured(address indexed safe, uint256 timelockDelay); event TransactionCancelled(address indexed safe, bytes32 indexed txId); event TransactionScheduled(address indexed safe, bytes32 indexed txId, uint256 when); @@ -62,7 +61,6 @@ interface Interface { bytes memory, address ) external; - function clearTimelockGuard() external; function configureTimelockGuard(uint256 _timelockDelay) external; function getScheduledTransaction(address _safe, bytes32 _txHash) external diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index c51b65547edbb..8ffd858ebb7c1 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -43,9 +43,6 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Error for when Safe is not configured for this guard error TimelockGuard_GuardNotConfigured(); - /// @notice Error for when attempt to clear guard while it is still enabled for the Safe - error TimelockGuard_GuardStillEnabled(); - /// @notice Error for invalid timelock delay error TimelockGuard_InvalidTimelockDelay(); @@ -64,9 +61,6 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Emitted when a Safe configures the guard event GuardConfigured(Safe indexed safe, uint256 timelockDelay); - /// @notice Emitted when a Safe clears the guard configuration - event GuardCleared(Safe indexed safe); - /// @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). @@ -113,48 +107,33 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST set the caller as a Safe /// @dev MUST take timelock_delay as a parameter and store it as related to the Safe /// @dev MUST emit a GuardConfigured event with at least timelock_delay as a parameter - /// @param _timelockDelay The timelock delay in seconds + /// @dev If _timelockDelay is 0, clears the configuration for the Safe + /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) function configureTimelockGuard(uint256 _timelockDelay) external { Safe callingSafe = Safe(payable(msg.sender)); - // Validate timelock delay - must be non-zero and not longer than 1 year - if (_timelockDelay == 0 || _timelockDelay > 365 days) { - revert TimelockGuard_InvalidTimelockDelay(); - } // Check that this guard is enabled on the calling Safe if (!_isGuardEnabled(callingSafe)) { revert TimelockGuard_GuardNotEnabled(); } + // Validate timelock delay - must not be longer than 1 year + if (_timelockDelay > 365 days) { + revert TimelockGuard_InvalidTimelockDelay(); + } + // Store the configuration for this safe safeConfigs[callingSafe].timelockDelay = _timelockDelay; - // Initialize cancellation threshold to 1 - safeCancellationThreshold[callingSafe] = 1; - emit GuardConfigured(callingSafe, _timelockDelay); - } - /// @notice Remove the timelock guard configuration by a previously enabled Safe - /// @dev MUST revert if the contract is not enabled as a guard for the Safe - /// @dev MUST erase the existing timelock_delay data related to the calling Safe - /// @dev MUST emit a GuardCleared event - function clearTimelockGuard() external { - Safe callingSafe = Safe(payable(msg.sender)); - // Check if the calling safe has configuration set - if (safeConfigs[callingSafe].timelockDelay == 0) { - revert TimelockGuard_GuardNotConfigured(); + // If timelock delay is 0, ensure the cancellation threshold is deleted + if (_timelockDelay == 0) { + delete safeCancellationThreshold[callingSafe]; + } else { + // Initialize cancellation threshold to 1 + safeCancellationThreshold[callingSafe] = 1; } - - // Check that this guard is NOT enabled on the calling Safe - if (_isGuardEnabled(callingSafe)) { - revert TimelockGuard_GuardStillEnabled(); - } - - // Erase the configuration data for this safe - delete safeConfigs[callingSafe]; - - emit GuardCleared(callingSafe); } /// @notice Returns the cancellation threshold for a given safe diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index a5191af495a78..657dd932e7767 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -17,7 +17,6 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // Events event GuardConfigured(Safe indexed safe, uint256 timelockDelay); - event GuardCleared(Safe indexed safe); 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); @@ -206,7 +205,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @notice Helper to clear the TimelockGuard configuration for a Safe function _clearGuard(SafeInstance memory _safe) internal { SafeTestLib.execTransaction( - _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.clearTimelockGuard, ()), Enum.Operation.Call + _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (0)), Enum.Operation.Call ); } } @@ -302,50 +301,30 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, newDelay); assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, newDelay); } -} -/// @title TimelockGuard_ClearTimelockGuard_Test -/// @notice Tests for clearTimelockGuard function -contract TimelockGuard_ClearTimelockGuard_Test is TimelockGuard_TestInit { - function test_clearTimelockGuard_succeeds() external { + function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); - // Disable the guard - _disableGuard(safeInstance); - - // Clear should succeed and emit event + // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); - emit GuardCleared(safe); - - _clearGuard(safeInstance); + emit GuardConfigured(safe, 0); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0); - // Configuration should be cleared + // Timelock delay should be set to 0 assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, 0); - // configured is now determined by timelockDelay == 0 - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay == 0, true); - // Ensure cancellation threshold is reset to 0 + // Cancellation threshold should be reset to 0 assertEq(timelockGuard.cancellationThreshold(safe), 0); - - // TODO: Check that any active challenge is cancelled - } - - function test_clearTimelockGuard_revertsIfGuardStillEnabled_reverts() external { - // First configure the guard - _configureGuard(safeInstance, TIMELOCK_DELAY); - - // Try to clear without disabling guard first - should revert - vm.expectRevert(TimelockGuard.TimelockGuard_GuardStillEnabled.selector); - vm.prank(address(safeInstance.safe)); - timelockGuard.clearTimelockGuard(); } - function test_clearTimelockGuard_revertsIfNotConfigured_reverts() external { - // Try to clear - should revert because not configured - vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); + 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); vm.prank(address(safeInstance.safe)); - timelockGuard.clearTimelockGuard(); + timelockGuard.configureTimelockGuard(0); } } From 581cab0a829bc5436b9ec8a9ef9f395fc589348d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 19 Sep 2025 12:24:25 -0400 Subject: [PATCH 046/102] Refactor: Add TransactionBuilder library This library significantly reduces the amount of boilerplace required to setup a Safe transaction, then schedule, cancel, or execute it. --- .../test/safe/TimelockGuard.t.sol | 583 +++++++++--------- 1 file changed, 278 insertions(+), 305 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 657dd932e7767..f62e1164dec31 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -10,11 +10,141 @@ 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.cancellationThreshold(_tx.safeInstance.safe); + + // Set the signatures. We do not update the hash, as it is the same as the transaction + // being cancelled. + // cancellation.setSignatures(cancellationThreshold); + + cancellation.updateTransaction(cancellationThreshold); + return cancellation; + } +} + /// @title TimelockGuard_TestInit /// @notice Reusable test initialization for `TimelockGuard` tests. contract TimelockGuard_TestInit is Test, SafeTestTools { - using SafeTestLib for SafeInstance; - // Events event GuardConfigured(Safe indexed safe, uint256 timelockDelay); event TransactionScheduled(Safe indexed safe, bytes32 indexed txId, uint256 when); @@ -37,6 +167,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { SafeInstance unguardedSafe; + /// @notice Deploys test fixtures and configures default Safe instances. function setUp() public virtual { vm.warp(INIT_TIME); @@ -55,157 +186,47 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { unguardedSafe = _setupSafe(keys, THRESHOLD - 1); // Enable the guard on the Safe - SafeTestLib.execTransaction( - safeInstance, - address(safeInstance.safe), - 0, - abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))), - Enum.Operation.Call - ); - } - - /// @notice Helper to generate the transaction hash for a given transaction params and nonce - function _getTxHash( - SafeInstance memory _safeInstance, - ExecTransactionParams memory _params, - uint256 _nonce - ) - internal - view - returns (bytes32) - { - return _safeInstance.safe.getTransactionHash({ - to: _params.to, - value: _params.value, - data: _params.data, - operation: _params.operation, - safeTxGas: _params.safeTxGas, - baseGas: _params.baseGas, - gasPrice: _params.gasPrice, - gasToken: _params.gasToken, - refundReceiver: _params.refundReceiver, - _nonce: _nonce - }); - } - - /// @notice Helper to generate signatures for an arbitrary transaction - function _getSignaturesForTx( - SafeInstance memory _safeInstance, - bytes32 _txHash, - uint256 _numSignatures - ) - internal - pure - returns (bytes memory) - { - bytes memory signatures = new bytes(0); - for (uint256 i; i < _numSignatures; ++i) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(_safeInstance.ownerPKs[i], _txHash); - - // The signature format is a compact form of: {bytes32 r}{bytes32 s}{uint8 v} - signatures = bytes.concat(signatures, abi.encodePacked(r, s, v)); - } - return signatures; - } - - /// @notice Helper to generate dummy transaction parameters - function _getDummyTxParams() internal pure returns (ExecTransactionParams memory) { - return ExecTransactionParams({ - to: address(0xabba), - value: 0, - data: hex"acdc", - operation: Enum.Operation.Call, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)) - }); + _enableGuard(safeInstance); } - /// @notice Helper to generate everything needed to schedule a transaction for the current nonce - function _getDummyTxWithSignaturesAndHash(SafeInstance memory _safe) + /// @notice Builds an empty transaction wrapper for a Safe instance. + function _createEmptyTransaction(SafeInstance memory _safeInstance) internal view - returns (ExecTransactionParams memory, bytes32, bytes memory) + returns (TransactionBuilder.Transaction memory) { - ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); - (bytes32 txHash, bytes memory signatures) = _getTxWithSignaturesAndHash(_safe, dummyTxParams); - - return (dummyTxParams, txHash, signatures); + TransactionBuilder.Transaction memory transaction; + // transaction.params will have null values + transaction.safeInstance = _safeInstance; + transaction.nonce = _safeInstance.safe.nonce(); + transaction.updateTransaction(); + return transaction; } - /// @notice Helper to generate everything needed to schedule a transaction for the current nonce - function _getTxWithSignaturesAndHash( - SafeInstance memory _safe, - ExecTransactionParams memory _params - ) + /// @notice Creates a dummy transaction populated with placeholder call data. + function _createDummyTransaction(SafeInstance memory _safeInstance) internal view - returns (bytes32, bytes memory) + returns (TransactionBuilder.Transaction memory) { - uint256 nonce = _safe.safe.nonce(); - bytes32 txHash = _getTxHash(_safe, _params, nonce); - bytes memory signatures = _getSignaturesForTx(_safe, txHash, THRESHOLD); - return (txHash, signatures); - } - - function _getCancellationTx(address _safe, bytes32 _txHash) internal pure returns (ExecTransactionParams memory) { - bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); - ExecTransactionParams memory cancellationTxParams = - ExecTransactionParams(_safe, 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), payable(address(0))); - - return cancellationTxParams; + 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 _delay) internal { SafeTestLib.execTransaction( - _safe, - address(timelockGuard), - 0, - abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)), - Enum.Operation.Call - ); - } - - /// @notice Helper to disable guard on a Safe - function _disableGuard(SafeInstance memory _safe) internal { - // Schedule the disable guard transaction - ExecTransactionParams memory disableGuardTxParams = ExecTransactionParams({ - to: address(_safe.safe), - value: 0, - data: abi.encodeCall(GuardManager.setGuard, (address(0))), - operation: Enum.Operation.Call, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)) - }); - (, bytes memory signatures) = _getTxWithSignaturesAndHash(_safe, disableGuardTxParams); - timelockGuard.scheduleTransaction(_safe.safe, _safe.safe.nonce(), disableGuardTxParams, signatures); - vm.warp(block.timestamp + TIMELOCK_DELAY); - SafeTestLib.execTransaction( - _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(0))), Enum.Operation.Call + _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)) ); } /// @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))), - Enum.Operation.Call - ); - } - - /// @notice Helper to clear the TimelockGuard configuration for a Safe - function _clearGuard(SafeInstance memory _safe) internal { - SafeTestLib.execTransaction( - _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (0)), Enum.Operation.Call + _safe, address(_safe.safe), 0, abi.encodeCall(GuardManager.setGuard, (address(timelockGuard))) ); } } @@ -213,6 +234,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { /// @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.viewTimelockGuardConfiguration(safeInstance.safe); assertEq(config.timelockDelay, 0); @@ -220,6 +242,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test 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); TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); @@ -232,6 +255,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test /// @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); @@ -244,12 +268,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { 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); } + /// @notice Confirms delays above the maximum revert during configuration. function test_configureTimelockGuard_revertsIfDelayTooLong_reverts() external { uint256 tooLongDelay = ONE_YEAR + 1; @@ -258,6 +284,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(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); @@ -270,28 +297,21 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { 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); assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; - // Schedule the reconfiguration transaction - ExecTransactionParams memory reconfigureGuardTxParams = ExecTransactionParams({ - to: address(timelockGuard), - value: 0, - data: abi.encodeCall(TimelockGuard.configureTimelockGuard, (newDelay)), - operation: Enum.Operation.Call, - safeTxGas: 0, - baseGas: 0, - gasPrice: 0, - gasToken: address(0), - refundReceiver: payable(address(0)) - }); - (, bytes memory signatures) = _getTxWithSignaturesAndHash(safeInstance, reconfigureGuardTxParams); - timelockGuard.scheduleTransaction( - safeInstance.safe, safeInstance.safe.nonce(), reconfigureGuardTxParams, signatures - ); + + // 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)); + reconfigureGuardTx.updateTransaction(); + reconfigureGuardTx.scheduleTransaction(timelockGuard); + vm.warp(block.timestamp + TIMELOCK_DELAY); // Reconfigure with different delay @@ -302,6 +322,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, newDelay); } + /// @notice Ensures setting delay to zero clears the configuration. function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); @@ -319,6 +340,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.cancellationThreshold(safe), 0); } + /// @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); @@ -331,17 +353,20 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { /// @title TimelockGuard_CancellationThreshold_Test /// @notice Tests for cancellationThreshold function contract TimelockGuard_CancellationThreshold_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.cancellationThreshold(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.cancellationThreshold(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); @@ -358,94 +383,81 @@ contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { /// @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); } + /// @notice Ensures scheduling emits the expected event and stores state. function test_scheduleTransaction_succeeds() public { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); vm.expectEmit(true, true, true, true); - emit TransactionScheduled(safe, txHash, INIT_TIME + TIMELOCK_DELAY); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + 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. - function test_scheduleTransaction_guardNotConfigured_succeeds() external { - // TODO: Determine what the actual behavior should be here, ie. if unconfigured, - // should scheduleTransaction revert, or should it schedule and allow immediate execution? - vm.skip(true); + /// @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.viewTimelockGuardConfiguration(unguardedSafe.safe).timelockDelay, 0); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(unguardedSafe); - - bytes memory signatures = _getSignaturesForTx(unguardedSafe, txHash, THRESHOLD - 1); - - uint256 nonce = unguardedSafe.safe.nonce(); - - vm.expectEmit(true, true, true, true); - emit TransactionScheduled(unguardedSafe.safe, txHash, INIT_TIME + 0); - timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, dummyTxParams, signatures); - - // TODO: show that an unscheduled tx will be executed immediately. + 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 { - uint256 nonce = safeInstance.safe.nonce(); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); - (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); - timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); + timelockGuard.scheduleTransaction(safeInstance.safe, dummyTx.nonce, dummyTx.params, dummyTx.signatures); vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyScheduled.selector); - timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); + timelockGuard.scheduleTransaction(dummyTx.safeInstance.safe, dummyTx.nonce, dummyTx.params, dummyTx.signatures); } + /// @notice Placeholder for verifying rescheduling after cancellation behaviour. function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { // TODO: Implement once cancelTransaction is implemented and tested } + /// @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); - uint256 nonce = unguardedSafe.safe.nonce(); - vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); - timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, _getDummyTxParams(), ""); - } + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); - function test_scheduleTransaction_guardNotConfigured_reverts() external { - // Attempt to schedule a transaction with a Safe that has not enabled the guard - uint256 nonce = unguardedSafe.safe.nonce(); - vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotEnabled.selector); - timelockGuard.scheduleTransaction(unguardedSafe.safe, nonce, _getDummyTxParams(), ""); + 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 - (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); // Schedule an identical transaction with a different nonce (salt) - uint256 newNonce = safeInstance.safe.nonce() + 1; - bytes32 newTxHash = _getTxHash(safeInstance, dummyTxParams, newNonce); - bytes memory newSignatures = _getSignaturesForTx(safeInstance, newTxHash, THRESHOLD); + TransactionBuilder.Transaction memory newTx = dummyTx.deepCopy(); + newTx.nonce = dummyTx.nonce + 1; + newTx.updateTransaction(); vm.expectEmit(true, true, true, true); - emit TransactionScheduled(safe, newTxHash, INIT_TIME + TIMELOCK_DELAY); - timelockGuard.scheduleTransaction(safeInstance.safe, newNonce, dummyTxParams, newSignatures); + emit TransactionScheduled(safe, newTx.hash, INIT_TIME + TIMELOCK_DELAY); + timelockGuard.scheduleTransaction(safeInstance.safe, newTx.nonce, newTx.params, newTx.signatures); } } /// @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(); @@ -453,98 +465,67 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); } - /// @notice Helper to schedule a transaction in order to test cancelTransaction. - /// Will always schedule the dummy transaction using the Safe's current nonce. - function _scheduleTransaction() internal { - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); - - // Confirm that the transaction is scheduled - TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); - assertEq(scheduledTransaction.executionTime, block.timestamp + TIMELOCK_DELAY); - assertEq(scheduledTransaction.cancelled, false); - assertEq(scheduledTransaction.executed, false); - } - + /// @notice Ensures cancellations succeed using owner signatures. function test_cancelTransaction_withPrivKeySignature_succeeds() external { - _scheduleTransaction(); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); - // Get the transaction hash - (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); - - // Get the nonce - uint256 nonce = safeInstance.safe.nonce(); - - // Get the cancellation signatures - uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); - bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); - bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); + // Get the cancellation transaction + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); + uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); - emit CancellationThresholdUpdated(safeInstance.safe, numSignatures, numSignatures + 1); + emit CancellationThresholdUpdated(safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); vm.expectEmit(true, true, true, true); - emit TransactionCancelled(safeInstance.safe, txHash); - timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); + emit TransactionCancelled(safeInstance.safe, dummyTx.hash); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); - // Confirm that the transaction is cancelled - TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.getScheduledTransaction(safeInstance.safe, txHash); - assertEq(scheduledTransaction.cancelled, true); + assertEq(timelockGuard.getScheduledTransaction(safeInstance.safe, dummyTx.hash).cancelled, true); } + /// @notice Confirms pre-approved hashes can authorise cancellations. function test_cancelTransaction_withApproveHash_succeeds() external { - _scheduleTransaction(); - - // Get the transaction hash - (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); - - // Get the nonce - uint256 nonce = safeInstance.safe.nonce(); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); // Get the cancellation transaction hash - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); - bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); + TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); // Get the owner - address owner = safeInstance.safe.getOwners()[0]; + address owner = dummyTx.safeInstance.safe.getOwners()[0]; // Approve the cancellation transaction hash vm.prank(owner); - safeInstance.safe.approveHash(cancellationTxHash); + safeInstance.safe.approveHash(cancellationTx.hash); // Encode the prevalidated cancellation signature - bytes memory signatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); + bytes memory cancellationSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); // Get the cancellation threshold - uint256 cancellationThreshold = timelockGuard.cancellationThreshold(safeInstance.safe); + uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); - emit CancellationThresholdUpdated(safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); + emit CancellationThresholdUpdated(dummyTx.safeInstance.safe, cancellationThreshold, cancellationThreshold + 1); vm.expectEmit(true, true, true, true); - emit TransactionCancelled(safeInstance.safe, txHash); - timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, signatures); + 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.getScheduledTransaction(safeInstance.safe, txHash); + timelockGuard.getScheduledTransaction(dummyTx.safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.cancelled, true); } + /// @notice Verifies cancelling an unscheduled transaction reverts. function test_cancelTransaction_revertsIfTransactionNotScheduled_reverts() external { - // Get the transaction hash - (, bytes32 txHash,) = _getDummyTxWithSignaturesAndHash(safeInstance); - - // Get the cancellation signatures - uint256 nonce = safeInstance.safe.nonce(); + 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, txHash, nonce, new bytes(0)); + timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); } } @@ -553,6 +534,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { 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); @@ -562,9 +544,8 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { function test_checkTransaction_scheduledTransactionAfterDelay_succeeds() external { // Schedule a transaction uint256 nonce = safeInstance.safe.nonce(); - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); - timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); // Fast forward past the timelock delay vm.warp(block.timestamp + TIMELOCK_DELAY); @@ -583,24 +564,24 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { vm.prank(address(safeInstance.safe)); vm.expectEmit(true, true, true, true); - emit TransactionExecuted(safeInstance.safe, nonce, txHash); + emit TransactionExecuted(safeInstance.safe, nonce, dummyTx.hash); timelockGuard.checkTransaction( - dummyTxParams.to, - dummyTxParams.value, - dummyTxParams.data, - dummyTxParams.operation, - dummyTxParams.safeTxGas, - dummyTxParams.baseGas, - dummyTxParams.gasPrice, - dummyTxParams.gasToken, - dummyTxParams.refundReceiver, + 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.getScheduledTransaction(safeInstance.safe, txHash); + timelockGuard.getScheduledTransaction(safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.executed, true); // Confirm that the cancellation threshold is reset @@ -609,11 +590,10 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { /// @notice Test that checkTransaction reverts when scheduled transaction delay hasn't passed function test_checkTransaction_scheduledTransactionNotReady_reverts() external { - (ExecTransactionParams memory dummyTxParams,, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); // Schedule the transaction but do not advance time past the timelock delay - timelockGuard.scheduleTransaction(safeInstance.safe, safeInstance.safe.nonce(), dummyTxParams, signatures); + 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))); @@ -621,15 +601,15 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { vm.expectRevert(TimelockGuard.TimelockGuard_TransactionNotReady.selector); vm.prank(address(safeInstance.safe)); timelockGuard.checkTransaction( - dummyTxParams.to, - dummyTxParams.value, - dummyTxParams.data, - dummyTxParams.operation, - dummyTxParams.safeTxGas, - dummyTxParams.baseGas, - dummyTxParams.gasPrice, - dummyTxParams.gasToken, - dummyTxParams.refundReceiver, + 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) ); @@ -638,20 +618,13 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { /// @notice Test that checkTransaction reverts when scheduled transaction was cancelled function test_checkTransaction_scheduledTransactionCancelled_reverts() external { // Schedule a transaction - (ExecTransactionParams memory dummyTxParams, bytes32 txHash, bytes memory signatures) = - _getDummyTxWithSignaturesAndHash(safeInstance); - uint256 nonce = safeInstance.safe.nonce(); - timelockGuard.scheduleTransaction(safeInstance.safe, nonce, dummyTxParams, signatures); - - // new scope for the compiler error which must not be named - { - // Cancel the transaction - uint256 numSignatures = timelockGuard.cancellationThreshold(safeInstance.safe); - ExecTransactionParams memory cancellationTxParams = _getCancellationTx(address(safeInstance.safe), txHash); - bytes32 cancellationTxHash = _getTxHash(safeInstance, cancellationTxParams, nonce); - bytes memory cancelSignatures = _getSignaturesForTx(safeInstance, cancellationTxHash, numSignatures); - timelockGuard.cancelTransaction(safeInstance.safe, txHash, nonce, cancelSignatures); - } + 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 @@ -661,15 +634,15 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { vm.expectRevert(TimelockGuard.TimelockGuard_TransactionAlreadyCancelled.selector); vm.prank(address(safeInstance.safe)); timelockGuard.checkTransaction( - dummyTxParams.to, - dummyTxParams.value, - dummyTxParams.data, - dummyTxParams.operation, - dummyTxParams.safeTxGas, - dummyTxParams.baseGas, - dummyTxParams.gasPrice, - dummyTxParams.gasToken, - dummyTxParams.refundReceiver, + 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) ); @@ -678,21 +651,21 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { /// @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 - ExecTransactionParams memory dummyTxParams = _getDummyTxParams(); + 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( - dummyTxParams.to, - dummyTxParams.value, - dummyTxParams.data, - dummyTxParams.operation, - dummyTxParams.safeTxGas, - dummyTxParams.baseGas, - dummyTxParams.gasPrice, - dummyTxParams.gasToken, - dummyTxParams.refundReceiver, + 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) ); From c84267dc05ed66d21c8db7d73731d9a481beeced Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 19 Sep 2025 16:54:47 -0400 Subject: [PATCH 047/102] Add unreachable AlreadyExecuted error --- .../src/safe/TimelockGuard.sol | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 8ffd858ebb7c1..8d55480ba01ba 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -58,6 +58,9 @@ contract TimelockGuard is IGuard, ISemver { /// @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); @@ -271,6 +274,9 @@ contract TimelockGuard is IGuard, ISemver { 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(); } @@ -353,21 +359,28 @@ contract TimelockGuard is IGuard, ISemver { // Get the scheduled transaction ScheduledTransaction storage scheduledTx = scheduledTransactions[callingSafe][txHash]; - // Check if the transaction has been scheduled - if (scheduledTx.executionTime == 0) { - revert TimelockGuard_TransactionNotScheduled(); - } - // 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(); + } + // Check if the timelock delay has passed if (scheduledTx.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; @@ -382,6 +395,6 @@ contract TimelockGuard is IGuard, ISemver { function checkAfterExecution(bytes32, bool) external override { // Do nothing // In order to follow the Checks-Effects-Interactions pattern, - // all checks and effects should be done in the checkTransaction function. + // all logic should be done in the checkTransaction function. } } From 733a6e1aa44a13e8667704fd433bfc7c6ad03955 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 19 Sep 2025 16:54:59 -0400 Subject: [PATCH 048/102] Add integration tests --- .../test/safe/TimelockGuard.t.sol | 107 +++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index f62e1164dec31..c094b3f7751cd 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -421,11 +421,6 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { timelockGuard.scheduleTransaction(dummyTx.safeInstance.safe, dummyTx.nonce, dummyTx.params, dummyTx.signatures); } - /// @notice Placeholder for verifying rescheduling after cancellation behaviour. - function test_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { - // TODO: Implement once cancelTransaction is implemented and tested - } - /// @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 @@ -671,3 +666,105 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { ); } } + +/// @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); + } + + /// @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.getScheduledTransaction(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_disablThenResetGuard_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)); + 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)); + 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(); + } +} From 577050962dc4970a87c8109eab38bd70a5661ef6 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 19 Sep 2025 09:51:58 -0400 Subject: [PATCH 049/102] Add getPendingTransactions function and tests --- .../src/safe/TimelockGuard.sol | 40 ++++++++++++---- .../test/safe/TimelockGuard.t.sol | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 8d55480ba01ba..145e8c6fe314f 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -7,6 +7,9 @@ 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"; @@ -16,6 +19,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// 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; @@ -26,6 +31,7 @@ contract TimelockGuard is IGuard, ISemver { uint256 executionTime; bool cancelled; bool executed; + ExecTransactionParams params; } /// @notice Mapping from Safe address to its guard configuration @@ -34,6 +40,10 @@ contract TimelockGuard is IGuard, ISemver { /// @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; @@ -103,6 +113,24 @@ contract TimelockGuard is IGuard, ISemver { 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 getPendingTransactions(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 /// @dev MUST allow an arbitrary number of Safe contracts to use the contract as a guard /// @dev MUST revert if the contract is not enabled as a guard for the Safe @@ -241,18 +269,12 @@ contract TimelockGuard is IGuard, ISemver { // Schedule the transaction scheduledTransactions[_safe][txHash] = - ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false }); + ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); + safePendingTxHashes[_safe].add(txHash); emit TransactionScheduled(_safe, txHash, executionTime); } - /// @notice Returns the list of all scheduled but not cancelled transactions for a given safe - /// @dev MUST NOT revert - NOT IMPLEMENTED YET - /// @return List of pending transaction hashes - function checkPendingTransactions(address) external pure returns (bytes32[] memory) { - return new bytes32[](0); - } - /// @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, which would be to sign and execute an empty @@ -295,6 +317,7 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, safeCancellationThreshold[_safe]); scheduledTransactions[_safe][_txHash].cancelled = true; + safePendingTxHashes[_safe].remove(_txHash); increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash); @@ -383,6 +406,7 @@ contract TimelockGuard is IGuard, ISemver { // Set the transaction as executed scheduledTx.executed = true; + safePendingTxHashes[callingSafe].remove(txHash); // Reset the cancellation threshold resetCancellationThreshold(callingSafe); diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index c094b3f7751cd..df2b023f59fe1 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -449,6 +449,54 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } } +contract TimelockGuard_GetPendingTransactions_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY); + } + + function test_getPendingTransactions_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.getPendingTransactions(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_getPendingTransactions_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.getPendingTransactions(safe); + assertEq(pendingTransactions.length, 0); + } + + function test_getPendingTransactions_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.getPendingTransactions(safe); + assertEq(pendingTransactions.length, 0); + } +} + /// @title TimelockGuard_CancelTransaction_Test /// @notice Tests for cancelTransaction function contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { From 572d6cf51ab120831e95c6e3183352b99707f33d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 19 Sep 2025 17:10:24 -0400 Subject: [PATCH 050/102] Add tests for getScheduledTransaction --- .../test/safe/TimelockGuard.t.sol | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index df2b023f59fe1..19ff3efb671ea 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -449,6 +449,30 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } } +/// @title TimelockGuard_GetScheduledTransactions_Test +/// @notice Tests for getScheduledTransactions function +contract TimelockGuard_GetScheduledTransactions_Test is TimelockGuard_TestInit { + /// @notice Configures the guard before each scheduleTransaction test. + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY); + } + + function test_getScheduledTransaction_succeeds() external { + // schedule a transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.getScheduledTransaction(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_GetPendingTransactions_Test +/// @notice Tests for getPendingTransactions function contract TimelockGuard_GetPendingTransactions_Test is TimelockGuard_TestInit { function setUp() public override { super.setUp(); From c8b655c2180159cf4c3eff13d69379eec7c46ea0 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 22 Sep 2025 13:21:06 -0400 Subject: [PATCH 051/102] Add _ prefix in front of internal mappings --- .../src/safe/TimelockGuard.sol | 64 +++++++++---------- .../test/safe/TimelockGuard.t.sol | 3 +- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 145e8c6fe314f..773d124fa6f18 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -35,17 +35,17 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Mapping from Safe address to its guard configuration - mapping(Safe => GuardConfig) public safeConfigs; + mapping(Safe => GuardConfig) internal _timelockSafeConfiguration; /// @notice Mapping from Safe and tx id to scheduled transaction. - mapping(Safe => mapping(bytes32 => ScheduledTransaction)) internal scheduledTransactions; + 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; + mapping(Safe => EnumerableSet.Bytes32Set) internal _safePendingTxHashes; /// @notice Mapping from Safe to cancellation threshold. - mapping(Safe => uint256) internal safeCancellationThreshold; + mapping(Safe => uint256) internal _safeCancellationThreshold; /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -103,14 +103,14 @@ contract TimelockGuard is IGuard, ISemver { /// @param _safe The Safe address to query /// @return The timelock delay in seconds function viewTimelockGuardConfiguration(Safe _safe) public view returns (GuardConfig memory) { - return safeConfigs[_safe]; + 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 getScheduledTransaction(Safe _safe, bytes32 _txHash) public view returns (ScheduledTransaction memory) { - return scheduledTransactions[_safe][_txHash]; + return _scheduledTransactions[_safe][_txHash]; } /// @notice Returns the list of all scheduled but not cancelled or executed transactions for @@ -123,10 +123,10 @@ contract TimelockGuard is IGuard, ISemver { /// in a block. /// @return List of pending transaction hashes function getPendingTransactions(Safe _safe) external view returns (ScheduledTransaction[] memory) { - bytes32[] memory hashes = safePendingTxHashes[_safe].values(); + 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]]; + scheduled[i] = _scheduledTransactions[_safe][hashes[i]]; } return scheduled; } @@ -154,16 +154,16 @@ contract TimelockGuard is IGuard, ISemver { } // Store the configuration for this safe - safeConfigs[callingSafe].timelockDelay = _timelockDelay; + _timelockSafeConfiguration[callingSafe].timelockDelay = _timelockDelay; emit GuardConfigured(callingSafe, _timelockDelay); // If timelock delay is 0, ensure the cancellation threshold is deleted if (_timelockDelay == 0) { - delete safeCancellationThreshold[callingSafe]; + delete _safeCancellationThreshold[callingSafe]; } else { // Initialize cancellation threshold to 1 - safeCancellationThreshold[callingSafe] = 1; + _safeCancellationThreshold[callingSafe] = 1; } } @@ -178,7 +178,7 @@ contract TimelockGuard is IGuard, ISemver { return 0; } - return safeCancellationThreshold[_safe]; + return _safeCancellationThreshold[_safe]; } /// @notice Returns the blocking threshold threshold for a given safe @@ -218,7 +218,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that the guard has been configured for the Safe - if (safeConfigs[_safe].timelockDelay == 0) { + if (_timelockSafeConfiguration[_safe].timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -256,7 +256,7 @@ contract TimelockGuard is IGuard, ISemver { // 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) { + if (_scheduledTransactions[_safe][txHash].executionTime != 0) { revert TimelockGuard_TransactionAlreadyScheduled(); } @@ -265,12 +265,12 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkSignatures(txHash, txHashData, _signatures); // Calculate the execution time - uint256 executionTime = block.timestamp + safeConfigs[_safe].timelockDelay; + uint256 executionTime = block.timestamp + _timelockSafeConfiguration[_safe].timelockDelay; // Schedule the transaction - scheduledTransactions[_safe][txHash] = + _scheduledTransactions[_safe][txHash] = ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); - safePendingTxHashes[_safe].add(txHash); + _safePendingTxHashes[_safe].add(txHash); emit TransactionScheduled(_safe, txHash, executionTime); } @@ -293,13 +293,13 @@ contract TimelockGuard is IGuard, ISemver { /// 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) { + if (_scheduledTransactions[_safe][_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } - if (scheduledTransactions[_safe][_txHash].executed) { + if (_scheduledTransactions[_safe][_txHash].executed) { revert TimelockGuard_TransactionAlreadyExecuted(); } - if (scheduledTransactions[_safe][_txHash].executionTime == 0) { + if (_scheduledTransactions[_safe][_txHash].executionTime == 0) { revert TimelockGuard_TransactionNotScheduled(); } @@ -314,10 +314,10 @@ contract TimelockGuard is IGuard, ISemver { // 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]); + _safe.checkNSignatures(cancellationTxHash, cancellationTxData, _signatures, _safeCancellationThreshold[_safe]); - scheduledTransactions[_safe][_txHash].cancelled = true; - safePendingTxHashes[_safe].remove(_txHash); + _scheduledTransactions[_safe][_txHash].cancelled = true; + _safePendingTxHashes[_safe].remove(_txHash); increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash); @@ -326,18 +326,18 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Increase the cancellation threshold for a safe /// @dev This function must be caled only once and only when calling cancel function increaseCancellationThreshold(Safe _safe) internal { - if (safeCancellationThreshold[_safe] < blockingThreshold(_safe)) { - uint256 oldThreshold = safeCancellationThreshold[_safe]; - safeCancellationThreshold[_safe]++; - emit CancellationThresholdUpdated(_safe, oldThreshold, safeCancellationThreshold[_safe]); + if (_safeCancellationThreshold[_safe] < blockingThreshold(_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; + uint256 oldThreshold = _safeCancellationThreshold[_safe]; + _safeCancellationThreshold[_safe] = 1; emit CancellationThresholdUpdated(_safe, oldThreshold, 1); } @@ -361,7 +361,7 @@ contract TimelockGuard is IGuard, ISemver { { Safe callingSafe = Safe(payable(msg.sender)); - if (safeConfigs[callingSafe].timelockDelay == 0) { + 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 @@ -380,7 +380,7 @@ contract TimelockGuard is IGuard, ISemver { ); // Get the scheduled transaction - ScheduledTransaction storage scheduledTx = scheduledTransactions[callingSafe][txHash]; + ScheduledTransaction storage scheduledTx = _scheduledTransactions[callingSafe][txHash]; // Check if the transaction was cancelled if (scheduledTx.cancelled) { @@ -406,7 +406,7 @@ contract TimelockGuard is IGuard, ISemver { // Set the transaction as executed scheduledTx.executed = true; - safePendingTxHashes[callingSafe].remove(txHash); + _safePendingTxHashes[callingSafe].remove(txHash); // Reset the cancellation threshold resetCancellationThreshold(callingSafe); diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 19ff3efb671ea..fef334ccb50a3 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -463,7 +463,8 @@ contract TimelockGuard_GetScheduledTransactions_Test is TimelockGuard_TestInit { TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); - TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.getScheduledTransaction(safe, dummyTx.hash); + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.getScheduledTransaction(safe, dummyTx.hash); assertEq(scheduledTransaction.executionTime, INIT_TIME + TIMELOCK_DELAY); assertEq(scheduledTransaction.cancelled, false); assertEq(scheduledTransaction.executed, false); From df3daa63a6f486e3da96757bf506875a62245af8 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 22 Sep 2025 13:22:23 -0400 Subject: [PATCH 052/102] Rename viewTimelockGuard to timelockSafeConfigurationper specs --- .../src/safe/TimelockGuard.sol | 2 +- .../test/safe/TimelockGuard.t.sol | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 773d124fa6f18..23688f89698ed 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -102,7 +102,7 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The timelock delay in seconds - function viewTimelockGuardConfiguration(Safe _safe) public view returns (GuardConfig memory) { + function timelockSafeConfiguration(Safe _safe) public view returns (GuardConfig memory) { return _timelockSafeConfiguration[_safe]; } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index fef334ccb50a3..704558cd5323a 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -236,7 +236,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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.viewTimelockGuardConfiguration(safeInstance.safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safeInstance.safe); assertEq(config.timelockDelay, 0); // configured is now determined by timelockDelay == 0 assertEq(config.timelockDelay == 0, true); @@ -245,7 +245,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test /// @notice Validates the configuration view reflects the stored timelock delay. function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safeInstance.safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safeInstance.safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -262,7 +262,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -291,7 +291,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - TimelockGuard.GuardConfig memory config = timelockGuard.viewTimelockGuardConfiguration(safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safe); assertEq(config.timelockDelay, ONE_YEAR); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -301,7 +301,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; @@ -319,14 +319,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, newDelay); + assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, newDelay); } /// @notice Ensures setting delay to zero clears the configuration. function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, TIMELOCK_DELAY); // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); @@ -335,7 +335,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 - assertEq(timelockGuard.viewTimelockGuardConfiguration(safe).timelockDelay, 0); + assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, 0); // Cancellation threshold should be reset to 0 assertEq(timelockGuard.cancellationThreshold(safe), 0); } @@ -404,7 +404,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_guardNotConfigured_reverts() external { // Enable the guard on the unguarded Safe, but don't configure it _enableGuard(unguardedSafe); - assertEq(timelockGuard.viewTimelockGuardConfiguration(unguardedSafe.safe).timelockDelay, 0); + assertEq(timelockGuard.timelockSafeConfiguration(unguardedSafe.safe).timelockDelay, 0); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); From 072c1b407776d85d617b308638d7d345f849d4e5 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Mon, 22 Sep 2025 14:16:11 -0400 Subject: [PATCH 053/102] Add maxCancellationThreshold --- .../src/safe/TimelockGuard.sol | 35 ++++++++++++------- .../test/safe/TimelockGuard.t.sol | 28 ++++++++++++--- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 23688f89698ed..327275fdbab20 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -167,6 +167,15 @@ contract TimelockGuard is IGuard, ISemver { } } + /// @notice Returns the blocking threshold threshold for a given safe + /// @dev MUST NOT revert + /// @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 /// @dev MUST NOT revert /// @dev MUST return 0 if the contract is not enabled as a guard for the safe @@ -181,13 +190,13 @@ contract TimelockGuard is IGuard, ISemver { return _safeCancellationThreshold[_safe]; } - /// @notice Returns the blocking threshold threshold for a given safe - /// @dev MUST NOT revert - /// @return The current blocking threshold - function blockingThreshold(Safe /* _safe */ ) public pure returns (uint256) { - // TODO: Implement this - return 10; - // return min(quorum, total_owners - quorum + 1) for _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 @@ -318,15 +327,15 @@ contract TimelockGuard is IGuard, ISemver { _scheduledTransactions[_safe][_txHash].cancelled = true; _safePendingTxHashes[_safe].remove(_txHash); - increaseCancellationThreshold(_safe); + _increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash); } /// @notice Increase the cancellation threshold for a safe - /// @dev This function must be caled only once and only when calling cancel - function increaseCancellationThreshold(Safe _safe) internal { - if (_safeCancellationThreshold[_safe] < blockingThreshold(_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]); @@ -335,7 +344,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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 { + function _resetCancellationThreshold(Safe _safe) internal { uint256 oldThreshold = _safeCancellationThreshold[_safe]; _safeCancellationThreshold[_safe] = 1; emit CancellationThresholdUpdated(_safe, oldThreshold, 1); @@ -409,7 +418,7 @@ contract TimelockGuard is IGuard, ISemver { _safePendingTxHashes[callingSafe].remove(txHash); // Reset the cancellation threshold - resetCancellationThreshold(callingSafe); + _resetCancellationThreshold(callingSafe); emit TransactionExecuted(callingSafe, nonce, txHash); } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 704558cd5323a..7975f84929612 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -133,10 +133,6 @@ library TransactionBuilder { // Get only the number of signatures required for the cancellation transaction uint256 cancellationThreshold = _timelockGuard.cancellationThreshold(_tx.safeInstance.safe); - // Set the signatures. We do not update the hash, as it is the same as the transaction - // being cancelled. - // cancellation.setSignatures(cancellationThreshold); - cancellation.updateTransaction(cancellationThreshold); return cancellation; } @@ -797,7 +793,7 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { /// @notice Test that the guard can be disabled while still configured, and then can be /// deconfigured - function test_integration_disablThenResetGuard_succeeds() external { + 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))); @@ -840,4 +836,26 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { 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.cancellationThreshold(safeInstance.safe), maxThreshold); + } } From 487b09807a366603e176a0f86bb9b39627a10e57 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 23 Sep 2025 13:47:26 -0400 Subject: [PATCH 054/102] Improve names on getter functions --- .../interfaces/safe/ITimelockGuard.sol | 10 +-- .../snapshots/abi/TimelockGuard.json | 14 ++-- .../src/safe/TimelockGuard.sol | 15 +++- .../test/safe/TimelockGuard.t.sol | 82 +++++++++---------- 4 files changed, 64 insertions(+), 57 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 8c18257efd423..f0aab07856b29 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -43,11 +43,11 @@ interface Interface { event TransactionCancelled(address indexed safe, bytes32 indexed txId); event TransactionScheduled(address indexed safe, bytes32 indexed txId, uint256 when); - function blockingThreshold(address) external pure returns (uint256); + function blockingThresholdForSafe(address) external pure returns (uint256); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; - function cancellationThreshold(address _safe) external view returns (uint256); + function cancellationThresholdForSafe(address _safe) external view returns (uint256); function checkAfterExecution(bytes32, bool) external; - function checkPendingTransactions(address) external pure returns (bytes32[] memory); + function pendingTransactionsForSafe(address) external pure returns (bytes32[] memory); function checkTransaction( address, uint256 _value, @@ -62,7 +62,7 @@ interface Interface { address ) external; function configureTimelockGuard(uint256 _timelockDelay) external; - function getScheduledTransaction(address _safe, bytes32 _txHash) + function scheduledTransactionForSafe(address _safe, bytes32 _txHash) external view returns (TimelockGuard.ScheduledTransaction memory); @@ -74,5 +74,5 @@ interface Interface { bytes memory _signatures ) external; function version() external view returns (string memory); - function viewTimelockGuardConfiguration(address _safe) external view returns (TimelockGuard.GuardConfig 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 index 8c529698e9514..838896bda8417 100644 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -7,7 +7,7 @@ "type": "address" } ], - "name": "blockingThreshold", + "name": "blockingThresholdForSafe", "outputs": [ { "internalType": "uint256", @@ -15,7 +15,7 @@ "type": "uint256" } ], - "stateMutability": "pure", + "stateMutability": "view", "type": "function" }, { @@ -54,7 +54,7 @@ "type": "address" } ], - "name": "cancellationThreshold", + "name": "cancellationThresholdForSafe", "outputs": [ { "internalType": "uint256", @@ -91,7 +91,7 @@ "type": "address" } ], - "name": "checkPendingTransactions", + "name": "pendingTransactionsForSafe", "outputs": [ { "internalType": "bytes32[]", @@ -198,7 +198,7 @@ "type": "bytes32" } ], - "name": "getScheduledTransaction", + "name": "scheduledTransactionForSafe", "outputs": [ { "components": [ @@ -346,7 +346,7 @@ "type": "address" } ], - "name": "viewTimelockGuardConfiguration", + "name": "timelockConfigurationForSafe", "outputs": [ { "components": [ @@ -535,4 +535,4 @@ "name": "TimelockGuard_TransactionNotScheduled", "type": "error" } -] \ No newline at end of file +] diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 327275fdbab20..069e24cb10381 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -102,14 +102,21 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The timelock delay in seconds - function timelockSafeConfiguration(Safe _safe) public view returns (GuardConfig memory) { + 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 getScheduledTransaction(Safe _safe, bytes32 _txHash) public view returns (ScheduledTransaction memory) { + function scheduledTransactionForSafe( + Safe _safe, + bytes32 _txHash + ) + public + view + returns (ScheduledTransaction memory) + { return _scheduledTransactions[_safe][_txHash]; } @@ -122,7 +129,7 @@ contract TimelockGuard is IGuard, ISemver { /// 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 getPendingTransactions(Safe _safe) external view returns (ScheduledTransaction[] memory) { + 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++) { @@ -181,7 +188,7 @@ contract TimelockGuard is IGuard, ISemver { /// @dev MUST return 0 if the contract is not enabled as a guard for the safe /// @param _safe The Safe address to query /// @return The current cancellation threshold - function cancellationThreshold(Safe _safe) public view returns (uint256) { + function cancellationThresholdForSafe(Safe _safe) public view returns (uint256) { // Return 0 if guard is not enabled if (!_isGuardEnabled(_safe)) { return 0; diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 7975f84929612..8e904dc2dfbfd 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -131,7 +131,7 @@ library TransactionBuilder { cancellation.params.data = abi.encodeWithSignature("cancelTransaction(bytes32)", _tx.hash); // Get only the number of signatures required for the cancellation transaction - uint256 cancellationThreshold = _timelockGuard.cancellationThreshold(_tx.safeInstance.safe); + uint256 cancellationThreshold = _timelockGuard.cancellationThresholdForSafe(_tx.safeInstance.safe); cancellation.updateTransaction(cancellationThreshold); return cancellation; @@ -232,7 +232,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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.timelockSafeConfiguration(safeInstance.safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); assertEq(config.timelockDelay, 0); // configured is now determined by timelockDelay == 0 assertEq(config.timelockDelay == 0, true); @@ -241,7 +241,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test /// @notice Validates the configuration view reflects the stored timelock delay. function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safeInstance.safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -258,7 +258,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); assertEq(config.timelockDelay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -287,7 +287,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockSafeConfiguration(safe); + TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); assertEq(config.timelockDelay, ONE_YEAR); // configured is now determined by timelockDelay != 0 assertEq(config.timelockDelay != 0, true); @@ -297,7 +297,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; @@ -315,14 +315,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, newDelay); + assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, newDelay); } /// @notice Ensures setting delay to zero clears the configuration. function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_DELAY); // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); @@ -331,9 +331,9 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 - assertEq(timelockGuard.timelockSafeConfiguration(safe).timelockDelay, 0); + assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, 0); // Cancellation threshold should be reset to 0 - assertEq(timelockGuard.cancellationThreshold(safe), 0); + assertEq(timelockGuard.cancellationThresholdForSafe(safe), 0); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -346,19 +346,19 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_CancellationThreshold_Test -/// @notice Tests for cancellationThreshold function -contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { +/// @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.cancellationThreshold(Safe(payable(unguardedSafe.safe))); + 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.cancellationThreshold(safe); + uint256 threshold = timelockGuard.cancellationThresholdForSafe(safe); assertEq(threshold, 0); } @@ -368,7 +368,7 @@ contract TimelockGuard_CancellationThreshold_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); // Should default to 1 after configuration - uint256 threshold = timelockGuard.cancellationThreshold(safe); + uint256 threshold = timelockGuard.cancellationThresholdForSafe(safe); assertEq(threshold, 1); } @@ -400,7 +400,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { function test_scheduleTransaction_guardNotConfigured_reverts() external { // Enable the guard on the unguarded Safe, but don't configure it _enableGuard(unguardedSafe); - assertEq(timelockGuard.timelockSafeConfiguration(unguardedSafe.safe).timelockDelay, 0); + assertEq(timelockGuard.timelockConfigurationForSafe(unguardedSafe.safe).timelockDelay, 0); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); @@ -445,22 +445,22 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_GetScheduledTransactions_Test -/// @notice Tests for getScheduledTransactions function -contract TimelockGuard_GetScheduledTransactions_Test is TimelockGuard_TestInit { +/// @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); } - function test_getScheduledTransaction_succeeds() external { + function test_scheduledTransactionForSafe_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.getScheduledTransaction(safe, dummyTx.hash); + timelockGuard.scheduledTransactionForSafe(safe, dummyTx.hash); assertEq(scheduledTransaction.executionTime, INIT_TIME + TIMELOCK_DELAY); assertEq(scheduledTransaction.cancelled, false); assertEq(scheduledTransaction.executed, false); @@ -468,27 +468,27 @@ contract TimelockGuard_GetScheduledTransactions_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_GetPendingTransactions_Test -/// @notice Tests for getPendingTransactions function -contract TimelockGuard_GetPendingTransactions_Test is TimelockGuard_TestInit { +/// @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); } - function test_getPendingTransactions_succeeds() external { + function test_pendingTransactionsForSafe_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.getPendingTransactions(safe); + 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_getPendingTransactions_removeTransactionAfterCancellation_succeeds() external { + function test_pendingTransactionsForSafe_removeTransactionAfterCancellation_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); @@ -498,11 +498,11 @@ contract TimelockGuard_GetPendingTransactions_Test is TimelockGuard_TestInit { timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); // get the pending transactions - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.getPendingTransactions(safe); + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); assertEq(pendingTransactions.length, 0); } - function test_getPendingTransactions_removeTransactionAfterExecution_succeeds() external { + function test_pendingTransactionsForSafe_removeTransactionAfterExecution_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); @@ -513,7 +513,7 @@ contract TimelockGuard_GetPendingTransactions_Test is TimelockGuard_TestInit { dummyTx.executeTransaction(); // get the pending transactions - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.getPendingTransactions(safe); + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); assertEq(pendingTransactions.length, 0); } } @@ -536,7 +536,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Get the cancellation transaction TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); - uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); + uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); @@ -545,7 +545,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { emit TransactionCancelled(safeInstance.safe, dummyTx.hash); timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); - assertEq(timelockGuard.getScheduledTransaction(safeInstance.safe, dummyTx.hash).cancelled, true); + assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).cancelled, true); } /// @notice Confirms pre-approved hashes can authorise cancellations. @@ -567,7 +567,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { bytes memory cancellationSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); // Get the cancellation threshold - uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); + uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); @@ -578,7 +578,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.getScheduledTransaction(dummyTx.safeInstance.safe, dummyTx.hash); + timelockGuard.scheduledTransactionForSafe(dummyTx.safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.cancelled, true); } @@ -617,13 +617,13 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { 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("cancellationThreshold(address)").with_key( + uint256 slot = stdstore.target(address(timelockGuard)).sig("cancellationThresholdForSafe(address)").with_key( address(safeInstance.safe) ).find(); vm.store( address(timelockGuard), bytes32(slot), - bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe) + 1)) + bytes32(uint256(timelockGuard.cancellationThresholdForSafe(safeInstance.safe) + 1)) ); vm.prank(address(safeInstance.safe)); @@ -645,11 +645,11 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is executed TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.getScheduledTransaction(safeInstance.safe, dummyTx.hash); + timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.executed, true); // Confirm that the cancellation threshold is reset - assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); + assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), 1); } /// @notice Test that checkTransaction reverts when scheduled transaction delay hasn't passed @@ -752,7 +752,7 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { vm.warp(block.timestamp + TIMELOCK_DELAY); dummyTx.executeTransaction(); - assertEq(timelockGuard.getScheduledTransaction(safeInstance.safe, dummyTx.hash).executed, true); + assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).executed, true); } /// @notice Test that scheduling a transaction and then executing it twice reverts @@ -856,6 +856,6 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); } - assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), maxThreshold); + assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), maxThreshold); } } From 93256f2306ea6675fdeb781c6a60b7e28bd75e51 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 23 Sep 2025 14:07:22 -0400 Subject: [PATCH 055/102] Remove @dev tags with invariants Avoids duplicating logic between specs and implementation --- .../src/safe/TimelockGuard.sol | 33 ++++--------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 069e24cb10381..a80d719bdeaeb 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -99,7 +99,6 @@ contract TimelockGuard is IGuard, ISemver { string public constant version = "1.0.0"; /// @notice Returns the timelock delay for a given Safe - /// @dev MUST never revert /// @param _safe The Safe address to query /// @return The timelock delay in seconds function timelockConfigurationForSafe(Safe _safe) public view returns (GuardConfig memory) { @@ -139,13 +138,6 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Configure the contract as a timelock guard by setting the timelock delay - /// @dev MUST allow an arbitrary number of Safe contracts to use the contract as a guard - /// @dev MUST revert if the contract is not enabled as a guard for the Safe - /// @dev MUST revert if timelock_delay is longer than 1 year - /// @dev MUST set the caller as a Safe - /// @dev MUST take timelock_delay as a parameter and store it as related to the Safe - /// @dev MUST emit a GuardConfigured event with at least timelock_delay as a parameter - /// @dev If _timelockDelay is 0, clears the configuration for the Safe /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) function configureTimelockGuard(uint256 _timelockDelay) external { Safe callingSafe = Safe(payable(msg.sender)); @@ -175,7 +167,6 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Returns the blocking threshold threshold for a given safe - /// @dev MUST NOT revert /// @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 @@ -184,8 +175,6 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Returns the cancellation threshold for a given safe - /// @dev MUST NOT revert - /// @dev MUST return 0 if the contract is not enabled as a guard for the safe /// @param _safe The Safe address to query /// @return The current cancellation threshold function cancellationThresholdForSafe(Safe _safe) public view returns (uint256) { @@ -217,9 +206,6 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Schedule a transaction for execution after the timelock delay. - /// @dev Minimal implementation: checks enabled+configured, uniqueness, cancellation, stores execution time and - /// emits. - /// @dev The txId is computed independent of Safe nonce using all exec params (with keccak(data)). function scheduleTransaction( Safe _safe, uint256 _nonce, @@ -293,20 +279,15 @@ contract TimelockGuard is IGuard, ISemver { /// @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, which would be to sign and execute an empty + /// cancel a partially signed transaction, by signing and executing an empty /// transaction at the same nonce. - /// This enables us to deterministically generate the transaction inputs for a cancellation - /// transaction from the transaction being cancelled. - /// In this case however we cannot use a completely empty transaction (with all inputs other than the nonce - /// being null), - /// as that would allow for the signatures used to cancel one transaction at nonce X to - /// be used to cancel all transactions at nonce X. + /// 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. /// - /// Therefore we define a custom set of inputs for a cancellation transaction, based on the - /// Safe's address as well as the nonce and hash of the transaction being cancelled. - /// - /// Since the Safe's checkNSignatures function is used, the owner can use any method - /// to sign the cancellation transaction inputs, including signing with a private key, + /// 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) { From daad53b9090af05de64177680dfbe9baf7b2f9e8 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 10:20:36 -0400 Subject: [PATCH 056/102] Update configureTimelockGuard to accept and validate signatures outside of the Safe --- .../src/safe/TimelockGuard.sol | 40 +++++++----- .../test/safe/TimelockGuard.t.sol | 64 ++++--------------- 2 files changed, 37 insertions(+), 67 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a80d719bdeaeb..13d9967a67600 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -138,32 +138,38 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Configure the contract as a timelock guard by setting the timelock delay + /// @dev This function does not check if the guard is enabled on the Safe. This provides the option to + /// configure the guard prior to enabling it, or to reset the configuration to 0 after disabling. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) - function configureTimelockGuard(uint256 _timelockDelay) external { - Safe callingSafe = Safe(payable(msg.sender)); - - // Check that this guard is enabled on the calling Safe - if (!_isGuardEnabled(callingSafe)) { - revert TimelockGuard_GuardNotEnabled(); - } - + function configureTimelockGuard(Safe _safe, uint256 _timelockDelay, bytes memory _signatures) external { // Validate timelock delay - must not be longer than 1 year if (_timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); } + // Generate the configuration transaction data + bytes memory data = abi.encodeWithSignature("configureTimelockGuard(uint256)", _timelockDelay); + + // Get the nonce of the Safe to be configured + uint256 nonce = _safe.nonce(); + bytes memory configureTxData = _safe.encodeTransactionData( + address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), nonce + ); + bytes32 configureTxHash = _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(configureTxHash, configureTxData, _signatures); + // Store the configuration for this safe - _timelockSafeConfiguration[callingSafe].timelockDelay = _timelockDelay; + _timelockSafeConfiguration[_safe].timelockDelay = _timelockDelay; - emit GuardConfigured(callingSafe, _timelockDelay); + // Initialize cancellation threshold to 1 + _safeCancellationThreshold[_safe] = 1; - // If timelock delay is 0, ensure the cancellation threshold is deleted - if (_timelockDelay == 0) { - delete _safeCancellationThreshold[callingSafe]; - } else { - // Initialize cancellation threshold to 1 - _safeCancellationThreshold[callingSafe] = 1; - } + emit GuardConfigured(_safe, _timelockDelay); } /// @notice Returns the blocking threshold threshold for a given safe diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 8e904dc2dfbfd..65fe4f0407233 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -213,10 +213,13 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to configure the TimelockGuard for a Safe - function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { - SafeTestLib.execTransaction( - _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)) - ); + function _configureGuard(SafeInstance memory _safeInstance, uint256 _delay) internal { + TransactionBuilder.Transaction memory configureGuardTx = _createEmptyTransaction(_safeInstance); + configureGuardTx.params.to = address(_safeInstance.safe); + configureGuardTx.params.data = abi.encodeWithSignature("configureTimelockGuard(uint256)", _delay); + configureGuardTx.updateTransaction(); + + timelockGuard.configureTimelockGuard(_safeInstance.safe, _delay, configureGuardTx.signatures); } /// @notice Helper to enable guard on a Safe @@ -264,20 +267,12 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { 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); - } - /// @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); + timelockGuard.configureTimelockGuard(safeInstance.safe, tooLongDelay, ""); } /// @notice Asserts the maximum valid delay configures successfully. @@ -300,16 +295,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_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)); - 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); @@ -327,13 +312,12 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); emit GuardConfigured(safe, 0); - vm.prank(address(safeInstance.safe)); - timelockGuard.configureTimelockGuard(0); + _configureGuard(safeInstance, 0); // Timelock delay should be set to 0 assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, 0); - // Cancellation threshold should be reset to 0 - assertEq(timelockGuard.cancellationThresholdForSafe(safe), 0); + // Cancellation threshold should be set to 1 + assertEq(timelockGuard.cancellationThresholdForSafe(safe), 1); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -341,8 +325,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { // Try to clear - should succeed even if not yet configured vm.expectEmit(true, true, true, true); emit GuardConfigured(safe, 0); - vm.prank(address(safeInstance.safe)); - timelockGuard.configureTimelockGuard(0); + _configureGuard(safeInstance, 0); } } @@ -803,37 +786,18 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { 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)); - resetGuardConfigTx.updateTransaction(); - resetGuardConfigTx.scheduleTransaction(timelockGuard); - - // vm.warp(block.timestamp + TIMELOCK_DELAY); - resetGuardConfigTx.executeTransaction(); + _configureGuard(safeInstance, 0); } /// @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)); - resetGuardTx.updateTransaction(); - resetGuardTx.scheduleTransaction(timelockGuard); - - vm.warp(block.timestamp + TIMELOCK_DELAY); - resetGuardTx.executeTransaction(); + _configureGuard(safeInstance, 0); 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(); } From 75bc23b2db4e7fbc4e63d29ee6c8345ad482014f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 23 Sep 2025 14:37:10 -0400 Subject: [PATCH 057/102] Refactor: use a single struct to store all state for a given Safe --- .../src/safe/TimelockGuard.sol | 115 ++++++++++-------- 1 file changed, 66 insertions(+), 49 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 13d9967a67600..354ec41be30ae 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -34,18 +34,16 @@ contract TimelockGuard is IGuard, ISemver { 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 Aggregated state for each Safe using this guard. + struct SafeState { + GuardConfig config; + uint256 cancellationThreshold; + mapping(bytes32 => ScheduledTransaction) scheduledTransactions; + EnumerableSet.Bytes32Set pendingTxHashes; + } - /// @notice Mapping from Safe to cancellation threshold. - mapping(Safe => uint256) internal _safeCancellationThreshold; + /// @notice Mapping from Safe address to its timelock guard state. + mapping(Safe => SafeState) internal _safeState; /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -102,7 +100,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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]; + return _safeState[_safe].config; } /// @notice Returns the scheduled transaction for a given Safe and tx hash @@ -116,7 +114,7 @@ contract TimelockGuard is IGuard, ISemver { view returns (ScheduledTransaction memory) { - return _scheduledTransactions[_safe][_txHash]; + return _safeState[_safe].scheduledTransactions[_txHash]; } /// @notice Returns the list of all scheduled but not cancelled or executed transactions for @@ -129,10 +127,12 @@ contract TimelockGuard is IGuard, ISemver { /// in a block. /// @return List of pending transaction hashes function pendingTransactionsForSafe(Safe _safe) external view returns (ScheduledTransaction[] memory) { - bytes32[] memory hashes = _safePendingTxHashes[_safe].values(); + SafeState storage safeState = _safeState[_safe]; + + bytes32[] memory hashes = safeState.pendingTxHashes.values(); ScheduledTransaction[] memory scheduled = new ScheduledTransaction[](hashes.length); for (uint256 i = 0; i < hashes.length; i++) { - scheduled[i] = _scheduledTransactions[_safe][hashes[i]]; + scheduled[i] = safeState.scheduledTransactions[hashes[i]]; } return scheduled; } @@ -164,12 +164,20 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkSignatures(configureTxHash, configureTxData, _signatures); // Store the configuration for this safe - _timelockSafeConfiguration[_safe].timelockDelay = _timelockDelay; + SafeState storage safeState = _safeState[_safe]; + safeState.config.timelockDelay = _timelockDelay; // Initialize cancellation threshold to 1 - _safeCancellationThreshold[_safe] = 1; + safeState.cancellationThreshold = 1; emit GuardConfigured(_safe, _timelockDelay); + // If timelock delay is 0, ensure the cancellation threshold is deleted + if (_timelockDelay == 0) { + delete _safeCancellationThreshold[_safe]; + } else { + // Initialize cancellation threshold to 1 + _safeCancellationThreshold[_safe] = 1; + } } /// @notice Returns the blocking threshold threshold for a given safe @@ -189,7 +197,7 @@ contract TimelockGuard is IGuard, ISemver { return 0; } - return _safeCancellationThreshold[_safe]; + return _safeState[_safe].cancellationThreshold; } /// @notice Returns the maximum cancellation threshold for a given safe @@ -226,7 +234,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that the guard has been configured for the Safe - if (_timelockSafeConfiguration[_safe].timelockDelay == 0) { + if (_safeState[_safe].config.timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -264,7 +272,7 @@ contract TimelockGuard is IGuard, ISemver { // 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) { + if (_safeState[_safe].scheduledTransactions[txHash].executionTime != 0) { revert TimelockGuard_TransactionAlreadyScheduled(); } @@ -273,12 +281,12 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkSignatures(txHash, txHashData, _signatures); // Calculate the execution time - uint256 executionTime = block.timestamp + _timelockSafeConfiguration[_safe].timelockDelay; + uint256 executionTime = block.timestamp + _safeState[_safe].config.timelockDelay; // Schedule the transaction - _scheduledTransactions[_safe][txHash] = + _safeState[_safe].scheduledTransactions[txHash] = ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); - _safePendingTxHashes[_safe].add(txHash); + _safeState[_safe].pendingTxHashes.add(txHash); emit TransactionScheduled(_safe, txHash, executionTime); } @@ -291,36 +299,42 @@ contract TimelockGuard is IGuard, ISemver { /// 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 + /// 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) { + if (_safeState[_safe].scheduledTransactions[_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } - if (_scheduledTransactions[_safe][_txHash].executed) { + if (_safeState[_safe].scheduledTransactions[_txHash].executed) { revert TimelockGuard_TransactionAlreadyExecuted(); } - if (_scheduledTransactions[_safe][_txHash].executionTime == 0) { + if (_safeState[_safe].scheduledTransactions[_txHash].executionTime == 0) { revert TimelockGuard_TransactionNotScheduled(); } - // Generate the cancellation transaction data - bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); - bytes memory cancellationTxData = _safe.encodeTransactionData( - address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce - ); - bytes32 cancellationTxHash = _safe.getTransactionHash( - address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce - ); - + bytes memory cancellationTxData; + bytes32 cancellationTxHash; + // New scope for the compiler error that must not be named + { + // Generate the cancellation transaction data + bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); + cancellationTxData = _safe.encodeTransactionData( + address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce + ); + cancellationTxHash = _safe.getTransactionHash( + address(_safe), 0, txData, 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); + _safe.checkNSignatures( + cancellationTxHash, cancellationTxData, _signatures, _safeState[_safe].cancellationThreshold + ); + _safeState[_safe].scheduledTransactions[_txHash].cancelled = true; + _safeState[_safe].pendingTxHashes.remove(_txHash); _increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash); @@ -329,18 +343,21 @@ contract TimelockGuard is IGuard, ISemver { /// @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]); + SafeState storage safeState = _safeState[_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 function _resetCancellationThreshold(Safe _safe) internal { - uint256 oldThreshold = _safeCancellationThreshold[_safe]; - _safeCancellationThreshold[_safe] = 1; + SafeState storage safeState = _safeState[_safe]; + uint256 oldThreshold = safeState.cancellationThreshold; + safeState.cancellationThreshold = 1; emit CancellationThresholdUpdated(_safe, oldThreshold, 1); } @@ -364,7 +381,7 @@ contract TimelockGuard is IGuard, ISemver { { Safe callingSafe = Safe(payable(msg.sender)); - if (_timelockSafeConfiguration[callingSafe].timelockDelay == 0) { + if (_safeState[callingSafe].config.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 @@ -383,7 +400,7 @@ contract TimelockGuard is IGuard, ISemver { ); // Get the scheduled transaction - ScheduledTransaction storage scheduledTx = _scheduledTransactions[callingSafe][txHash]; + ScheduledTransaction storage scheduledTx = _safeState[callingSafe].scheduledTransactions[txHash]; // Check if the transaction was cancelled if (scheduledTx.cancelled) { @@ -409,7 +426,7 @@ contract TimelockGuard is IGuard, ISemver { // Set the transaction as executed scheduledTx.executed = true; - _safePendingTxHashes[callingSafe].remove(txHash); + _safeState[callingSafe].pendingTxHashes.remove(txHash); // Reset the cancellation threshold _resetCancellationThreshold(callingSafe); From a5ea6eecf60539bb72a7d2b3f178f6d24cb7f64b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 10:27:07 -0400 Subject: [PATCH 058/102] Do not unnecessarily reset cancellation threshold when config set to 0 --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 354ec41be30ae..9b40f9a1992cc 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -171,13 +171,6 @@ contract TimelockGuard is IGuard, ISemver { safeState.cancellationThreshold = 1; emit GuardConfigured(_safe, _timelockDelay); - // If timelock delay is 0, ensure the cancellation threshold is deleted - if (_timelockDelay == 0) { - delete _safeCancellationThreshold[_safe]; - } else { - // Initialize cancellation threshold to 1 - _safeCancellationThreshold[_safe] = 1; - } } /// @notice Returns the blocking threshold threshold for a given safe From 4d26c466dce2dd96476264d6ccfa245a377f6437 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 13:03:53 -0400 Subject: [PATCH 059/102] Revert "Update configureTimelockGuard to accept and validate signatures outside of the Safe" This reverts commit daad53b9090af05de64177680dfbe9baf7b2f9e8. --- .../src/safe/TimelockGuard.sol | 34 ++++------ .../test/safe/TimelockGuard.t.sol | 62 ++++++++++++++----- 2 files changed, 59 insertions(+), 37 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 9b40f9a1992cc..0571ad661be18 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -138,39 +138,27 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Configure the contract as a timelock guard by setting the timelock delay - /// @dev This function does not check if the guard is enabled on the Safe. This provides the option to - /// configure the guard prior to enabling it, or to reset the configuration to 0 after disabling. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) - function configureTimelockGuard(Safe _safe, uint256 _timelockDelay, bytes memory _signatures) external { + function configureTimelockGuard(uint256 _timelockDelay) external { + Safe callingSafe = Safe(payable(msg.sender)); + + // Check that this guard is enabled on the calling Safe + if (!_isGuardEnabled(callingSafe)) { + revert TimelockGuard_GuardNotEnabled(); + } + // Validate timelock delay - must not be longer than 1 year if (_timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); } - // Generate the configuration transaction data - bytes memory data = abi.encodeWithSignature("configureTimelockGuard(uint256)", _timelockDelay); - - // Get the nonce of the Safe to be configured - uint256 nonce = _safe.nonce(); - bytes memory configureTxData = _safe.encodeTransactionData( - address(_safe), 0, data, Enum.Operation.Call, 0, 0, 0, address(0), address(0), nonce - ); - bytes32 configureTxHash = _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(configureTxHash, configureTxData, _signatures); - // Store the configuration for this safe - SafeState storage safeState = _safeState[_safe]; + SafeState storage safeState = _safeState[callingSafe]; safeState.config.timelockDelay = _timelockDelay; // Initialize cancellation threshold to 1 - safeState.cancellationThreshold = 1; - - emit GuardConfigured(_safe, _timelockDelay); + _resetCancellationThreshold(callingSafe); + emit GuardConfigured(callingSafe, _timelockDelay); } /// @notice Returns the blocking threshold threshold for a given safe diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 65fe4f0407233..655a01f2c6cfe 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -213,13 +213,10 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Helper to configure the TimelockGuard for a Safe - function _configureGuard(SafeInstance memory _safeInstance, uint256 _delay) internal { - TransactionBuilder.Transaction memory configureGuardTx = _createEmptyTransaction(_safeInstance); - configureGuardTx.params.to = address(_safeInstance.safe); - configureGuardTx.params.data = abi.encodeWithSignature("configureTimelockGuard(uint256)", _delay); - configureGuardTx.updateTransaction(); - - timelockGuard.configureTimelockGuard(_safeInstance.safe, _delay, configureGuardTx.signatures); + function _configureGuard(SafeInstance memory _safe, uint256 _delay) internal { + SafeTestLib.execTransaction( + _safe, address(timelockGuard), 0, abi.encodeCall(TimelockGuard.configureTimelockGuard, (_delay)) + ); } /// @notice Helper to enable guard on a Safe @@ -267,12 +264,20 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { 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); + } + /// @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); - timelockGuard.configureTimelockGuard(safeInstance.safe, tooLongDelay, ""); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(tooLongDelay); } /// @notice Asserts the maximum valid delay configures successfully. @@ -295,6 +300,16 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_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)); + 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); @@ -312,12 +327,11 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); emit GuardConfigured(safe, 0); - _configureGuard(safeInstance, 0); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, 0); - // Cancellation threshold should be set to 1 - assertEq(timelockGuard.cancellationThresholdForSafe(safe), 1); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -325,7 +339,8 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { // Try to clear - should succeed even if not yet configured vm.expectEmit(true, true, true, true); emit GuardConfigured(safe, 0); - _configureGuard(safeInstance, 0); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(0); } } @@ -786,18 +801,37 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { vm.warp(block.timestamp + TIMELOCK_DELAY); disableGuardTx.executeTransaction(); - _configureGuard(safeInstance, 0); + // 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)); + 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 { - _configureGuard(safeInstance, 0); + TransactionBuilder.Transaction memory resetGuardTx = _createEmptyTransaction(safeInstance); + resetGuardTx.params.to = address(timelockGuard); + resetGuardTx.params.data = abi.encodeCall(TimelockGuard.configureTimelockGuard, (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(); } From 5fc7d25131fba4dd965e57afe24d40391c290a0b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 13:16:14 -0400 Subject: [PATCH 060/102] Move timelockDelay out of unnecessary struct --- .../interfaces/safe/ITimelockGuard.sol | 6 +--- .../src/safe/TimelockGuard.sol | 19 ++++------- .../test/safe/TimelockGuard.t.sol | 34 +++++++++---------- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index f0aab07856b29..aff9fa38f3bd8 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -6,10 +6,6 @@ library Enum { } library TimelockGuard { - struct GuardConfig { - uint256 timelockDelay; - } - struct ScheduledTransaction { uint256 executionTime; bool cancelled; @@ -74,5 +70,5 @@ interface Interface { bytes memory _signatures ) external; function version() external view returns (string memory); - function timelockConfigurationForSafe(address _safe) external view returns (TimelockGuard.GuardConfig memory); + function timelockConfigurationForSafe(address _safe) external view returns (uint256 timelockDelay); } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 0571ad661be18..a85dc3c3da0ae 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -21,11 +21,6 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; - /// @notice Configuration for a Safe's timelock guard - struct GuardConfig { - uint256 timelockDelay; - } - /// @notice Scheduled transaction struct ScheduledTransaction { uint256 executionTime; @@ -36,7 +31,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Aggregated state for each Safe using this guard. struct SafeState { - GuardConfig config; + uint256 timelockDelay; uint256 cancellationThreshold; mapping(bytes32 => ScheduledTransaction) scheduledTransactions; EnumerableSet.Bytes32Set pendingTxHashes; @@ -99,8 +94,8 @@ contract TimelockGuard is IGuard, ISemver { /// @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 _safeState[_safe].config; + function timelockConfigurationForSafe(Safe _safe) public view returns (uint256) { + return _safeState[_safe].timelockDelay; } /// @notice Returns the scheduled transaction for a given Safe and tx hash @@ -154,7 +149,7 @@ contract TimelockGuard is IGuard, ISemver { // Store the configuration for this safe SafeState storage safeState = _safeState[callingSafe]; - safeState.config.timelockDelay = _timelockDelay; + safeState.timelockDelay = _timelockDelay; // Initialize cancellation threshold to 1 _resetCancellationThreshold(callingSafe); @@ -215,7 +210,7 @@ contract TimelockGuard is IGuard, ISemver { } // Check that the guard has been configured for the Safe - if (_safeState[_safe].config.timelockDelay == 0) { + if (_safeState[_safe].timelockDelay == 0) { revert TimelockGuard_GuardNotConfigured(); } @@ -262,7 +257,7 @@ contract TimelockGuard is IGuard, ISemver { _safe.checkSignatures(txHash, txHashData, _signatures); // Calculate the execution time - uint256 executionTime = block.timestamp + _safeState[_safe].config.timelockDelay; + uint256 executionTime = block.timestamp + _safeState[_safe].timelockDelay; // Schedule the transaction _safeState[_safe].scheduledTransactions[txHash] = @@ -362,7 +357,7 @@ contract TimelockGuard is IGuard, ISemver { { Safe callingSafe = Safe(payable(msg.sender)); - if (_safeState[callingSafe].config.timelockDelay == 0) { + if (_safeState[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 diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 655a01f2c6cfe..2c541829241f7 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -232,19 +232,19 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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); + uint256 delay = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + assertEq(delay, 0); // configured is now determined by timelockDelay == 0 - assertEq(config.timelockDelay == 0, true); + assertEq(delay == 0, true); } /// @notice Validates the configuration view reflects the stored timelock delay. function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); - assertEq(config.timelockDelay, TIMELOCK_DELAY); + uint256 delay = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 - assertEq(config.timelockDelay != 0, true); + assertEq(delay != 0, true); } } @@ -258,10 +258,10 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); - assertEq(config.timelockDelay, TIMELOCK_DELAY); + uint256 delay = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 - assertEq(config.timelockDelay != 0, true); + assertEq(delay != 0, true); } /// @notice Checks configuration reverts when the guard is not enabled. @@ -287,17 +287,17 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - TimelockGuard.GuardConfig memory config = timelockGuard.timelockConfigurationForSafe(safe); - assertEq(config.timelockDelay, ONE_YEAR); + uint256 delay = timelockGuard.timelockConfigurationForSafe(safe); + assertEq(delay, ONE_YEAR); // configured is now determined by timelockDelay != 0 - assertEq(config.timelockDelay != 0, true); + assertEq(delay != 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); - assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfigurationForSafe(safe), TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; @@ -315,14 +315,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, newDelay); + assertEq(timelockGuard.timelockConfigurationForSafe(safe), newDelay); } /// @notice Ensures setting delay to zero clears the configuration. function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfigurationForSafe(safe), TIMELOCK_DELAY); // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); @@ -331,7 +331,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 - assertEq(timelockGuard.timelockConfigurationForSafe(safe).timelockDelay, 0); + assertEq(timelockGuard.timelockConfigurationForSafe(safe), 0); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -398,7 +398,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { 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); + assertEq(timelockGuard.timelockConfigurationForSafe(unguardedSafe.safe), 0); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); From f8ac2f836aa6a8d638801daea80a0e630a380f6c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 15:08:04 -0400 Subject: [PATCH 061/102] Add top level detail natspec, reorder functions by vis and mutability --- .../src/safe/TimelockGuard.sol | 331 ++++++++++-------- 1 file changed, 184 insertions(+), 147 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a85dc3c3da0ae..062450e3e7200 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -15,9 +15,26 @@ 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() +/// @dev This is a singleton contract, any Safe on the network can use this guard to enforce a timelock delay, and +/// allow a subset of signers to cancel a transaction if they do not agree with the execution. This provides +/// significant security improvements over the Safe's default execution mechanism, which will allow any transaction +/// to be executed as long as it is fully signed, and with no mechanism for revealing the existence of said +/// signatures. +/// Usage: +/// In order to use this guard, the Safe must first enable it using Safe.setGuard(), and then configure it +/// by calling TimelockGuard.configureTimelockGuard(). +/// Scheduling and executing transactions: +/// Once enabled and configured, all transactions executed by the Safe's execTransaction() function will revert, +/// unless the transaction has first been scheduled by calling scheduleTransaction() on this contract. Because +/// scheduleTransaction() uses the Safe's own signature verification logic, the same signatures used +/// to execute a transaction can be used to schedule it. +/// Cancelling transactions: +/// Once a transaction has been scheduled, so long as it has not already been executed, it can be +/// cancelled by calling cancelTransaction() on this contract. +/// This mechanism allows for a subset of signers to cancel a transaction if they do not agree with the execution. +/// As an 'anti-griefing' mechanism, the cancellation threshold (the number of signatures required to cancel a +/// transaction) starts at 1, and is automatically increased by 1 after each cancellation. +/// The cancellation threshold is reset to 1 after any transaction is executed successfully. contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -40,6 +57,10 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Mapping from Safe address to its timelock guard state. mapping(Safe => SafeState) internal _safeState; + /// @notice Semantic version. + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + /// @notice Error for when guard is not enabled for the Safe error TimelockGuard_GuardNotEnabled(); @@ -87,9 +108,52 @@ contract TimelockGuard is IGuard, ISemver { /// @param txHash The identifier of the executed transaction (nonce-independent). event TransactionExecuted(Safe indexed safe, uint256 indexed nonce, bytes32 txHash); - /// @notice Semantic version. - /// @custom:semver 1.0.0 - string public constant version = "1.0.0"; + //////////////////////////////////////////////////////////////// + // Internal View Functions // + //////////////////////////////////////////////////////////////// + + /// @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 _safeState[_safe].cancellationThreshold; + } + + /// @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); + } + + //////////////////////////////////////////////////////////////// + // External View Functions // + //////////////////////////////////////////////////////////////// /// @notice Returns the timelock delay for a given Safe /// @param _safe The Safe address to query @@ -132,6 +196,120 @@ contract TimelockGuard is IGuard, ISemver { return scheduled; } + //////////////////////////////////////////////////////////////// + // Guard Interface Functions // + //////////////////////////////////////////////////////////////// + + /// @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 (_safeState[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 = _safeState[callingSafe].scheduledTransactions[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(); + } + + // Check if the timelock delay has passed + if (scheduledTx.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; + _safeState[callingSafe].pendingTxHashes.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. + } + + //////////////////////////////////////////////////////////////// + // 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 + function _increaseCancellationThreshold(Safe _safe) internal { + SafeState storage safeState = _safeState[_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 + function _resetCancellationThreshold(Safe _safe) internal { + SafeState storage safeState = _safeState[_safe]; + uint256 oldThreshold = safeState.cancellationThreshold; + safeState.cancellationThreshold = 1; + emit CancellationThresholdUpdated(_safe, oldThreshold, 1); + } + + //////////////////////////////////////////////////////////////// + // External State-Changing Functions // + //////////////////////////////////////////////////////////////// + /// @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) external { @@ -156,45 +334,6 @@ contract TimelockGuard is IGuard, ISemver { emit GuardConfigured(callingSafe, _timelockDelay); } - /// @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 _safeState[_safe].cancellationThreshold; - } - - /// @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, @@ -315,106 +454,4 @@ contract TimelockGuard is IGuard, ISemver { emit TransactionCancelled(_safe, _txHash); } - - /// @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 { - SafeState storage safeState = _safeState[_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 - function _resetCancellationThreshold(Safe _safe) internal { - SafeState storage safeState = _safeState[_safe]; - uint256 oldThreshold = safeState.cancellationThreshold; - safeState.cancellationThreshold = 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 (_safeState[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 = _safeState[callingSafe].scheduledTransactions[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(); - } - - // Check if the timelock delay has passed - if (scheduledTx.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; - _safeState[callingSafe].pendingTxHashes.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. - } } From 0a77d01b766b6a80609005f83a3ad091a1fb59ad Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 17:12:49 -0400 Subject: [PATCH 062/102] Remove test that does not conform to spec --- .../test/safe/TimelockGuard.t.sol | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 2c541829241f7..3b77f098ccf48 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -789,32 +789,6 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { 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)); - 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); From 34b77e5069d8d9cf347a1bd96f98c3f081723a2a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 17:13:43 -0400 Subject: [PATCH 063/102] Add cancelTransactionOnSafe to interface as reverting function --- .../src/safe/TimelockGuard.sol | 32 ++++++++++--------- .../test/safe/TimelockGuard.t.sol | 5 +-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 062450e3e7200..5bd26b1a774a0 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -430,21 +430,14 @@ contract TimelockGuard is IGuard, ISemver { revert TimelockGuard_TransactionNotScheduled(); } - bytes memory cancellationTxData; - bytes32 cancellationTxHash; - // New scope for the compiler error that must not be named - { - // Generate the cancellation transaction data - bytes memory txData = abi.encodeWithSignature("cancelTransaction(bytes32)", _txHash); - cancellationTxData = _safe.encodeTransactionData( - address(_safe), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce - ); - cancellationTxHash = _safe.getTransactionHash( - address(_safe), 0, txData, 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. + // Generate the cancellation transaction data + bytes memory txData = abi.encodeCall(this.cancelTransactionOnSafe, (_safe, _txHash)); + 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 + ); _safe.checkNSignatures( cancellationTxHash, cancellationTxData, _signatures, _safeState[_safe].cancellationThreshold ); @@ -454,4 +447,13 @@ contract TimelockGuard is IGuard, ISemver { emit TransactionCancelled(_safe, _txHash); } + //////////////////////////////////////////////////////////////// + // Dummy Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data + /// @dev This function is not meant to be called, use cancelTransaction instead + function cancelTransactionOnSafe(Safe, bytes32) public { + revert("This function is not meant to be called, use cancelTransaction instead"); + } } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 3b77f098ccf48..433745ac1777a 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -127,8 +127,9 @@ library TransactionBuilder { // 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); + cancellation.params.to = address(_timelockGuard); + cancellation.params.data = + abi.encodeCall(TimelockGuard.cancelTransactionOnSafe, (Safe(payable(_tx.safeInstance.safe)), _tx.hash)); // Get only the number of signatures required for the cancellation transaction uint256 cancellationThreshold = _timelockGuard.cancellationThresholdForSafe(_tx.safeInstance.safe); From 2b16f8ef5769d685b0750a3bd938268790a7357b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 24 Sep 2025 17:16:00 -0400 Subject: [PATCH 064/102] Add many more comments --- .../src/safe/TimelockGuard.sol | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5bd26b1a774a0..5ab120aed22bc 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -28,6 +28,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// unless the transaction has first been scheduled by calling scheduleTransaction() on this contract. Because /// scheduleTransaction() uses the Safe's own signature verification logic, the same signatures used /// to execute a transaction can be used to schedule it. +/// !Note: this guard does not apply a delay to transactions executed by modules which are installed on the Safe. /// Cancelling transactions: /// Once a transaction has been scheduled, so long as it has not already been executed, it can be /// cancelled by calling cancelTransaction() on this contract. @@ -35,6 +36,9 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// As an 'anti-griefing' mechanism, the cancellation threshold (the number of signatures required to cancel a /// transaction) starts at 1, and is automatically increased by 1 after each cancellation. /// The cancellation threshold is reset to 1 after any transaction is executed successfully. +/// Safe Version Compatibility: +/// This guard is compatible with Safe versions 1.3.0 and higher. Earlier versions of the Safe do not expose +/// the checkSignatures or checkNSignatures functions required by this guard. contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -113,6 +117,7 @@ contract TimelockGuard is IGuard, ISemver { //////////////////////////////////////////////////////////////// /// @notice Returns the blocking threshold threshold for a given safe + /// @param _safe The Safe address to query /// @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 @@ -133,6 +138,7 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Returns the maximum cancellation threshold for a given safe + /// @param _safe The Safe address to query /// @return The maximum cancellation threshold function maxCancellationThreshold(Safe _safe) public view returns (uint256) { uint256 blockingThreshold = _blockingThreshold(_safe); @@ -188,7 +194,12 @@ contract TimelockGuard is IGuard, ISemver { function pendingTransactionsForSafe(Safe _safe) external view returns (ScheduledTransaction[] memory) { SafeState storage safeState = _safeState[_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]]; @@ -287,6 +298,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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 = _safeState[_safe]; @@ -299,6 +311,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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 = _safeState[_safe]; uint256 oldThreshold = safeState.cancellationThreshold; @@ -311,11 +324,19 @@ contract TimelockGuard is IGuard, ISemver { //////////////////////////////////////////////////////////////// /// @notice Configure the contract as a timelock guard by setting the timelock delay + /// @dev This function is only callable by the Safe itself, and will revert if the guard is not enabled on the Safe. + /// Requiring a call from the Safe itself (rather than accepting signatures directly as in cancelTransaction()) + /// is important to ensure that maliciously gathered signatures will not be able to instantly reconfigure + /// the delay to zero. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) function configureTimelockGuard(uint256 _timelockDelay) external { + // Record the calling Safe Safe callingSafe = Safe(payable(msg.sender)); // Check that this guard is enabled on the calling Safe + // There is nothing inherently wrong with configuring the guard on a safe that it is not enabled on, + // but we choose to revert here to avoid users from mistakenly believing that simply configuring the guard + // is enough to activate the delay. if (!_isGuardEnabled(callingSafe)) { revert TimelockGuard_GuardNotEnabled(); } @@ -325,16 +346,24 @@ contract TimelockGuard is IGuard, ISemver { revert TimelockGuard_InvalidTimelockDelay(); } - // Store the configuration for this safe - SafeState storage safeState = _safeState[callingSafe]; - safeState.timelockDelay = _timelockDelay; + // Store the timelock delay for this safe + _safeState[callingSafe].timelockDelay = _timelockDelay; - // Initialize cancellation threshold to 1 + // Initialize or reset the cancellation threshold to 1 _resetCancellationThreshold(callingSafe); emit GuardConfigured(callingSafe, _timelockDelay); } /// @notice Schedule a transaction for execution after the timelock delay. + /// @dev This function validates signatures in the exact same way as the Safe's own execTransaction function, + /// meaning that the same signatures used to schedule a transaction can be used to execute it later. This + /// maintains compatibility with existing signature generation tools. Owners can use any method to sign the + /// a transaction, including signing with a private key, calling the Safe's approveHash function, or EIP1271 + /// contract signatures. + /// @param _safe The Safe address to schedule the transaction for. + /// @param _nonce The nonce of the Safe for the transaction being scheduled. + /// @param _params The parameters of the transaction being scheduled. + /// @param _signatures The signatures of the owners who are scheduling the transaction. function scheduleTransaction( Safe _safe, uint256 _nonce, @@ -398,7 +427,7 @@ contract TimelockGuard is IGuard, ISemver { // Calculate the execution time uint256 executionTime = block.timestamp + _safeState[_safe].timelockDelay; - // Schedule the transaction + // Schedule the transaction and add it to the pending transactions set _safeState[_safe].scheduledTransactions[txHash] = ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); _safeState[_safe].pendingTxHashes.add(txHash); @@ -415,11 +444,20 @@ contract TimelockGuard is IGuard, ISemver { /// 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. + /// @param _safe The Safe address to cancel the transaction for. + /// @param _txHash The hash of the transaction being cancelled. + /// @param _nonce The nonce of the Safe for the transaction being cancelled. + /// @param _signatures The signatures of the owners who are cancelling the transaction. function cancelTransaction(Safe _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external { + // The following checks ensure that the transaction has: + // 1. Been scheduled + // 2. Not already been cancelled + // 3. Not already been executed + // There is nothing inherently wrong with cancelling a transaction a transaction that doesn't meet these criteria, + // but we revert in order to inform the user, and avoid emitting a misleading TransactionCancelled event. if (_safeState[_safe].scheduledTransactions[_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } @@ -438,15 +476,23 @@ contract TimelockGuard is IGuard, ISemver { 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, _safeState[_safe].cancellationThreshold ); + + // Set the transaction as cancelled, and remove it from the pending transactions set _safeState[_safe].scheduledTransactions[_txHash].cancelled = true; _safeState[_safe].pendingTxHashes.remove(_txHash); + + // Increase the cancellation threshold _increaseCancellationThreshold(_safe); emit TransactionCancelled(_safe, _txHash); } + //////////////////////////////////////////////////////////////// // Dummy Functions // //////////////////////////////////////////////////////////////// @@ -454,6 +500,8 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data /// @dev This function is not meant to be called, use cancelTransaction instead function cancelTransactionOnSafe(Safe, bytes32) public { + // Reverting here may cause issues for some signing tooling, but it is better to revert than for + // to silently fail, potentially allowing the caller to believe that the transaction has been cancelled. revert("This function is not meant to be called, use cancelTransaction instead"); } } From e55f7a1f40b6f9ae034982d3bc68aff593df7a2e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 07:45:35 -0600 Subject: [PATCH 065/102] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alberto Cuesta Cañada <38806121+alcueca@users.noreply.github.com> --- .../src/safe/TimelockGuard.sol | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5ab120aed22bc..72a6ad3b40e36 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -39,6 +39,27 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// Safe Version Compatibility: /// This guard is compatible with Safe versions 1.3.0 and higher. Earlier versions of the Safe do not expose /// the checkSignatures or checkNSignatures functions required by this guard. +/// Threats Mitigated and Integration With LivenessModule: +/// This Guard is designed to protect against a number of well-defined scenarios, defined on +/// the two axes of amount of keys compromised, and type of compromise. +/// For scenarios where the keys compromised don't amount to a blocking threshold, regular transactions from the +/// multisig for removal or rotation is the preferred solution. +/// For scenarios where the keys compromised are at least a blocking threshold, but not as much as quorum, the +/// LivenessModule would be used. If there is a quorum of absent keys, but no significant malicious control, the +/// LivenessModule would also be used. +/// The TimelockGuard acts when there is malicious control of a quorum of keys. If the control is temporary, for +/// example by phishing a single set of signatures, then the TimelockGuard is enough to detect and stop the attack +/// entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to execute +/// remediations external to the compromised safe. +/// +---------------------------------------------------------------------+ +/// | | Absent Keys | Malicious Control | +/// +---------------------------------------------------------------------+ +/// | 1+ | Detection and Removal | Detection and Removal | +/// +---------------------------------------------------------------------+ +/// | Blocking Threshold+ | Liveness Module | Liveness Module | +/// +---------------------------------------------------------------------+ +/// | Quorum+ | Liveness Module | Timelock Guard | +/// +---------------------------------------------------------------------+ contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -51,6 +72,15 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Aggregated state for each Safe using this guard. + /// @dev We have chosen for operational reasons to keep a list of pending transactions that can be easily retrieved + /// via a function call. There are several ways to accomplis this but we chose to maintain a separate EnumerableSet + /// with the txHashes of the pending transactions, that needs to be maintained in sync with the mapping that keeps + /// all the data about all the transactions, regardless of their state. + /// We chose this implementation because the set of pending transactions is independent of the core flow, and if + /// there would be a bug in it, it would only affect the `pendingTransactions` view function. + /// A notable alternative was to keen onlt the pending transactions in storage, and remove them as soon as they are + /// cancelled or executed, but that opens the Timelock to some low-risk griefing attackes that we nontheless prefer + /// to avoid. struct SafeState { uint256 timelockDelay; uint256 cancellationThreshold; @@ -329,6 +359,8 @@ contract TimelockGuard is IGuard, ISemver { /// is important to ensure that maliciously gathered signatures will not be able to instantly reconfigure /// the delay to zero. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) + /// @dev We considered several implementations to allow for a pause mechanism of sorts, which could be used if the + /// on-call team needed more time. Ultimately, we rejected all of them in favour of better on-call processes. function configureTimelockGuard(uint256 _timelockDelay) external { // Record the calling Safe Safe callingSafe = Safe(payable(msg.sender)); From 4add3cd38c242f4fa13e9ed04fc8beb250535a38 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 15:59:53 -0400 Subject: [PATCH 066/102] Fix ITimelockGuard iface to match impl --- .../interfaces/safe/ITimelockGuard.sol | 50 +++++++++++-------- .../src/safe/TimelockGuard.sol | 5 +- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index aff9fa38f3bd8..d8c8a748e1b01 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -11,9 +11,7 @@ library TimelockGuard { bool cancelled; bool executed; } -} -interface Interface { struct ExecTransactionParams { address to; uint256 value; @@ -34,31 +32,35 @@ interface Interface { error TimelockGuard_TransactionAlreadyScheduled(); error TimelockGuard_TransactionNotScheduled(); - event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); - event GuardConfigured(address indexed safe, uint256 timelockDelay); - event TransactionCancelled(address indexed safe, bytes32 indexed txId); - event TransactionScheduled(address indexed safe, bytes32 indexed txId, uint256 when); + event CancellationThresholdUpdated(address indexed _safe, uint256 _oldThreshold, uint256 _newThreshold); + event GuardConfigured(address indexed _safe, uint256 _timelockDelay); + event TransactionCancelled(address indexed _safe, bytes32 indexed _txHash); + event TransactionScheduled(address indexed _safe, bytes32 indexed _txHash, uint256 when); - function blockingThresholdForSafe(address) external pure returns (uint256); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; + function cancelTransactionOnSafe(address _safe, bytes32 _txHash) 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, + address _to, uint256 _value, - bytes memory, - Enum.Operation, - uint256, - uint256, - uint256, - address, - address payable, - bytes memory, - address - ) external; + bytes memory _data, + Enum.Operation _operation, + uint256 _safeTxGas, + uint256 _baseGas, + uint256 _gasPrice, + address _gasToken, + address payable _refundReceiver, + bytes memory _signatures, + address _msgSender + ) + external; + function checkAfterExecution(bytes32, bool) external; function configureTimelockGuard(uint256 _timelockDelay) external; - function scheduledTransactionForSafe(address _safe, bytes32 _txHash) + function scheduledTransactionForSafe( + address _safe, + bytes32 _txHash + ) external view returns (TimelockGuard.ScheduledTransaction memory); @@ -68,7 +70,13 @@ interface Interface { uint256 _nonce, ExecTransactionParams memory _params, bytes memory _signatures - ) external; + ) + external; function version() external view returns (string memory); function timelockConfigurationForSafe(address _safe) external view returns (uint256 timelockDelay); + function maxCancellationThreshold(address _safe) external view returns (uint256); + function pendingTransactionsForSafe(address _safe) + external + view + returns (TimelockGuard.ScheduledTransaction[] memory); } diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 72a6ad3b40e36..8a32c168e1b65 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -488,8 +488,9 @@ contract TimelockGuard is IGuard, ISemver { // 1. Been scheduled // 2. Not already been cancelled // 3. Not already been executed - // There is nothing inherently wrong with cancelling a transaction a transaction that doesn't meet these criteria, - // but we revert in order to inform the user, and avoid emitting a misleading TransactionCancelled event. + // There is nothing inherently wrong with cancelling a transaction a transaction that doesn't meet these + // criteria, but we revert in order to inform the user, and avoid emitting a misleading TransactionCancelled + // event. if (_safeState[_safe].scheduledTransactions[_txHash].cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } From 374824cfe50a113f2d871b2d391ab5156a41654c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 16:05:50 -0400 Subject: [PATCH 067/102] Rename arguments for consistency --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 8a32c168e1b65..f2f76bcd7785d 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -124,14 +124,14 @@ contract TimelockGuard is IGuard, ISemver { /// @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 txHash 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); + event TransactionScheduled(Safe indexed safe, bytes32 indexed txHash, uint256 executionTime); /// @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); + /// @param txHash The identifier of the cancelled transaction (nonce-independent). + event TransactionCancelled(Safe indexed safe, bytes32 indexed txHash); /// @notice Emitted when the cancellation threshold is updated event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); @@ -146,12 +146,11 @@ contract TimelockGuard is IGuard, ISemver { // Internal View Functions // //////////////////////////////////////////////////////////////// - /// @notice Returns the blocking threshold threshold for a given safe + /// @notice Returns the blocking threshold, which is defined as the minimum number of owners that must coordinate to + /// block a transaction from being executed by refusing to sign. /// @param _safe The Safe address to query /// @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; } From c615461714b5fdf26ec73cca00f8b0e16444b19a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 16:08:30 -0400 Subject: [PATCH 068/102] Add/fixup @param on events --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index f2f76bcd7785d..1829eead73025 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -120,12 +120,14 @@ contract TimelockGuard is IGuard, ISemver { error TimelockGuard_TransactionAlreadyExecuted(); /// @notice Emitted when a Safe configures the guard + /// @param safe The Safe whose guard is configured. + /// @param timelockDelay The timelock delay in seconds. event GuardConfigured(Safe indexed safe, uint256 timelockDelay); /// @notice Emitted when a transaction is scheduled for a Safe. /// @param safe The Safe whose transaction is scheduled. /// @param txHash The identifier of the scheduled transaction (nonce-independent). - /// @param when The timestamp when execution becomes valid. + /// @param executionTime The timestamp when execution becomes valid. event TransactionScheduled(Safe indexed safe, bytes32 indexed txHash, uint256 executionTime); /// @notice Emitted when a transaction is cancelled for a Safe. @@ -134,6 +136,9 @@ contract TimelockGuard is IGuard, ISemver { event TransactionCancelled(Safe indexed safe, bytes32 indexed txHash); /// @notice Emitted when the cancellation threshold is updated + /// @param safe The Safe whose cancellation threshold is updated. + /// @param oldThreshold The old cancellation threshold. + /// @param newThreshold The new cancellation threshold. event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); /// @notice Emitted when a transaction is executed for a Safe. From 16ac7c312e3dcc53d14e3c638eb4c2f67e990b6d Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 16:15:31 -0400 Subject: [PATCH 069/102] Small fixes --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 1829eead73025..7eaf60890ad5f 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -257,8 +257,8 @@ contract TimelockGuard is IGuard, ISemver { uint256 _gasPrice, address _gasToken, address payable _refundReceiver, - bytes memory, - address + bytes memory, /* signatures */ + address /* msgSender */ ) external override @@ -267,7 +267,8 @@ contract TimelockGuard is IGuard, ISemver { if (_safeState[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. + // 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; From 5df4668cf356704079ede0d9c82717c710de4826 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 16:45:07 -0400 Subject: [PATCH 070/102] Fix ITimelockGuard declaration --- packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index d8c8a748e1b01..df1d7db9525f9 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -5,7 +5,8 @@ library Enum { type Operation is uint8; } -library TimelockGuard { +interface ITimelockGuard { + struct ScheduledTransaction { uint256 executionTime; bool cancelled; From 2694a9595d389185d30f1f7980f3d4b7f0ff0d7e Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 17:05:51 -0400 Subject: [PATCH 071/102] Improve names on getter functions --- .../interfaces/safe/ITimelockGuard.sol | 10 ++-- .../src/safe/TimelockGuard.sol | 8 +-- .../test/safe/TimelockGuard.t.sol | 52 +++++++++---------- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index df1d7db9525f9..009c0d366dd23 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -40,8 +40,8 @@ interface ITimelockGuard { function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function cancelTransactionOnSafe(address _safe, bytes32 _txHash) external; - function cancellationThresholdForSafe(address _safe) external view returns (uint256); - function pendingTransactionsForSafe(address) external pure returns (bytes32[] memory); + function cancellationThreshold(address _safe) external view returns (uint256); + function pendingTransactions(address) external pure returns (bytes32[] memory); function checkTransaction( address _to, uint256 _value, @@ -58,7 +58,7 @@ interface ITimelockGuard { external; function checkAfterExecution(bytes32, bool) external; function configureTimelockGuard(uint256 _timelockDelay) external; - function scheduledTransactionForSafe( + function scheduledTransaction( address _safe, bytes32 _txHash ) @@ -74,9 +74,9 @@ interface ITimelockGuard { ) external; function version() external view returns (string memory); - function timelockConfigurationForSafe(address _safe) external view returns (uint256 timelockDelay); + function timelockConfiguration(address _safe) external view returns (uint256 timelockDelay); function maxCancellationThreshold(address _safe) external view returns (uint256); - function pendingTransactionsForSafe(address _safe) + function pendingTransactions(address _safe) external view returns (TimelockGuard.ScheduledTransaction[] memory); diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 7eaf60890ad5f..ae97e604e0373 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -162,7 +162,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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) { + function cancellationThreshold(Safe _safe) public view returns (uint256) { // Return 0 if guard is not enabled if (!_isGuardEnabled(_safe)) { return 0; @@ -198,14 +198,14 @@ contract TimelockGuard is IGuard, ISemver { /// @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 (uint256) { + function timelockConfiguration(Safe _safe) public view returns (uint256) { return _safeState[_safe].timelockDelay; } /// @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( + function scheduledTransaction( Safe _safe, bytes32 _txHash ) @@ -225,7 +225,7 @@ contract TimelockGuard is IGuard, ISemver { /// 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) { + function pendingTransactions(Safe _safe) external view returns (ScheduledTransaction[] memory) { SafeState storage safeState = _safeState[_safe]; // Get the list of pending transaction hashes diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 433745ac1777a..91be60a467cc7 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -132,7 +132,7 @@ library TransactionBuilder { abi.encodeCall(TimelockGuard.cancelTransactionOnSafe, (Safe(payable(_tx.safeInstance.safe)), _tx.hash)); // Get only the number of signatures required for the cancellation transaction - uint256 cancellationThreshold = _timelockGuard.cancellationThresholdForSafe(_tx.safeInstance.safe); + uint256 cancellationThreshold = _timelockGuard.cancellationThreshold(_tx.safeInstance.safe); cancellation.updateTransaction(cancellationThreshold); return cancellation; @@ -233,7 +233,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { /// @notice Ensures an unconfigured Safe reports a zero timelock delay. function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { - uint256 delay = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); assertEq(delay, 0); // configured is now determined by timelockDelay == 0 assertEq(delay == 0, true); @@ -242,7 +242,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test /// @notice Validates the configuration view reflects the stored timelock delay. function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 delay = timelockGuard.timelockConfigurationForSafe(safeInstance.safe); + uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -259,7 +259,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, TIMELOCK_DELAY); - uint256 delay = timelockGuard.timelockConfigurationForSafe(safe); + uint256 delay = timelockGuard.timelockConfiguration(safe); assertEq(delay, TIMELOCK_DELAY); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -288,7 +288,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { _configureGuard(safeInstance, ONE_YEAR); - uint256 delay = timelockGuard.timelockConfigurationForSafe(safe); + uint256 delay = timelockGuard.timelockConfiguration(safe); assertEq(delay, ONE_YEAR); // configured is now determined by timelockDelay != 0 assertEq(delay != 0, true); @@ -298,7 +298,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { function test_configureTimelockGuard_allowsReconfiguration_succeeds() external { // Initial configuration _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockConfigurationForSafe(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfiguration(safe), TIMELOCK_DELAY); uint256 newDelay = TIMELOCK_DELAY + 1; @@ -316,14 +316,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { emit GuardConfigured(safe, newDelay); _configureGuard(safeInstance, newDelay); - assertEq(timelockGuard.timelockConfigurationForSafe(safe), newDelay); + assertEq(timelockGuard.timelockConfiguration(safe), newDelay); } /// @notice Ensures setting delay to zero clears the configuration. function test_configureTimelockGuard_clearConfiguration_succeeds() external { // First configure the guard _configureGuard(safeInstance, TIMELOCK_DELAY); - assertEq(timelockGuard.timelockConfigurationForSafe(safe), TIMELOCK_DELAY); + assertEq(timelockGuard.timelockConfiguration(safe), TIMELOCK_DELAY); // Configure timelock delay to 0 should succeed and emit event vm.expectEmit(true, true, true, true); @@ -332,7 +332,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(0); // Timelock delay should be set to 0 - assertEq(timelockGuard.timelockConfigurationForSafe(safe), 0); + assertEq(timelockGuard.timelockConfiguration(safe), 0); } /// @notice Checks clearing succeeds even if the guard was never configured. @@ -350,14 +350,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { 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))); + uint256 threshold = timelockGuard.cancellationThreshold(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); + uint256 threshold = timelockGuard.cancellationThreshold(safe); assertEq(threshold, 0); } @@ -367,7 +367,7 @@ contract TimelockGuard_CancellationThresholdForSafe_Test is TimelockGuard_TestIn _configureGuard(safeInstance, TIMELOCK_DELAY); // Should default to 1 after configuration - uint256 threshold = timelockGuard.cancellationThresholdForSafe(safe); + uint256 threshold = timelockGuard.cancellationThreshold(safe); assertEq(threshold, 1); } @@ -399,7 +399,7 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { 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), 0); + assertEq(timelockGuard.timelockConfiguration(unguardedSafe.safe), 0); TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(unguardedSafe); vm.expectRevert(TimelockGuard.TimelockGuard_GuardNotConfigured.selector); @@ -459,7 +459,7 @@ contract TimelockGuard_ScheduledTransactionForSafe_Test is TimelockGuard_TestIni dummyTx.scheduleTransaction(timelockGuard); TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.scheduledTransactionForSafe(safe, dummyTx.hash); + timelockGuard.scheduledTransaction(safe, dummyTx.hash); assertEq(scheduledTransaction.executionTime, INIT_TIME + TIMELOCK_DELAY); assertEq(scheduledTransaction.cancelled, false); assertEq(scheduledTransaction.executed, false); @@ -480,7 +480,7 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactions(safe); assertEq(pendingTransactions.length, 1); // ensure the hash of the transaction params are the same assertEq(pendingTransactions[0].params.to, dummyTx.params.to); @@ -497,7 +497,7 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); // get the pending transactions - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactions(safe); assertEq(pendingTransactions.length, 0); } @@ -512,7 +512,7 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit dummyTx.executeTransaction(); // get the pending transactions - TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactionsForSafe(safe); + TimelockGuard.ScheduledTransaction[] memory pendingTransactions = timelockGuard.pendingTransactions(safe); assertEq(pendingTransactions.length, 0); } } @@ -535,7 +535,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Get the cancellation transaction TransactionBuilder.Transaction memory cancellationTx = dummyTx.makeCancellationTransaction(timelockGuard); - uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); + uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); @@ -544,7 +544,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { 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); + assertEq(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).cancelled, true); } /// @notice Confirms pre-approved hashes can authorise cancellations. @@ -566,7 +566,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { bytes memory cancellationSignatures = abi.encodePacked(bytes32(uint256(uint160(owner))), bytes32(0), uint8(1)); // Get the cancellation threshold - uint256 cancellationThreshold = timelockGuard.cancellationThresholdForSafe(dummyTx.safeInstance.safe); + uint256 cancellationThreshold = timelockGuard.cancellationThreshold(dummyTx.safeInstance.safe); // Cancel the transaction vm.expectEmit(true, true, true, true); @@ -577,7 +577,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.scheduledTransactionForSafe(dummyTx.safeInstance.safe, dummyTx.hash); + timelockGuard.scheduledTransaction(dummyTx.safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.cancelled, true); } @@ -622,7 +622,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { vm.store( address(timelockGuard), bytes32(slot), - bytes32(uint256(timelockGuard.cancellationThresholdForSafe(safeInstance.safe) + 1)) + bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe) + 1)) ); vm.prank(address(safeInstance.safe)); @@ -644,11 +644,11 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is executed TimelockGuard.ScheduledTransaction memory scheduledTransaction = - timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash); + timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); assertEq(scheduledTransaction.executed, true); // Confirm that the cancellation threshold is reset - assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), 1); + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); } /// @notice Test that checkTransaction reverts when scheduled transaction delay hasn't passed @@ -751,7 +751,7 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { vm.warp(block.timestamp + TIMELOCK_DELAY); dummyTx.executeTransaction(); - assertEq(timelockGuard.scheduledTransactionForSafe(safeInstance.safe, dummyTx.hash).executed, true); + assertEq(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).executed, true); } /// @notice Test that scheduling a transaction and then executing it twice reverts @@ -829,6 +829,6 @@ contract TimelockGuard_Integration_test is TimelockGuard_TestInit { timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); } - assertEq(timelockGuard.cancellationThresholdForSafe(safeInstance.safe), maxThreshold); + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), maxThreshold); } } From c96b476c1d314772cfe6a130fb2d97d7df102dbf Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 25 Sep 2025 17:08:44 -0400 Subject: [PATCH 072/102] Move ExecTransactionParams into TimelockGuard.sol --- .../src/safe/TimelockGuard.sol | 30 +++++++++++-------- packages/contracts-bedrock/src/safe/Types.sol | 17 ----------- .../test/safe/TimelockGuard.t.sol | 3 +- 3 files changed, 19 insertions(+), 31 deletions(-) delete mode 100644 packages/contracts-bedrock/src/safe/Types.sol diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ae97e604e0373..34e648048b27c 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -71,9 +71,22 @@ contract TimelockGuard is IGuard, ISemver { ExecTransactionParams params; } + /// @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; + } + /// @notice Aggregated state for each Safe using this guard. /// @dev We have chosen for operational reasons to keep a list of pending transactions that can be easily retrieved - /// via a function call. There are several ways to accomplis this but we chose to maintain a separate EnumerableSet + /// via a function call. There are several ways to accomplish this but we chose to maintain a separate EnumerableSet /// with the txHashes of the pending transactions, that needs to be maintained in sync with the mapping that keeps /// all the data about all the transactions, regardless of their state. /// We chose this implementation because the set of pending transactions is independent of the core flow, and if @@ -178,7 +191,7 @@ contract TimelockGuard is IGuard, ISemver { uint256 blockingThreshold = _blockingThreshold(_safe); uint256 quorum = _safe.getThreshold(); // Return the minimum of the blocking threshold and the quorum - return (blockingThreshold < quorum ? blockingThreshold : quorum) - 1; + return (blockingThreshold < quorum ? blockingThreshold : quorum); } /// @notice Internal helper to get the guard address from a Safe @@ -205,14 +218,7 @@ contract TimelockGuard is IGuard, ISemver { /// @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 scheduledTransaction( - Safe _safe, - bytes32 _txHash - ) - public - view - returns (ScheduledTransaction memory) - { + function scheduledTransaction(Safe _safe, bytes32 _txHash) public view returns (ScheduledTransaction memory) { return _safeState[_safe].scheduledTransactions[_txHash]; } @@ -480,7 +486,7 @@ contract TimelockGuard is IGuard, ISemver { /// 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 + /// Signature verification uses the Safe's checkNSignatures function, so that the number of signatures /// 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. @@ -537,7 +543,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data /// @dev This function is not meant to be called, use cancelTransaction instead - function cancelTransactionOnSafe(Safe, bytes32) public { + function cancelTransactionOnSafe(Safe, bytes32) public pure { // Reverting here may cause issues for some signing tooling, but it is better to revert than for // to silently fail, potentially allowing the caller to believe that the transaction has been cancelled. revert("This function is not meant to be called, use cancelTransaction instead"); diff --git a/packages/contracts-bedrock/src/safe/Types.sol b/packages/contracts-bedrock/src/safe/Types.sol deleted file mode 100644 index 1d999d04a4db6..0000000000000 --- a/packages/contracts-bedrock/src/safe/Types.sol +++ /dev/null @@ -1,17 +0,0 @@ -// 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 index 91be60a467cc7..e75d42c67db3f 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -5,7 +5,6 @@ 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"; @@ -16,7 +15,7 @@ library TransactionBuilder { // A struct type used to construct a transaction for scheduling and execution struct Transaction { SafeInstance safeInstance; - ExecTransactionParams params; + TimelockGuard.ExecTransactionParams params; uint256 nonce; bytes32 hash; bytes signatures; From e7e8016520c62c83f684e40722b53192b596333b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 09:48:09 -0400 Subject: [PATCH 073/102] Address comment nits --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 5 ++--- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 4 ++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 34e648048b27c..ffb9b31cbd577 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -5,7 +5,6 @@ pragma solidity 0.8.15; 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"; @@ -28,7 +27,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// unless the transaction has first been scheduled by calling scheduleTransaction() on this contract. Because /// scheduleTransaction() uses the Safe's own signature verification logic, the same signatures used /// to execute a transaction can be used to schedule it. -/// !Note: this guard does not apply a delay to transactions executed by modules which are installed on the Safe. +/// Note: this guard does not apply a delay to transactions executed by modules which are installed on the Safe. /// Cancelling transactions: /// Once a transaction has been scheduled, so long as it has not already been executed, it can be /// cancelled by calling cancelTransaction() on this contract. @@ -48,7 +47,7 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// LivenessModule would be used. If there is a quorum of absent keys, but no significant malicious control, the /// LivenessModule would also be used. /// The TimelockGuard acts when there is malicious control of a quorum of keys. If the control is temporary, for -/// example by phishing a single set of signatures, then the TimelockGuard is enough to detect and stop the attack +/// example by phishing a single set of signatures, then the TimelockGuard's cancellation is enough to stop the attack /// entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to execute /// remediations external to the compromised safe. /// +---------------------------------------------------------------------+ diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index e75d42c67db3f..b7591e2d84ac2 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -11,6 +11,10 @@ import { TimelockGuard } from "src/safe/TimelockGuard.sol"; using TransactionBuilder for TransactionBuilder.Transaction; + +/// @title TransactionBuilder +/// @notice Facilitates the construction of transactions and signatures, and provides helper methods +/// for scheduling, executing, and cancelling transactions. library TransactionBuilder { // A struct type used to construct a transaction for scheduling and execution struct Transaction { From 1912649821ccb4ee71b2bcfe9775d2b9beaf3ed8 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 10:49:27 -0400 Subject: [PATCH 074/102] Add TimelockGuard_MaxCancellationThreshold_Test and _deploySafe helper --- .../test/safe/TimelockGuard.t.sol | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index b7591e2d84ac2..0543d6f4990a4 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -173,22 +173,23 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { // 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); + safeInstance = _deploySafe("owners", NUM_OWNERS, 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); + unguardedSafe = _deploySafe("owners-unguarded", NUM_OWNERS, THRESHOLD); // Enable the guard on the Safe _enableGuard(safeInstance); } + /// @notice Deploys a Safe with the given owners and threshold + function _deploySafe(string memory _prefix, uint256 _numOwners, uint256 _threshold) internal returns (SafeInstance memory) { + (, uint256[] memory keys) = SafeTestLib.makeAddrsAndKeys(_prefix, _numOwners); + return _setupSafe(keys, _threshold); + } + /// @notice Builds an empty transaction wrapper for a Safe instance. function _createEmptyTransaction(SafeInstance memory _safeInstance) internal @@ -738,9 +739,64 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_Integration_test +/// @title TimelockGuard_MaxCancellationThreshold_Test +/// @notice Tests for the maxCancellationThreshold function in TimelockGuard +contract TimelockGuard_MaxCancellationThreshold_Test is TimelockGuard_TestInit { + function setUp() public override { + super.setUp(); + _configureGuard(safeInstance, TIMELOCK_DELAY); + } + + /// @notice Test that maxCancellationThreshold returns the correct value + function test_maxCancellationThreshold_maxThresholdIsBlockingThreshold_succeeds() external { + // create a new Safe with 7 owners and quorum of 5 (blocking threshold is 3) + SafeInstance memory newSafeInstance = _deploySafe("owners", 7, 5); + _enableGuard(newSafeInstance); + _configureGuard(newSafeInstance, TIMELOCK_DELAY); + + // Set up a dummy transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(newSafeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Calculate expected max cancellation threshold + uint256 blockingThreshold = newSafeInstance.safe.getOwners().length - newSafeInstance.safe.getThreshold() + 1; + uint256 quorum = newSafeInstance.safe.getThreshold(); + + // Ensure that the minimum is set by the blocking threshold + assertGt(quorum, blockingThreshold); + + // Assert that the maxCancellationThreshold function returns the expected value + assertEq(timelockGuard.maxCancellationThreshold(newSafeInstance.safe), blockingThreshold); + } + + /// @notice Test that maxCancellationThreshold returns the correct value + function test_maxCancellationThreshold_maxThresholdIsQuorum_succeeds() external { + // create a new Safe with 7 owners and quorum of 3 (blocking threshold is 5) + SafeInstance memory newSafeInstance = _deploySafe("owners", 7, 3); + _enableGuard(newSafeInstance); + _configureGuard(newSafeInstance, TIMELOCK_DELAY); + + // Set up a dummy transaction + TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(newSafeInstance); + dummyTx.scheduleTransaction(timelockGuard); + + // Calculate expected max cancellation threshold + uint256 blockingThreshold = newSafeInstance.safe.getOwners().length - newSafeInstance.safe.getThreshold() + 1; + uint256 quorum = newSafeInstance.safe.getThreshold(); + + // Ensure that the minimum is set by quorum + assertGt(blockingThreshold, quorum); + + // Assert that the maxCancellationThreshold function returns the expected value + assertEq(timelockGuard.maxCancellationThreshold(newSafeInstance.safe), quorum); + } +} + + + +/// @title TimelockGuard_Integration_Test /// @notice Tests for integration between TimelockGuard and Safe -contract TimelockGuard_Integration_test is TimelockGuard_TestInit { +contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); From ba9ac1c0017a84c48218cd18fa810da5f5a2cd4c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 10:55:31 -0400 Subject: [PATCH 075/102] Fix up iface and comment typos --- .../contracts-bedrock/interfaces/safe/ITimelockGuard.sol | 8 ++++---- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 009c0d366dd23..21816c3613082 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -33,10 +33,10 @@ interface ITimelockGuard { error TimelockGuard_TransactionAlreadyScheduled(); error TimelockGuard_TransactionNotScheduled(); - event CancellationThresholdUpdated(address indexed _safe, uint256 _oldThreshold, uint256 _newThreshold); - event GuardConfigured(address indexed _safe, uint256 _timelockDelay); - event TransactionCancelled(address indexed _safe, bytes32 indexed _txHash); - event TransactionScheduled(address indexed _safe, bytes32 indexed _txHash, uint256 when); + event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); + event GuardConfigured(address indexed safe, uint256 timelockDelay); + event TransactionCancelled(address indexed safe, bytes32 indexed txHash); + event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executed); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function cancelTransactionOnSafe(address _safe, bytes32 _txHash) external; diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ffb9b31cbd577..0128a83629930 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -90,8 +90,8 @@ contract TimelockGuard is IGuard, ISemver { /// all the data about all the transactions, regardless of their state. /// We chose this implementation because the set of pending transactions is independent of the core flow, and if /// there would be a bug in it, it would only affect the `pendingTransactions` view function. - /// A notable alternative was to keen onlt the pending transactions in storage, and remove them as soon as they are - /// cancelled or executed, but that opens the Timelock to some low-risk griefing attackes that we nontheless prefer + /// A notable alternative was to keep only the pending transactions in storage, and remove them as soon as they are + /// cancelled or executed, but that opens the Timelock to some low-risk griefing attacks that we nonetheless prefer /// to avoid. struct SafeState { uint256 timelockDelay; From 45ad7b9745d6fb38b69097326a72d9d26c2b1213 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 11:07:48 -0400 Subject: [PATCH 076/102] Fix storage lookup in test --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 0543d6f4990a4..7bdaa50f993e7 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -620,7 +620,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { 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( + uint256 slot = stdstore.target(address(timelockGuard)).sig("cancellationThreshold(address)").with_key( address(safeInstance.safe) ).find(); vm.store( From 5f3dca6e06551c88e740b3f7692a4ed1c863da25 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 11:08:05 -0400 Subject: [PATCH 077/102] Add enum Transaction state and remove cancelled/executed booleans --- .../src/safe/TimelockGuard.sol | 41 +++++++++++-------- .../test/safe/TimelockGuard.t.sol | 26 +++++++----- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 0128a83629930..00d0fbabf1f83 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -62,11 +62,18 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; + /// @notice Allowed states of a transaction + enum TransactionState { + NotScheduled, + Pending, + Cancelled, + Executed + } + /// @notice Scheduled transaction struct ScheduledTransaction { uint256 executionTime; - bool cancelled; - bool executed; + TransactionState state; ExecTransactionParams params; } @@ -293,12 +300,19 @@ contract TimelockGuard is IGuard, ISemver { ScheduledTransaction storage scheduledTx = _safeState[callingSafe].scheduledTransactions[txHash]; // Check if the transaction was cancelled - if (scheduledTx.cancelled) { + if (scheduledTx.state == TransactionState.Cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } + // 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.state == TransactionState.Executed) { + revert TimelockGuard_TransactionAlreadyExecuted(); + } + // Check if the transaction has been scheduled - if (scheduledTx.executionTime == 0) { + if (scheduledTx.state == TransactionState.NotScheduled) { revert TimelockGuard_TransactionNotScheduled(); } @@ -307,15 +321,8 @@ contract TimelockGuard is IGuard, ISemver { 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; + scheduledTx.state = TransactionState.Executed; _safeState[callingSafe].pendingTxHashes.remove(txHash); // Reset the cancellation threshold @@ -471,7 +478,7 @@ contract TimelockGuard is IGuard, ISemver { // Schedule the transaction and add it to the pending transactions set _safeState[_safe].scheduledTransactions[txHash] = - ScheduledTransaction({ executionTime: executionTime, cancelled: false, executed: false, params: _params }); + ScheduledTransaction({ executionTime: executionTime, state: TransactionState.Pending, params: _params }); _safeState[_safe].pendingTxHashes.add(txHash); emit TransactionScheduled(_safe, txHash, executionTime); @@ -501,13 +508,13 @@ contract TimelockGuard is IGuard, ISemver { // There is nothing inherently wrong with cancelling a transaction a transaction that doesn't meet these // criteria, but we revert in order to inform the user, and avoid emitting a misleading TransactionCancelled // event. - if (_safeState[_safe].scheduledTransactions[_txHash].cancelled) { + if (_safeState[_safe].scheduledTransactions[_txHash].state == TransactionState.Cancelled) { revert TimelockGuard_TransactionAlreadyCancelled(); } - if (_safeState[_safe].scheduledTransactions[_txHash].executed) { + if (_safeState[_safe].scheduledTransactions[_txHash].state == TransactionState.Executed) { revert TimelockGuard_TransactionAlreadyExecuted(); } - if (_safeState[_safe].scheduledTransactions[_txHash].executionTime == 0) { + if (_safeState[_safe].scheduledTransactions[_txHash].state == TransactionState.NotScheduled) { revert TimelockGuard_TransactionNotScheduled(); } @@ -527,7 +534,7 @@ contract TimelockGuard is IGuard, ISemver { ); // Set the transaction as cancelled, and remove it from the pending transactions set - _safeState[_safe].scheduledTransactions[_txHash].cancelled = true; + _safeState[_safe].scheduledTransactions[_txHash].state = TransactionState.Cancelled; _safeState[_safe].pendingTxHashes.remove(_txHash); // Increase the cancellation threshold diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 7bdaa50f993e7..359b0c80a15d6 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -11,7 +11,6 @@ import { TimelockGuard } from "src/safe/TimelockGuard.sol"; using TransactionBuilder for TransactionBuilder.Transaction; - /// @title TransactionBuilder /// @notice Facilitates the construction of transactions and signatures, and provides helper methods /// for scheduling, executing, and cancelling transactions. @@ -185,7 +184,14 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } /// @notice Deploys a Safe with the given owners and threshold - function _deploySafe(string memory _prefix, uint256 _numOwners, uint256 _threshold) internal returns (SafeInstance memory) { + function _deploySafe( + string memory _prefix, + uint256 _numOwners, + uint256 _threshold + ) + internal + returns (SafeInstance memory) + { (, uint256[] memory keys) = SafeTestLib.makeAddrsAndKeys(_prefix, _numOwners); return _setupSafe(keys, _threshold); } @@ -465,8 +471,7 @@ contract TimelockGuard_ScheduledTransactionForSafe_Test is TimelockGuard_TestIni TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.scheduledTransaction(safe, dummyTx.hash); assertEq(scheduledTransaction.executionTime, INIT_TIME + TIMELOCK_DELAY); - assertEq(scheduledTransaction.cancelled, false); - assertEq(scheduledTransaction.executed, false); + assert(scheduledTransaction.state == TimelockGuard.TransactionState.Pending); assertEq(keccak256(abi.encode(scheduledTransaction.params)), keccak256(abi.encode(dummyTx.params))); } } @@ -548,7 +553,10 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { emit TransactionCancelled(safeInstance.safe, dummyTx.hash); timelockGuard.cancelTransaction(safeInstance.safe, dummyTx.hash, dummyTx.nonce, cancellationTx.signatures); - assertEq(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).cancelled, true); + assert( + timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).state + == TimelockGuard.TransactionState.Cancelled + ); } /// @notice Confirms pre-approved hashes can authorise cancellations. @@ -582,7 +590,7 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is cancelled TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.scheduledTransaction(dummyTx.safeInstance.safe, dummyTx.hash); - assertEq(scheduledTransaction.cancelled, true); + assert(scheduledTransaction.state == TimelockGuard.TransactionState.Cancelled); } /// @notice Verifies cancelling an unscheduled transaction reverts. @@ -649,7 +657,7 @@ contract TimelockGuard_CheckTransaction_Test is TimelockGuard_TestInit { // Confirm that the transaction is executed TimelockGuard.ScheduledTransaction memory scheduledTransaction = timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); - assertEq(scheduledTransaction.executed, true); + assert(scheduledTransaction.state == TimelockGuard.TransactionState.Executed); // Confirm that the cancellation threshold is reset assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); @@ -792,8 +800,6 @@ contract TimelockGuard_MaxCancellationThreshold_Test is TimelockGuard_TestInit { } } - - /// @title TimelockGuard_Integration_Test /// @notice Tests for integration between TimelockGuard and Safe contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { @@ -810,7 +816,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { vm.warp(block.timestamp + TIMELOCK_DELAY); dummyTx.executeTransaction(); - assertEq(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).executed, true); + assert(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).state == TimelockGuard.TransactionState.Executed); } /// @notice Test that scheduling a transaction and then executing it twice reverts From 0ef9df55c4579234ced0edff6022199d22f0178b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 11:16:29 -0400 Subject: [PATCH 078/102] add /// @custom:field on ScheduledTransaction struct --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 00d0fbabf1f83..2d2a2150b49d2 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -71,6 +71,9 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Scheduled transaction + /// @custom:field executionTime The timestamp when execution becomes valid. + /// @custom:field state The state of the transaction. + /// @custom:field params The parameters of the transaction. struct ScheduledTransaction { uint256 executionTime; TransactionState state; From 2bb19711d6bc374734feb9c0ad5e410543bcd49b Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 11:18:49 -0400 Subject: [PATCH 079/102] add /// @custom:field on ExecTransactionParams struct --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 2d2a2150b49d2..234dc8c42282a 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -81,6 +81,15 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Parameters for the Safe's execTransaction function + /// @custom:field to The address of the contract to call. + /// @custom:field value The value to send with the transaction. + /// @custom:field data The data to send with the transaction. + /// @custom:field operation The operation to perform with the transaction. + /// @custom:field safeTxGas The gas to use for the transaction. + /// @custom:field baseGas The base gas to use for the transaction. + /// @custom:field gasPrice The gas price to use for the transaction. + /// @custom:field gasToken The token to use for the transaction. + /// @custom:field refundReceiver The address to receive the refund for the transaction. struct ExecTransactionParams { address to; uint256 value; From f3905eef3fcd8d6f70f8852d5898f99cd3971f35 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 11:46:43 -0400 Subject: [PATCH 080/102] Add SemverComp to enforce minimum Safe version --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 11 +++++++++++ .../contracts-bedrock/test/safe/TimelockGuard.t.sol | 8 ++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 234dc8c42282a..5c76449d8b836 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -8,6 +8,7 @@ import { Guard as IGuard } from "safe-contracts/base/GuardManager.sol"; // Libraries import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { SemverComp } from "src/libraries/SemverComp.sol"; // Interfaces import { ISemver } from "interfaces/universal/ISemver.sol"; @@ -150,6 +151,9 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Error for when a transaction has already been executed error TimelockGuard_TransactionAlreadyExecuted(); + /// @notice Error for when the contract is not at least version 1.3.0 + error TimelockGuard_InvalidVersion(); + /// @notice Emitted when a Safe configures the guard /// @param safe The Safe whose guard is configured. /// @param timelockDelay The timelock delay in seconds. @@ -394,6 +398,13 @@ contract TimelockGuard is IGuard, ISemver { // Record the calling Safe Safe callingSafe = Safe(payable(msg.sender)); + // Check that the contract is at least version 1.3.0 + // Prior to version 1.3.0, checkSignatures() was not exposed as a public function, so we need to check the + // version otherwise the safe will be bricked. + if (SemverComp.lt(callingSafe.VERSION(), "1.3.0")) { + revert TimelockGuard_InvalidVersion(); + } + // Check that this guard is enabled on the calling Safe // There is nothing inherently wrong with configuring the guard on a safe that it is not enabled on, // but we choose to revert here to avoid users from mistakenly believing that simply configuring the guard diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 359b0c80a15d6..a65ec2cc0ad68 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -291,6 +291,14 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { timelockGuard.configureTimelockGuard(tooLongDelay); } + /// @notice Checks configuration reverts when the contract is too old. + function test_configureTimelockGuard_revertsIfVersionTooOld_reverts() external { + vm.mockCall(address(timelockGuard), abi.encodeWithSignature("VERSION()"), abi.encode("1.2.0")); + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidVersion.selector); + vm.prank(address(safeInstance.safe)); + timelockGuard.configureTimelockGuard(TIMELOCK_DELAY); + } + /// @notice Asserts the maximum valid delay configures successfully. function test_configureTimelockGuard_acceptsMaxValidDelay_succeeds() external { vm.expectEmit(true, true, true, true); From c7e9eb4be171e6af5643898c828e75083f0aae85 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 13:03:56 -0400 Subject: [PATCH 081/102] Rename empty function to signCancellationForSafe --- packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol | 2 +- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 4 ++-- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 21816c3613082..502a1a14a4bd3 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -39,7 +39,7 @@ interface ITimelockGuard { event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executed); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; - function cancelTransactionOnSafe(address _safe, bytes32 _txHash) external; + function signCancellationForSafe(address _safe, bytes32 _txHash) external; function cancellationThreshold(address _safe) external view returns (uint256); function pendingTransactions(address) external pure returns (bytes32[] memory); function checkTransaction( diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5c76449d8b836..ab025cd05c952 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -542,7 +542,7 @@ contract TimelockGuard is IGuard, ISemver { } // Generate the cancellation transaction data - bytes memory txData = abi.encodeCall(this.cancelTransactionOnSafe, (_safe, _txHash)); + bytes memory txData = abi.encodeCall(this.signCancellationForSafe, (_safe, _txHash)); bytes memory cancellationTxData = _safe.encodeTransactionData( address(this), 0, txData, Enum.Operation.Call, 0, 0, 0, address(0), address(0), _nonce ); @@ -572,7 +572,7 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data /// @dev This function is not meant to be called, use cancelTransaction instead - function cancelTransactionOnSafe(Safe, bytes32) public pure { + function signCancellationForSafe(Safe, bytes32) public pure { // Reverting here may cause issues for some signing tooling, but it is better to revert than for // to silently fail, potentially allowing the caller to believe that the transaction has been cancelled. revert("This function is not meant to be called, use cancelTransaction instead"); diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index a65ec2cc0ad68..60a47d9b35aa2 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -131,7 +131,7 @@ library TransactionBuilder { delete cancellation.params; cancellation.params.to = address(_timelockGuard); cancellation.params.data = - abi.encodeCall(TimelockGuard.cancelTransactionOnSafe, (Safe(payable(_tx.safeInstance.safe)), _tx.hash)); + abi.encodeCall(TimelockGuard.signCancellationForSafe, (Safe(payable(_tx.safeInstance.safe)), _tx.hash)); // Get only the number of signatures required for the cancellation transaction uint256 cancellationThreshold = _timelockGuard.cancellationThreshold(_tx.safeInstance.safe); From 0d5dac0bdd921fcbb92d5cba4c2e3de6a1276c92 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 13:38:07 -0400 Subject: [PATCH 082/102] Fix location of external view functions --- .../src/safe/TimelockGuard.sol | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index ab025cd05c952..5a17fe7f3c8ec 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -194,6 +194,20 @@ contract TimelockGuard is IGuard, ISemver { return _safe.getOwners().length - _safe.getThreshold() + 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); + } + + //////////////////////////////////////////////////////////////// + // External View Functions // + //////////////////////////////////////////////////////////////// + /// @notice Returns the cancellation threshold for a given safe /// @param _safe The Safe address to query /// @return The current cancellation threshold @@ -216,20 +230,6 @@ contract TimelockGuard is IGuard, ISemver { return (blockingThreshold < quorum ? blockingThreshold : quorum); } - /// @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); - } - - //////////////////////////////////////////////////////////////// - // External View Functions // - //////////////////////////////////////////////////////////////// - /// @notice Returns the timelock delay for a given Safe /// @param _safe The Safe address to query /// @return The timelock delay in seconds From 24f20a14511887d8ff15dae09fae37028152f1aa Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 14:04:57 -0400 Subject: [PATCH 083/102] Add some more comments where helpful. --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 5a17fe7f3c8ec..1d3b760b6c5f4 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -221,6 +221,8 @@ contract TimelockGuard is IGuard, ISemver { } /// @notice Returns the maximum cancellation threshold for a given safe + /// @dev The cancellation threshold must be capped in order to preserve the ability of honest users to cancel + /// malicious transactions. /// @param _safe The Safe address to query /// @return The maximum cancellation threshold function maxCancellationThreshold(Safe _safe) public view returns (uint256) { @@ -421,7 +423,9 @@ contract TimelockGuard is IGuard, ISemver { // Store the timelock delay for this safe _safeState[callingSafe].timelockDelay = _timelockDelay; - // Initialize or reset the cancellation threshold to 1 + // Initialize or reset the cancellation threshold to 1. + // Note that this is redundant with the cancellation threshold reset which occurs when a transaction is + // executed, but it is kept here for completeness. _resetCancellationThreshold(callingSafe); emit GuardConfigured(callingSafe, _timelockDelay); } @@ -487,7 +491,11 @@ contract TimelockGuard is IGuard, ISemver { ); // Check if the transaction exists - // A transaction can only be scheduled once, regardless of whether it has been cancelled or not. + // 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 (_safeState[_safe].scheduledTransactions[txHash].executionTime != 0) { revert TimelockGuard_TransactionAlreadyScheduled(); } From 5b68fb4dd9924e4b172f34254ac920adbb5647f5 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 14:18:04 -0400 Subject: [PATCH 084/102] Further expand on the maxCancellationThreshold rationale --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 1d3b760b6c5f4..2d2f465ac9df8 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -222,7 +222,14 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Returns the maximum cancellation threshold for a given safe /// @dev The cancellation threshold must be capped in order to preserve the ability of honest users to cancel - /// malicious transactions. + /// malicious transactions. The rationale for the calculation of the maximum cancellation threshold is as + /// follows: + /// If the quorum is lower, then it is used as the maximum cancellation threshold, + /// so that even if an attacker has _joint control_ of a quorum of keys, the honest users can still + /// indefinitely cancel a malicious transaction. + /// If the blocking threshold is lower, then it is used as the maximum cancellation threshold, so that if an + /// attacker has less than a quorum of keys, honest users can still remove an attacker from the Safe by + /// refusing to respond to a malicious transaction. /// @param _safe The Safe address to query /// @return The maximum cancellation threshold function maxCancellationThreshold(Safe _safe) public view returns (uint256) { From 2daf9a138199197ee5ab2dc11394054ae824be75 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 14:20:54 -0400 Subject: [PATCH 085/102] Clarify blocking threshold --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 2d2f465ac9df8..e69113c4d34eb 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -42,7 +42,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// Threats Mitigated and Integration With LivenessModule: /// This Guard is designed to protect against a number of well-defined scenarios, defined on /// the two axes of amount of keys compromised, and type of compromise. -/// For scenarios where the keys compromised don't amount to a blocking threshold, regular transactions from the +/// For scenarios where the keys compromised don't amount to a blocking threshold (the number of signers who must +/// refuse to sign a transaction in order to block it from being executed), regular transactions from the /// multisig for removal or rotation is the preferred solution. /// For scenarios where the keys compromised are at least a blocking threshold, but not as much as quorum, the /// LivenessModule would be used. If there is a quorum of absent keys, but no significant malicious control, the From f5618ac23cb625feae3a0213e562db45657b6791 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 14:31:59 -0400 Subject: [PATCH 086/102] iFace fixes --- .../contracts-bedrock/interfaces/safe/ITimelockGuard.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 502a1a14a4bd3..298d72a5d627f 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -36,12 +36,12 @@ interface ITimelockGuard { event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); event GuardConfigured(address indexed safe, uint256 timelockDelay); event TransactionCancelled(address indexed safe, bytes32 indexed txHash); - event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executed); + event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executionTime); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function signCancellationForSafe(address _safe, bytes32 _txHash) external; function cancellationThreshold(address _safe) external view returns (uint256); - function pendingTransactions(address) external pure returns (bytes32[] memory); + function checkTransaction( address _to, uint256 _value, @@ -64,7 +64,7 @@ interface ITimelockGuard { ) external view - returns (TimelockGuard.ScheduledTransaction memory); + returns (ScheduledTransaction memory); function safeConfigs(address) external view returns (uint256 timelockDelay); function scheduleTransaction( address _safe, @@ -79,5 +79,5 @@ interface ITimelockGuard { function pendingTransactions(address _safe) external view - returns (TimelockGuard.ScheduledTransaction[] memory); + returns (ScheduledTransaction[] memory); } From 1d709b3f81aee42f642e4f1f3cf21ad27db933e5 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 14:53:05 -0400 Subject: [PATCH 087/102] Fix iface --- .../interfaces/safe/ITimelockGuard.sol | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 298d72a5d627f..a1bce40b6dc1c 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -6,11 +6,16 @@ library Enum { } interface ITimelockGuard { - + enum TransactionState { + NotScheduled, + Pending, + Cancelled, + Executed + } struct ScheduledTransaction { uint256 executionTime; - bool cancelled; - bool executed; + TransactionState state; + ExecTransactionParams params; } struct ExecTransactionParams { @@ -32,6 +37,9 @@ interface ITimelockGuard { error TimelockGuard_TransactionAlreadyCancelled(); error TimelockGuard_TransactionAlreadyScheduled(); error TimelockGuard_TransactionNotScheduled(); + error TimelockGuard_TransactionNotReady(); + error TimelockGuard_TransactionAlreadyExecuted(); + error TimelockGuard_InvalidVersion(); event CancellationThresholdUpdated(address indexed safe, uint256 oldThreshold, uint256 newThreshold); event GuardConfigured(address indexed safe, uint256 timelockDelay); @@ -41,7 +49,6 @@ interface ITimelockGuard { function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function signCancellationForSafe(address _safe, bytes32 _txHash) external; function cancellationThreshold(address _safe) external view returns (uint256); - function checkTransaction( address _to, uint256 _value, From ee0cde70245fc7e0bb1b46f68512b96c3273bd54 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 15:27:05 -0400 Subject: [PATCH 088/102] Move update of tx state, event emission and cancellationThreshold into checkAfterExection --- .../interfaces/safe/ITimelockGuard.sol | 1 + .../src/safe/TimelockGuard.sol | 52 +++++++++---- .../test/safe/TimelockGuard.t.sol | 78 ++++++------------- 3 files changed, 62 insertions(+), 69 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index a1bce40b6dc1c..5f2fcbc621222 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -45,6 +45,7 @@ interface ITimelockGuard { event GuardConfigured(address indexed safe, uint256 timelockDelay); event TransactionCancelled(address indexed safe, bytes32 indexed txHash); event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executionTime); + event TransactionExecuted(address indexed safe, bytes32 txHash); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; function signCancellationForSafe(address _safe, bytes32 _txHash) external; diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index e69113c4d34eb..6032339ec792b 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -49,7 +49,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// LivenessModule would be used. If there is a quorum of absent keys, but no significant malicious control, the /// LivenessModule would also be used. /// The TimelockGuard acts when there is malicious control of a quorum of keys. If the control is temporary, for -/// example by phishing a single set of signatures, then the TimelockGuard's cancellation is enough to stop the attack +/// example by phishing a single set of signatures, then the TimelockGuard's cancellation is enough to stop the +/// attack /// entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to execute /// remediations external to the compromised safe. /// +---------------------------------------------------------------------+ @@ -179,9 +180,8 @@ contract TimelockGuard is IGuard, ISemver { /// @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); + event TransactionExecuted(Safe indexed safe, bytes32 txHash); //////////////////////////////////////////////////////////////// // Internal View Functions // @@ -283,8 +283,10 @@ contract TimelockGuard is IGuard, ISemver { // Guard Interface Functions // //////////////////////////////////////////////////////////////// - /// @notice Called by the Safe before executing a transaction - /// @dev Implementation of IGuard interface + /// @notice Implementation of IGuard 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, @@ -299,6 +301,7 @@ contract TimelockGuard is IGuard, ISemver { address /* msgSender */ ) external + view override { Safe callingSafe = Safe(payable(msg.sender)); @@ -346,23 +349,42 @@ contract TimelockGuard is IGuard, ISemver { if (scheduledTx.executionTime > block.timestamp) { revert TimelockGuard_TransactionNotReady(); } + } + + /// @notice Implementation of IGuard interface. Called by the Safe after executing a transaction + /// @dev This function is used to update the state of the contract after the transaction has been executed. + /// Although making state changes here is a violation of the Checks-Effects-Interactions pattern, it + /// safe to do in this case because we trust that the Safe does not enable arbitrary calls without + /// proper authorization checks. + function checkAfterExecution(bytes32 _txHash, bool _success) external override { + Safe callingSafe = Safe(payable(msg.sender)); + // If the timelock delay is zero, 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, and so + // we do not expect the transaction to have been scheduled. + if (_safeState[callingSafe].timelockDelay == 0) { + return; + } + + // If the transaction failed, then we return early and leave the transaction in its current state, + // which allows the transaction to be retried. + // This is consistent with the Safe's own behaviour, which does not increment the nonce if the + // call fails. + if (!_success) { + return; + } + + ScheduledTransaction storage scheduledTx = _safeState[callingSafe].scheduledTransactions[_txHash]; // Set the transaction as executed scheduledTx.state = TransactionState.Executed; - _safeState[callingSafe].pendingTxHashes.remove(txHash); + _safeState[callingSafe].pendingTxHashes.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. + emit TransactionExecuted(callingSafe, _txHash); } //////////////////////////////////////////////////////////////// diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 60a47d9b35aa2..d69f8512f9813 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -149,7 +149,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { 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 TransactionExecuted(Safe indexed safe, bytes32 txHash); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -293,8 +293,8 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { /// @notice Checks configuration reverts when the contract is too old. function test_configureTimelockGuard_revertsIfVersionTooOld_reverts() external { - vm.mockCall(address(timelockGuard), abi.encodeWithSignature("VERSION()"), abi.encode("1.2.0")); - vm.expectRevert(TimelockGuard.TimelockGuard_InvalidVersion.selector); + vm.mockCall(address(safeInstance.safe), abi.encodeWithSignature("VERSION()"), abi.encode("1.2.0")); + vm.expectRevert(TimelockGuard.TimelockGuard_InvalidVersion.selector, address(timelockGuard)); vm.prank(address(safeInstance.safe)); timelockGuard.configureTimelockGuard(TIMELOCK_DELAY); } @@ -615,62 +615,12 @@ contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { /// @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); } - /// @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("cancellationThreshold(address)").with_key( - address(safeInstance.safe) - ).find(); - vm.store( - address(timelockGuard), - bytes32(slot), - bytes32(uint256(timelockGuard.cancellationThreshold(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.scheduledTransaction(safeInstance.safe, dummyTx.hash); - assert(scheduledTransaction.state == TimelockGuard.TransactionState.Executed); - - // Confirm that the cancellation threshold is reset - assertEq(timelockGuard.cancellationThreshold(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); @@ -811,6 +761,7 @@ contract TimelockGuard_MaxCancellationThreshold_Test is TimelockGuard_TestInit { /// @title TimelockGuard_Integration_Test /// @notice Tests for integration between TimelockGuard and Safe contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { + using stdStorage for StdStorage; function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); @@ -822,9 +773,28 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { dummyTx.scheduleTransaction(timelockGuard); vm.warp(block.timestamp + TIMELOCK_DELAY); + + // increment the cancellation threshold so that we can test that it is reset + uint256 slot = stdstore.target(address(timelockGuard)).sig("cancellationThreshold(address)").with_key( + address(safeInstance.safe) + ).find(); + vm.store( + address(timelockGuard), + bytes32(slot), + bytes32(uint256(timelockGuard.cancellationThreshold(safeInstance.safe) + 1)) + ); + + vm.expectEmit(true, true, true, true); + emit TransactionExecuted(safeInstance.safe, dummyTx.hash); dummyTx.executeTransaction(); - assert(timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash).state == TimelockGuard.TransactionState.Executed); + // Confirm that the transaction is executed + TimelockGuard.ScheduledTransaction memory scheduledTransaction = + timelockGuard.scheduledTransaction(safeInstance.safe, dummyTx.hash); + assert(scheduledTransaction.state == TimelockGuard.TransactionState.Executed); + + // Confirm that the cancellation threshold is reset + assertEq(timelockGuard.cancellationThreshold(safeInstance.safe), 1); } /// @notice Test that scheduling a transaction and then executing it twice reverts From fa7c6d6b0717323daebd53c26c970cb4dfc9790f Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 16:16:37 -0400 Subject: [PATCH 089/102] Simplified comment --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 6032339ec792b..79ba8976b545e 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -107,14 +107,9 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Aggregated state for each Safe using this guard. /// @dev We have chosen for operational reasons to keep a list of pending transactions that can be easily retrieved - /// via a function call. There are several ways to accomplish this but we chose to maintain a separate EnumerableSet - /// with the txHashes of the pending transactions, that needs to be maintained in sync with the mapping that keeps - /// all the data about all the transactions, regardless of their state. - /// We chose this implementation because the set of pending transactions is independent of the core flow, and if - /// there would be a bug in it, it would only affect the `pendingTransactions` view function. - /// A notable alternative was to keep only the pending transactions in storage, and remove them as soon as they are - /// cancelled or executed, but that opens the Timelock to some low-risk griefing attacks that we nonetheless prefer - /// to avoid. + /// via a function call. This is done by maintaining a separate EnumerableSet with the hashes of pending + /// transactions. Transactions in the enumerable set need to be updated along with updates to the + /// ScheduledTransactions mapping. struct SafeState { uint256 timelockDelay; uint256 cancellationThreshold; From de6e131b71a5a3ac7b7e48ec4c5840249ee921f2 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 26 Sep 2025 16:27:56 -0400 Subject: [PATCH 090/102] remove unclear comments --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 79ba8976b545e..72811f2a0bf37 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -50,18 +50,8 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// LivenessModule would also be used. /// The TimelockGuard acts when there is malicious control of a quorum of keys. If the control is temporary, for /// example by phishing a single set of signatures, then the TimelockGuard's cancellation is enough to stop the -/// attack -/// entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to execute -/// remediations external to the compromised safe. -/// +---------------------------------------------------------------------+ -/// | | Absent Keys | Malicious Control | -/// +---------------------------------------------------------------------+ -/// | 1+ | Detection and Removal | Detection and Removal | -/// +---------------------------------------------------------------------+ -/// | Blocking Threshold+ | Liveness Module | Liveness Module | -/// +---------------------------------------------------------------------+ -/// | Quorum+ | Liveness Module | Timelock Guard | -/// +---------------------------------------------------------------------+ +/// attack entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to +/// execute remediations external to the compromised safe. contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -419,8 +409,6 @@ contract TimelockGuard is IGuard, ISemver { /// is important to ensure that maliciously gathered signatures will not be able to instantly reconfigure /// the delay to zero. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) - /// @dev We considered several implementations to allow for a pause mechanism of sorts, which could be used if the - /// on-call team needed more time. Ultimately, we rejected all of them in favour of better on-call processes. function configureTimelockGuard(uint256 _timelockDelay) external { // Record the calling Safe Safe callingSafe = Safe(payable(msg.sender)); From 93e0ca944cad5520d969b9c1d3e3ce06c2be2b20 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 30 Sep 2025 17:14:33 -0400 Subject: [PATCH 091/102] fix semgrep sol-style-use-abi-encodecall --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index d69f8512f9813..99ad122f4e18f 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -11,6 +11,10 @@ import { TimelockGuard } from "src/safe/TimelockGuard.sol"; using TransactionBuilder for TransactionBuilder.Transaction; +contract Target { + function doSomething() external {} +} + /// @title TransactionBuilder /// @notice Facilitates the construction of transactions and signatures, and provides helper methods /// for scheduling, executing, and cancelling transactions. @@ -218,7 +222,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { { TransactionBuilder.Transaction memory transaction = _createEmptyTransaction(_safeInstance); transaction.params.to = address(0xabba); - transaction.params.data = abi.encodeWithSignature("doSomething()"); + transaction.params.data = abi.encodeCall(Target.doSomething, ()); transaction.updateTransaction(); return transaction; } @@ -293,6 +297,7 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { /// @notice Checks configuration reverts when the contract is too old. function test_configureTimelockGuard_revertsIfVersionTooOld_reverts() external { + // nosemgrep: sol-style-use-abi-encodecall vm.mockCall(address(safeInstance.safe), abi.encodeWithSignature("VERSION()"), abi.encode("1.2.0")); vm.expectRevert(TimelockGuard.TimelockGuard_InvalidVersion.selector, address(timelockGuard)); vm.prank(address(safeInstance.safe)); From 433c04857529f764c53423180566b2bafb7353d9 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Tue, 30 Sep 2025 17:15:17 -0400 Subject: [PATCH 092/102] snapshots --- .../snapshots/abi/TimelockGuard.json | 335 +++++++++++------- .../snapshots/semver-lock.json | 4 + .../storageLayout/TimelockGuard.json | 18 +- 3 files changed, 212 insertions(+), 145 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json index 838896bda8417..3507d593c8a31 100644 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -1,23 +1,4 @@ [ - { - "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "", - "type": "address" - } - ], - "name": "blockingThresholdForSafe", - "outputs": [ - { - "internalType": "uint256", - "name": "", - "type": "uint256" - } - ], - "stateMutability": "view", - "type": "function" - }, { "inputs": [ { @@ -54,7 +35,7 @@ "type": "address" } ], - "name": "cancellationThresholdForSafe", + "name": "cancellationThreshold", "outputs": [ { "internalType": "uint256", @@ -69,12 +50,12 @@ "inputs": [ { "internalType": "bytes32", - "name": "", + "name": "_txHash", "type": "bytes32" }, { "internalType": "bool", - "name": "", + "name": "_success", "type": "bool" } ], @@ -83,25 +64,6 @@ "stateMutability": "nonpayable", "type": "function" }, - { - "inputs": [ - { - "internalType": "address", - "name": "", - "type": "address" - } - ], - "name": "pendingTransactionsForSafe", - "outputs": [ - { - "internalType": "bytes32[]", - "name": "", - "type": "bytes32[]" - } - ], - "stateMutability": "pure", - "type": "function" - }, { "inputs": [ { @@ -162,14 +124,7 @@ ], "name": "checkTransaction", "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "clearTimelockGuard", - "outputs": [], - "stateMutability": "nonpayable", + "stateMutability": "view", "type": "function" }, { @@ -191,36 +146,14 @@ "internalType": "contract GnosisSafe", "name": "_safe", "type": "address" - }, - { - "internalType": "bytes32", - "name": "_txHash", - "type": "bytes32" } ], - "name": "scheduledTransactionForSafe", + "name": "maxCancellationThreshold", "outputs": [ { - "components": [ - { - "internalType": "uint256", - "name": "executionTime", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "cancelled", - "type": "bool" - }, - { - "internalType": "bool", - "name": "executed", - "type": "bool" - } - ], - "internalType": "struct TimelockGuard.ScheduledTransaction", + "internalType": "uint256", "name": "", - "type": "tuple" + "type": "uint256" } ], "stateMutability": "view", @@ -230,21 +163,80 @@ "inputs": [ { "internalType": "contract GnosisSafe", - "name": "", + "name": "_safe", "type": "address" } ], - "name": "safeConfigs", + "name": "pendingTransactions", "outputs": [ { - "internalType": "uint256", - "name": "timelockDelay", - "type": "uint256" - }, - { - "internalType": "bool", - "name": "configured", - "type": "bool" + "components": [ + { + "internalType": "uint256", + "name": "executionTime", + "type": "uint256" + }, + { + "internalType": "enum TimelockGuard.TransactionState", + "name": "state", + "type": "uint8" + }, + { + "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 TimelockGuard.ExecTransactionParams", + "name": "params", + "type": "tuple" + } + ], + "internalType": "struct TimelockGuard.ScheduledTransaction[]", + "name": "", + "type": "tuple[]" } ], "stateMutability": "view", @@ -310,7 +302,7 @@ "type": "address" } ], - "internalType": "struct ExecTransactionParams", + "internalType": "struct TimelockGuard.ExecTransactionParams", "name": "_params", "type": "tuple" }, @@ -325,43 +317,87 @@ "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" + }, + { + "internalType": "bytes32", + "name": "_txHash", + "type": "bytes32" } ], - "name": "timelockConfigurationForSafe", + "name": "scheduledTransaction", "outputs": [ { "components": [ { "internalType": "uint256", - "name": "timelockDelay", + "name": "executionTime", "type": "uint256" }, { - "internalType": "bool", - "name": "configured", - "type": "bool" + "internalType": "enum TimelockGuard.TransactionState", + "name": "state", + "type": "uint8" + }, + { + "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 TimelockGuard.ExecTransactionParams", + "name": "params", + "type": "tuple" } ], - "internalType": "struct TimelockGuard.GuardConfig", + "internalType": "struct TimelockGuard.ScheduledTransaction", "name": "", "type": "tuple" } @@ -370,29 +406,54 @@ "type": "function" }, { - "anonymous": false, "inputs": [ { - "indexed": true, "internalType": "contract GnosisSafe", - "name": "safe", + "name": "", "type": "address" }, { - "indexed": false, - "internalType": "uint256", - "name": "oldThreshold", - "type": "uint256" - }, + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "signCancellationForSafe", + "outputs": [], + "stateMutability": "pure", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract GnosisSafe", + "name": "_safe", + "type": "address" + } + ], + "name": "timelockConfiguration", + "outputs": [ { - "indexed": false, "internalType": "uint256", - "name": "newThreshold", + "name": "", "type": "uint256" } ], - "name": "CancellationThresholdUpdated", - "type": "event" + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" }, { "anonymous": false, @@ -402,9 +463,21 @@ "internalType": "contract GnosisSafe", "name": "safe", "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "oldThreshold", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "newThreshold", + "type": "uint256" } ], - "name": "GuardCleared", + "name": "CancellationThresholdUpdated", "type": "event" }, { @@ -438,7 +511,7 @@ { "indexed": true, "internalType": "bytes32", - "name": "txId", + "name": "txHash", "type": "bytes32" } ], @@ -454,12 +527,6 @@ "name": "safe", "type": "address" }, - { - "indexed": true, - "internalType": "uint256", - "name": "nonce", - "type": "uint256" - }, { "indexed": false, "internalType": "bytes32", @@ -482,19 +549,24 @@ { "indexed": true, "internalType": "bytes32", - "name": "txId", + "name": "txHash", "type": "bytes32" }, { "indexed": false, "internalType": "uint256", - "name": "when", + "name": "executionTime", "type": "uint256" } ], "name": "TransactionScheduled", "type": "event" }, + { + "inputs": [], + "name": "SemverComp_InvalidSemverParts", + "type": "error" + }, { "inputs": [], "name": "TimelockGuard_GuardNotConfigured", @@ -507,12 +579,12 @@ }, { "inputs": [], - "name": "TimelockGuard_GuardStillEnabled", + "name": "TimelockGuard_InvalidTimelockDelay", "type": "error" }, { "inputs": [], - "name": "TimelockGuard_InvalidTimelockDelay", + "name": "TimelockGuard_InvalidVersion", "type": "error" }, { @@ -520,6 +592,11 @@ "name": "TimelockGuard_TransactionAlreadyCancelled", "type": "error" }, + { + "inputs": [], + "name": "TimelockGuard_TransactionAlreadyExecuted", + "type": "error" + }, { "inputs": [], "name": "TimelockGuard_TransactionAlreadyScheduled", @@ -535,4 +612,4 @@ "name": "TimelockGuard_TransactionNotScheduled", "type": "error" } -] +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 07666946c7696..e4d48654b7c56 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -211,6 +211,10 @@ "initCodeHash": "0x4679b41e5648a955a883efd0271453c8b13ff4846f853d372527ebb1e0905ab5", "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" }, + "src/safe/TimelockGuard.sol:TimelockGuard": { + "initCodeHash": "0x528f24e0b41bc77ee9fbc312f24afa7118b2e5cedd2744d051ed92104429574a", + "sourceCodeHash": "0x5cc610de65df062e13e89197f9d8cf854483cce4540c58ff2e6fd97fba42b74d" + }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", "sourceCodeHash": "0xf5e29dd5c750ea935c7281ec916ba5277f5610a0a9e984e53ae5d5245b3cf2f4" diff --git a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json index 3b67ff1ebd391..97c754bfc8c5a 100644 --- a/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/storageLayout/TimelockGuard.json @@ -1,23 +1,9 @@ [ { "bytes": "32", - "label": "safeConfigs", + "label": "_safeState", "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)" + "type": "mapping(contract GnosisSafe => struct TimelockGuard.SafeState)" } ] \ No newline at end of file From 82eb7561a911154690d8792c0e9f2f671bd31a2c Mon Sep 17 00:00:00 2001 From: Maurelian Date: Wed, 1 Oct 2025 15:17:01 -0400 Subject: [PATCH 093/102] Add course of actions table --- .../contracts-bedrock/src/safe/TimelockGuard.sol | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 72811f2a0bf37..20b06d1580801 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -52,6 +52,21 @@ import { ISemver } from "interfaces/universal/ISemver.sol"; /// example by phishing a single set of signatures, then the TimelockGuard's cancellation is enough to stop the /// attack entirely. If the malicious control would be permanent, then the TimelockGuard will buy some time to /// execute remediations external to the compromised safe. +/// The following table summarizes the various scenarios and the course of action to take in each case. +/// +---------------------------------------------------------------------------+ +/// | Course of action when X Number of keys... | +/// +-------------------------------------------------------------------------------------------------+ +/// | | ... are Absent | ... are Maliciously Controlled | +/// | X Number of keys | (Honest signers cannot sign) | (Malicious signers can sign) | +/// +-------------------------------------------------------------------------------------------------+ +/// | 1+ | swapOwner | swapOwner | +/// +-------------------------------------------------------------------------------------------------+ +/// | Blocking Threshold+ | challenge + | challenge + | +/// | | changeOwnershipToFallback | changeOwnershipToFallback | +/// +-------------------------------------------------------------------------------------------------+ +/// | Quorum+ | challenge + | cancelTransaction | +/// | | changeOwnershipToFallback | | +/// +-------------------------------------------------------------------------------------------------+ contract TimelockGuard is IGuard, ISemver { using EnumerableSet for EnumerableSet.Bytes32Set; From f768b54ce7d87c6fc5325e580e370cd1daa955d4 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 07:43:13 -0400 Subject: [PATCH 094/102] Remove unnecessary address arg from signCancellation --- .../interfaces/safe/ITimelockGuard.sol | 3 ++- .../snapshots/abi/TimelockGuard.json | 22 +++++++++++++------ .../snapshots/semver-lock.json | 4 ++-- .../src/safe/TimelockGuard.sol | 22 +++++++++++++------ .../test/safe/TimelockGuard.t.sol | 20 ++++++++++++----- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol index 5f2fcbc621222..ccf454985a90d 100644 --- a/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol +++ b/packages/contracts-bedrock/interfaces/safe/ITimelockGuard.sol @@ -46,9 +46,10 @@ interface ITimelockGuard { event TransactionCancelled(address indexed safe, bytes32 indexed txHash); event TransactionScheduled(address indexed safe, bytes32 indexed txHash, uint256 executionTime); event TransactionExecuted(address indexed safe, bytes32 txHash); + event Message(string message); function cancelTransaction(address _safe, bytes32 _txHash, uint256 _nonce, bytes memory _signatures) external; - function signCancellationForSafe(address _safe, bytes32 _txHash) external; + function signCancellation(bytes32 _txHash) external; function cancellationThreshold(address _safe) external view returns (uint256); function checkTransaction( address _to, diff --git a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json index 3507d593c8a31..15145753d43b0 100644 --- a/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json +++ b/packages/contracts-bedrock/snapshots/abi/TimelockGuard.json @@ -407,20 +407,15 @@ }, { "inputs": [ - { - "internalType": "contract GnosisSafe", - "name": "", - "type": "address" - }, { "internalType": "bytes32", "name": "", "type": "bytes32" } ], - "name": "signCancellationForSafe", + "name": "signCancellation", "outputs": [], - "stateMutability": "pure", + "stateMutability": "nonpayable", "type": "function" }, { @@ -499,6 +494,19 @@ "name": "GuardConfigured", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "string", + "name": "message", + "type": "string" + } + ], + "name": "Message", + "type": "event" + }, { "anonymous": false, "inputs": [ diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index e4d48654b7c56..cf72ee710b73f 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -212,8 +212,8 @@ "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" }, "src/safe/TimelockGuard.sol:TimelockGuard": { - "initCodeHash": "0x528f24e0b41bc77ee9fbc312f24afa7118b2e5cedd2744d051ed92104429574a", - "sourceCodeHash": "0x5cc610de65df062e13e89197f9d8cf854483cce4540c58ff2e6fd97fba42b74d" + "initCodeHash": "0x1b97340271fcb971499f4d96ae6f1c1b9145aa25248667a571a2ef300e7aa88c", + "sourceCodeHash": "0xe531e22da4e02f1f8f84036fa0b04a6ac3b2857d6ae2e1e595a04e4ec27516fb" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0xc3289416829b252c830ad7d389a430986a7404df4fe0be37cb19e1c40907f047", diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 20b06d1580801..a91a39676895b 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -183,6 +183,10 @@ contract TimelockGuard is IGuard, ISemver { /// @param txHash The identifier of the executed transaction (nonce-independent). event TransactionExecuted(Safe indexed safe, bytes32 txHash); + /// @notice Used to emit a message, primarily to ensure that the cancelTransaction function is + /// is not labelled as view so that it is treated as a state-changing function. + event Message(string message); + //////////////////////////////////////////////////////////////// // Internal View Functions // //////////////////////////////////////////////////////////////// @@ -578,7 +582,13 @@ contract TimelockGuard is IGuard, ISemver { } // Generate the cancellation transaction data - bytes memory txData = abi.encodeCall(this.signCancellationForSafe, (_safe, _txHash)); + 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 ); @@ -606,11 +616,9 @@ contract TimelockGuard is IGuard, ISemver { // Dummy Functions // //////////////////////////////////////////////////////////////// - /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data - /// @dev This function is not meant to be called, use cancelTransaction instead - function signCancellationForSafe(Safe, bytes32) public pure { - // Reverting here may cause issues for some signing tooling, but it is better to revert than for - // to silently fail, potentially allowing the caller to believe that the transaction has been cancelled. - revert("This function is not meant to be called, use cancelTransaction instead"); + /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data in + /// the Safe UI. + function signCancellation(bytes32) public { + emit Message("This function not meant to be called, did you mean to call cancelTransaction?"); } } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 99ad122f4e18f..a03b0b6fdb2d5 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -3,7 +3,6 @@ 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 "test/safe-tools/SafeTestTools.sol"; @@ -12,7 +11,7 @@ import { TimelockGuard } from "src/safe/TimelockGuard.sol"; using TransactionBuilder for TransactionBuilder.Transaction; contract Target { - function doSomething() external {} + function doSomething() external { } } /// @title TransactionBuilder @@ -134,8 +133,7 @@ library TransactionBuilder { // Empty out the params, then set based on the cancellation transaction format delete cancellation.params; cancellation.params.to = address(_timelockGuard); - cancellation.params.data = - abi.encodeCall(TimelockGuard.signCancellationForSafe, (Safe(payable(_tx.safeInstance.safe)), _tx.hash)); + cancellation.params.data = abi.encodeCall(TimelockGuard.signCancellation, (_tx.hash)); // Get only the number of signatures required for the cancellation transaction uint256 cancellationThreshold = _timelockGuard.cancellationThreshold(_tx.safeInstance.safe); @@ -154,6 +152,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { event TransactionCancelled(Safe indexed safe, bytes32 indexed txId); event CancellationThresholdUpdated(Safe indexed safe, uint256 oldThreshold, uint256 newThreshold); event TransactionExecuted(Safe indexed safe, bytes32 txHash); + event Message(string message); uint256 constant INIT_TIME = 10; uint256 constant TIMELOCK_DELAY = 7 days; @@ -539,8 +538,16 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit } } -/// @title TimelockGuard_CancelTransaction_Test -/// @notice Tests for cancelTransaction function +/// @title TimelockGuard_signCancellation_Test +/// @notice Tests for signCancellation function +contract TimelockGuard_signCancellation_Test is TimelockGuard_TestInit { + function test_signCancellation_succeeds() external { + vm.expectEmit(true, true, true, true); + emit Message("This function not meant to be called, did you mean to call cancelTransaction?"); + timelockGuard.signCancellation(bytes32(0)); + } +} + contract TimelockGuard_CancelTransaction_Test is TimelockGuard_TestInit { /// @notice Prepares a configured guard before cancellation tests run. function setUp() public override { @@ -767,6 +774,7 @@ contract TimelockGuard_MaxCancellationThreshold_Test is TimelockGuard_TestInit { /// @notice Tests for integration between TimelockGuard and Safe contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { using stdStorage for StdStorage; + function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); From d9f2f86f8c6341a9b260c38e64c362215ec9c784 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 10:34:03 -0400 Subject: [PATCH 095/102] Fix test names --- .../test/safe/TimelockGuard.t.sol | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index a03b0b6fdb2d5..653b14afa2dfd 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -241,11 +241,11 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { } } -/// @title TimelockGuard_ViewTimelockGuardConfiguration_Test -/// @notice Tests for viewTimelockGuardConfiguration function -contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_TestInit { +/// @title TimelockGuard_TimelockConfiguration_Test +/// @notice Tests for timelockConfiguration function +contract TimelockGuard_TimelockConfiguration_Test is TimelockGuard_TestInit { /// @notice Ensures an unconfigured Safe reports a zero timelock delay. - function test_viewTimelockGuardConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { + function test_timelockConfiguration_returnsZeroForUnconfiguredSafe_succeeds() external view { uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); assertEq(delay, 0); // configured is now determined by timelockDelay == 0 @@ -253,7 +253,7 @@ contract TimelockGuard_ViewTimelockGuardConfiguration_Test is TimelockGuard_Test } /// @notice Validates the configuration view reflects the stored timelock delay. - function test_viewTimelockGuardConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { + function test_timelockConfiguration_returnsConfigurationForConfiguredSafe_succeeds() external { _configureGuard(safeInstance, TIMELOCK_DELAY); uint256 delay = timelockGuard.timelockConfiguration(safeInstance.safe); assertEq(delay, TIMELOCK_DELAY); @@ -367,9 +367,9 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_CancellationThresholdForSafe_Test -/// @notice Tests for cancellationThresholdForSafe function -contract TimelockGuard_CancellationThresholdForSafe_Test is TimelockGuard_TestInit { +/// @title TimelockGuard_CancellationThreshold_Test +/// @notice Tests for cancellationThreshold function +contract TimelockGuard_CancellationThreshold_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.cancellationThreshold(Safe(payable(unguardedSafe.safe))); @@ -466,16 +466,16 @@ contract TimelockGuard_ScheduleTransaction_Test is TimelockGuard_TestInit { } } -/// @title TimelockGuard_ScheduledTransactionForSafe_Test -/// @notice Tests for scheduledTransactionForSafe function -contract TimelockGuard_ScheduledTransactionForSafe_Test is TimelockGuard_TestInit { +/// @title TimelockGuard_ScheduledTransaction_Test +/// @notice Tests for scheduledTransaction function +contract TimelockGuard_ScheduledTransaction_Test is TimelockGuard_TestInit { /// @notice Configures the guard before each scheduleTransaction test. function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); } - function test_scheduledTransactionForSafe_succeeds() external { + function test_scheduledTransaction_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); @@ -488,15 +488,15 @@ contract TimelockGuard_ScheduledTransactionForSafe_Test is TimelockGuard_TestIni } } -/// @title TimelockGuard_PendingTransactionsForSafe_Test -/// @notice Tests for pendingTransactionsForSafe function -contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit { +/// @title TimelockGuard_PendingTransactions_Test +/// @notice Tests for pendingTransactions function +contract TimelockGuard_PendingTransactions_Test is TimelockGuard_TestInit { function setUp() public override { super.setUp(); _configureGuard(safeInstance, TIMELOCK_DELAY); } - function test_pendingTransactionsForSafe_succeeds() external { + function test_pendingTransactions_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); @@ -508,7 +508,7 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit assertEq(keccak256(abi.encode(pendingTransactions[0].params)), keccak256(abi.encode(dummyTx.params))); } - function test_pendingTransactionsForSafe_removeTransactionAfterCancellation_succeeds() external { + function test_pendingTransactions_removeTransactionAfterCancellation_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); @@ -522,7 +522,7 @@ contract TimelockGuard_PendingTransactionsForSafe_Test is TimelockGuard_TestInit assertEq(pendingTransactions.length, 0); } - function test_pendingTransactionsForSafe_removeTransactionAfterExecution_succeeds() external { + function test_pendingTransactions_removeTransactionAfterExecution_succeeds() external { // schedule a transaction TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); From db0f5af94f51ed4ec8e022f8d21d55bdcc6152d1 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 10:52:54 -0400 Subject: [PATCH 096/102] Fix test name validation --- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 653b14afa2dfd..2b79bf22267b1 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -10,10 +10,6 @@ import { TimelockGuard } from "src/safe/TimelockGuard.sol"; using TransactionBuilder for TransactionBuilder.Transaction; -contract Target { - function doSomething() external { } -} - /// @title TransactionBuilder /// @notice Facilitates the construction of transactions and signatures, and provides helper methods /// for scheduling, executing, and cancelling transactions. @@ -221,7 +217,7 @@ contract TimelockGuard_TestInit is Test, SafeTestTools { { TransactionBuilder.Transaction memory transaction = _createEmptyTransaction(_safeInstance); transaction.params.to = address(0xabba); - transaction.params.data = abi.encodeCall(Target.doSomething, ()); + transaction.params.data = hex"acdc"; transaction.updateTransaction(); return transaction; } @@ -835,7 +831,7 @@ contract TimelockGuard_Integration_Test is TimelockGuard_TestInit { } /// @notice Test that rescheduling an identical previously cancelled transaction reverts - function test_integration_scheduleTransaction_identicalPreviouslyCancelled_reverts() external { + function test_integration_scheduleTransactionIdenticalToPreviouslyCancelled_reverts() external { TransactionBuilder.Transaction memory dummyTx = _createDummyTransaction(safeInstance); dummyTx.scheduleTransaction(timelockGuard); From a19dbc04e1688196ba17841d1ac65e06d1a4b304 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 13:22:42 -0400 Subject: [PATCH 097/102] Remove enabled guard check from configureTimelockGuard --- .../src/safe/TimelockGuard.sol | 17 ++++------------- .../test/safe/TimelockGuard.t.sol | 7 ------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index a91a39676895b..2c520e79ae818 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -423,10 +423,11 @@ contract TimelockGuard is IGuard, ISemver { //////////////////////////////////////////////////////////////// /// @notice Configure the contract as a timelock guard by setting the timelock delay - /// @dev This function is only callable by the Safe itself, and will revert if the guard is not enabled on the Safe. + /// @dev This function is only callable by the Safe itself. /// Requiring a call from the Safe itself (rather than accepting signatures directly as in cancelTransaction()) /// is important to ensure that maliciously gathered signatures will not be able to instantly reconfigure - /// the delay to zero. + /// the delay to zero. This function does not check that the guard is enabled on the Safe, the recommended + /// approach is to atomically enable the guard and configure the delay in a single batched transaction. /// @param _timelockDelay The timelock delay in seconds (0 to clear configuration) function configureTimelockGuard(uint256 _timelockDelay) external { // Record the calling Safe @@ -439,14 +440,6 @@ contract TimelockGuard is IGuard, ISemver { revert TimelockGuard_InvalidVersion(); } - // Check that this guard is enabled on the calling Safe - // There is nothing inherently wrong with configuring the guard on a safe that it is not enabled on, - // but we choose to revert here to avoid users from mistakenly believing that simply configuring the guard - // is enough to activate the delay. - if (!_isGuardEnabled(callingSafe)) { - revert TimelockGuard_GuardNotEnabled(); - } - // Validate timelock delay - must not be longer than 1 year if (_timelockDelay > 365 days) { revert TimelockGuard_InvalidTimelockDelay(); @@ -455,9 +448,7 @@ contract TimelockGuard is IGuard, ISemver { // Store the timelock delay for this safe _safeState[callingSafe].timelockDelay = _timelockDelay; - // Initialize or reset the cancellation threshold to 1. - // Note that this is redundant with the cancellation threshold reset which occurs when a transaction is - // executed, but it is kept here for completeness. + // Initialize (or reset) the cancellation threshold to 1. _resetCancellationThreshold(callingSafe); emit GuardConfigured(callingSafe, _timelockDelay); } diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 2b79bf22267b1..98feb979c1c27 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -274,13 +274,6 @@ contract TimelockGuard_ConfigureTimelockGuard_Test is TimelockGuard_TestInit { assertEq(delay != 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); - } - /// @notice Confirms delays above the maximum revert during configuration. function test_configureTimelockGuard_revertsIfDelayTooLong_reverts() external { uint256 tooLongDelay = ONE_YEAR + 1; From 06ae5e222514c2d021472a545424cb8bc7bf47b1 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 13:43:01 -0400 Subject: [PATCH 098/102] Allow _Integration_Test in tests --- .../scripts/checks/test-validation/exclusions.toml | 1 + .../scripts/checks/test-validation/main.go | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml index 8adb91a6091be..9c99014cae0a0 100644 --- a/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml +++ b/packages/contracts-bedrock/scripts/checks/test-validation/exclusions.toml @@ -91,4 +91,5 @@ contracts = [ "OptimismPortal2_MigrateLiquidity_Test", # Interop tests hosted in the OptimismPortal2 test file "OptimismPortal2_MigrateToSuperRoots_Test", # Interop tests hosted in the OptimismPortal2 test file "OptimismPortal2_UpgradeInterop_Test", # Interop tests hosted in the OptimismPortal2 test file + "TransactionBuilder" # Transaction builder helper library in TimelockGuard test file ] diff --git a/packages/contracts-bedrock/scripts/checks/test-validation/main.go b/packages/contracts-bedrock/scripts/checks/test-validation/main.go index d0bda5f33618b..bb8f013852138 100644 --- a/packages/contracts-bedrock/scripts/checks/test-validation/main.go +++ b/packages/contracts-bedrock/scripts/checks/test-validation/main.go @@ -192,9 +192,12 @@ func checkTestStructure(artifact *solc.ForgeArtifact) []error { func checkTestMethodName(artifact *solc.ForgeArtifact, contractName string, functionName string, _ string) []error { // Check for uncategorized test pattern - if functionName == "Uncategorized" { - // Pattern: _Uncategorized_Test - return nil + allowedFunctionNames := []string{"Uncategorized", "Integration"} + for _, allowed := range allowedFunctionNames { + if functionName == allowed { + // Pattern: _Uncategorized_Test or _Integration_Test + return nil + } } // Pattern: __Test - validate function exists if !checkFunctionExists(artifact, functionName) { From 05362ab0b7eaa14e276be74229edeadb1ca22f1a Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 13:43:36 -0400 Subject: [PATCH 099/102] Add isExcludedTest in checkContractNameFilePath() --- .../contracts-bedrock/scripts/checks/test-validation/main.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/contracts-bedrock/scripts/checks/test-validation/main.go b/packages/contracts-bedrock/scripts/checks/test-validation/main.go index bb8f013852138..29bc7d558ed56 100644 --- a/packages/contracts-bedrock/scripts/checks/test-validation/main.go +++ b/packages/contracts-bedrock/scripts/checks/test-validation/main.go @@ -251,6 +251,11 @@ func checkSrcPath(artifact *solc.ForgeArtifact) bool { // Validates that contract name matches the file path func checkContractNameFilePath(artifact *solc.ForgeArtifact) bool { for filePath, contractName := range artifact.Metadata.Settings.CompilationTarget { + + if isExcludedTest(contractName) { + continue + } + // Split contract name to get the base contract name (before first underscore) contractParts := strings.Split(contractName, "_") // Split file path to get individual path components From 7972739f4cb686c3f04ae04c65a28ee400601ee9 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Thu, 2 Oct 2025 16:34:26 -0400 Subject: [PATCH 100/102] Update semver-lock --- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index ec9591cc9201a..0c88a0dfc0de6 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -212,8 +212,8 @@ "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" }, "src/safe/TimelockGuard.sol:TimelockGuard": { - "initCodeHash": "0x1b97340271fcb971499f4d96ae6f1c1b9145aa25248667a571a2ef300e7aa88c", - "sourceCodeHash": "0xe531e22da4e02f1f8f84036fa0b04a6ac3b2857d6ae2e1e595a04e4ec27516fb" + "initCodeHash": "0xddebf627921fef026da31af50da90d148c9f7cf4411bc4a5df1bb630429d59e1", + "sourceCodeHash": "0xbcc30891f3662d85241153d3e3dd052e636263a495defac27653a7536e38b478" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", From 40f24d576605a049bd5594680ee2a0eda7972c71 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 3 Oct 2025 10:59:51 -0400 Subject: [PATCH 101/102] Fix typo --- packages/contracts-bedrock/src/safe/TimelockGuard.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts-bedrock/src/safe/TimelockGuard.sol b/packages/contracts-bedrock/src/safe/TimelockGuard.sol index 2c520e79ae818..26dce92ce472e 100644 --- a/packages/contracts-bedrock/src/safe/TimelockGuard.sol +++ b/packages/contracts-bedrock/src/safe/TimelockGuard.sol @@ -610,6 +610,6 @@ contract TimelockGuard is IGuard, ISemver { /// @notice Dummy function provided as a utility to facilitate signing cancelTransaction data in /// the Safe UI. function signCancellation(bytes32) public { - emit Message("This function not meant to be called, did you mean to call cancelTransaction?"); + emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); } } From 89970946fe1215baccfeef1c867bc3646e693032 Mon Sep 17 00:00:00 2001 From: Maurelian Date: Fri, 3 Oct 2025 13:46:15 -0400 Subject: [PATCH 102/102] fix typo in tests --- packages/contracts-bedrock/snapshots/semver-lock.json | 4 ++-- packages/contracts-bedrock/test/safe/TimelockGuard.t.sol | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/contracts-bedrock/snapshots/semver-lock.json b/packages/contracts-bedrock/snapshots/semver-lock.json index 342fdfc661570..4c4a77f3508f4 100644 --- a/packages/contracts-bedrock/snapshots/semver-lock.json +++ b/packages/contracts-bedrock/snapshots/semver-lock.json @@ -212,8 +212,8 @@ "sourceCodeHash": "0xd3084fb5446782cb6d0adb4278ef0a12c418dd538b4b14b90407b971b44cc35b" }, "src/safe/TimelockGuard.sol:TimelockGuard": { - "initCodeHash": "0xddebf627921fef026da31af50da90d148c9f7cf4411bc4a5df1bb630429d59e1", - "sourceCodeHash": "0xbcc30891f3662d85241153d3e3dd052e636263a495defac27653a7536e38b478" + "initCodeHash": "0x1f8188872de93ce59e8f0bd415d4fbf30209bc668c09623f61d6fe592eee895a", + "sourceCodeHash": "0x0dada93f051d29dabbb6de3e1c1ece14b95cd20dc854454926d19ea1ebcae436" }, "src/universal/OptimismMintableERC20.sol:OptimismMintableERC20": { "initCodeHash": "0x3c85eed0d017dca8eda6396aa842ddc12492587b061e8c756a8d32c4610a9658", diff --git a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol index 98feb979c1c27..88e12657997d4 100644 --- a/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol +++ b/packages/contracts-bedrock/test/safe/TimelockGuard.t.sol @@ -532,7 +532,7 @@ contract TimelockGuard_PendingTransactions_Test is TimelockGuard_TestInit { contract TimelockGuard_signCancellation_Test is TimelockGuard_TestInit { function test_signCancellation_succeeds() external { vm.expectEmit(true, true, true, true); - emit Message("This function not meant to be called, did you mean to call cancelTransaction?"); + emit Message("This function is not meant to be called, did you mean to call cancelTransaction?"); timelockGuard.signCancellation(bytes32(0)); } }